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.
Files changed (109) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -2
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +50 -2
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +5 -2
  6. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js +31 -0
  8. package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js.map +1 -0
  9. package/bin/adapter/repositories/FileHandoverStateRepository.js +99 -0
  10. package/bin/adapter/repositories/FileHandoverStateRepository.js.map +1 -0
  11. package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js +125 -0
  12. package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js.map +1 -0
  13. package/bin/adapter/repositories/NodeProcessSignalRepository.js +44 -0
  14. package/bin/adapter/repositories/NodeProcessSignalRepository.js.map +1 -0
  15. package/bin/adapter/repositories/NodeTmuxSessionRepository.js +67 -1
  16. package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
  17. package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js +206 -0
  18. package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js.map +1 -0
  19. package/bin/adapter/repositories/RateLimitSnapshotRepository.js +44 -0
  20. package/bin/adapter/repositories/RateLimitSnapshotRepository.js.map +1 -0
  21. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +4 -1
  22. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -1
  23. package/bin/domain/entities/ClaudeHandoverSession.js +3 -0
  24. package/bin/domain/entities/ClaudeHandoverSession.js.map +1 -0
  25. package/bin/domain/entities/TokenExhaustionHandoverState.js +3 -0
  26. package/bin/domain/entities/TokenExhaustionHandoverState.js.map +1 -0
  27. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +48 -1
  28. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  29. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js +255 -0
  30. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js.map +1 -0
  31. package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js +3 -0
  32. package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js.map +1 -0
  33. package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js +3 -0
  34. package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js.map +1 -0
  35. package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js +3 -0
  36. package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js.map +1 -0
  37. package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js +3 -0
  38. package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js.map +1 -0
  39. package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js +3 -0
  40. package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js.map +1 -0
  41. package/package.json +1 -1
  42. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +37 -0
  43. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +30 -0
  44. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +8 -0
  45. package/src/adapter/entry-points/handlers/tokenExhaustionHandover.ts +79 -0
  46. package/src/adapter/repositories/FileHandoverStateRepository.test.ts +114 -0
  47. package/src/adapter/repositories/FileHandoverStateRepository.ts +76 -0
  48. package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.test.ts +127 -0
  49. package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.ts +108 -0
  50. package/src/adapter/repositories/NodeProcessSignalRepository.test.ts +82 -0
  51. package/src/adapter/repositories/NodeProcessSignalRepository.ts +47 -0
  52. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
  53. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +100 -0
  54. package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.test.ts +194 -0
  55. package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.ts +208 -0
  56. package/src/adapter/repositories/RateLimitSnapshotRepository.ts +50 -0
  57. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +23 -2
  58. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +5 -1
  59. package/src/domain/entities/ClaudeHandoverSession.ts +11 -0
  60. package/src/domain/entities/TokenExhaustionHandoverState.ts +8 -0
  61. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +218 -8
  62. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +60 -0
  63. package/src/domain/usecases/TokenExhaustionHandoverUseCase.test.ts +696 -0
  64. package/src/domain/usecases/TokenExhaustionHandoverUseCase.ts +413 -0
  65. package/src/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.ts +5 -0
  66. package/src/domain/usecases/adapter-interfaces/ProcessSignalRepository.ts +5 -0
  67. package/src/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.ts +10 -0
  68. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +2 -0
  69. package/src/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.ts +6 -0
  70. package/src/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.ts +22 -0
  71. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +2 -0
  72. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  73. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +1 -0
  74. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  75. package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts +14 -0
  76. package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts.map +1 -0
  77. package/types/adapter/repositories/FileHandoverStateRepository.d.ts +11 -0
  78. package/types/adapter/repositories/FileHandoverStateRepository.d.ts.map +1 -0
  79. package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts +16 -0
  80. package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts.map +1 -0
  81. package/types/adapter/repositories/NodeProcessSignalRepository.d.ts +8 -0
  82. package/types/adapter/repositories/NodeProcessSignalRepository.d.ts.map +1 -0
  83. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +7 -1
  84. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
  85. package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts +13 -0
  86. package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts.map +1 -0
  87. package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts +9 -0
  88. package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts.map +1 -0
  89. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -1
  90. package/types/domain/entities/ClaudeHandoverSession.d.ts +10 -0
  91. package/types/domain/entities/ClaudeHandoverSession.d.ts.map +1 -0
  92. package/types/domain/entities/TokenExhaustionHandoverState.d.ts +8 -0
  93. package/types/domain/entities/TokenExhaustionHandoverState.d.ts.map +1 -0
  94. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +3 -1
  95. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  96. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts +50 -0
  97. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts.map +1 -0
  98. package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts +5 -0
  99. package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts.map +1 -0
  100. package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts +6 -0
  101. package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts.map +1 -0
  102. package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts +11 -0
  103. package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts.map +1 -0
  104. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +2 -0
  105. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
  106. package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts +6 -0
  107. package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts.map +1 -0
  108. package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts +21 -0
  109. package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts.map +1 -0
