github-issue-tower-defence-management 1.146.5 → 1.146.6

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 (48) hide show
  1. package/.github/dependabot.yml +2 -0
  2. package/.github/workflows/commit-lint.yml +20 -0
  3. package/.github/workflows/umino-project.yml +0 -1
  4. package/CHANGELOG.md +7 -0
  5. package/bin/adapter/entry-points/cli/index.js +2 -2
  6. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js +4 -3
  8. package/bin/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js +4 -3
  10. package/bin/adapter/entry-points/handlers/OauthTokenSelectHandler.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +3 -2
  12. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  13. package/bin/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.js +1 -1
  14. package/bin/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.js.map +1 -1
  15. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +73 -2
  16. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -1
  17. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js +3 -1
  18. package/bin/domain/usecases/LiveSessionOauthTokenSelectUseCase.js.map +1 -1
  19. package/bin/domain/usecases/OauthTokenSelectUseCase.js +13 -4
  20. package/bin/domain/usecases/OauthTokenSelectUseCase.js.map +1 -1
  21. package/package.json +1 -1
  22. package/renovate.json +5 -0
  23. package/src/adapter/entry-points/cli/index.ts +2 -2
  24. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.test.ts +7 -4
  25. package/src/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.ts +4 -1
  26. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.test.ts +6 -2
  27. package/src/adapter/entry-points/handlers/OauthTokenSelectHandler.ts +8 -2
  28. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +1 -0
  29. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +14 -2
  30. package/src/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.ts +4 -1
  31. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +157 -15
  32. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +115 -1
  33. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.test.ts +77 -5
  34. package/src/domain/usecases/LiveSessionOauthTokenSelectUseCase.ts +10 -1
  35. package/src/domain/usecases/OauthTokenSelectUseCase.test.ts +39 -8
  36. package/src/domain/usecases/OauthTokenSelectUseCase.ts +25 -5
  37. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts +3 -1
  38. package/types/adapter/entry-points/handlers/LiveSessionOauthTokenSelectHandler.d.ts.map +1 -1
  39. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts +3 -2
  40. package/types/adapter/entry-points/handlers/OauthTokenSelectHandler.d.ts.map +1 -1
  41. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +1 -0
  42. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  43. package/types/adapter/entry-points/handlers/resetDegeneratedTmuxSessions.d.ts.map +1 -1
  44. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts +14 -1
  45. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -1
  46. package/types/domain/usecases/LiveSessionOauthTokenSelectUseCase.d.ts.map +1 -1
  47. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts +4 -1
  48. package/types/domain/usecases/OauthTokenSelectUseCase.d.ts.map +1 -1
@@ -919,7 +919,7 @@ addServeWebOptions(program.command('serveConsole'))
919
919
  program
920
920
  .command('selectOauthToken')
921
921
  .description(
922
- 'Print exactly one Claude Code OAuth token chosen by a rate-limit-aware filter. When tokens carry differing per-token selectionWeight values, the choice among rate-limit-eligible tokens is weighted-random by that weight (default 1), so a lower-weight token is chosen proportionally less often; uniform weights preserve the deterministic soonest-7d-reset choice. The token string is written to stdout (pipeable); the per-candidate decision trace is written to stderr. Exits non-zero when no token passes the filter.',
922
+ 'Print exactly one Claude Code OAuth token chosen by a rate-limit-aware filter. Among rate-limit-eligible tokens the choice is weighted-random, with each token weighted by its per-token selectionWeight (default 1) multiplied by how urgent its 7d window is (the free share of that window times 168 divided by the hours left before it resets, with those hours floored at 1), so a token holding unused allowance that resets soon is chosen more often. When every eligible weight is identical, the choice stays deterministic and no random draw is made. The token string is written to stdout (pipeable); the per-candidate decision trace is written to stderr. Exits non-zero when no token passes the filter.',
923
923
  )
