github-issue-tower-defence-management 1.139.1 → 1.140.1
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 +6 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +43 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.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/FileSystemSessionOutputActivityRepository.js +6 -40
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
- 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/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 +9 -46
- 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/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 +32 -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/FileSystemSessionOutputActivityRepository.test.ts +24 -11
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +8 -50
- 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/domain/entities/ClaudeHandoverSession.ts +11 -0
- package/src/domain/entities/LiveSessionActivitySnapshot.ts +0 -1
- package/src/domain/entities/LiveSessionOutputActivity.ts +0 -1
- package/src/domain/entities/TokenExhaustionHandoverState.ts +8 -0
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +7 -114
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +8 -53
- 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/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/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/FileSystemSessionOutputActivityRepository.d.ts +1 -1
- package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
- 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/domain/entities/ClaudeHandoverSession.d.ts +10 -0
- package/types/domain/entities/ClaudeHandoverSession.d.ts.map +1 -0
- package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +0 -1
- package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
- package/types/domain/entities/LiveSessionOutputActivity.d.ts +0 -1
- package/types/domain/entities/LiveSessionOutputActivity.d.ts.map +1 -1
- 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 +0 -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/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
|
@@ -124,7 +124,6 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
124
124
|
lastOutputEpochSeconds: Math.floor(
|
|
125
125
|
Date.parse('2026-06-27T01:00:00.000Z') / 1000,
|
|
126
126
|
),
|
|
127
|
-
hasInProgressToolCall: false,
|
|
128
127
|
},
|
|
129
128
|
]);
|
|
130
129
|
});
|
|
@@ -146,7 +145,6 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
146
145
|
lastOutputEpochSeconds: Math.floor(
|
|
147
146
|
Date.parse('2026-06-27T09:55:00.000Z') / 1000,
|
|
148
147
|
),
|
|
149
|
-
hasInProgressToolCall: false,
|
|
150
148
|
},
|
|
151
149
|
]);
|
|
152
150
|
});
|
|
@@ -168,7 +166,6 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
168
166
|
lastOutputEpochSeconds: Math.floor(
|
|
169
167
|
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
170
168
|
),
|
|
171
|
-
hasInProgressToolCall: false,
|
|
172
169
|
},
|
|
173
170
|
]);
|
|
174
171
|
});
|
|
@@ -190,7 +187,6 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
190
187
|
lastOutputEpochSeconds: Math.floor(
|
|
191
188
|
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
192
189
|
),
|
|
193
|
-
hasInProgressToolCall: false,
|
|
194
190
|
},
|
|
195
191
|
]);
|
|
196
192
|
});
|
|
@@ -214,7 +210,6 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
214
210
|
lastOutputEpochSeconds: Math.floor(
|
|
215
211
|
Date.parse('2026-06-27T10:05:00.000Z') / 1000,
|
|
216
212
|
),
|
|
217
|
-
hasInProgressToolCall: false,
|
|
218
213
|
},
|
|
219
214
|
]);
|
|
220
215
|
});
|
|
@@ -236,7 +231,6 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
236
231
|
lastOutputEpochSeconds: Math.floor(
|
|
237
232
|
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
238
233
|
),
|
|
239
|
-
hasInProgressToolCall: false,
|
|
240
234
|
},
|
|
241
235
|
]);
|
|
242
236
|
});
|
|
@@ -260,7 +254,6 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
260
254
|
lastOutputEpochSeconds: Math.floor(
|
|
261
255
|
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
262
256
|
),
|
|
263
|
-
hasInProgressToolCall: false,
|
|
264
257
|
},
|
|
265
258
|
]);
|
|
266
259
|
});
|
|
@@ -293,7 +286,7 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
293
286
|
expect(result).toEqual([]);
|
|
294
287
|
});
|
|
295
288
|
|
|
296
|
-
it('
|
|
289
|
+
it('reports the assistant tool_use timestamp as the last output even when no tool_result has arrived, without any in-progress flagging', async () => {
|
|
297
290
|
const transcriptPath = writeTranscript('busy.jsonl', [
|
|
298
291
|
assistantEntry('2026-06-27T09:00:00.000Z'),
|
|
299
292
|
assistantEntryWithToolUse('2026-06-27T09:01:00.000Z', 'toolu_running'),
|
|
@@ -310,12 +303,11 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
310
303
|
lastOutputEpochSeconds: Math.floor(
|
|
311
304
|
Date.parse('2026-06-27T09:01:00.000Z') / 1000,
|
|
312
305
|
),
|
|
313
|
-
hasInProgressToolCall: true,
|
|
314
306
|
},
|
|
315
307
|
]);
|
|
316
308
|
});
|
|
317
309
|
|
|
318
|
-
it('
|
|
310
|
+
it('reports the assistant tool_use timestamp as the last output when a later tool_result user entry follows it', async () => {
|
|
319
311
|
const transcriptPath = writeTranscript('completed.jsonl', [
|
|
320
312
|
assistantEntryWithToolUse('2026-06-27T09:01:00.000Z', 'toolu_done'),
|
|
321
313
|
toolResultUserEntry('2026-06-27T09:05:00.000Z', 'toolu_done'),
|
|
@@ -332,7 +324,28 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
332
324
|
lastOutputEpochSeconds: Math.floor(
|
|
333
325
|
Date.parse('2026-06-27T09:01:00.000Z') / 1000,
|
|
334
326
|
),
|
|
335
|
-
|
|
327
|
+
},
|
|
328
|
+
]);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('reports the later assistant text timestamp when an orphaned tool_use is followed by a resumed assistant turn, carrying no in-progress state', async () => {
|
|
332
|
+
const transcriptPath = writeTranscript('orphaned.jsonl', [
|
|
333
|
+
assistantEntryWithToolUse('2026-06-27T09:00:00.000Z', 'toolu_orphaned'),
|
|
334
|
+
assistantEntry('2026-06-27T09:46:53.000Z'),
|
|
335
|
+
userEntry('2026-06-27T09:47:00.000Z'),
|
|
336
|
+
]);
|
|
337
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
338
|
+
|
|
339
|
+
const result = await repository.listSessionOutputActivities(
|
|
340
|
+
new Map([['orphaned', transcriptPath]]),
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
expect(result).toEqual([
|
|
344
|
+
{
|
|
345
|
+
sessionName: 'orphaned',
|
|
346
|
+
lastOutputEpochSeconds: Math.floor(
|
|
347
|
+
Date.parse('2026-06-27T09:46:53.000Z') / 1000,
|
|
348
|
+
),
|
|
336
349
|
},
|
|
337
350
|
]);
|
|
338
351
|
});
|
|
@@ -21,54 +21,34 @@ const parseEpochMilliseconds = (timestamp: string | null): number | null => {
|
|
|
21
21
|
return Number.isNaN(parsed) ? null : parsed;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
const readContentBlocks = (
|
|
25
|
-
message: Record<string, unknown>,
|
|
26
|
-
): Record<string, unknown>[] => {
|
|
27
|
-
const content = message.content;
|
|
28
|
-
if (!Array.isArray(content)) {
|
|
29
|
-
return [];
|
|
30
|
-
}
|
|
31
|
-
return content.filter(isRecord);
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
type TranscriptActivity = {
|
|
35
|
-
lastAssistantOutputEpochSeconds: number | null;
|
|
36
|
-
hasInProgressToolCall: boolean;
|
|
37
|
-
};
|
|
38
|
-
|
|
39
24
|
export class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
|
|
40
25
|
listSessionOutputActivities = async (
|
|
41
26
|
transcriptPathBySessionName: Map<string, string>,
|
|
42
27
|
): Promise<LiveSessionOutputActivity[]> => {
|
|
43
28
|
const activities: LiveSessionOutputActivity[] = [];
|
|
44
29
|
for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
|
|
45
|
-
const
|
|
46
|
-
this.
|
|
30
|
+
const lastAssistantOutputEpochSeconds =
|
|
31
|
+
this.readLastAssistantOutputEpochSeconds(transcriptPath);
|
|
47
32
|
if (lastAssistantOutputEpochSeconds !== null) {
|
|
48
33
|
activities.push({
|
|
49
34
|
sessionName,
|
|
50
35
|
lastOutputEpochSeconds: lastAssistantOutputEpochSeconds,
|
|
51
|
-
hasInProgressToolCall,
|
|
52
36
|
});
|
|
53
37
|
}
|
|
54
38
|
}
|
|
55
39
|
return activities;
|
|
56
40
|
};
|
|
57
41
|
|
|
58
|
-
private
|
|
42
|
+
private readLastAssistantOutputEpochSeconds = (
|
|
59
43
|
transcriptPath: string,
|
|
60
|
-
):
|
|
44
|
+
): number | null => {
|
|
61
45
|
let content: string;
|
|
62
46
|
try {
|
|
63
47
|
content = fs.readFileSync(transcriptPath, 'utf8');
|
|
64
48
|
} catch {
|
|
65
|
-
return
|
|
66
|
-
lastAssistantOutputEpochSeconds: null,
|
|
67
|
-
hasInProgressToolCall: false,
|
|
68
|
-
};
|
|
49
|
+
return null;
|
|
69
50
|
}
|
|
70
51
|
let lastAssistantOutputEpochMs: number | null = null;
|
|
71
|
-
const pendingToolUseIds = new Set<string>();
|
|
72
52
|
for (const line of content.split('\n')) {
|
|
73
53
|
const trimmed = line.trim();
|
|
74
54
|
if (trimmed.length === 0) {
|
|
@@ -83,37 +63,15 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
83
63
|
if (!isRecord(parsed)) {
|
|
84
64
|
continue;
|
|
85
65
|
}
|
|
86
|
-
const message = parsed.message;
|
|
87
66
|
if (readString(parsed, 'type') === 'assistant') {
|
|
88
67
|
const epochMs = parseEpochMilliseconds(readString(parsed, 'timestamp'));
|
|
89
68
|
if (epochMs !== null) {
|
|
90
69
|
lastAssistantOutputEpochMs = epochMs;
|
|
91
70
|
}
|
|
92
71
|
}
|
|
93
|
-
if (!isRecord(message)) {
|
|
94
|
-
continue;
|
|
95
|
-
}
|
|
96
|
-
for (const block of readContentBlocks(message)) {
|
|
97
|
-
const blockType = readString(block, 'type');
|
|
98
|
-
if (blockType === 'tool_use') {
|
|
99
|
-
const toolUseId = readString(block, 'id');
|
|
100
|
-
if (toolUseId !== null) {
|
|
101
|
-
pendingToolUseIds.add(toolUseId);
|
|
102
|
-
}
|
|
103
|
-
} else if (blockType === 'tool_result') {
|
|
104
|
-
const toolUseId = readString(block, 'tool_use_id');
|
|
105
|
-
if (toolUseId !== null) {
|
|
106
|
-
pendingToolUseIds.delete(toolUseId);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
72
|
}
|
|
111
|
-
return
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
? null
|
|
115
|
-
: Math.floor(lastAssistantOutputEpochMs / 1000),
|
|
116
|
-
hasInProgressToolCall: pendingToolUseIds.size > 0,
|
|
117
|
-
};
|
|
73
|
+
return lastAssistantOutputEpochMs === null
|
|
74
|
+
? null
|
|
75
|
+
: Math.floor(lastAssistantOutputEpochMs / 1000);
|
|
118
76
|
};
|
|
119
77
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|