github-issue-tower-defence-management 1.104.3 → 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 (80) 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 +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/adapter/repositories/issue/GraphqlProjectItemRepository.js +93 -8
  22. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  23. package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js +3 -0
  24. package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js.map +1 -0
  25. package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js +38 -0
  26. package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js.map +1 -0
  27. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +115 -0
  28. package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -0
  29. package/package.json +1 -1
  30. package/src/adapter/entry-points/cli/index.test.ts +74 -21
  31. package/src/adapter/entry-points/cli/index.ts +146 -136
  32. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -2
  33. package/src/adapter/entry-points/console/{consoleServer.test.ts → webServer.test.ts} +32 -32
  34. package/src/adapter/entry-points/console/{consoleServer.ts → webServer.ts} +15 -17
  35. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +52 -0
  36. package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +111 -0
  37. package/src/adapter/entry-points/handlers/dashboardRowWriter.ts +51 -0
  38. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +100 -0
  39. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +79 -0
  40. package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +176 -0
  41. package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +139 -0
  42. package/src/adapter/proxy/RateLimitCache.test.ts +32 -0
  43. package/src/adapter/proxy/RateLimitCache.ts +6 -0
  44. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +135 -0
  45. package/src/adapter/repositories/ProcHostMetricsRepository.ts +136 -0
  46. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +130 -0
  47. package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +118 -0
  48. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.test.ts +267 -13
  49. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +140 -47
  50. package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
  51. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +159 -0
  52. package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.ts +72 -0
  53. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +209 -0
  54. package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +195 -0
  55. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  56. package/types/adapter/entry-points/console/{consoleServer.d.ts → webServer.d.ts} +7 -7
  57. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -0
  58. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  59. package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts +15 -0
  60. package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts.map +1 -0
  61. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +16 -0
  62. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -0
  63. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +21 -0
  64. package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -0
  65. package/types/adapter/proxy/RateLimitCache.d.ts +2 -0
  66. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  67. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +23 -0
  68. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -0
  69. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +11 -0
  70. package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -0
  71. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +6 -1
  72. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  73. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +8 -0
  74. package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -0
  75. package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts +19 -0
  76. package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts.map +1 -0
  77. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +53 -0
  78. package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -0
  79. package/bin/adapter/entry-points/console/consoleServer.js.map +0 -1
  80. package/types/adapter/entry-points/console/consoleServer.d.ts.map +0 -1
