github-issue-tower-defence-management 1.104.4 → 1.105.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 (74) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +8 -7
  3. package/bin/adapter/entry-points/cli/index.js +21 -13
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/{consoleServer.js → webServer.js} +11 -11
  6. package/bin/adapter/entry-points/console/webServer.js.map +1 -0
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +41 -2
  8. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/dashboardRowWriter.js +34 -0
  10. package/bin/adapter/entry-points/handlers/dashboardRowWriter.js.map +1 -0
  11. package/bin/adapter/entry-points/handlers/machineStatusWriter.js +60 -0
  12. package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -0
  13. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +98 -0
  14. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -0
  15. package/bin/adapter/proxy/RateLimitCache.js +3 -0
  16. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  17. package/bin/adapter/repositories/ProcHostMetricsRepository.js +136 -0
  18. package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -0
  19. package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js +131 -0
  20. package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js.map +1 -0
  21. package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js +3 -0
  22. package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js.map +1 -0
  23. package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js +38 -0
  24. package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js.map +1 -0
  25. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +115 -0
  26. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -0
  27. package/package.json +1 -1
  28. package/src/adapter/entry-points/cli/index.test.ts +74 -21
  29. package/src/adapter/entry-points/cli/index.ts +146 -136
  30. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -2
  31. package/src/adapter/entry-points/console/{consoleServer.test.ts → webServer.test.ts} +32 -32
  32. package/src/adapter/entry-points/console/{consoleServer.ts → webServer.ts} +15 -17
  33. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +52 -0
  34. package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +111 -0
  35. package/src/adapter/entry-points/handlers/dashboardRowWriter.ts +51 -0
  36. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +100 -0
  37. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +79 -0
  38. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +176 -0
  39. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +139 -0
  40. package/src/adapter/proxy/RateLimitCache.test.ts +32 -0
  41. package/src/adapter/proxy/RateLimitCache.ts +6 -0
  42. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +135 -0
  43. package/src/adapter/repositories/ProcHostMetricsRepository.ts +136 -0
  44. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +130 -0
  45. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +118 -0
  46. package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
  47. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +159 -0
  48. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.ts +72 -0
  49. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +209 -0
  50. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +195 -0
  51. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  52. package/types/adapter/entry-points/console/{consoleServer.d.ts → webServer.d.ts} +7 -7
  53. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -0
  54. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  55. package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts +15 -0
  56. package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts.map +1 -0
  57. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +16 -0
  58. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -0
  59. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +21 -0
  60. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -0
  61. package/types/adapter/proxy/RateLimitCache.d.ts +2 -0
  62. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  63. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +23 -0
  64. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -0
  65. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +11 -0
  66. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -0
  67. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +8 -0
  68. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -0
  69. package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts +19 -0
  70. package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts.map +1 -0
  71. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +53 -0
  72. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -0
  73. package/bin/adapter/entry-points/console/consoleServer.js.map +0 -1
  74. package/types/adapter/entry-points/console/consoleServer.d.ts.map +0 -1
