github-issue-tower-defence-management 1.139.0 → 1.140.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/CHANGELOG.md +14 -0
- package/README.md +8 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +50 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +5 -2
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js +31 -0
- package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js.map +1 -0
- package/bin/adapter/repositories/FileHandoverStateRepository.js +99 -0
- package/bin/adapter/repositories/FileHandoverStateRepository.js.map +1 -0
- package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js +125 -0
- package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js.map +1 -0
- package/bin/adapter/repositories/NodeProcessSignalRepository.js +44 -0
- package/bin/adapter/repositories/NodeProcessSignalRepository.js.map +1 -0
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js +67 -1
- package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
- package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js +206 -0
- package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js.map +1 -0
- package/bin/adapter/repositories/RateLimitSnapshotRepository.js +44 -0
- package/bin/adapter/repositories/RateLimitSnapshotRepository.js.map +1 -0
- package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +4 -1
- package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -1
- package/bin/domain/entities/ClaudeHandoverSession.js +3 -0
- package/bin/domain/entities/ClaudeHandoverSession.js.map +1 -0
- package/bin/domain/entities/TokenExhaustionHandoverState.js +3 -0
- package/bin/domain/entities/TokenExhaustionHandoverState.js.map +1 -0
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +48 -1
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js +255 -0
- package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +37 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +30 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +8 -0
- package/src/adapter/entry-points/handlers/tokenExhaustionHandover.ts +79 -0
- package/src/adapter/repositories/FileHandoverStateRepository.test.ts +114 -0
- package/src/adapter/repositories/FileHandoverStateRepository.ts +76 -0
- package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.test.ts +127 -0
- package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.ts +108 -0
- package/src/adapter/repositories/NodeProcessSignalRepository.test.ts +82 -0
- package/src/adapter/repositories/NodeProcessSignalRepository.ts +47 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
- package/src/adapter/repositories/NodeTmuxSessionRepository.ts +100 -0
- package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.test.ts +194 -0
- package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.ts +208 -0
- package/src/adapter/repositories/RateLimitSnapshotRepository.ts +50 -0
- package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +23 -2
- package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +5 -1
- package/src/domain/entities/ClaudeHandoverSession.ts +11 -0
- package/src/domain/entities/TokenExhaustionHandoverState.ts +8 -0
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +218 -8
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +60 -0
- package/src/domain/usecases/TokenExhaustionHandoverUseCase.test.ts +696 -0
- package/src/domain/usecases/TokenExhaustionHandoverUseCase.ts +413 -0
- package/src/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.ts +5 -0
- package/src/domain/usecases/adapter-interfaces/ProcessSignalRepository.ts +5 -0
- package/src/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.ts +10 -0
- package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +2 -0
- package/src/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.ts +6 -0
- package/src/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.ts +22 -0
- package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +2 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +1 -0
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts +14 -0
- package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts.map +1 -0
- package/types/adapter/repositories/FileHandoverStateRepository.d.ts +11 -0
- package/types/adapter/repositories/FileHandoverStateRepository.d.ts.map +1 -0
- package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts +16 -0
- package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts.map +1 -0
- package/types/adapter/repositories/NodeProcessSignalRepository.d.ts +8 -0
- package/types/adapter/repositories/NodeProcessSignalRepository.d.ts.map +1 -0
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +7 -1
- package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
- package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts +13 -0
- package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts.map +1 -0
- package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts +9 -0
- package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts.map +1 -0
- package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -1
- package/types/domain/entities/ClaudeHandoverSession.d.ts +10 -0
- package/types/domain/entities/ClaudeHandoverSession.d.ts.map +1 -0
- package/types/domain/entities/TokenExhaustionHandoverState.d.ts +8 -0
- package/types/domain/entities/TokenExhaustionHandoverState.d.ts.map +1 -0
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +3 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts +50 -0
- package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts +5 -0
- package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts +6 -0
- package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts +11 -0
- package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +2 -0
- package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts +6 -0
- package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts +21 -0
- package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts.map +1 -0
|
@@ -294,4 +294,136 @@ describe('NodeTmuxSessionRepository', () => {
|
|
|
294
294
|
]);
|
|
295
295
|
});
|
|
296
296
|
});
|
|
297
|
+
|
|
298
|
+
describe('sendKeys', () => {
|
|
299
|
+
it('sends the literal text then Enter and stops when the composer is empty', async () => {
|
|
300
|
+
const runner = createMockRunner();
|
|
301
|
+
runner.runCommand
|
|
302
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
303
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
304
|
+
.mockResolvedValueOnce({
|
|
305
|
+
stdout: 'idle pane content\n> ',
|
|
306
|
+
stderr: '',
|
|
307
|
+
exitCode: 0,
|
|
308
|
+
});
|
|
309
|
+
const repository = new NodeTmuxSessionRepository(runner, '/proc', 0);
|
|
310
|
+
|
|
311
|
+
await repository.sendKeys('session-a', 'checkpoint now');
|
|
312
|
+
|
|
313
|
+
expect(runner.runCommand.mock.calls[0][1]).toEqual([
|
|
314
|
+
'send-keys',
|
|
315
|
+
'-t',
|
|
316
|
+
'session-a',
|
|
317
|
+
'-l',
|
|
318
|
+
'checkpoint now',
|
|
319
|
+
]);
|
|
320
|
+
expect(runner.runCommand.mock.calls[1][1]).toEqual([
|
|
321
|
+
'send-keys',
|
|
322
|
+
'-t',
|
|
323
|
+
'session-a',
|
|
324
|
+
'Enter',
|
|
325
|
+
]);
|
|
326
|
+
expect(runner.runCommand.mock.calls[2][1]).toEqual([
|
|
327
|
+
'capture-pane',
|
|
328
|
+
'-p',
|
|
329
|
+
'-t',
|
|
330
|
+
'session-a',
|
|
331
|
+
]);
|
|
332
|
+
expect(runner.runCommand.mock.calls).toHaveLength(3);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('re-sends Enter when the message is still visible in the composer', async () => {
|
|
336
|
+
const runner = createMockRunner();
|
|
337
|
+
runner.runCommand
|
|
338
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
339
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 })
|
|
340
|
+
.mockResolvedValueOnce({
|
|
341
|
+
stdout: '> checkpoint now',
|
|
342
|
+
stderr: '',
|
|
343
|
+
exitCode: 0,
|
|
344
|
+
})
|
|
345
|
+
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
|
346
|
+
const repository = new NodeTmuxSessionRepository(runner, '/proc', 0);
|
|
347
|
+
|
|
348
|
+
await repository.sendKeys('session-a', 'checkpoint now');
|
|
349
|
+
|
|
350
|
+
expect(runner.runCommand.mock.calls).toHaveLength(4);
|
|
351
|
+
expect(runner.runCommand.mock.calls[3][1]).toEqual([
|
|
352
|
+
'send-keys',
|
|
353
|
+
'-t',
|
|
354
|
+
'session-a',
|
|
355
|
+
'Enter',
|
|
356
|
+
]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('throws when sending the literal text fails', async () => {
|
|
360
|
+
const runner = createMockRunner();
|
|
361
|
+
runner.runCommand.mockResolvedValue({
|
|
362
|
+
stdout: '',
|
|
363
|
+
stderr: 'no session',
|
|
364
|
+
exitCode: 1,
|
|
365
|
+
});
|
|
366
|
+
const repository = new NodeTmuxSessionRepository(runner, '/proc', 0);
|
|
367
|
+
|
|
368
|
+
await expect(repository.sendKeys('session-a', 'text')).rejects.toThrow(
|
|
369
|
+
'Failed to send keys to tmux session "session-a"',
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('launchBareNameLeaderSession', () => {
|
|
375
|
+
it('creates a detached tmux session running cl for the bare name', async () => {
|
|
376
|
+
const runner = createMockRunner();
|
|
377
|
+
runner.runCommand.mockResolvedValue({
|
|
378
|
+
stdout: '',
|
|
379
|
+
stderr: '',
|
|
380
|
+
exitCode: 0,
|
|
381
|
+
});
|
|
382
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
383
|
+
|
|
384
|
+
await repository.launchBareNameLeaderSession('app');
|
|
385
|
+
|
|
386
|
+
expect(runner.runCommand.mock.calls[0][0]).toBe('tmux');
|
|
387
|
+
expect(runner.runCommand.mock.calls[0][1]).toEqual([
|
|
388
|
+
'new-session',
|
|
389
|
+
'-d',
|
|
390
|
+
'-s',
|
|
391
|
+
'app',
|
|
392
|
+
'bash',
|
|
393
|
+
'-lc',
|
|
394
|
+
"cl 'app'; exec /bin/bash",
|
|
395
|
+
]);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('normalizes dots and colons in the session name', async () => {
|
|
399
|
+
const runner = createMockRunner();
|
|
400
|
+
runner.runCommand.mockResolvedValue({
|
|
401
|
+
stdout: '',
|
|
402
|
+
stderr: '',
|
|
403
|
+
exitCode: 0,
|
|
404
|
+
});
|
|
405
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
406
|
+
|
|
407
|
+
await repository.launchBareNameLeaderSession('tdpm-cli.v2');
|
|
408
|
+
|
|
409
|
+
expect(runner.runCommand.mock.calls[0][1][3]).toBe('tdpm-cli_v2');
|
|
410
|
+
expect(runner.runCommand.mock.calls[0][1][6]).toBe(
|
|
411
|
+
"cl 'tdpm-cli.v2'; exec /bin/bash",
|
|
412
|
+
);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('throws when tmux fails to create the session', async () => {
|
|
416
|
+
const runner = createMockRunner();
|
|
417
|
+
runner.runCommand.mockResolvedValue({
|
|
418
|
+
stdout: '',
|
|
419
|
+
stderr: 'duplicate session',
|
|
420
|
+
exitCode: 1,
|
|
421
|
+
});
|
|
422
|
+
const repository = new NodeTmuxSessionRepository(runner);
|
|
423
|
+
|
|
424
|
+
await expect(
|
|
425
|
+
repository.launchBareNameLeaderSession('app'),
|
|
426
|
+
).rejects.toThrow('Failed to relaunch bare-name leader session "app"');
|
|
427
|
+
});
|
|
428
|
+
});
|
|
297
429
|
});
|
|
@@ -6,10 +6,18 @@ import { LiveTmuxSession } from '../../domain/entities/LiveTmuxSession';
|
|
|
6
6
|
import { clSessionScopeUnitName } from './clSessionScopeUnitName';
|
|
7
7
|
import { clSessionScopeUnitNameFromCgroupContent } from './clSessionScopeUnitNameFromCgroupContent';
|
|
8
8
|
|
|
9
|
+
const DEFAULT_SEND_KEYS_SUBMIT_DELAY_MS = 1000;
|
|
10
|
+
const SEND_KEYS_COMPOSER_PROBE_LENGTH = 40;
|
|
11
|
+
const SEND_KEYS_COMPOSER_TAIL_LINES = 8;
|
|
12
|
+
|
|
13
|
+
const shellSingleQuote = (value: string): string =>
|
|
14
|
+
`'${value.replace(/'/g, `'\\''`)}'`;
|
|
15
|
+
|
|
9
16
|
export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
10
17
|
constructor(
|
|
11
18
|
private readonly localCommandRunner: LocalCommandRunner,
|
|
12
19
|
private readonly procDirectory: string = '/proc',
|
|
20
|
+
private readonly submitDelayMilliseconds: number = DEFAULT_SEND_KEYS_SUBMIT_DELAY_MS,
|
|
13
21
|
) {}
|
|
14
22
|
|
|
15
23
|
listLiveSessionNames = async (): Promise<string[]> => {
|
|
@@ -134,4 +142,96 @@ export class NodeTmuxSessionRepository implements TmuxSessionRepository {
|
|
|
134
142
|
);
|
|
135
143
|
}
|
|
136
144
|
};
|
|
145
|
+
|
|
146
|
+
sendKeys = async (
|
|
147
|
+
sessionName: string,
|
|
148
|
+
literalText: string,
|
|
149
|
+
): Promise<void> => {
|
|
150
|
+
const literalResult = await this.localCommandRunner.runCommand('tmux', [
|
|
151
|
+
'send-keys',
|
|
152
|
+
'-t',
|
|
153
|
+
sessionName,
|
|
154
|
+
'-l',
|
|
155
|
+
literalText,
|
|
156
|
+
]);
|
|
157
|
+
if (literalResult.exitCode !== 0) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`Failed to send keys to tmux session "${sessionName}": exit code ${literalResult.exitCode}${
|
|
160
|
+
literalResult.stderr ? `: ${literalResult.stderr}` : ''
|
|
161
|
+
}`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
await this.delaySubmit();
|
|
165
|
+
await this.sendEnter(sessionName);
|
|
166
|
+
if (await this.messageStillInComposer(sessionName, literalText)) {
|
|
167
|
+
await this.delaySubmit();
|
|
168
|
+
await this.sendEnter(sessionName);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
launchBareNameLeaderSession = async (name: string): Promise<void> => {
|
|
173
|
+
const sessionName = name.replace(/[.:]/g, '_');
|
|
174
|
+
const leaderCommand = `cl ${shellSingleQuote(name)}; exec /bin/bash`;
|
|
175
|
+
const { stderr, exitCode } = await this.localCommandRunner.runCommand(
|
|
176
|
+
'tmux',
|
|
177
|
+
['new-session', '-d', '-s', sessionName, 'bash', '-lc', leaderCommand],
|
|
178
|
+
);
|
|
179
|
+
if (exitCode !== 0) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
`Failed to relaunch bare-name leader session "${sessionName}": exit code ${exitCode}${
|
|
182
|
+
stderr ? `: ${stderr}` : ''
|
|
183
|
+
}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
private sendEnter = async (sessionName: string): Promise<void> => {
|
|
189
|
+
const enterResult = await this.localCommandRunner.runCommand('tmux', [
|
|
190
|
+
'send-keys',
|
|
191
|
+
'-t',
|
|
192
|
+
sessionName,
|
|
193
|
+
'Enter',
|
|
194
|
+
]);
|
|
195
|
+
if (enterResult.exitCode !== 0) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Failed to send Enter to tmux session "${sessionName}": exit code ${enterResult.exitCode}${
|
|
198
|
+
enterResult.stderr ? `: ${enterResult.stderr}` : ''
|
|
199
|
+
}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
private messageStillInComposer = async (
|
|
205
|
+
sessionName: string,
|
|
206
|
+
literalText: string,
|
|
207
|
+
): Promise<boolean> => {
|
|
208
|
+
const probe = literalText
|
|
209
|
+
.trim()
|
|
210
|
+
.split('\n', 1)[0]
|
|
211
|
+
.slice(0, SEND_KEYS_COMPOSER_PROBE_LENGTH);
|
|
212
|
+
if (probe.length === 0) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const { stdout, exitCode } = await this.localCommandRunner.runCommand(
|
|
216
|
+
'tmux',
|
|
217
|
+
['capture-pane', '-p', '-t', sessionName],
|
|
218
|
+
);
|
|
219
|
+
if (exitCode !== 0) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
const tail = stdout
|
|
223
|
+
.split('\n')
|
|
224
|
+
.slice(-SEND_KEYS_COMPOSER_TAIL_LINES)
|
|
225
|
+
.join('\n');
|
|
226
|
+
return tail.includes(probe);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
private delaySubmit = async (): Promise<void> => {
|
|
230
|
+
if (this.submitDelayMilliseconds <= 0) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
await new Promise((resolve) =>
|
|
234
|
+
setTimeout(resolve, this.submitDelayMilliseconds),
|
|
235
|
+
);
|
|
236
|
+
};
|
|
137
237
|
}
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { ProcClaudeHandoverSessionRepository } from './ProcClaudeHandoverSessionRepository';
|
|
5
|
+
|
|
6
|
+
type FakeProcess = {
|
|
7
|
+
pid: number;
|
|
8
|
+
comm: string;
|
|
9
|
+
cmdline: string;
|
|
10
|
+
environ: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const issueUrl = 'https://github.com/HiromiShikata/example/issues/1';
|
|
14
|
+
const argv = (...parts: string[]): string => `${parts.join('\0')}\0`;
|
|
15
|
+
|
|
16
|
+
describe('ProcClaudeHandoverSessionRepository', () => {
|
|
17
|
+
let procDirectory: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
procDirectory = fs.mkdtempSync(
|
|
21
|
+
path.join(os.tmpdir(), 'fake-proc-handover-'),
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const writeProcess = (fakeProcess: FakeProcess): void => {
|
|
30
|
+
const processDirectory = path.join(procDirectory, String(fakeProcess.pid));
|
|
31
|
+
fs.mkdirSync(processDirectory, { recursive: true });
|
|
32
|
+
fs.writeFileSync(
|
|
33
|
+
path.join(processDirectory, 'comm'),
|
|
34
|
+
`${fakeProcess.comm}\n`,
|
|
35
|
+
);
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
path.join(processDirectory, 'cmdline'),
|
|
38
|
+
fakeProcess.cmdline,
|
|
39
|
+
);
|
|
40
|
+
const environBuffer = Object.entries(fakeProcess.environ)
|
|
41
|
+
.map(([key, value]) => `${key}=${value}\0`)
|
|
42
|
+
.join('');
|
|
43
|
+
fs.writeFileSync(path.join(processDirectory, 'environ'), environBuffer);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
it('classifies an issue-URL leader from a cl --name url launch', () => {
|
|
47
|
+
writeProcess({
|
|
48
|
+
pid: 201,
|
|
49
|
+
comm: 'claude',
|
|
50
|
+
cmdline: argv('claude', '--model', 'opus', '--name', issueUrl),
|
|
51
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-a' },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const repository = new ProcClaudeHandoverSessionRepository(procDirectory);
|
|
55
|
+
|
|
56
|
+
expect(repository.listHandoverSessions()).toEqual([
|
|
57
|
+
{
|
|
58
|
+
kind: 'issueUrlLeader',
|
|
59
|
+
pid: 201,
|
|
60
|
+
token: 'token-a',
|
|
61
|
+
sessionName: issueUrl.replace(/[.:]/g, '_'),
|
|
62
|
+
name: issueUrl,
|
|
63
|
+
issueUrl,
|
|
64
|
+
},
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('classifies a bare-name resident leader from a cl --name bare launch', () => {
|
|
69
|
+
writeProcess({
|
|
70
|
+
pid: 202,
|
|
71
|
+
comm: 'claude',
|
|
72
|
+
cmdline: argv('claude', '--name', 'app'),
|
|
73
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-b' },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const repository = new ProcClaudeHandoverSessionRepository(procDirectory);
|
|
77
|
+
|
|
78
|
+
expect(repository.listHandoverSessions()).toEqual([
|
|
79
|
+
{
|
|
80
|
+
kind: 'bareNameLeader',
|
|
81
|
+
pid: 202,
|
|
82
|
+
token: 'token-b',
|
|
83
|
+
sessionName: 'app',
|
|
84
|
+
name: 'app',
|
|
85
|
+
issueUrl: null,
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('classifies an impl subagent from a -p task string containing an issue URL', () => {
|
|
91
|
+
writeProcess({
|
|
92
|
+
pid: 203,
|
|
93
|
+
comm: 'claude',
|
|
94
|
+
cmdline: argv(
|
|
95
|
+
'claude',
|
|
96
|
+
'--verbose',
|
|
97
|
+
'-p',
|
|
98
|
+
`Take ownership of ${issueUrl} and finish it`,
|
|
99
|
+
),
|
|
100
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-c' },
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const repository = new ProcClaudeHandoverSessionRepository(procDirectory);
|
|
104
|
+
|
|
105
|
+
expect(repository.listHandoverSessions()).toEqual([
|
|
106
|
+
{
|
|
107
|
+
kind: 'implSubagent',
|
|
108
|
+
pid: 203,
|
|
109
|
+
token: 'token-c',
|
|
110
|
+
sessionName: null,
|
|
111
|
+
name: null,
|
|
112
|
+
issueUrl,
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('normalizes dots and colons in a bare name to the tmux session name', () => {
|
|
118
|
+
writeProcess({
|
|
119
|
+
pid: 204,
|
|
120
|
+
comm: 'claude',
|
|
121
|
+
cmdline: argv('claude', '--name', 'tdpm-cli.v2'),
|
|
122
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-d' },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const repository = new ProcClaudeHandoverSessionRepository(procDirectory);
|
|
126
|
+
|
|
127
|
+
expect(repository.listHandoverSessions()[0].sessionName).toBe(
|
|
128
|
+
'tdpm-cli_v2',
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('ignores a non-claude process even when it has a token and a name', () => {
|
|
133
|
+
writeProcess({
|
|
134
|
+
pid: 205,
|
|
135
|
+
comm: 'node',
|
|
136
|
+
cmdline: argv('node', '--name', 'app'),
|
|
137
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-e' },
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const repository = new ProcClaudeHandoverSessionRepository(procDirectory);
|
|
141
|
+
|
|
142
|
+
expect(repository.listHandoverSessions()).toEqual([]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('ignores a claude process without an OAuth token', () => {
|
|
146
|
+
writeProcess({
|
|
147
|
+
pid: 206,
|
|
148
|
+
comm: 'claude',
|
|
149
|
+
cmdline: argv('claude', '--name', 'app'),
|
|
150
|
+
environ: {},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const repository = new ProcClaudeHandoverSessionRepository(procDirectory);
|
|
154
|
+
|
|
155
|
+
expect(repository.listHandoverSessions()).toEqual([]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('ignores a claude process with neither a name nor an issue URL', () => {
|
|
159
|
+
writeProcess({
|
|
160
|
+
pid: 207,
|
|
161
|
+
comm: 'claude',
|
|
162
|
+
cmdline: argv('claude', '--model', 'opus'),
|
|
163
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-f' },
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const repository = new ProcClaudeHandoverSessionRepository(procDirectory);
|
|
167
|
+
|
|
168
|
+
expect(repository.listHandoverSessions()).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('accepts a claude-agent comm', () => {
|
|
172
|
+
writeProcess({
|
|
173
|
+
pid: 208,
|
|
174
|
+
comm: 'claude-agent',
|
|
175
|
+
cmdline: argv('claude-agent', '--name', 'secretary'),
|
|
176
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-g' },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const repository = new ProcClaudeHandoverSessionRepository(procDirectory);
|
|
180
|
+
|
|
181
|
+
expect(repository.listHandoverSessions()[0]).toMatchObject({
|
|
182
|
+
kind: 'bareNameLeader',
|
|
183
|
+
name: 'secretary',
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns an empty list when the proc directory cannot be read', () => {
|
|
188
|
+
const repository = new ProcClaudeHandoverSessionRepository(
|
|
189
|
+
path.join(procDirectory, 'does-not-exist'),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(repository.listHandoverSessions()).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
ClaudeHandoverSession,
|
|
5
|
+
ClaudeHandoverSessionKind,
|
|
6
|
+
} from '../../domain/entities/ClaudeHandoverSession';
|
|
7
|
+
import { ClaudeHandoverSessionRepository } from '../../domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_PROC_DIRECTORY = '/proc';
|
|
10
|
+
const OAUTH_TOKEN_ENVIRON_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
11
|
+
const NAME_ARGUMENT = '--name';
|
|
12
|
+
const NAME_ARGUMENT_PREFIX = '--name=';
|
|
13
|
+
const PRINT_ARGUMENTS = ['-p', '--print'];
|
|
14
|
+
const PRINT_ARGUMENT_PREFIXES = ['-p=', '--print='];
|
|
15
|
+
const CLAUDE_COMM_PATTERN = /^claude(-agent)?$/;
|
|
16
|
+
const ISSUE_OR_PR_URL_PATTERN =
|
|
17
|
+
/https:\/\/github\.com\/[^\s\0]+?\/(?:issues|pull)\/\d+/;
|
|
18
|
+
|
|
19
|
+
const normalizeName = (value: string): string => value.replace(/[.:]/g, '_');
|
|
20
|
+
|
|
21
|
+
const isIssueUrl = (value: string): boolean =>
|
|
22
|
+
value.startsWith('http://') || value.startsWith('https://');
|
|
23
|
+
|
|
24
|
+
const extractIssueUrlFromText = (text: string): string | null => {
|
|
25
|
+
const match = text.match(ISSUE_OR_PR_URL_PATTERN);
|
|
26
|
+
return match ? match[0] : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const parseName = (commandArguments: string[]): string | null => {
|
|
30
|
+
for (let index = 0; index < commandArguments.length; index += 1) {
|
|
31
|
+
const argument = commandArguments[index];
|
|
32
|
+
if (argument === NAME_ARGUMENT && index + 1 < commandArguments.length) {
|
|
33
|
+
const value = commandArguments[index + 1];
|
|
34
|
+
return value.length > 0 ? value : null;
|
|
35
|
+
}
|
|
36
|
+
if (argument.startsWith(NAME_ARGUMENT_PREFIX)) {
|
|
37
|
+
const value = argument.slice(NAME_ARGUMENT_PREFIX.length);
|
|
38
|
+
return value.length > 0 ? value : null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const parseIssueUrlFromPrint = (commandArguments: string[]): string | null => {
|
|
45
|
+
for (let index = 0; index < commandArguments.length; index += 1) {
|
|
46
|
+
const argument = commandArguments[index];
|
|
47
|
+
if (
|
|
48
|
+
PRINT_ARGUMENTS.includes(argument) &&
|
|
49
|
+
index + 1 < commandArguments.length
|
|
50
|
+
) {
|
|
51
|
+
const url = extractIssueUrlFromText(commandArguments[index + 1]);
|
|
52
|
+
if (url !== null) {
|
|
53
|
+
return url;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const prefix of PRINT_ARGUMENT_PREFIXES) {
|
|
57
|
+
if (argument.startsWith(prefix)) {
|
|
58
|
+
const url = extractIssueUrlFromText(argument.slice(prefix.length));
|
|
59
|
+
if (url !== null) {
|
|
60
|
+
return url;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return extractIssueUrlFromText(commandArguments.join(' '));
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const classifySession = (
|
|
69
|
+
pid: number,
|
|
70
|
+
commandArguments: string[],
|
|
71
|
+
token: string | null,
|
|
72
|
+
): ClaudeHandoverSession | null => {
|
|
73
|
+
if (token === null || token.length === 0) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const name = parseName(commandArguments);
|
|
77
|
+
if (name !== null) {
|
|
78
|
+
const sessionName = normalizeName(name);
|
|
79
|
+
if (isIssueUrl(name)) {
|
|
80
|
+
const kind: ClaudeHandoverSessionKind = 'issueUrlLeader';
|
|
81
|
+
return {
|
|
82
|
+
kind,
|
|
83
|
+
pid,
|
|
84
|
+
token,
|
|
85
|
+
sessionName,
|
|
86
|
+
name,
|
|
87
|
+
issueUrl: extractIssueUrlFromText(name),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const kind: ClaudeHandoverSessionKind = 'bareNameLeader';
|
|
91
|
+
return { kind, pid, token, sessionName, name, issueUrl: null };
|
|
92
|
+
}
|
|
93
|
+
const issueUrl = parseIssueUrlFromPrint(commandArguments);
|
|
94
|
+
if (issueUrl === null) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
kind: 'implSubagent',
|
|
99
|
+
pid,
|
|
100
|
+
token,
|
|
101
|
+
sessionName: null,
|
|
102
|
+
name: null,
|
|
103
|
+
issueUrl,
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export class ProcClaudeHandoverSessionRepository implements ClaudeHandoverSessionRepository {
|
|
108
|
+
constructor(
|
|
109
|
+
private readonly procDirectory: string = DEFAULT_PROC_DIRECTORY,
|
|
110
|
+
) {}
|
|
111
|
+
|
|
112
|
+
listHandoverSessions = (): ClaudeHandoverSession[] => {
|
|
113
|
+
const sessions: ClaudeHandoverSession[] = [];
|
|
114
|
+
for (const processIdDirectory of this.listProcessIdDirectories()) {
|
|
115
|
+
const session = this.readHandoverSession(processIdDirectory);
|
|
116
|
+
if (session !== null) {
|
|
117
|
+
sessions.push(session);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return sessions;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
private listProcessIdDirectories = (): string[] => {
|
|
124
|
+
let entries: string[];
|
|
125
|
+
try {
|
|
126
|
+
entries = fs.readdirSync(this.procDirectory);
|
|
127
|
+
} catch {
|
|
128
|
+
return [];
|
|
129
|
+
}
|
|
130
|
+
return entries.filter((entry) => /^\d+$/.test(entry));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
private readHandoverSession = (
|
|
134
|
+
processIdDirectory: string,
|
|
135
|
+
): ClaudeHandoverSession | null => {
|
|
136
|
+
const comm = this.readComm(processIdDirectory);
|
|
137
|
+
if (comm === null || !CLAUDE_COMM_PATTERN.test(comm)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const commandArguments = this.readCommandArguments(processIdDirectory);
|
|
141
|
+
if (commandArguments === null) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
const environ = this.readEnviron(processIdDirectory);
|
|
145
|
+
if (environ === null) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const token = environ.get(OAUTH_TOKEN_ENVIRON_KEY) ?? null;
|
|
149
|
+
return classifySession(Number(processIdDirectory), commandArguments, token);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
private readComm = (processIdDirectory: string): string | null => {
|
|
153
|
+
const commPath = path.join(this.procDirectory, processIdDirectory, 'comm');
|
|
154
|
+
try {
|
|
155
|
+
return fs.readFileSync(commPath, 'utf8').trim();
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
private readCommandArguments = (
|
|
162
|
+
processIdDirectory: string,
|
|
163
|
+
): string[] | null => {
|
|
164
|
+
const cmdlinePath = path.join(
|
|
165
|
+
this.procDirectory,
|
|
166
|
+
processIdDirectory,
|
|
167
|
+
'cmdline',
|
|
168
|
+
);
|
|
169
|
+
let raw: string;
|
|
170
|
+
try {
|
|
171
|
+
raw = fs.readFileSync(cmdlinePath, 'utf8');
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
return raw.split('\0').filter((argument) => argument.length > 0);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
private readEnviron = (
|
|
179
|
+
processIdDirectory: string,
|
|
180
|
+
): Map<string, string> | null => {
|
|
181
|
+
const environPath = path.join(
|
|
182
|
+
this.procDirectory,
|
|
183
|
+
processIdDirectory,
|
|
184
|
+
'environ',
|
|
185
|
+
);
|
|
186
|
+
let raw: string;
|
|
187
|
+
try {
|
|
188
|
+
raw = fs.readFileSync(environPath, 'utf8');
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
const environ = new Map<string, string>();
|
|
193
|
+
for (const entry of raw.split('\0')) {
|
|
194
|
+
if (entry.length === 0) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const separatorIndex = entry.indexOf('=');
|
|
198
|
+
if (separatorIndex <= 0) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
environ.set(
|
|
202
|
+
entry.slice(0, separatorIndex),
|
|
203
|
+
entry.slice(separatorIndex + 1),
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
return environ;
|
|
207
|
+
};
|
|
208
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TokenModelWeeklyLimit,
|
|
3
|
+
TokenRateLimitSnapshot,
|
|
4
|
+
TokenRateLimitSnapshotRepository,
|
|
5
|
+
} from '../../domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository';
|
|
6
|
+
import { loadTokenEntries } from '../proxy/TokenListLoader';
|
|
7
|
+
import { readRateLimit, cacheDir } from '../proxy/RateLimitCache';
|
|
8
|
+
|
|
9
|
+
export class RateLimitSnapshotRepository implements TokenRateLimitSnapshotRepository {
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly tokenListJsonPath: string,
|
|
12
|
+
private readonly baseDir: string = cacheDir(),
|
|
13
|
+
) {}
|
|
14
|
+
|
|
15
|
+
listSnapshots = (): TokenRateLimitSnapshot[] => {
|
|
16
|
+
const entries = loadTokenEntries(this.tokenListJsonPath);
|
|
17
|
+
if (entries === null) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const snapshots: TokenRateLimitSnapshot[] = [];
|
|
21
|
+
for (const { name, token } of entries) {
|
|
22
|
+
const snapshot = readRateLimit(token, this.baseDir);
|
|
23
|
+
if (snapshot === null) {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
snapshots.push({
|
|
27
|
+
token,
|
|
28
|
+
name,
|
|
29
|
+
fiveHourUtilization: snapshot.fiveHourUtilization,
|
|
30
|
+
fiveHourReset: snapshot.fiveHourReset,
|
|
31
|
+
sevenDayUtilization: snapshot.sevenDayUtilization,
|
|
32
|
+
sevenDayReset: snapshot.sevenDayReset,
|
|
33
|
+
blocked: snapshot.blocked,
|
|
34
|
+
rejected: snapshot.rejected,
|
|
35
|
+
blockedUntilEpoch: snapshot.blockedUntilEpoch,
|
|
36
|
+
modelWeeklyLimits: this.toModelWeeklyLimits(snapshot.modelWeeklyLimits),
|
|
37
|
+
lastUpdatedEpoch: snapshot.lastUpdatedEpoch,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return snapshots;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
private toModelWeeklyLimits = (
|
|
44
|
+
modelWeeklyLimits: Record<string, { rejected: boolean; resetsAt: number }>,
|
|
45
|
+
): TokenModelWeeklyLimit[] =>
|
|
46
|
+
Object.values(modelWeeklyLimits).map((limit) => ({
|
|
47
|
+
rejected: limit.rejected,
|
|
48
|
+
resetsAt: limit.resetsAt,
|
|
49
|
+
}));
|
|
50
|
+
}
|