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.
Files changed (99) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +6 -0
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +43 -2
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js +31 -0
  6. package/bin/adapter/entry-points/handlers/tokenExhaustionHandover.js.map +1 -0
  7. package/bin/adapter/repositories/FileHandoverStateRepository.js +99 -0
  8. package/bin/adapter/repositories/FileHandoverStateRepository.js.map +1 -0
  9. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +6 -40
  10. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
  11. package/bin/adapter/repositories/NodeProcessSignalRepository.js +44 -0
  12. package/bin/adapter/repositories/NodeProcessSignalRepository.js.map +1 -0
  13. package/bin/adapter/repositories/NodeTmuxSessionRepository.js +67 -1
  14. package/bin/adapter/repositories/NodeTmuxSessionRepository.js.map +1 -1
  15. package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js +206 -0
  16. package/bin/adapter/repositories/ProcClaudeHandoverSessionRepository.js.map +1 -0
  17. package/bin/adapter/repositories/RateLimitSnapshotRepository.js +44 -0
  18. package/bin/adapter/repositories/RateLimitSnapshotRepository.js.map +1 -0
  19. package/bin/domain/entities/ClaudeHandoverSession.js +3 -0
  20. package/bin/domain/entities/ClaudeHandoverSession.js.map +1 -0
  21. package/bin/domain/entities/TokenExhaustionHandoverState.js +3 -0
  22. package/bin/domain/entities/TokenExhaustionHandoverState.js.map +1 -0
  23. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +9 -46
  24. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  25. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js +255 -0
  26. package/bin/domain/usecases/TokenExhaustionHandoverUseCase.js.map +1 -0
  27. package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js +3 -0
  28. package/bin/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.js.map +1 -0
  29. package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js +3 -0
  30. package/bin/domain/usecases/adapter-interfaces/ProcessSignalRepository.js.map +1 -0
  31. package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js +3 -0
  32. package/bin/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.js.map +1 -0
  33. package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js +3 -0
  34. package/bin/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +32 -0
  37. package/src/adapter/entry-points/handlers/tokenExhaustionHandover.ts +79 -0
  38. package/src/adapter/repositories/FileHandoverStateRepository.test.ts +114 -0
  39. package/src/adapter/repositories/FileHandoverStateRepository.ts +76 -0
  40. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +24 -11
  41. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +8 -50
  42. package/src/adapter/repositories/NodeProcessSignalRepository.test.ts +82 -0
  43. package/src/adapter/repositories/NodeProcessSignalRepository.ts +47 -0
  44. package/src/adapter/repositories/NodeTmuxSessionRepository.test.ts +132 -0
  45. package/src/adapter/repositories/NodeTmuxSessionRepository.ts +100 -0
  46. package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.test.ts +194 -0
  47. package/src/adapter/repositories/ProcClaudeHandoverSessionRepository.ts +208 -0
  48. package/src/adapter/repositories/RateLimitSnapshotRepository.ts +50 -0
  49. package/src/domain/entities/ClaudeHandoverSession.ts +11 -0
  50. package/src/domain/entities/LiveSessionActivitySnapshot.ts +0 -1
  51. package/src/domain/entities/LiveSessionOutputActivity.ts +0 -1
  52. package/src/domain/entities/TokenExhaustionHandoverState.ts +8 -0
  53. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +7 -114
  54. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +8 -53
  55. package/src/domain/usecases/TokenExhaustionHandoverUseCase.test.ts +696 -0
  56. package/src/domain/usecases/TokenExhaustionHandoverUseCase.ts +413 -0
  57. package/src/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.ts +5 -0
  58. package/src/domain/usecases/adapter-interfaces/ProcessSignalRepository.ts +5 -0
  59. package/src/domain/usecases/adapter-interfaces/TmuxSessionRepository.ts +2 -0
  60. package/src/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.ts +6 -0
  61. package/src/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.ts +22 -0
  62. package/src/domain/usecases/intmux/InTmuxByHumanSessionReconcileUseCase.test.ts +2 -0
  63. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  64. package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts +14 -0
  65. package/types/adapter/entry-points/handlers/tokenExhaustionHandover.d.ts.map +1 -0
  66. package/types/adapter/repositories/FileHandoverStateRepository.d.ts +11 -0
  67. package/types/adapter/repositories/FileHandoverStateRepository.d.ts.map +1 -0
  68. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +1 -1
  69. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
  70. package/types/adapter/repositories/NodeProcessSignalRepository.d.ts +8 -0
  71. package/types/adapter/repositories/NodeProcessSignalRepository.d.ts.map +1 -0
  72. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts +7 -1
  73. package/types/adapter/repositories/NodeTmuxSessionRepository.d.ts.map +1 -1
  74. package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts +13 -0
  75. package/types/adapter/repositories/ProcClaudeHandoverSessionRepository.d.ts.map +1 -0
  76. package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts +9 -0
  77. package/types/adapter/repositories/RateLimitSnapshotRepository.d.ts.map +1 -0
  78. package/types/domain/entities/ClaudeHandoverSession.d.ts +10 -0
  79. package/types/domain/entities/ClaudeHandoverSession.d.ts.map +1 -0
  80. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +0 -1
  81. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
  82. package/types/domain/entities/LiveSessionOutputActivity.d.ts +0 -1
  83. package/types/domain/entities/LiveSessionOutputActivity.d.ts.map +1 -1
  84. package/types/domain/entities/TokenExhaustionHandoverState.d.ts +8 -0
  85. package/types/domain/entities/TokenExhaustionHandoverState.d.ts.map +1 -0
  86. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +0 -1
  87. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  88. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts +50 -0
  89. package/types/domain/usecases/TokenExhaustionHandoverUseCase.d.ts.map +1 -0
  90. package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts +5 -0
  91. package/types/domain/usecases/adapter-interfaces/ClaudeHandoverSessionRepository.d.ts.map +1 -0
  92. package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts +6 -0
  93. package/types/domain/usecases/adapter-interfaces/ProcessSignalRepository.d.ts.map +1 -0
  94. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts +2 -0
  95. package/types/domain/usecases/adapter-interfaces/TmuxSessionRepository.d.ts.map +1 -1
  96. package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts +6 -0
  97. package/types/domain/usecases/adapter-interfaces/TokenExhaustionHandoverStateRepository.d.ts.map +1 -0
  98. package/types/domain/usecases/adapter-interfaces/TokenRateLimitSnapshotRepository.d.ts +21 -0
  99. 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('flags a session as waiting on a running tool when the last assistant tool_use has no matching tool_result', async () => {
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('does not flag a session once the matching tool_result for its last tool_use is appended', async () => {
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
- hasInProgressToolCall: false,
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 { lastAssistantOutputEpochSeconds, hasInProgressToolCall } =
46
- this.readTranscriptActivity(transcriptPath);
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 readTranscriptActivity = (
42
+ private readLastAssistantOutputEpochSeconds = (
59
43
  transcriptPath: string,
60
- ): TranscriptActivity => {
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
- lastAssistantOutputEpochSeconds:
113
- lastAssistantOutputEpochMs === null
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
  }