@@ -0,0 +1,100 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { ProcHostMetricsRepository } from '../../repositories/ProcHostMetricsRepository';
5
+ import { MachineStatusFile, writeMachineStatus } from './machineStatusWriter';
6
+
7
+ const readJson = (filePath: string): unknown =>
8
+ JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
9
+
10
+ const buildHostMetricsRepository = (
11
+ procDirectory: string,
12
+ ): {
13
+ repository: ProcHostMetricsRepository;
14
+ } => {
15
+ fs.writeFileSync(
16
+ path.join(procDirectory, 'meminfo'),
17
+ 'MemTotal: 1000 kB\nMemAvailable: 450 kB\n',
18
+ );
19
+ fs.writeFileSync(
20
+ path.join(procDirectory, 'loadavg'),
21
+ '1.50 0.80 0.40 1/100 200\n',
22
+ );
23
+ const statPath = path.join(procDirectory, 'stat');
24
+ fs.writeFileSync(statPath, 'cpu 600 0 0 400 0 0 0 0 0 0\n');
25
+ const sleep = async (): Promise<void> => {
26
+ fs.writeFileSync(statPath, 'cpu 660 0 0 440 0 0 0 0 0 0\n');
27
+ };
28
+ return { repository: new ProcHostMetricsRepository(procDirectory, sleep) };
29
+ };
30
+
31
+ describe('writeMachineStatus', () => {
32
+ let dir: string;
33
+ let procDirectory: string;
34
+
35
+ beforeEach(() => {
36
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-status-'));
37
+ procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-proc-'));
38
+ });
39
+
40
+ afterEach(() => {
41
+ fs.rmSync(dir, { recursive: true, force: true });
42
+ fs.rmSync(procDirectory, { recursive: true, force: true });
43
+ });
44
+
45
+ it('writes machine-status.json with mem, cpu, load and cycle minutes', async () => {
46
+ const { repository } = buildHostMetricsRepository(procDirectory);
47
+ const cacheDir = path.join(dir, 'allIssues-PVT');
48
+ fs.mkdirSync(cacheDir, { recursive: true });
49
+ const newer = path.join(cacheDir, 'a.json');
50
+ const older = path.join(cacheDir, 'b.json');
51
+ fs.writeFileSync(older, '[]');
52
+ fs.writeFileSync(newer, '[]');
53
+ const base = Date.now();
54
+ fs.utimesSync(older, new Date(base - 900000), new Date(base - 900000));
55
+ fs.utimesSync(newer, new Date(base), new Date(base));
56
+
57
+ await writeMachineStatus({
58
+ dashboardDataDir: dir,
59
+ allIssuesCacheDir: cacheDir,
60
+ hostMetricsRepository: repository,
61
+ now: new Date('2026-06-26T12:00:00.000Z'),
62
+ });
63
+
64
+ const written = readJson(path.join(dir, 'machine-status.json'));
65
+ const expected: MachineStatusFile = {
66
+ memPct: 55,
67
+ cpuPct: 60,
68
+ load: [1.5, 0.8, 0.4],
69
+ cycleMinutes: 15,
70
+ capturedAt: '2026-06-26T12:00:00.000Z',
71
+ };
72
+ expect(written).toEqual(expected);
73
+ });
74
+
75
+ it('writes cycleMinutes null when fewer than two cache files exist', async () => {
76
+ const { repository } = buildHostMetricsRepository(procDirectory);
77
+ const cacheDir = path.join(dir, 'allIssues-PVT');
78
+ fs.mkdirSync(cacheDir, { recursive: true });
79
+ fs.writeFileSync(path.join(cacheDir, 'only.json'), '[]');
80
+
81
+ await writeMachineStatus({
82
+ dashboardDataDir: dir,
83
+ allIssuesCacheDir: cacheDir,
84
+ hostMetricsRepository: repository,
85
+ });
86
+
87
+ const written = readJson(path.join(dir, 'machine-status.json'));
88
+ expect(written).toMatchObject({ cycleMinutes: null });
89
+ });
90
+
91
+ it('is a no-op when dashboardDataDir is unset', async () => {
92
+ const { repository } = buildHostMetricsRepository(procDirectory);
93
+ await writeMachineStatus({
94
+ dashboardDataDir: null,
95
+ allIssuesCacheDir: null,
96
+ hostMetricsRepository: repository,
97
+ });
98
+ expect(fs.readdirSync(dir)).toEqual([]);
99
+ });
100
+ });
@@ -0,0 +1,79 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import {
4
+ ProcHostMetricsRepository,
5
+ cycleMinutesFromMtimes,
6
+ } from '../../repositories/ProcHostMetricsRepository';
7
+
8
+ export type MachineStatusWriterParams = {
9
+ dashboardDataDir: string | null | undefined;
10
+ allIssuesCacheDir: string | null | undefined;
11
+ hostMetricsRepository?: ProcHostMetricsRepository;
12
+ now?: Date;
13
+ };
14
+
15
+ export type MachineStatusFile = {
16
+ memPct: number;
17
+ cpuPct: number;
18
+ load: [number, number, number];
19
+ cycleMinutes: number | null;
20
+ capturedAt: string;
21
+ };
22
+
23
+ const writeJsonAtomic = (filePath: string, data: unknown): void => {
24
+ const dir = path.dirname(filePath);
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ const tmpPath = `${filePath}.tmp`;
27
+ fs.writeFileSync(tmpPath, JSON.stringify(data));
28
+ fs.renameSync(tmpPath, filePath);
29
+ };
30
+
31
+ const cacheFileMtimesDescending = (allIssuesCacheDir: string): number[] => {
32
+ let entries: string[];
33
+ try {
34
+ entries = fs.readdirSync(allIssuesCacheDir);
35
+ } catch {
36
+ return [];
37
+ }
38
+ return entries
39
+ .filter((entry) => entry.endsWith('.json'))
40
+ .map((entry) => {
41
+ try {
42
+ return fs.statSync(path.join(allIssuesCacheDir, entry)).mtimeMs / 1000;
43
+ } catch {
44
+ return null;
45
+ }
46
+ })
47
+ .filter((value): value is number => value !== null)
48
+ .sort((a, b) => b - a);
49
+ };
50
+
51
+ export const writeMachineStatus = async (
52
+ params: MachineStatusWriterParams,
53
+ ): Promise<void> => {
54
+ const { dashboardDataDir, allIssuesCacheDir } = params;
55
+ if (!dashboardDataDir) {
56
+ return;
57
+ }
58
+
59
+ const hostMetricsRepository =
60
+ params.hostMetricsRepository ?? new ProcHostMetricsRepository();
61
+
62
+ const memPct = hostMetricsRepository.readMemoryUsedPercent();
63
+ const cpuPct = await hostMetricsRepository.readCpuUsedPercent();
64
+ const load = hostMetricsRepository.readLoadAverages();
65
+
66
+ const cycleMinutes = allIssuesCacheDir
67
+ ? cycleMinutesFromMtimes(cacheFileMtimesDescending(allIssuesCacheDir))
68
+ : null;
69
+
70
+ const file: MachineStatusFile = {
71
+ memPct,
72
+ cpuPct,
73
+ load: [load.oneMinute, load.fiveMinute, load.fifteenMinute],
74
+ cycleMinutes,
75
+ capturedAt: (params.now ?? new Date()).toISOString(),
76
+ };
77
+
78
+ writeJsonAtomic(path.join(dashboardDataDir, 'machine-status.json'), file);
79
+ };
@@ -0,0 +1,176 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { Issue } from '../../../domain/entities/Issue';
5
+ import { ClaudeInteractiveSession } from '../../../domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository';
6
+ import { TakeOwnershipSpawn } from '../../../domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository';
7
+ import { RateLimitSnapshot } from '../../proxy/RateLimitCache';
8
+ import { TokenStatusFile, writeTokenStatus } from './tokenStatusWriter';
9
+
10
+ const readJson = (filePath: string): unknown =>
11
+ JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
12
+
13
+ const ASSIGNEE = 'HiromiShikata';
14
+
15
+ const baseSnapshot = (
16
+ overrides: Partial<RateLimitSnapshot> = {},
17
+ ): RateLimitSnapshot => ({
18
+ fiveHourUtilization: 0.1,
19
+ fiveHourReset: 0,
20
+ sevenDayUtilization: 0.1,
21
+ sevenDayReset: 0,
22
+ blocked: false,
23
+ rejected: false,
24
+ unifiedRejected: false,
25
+ fiveHourRejected: false,
26
+ sevenDayRejected: false,
27
+ unifiedStatus: 'allowed',
28
+ overageDisabledReason: null,
29
+ modelWeeklyLimits: {},
30
+ lastUpdatedEpoch: 0,
31
+ blockedUntilEpoch: 0,
32
+ ...overrides,
33
+ });
34
+
35
+ const makeIssue = (overrides: Partial<Issue>): Issue => ({
36
+ nameWithOwner: 'demo/repo',
37
+ number: 1,
38
+ title: 'Issue',
39
+ state: 'OPEN',
40
+ status: null,
41
+ story: null,
42
+ nextActionDate: null,
43
+ nextActionHour: null,
44
+ estimationMinutes: null,
45
+ dependedIssueUrls: [],
46
+ completionDate50PercentConfidence: null,
47
+ url: 'https://github.com/demo/repo/issues/1',
48
+ assignees: [ASSIGNEE],
49
+ labels: [],
50
+ org: 'demo',
51
+ repo: 'repo',
52
+ body: '',
53
+ itemId: 'item-1',
54
+ isPr: false,
55
+ isInProgress: false,
56
+ isClosed: false,
57
+ createdAt: new Date('2026-06-13T08:18:45.000Z'),
58
+ author: 'someone',
59
+ closingIssueReferenceUrls: [],
60
+ ...overrides,
61
+ });
62
+
63
+ describe('writeTokenStatus', () => {
64
+ let dir: string;
65
+ let tokenListPath: string;
66
+ const now = new Date('2026-06-26T12:00:00.000Z');
67
+ const nowEpoch = Math.floor(now.getTime() / 1000);
68
+
69
+ beforeEach(() => {
70
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'token-status-'));
71
+ tokenListPath = path.join(dir, 'tokens.json');
72
+ fs.writeFileSync(
73
+ tokenListPath,
74
+ JSON.stringify([
75
+ { name: 'alice', token: 'token-a' },
76
+ { name: 'bob', token: 'token-b' },
77
+ ]),
78
+ );
79
+ });
80
+
81
+ afterEach(() => {
82
+ fs.rmSync(dir, { recursive: true, force: true });
83
+ });
84
+
85
+ it('writes token-status.json with per-token color, util, prep and hum', () => {
86
+ const interactiveSessions: ClaudeInteractiveSession[] = [
87
+ {
88
+ token: 'token-a',
89
+ sessionId: 'session-1',
90
+ issueUrl: 'https://github.com/demo/repo/issues/1',
91
+ },
92
+ ];
93
+ const spawns: TakeOwnershipSpawn[] = [
94
+ { token: 'token-a', logPath: '/logs-aw/x.log' },
95
+ { token: 'token-a', logPath: '/logs-aw/x.log' },
96
+ { token: 'token-a', logPath: '/logs-aw/y.log' },
97
+ ];
98
+
99
+ writeTokenStatus({
100
+ dashboardDataDir: dir,
101
+ tokenListJsonPath: tokenListPath,
102
+ issues: [
103
+ makeIssue({
104
+ status: 'In Tmux by human',
105
+ url: 'https://github.com/demo/repo/issues/1',
106
+ }),
107
+ ],
108
+ now,
109
+ readSnapshot: (token) =>
110
+ token === 'token-a'
111
+ ? baseSnapshot({
112
+ fiveHourUtilization: 0.42,
113
+ fiveHourReset: nowEpoch + 3600,
114
+ sevenDayUtilization: 0.5,
115
+ sevenDayReset: nowEpoch + 86400,
116
+ })
117
+ : null,
118
+ interactiveSessionRepository: {
119
+ listInteractiveSessions: () => interactiveSessions,
120
+ },
121
+ spawnRepository: { listSpawns: () => spawns },
122
+ });
123
+
124
+ const written = readJson(path.join(dir, 'token-status.json'));
125
+ const expected: TokenStatusFile = {
126
+ capturedAt: '2026-06-26T12:00:00.000Z',
127
+ tokens: [
128
+ {
129
+ name: 'alice',
130
+ fiveHourUtilizationPercent: 42,
131
+ fiveHourResetSeconds: 3600,
132
+ sevenDayUtilizationPercent: 50,
133
+ sevenDayResetSeconds: 86400,
134
+ color: 'G',
135
+ prep: 2,
136
+ hum: 1,
137
+ },
138
+ {
139
+ name: 'bob',
140
+ fiveHourUtilizationPercent: null,
141
+ fiveHourResetSeconds: null,
142
+ sevenDayUtilizationPercent: null,
143
+ sevenDayResetSeconds: null,
144
+ color: 'Y',
145
+ prep: 0,
146
+ hum: 0,
147
+ },
148
+ ],
149
+ };
150
+ expect(written).toEqual(expected);
151
+ });
152
+
153
+ it('is a no-op when dashboardDataDir is unset', () => {
154
+ writeTokenStatus({
155
+ dashboardDataDir: null,
156
+ tokenListJsonPath: tokenListPath,
157
+ issues: [],
158
+ readSnapshot: () => null,
159
+ interactiveSessionRepository: { listInteractiveSessions: () => [] },
160
+ spawnRepository: { listSpawns: () => [] },
161
+ });
162
+ expect(fs.existsSync(path.join(dir, 'token-status.json'))).toBe(false);
163
+ });
164
+
165
+ it('is a no-op when the token list path is unset', () => {
166
+ writeTokenStatus({
167
+ dashboardDataDir: dir,
168
+ tokenListJsonPath: null,
169
+ issues: [],
170
+ readSnapshot: () => null,
171
+ interactiveSessionRepository: { listInteractiveSessions: () => [] },
172
+ spawnRepository: { listSpawns: () => [] },
173
+ });
174
+ expect(fs.existsSync(path.join(dir, 'token-status.json'))).toBe(false);
175
+ });
176
+ });
@@ -0,0 +1,139 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import type { Issue } from '../../../domain/entities/Issue';
4
+ import {
5
+ GenerateTokenStatusUseCase,
6
+ TokenRateLimitSnapshot,
7
+ TokenStatus,
8
+ TokenStatusInput,
9
+ } from '../../../domain/usecases/dashboard/GenerateTokenStatusUseCase';
10
+ import { InTmuxByHumanSessionTokenCountUseCase } from '../../../domain/usecases/InTmuxByHumanSessionTokenCountUseCase';
11
+ import { OauthTokenCandidate } from '../../../domain/usecases/OauthTokenSelectUseCase';
12
+ import { ClaudeInteractiveSessionRepository } from '../../../domain/usecases/adapter-interfaces/ClaudeInteractiveSessionRepository';
13
+ import { TakeOwnershipSpawnRepository } from '../../../domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository';
14
+ import { RateLimitSnapshot, readRateLimit } from '../../proxy/RateLimitCache';
15
+ import { loadTokenEntries } from '../../proxy/TokenListLoader';
16
+ import { ProcClaudeInteractiveSessionRepository } from '../../repositories/ProcClaudeInteractiveSessionRepository';
17
+ import { ProcTakeOwnershipSpawnRepository } from '../../repositories/ProcTakeOwnershipSpawnRepository';
18
+
19
+ const SEVEN_DAY_SONNET_LIMIT_TYPE = 'seven_day_sonnet';
20
+ const SEVEN_DAY_OPUS_LIMIT_TYPE = 'seven_day_opus';
21
+
22
+ export type TokenStatusWriterParams = {
23
+ dashboardDataDir: string | null | undefined;
24
+ tokenListJsonPath: string | null | undefined;
25
+ issues: Issue[];
26
+ now?: Date;
27
+ readSnapshot?: (token: string) => RateLimitSnapshot | null;
28
+ interactiveSessionRepository?: ClaudeInteractiveSessionRepository;
29
+ spawnRepository?: TakeOwnershipSpawnRepository;
30
+ };
31
+
32
+ export type TokenStatusFile = {
33
+ tokens: TokenStatus[];
34
+ capturedAt: string;
35
+ };
36
+
37
+ const writeJsonAtomic = (filePath: string, data: unknown): void => {
38
+ const dir = path.dirname(filePath);
39
+ fs.mkdirSync(dir, { recursive: true });
40
+ const tmpPath = `${filePath}.tmp`;
41
+ fs.writeFileSync(tmpPath, JSON.stringify(data));
42
+ fs.renameSync(tmpPath, filePath);
43
+ };
44
+
45
+ export const toTokenRateLimitSnapshot = (
46
+ snapshot: RateLimitSnapshot | null,
47
+ ): TokenRateLimitSnapshot | null => {
48
+ if (snapshot === null) {
49
+ return null;
50
+ }
51
+ const hasWindowData =
52
+ snapshot.unifiedStatus !== null ||
53
+ snapshot.fiveHourReset > 0 ||
54
+ snapshot.sevenDayReset > 0 ||
55
+ snapshot.fiveHourUtilization > 0 ||
56
+ snapshot.sevenDayUtilization > 0;
57
+ return {
58
+ fiveHourUtilization: snapshot.fiveHourUtilization,
59
+ fiveHourReset: snapshot.fiveHourReset,
60
+ sevenDayUtilization: snapshot.sevenDayUtilization,
61
+ sevenDayReset: snapshot.sevenDayReset,
62
+ blocked: snapshot.blocked,
63
+ fiveHourRejected: snapshot.fiveHourRejected,
64
+ sevenDayRejected: snapshot.sevenDayRejected,
65
+ unifiedStatus: snapshot.unifiedStatus,
66
+ sevenDaySonnetRejected:
67
+ snapshot.modelWeeklyLimits[SEVEN_DAY_SONNET_LIMIT_TYPE]?.rejected ??
68
+ false,
69
+ sevenDayOpusRejected:
70
+ snapshot.modelWeeklyLimits[SEVEN_DAY_OPUS_LIMIT_TYPE]?.rejected ?? false,
71
+ hasWindowData,
72
+ };
73
+ };
74
+
75
+ export const writeTokenStatus = (params: TokenStatusWriterParams): void => {
76
+ const { dashboardDataDir, tokenListJsonPath, issues } = params;
77
+ if (!dashboardDataDir || !tokenListJsonPath) {
78
+ return;
79
+ }
80
+
81
+ const entries = loadTokenEntries(tokenListJsonPath);
82
+ if (entries === null) {
83
+ return;
84
+ }
85
+
86
+ const readSnapshot = params.readSnapshot ?? readRateLimit;
87
+ const interactiveSessionRepository =
88
+ params.interactiveSessionRepository ??
89
+ new ProcClaudeInteractiveSessionRepository();
90
+ const spawnRepository =
91
+ params.spawnRepository ?? new ProcTakeOwnershipSpawnRepository();
92
+
93
+ const tokenInputs: TokenStatusInput[] = entries.map((entry) => ({
94
+ name: entry.name,
95
+ token: entry.token,
96
+ snapshot: toTokenRateLimitSnapshot(readSnapshot(entry.token)),
97
+ }));
98
+
99
+ const distinctLogPathsByToken = new Map<string, Set<string>>();
100
+ for (const spawn of spawnRepository.listSpawns()) {
101
+ const logPaths =
102
+ distinctLogPathsByToken.get(spawn.token) ?? new Set<string>();
103
+ logPaths.add(spawn.logPath);
104
+ distinctLogPathsByToken.set(spawn.token, logPaths);
105
+ }
106
+ const prepCountByToken = new Map<string, number>();
107
+ for (const [token, logPaths] of distinctLogPathsByToken.entries()) {
108
+ prepCountByToken.set(token, logPaths.size);
109
+ }
110
+
111
+ const candidates: OauthTokenCandidate[] = entries.map((entry) => ({
112
+ name: entry.name,
113
+ token: entry.token,
114
+ snapshot: null,
115
+ }));
116
+ const humResult = new InTmuxByHumanSessionTokenCountUseCase().run(
117
+ candidates,
118
+ interactiveSessionRepository.listInteractiveSessions(),
119
+ issues,
120
+ );
121
+ const humCountByToken = new Map<string, number>(
122
+ humResult.counts.map((count) => [count.token, count.count]),
123
+ );
124
+
125
+ const now = params.now ?? new Date();
126
+ const tokens = new GenerateTokenStatusUseCase().run({
127
+ tokens: tokenInputs,
128
+ prepCountByToken,
129
+ humCountByToken,
130
+ nowEpochSeconds: Math.floor(now.getTime() / 1000),
131
+ });
132
+
133
+ const file: TokenStatusFile = {
134
+ tokens,
135
+ capturedAt: now.toISOString(),
136
+ };
137
+
138
+ writeJsonAtomic(path.join(dashboardDataDir, 'token-status.json'), file);
139
+ };
@@ -180,6 +180,38 @@ describe('RateLimitCache', () => {
180
180
  expect(snapshot?.sevenDayRejected).toBe(false);
181
181
  });