@@ -0,0 +1,79 @@
1
+ import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
2
+ import {
3
+ TokenExhaustionHandoverUseCase,
4
+ DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE,
5
+ DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE_BARE_NAME_LEADER,
6
+ DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS,
7
+ } from '../../../domain/usecases/TokenExhaustionHandoverUseCase';
8
+ import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
9
+ import { RateLimitSnapshotRepository } from '../../repositories/RateLimitSnapshotRepository';
10
+ import { ProcClaudeHandoverSessionRepository } from '../../repositories/ProcClaudeHandoverSessionRepository';
11
+ import { NodeProcessSignalRepository } from '../../repositories/NodeProcessSignalRepository';
12
+ import {
13
+ FileHandoverStateRepository,
14
+ defaultHandoverStateFilePath,
15
+ } from '../../repositories/FileHandoverStateRepository';
16
+
17
+ export type TokenExhaustionHandoverParams = {
18
+ enabled: boolean;
19
+ tokenListJsonPath: string | null;
20
+ handoverMessage?: string | null;
21
+ bareNameLeaderHandoverMessage?: string | null;
22
+ tokenRateLimitSnapshotBaseDir?: string | null;
23
+ gracePeriodSeconds?: number | null;
24
+ stateFilePath?: string | null;
25
+ localCommandRunner: LocalCommandRunner;
26
+ now: Date;
27
+ };
28
+
29
+ export const handleTokenExhaustionHandover = async (
30
+ params: TokenExhaustionHandoverParams,
31
+ ): Promise<void> => {
32
+ const {
33
+ enabled,
34
+ tokenListJsonPath,
35
+ handoverMessage,
36
+ bareNameLeaderHandoverMessage,
37
+ tokenRateLimitSnapshotBaseDir,
38
+ gracePeriodSeconds,
39
+ stateFilePath,
40
+ localCommandRunner,
41
+ now,
42
+ } = params;
43
+
44
+ if (tokenListJsonPath === null) {
45
+ console.log(
46
+ 'Token exhaustion handover: skipped (no claudeCodeOauthTokenListJsonPath configured).',
47
+ );
48
+ return;
49
+ }
50
+
51
+ const snapshotRepository = new RateLimitSnapshotRepository(
52
+ tokenListJsonPath,
53
+ tokenRateLimitSnapshotBaseDir ?? undefined,
54
+ );
55
+ const stateRepository = new FileHandoverStateRepository(
56
+ stateFilePath ?? defaultHandoverStateFilePath(),
57
+ );
58
+ const useCase = new TokenExhaustionHandoverUseCase(
59
+ new ProcClaudeHandoverSessionRepository(),
60
+ snapshotRepository,
61
+ new NodeTmuxSessionRepository(localCommandRunner),
62
+ new NodeProcessSignalRepository(),
63
+ );
64
+
65
+ const result = await useCase.run({
66
+ enabled,
67
+ issueUrlLeaderMessage:
68
+ handoverMessage ?? DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE,
69
+ bareNameLeaderMessage:
70
+ bareNameLeaderHandoverMessage ??
71
+ DEFAULT_TOKEN_EXHAUSTION_HANDOVER_MESSAGE_BARE_NAME_LEADER,
72
+ gracePeriodSeconds:
73
+ gracePeriodSeconds ?? DEFAULT_TOKEN_EXHAUSTION_GRACE_PERIOD_SECONDS,
74
+ state: stateRepository.load(),
75
+ now,
76
+ });
77
+
78
+ stateRepository.save(result.state);
79
+ };
@@ -0,0 +1,114 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import {
5
+ FileHandoverStateRepository,
6
+ defaultHandoverStateFilePath,
7
+ } from './FileHandoverStateRepository';
8
+
9
+ describe('FileHandoverStateRepository', () => {
10
+ let directory: string;
11
+ let filePath: string;
12
+
13
+ beforeEach(() => {
14
+ directory = fs.mkdtempSync(path.join(os.tmpdir(), 'handover-state-'));
15
+ filePath = path.join(directory, 'nested', 'state.json');
16
+ });
17
+
18
+ afterEach(() => {
19
+ fs.rmSync(directory, { recursive: true, force: true });
20
+ });
21
+
22
+ it('returns an empty state when the file does not exist', () => {
23
+ const repository = new FileHandoverStateRepository(filePath);
24
+
25
+ expect(repository.load()).toEqual({ entries: {} });
26
+ });
27
+
28
+ it('saves and reloads state, creating parent directories', () => {
29
+ const repository = new FileHandoverStateRepository(filePath);
30
+ const state = {
31
+ entries: {
32
+ 'session-a': { signaledAtEpoch: 1700000000, pid: 4242 },
33
+ },
34
+ };
35
+
36
+ repository.save(state);
37
+
38
+ expect(fs.existsSync(filePath)).toBe(true);
39
+ expect(new FileHandoverStateRepository(filePath).load()).toEqual(state);
40
+ });
41
+
42
+ it('writes atomically via a temporary file and leaves no temp file behind', () => {
43
+ const repository = new FileHandoverStateRepository(filePath);
44
+
45
+ repository.save({
46
+ entries: { 'session-a': { signaledAtEpoch: 1, pid: 2 } },
47
+ });
48
+
49
+ expect(fs.existsSync(`${filePath}.tmp`)).toBe(false);
50
+ });
51
+
52
+ it('returns an empty state when the file contains invalid JSON', () => {
53
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
54
+ fs.writeFileSync(filePath, 'not json');
55
+ const repository = new FileHandoverStateRepository(filePath);
56
+
57
+ expect(repository.load()).toEqual({ entries: {} });
58
+ });
59
+
60
+ it('drops entries with malformed values while keeping valid ones', () => {
61
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
62
+ fs.writeFileSync(
63
+ filePath,
64
+ JSON.stringify({
65
+ entries: {
66
+ valid: { signaledAtEpoch: 5, pid: 6 },
67
+ missingPid: { signaledAtEpoch: 5 },
68
+ notAnObject: 7,
69
+ },
70
+ }),
71
+ );
72
+ const repository = new FileHandoverStateRepository(filePath);
73
+
74
+ expect(repository.load()).toEqual({
75
+ entries: { valid: { signaledAtEpoch: 5, pid: 6 } },
76
+ });
77
+ });
78
+ });
79
+
80
+ describe('defaultHandoverStateFilePath', () => {
81
+ const originalXdgCacheHome = process.env.XDG_CACHE_HOME;
82
+
83
+ afterEach(() => {
84
+ if (originalXdgCacheHome === undefined) {
85
+ delete process.env.XDG_CACHE_HOME;
86
+ } else {
87
+ process.env.XDG_CACHE_HOME = originalXdgCacheHome;
88
+ }
89
+ });
90
+
91
+ it('uses a TDPM-native filename distinct from the standalone monitor state file', () => {
92
+ process.env.XDG_CACHE_HOME = '/custom/cache';
93
+
94
+ expect(defaultHandoverStateFilePath()).toBe(
95
+ '/custom/cache/tdpm/token-exhaustion-handover-state-tdpm-native.json',
96
+ );
97
+ expect(defaultHandoverStateFilePath()).not.toBe(
98
+ '/custom/cache/tdpm/token-exhaustion-handover-state.json',
99
+ );
100
+ });
101
+
102
+ it('falls back to ~/.cache when XDG_CACHE_HOME is unset', () => {
103
+ delete process.env.XDG_CACHE_HOME;
104
+
105
+ expect(defaultHandoverStateFilePath()).toBe(
106
+ path.join(
107
+ os.homedir(),
108
+ '.cache',
109
+ 'tdpm',
110
+ 'token-exhaustion-handover-state-tdpm-native.json',
111
+ ),
112
+ );
113
+ });
114
+ });
@@ -0,0 +1,76 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import {
5
+ TokenExhaustionHandoverState,
6
+ TokenExhaustionHandoverStateEntry,
7
+ } from '../../domain/entities/TokenExhaustionHandoverState';
8
+ import { TokenExhaustionHandoverStateRepository } from '../../domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository';
9
+
10
+ export const defaultHandoverStateFilePath = (): string => {
11
+ const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
12
+ return path.join(
13
+ base,
14
+ 'tdpm',
15
+ 'token-exhaustion-handover-state-tdpm-native.json',
16
+ );
17
+ };
18
+
19
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
20
+ value !== null && typeof value === 'object' && !Array.isArray(value);
21
+
22
+ export class FileHandoverStateRepository implements TokenExhaustionHandoverStateRepository {
23
+ constructor(
24
+ private readonly filePath: string = defaultHandoverStateFilePath(),
25
+ ) {}
26
+
27
+ load = (): TokenExhaustionHandoverState => {
28
+ let raw: string;
29
+ try {
30
+ raw = fs.readFileSync(this.filePath, 'utf8');
31
+ } catch {
32
+ return { entries: {} };
33
+ }
34
+ let parsed: unknown;
35
+ try {
36
+ parsed = JSON.parse(raw);
37
+ } catch {
38
+ return { entries: {} };
39
+ }
40
+ return { entries: this.parseEntries(parsed) };
41
+ };
42
+
43
+ save = (state: TokenExhaustionHandoverState): void => {
44
+ const directory = path.dirname(this.filePath);
45
+ if (!fs.existsSync(directory)) {
46
+ fs.mkdirSync(directory, { recursive: true });
47
+ }
48
+ const temporaryPath = `${this.filePath}.tmp`;
49
+ fs.writeFileSync(temporaryPath, JSON.stringify(state));
50
+ fs.renameSync(temporaryPath, this.filePath);
51
+ };
52
+
53
+ private parseEntries = (
54
+ parsed: unknown,
55
+ ): Record<string, TokenExhaustionHandoverStateEntry> => {
56
+ const entries: Record<string, TokenExhaustionHandoverStateEntry> = {};
57
+ if (!isRecord(parsed)) {
58
+ return entries;
59
+ }
60
+ const stored = parsed.entries;
61
+ if (!isRecord(stored)) {
62
+ return entries;
63
+ }
64
+ for (const [key, value] of Object.entries(stored)) {
65
+ if (!isRecord(value)) {
66
+ continue;
67
+ }
68
+ const signaledAtEpoch = value.signaledAtEpoch;
69
+ const pid = value.pid;
70
+ if (typeof signaledAtEpoch === 'number' && typeof pid === 'number') {
71
+ entries[key] = { signaledAtEpoch, pid };
72
+ }
73
+ }
74
+ return entries;
75
+ };
76
+ }
@@ -0,0 +1,127 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { FileSystemSilentSessionNotifiedStateRepository } from './FileSystemSilentSessionNotifiedStateRepository';
5
+
6
+ describe('FileSystemSilentSessionNotifiedStateRepository', () => {
7
+ let rootDirectory: string;
8
+ let stateFilePath: string;
9
+
10
+ beforeEach(() => {
11
+ rootDirectory = fs.mkdtempSync(
12
+ path.join(os.tmpdir(), 'silent-session-notified-state-'),
13
+ );
14
+ stateFilePath = path.join(rootDirectory, 'silent-session-notified.json');
15
+ });
16
+
17
+ afterEach(() => {
18
+ fs.rmSync(rootDirectory, { force: true, recursive: true });
19
+ });
20
+
21
+ const sessionAlpha = 'https://github.com/HiromiShikata/repo/issues/100';
22
+ const sessionBravo = 'https://github.com/HiromiShikata/repo/issues/200';
23
+
24
+ it('round-trips a saved notified set so the next cycle reads it back', async () => {
25
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
26
+ stateFilePath,
27
+ );
28
+ const now = new Date('2026-06-26T00:00:00Z');
29
+
30
+ await repository.saveNotifiedSessionNames({
31
+ sessionNames: [sessionAlpha, sessionBravo],
32
+ now,
33
+ });
34
+ const loaded = await repository.loadRecentNotifiedSessionNames({
35
+ now: new Date('2026-06-26T00:01:00Z'),
36
+ recencyWindowSeconds: 15 * 60,
37
+ });
38
+
39
+ expect(loaded).toEqual(new Set([sessionAlpha, sessionBravo]));
40
+ });
41
+
42
+ it('returns an empty set when no state file exists yet', async () => {
43
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
44
+ stateFilePath,
45
+ );
46
+
47
+ const loaded = await repository.loadRecentNotifiedSessionNames({
48
+ now: new Date('2026-06-26T00:00:00Z'),
49
+ recencyWindowSeconds: 15 * 60,
50
+ });
51
+
52
+ expect(loaded).toEqual(new Set<string>());
53
+ });
54
+
55
+ it('excludes an entry older than the recency window when loading', async () => {
56
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
57
+ stateFilePath,
58
+ );
59
+
60
+ await repository.saveNotifiedSessionNames({
61
+ sessionNames: [sessionAlpha],
62
+ now: new Date('2026-06-26T00:00:00Z'),
63
+ });
64
+ const loaded = await repository.loadRecentNotifiedSessionNames({
65
+ now: new Date('2026-06-26T00:20:00Z'),
66
+ recencyWindowSeconds: 15 * 60,
67
+ });
68
+
69
+ expect(loaded).toEqual(new Set<string>());
70
+ });
71
+
72
+ it('overwrites the latch on save so a pruned session no longer lingers', async () => {
73
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
74
+ stateFilePath,
75
+ );
76
+
77
+ await repository.saveNotifiedSessionNames({
78
+ sessionNames: [sessionAlpha, sessionBravo],
79
+ now: new Date('2026-06-26T00:00:00Z'),
80
+ });
81
+ await repository.saveNotifiedSessionNames({
82
+ sessionNames: [sessionAlpha],
83
+ now: new Date('2026-06-26T00:01:30Z'),
84
+ });
85
+ const loaded = await repository.loadRecentNotifiedSessionNames({
86
+ now: new Date('2026-06-26T00:02:00Z'),
87
+ recencyWindowSeconds: 15 * 60,
88
+ });
89
+
90
+ expect(loaded).toEqual(new Set([sessionAlpha]));
91
+ });
92
+
93
+ it('refreshes the recorded timestamp on each save so a continuously latched session stays within the window', async () => {
94
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
95
+ stateFilePath,
96
+ );
97
+
98
+ await repository.saveNotifiedSessionNames({
99
+ sessionNames: [sessionAlpha],
100
+ now: new Date('2026-06-26T00:00:00Z'),
101
+ });
102
+ await repository.saveNotifiedSessionNames({
103
+ sessionNames: [sessionAlpha],
104
+ now: new Date('2026-06-26T00:14:00Z'),
105
+ });
106
+ const loaded = await repository.loadRecentNotifiedSessionNames({
107
+ now: new Date('2026-06-26T00:20:00Z'),
108
+ recencyWindowSeconds: 15 * 60,
109
+ });
110
+
111
+ expect(loaded).toEqual(new Set([sessionAlpha]));
112
+ });
113
+
114
+ it('returns an empty set when the state file is not valid JSON', async () => {
115
+ fs.writeFileSync(stateFilePath, 'not json');
116
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
117
+ stateFilePath,
118
+ );
119
+
120
+ const loaded = await repository.loadRecentNotifiedSessionNames({
121
+ now: new Date('2026-06-26T00:00:00Z'),
122
+ recencyWindowSeconds: 15 * 60,
123
+ });
124
+
125
+ expect(loaded).toEqual(new Set<string>());
126
+ });
127
+ });
@@ -0,0 +1,108 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { SilentSessionNotifiedStateRepository } from '../../domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository';
5
+
6
+ type StoredNotifiedEntry = {
7
+ sessionName: string;
8
+ recordedEpochSeconds: number;
9
+ };
10
+
11
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
12
+ typeof value === 'object' && value !== null && !Array.isArray(value);
13
+
14
+ const defaultStateFilePath = (): string => {
15
+ const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
16
+ return path.join(base, 'tdpm', 'silent-session-notified.json');
17
+ };
18
+
19
+ // Persists the fire-once latch: the set of session names that have already
20
+ // received the current silent-episode reminder. The use case re-writes the
21
+ // full latch each cycle with the current timestamp, keeping a session latched
22
+ // for as long as it stays a reminder candidate (a continuous silent episode
23
+ // produces exactly one reminder), and prunes a session the moment it stops
24
+ // being a candidate so a later re-qualification fires again. The save
25
+ // intentionally OVERWRITES rather than merges: the use case supplies the
26
+ // complete latch to persist, so a pruned session must not linger.
27
+ export class FileSystemSilentSessionNotifiedStateRepository implements SilentSessionNotifiedStateRepository {
28
+ constructor(
29
+ private readonly stateFilePath: string = defaultStateFilePath(),
30
+ ) {}
31
+
32
+ loadRecentNotifiedSessionNames = async (params: {
33
+ now: Date;
34
+ recencyWindowSeconds: number;
35
+ }): Promise<Set<string>> => {
36
+ const nowEpochSeconds = Math.floor(params.now.getTime() / 1000);
37
+ const oldestAllowedEpochSeconds =
38
+ nowEpochSeconds - params.recencyWindowSeconds;
39
+ const recentSessionNames = new Set<string>();
40
+ for (const entry of this.readNotifiedEntries()) {
41
+ if (entry.recordedEpochSeconds >= oldestAllowedEpochSeconds) {
42
+ recentSessionNames.add(entry.sessionName);
43
+ }
44
+ }
45
+ return recentSessionNames;
46
+ };
47
+
48
+ saveNotifiedSessionNames = async (params: {
49
+ sessionNames: string[];
50
+ now: Date;
51
+ }): Promise<void> => {
52
+ const recordedEpochSeconds = Math.floor(params.now.getTime() / 1000);
53
+ const notifiedBySessionName = new Map<string, StoredNotifiedEntry>();
54
+ for (const sessionName of params.sessionNames) {
55
+ notifiedBySessionName.set(sessionName, {
56
+ sessionName,
57
+ recordedEpochSeconds,
58
+ });
59
+ }
60
+ this.writeState(Array.from(notifiedBySessionName.values()));
61
+ };
62
+
63
+ private readNotifiedEntries = (): StoredNotifiedEntry[] => {
64
+ let raw: string;
65
+ try {
66
+ raw = fs.readFileSync(this.stateFilePath, 'utf8');
67
+ } catch {
68
+ return [];
69
+ }
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ } catch {
74
+ return [];
75
+ }
76
+ if (!isRecord(parsed)) {
77
+ return [];
78
+ }
79
+ const storedEntries = parsed.notified;
80
+ if (!Array.isArray(storedEntries)) {
81
+ return [];
82
+ }
83
+ const entries: StoredNotifiedEntry[] = [];
84
+ for (const storedEntry of storedEntries) {
85
+ if (!isRecord(storedEntry)) {
86
+ continue;
87
+ }
88
+ const sessionName = storedEntry.sessionName;
89
+ const recordedEpochSeconds = storedEntry.recordedEpochSeconds;
90
+ if (
91
+ typeof sessionName === 'string' &&
92
+ typeof recordedEpochSeconds === 'number' &&
93
+ Number.isFinite(recordedEpochSeconds)
94
+ ) {
95
+ entries.push({ sessionName, recordedEpochSeconds });
96
+ }
97
+ }
98
+ return entries;
99
+ };
100
+
101
+ private writeState = (notified: StoredNotifiedEntry[]): void => {
102
+ const directory = path.dirname(this.stateFilePath);
103
+ fs.mkdirSync(directory, { recursive: true });
104
+ const temporaryPath = `${this.stateFilePath}.${process.pid}.tmp`;
105
+ fs.writeFileSync(temporaryPath, JSON.stringify({ notified }));
106
+ fs.renameSync(temporaryPath, this.stateFilePath);
107
+ };
108
+ }
@@ -0,0 +1,82 @@
1
+ import { NodeProcessSignalRepository } from './NodeProcessSignalRepository';
2
+
3
+ const errnoError = (message: string, code: string): NodeJS.ErrnoException => {
4
+ const error: NodeJS.ErrnoException = new Error(message);
5
+ error.code = code;
6
+ return error;
7
+ };
8
+
9
+ describe('NodeProcessSignalRepository', () => {
10
+ let repository: NodeProcessSignalRepository;
11
+ let killSpy: jest.SpyInstance;
12
+
13
+ beforeEach(() => {
14
+ repository = new NodeProcessSignalRepository();
15
+ jest.spyOn(console, 'error').mockImplementation(() => undefined);
16
+ });
17
+
18
+ afterEach(() => {
19
+ jest.restoreAllMocks();
20
+ });
21
+
22
+ describe('isProcessAlive', () => {
23
+ it('returns true for the current process', () => {
24
+ expect(repository.isProcessAlive(process.pid)).toBe(true);
25
+ });
26
+
27
+ it('returns false for a pid that does not exist', () => {
28
+ killSpy = jest.spyOn(process, 'kill').mockImplementation(() => {
29
+ throw errnoError('no such process', 'ESRCH');
30
+ });
31
+
32
+ expect(repository.isProcessAlive(999999)).toBe(false);
33
+ expect(killSpy).toHaveBeenCalledWith(999999, 0);
34
+ });
35
+
36
+ it('returns true when signalling is not permitted', () => {
37
+ jest.spyOn(process, 'kill').mockImplementation(() => {
38
+ throw errnoError('operation not permitted', 'EPERM');
39
+ });
40
+
41
+ expect(repository.isProcessAlive(1)).toBe(true);
42
+ });
43
+ });
44
+
45
+ describe('terminateProcess', () => {
46
+ it('sends SIGTERM to the pid', () => {
47
+ killSpy = jest.spyOn(process, 'kill').mockReturnValue(true);
48
+
49
+ repository.terminateProcess(4242);
50
+
51
+ expect(killSpy).toHaveBeenCalledWith(4242, 'SIGTERM');
52
+ });
53
+
54
+ it('does not throw or log when the process already exited', () => {
55
+ jest.spyOn(process, 'kill').mockImplementation(() => {
56
+ throw errnoError('no such process', 'ESRCH');
57
+ });
58
+
59
+ expect(() => repository.terminateProcess(4242)).not.toThrow();
60
+ expect(console.error).not.toHaveBeenCalled();
61
+ });
62
+ });
63
+
64
+ describe('killProcess', () => {
65
+ it('sends SIGKILL to the pid', () => {
66
+ killSpy = jest.spyOn(process, 'kill').mockReturnValue(true);
67
+
68
+ repository.killProcess(4242);
69
+
70
+ expect(killSpy).toHaveBeenCalledWith(4242, 'SIGKILL');
71
+ });
72
+
73
+ it('logs an unexpected signalling error without throwing', () => {
74
+ jest.spyOn(process, 'kill').mockImplementation(() => {
75
+ throw errnoError('operation not permitted', 'EPERM');
76
+ });
77
+
78
+ expect(() => repository.killProcess(4242)).not.toThrow();
79
+ expect(console.error).toHaveBeenCalled();
80
+ });
81
+ });
82
+ });
@@ -0,0 +1,47 @@
1
+ import { ProcessSignalRepository } from '../../domain/usecases/adapter-interfaces/ProcessSignalRepository';
2
+
3
+ const errorCode = (error: unknown): string | null => {
4
+ if (
5
+ error !== null &&
6
+ typeof error === 'object' &&
7
+ 'code' in error &&
8
+ typeof error.code === 'string'
9
+ ) {
10
+ return error.code;
11
+ }
12
+ return null;
13
+ };
14
+
15
+ export class NodeProcessSignalRepository implements ProcessSignalRepository {
16
+ isProcessAlive = (pid: number): boolean => {
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ } catch (error) {
21
+ return errorCode(error) === 'EPERM';
22
+ }
23
+ };
24
+
25
+ terminateProcess = (pid: number): void => {
26
+ this.signal(pid, 'SIGTERM');
27
+ };
28
+
29
+ killProcess = (pid: number): void => {
30
+ this.signal(pid, 'SIGKILL');
31
+ };
32
+
33
+ private signal = (pid: number, signal: NodeJS.Signals): void => {
34
+ try {
35
+ process.kill(pid, signal);
36
+ } catch (error) {
37
+ if (errorCode(error) === 'ESRCH') {
38
+ return;
39
+ }
40
+ console.error(
41
+ `Failed to send ${signal} to pid ${pid}: ${
42
+ error instanceof Error ? error.message : String(error)
43
+ }`,
44
+ );
45
+ }
46
+ };
47
+ }