924
924
  .option(
925
925
  '--tokenListJsonPath <path>',
@@ -951,7 +951,7 @@ program
951
951
  program
952
952
  .command('selectLiveSessionOauthToken')
953
953
  .description(
954
- 'Print exactly one Claude Code OAuth token chosen for a new live interactive session. Among rate-limit-eligible tokens it prefers the one with the fewest current live sessions (by distinct CLAUDE_CODE_SESSION_ID found in running Claude Code processes), tiebreaking on the soonest 7d reset. When tokens carry differing per-token selectionWeight values, the choice among eligible tokens is weighted-random by that weight (default 1), so a lower-weight token is chosen proportionally less often; uniform weights preserve the fewest-live-sessions-then-soonest-7d-reset choice. The token string is written to stdout (pipeable); the per-candidate decision trace is written to stderr. Exits non-zero when no token passes the filter.',
954
+ 'Print exactly one Claude Code OAuth token chosen for a new live interactive session. Among rate-limit-eligible tokens the choice is weighted-random, with each token weighted by its per-token selectionWeight (default 1), multiplied by how urgent its 7d window is (the free share of that window times 168 divided by the hours left before it resets, with those hours floored at 1), and divided by one plus its current live session count (by distinct CLAUDE_CODE_SESSION_ID found in running Claude Code processes). A token whose 7d window still holds unused allowance and resets soon is therefore chosen more often, while occupancy keeps sessions spread across tokens. When every eligible weight is identical, the choice stays deterministic and no random draw is made. The token string is written to stdout (pipeable); the per-candidate decision trace is written to stderr. Exits non-zero when no token passes the filter.',
955
955
  )
956
956
  .option(
957
957
  '--tokenListJsonPath <path>',
@@ -109,10 +109,12 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
109
109
 
110
110
  const buildHandler = (
111
111
  sessions: ClaudeLiveSession[],
112
+ random: () => number = () => 0.5,
112
113
  ): LiveSessionOauthTokenSelectHandler =>
113
114
  new LiveSessionOauthTokenSelectHandler(
114
115
  new LiveSessionOauthTokenSelectUseCase(),
115
116
  new FakeClaudeLiveSessionRepository(sessions),
117
+ random,
116
118
  );
117
119
 
118
120
  const writeFableRejectionCache = (token: string, resetsAt: number): void => {
@@ -163,7 +165,7 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
163
165
  );
164
166
  });
165
167
 