@@ -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
+ });
@@ -0,0 +1,136 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
4
+ export type CpuSample = {
5
+ total: number;
6
+ idle: number;
7
+ };
8
+
9
+ export type LoadAverages = {
10
+ oneMinute: number;
11
+ fiveMinute: number;
12
+ fifteenMinute: number;
13
+ };
14
+
15
+ const DEFAULT_PROC_DIRECTORY = '/proc';
16
+ const CPU_SAMPLE_INTERVAL_MS = 400;
17
+
18
+ export const parseMemoryUsedPercent = (meminfoText: string): number => {
19
+ const fields = new Map<string, number>();
20
+ for (const line of meminfoText.split('\n')) {
21
+ const separatorIndex = line.indexOf(':');
22
+ if (separatorIndex <= 0) {
23
+ continue;
24
+ }
25
+ const key = line.slice(0, separatorIndex).trim();
26
+ const valuePart = line
27
+ .slice(separatorIndex + 1)
28
+ .trim()
29
+ .split(/\s+/)[0];
30
+ const value = Number(valuePart);
31
+ if (Number.isFinite(value)) {
32
+ fields.set(key, value);
33
+ }
34
+ }
35
+ const total = fields.get('MemTotal');
36
+ const available = fields.get('MemAvailable');
37
+ if (total === undefined || available === undefined) {
38
+ throw new Error('MemTotal and MemAvailable are required');
39
+ }
40
+ if (total <= 0) {
41
+ throw new Error('MemTotal must be positive');
42
+ }
43
+ const used = total - available;
44
+ return Math.round((used / total) * 100);
45
+ };
46
+
47
+ export const parseCpuSample = (statText: string): CpuSample => {
48
+ for (const line of statText.split('\n')) {
49
+ if (line.startsWith('cpu ')) {
50
+ const values = line
51
+ .trim()
52
+ .split(/\s+/)
53
+ .slice(1)
54
+ .map((value) => Number(value));
55
+ if (values.some((value) => !Number.isFinite(value))) {
56
+ throw new Error('aggregate cpu line contains non-numeric values');
57
+ }
58
+ const idle = values[3] + (values.length > 4 ? values[4] : 0);
59
+ const total = values.reduce((sum, value) => sum + value, 0);
60
+ return { total, idle };
61
+ }
62
+ }
63
+ throw new Error('aggregate cpu line not found');
64
+ };
65
+
66
+ export const cpuUsedPercentFromSamples = (
67
+ first: CpuSample,
68
+ second: CpuSample,
69
+ ): number => {
70
+ const totalDelta = second.total - first.total;
71
+ const idleDelta = second.idle - first.idle;
72
+ if (totalDelta <= 0) {
73
+ throw new Error('total delta must be positive');
74
+ }
75
+ const busyDelta = totalDelta - idleDelta;
76
+ return Math.round((busyDelta / totalDelta) * 100);
77
+ };
78
+
79
+ export const parseLoadAverages = (loadavgText: string): LoadAverages => {
80
+ const parts = loadavgText.trim().split(/\s+/);
81
+ const oneMinute = Number(parts[0]);
82
+ const fiveMinute = Number(parts[1]);
83
+ const fifteenMinute = Number(parts[2]);
84
+ if (
85
+ !Number.isFinite(oneMinute) ||
86
+ !Number.isFinite(fiveMinute) ||
87
+ !Number.isFinite(fifteenMinute)
88
+ ) {
89
+ throw new Error('loadavg must contain three numeric values');
90
+ }
91
+ return { oneMinute, fiveMinute, fifteenMinute };
92
+ };
93
+
94
+ export const cycleMinutesFromMtimes = (
95
+ mtimesDescendingSeconds: number[],
96
+ ): number | null => {
97
+ if (mtimesDescendingSeconds.length < 2) {
98
+ return null;
99
+ }
100
+ return Math.round(
101
+ (mtimesDescendingSeconds[0] - mtimesDescendingSeconds[1]) / 60,
102
+ );
103
+ };
104
+
105
+ export class ProcHostMetricsRepository {
106
+ constructor(
107
+ private readonly procDirectory: string = DEFAULT_PROC_DIRECTORY,
108
+ private readonly sleep: (milliseconds: number) => Promise<void> = (
109
+ milliseconds,
110
+ ) =>
111
+ new Promise((resolve) => {
112
+ setTimeout(resolve, milliseconds);
113
+ }),
114
+ ) {}
115
+
116
+ readMemoryUsedPercent = (): number =>
117
+ parseMemoryUsedPercent(
118
+ fs.readFileSync(path.join(this.procDirectory, 'meminfo'), 'utf8'),
119
+ );
120
+
121
+ readCpuUsedPercent = async (): Promise<number> => {
122
+ const first = parseCpuSample(
123
+ fs.readFileSync(path.join(this.procDirectory, 'stat'), 'utf8'),
124
+ );
125
+ await this.sleep(CPU_SAMPLE_INTERVAL_MS);
126
+ const second = parseCpuSample(
127
+ fs.readFileSync(path.join(this.procDirectory, 'stat'), 'utf8'),
128
+ );
129
+ return cpuUsedPercentFromSamples(first, second);
130
+ };
131
+
132
+ readLoadAverages = (): LoadAverages =>
133
+ parseLoadAverages(
134
+ fs.readFileSync(path.join(this.procDirectory, 'loadavg'), 'utf8'),
135
+ );
136
+ }
@@ -0,0 +1,130 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { ProcTakeOwnershipSpawnRepository } from './ProcTakeOwnershipSpawnRepository';
5
+
6
+ type FakeProcess = {
7
+ pid: number;
8
+ cmdline: string;
9
+ environ: Record<string, string>;
10
+ };
11
+
12
+ const issueUrl = 'https://github.com/HiromiShikata/example/issues/1';
13
+ const argv = (...parts: string[]): string => `${parts.join('\0')}\0`;
14
+
15
+ describe('ProcTakeOwnershipSpawnRepository', () => {
16
+ let procDirectory: string;
17
+
18
+ beforeEach(() => {
19
+ procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-proc-spawn-'));
20
+ });
21
+
22
+ afterEach(() => {
23
+ fs.rmSync(procDirectory, { recursive: true, force: true });
24
+ });
25
+
26
+ const writeProcess = (fakeProcess: FakeProcess): void => {
27
+ const processDirectory = path.join(procDirectory, String(fakeProcess.pid));
28
+ fs.mkdirSync(processDirectory, { recursive: true });
29
+ fs.writeFileSync(
30
+ path.join(processDirectory, 'cmdline'),
31
+ fakeProcess.cmdline,
32
+ );
33
+ const environBuffer = Object.entries(fakeProcess.environ)
34
+ .map(([key, value]) => `${key}=${value}\0`)
35
+ .join('');
36
+ fs.writeFileSync(path.join(processDirectory, 'environ'), environBuffer);
37
+ };
38
+
39
+ it('reads the token and log path of a Take ownership spawn', () => {
40
+ writeProcess({
41
+ pid: 301,
42
+ cmdline: argv(
43
+ 'bash',
44
+ '-c',
45
+ `claude-agent -p "Take ownership of ${issueUrl}" | tee /home/user/logs-aw/20260626_120000_abc.log`,
46
+ ),
47
+ environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-a' },
48
+ });
49
+
50
+ const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
51
+
52
+ expect(repository.listSpawns()).toEqual([
53
+ { token: 'token-a', logPath: '/logs-aw/20260626_120000_abc.log' },
54
+ ]);
55
+ });
56
+
57
+ it('ignores a process that is not a Take ownership spawn', () => {
58
+ writeProcess({
59
+ pid: 302,
60
+ cmdline: argv('claude', '--name', issueUrl),
61
+ environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-b' },
62
+ });
63
+
64
+ const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
65
+
66
+ expect(repository.listSpawns()).toEqual([]);
67
+ });
68
+
69
+ it('ignores a Take ownership process whose cmdline carries no log path', () => {
70
+ writeProcess({
71
+ pid: 303,
72
+ cmdline: argv('claude-agent', '-p', `Take ownership of ${issueUrl}`),
73
+ environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-c' },
74
+ });
75
+
76
+ const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
77
+
78
+ expect(repository.listSpawns()).toEqual([]);
79
+ });
80
+
81
+ it('ignores a Take ownership process without an oauth token', () => {
82
+ writeProcess({
83
+ pid: 304,
84
+ cmdline: argv(
85
+ 'bash',
86
+ '-c',
87
+ `Take ownership of ${issueUrl} | tee /home/user/logs-aw/x.log`,
88
+ ),
89
+ environ: { ANTHROPIC_API_KEY: 'api-key' },
90
+ });
91
+
92
+ const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
93
+
94
+ expect(repository.listSpawns()).toEqual([]);
95
+ });
96
+
97
+ it('returns one entry per child process so the use case can dedupe by log path', () => {
98
+ const fullLogPath = '/home/user/logs-aw/20260626_120000_dup.log';
99
+ const capturedLogPath = '/logs-aw/20260626_120000_dup.log';
100
+ writeProcess({
101
+ pid: 305,
102
+ cmdline: argv(
103
+ 'bash',
104
+ '-c',
105
+ `Take ownership of ${issueUrl} | tee ${fullLogPath}`,
106
+ ),
107
+ environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-d' },
108
+ });
109
+ writeProcess({
110
+ pid: 306,
111
+ cmdline: argv('tee', fullLogPath, 'Take ownership marker'),
112
+ environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-d' },
113
+ });
114
+
115
+ const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
116
+
117
+ expect(repository.listSpawns()).toEqual([
118
+ { token: 'token-d', logPath: capturedLogPath },
119
+ { token: 'token-d', logPath: capturedLogPath },
120
+ ]);
121
+ });
122
+
123
+ it('returns an empty list when the proc directory does not exist', () => {
124
+ const repository = new ProcTakeOwnershipSpawnRepository(
125
+ path.join(procDirectory, 'missing'),
126
+ );
127
+
128
+ expect(repository.listSpawns()).toEqual([]);
129
+ });
130
+ });