github-issue-tower-defence-management 1.104.4 → 1.106.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 (101) hide show
  1. package/CHANGELOG.md +20 -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 +54 -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/staleTmuxSessionCleaner.js +18 -0
  14. package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js.map +1 -0
  15. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +98 -0
  16. package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -0
  17. package/bin/adapter/proxy/RateLimitCache.js +3 -0
  18. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  19. package/bin/adapter/repositories/NodeTmuxSessionRepository.js +22 -0
  20. package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
  21. package/bin/adapter/repositories/ProcHostMetricsRepository.js +136 -0
  22. package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -0
  23. package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js +131 -0
  24. package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js.map +1 -0
  25. package/bin/domain/entities/LiveTmuxSession.js +3 -0
  26. package/bin/domain/entities/LiveTmuxSession.js.map +1 -0
  27. package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js +58 -0
  28. package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js.map +1 -0
  29. package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js +3 -0
  30. package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js.map +1 -0
  31. package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js +38 -0
  32. package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js.map +1 -0
  33. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +115 -0
  34. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/adapter/entry-points/cli/index.test.ts +74 -21
  37. package/src/adapter/entry-points/cli/index.ts +146 -136
  38. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -2
  39. package/src/adapter/entry-points/console/{consoleServer.test.ts → webServer.test.ts} +32 -32
  40. package/src/adapter/entry-points/console/{consoleServer.ts → webServer.ts} +15 -17
  41. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +69 -0
  42. package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +111 -0
  43. package/src/adapter/entry-points/handlers/dashboardRowWriter.ts +51 -0
  44. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +100 -0
  45. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +79 -0
  46. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +170 -0
  47. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.ts +40 -0
  48. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +176 -0
  49. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +139 -0
  50. package/src/adapter/proxy/RateLimitCache.test.ts +32 -0
  51. package/src/adapter/proxy/RateLimitCache.ts +6 -0
  52. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +81 -0
  53. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +35 -0
  54. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +135 -0
  55. package/src/adapter/repositories/ProcHostMetricsRepository.ts +136 -0
  56. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +130 -0
  57. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +118 -0
  58. package/src/domain/entities/LiveTmuxSession.ts +4 -0
  59. package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +286 -0
  60. package/src/domain/usecases/StaleTmuxSessionKillUseCase.ts +102 -0
  61. package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
  62. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +4 -0
  63. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +159 -0
  64. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.ts +72 -0
  65. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +209 -0
  66. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +195 -0
  67. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +6 -0
  68. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  69. package/types/adapter/entry-points/console/{consoleServer.d.ts → webServer.d.ts} +7 -7
  70. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -0
  71. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  72. package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts +15 -0
  73. package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts.map +1 -0
  74. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +16 -0
  75. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -0
  76. package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts +12 -0
  77. package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts.map +1 -0
  78. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +21 -0
  79. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -0
  80. package/types/adapter/proxy/RateLimitCache.d.ts +2 -0
  81. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  82. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +3 -0
  83. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
  84. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +23 -0
  85. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -0
  86. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +11 -0
  87. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -0
  88. package/types/domain/entities/LiveTmuxSession.d.ts +5 -0
  89. package/types/domain/entities/LiveTmuxSession.d.ts.map +1 -0
  90. package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts +19 -0
  91. package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts.map +1 -0
  92. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +8 -0
  93. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -0
  94. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +3 -0
  95. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
  96. package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts +19 -0
  97. package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts.map +1 -0
  98. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +53 -0
  99. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -0
  100. package/bin/adapter/entry-points/console/consoleServer.js.map +0 -1
  101. package/types/adapter/entry-points/console/consoleServer.d.ts.map +0 -1