166
- it('selects the eligible token with the fewest live sessions', () => {
168
+ it('selects the eligible token with fewer live sessions for a draw inside its weight slice', () => {
167
169
  writeTokenList([
168
170
  { name: 'busy', token: 'fake-busy' },
169
171
  { name: 'idle', token: 'fake-idle' },
@@ -181,9 +183,10 @@ describe('LiveSessionOauthTokenSelectHandler', () => {
181
183
  sevenDayReset: NOW + 6 * DAY,
182
184
  });
183
185
 
184
- const handler = buildHandler([
185
- { token: 'fake-busy', sessionKey: 'session-a' },
186
- ]);
186
+ const handler = buildHandler(
187
+ [{ token: 'fake-busy', sessionKey: 'session-a' }],
188
+ () => 0.9,
189
+ );
187
190
  const output = handler.handle({
188
191
  tokenListJsonPath: tokenListPath,
189
192
  cacheDirectory,
@@ -8,6 +8,7 @@ import {
8
8
  FIVE_HOUR_MIN_FREE_RATIO,
9
9
  OauthTokenCandidate,
10
10
  SEVEN_DAY_MIN_FREE_RATIO,
11
+ SelectionRandom,
11
12
  } from '../../../domain/usecases/OauthTokenSelectUseCase';
12
13
  import { ProcClaudeLiveSessionRepository } from '../../repositories/ProcClaudeLiveSessionRepository';
13
14
  import { FABLE_LIMIT_TYPE, readRateLimit } from '../../proxy/RateLimitCache';
@@ -33,6 +34,7 @@ export class LiveSessionOauthTokenSelectHandler {
33
34
  constructor(
34
35
  private readonly useCase: LiveSessionOauthTokenSelectUseCase = new LiveSessionOauthTokenSelectUseCase(),
35
36
  private readonly liveSessionRepository: ClaudeLiveSessionRepository = new ProcClaudeLiveSessionRepository(),
37
+ private readonly random: SelectionRandom = Math.random,
36
38
  ) {}
37
39
 
38
40
  handle = (
@@ -96,6 +98,7 @@ export class LiveSessionOauthTokenSelectHandler {
96
98
  candidates,
97
99
  liveSessions,
98
100
  input.nowEpochSeconds,
101
+ this.random,
99
102
  );
100
103
 
101
104
  return {
@@ -125,7 +128,7 @@ export class LiveSessionOauthTokenSelectHandler {
125
128
  );
126
129
  } else {
127
130
  lines.push(
128
- `Selected ${result.selected.name} (fewest live sessions, then soonest 7d reset among eligible tokens).`,
131
+ `Selected ${result.selected.name} (weighted by how soon the 7d window resets, how much of it is free, and how few live sessions the token carries).`,
129
132
  );
130
133
  }
131
134
 
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
+ import { OauthTokenSelectUseCase } from '../../../domain/usecases/OauthTokenSelectUseCase';
4
5
  import { FABLE_LIMIT_TYPE, hashToken } from '../../proxy/RateLimitCache';
5
6
  import {
6
7
  OauthTokenSelectHandler,
@@ -122,7 +123,7 @@ describe('OauthTokenSelectHandler', () => {
122
123
  );
123
124
  };
124
125
 
125
- it('selects the eligible token with the soonest 7d reset', () => {
126
+ it('selects the token whose 7d window resets soonest for a draw inside its weight slice', () => {
126
127
  writeTokenList([
127
128
  { name: 'far', token: 'fake-far' },
128
129
  { name: 'soon', token: 'fake-soon' },
@@ -140,7 +141,10 @@ describe('OauthTokenSelectHandler', () => {
140
141
  sevenDayReset: NOW + 2 * DAY,
141
142
  });
142
143
 
143
- const handler = new OauthTokenSelectHandler();
144
+ const handler = new OauthTokenSelectHandler(
145
+ new OauthTokenSelectUseCase(),
146
+ () => 0.5,
147
+ );
144
148
  const output = handler.handle({
145
149
  tokenListJsonPath: tokenListPath,
146
150
  cacheDirectory,
@@ -3,6 +3,7 @@ import {
3
3
  OauthTokenCandidate,
4
4
  OauthTokenSelectResult,
5
5
  OauthTokenSelectUseCase,
6
+ SelectionRandom,
6
7
  } from '../../../domain/usecases/OauthTokenSelectUseCase';
7
8
  import {
8
9
  FABLE_LIMIT_TYPE,
@@ -55,6 +56,7 @@ export const resolveCacheDirectory = (
55
56
  export class OauthTokenSelectHandler {
56
57
  constructor(
57
58
  private readonly useCase: OauthTokenSelectUseCase = new OauthTokenSelectUseCase(),
59
+ private readonly random: SelectionRandom = Math.random,
58
60
  ) {}
59
61
 
60
62
  handle = (
@@ -112,7 +114,11 @@ export class OauthTokenSelectHandler {
112
114
  },
113
115
  );
114
116
 
115
- const result = this.useCase.run(candidates, input.nowEpochSeconds);
117
+ const result = this.useCase.run(
118
+ candidates,
119
+ input.nowEpochSeconds,
120
+ this.random,
121
+ );
116
122
 
117
123
  return {
118
124
  selectedToken: result.selected?.token ?? null,
@@ -139,7 +145,7 @@ export class OauthTokenSelectHandler {
139
145
  lines.push('No eligible token passed the rate-limit filter.');
140
146
  } else {
141
147
  lines.push(
142
- `Selected ${result.selected.name} (soonest 7d reset among eligible tokens).`,
148
+ `Selected ${result.selected.name} (weighted by how soon the 7d window resets and how much of it is free among eligible tokens).`,
143
149
  );
144
150
  }
145
151
 
@@ -147,6 +147,7 @@ describe('notifySilentTmuxSessions', () => {
147
147
  messageTemplates: EMPTY_TEMPLATES,
148
148
  now: NOW,
149
149
  ...DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS,
150
+ submitPushOutWaitMilliseconds: 0,
150
151
  });
151
152
 
152
153
  it('sends a main stalled notification to a silent github-named live session that was already a candidate in the previous cycle', async () => {
@@ -18,7 +18,11 @@ import { LocalProcessLiveSessionProcessSnapshotProvider } from '../../repositori
18
18
  import { ProcFsProcessEnvironReader } from '../../repositories/ProcFsProcessEnvironReader';
19
19
  import { FileSystemInteractiveLiveSessionTranscriptResolver } from '../../repositories/FileSystemInteractiveLiveSessionTranscriptResolver';
20
20
  import { FileSystemSessionOutputActivityRepository } from '../../repositories/FileSystemSessionOutputActivityRepository';
21
- import { TmuxSilentSessionNotificationRepository } from '../../repositories/TmuxSilentSessionNotificationRepository';
21
+ import {
22
+ DEFAULT_SUBMIT_PUSH_OUT_ATTEMPT_LIMIT,
23
+ DEFAULT_SUBMIT_PUSH_OUT_WAIT_MILLISECONDS,
24
+ TmuxSilentSessionNotificationRepository,
25
+ } from '../../repositories/TmuxSilentSessionNotificationRepository';
22
26
  import { NoUnansweredOwnerCallStatusProvider } from '../../repositories/NoUnansweredOwnerCallStatusProvider';
23
27
  import { TranscriptOwnerCallStatusProvider } from '../../repositories/TranscriptOwnerCallStatusProvider';
24
28
  import { TranscriptRefusalTailStatusProvider } from '../../repositories/TranscriptRefusalTailStatusProvider';
@@ -57,6 +61,7 @@ export type NotifySilentTmuxSessionsParams = {
57
61
  hubTaskStatusCacheStateFilePath: string | null;
58
62
  hubTaskStatusCacheTtlSeconds: number;
59
63
  messageTemplates: SilentSessionMessageTemplates;
64
+ submitPushOutWaitMilliseconds?: number;
60
65
  now: Date;
61
66
  };
62
67
 
@@ -121,6 +126,7 @@ export const notifySilentTmuxSessions = async (
121
126
  hubTaskStatusCacheStateFilePath,
122
127
  hubTaskStatusCacheTtlSeconds,
123
128
  messageTemplates,
129
+ submitPushOutWaitMilliseconds,
124
130
  now,
125
131
  } = params;
126
132
  if (!enabled) {
@@ -149,7 +155,13 @@ export const notifySilentTmuxSessions = async (
149
155
  now,
150
156
  ),
151
157
  createOwnerCallStatusProvider(ownerCallMarker),
152
- new TmuxSilentSessionNotificationRepository(localCommandRunner),
158
+ new TmuxSilentSessionNotificationRepository(
159
+ localCommandRunner,
160
+ new RealSleeper(),
161
+ DEFAULT_SUBMIT_PUSH_OUT_ATTEMPT_LIMIT,
162
+ submitPushOutWaitMilliseconds ??
163
+ DEFAULT_SUBMIT_PUSH_OUT_WAIT_MILLISECONDS,
164
+ ),
153
165
  candidateDebounceStateFilePath !== null
154
166
  ? new FileSystemSilentSessionCandidateStateRepository(
155
167
  candidateDebounceStateFilePath,
@@ -46,7 +46,10 @@ export const resetDegeneratedTmuxSessions = async (
46
46
  ),
47
47
  new FileSystemInteractiveLiveSessionTranscriptResolver(),
48
48
  new FileSystemSessionAssistantTurnsRepository(),
49
- new TmuxSilentSessionNotificationRepository(localCommandRunner),
49
+ new TmuxSilentSessionNotificationRepository(
50
+ localCommandRunner,
51
+ new RealSleeper(),
52
+ ),
50
53
  new NodeTmuxSessionRepository(localCommandRunner),
51
54
  cooldownStateFilePath !== null
52
55
  ? new FileSystemSessionDegenerationCooldownStateRepository(
@@ -1,4 +1,5 @@
1
1
  import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
2
+ import { Sleeper } from '../../domain/usecases/adapter-interfaces/Sleeper';
2
3
  import { TmuxSilentSessionNotificationRepository } from './TmuxSilentSessionNotificationRepository';
3
4
 
4
5
  type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
@@ -11,14 +12,55 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
11
12
  }),
12
13
  });
13
14
 
15
+ const createMockSleeper = (): Mocked<Sleeper> => ({
16
+ sleep: jest.fn().mockResolvedValue(undefined),
17
+ });
18
+
19
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
20
+
21
+ const paneWithInputBoxContent = (inputBoxContent: string): string =>
22
+ [
23
+ '● The previous assistant output line',
24
+ '',
25
+ '────────────────────────────────────────',
26
+ inputBoxContent,
27
+ '────────────────────────────────────────',
28
+ ' {Opus 4.5} (dev1) CTX[██░░░░░░░░]16%',
29
+ ].join('\n');
30
+
31
+ const createPaneAwareRunner = (
32
+ paneTexts: string[],
33
+ ): Mocked<LocalCommandRunner> => {
34
+ let capturedPaneIndex = 0;
35
+ return {
36
+ runCommand: jest.fn().mockImplementation((_program, args: string[]) => {
37
+ if (args[0] !== 'capture-pane') {
38
+ return Promise.resolve({ stdout: '', stderr: '', exitCode: 0 });
39
+ }
40
+ const paneText =
41
+ paneTexts[Math.min(capturedPaneIndex, paneTexts.length - 1)];
42
+ capturedPaneIndex += 1;
43
+ return Promise.resolve({ stdout: paneText, stderr: '', exitCode: 0 });
44
+ }),
45
+ };
46
+ };
47
+
48
+ const submitKeyCallCount = (runner: Mocked<LocalCommandRunner>): number =>
49
+ runner.runCommand.mock.calls.filter(
50
+ (call) => call[1][0] === 'send-keys' && call[1][3] === 'Enter',
51
+ ).length;
52
+
14
53
  describe('TmuxSilentSessionNotificationRepository', () => {
15
54
  describe('sendSelfCheckNotification', () => {
16
55
  it('wraps the message in bracketed-paste framing, sends it literally, then submits it with Enter', async () => {
17
56
  const runner = createMockRunner();
18
- const repository = new TmuxSilentSessionNotificationRepository(runner);
57
+ const repository = new TmuxSilentSessionNotificationRepository(
58
+ runner,
59
+ createMockSleeper(),
60
+ );
19
61
 
20
62
  await repository.sendSelfCheckNotification(
21
- 'https_//github_com/owner/repo/issues/9',
63
+ sessionName,
22
64
  'self check message',
23
65
  );
24
66
 
@@ -27,26 +69,26 @@ describe('TmuxSilentSessionNotificationRepository', () => {
27
69
  [
28
70
  'send-keys',
29
71
  '-t',
30
- 'https_//github_com/owner/repo/issues/9',
72
+ sessionName,
31
73
  '-l',
32
74
  '\x1b[200~self check message\x1b[201~',
33
75
  ],
34
76
  ]);
35
77
  expect(runner.runCommand.mock.calls[1]).toEqual([
36
78
  'tmux',
37
- ['send-keys', '-t', 'https_//github_com/owner/repo/issues/9', 'Enter'],
79
+ ['send-keys', '-t', sessionName, 'Enter'],
38
80
  ]);
39
81
  });
40
82
 
41
83
  it('flattens a multi-line message into a single line so no newline reaches the input box', async () => {
42
84
  const runner = createMockRunner();
43
- const repository = new TmuxSilentSessionNotificationRepository(runner);
85
+ const repository = new TmuxSilentSessionNotificationRepository(
86
+ runner,
87
+ createMockSleeper(),
88
+ );
44
89
  const multiLineMessage = 'first line\nsecond line\n\nthird line';
45
90
 
46
- await repository.sendSelfCheckNotification(
47
- 'https_//github_com/owner/repo/issues/9',
48
- multiLineMessage,
49
- );
91
+ await repository.sendSelfCheckNotification(sessionName, multiLineMessage);
50
92
 
51
93
  const literalArgument = runner.runCommand.mock.calls[0][1][4];
52
94
  expect(literalArgument).not.toContain('\n');
@@ -58,10 +100,13 @@ describe('TmuxSilentSessionNotificationRepository', () => {
58
100
 
59
101
  it('keeps the bracketed-paste framing around the flattened message', async () => {
60
102
  const runner = createMockRunner();
61
- const repository = new TmuxSilentSessionNotificationRepository(runner);
103
+ const repository = new TmuxSilentSessionNotificationRepository(
104
+ runner,
105
+ createMockSleeper(),
106
+ );
62
107
 
63
108
  await repository.sendSelfCheckNotification(
64
- 'https_//github_com/owner/repo/issues/9',
109
+ sessionName,
65
110
  'first line\nsecond line',
66
111
  );
67
112
 
@@ -71,16 +116,19 @@ describe('TmuxSilentSessionNotificationRepository', () => {
71
116
  expect(literalBytes.endsWith('1b5b3230317e')).toBe(true);
72
117
  expect(runner.runCommand.mock.calls[1]).toEqual([
73
118
  'tmux',
74
- ['send-keys', '-t', 'https_//github_com/owner/repo/issues/9', 'Enter'],
119
+ ['send-keys', '-t', sessionName, 'Enter'],
75
120
  ]);
76
121
  });
77
122
 
78
123
  it('collapses the run of spaces left by consecutive line breaks', async () => {
79
124
  const runner = createMockRunner();
80
- const repository = new TmuxSilentSessionNotificationRepository(runner);
125
+ const repository = new TmuxSilentSessionNotificationRepository(
126
+ runner,
127
+ createMockSleeper(),
128
+ );
81
129
 
82
130
  await repository.sendSelfCheckNotification(
83
- 'https_//github_com/owner/repo/issues/9',
131
+ sessionName,
84
132
  'section one\n\n\nsection two',
85
133
  );
86
134
 
@@ -97,7 +145,10 @@ describe('TmuxSilentSessionNotificationRepository', () => {
97
145
  stderr: "can't find session",
98
146
  exitCode: 1,
99
147
  });
100
- const repository = new TmuxSilentSessionNotificationRepository(runner);
148
+ const repository = new TmuxSilentSessionNotificationRepository(
149
+ runner,
150
+ createMockSleeper(),
151
+ );
101
152
 
102
153
  await expect(
103
154
  repository.sendSelfCheckNotification('missing_session', 'message'),
@@ -105,5 +156,96 @@ describe('TmuxSilentSessionNotificationRepository', () => {
105
156
  'Failed to send notification to tmux session "missing_session"',
106
157
  );
107
158
  });
159
+
160
+ it('pushes the message out with another Enter while it is still sitting in the input box', async () => {
161
+ const runner = createPaneAwareRunner([
162
+ paneWithInputBoxContent(
163
+ '❯ [TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
164
+ ),
165
+ paneWithInputBoxContent('❯ '),
166
+ ]);
167
+ const repository = new TmuxSilentSessionNotificationRepository(
168
+ runner,
169
+ createMockSleeper(),
170
+ );
171
+
172
+ await repository.sendSelfCheckNotification(
173
+ sessionName,
174
+ '[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
175
+ );
176
+
177
+ expect(submitKeyCallCount(runner)).toBe(2);
178
+ expect(runner.runCommand.mock.calls[2]).toEqual([
179
+ 'tmux',
180
+ ['capture-pane', '-p', '-t', sessionName],
181
+ ]);
182
+ });
183
+
184
+ it('sends no further Enter once the input box no longer holds the message', async () => {
185
+ const runner = createPaneAwareRunner([paneWithInputBoxContent('❯ ')]);
186
+ const repository = new TmuxSilentSessionNotificationRepository(
187
+ runner,
188
+ createMockSleeper(),
189
+ );
190
+
191
+ await repository.sendSelfCheckNotification(
192
+ sessionName,
193
+ '[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
194
+ );
195
+
196
+ expect(submitKeyCallCount(runner)).toBe(1);
197
+ });
198
+
199
+ it('sends no further Enter while the message is held as a queued message', async () => {
200
+ const runner = createPaneAwareRunner([
201
+ paneWithInputBoxContent('❯ Press up to edit queued messages'),
202
+ ]);
203
+ const repository = new TmuxSilentSessionNotificationRepository(
204
+ runner,
205
+ createMockSleeper(),
206
+ );
207
+
208
+ await repository.sendSelfCheckNotification(
209
+ sessionName,
210
+ '[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
211
+ );
212
+
213
+ expect(submitKeyCallCount(runner)).toBe(1);
214
+ });
215
+
216
+ it('stops pushing after the configured attempt limit so newlines cannot pile up', async () => {
217
+ const runner = createPaneAwareRunner([
218
+ paneWithInputBoxContent(
219
+ '❯ [TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
220
+ ),
221
+ ]);
222
+ const repository = new TmuxSilentSessionNotificationRepository(
223
+ runner,
224
+ createMockSleeper(),
225
+ 2,
226
+ );
227
+
228
+ await repository.sendSelfCheckNotification(
229
+ sessionName,
230
+ '[TDPM_SILENT_SESSION_SELF_CHECK_REMINDER] report your state',
231
+ );
232
+
233
+ expect(submitKeyCallCount(runner)).toBe(3);
234
+ });
235
+
236
+ it('waits for the bracketed-paste parsing and rendering window before reading the input box back', async () => {
237
+ const runner = createPaneAwareRunner([paneWithInputBoxContent('❯ ')]);
238
+ const sleeper = createMockSleeper();
239
+ const repository = new TmuxSilentSessionNotificationRepository(
240
+ runner,
241
+ sleeper,
242
+ 3,
243
+ 2500,
244
+ );
245
+
246
+ await repository.sendSelfCheckNotification(sessionName, 'self check');
247
+
248
+ expect(sleeper.sleep).toHaveBeenCalledWith(2500);
249
+ });
108
250
  });
109
251
  });
@@ -1,11 +1,66 @@
1
1
  import { SilentSessionNotificationRepository } from '../../domain/usecases/adapter-interfaces/SilentSessionNotificationRepository';
2
2
  import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
+ import { Sleeper } from '../../domain/usecases/adapter-interfaces/Sleeper';
3
4
 
4
5
  const BRACKETED_PASTE_START = '\x1b[200~';
5
6
  const BRACKETED_PASTE_END = '\x1b[201~';
6
7
 
8
+ export const DEFAULT_SUBMIT_PUSH_OUT_ATTEMPT_LIMIT = 3;
9
+ export const DEFAULT_SUBMIT_PUSH_OUT_WAIT_MILLISECONDS = 2500;
10
+
11
+ const INPUT_BOX_BORDER_LINE_PATTERN = /^[\s─━╌┄┈│╭╮╰╯]+$/u;
12
+ const INPUT_PROMPT_PREFIX_PATTERN = /^[\s>❯│]+/u;
13
+ const INPUT_BOX_MESSAGE_PROBE_LENGTH = 24;
14
+
15
+ type InputBoxState = 'holdsMessage' | 'clearedMessage' | 'unreadable';
16
+
17
+ const withoutWhitespace = (value: string): string => value.replace(/\s+/gu, '');
18
+
19
+ export const extractTmuxInputBoxContent = (paneText: string): string | null => {
20
+ const lines = paneText.split('\n');
21
+ const borderLineIndexes = lines.flatMap((line, index) =>
22
+ line.trim().length > 0 && INPUT_BOX_BORDER_LINE_PATTERN.test(line)
23
+ ? [index]
24
+ : [],
25
+ );
26
+ if (borderLineIndexes.length < 2) {
27
+ return null;
28
+ }
29
+ const closingBorderIndex = borderLineIndexes[borderLineIndexes.length - 1];
30
+ const openingBorderIndex = borderLineIndexes[borderLineIndexes.length - 2];
31
+ return lines
32
+ .slice(openingBorderIndex + 1, closingBorderIndex)
33
+ .map((line) => line.replace(INPUT_PROMPT_PREFIX_PATTERN, ''))
34
+ .join('');
35
+ };
36
+
37
+ export const resolveInputBoxStateFromPane = (
38
+ paneText: string,
39
+ singleLineMessage: string,
40
+ ): InputBoxState => {
41
+ const inputBoxContent = extractTmuxInputBoxContent(paneText);
42
+ if (inputBoxContent === null) {
43
+ return 'unreadable';
44
+ }
45
+ const messageProbe = withoutWhitespace(singleLineMessage).slice(
46
+ 0,
47
+ INPUT_BOX_MESSAGE_PROBE_LENGTH,
48
+ );
49
+ if (messageProbe.length === 0) {
50
+ return 'clearedMessage';
51
+ }
52
+ return withoutWhitespace(inputBoxContent).includes(messageProbe)
53
+ ? 'holdsMessage'
54
+ : 'clearedMessage';
55
+ };
56
+
7
57
  export class TmuxSilentSessionNotificationRepository implements SilentSessionNotificationRepository {
8
- constructor(private readonly localCommandRunner: LocalCommandRunner) {}
58
+ constructor(
59
+ private readonly localCommandRunner: LocalCommandRunner,
60
+ private readonly sleeper: Sleeper,
61
+ private readonly submitPushOutAttemptLimit: number = DEFAULT_SUBMIT_PUSH_OUT_ATTEMPT_LIMIT,
62
+ private readonly submitPushOutWaitMilliseconds: number = DEFAULT_SUBMIT_PUSH_OUT_WAIT_MILLISECONDS,
63
+ ) {}
9
64
 
10
65
  sendSelfCheckNotification = async (
11
66
  sessionName: string,
@@ -27,6 +82,11 @@ export class TmuxSilentSessionNotificationRepository implements SilentSessionNot
27
82
  }`,
28
83
  );
29
84
  }
85
+ await this.submitInputBox(sessionName);
86
+ await this.pushOutUnsubmittedMessage(sessionName, singleLineMessage);
87
+ };
88
+
89
+ private submitInputBox = async (sessionName: string): Promise<void> => {
30
90
  const submitResult = await this.localCommandRunner.runCommand('tmux', [
31
91
  'send-keys',
32
92
  '-t',
@@ -41,4 +101,58 @@ export class TmuxSilentSessionNotificationRepository implements SilentSessionNot
41
101
  );
42
102
  }
43
103
  };
104
+
105
+ private pushOutUnsubmittedMessage = async (
106
+ sessionName: string,
107
+ singleLineMessage: string,
108
+ ): Promise<void> => {
109
+ for (
110
+ let attempt = 0;
111
+ attempt < this.submitPushOutAttemptLimit;
112
+ attempt += 1
113
+ ) {
114
+ await this.sleeper.sleep(this.submitPushOutWaitMilliseconds);
115
+ const inputBoxState = await this.readInputBoxState(
116
+ sessionName,
117
+ singleLineMessage,
118
+ );
119
+ if (inputBoxState === 'clearedMessage') {
120
+ return;
121
+ }
122
+ if (inputBoxState === 'unreadable') {
123
+ console.log(
124
+ `Could not read back the input box of tmux session "${sessionName}"; leaving the notification as sent without pushing it out.`,
125
+ );
126
+ return;
127
+ }
128
+ await this.submitInputBox(sessionName);
129
+ }
130
+ console.log(
131
+ `Notification to tmux session "${sessionName}" was still held in the input box after ${this.submitPushOutAttemptLimit} push-out attempt(s).`,
132
+ );
133
+ };
134
+
135
+ private readInputBoxState = async (
136
+ sessionName: string,
137
+ singleLineMessage: string,
138
+ ): Promise<InputBoxState> => {
139
+ const captureResult = await this.localCommandRunner.runCommand('tmux', [
140
+ 'capture-pane',
141
+ '-p',
142
+ '-t',
143
+ sessionName,
144
+ ]);
145
+ if (captureResult.exitCode !== 0) {
146
+ console.log(
147
+ `Failed to capture the pane of tmux session "${sessionName}": exit code ${captureResult.exitCode}${
148
+ captureResult.stderr ? `: ${captureResult.stderr}` : ''
149
+ }`,
150
+ );
151
+ return 'unreadable';
152
+ }
153
+ return resolveInputBoxStateFromPane(
154
+ captureResult.stdout,
155
+ singleLineMessage,
156
+ );
157
+ };
44
158
  }