182
182
 
183
+ it('should surface the raw unified status and overage-disabled reason headers', () => {
184
+ const token = 'unified-status-token';
185
+ writeRateLimit(token, {
186
+ 'anthropic-ratelimit-unified-status': 'allowed_warning',
187
+ 'anthropic-ratelimit-unified-overage-disabled-reason': 'out_of_credits',
188
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
189
+ 'anthropic-ratelimit-unified-5h-reset': '1700000000',
190
+ 'anthropic-ratelimit-unified-5h-utilization': '50',
191
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
192
+ 'anthropic-ratelimit-unified-7d-reset': '1700100000',
193
+ 'anthropic-ratelimit-unified-7d-utilization': '40',
194
+ });
195
+ const snapshot = readRateLimit(token);
196
+ expect(snapshot?.unifiedStatus).toBe('allowed_warning');
197
+ expect(snapshot?.overageDisabledReason).toBe('out_of_credits');
198
+ });
199
+
200
+ it('should expose null for the unified status and overage-disabled reason when absent', () => {
201
+ const token = 'no-unified-status-token';
202
+ writeRateLimit(token, {
203
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
204
+ 'anthropic-ratelimit-unified-5h-reset': '1700000000',
205
+ 'anthropic-ratelimit-unified-5h-utilization': '50',
206
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
207
+ 'anthropic-ratelimit-unified-7d-reset': '1700100000',
208
+ 'anthropic-ratelimit-unified-7d-utilization': '40',
209
+ });
210
+ const snapshot = readRateLimit(token);
211
+ expect(snapshot?.unifiedStatus).toBeNull();
212
+ expect(snapshot?.overageDisabledReason).toBeNull();
213
+ });
214
+
183
215
  it('should return null when file does not exist', () => {
184
216
  expect(readRateLimit('never-written-token')).toBeNull();
185
217
  });