@@ -0,0 +1,170 @@
1
+ import { Issue } from '../../../domain/entities/Issue';
2
+ import { Project } from '../../../domain/entities/Project';
3
+ import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
4
+ import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
5
+ import { IN_TMUX_STATUS_NAME } from '../../../domain/entities/WorkflowStatus';
6
+ import { cleanStaleTmuxSessions } from './staleTmuxSessionCleaner';
7
+
8
+ const NOW = new Date('2026-06-26T00:00:00.000Z');
9
+ const NOW_EPOCH_SECONDS = Math.floor(NOW.getTime() / 1000);
10
+ const ALLOW_CACHE_MINUTES = 10;
11
+
12
+ const makeProject = (): Project => ({
13
+ id: 'project-1',
14
+ url: 'https://github.com/users/user/projects/1',
15
+ databaseId: 1,
16
+ name: 'Test Project',
17
+ status: {
18
+ name: 'Status',
19
+ fieldId: 'field-1',
20
+ statuses: [],
21
+ },
22
+ nextActionDate: null,
23
+ nextActionHour: null,
24
+ story: null,
25
+ remainingEstimationMinutes: null,
26
+ dependedIssueUrlSeparatedByComma: null,
27
+ completionDate50PercentConfidence: null,
28
+ });
29
+
30
+ const makeIssue = (overrides: Partial<Issue> = {}): Issue => ({
31
+ nameWithOwner: 'demo/repo',
32
+ number: 1,
33
+ title: 'Issue 1',
34
+ state: 'OPEN',
35
+ status: IN_TMUX_STATUS_NAME,
36
+ story: null,
37
+ nextActionDate: null,
38
+ nextActionHour: null,
39
+ estimationMinutes: null,
40
+ dependedIssueUrls: [],
41
+ completionDate50PercentConfidence: null,
42
+ url: 'https://github.com/demo/repo/issues/1',
43
+ assignees: [],
44
+ labels: [],
45
+ org: 'demo',
46
+ repo: 'repo',
47
+ body: '',
48
+ itemId: 'item-1',
49
+ isPr: false,
50
+ isInProgress: false,
51
+ isClosed: false,
52
+ createdAt: new Date(),
53
+ author: '',
54
+ closingIssueReferenceUrls: [],
55
+ ...overrides,
56
+ });
57
+
58
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
59
+
60
+ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
61
+ runCommand: jest.fn(),
62
+ });
63
+
64
+ const createMockIssueRepository = (
65
+ issues: Issue[],
66
+ ): Pick<IssueRepository, 'getAllOpened'> => ({
67
+ getAllOpened: jest.fn().mockResolvedValue(issues),
68
+ });
69
+
70
+ describe('cleanStaleTmuxSessions', () => {
71
+ beforeEach(() => {
72
+ jest.spyOn(console, 'log').mockImplementation(() => undefined);
73
+ });
74
+
75
+ afterEach(() => {
76
+ jest.restoreAllMocks();
77
+ });
78
+
79
+ it('kills a session mapping to an open issue whose status is not the excluded status', async () => {
80
+ const runner = createMockRunner();
81
+ runner.runCommand.mockImplementation(async (program, args) => {
82
+ if (program === 'tmux' && args[0] === 'list-sessions') {
83
+ return {
84
+ stdout: `https_//github_com/demo/repo/issues/1 ${NOW_EPOCH_SECONDS}\n`,
85
+ stderr: '',
86
+ exitCode: 0,
87
+ };
88
+ }
89
+ return { stdout: '', stderr: '', exitCode: 0 };
90
+ });
91
+
92
+ await cleanStaleTmuxSessions({
93
+ project: makeProject(),
94
+ allowCacheMinutes: ALLOW_CACHE_MINUTES,
95
+ issueRepository: createMockIssueRepository([
96
+ makeIssue({ status: 'In Progress' }),
97
+ ]),
98
+ localCommandRunner: runner,
99
+ now: NOW,
100
+ });
101
+
102
+ const killCall = runner.runCommand.mock.calls.find(
103
+ (call) => call[0] === 'tmux' && call[1][0] === 'kill-session',
104
+ );
105
+ expect(killCall?.[1]).toEqual([
106
+ 'kill-session',
107
+ '-t',
108
+ 'https_//github_com/demo/repo/issues/1',
109
+ ]);
110
+ });
111
+
112
+ it('does not kill an excluded-status session that has no reactivation trigger', async () => {
113
+ const runner = createMockRunner();
114
+ runner.runCommand.mockImplementation(async (program, args) => {
115
+ if (program === 'tmux' && args[0] === 'list-sessions') {
116
+ return {
117
+ stdout: `https_//github_com/demo/repo/issues/1 ${NOW_EPOCH_SECONDS}\n`,
118
+ stderr: '',
119
+ exitCode: 0,
120
+ };
121
+ }
122
+ return { stdout: '', stderr: '', exitCode: 0 };
123
+ });
124
+
125
+ await cleanStaleTmuxSessions({
126
+ project: makeProject(),
127
+ allowCacheMinutes: ALLOW_CACHE_MINUTES,
128
+ issueRepository: createMockIssueRepository([
129
+ makeIssue({ status: IN_TMUX_STATUS_NAME }),
130
+ ]),
131
+ localCommandRunner: runner,
132
+ now: NOW,
133
+ });
134
+
135
+ const killCalls = runner.runCommand.mock.calls.filter(
136
+ (call) => call[0] === 'tmux' && call[1][0] === 'kill-session',
137
+ );
138
+ expect(killCalls).toHaveLength(0);
139
+ });
140
+
141
+ it('kills a no-task session idle at least 24 hours and spares a recently active one', async () => {
142
+ const runner = createMockRunner();
143
+ const idleActivity = NOW_EPOCH_SECONDS - 24 * 60 * 60;
144
+ const recentActivity = NOW_EPOCH_SECONDS - 24 * 60 * 60 + 1;
145
+ runner.runCommand.mockImplementation(async (program, args) => {
146
+ if (program === 'tmux' && args[0] === 'list-sessions') {
147
+ return {
148
+ stdout: `idle_no_task ${idleActivity}\nrecent_no_task ${recentActivity}\n`,
149
+ stderr: '',
150
+ exitCode: 0,
151
+ };
152
+ }
153
+ return { stdout: '', stderr: '', exitCode: 0 };
154
+ });
155
+
156
+ await cleanStaleTmuxSessions({
157
+ project: makeProject(),
158
+ allowCacheMinutes: ALLOW_CACHE_MINUTES,
159
+ issueRepository: createMockIssueRepository([]),
160
+ localCommandRunner: runner,
161
+ now: NOW,
162
+ });
163
+
164
+ const killCalls = runner.runCommand.mock.calls.filter(
165
+ (call) => call[0] === 'tmux' && call[1][0] === 'kill-session',
166
+ );
167
+ expect(killCalls).toHaveLength(1);
168
+ expect(killCalls[0][1]).toEqual(['kill-session', '-t', 'idle_no_task']);
169
+ });
170
+ });
@@ -0,0 +1,40 @@
1
+ import { Project } from '../../../domain/entities/Project';
2
+ import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
+ import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
4
+ import {
5
+ StaleTmuxSessionKillUseCase,
6
+ DEFAULT_EXCLUDED_STATUS,
7
+ DEFAULT_IDLE_THRESHOLD_SECONDS,
8
+ } from '../../../domain/usecases/StaleTmuxSessionKillUseCase';
9
+ import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
10
+
11
+ export type CleanStaleTmuxSessionsParams = {
12
+ project: Project;
13
+ allowCacheMinutes: number;
14
+ issueRepository: Pick<IssueRepository, 'getAllOpened'>;
15
+ localCommandRunner: LocalCommandRunner;
16
+ now: Date;
17
+ };
18
+
19
+ export const cleanStaleTmuxSessions = async (
20
+ params: CleanStaleTmuxSessionsParams,
21
+ ): Promise<void> => {
22
+ const {
23
+ project,
24
+ allowCacheMinutes,
25
+ issueRepository,
26
+ localCommandRunner,
27
+ now,
28
+ } = params;
29
+ const useCase = new StaleTmuxSessionKillUseCase(
30
+ issueRepository,
31
+ new NodeTmuxSessionRepository(localCommandRunner),
32
+ );
33
+ await useCase.run({
34
+ project,
35
+ allowCacheMinutes,
36
+ excludedStatus: DEFAULT_EXCLUDED_STATUS,
37
+ idleThresholdSeconds: DEFAULT_IDLE_THRESHOLD_SECONDS,
38
+ now,
39
+ });
40
+ };
@@ -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),
@@ -44,6 +44,87 @@ describe('NodeTmuxSessionRepository', () => {
44
44
  });