@@ -18,6 +18,8 @@ export interface RateLimitSnapshot {
18
18
  unifiedRejected: boolean;
19
19
  fiveHourRejected: boolean;
20
20
  sevenDayRejected: boolean;
21
+ unifiedStatus: string | null;
22
+ overageDisabledReason: string | null;
21
23
  modelWeeklyLimits: Record<string, ModelWeeklyLimit>;
22
24
  lastUpdatedEpoch: number;
23
25
  blockedUntilEpoch: number;
@@ -252,6 +254,8 @@ export const readRateLimit = (
252
254
  const status = headers['anthropic-ratelimit-unified-status'];
253
255
  const fiveHourStatus = headers['anthropic-ratelimit-unified-5h-status'];
254
256
  const sevenDayStatus = headers['anthropic-ratelimit-unified-7d-status'];
257
+ const overageDisabledReason =
258
+ headers['anthropic-ratelimit-unified-overage-disabled-reason'];
255
259
  const unifiedRejected = status === 'rejected';
256
260
  const fiveHourRejected = fiveHourStatus === 'rejected';
257
261
  const sevenDayRejected = sevenDayStatus === 'rejected';
@@ -273,6 +277,8 @@ export const readRateLimit = (
273
277
  unifiedRejected,
274
278
  fiveHourRejected,
275
279
  sevenDayRejected,
280
+ unifiedStatus: status ?? null,
281
+ overageDisabledReason: overageDisabledReason ?? null,
276
282
  modelWeeklyLimits: {
277
283
  ...parseModelRateLimitsFromHeaders(headers),
278
284
  ...readModelWeeklyLimits(parsed),
@@ -0,0 +1,135 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import {
5
+ ProcHostMetricsRepository,
6
+ cpuUsedPercentFromSamples,
7
+ cycleMinutesFromMtimes,
8
+ parseCpuSample,
9
+ parseLoadAverages,
10
+ parseMemoryUsedPercent,
11
+ } from './ProcHostMetricsRepository';
12
+
13
+ describe('parseMemoryUsedPercent', () => {
14
+ it('computes (total - available) / total rounded to a whole percent', () => {
15
+ expect(
16
+ parseMemoryUsedPercent(
17
+ 'MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 380 kB\n',
18
+ ),
19
+ ).toBe(62);
20
+ });
21
+
22
+ it('throws when MemTotal is not positive', () => {
23
+ expect(() =>
24
+ parseMemoryUsedPercent('MemTotal: 0 kB\nMemAvailable: 0 kB\n'),
25
+ ).toThrow();
26
+ });
27
+ });
28
+
29
+ describe('parseCpuSample', () => {
30
+ it('uses the aggregate cpu line with idle = idle + iowait and total = sum of all fields', () => {
31
+ expect(
32
+ parseCpuSample('cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 2 3 4\n'),
33
+ ).toEqual({ total: 1000, idle: 800 });
34
+ });
35
+
36
+ it('throws when there is no aggregate cpu line', () => {
37
+ expect(() => parseCpuSample('cpu0 1 2 3 4\n')).toThrow();
38
+ });
39
+ });
40
+
41
+ describe('cpuUsedPercentFromSamples', () => {
42
+ it('computes (total_delta - idle_delta) / total_delta', () => {
43
+ expect(
44
+ cpuUsedPercentFromSamples(
45
+ { total: 1000, idle: 700 },
46
+ { total: 1100, idle: 770 },
47
+ ),
48
+ ).toBe(30);
49
+ });
50
+
51
+ it('throws when the total delta is not positive', () => {
52
+ expect(() =>
53
+ cpuUsedPercentFromSamples(
54
+ { total: 1000, idle: 700 },
55
+ { total: 1000, idle: 700 },
56
+ ),
57
+ ).toThrow();
58
+ });
59
+ });
60
+
61
+ describe('parseLoadAverages', () => {
62
+ it('parses the first three floats from /proc/loadavg', () => {
63
+ expect(parseLoadAverages('1.20 0.98 0.75 1/123 4567\n')).toEqual({
64
+ oneMinute: 1.2,
65
+ fiveMinute: 0.98,
66
+ fifteenMinute: 0.75,
67
+ });
68
+ });
69
+
70
+ it('throws when fewer than three numeric values are present', () => {
71
+ expect(() => parseLoadAverages('1.20 0.98\n')).toThrow();
72
+ });
73
+ });
74
+
75
+ describe('cycleMinutesFromMtimes', () => {
76
+ it('rounds the gap between the two newest generations to whole minutes', () => {
77
+ expect(cycleMinutesFromMtimes([1782469254.0, 1782468443.0])).toBe(14);
78
+ });
79
+
80
+ it('rounds a 1620 second gap to 27 minutes', () => {
81
+ expect(cycleMinutesFromMtimes([1620.0, 0.0])).toBe(27);
82
+ });
83
+
84
+ it('returns null when a second generation is missing', () => {
85
+ expect(cycleMinutesFromMtimes([1000.0])).toBeNull();
86
+ expect(cycleMinutesFromMtimes([])).toBeNull();
87
+ });
88
+ });
89
+
90
+ describe('ProcHostMetricsRepository', () => {
91
+ let procDirectory: string;
92
+
93
+ beforeEach(() => {
94
+ procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-proc-host-'));
95
+ });
96
+
97
+ afterEach(() => {
98
+ fs.rmSync(procDirectory, { recursive: true, force: true });
99
+ });
100
+
101
+ it('reads memory used percent from /proc/meminfo', () => {
102
+ fs.writeFileSync(
103
+ path.join(procDirectory, 'meminfo'),
104
+ 'MemTotal: 1000 kB\nMemAvailable: 380 kB\n',
105
+ );
106
+ const repository = new ProcHostMetricsRepository(procDirectory);
107
+ expect(repository.readMemoryUsedPercent()).toBe(62);
108
+ });
109
+
110
+ it('reads load averages from /proc/loadavg', () => {
111
+ fs.writeFileSync(
112
+ path.join(procDirectory, 'loadavg'),
113
+ '2.00 1.00 0.50 1/100 200\n',
114
+ );
115
+ const repository = new ProcHostMetricsRepository(procDirectory);
116
+ expect(repository.readLoadAverages()).toEqual({
117
+ oneMinute: 2.0,
118
+ fiveMinute: 1.0,
119
+ fifteenMinute: 0.5,
120
+ });
121
+ });
122
+
123
+ it('samples /proc/stat twice and computes the busy percent', async () => {
124
+ const statPath = path.join(procDirectory, 'stat');
125
+ let sampleIndex = 0;
126
+ fs.writeFileSync(statPath, 'cpu 700 0 0 300 0 0 0 0 0 0\n');
127
+ const sleep = async (): Promise<void> => {
128
+ sampleIndex += 1;
129
+ fs.writeFileSync(statPath, 'cpu 770 0 0 330 0 0 0 0 0 0\n');
130
+ };
131
+ const repository = new ProcHostMetricsRepository(procDirectory, sleep);
132
+ expect(await repository.readCpuUsedPercent()).toBe(70);
133
+ expect(sampleIndex).toBe(1);
134
+ });
135
+ });