45
45
  });
46
46
 
47
+ describe('listLiveSessionsWithActivity', () => {
48
+ it('parses session names and activity epoch seconds and drops blank lines', async () => {
49
+ const runner = createMockRunner();
50
+ runner.runCommand.mockResolvedValue({
51
+ stdout:
52
+ 'https_//github_com/owner/repo/issues/9 1700000000\nno_task_session 1699000000\n\n',
53
+ stderr: '',
54
+ exitCode: 0,
55
+ });
56
+ const repository = new NodeTmuxSessionRepository(runner);
57
+
58
+ const result = await repository.listLiveSessionsWithActivity();
59
+
60
+ expect(result).toEqual([
61
+ {
62
+ sessionName: 'https_//github_com/owner/repo/issues/9',
63
+ activityEpochSeconds: 1700000000,
64
+ },
65
+ {
66
+ sessionName: 'no_task_session',
67
+ activityEpochSeconds: 1699000000,
68
+ },
69
+ ]);
70
+ expect(runner.runCommand.mock.calls[0][0]).toBe('tmux');
71
+ expect(runner.runCommand.mock.calls[0][1]).toEqual([
72
+ 'list-sessions',
73
+ '-F',
74
+ '#{session_name} #{session_activity}',
75
+ ]);
76
+ });
77
+
78
+ it('returns an empty list when tmux exits non-zero', async () => {
79
+ const runner = createMockRunner();
80
+ runner.runCommand.mockResolvedValue({
81
+ stdout: '',
82
+ stderr: 'no server running',
83
+ exitCode: 1,
84
+ });
85
+ const repository = new NodeTmuxSessionRepository(runner);
86
+
87
+ const result = await repository.listLiveSessionsWithActivity();
88
+
89
+ expect(result).toEqual([]);
90
+ });
91
+ });
92
+
93
+ describe('killSession', () => {
94
+ it('kills the tmux session by name', async () => {
95
+ const runner = createMockRunner();
96
+ runner.runCommand.mockResolvedValue({
97
+ stdout: '',
98
+ stderr: '',
99
+ exitCode: 0,
100
+ });
101
+ const repository = new NodeTmuxSessionRepository(runner);
102
+
103
+ await repository.killSession('no_task_session');
104
+
105
+ expect(runner.runCommand.mock.calls[0][0]).toBe('tmux');
106
+ expect(runner.runCommand.mock.calls[0][1]).toEqual([
107
+ 'kill-session',
108
+ '-t',
109
+ 'no_task_session',
110
+ ]);
111
+ });
112
+
113
+ it('throws when tmux exits non-zero', async () => {
114
+ const runner = createMockRunner();
115
+ runner.runCommand.mockResolvedValue({
116
+ stdout: '',
117
+ stderr: "can't find session",
118
+ exitCode: 1,
119
+ });
120
+ const repository = new NodeTmuxSessionRepository(runner);
121
+
122
+ await expect(repository.killSession('missing_session')).rejects.toThrow(
123
+ 'Failed to kill tmux session "missing_session": exit code 1',
124
+ );
125
+ });
126
+ });
127
+
47
128
  describe('listInteractiveProcessCommandLines', () => {
48
129
  it('parses process command lines from ps output', async () => {
49
130
  const runner = createMockRunner();