github-issue-tower-defence-management 1.110.0 → 1.111.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 (97) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -7
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +9 -21
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +10 -15
  6. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  7. package/bin/adapter/proxy/RateLimitCache.js +8 -0
  8. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  9. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +104 -0
  10. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -0
  11. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +55 -19
  12. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
  13. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +75 -0
  14. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -0
  15. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +1 -1
  16. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -1
  17. package/bin/adapter/repositories/ProcFsProcessEnvironReader.js +73 -0
  18. package/bin/adapter/repositories/ProcFsProcessEnvironReader.js.map +1 -0
  19. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +8 -16
  20. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
  21. package/bin/domain/entities/InteractiveLiveSession.js +3 -0
  22. package/bin/domain/entities/InteractiveLiveSession.js.map +1 -0
  23. package/bin/domain/entities/LiveSessionProcessSnapshot.js +3 -0
  24. package/bin/domain/entities/LiveSessionProcessSnapshot.js.map +1 -0
  25. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +20 -33
  26. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  27. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +89 -0
  28. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -0
  29. package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js +3 -0
  30. package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js.map +1 -0
  31. package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js +3 -0
  32. package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js.map +1 -0
  33. package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js +3 -0
  34. package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -15
  37. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +96 -204
  38. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +19 -40
  39. package/src/adapter/proxy/RateLimitCache.test.ts +63 -0
  40. package/src/adapter/proxy/RateLimitCache.ts +11 -0
  41. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +147 -0
  42. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +75 -0
  43. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +82 -44
  44. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +62 -24
  45. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +138 -0
  46. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +93 -0
  47. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +6 -4
  48. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +1 -1
  49. package/src/adapter/repositories/ProcFsProcessEnvironReader.test.ts +60 -0
  50. package/src/adapter/repositories/ProcFsProcessEnvironReader.ts +38 -0
  51. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +61 -81
  52. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +8 -25
  53. package/src/domain/entities/InteractiveLiveSession.ts +5 -0
  54. package/src/domain/entities/LiveSessionProcessSnapshot.ts +17 -0
  55. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +125 -349
  56. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +33 -55
  57. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +220 -0
  58. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +105 -0
  59. package/src/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.ts +7 -0
  60. package/src/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.ts +5 -0
  61. package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +1 -1
  62. package/src/domain/usecases/adapter-interfaces/ProcessEnvironReader.ts +3 -0
  63. package/src/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.ts +1 -1
  64. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  65. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -7
  66. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  67. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  68. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +18 -0
  69. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
  70. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +9 -5
  71. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
  72. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +21 -0
  73. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -0
  74. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +1 -1
  75. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -1
  76. package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts +12 -0
  77. package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts.map +1 -0
  78. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -4
  79. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
  80. package/types/domain/entities/InteractiveLiveSession.d.ts +6 -0
  81. package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -0
  82. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +16 -0
  83. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -0
  84. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +6 -11
  85. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  86. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +23 -0
  87. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -0
  88. package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts +5 -0
  89. package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
  90. package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts +5 -0
  91. package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts.map +1 -0
  92. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +1 -1
  93. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -1
  94. package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts +4 -0
  95. package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts.map +1 -0
  96. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts +1 -1
  97. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts.map +1 -1
@@ -0,0 +1,3 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ //# sourceMappingURL=ProcessEnvironReader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProcessEnvironReader.js","sourceRoot":"","sources":["../../../../src/domain/usecases/adapter-interfaces/ProcessEnvironReader.ts"],"names":[],"mappings":""}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "github-issue-tower-defence-management",
3
- "version": "1.110.0",
3
+ "version": "1.111.0",
4
4
  "description": "",
5
5
  "main": "bin/index.js",
6
6
  "scripts": {
@@ -108,8 +108,7 @@ export class HandleScheduledEventUseCaseHandler {
108
108
  inTmuxConsoleToken?: string;
109
109
  inTmuxProjectOrder?: string[];
110
110
  inTmuxLauncherCommand?: string;
111
- sessionOutputRootDirectory?: string;
112
- sessionTranscriptRootDirectory?: string;
111
+ silentNotificationEnabled?: boolean;
113
112
  ownerCallMarker?: string;
114
113
  subAgentOutputRootDirectory?: string;
115
114
  subAgentProcessMatchPattern?: string;
@@ -563,10 +562,9 @@ export class HandleScheduledEventUseCaseHandler {
563
562
  }
564
563
 
565
564
  try {
566
- const sessionOutputRootDirectory =
567
- mergedInput.sessionOutputRootDirectory ??
568
- process.env.TDPM_SESSION_OUTPUT_ROOT_DIRECTORY ??
569
- null;
565
+ const silentNotificationEnabled =
566
+ mergedInput.silentNotificationEnabled ??
567
+ process.env.TDPM_SILENT_NOTIFICATION_ENABLED === 'true';
570
568
  const subAgentOutputRootDirectory =
571
569
  mergedInput.subAgentOutputRootDirectory ??
572
570
  process.env.TDPM_SUBAGENT_OUTPUT_ROOT_DIRECTORY ??
@@ -575,10 +573,6 @@ export class HandleScheduledEventUseCaseHandler {
575
573
  mergedInput.subAgentProcessMatchPattern ??
576
574
  process.env.TDPM_SUBAGENT_PROCESS_MATCH_PATTERN ??
577
575
  null;
578
- const sessionTranscriptRootDirectory =
579
- mergedInput.sessionTranscriptRootDirectory ??
580
- process.env.TDPM_SESSION_TRANSCRIPT_ROOT_DIRECTORY ??
581
- null;
582
576
  const ownerCallMarker =
583
577
  mergedInput.ownerCallMarker ??
584
578
  process.env.TDPM_SILENT_OWNER_CALL_MARKER ??
@@ -588,13 +582,9 @@ export class HandleScheduledEventUseCaseHandler {
588
582
  process.env.TDPM_SUBAGENT_TRANSCRIPT_ROOT_DIRECTORY ??
589
583
  null;
590
584
  await notifySilentTmuxSessions({
591
- project: result.project,
592
- allowCacheMinutes: mergedInput.allowIssueCacheMinutes,
593
- issueRepository,
585
+ enabled: silentNotificationEnabled,
594
586
  localCommandRunner: nodeLocalCommandRunner,
595
587
  cacheRepository: localStorageCacheRepository,
596
- sessionOutputRootDirectory,
597
- sessionTranscriptRootDirectory,
598
588
  ownerCallMarker,
599
589
  subAgentOutputRootDirectory,
600
590
  subAgentProcessMatchPattern,
@@ -1,11 +1,8 @@
1
1
  import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
- import { Issue } from '../../../domain/entities/Issue';
5
- import { Project } from '../../../domain/entities/Project';
6
4
  import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
7
- import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
8
- import { IN_TMUX_STATUS_NAME } from '../../../domain/entities/WorkflowStatus';
5
+ import { ProcessEnvironReader } from '../../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
9
6
  import { LocalStorageCacheRepository } from '../../repositories/LocalStorageCacheRepository';
10
7
  import { LocalStorageRepository } from '../../repositories/LocalStorageRepository';
11
8
  import { SilentSessionMessageTemplates } from '../../repositories/ConfigurableSilentSessionMessageComposer';
@@ -16,8 +13,10 @@ import {
16
13
 
17
14
  const NOW = new Date('2026-06-26T00:00:00.000Z');
18
15
  const NOW_EPOCH_SECONDS = Math.floor(NOW.getTime() / 1000);
19
- const ALLOW_CACHE_MINUTES = 10;
20
- const SESSION_NAME = 'https_//github_com/demo/repo/issues/1';
16
+ const SESSION_NAME = 'workbench';
17
+ const SESSION_ID = 'wb-uuid';
18
+ const PANE_PID = 200;
19
+ const CLAUDE_PID = 201;
21
20
 
22
21
  const EMPTY_TEMPLATES: SilentSessionMessageTemplates = {
23
22
  mainStalledMessage: null,
@@ -25,52 +24,6 @@ const EMPTY_TEMPLATES: SilentSessionMessageTemplates = {
25
24
  subAgentMessageFooter: null,
26
25
  };
27
26
 
28
- const makeProject = (): Project => ({
29
- id: 'project-1',
30
- url: 'https://github.com/users/user/projects/1',
31
- databaseId: 1,
32
- name: 'Test Project',
33
- status: {
34
- name: 'Status',
35
- fieldId: 'field-1',
36
- statuses: [],
37
- },
38
- nextActionDate: null,
39
- nextActionHour: null,
40
- story: null,
41
- remainingEstimationMinutes: null,
42
- dependedIssueUrlSeparatedByComma: null,
43
- completionDate50PercentConfidence: null,
44
- });
45
-
46
- const makeIssue = (overrides: Partial<Issue> = {}): Issue => ({
47
- nameWithOwner: 'demo/repo',
48
- number: 1,
49
- title: 'Issue 1',
50
- state: 'OPEN',
51
- status: IN_TMUX_STATUS_NAME,
52
- story: null,
53
- nextActionDate: null,
54
- nextActionHour: null,
55
- estimationMinutes: null,
56
- dependedIssueUrls: [],
57
- completionDate50PercentConfidence: null,
58
- url: 'https://github.com/demo/repo/issues/1',
59
- assignees: [],
60
- labels: [],
61
- org: 'demo',
62
- repo: 'repo',
63
- body: '',
64
- itemId: 'item-1',
65
- isPr: false,
66
- isInProgress: false,
67
- isClosed: false,
68
- createdAt: new Date(),
69
- author: '',
70
- closingIssueReferenceUrls: [],
71
- ...overrides,
72
- });
73
-
74
27
  type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
75
28
 
76
29
  const createMockRunner = (): Mocked<LocalCommandRunner> => ({
@@ -81,34 +34,46 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
81
34
  }),
82
35
  });
83
36
 
84
- const createMockIssueRepository = (
85
- issues: Issue[],
86
- ): Pick<IssueRepository, 'getAllOpened'> => ({
87
- getAllOpened: jest.fn().mockResolvedValue(issues),
88
- });
89
-
90
37
  describe('notifySilentTmuxSessions', () => {
91
- let outputDirectory: string;
38
+ let configDir: string;
92
39
  let cacheDirectory: string;
93
40
 
94
41
  beforeEach(() => {
95
42
  jest.spyOn(console, 'log').mockImplementation(() => undefined);
96
- outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'silent-output-'));
43
+ configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'silent-config-'));
97
44
  cacheDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'silent-cache-'));
98
45
  });
99
46
 
100
47
  afterEach(() => {
101
48
  jest.restoreAllMocks();
102
- fs.rmSync(outputDirectory, { force: true, recursive: true });
49
+ fs.rmSync(configDir, { force: true, recursive: true });
103
50
  fs.rmSync(cacheDirectory, { force: true, recursive: true });
104
51
  });
105
52
 
106
- const writeSilentOutputFile = (sessionName: string): void => {
107
- const fileName = sessionName.replace(/\//g, '_');
108
- const filePath = path.join(outputDirectory, fileName);
109
- fs.writeFileSync(filePath, 'output', 'utf8');
110
- const silentEpoch = NOW_EPOCH_SECONDS - 11 * 60;
111
- fs.utimesSync(filePath, silentEpoch, silentEpoch);
53
+ const writeTranscript = (lines: object[]): void => {
54
+ const projectDirectory = path.join(configDir, 'projects', '-home-user');
55
+ fs.mkdirSync(projectDirectory, { recursive: true });
56
+ fs.writeFileSync(
57
+ path.join(projectDirectory, `${SESSION_ID}.jsonl`),
58
+ lines.map((line) => JSON.stringify(line)).join('\n'),
59
+ 'utf8',
60
+ );
61
+ };
62
+
63
+ const silentAssistantTranscript = (): void => {
64
+ const silentTimestamp = new Date(
65
+ (NOW_EPOCH_SECONDS - 11 * 60) * 1000,
66
+ ).toISOString();
67
+ writeTranscript([
68
+ {
69
+ type: 'assistant',
70
+ timestamp: silentTimestamp,
71
+ message: {
72
+ role: 'assistant',
73
+ content: [{ type: 'text', text: 'progress update' }],
74
+ },
75
+ },
76
+ ]);
112
77
  };
113
78
 
114
79
  const makeCacheRepository = (): LocalStorageCacheRepository =>
@@ -117,16 +82,44 @@ describe('notifySilentTmuxSessions', () => {
117
82
  cacheDirectory,
118
83
  );
119
84
 
85
+ const makeEnvironReader = (): ProcessEnvironReader => ({
86
+ readEnviron: (pid: number) =>
87
+ pid === CLAUDE_PID
88
+ ? {
89
+ CLAUDE_CODE_SESSION_ID: SESSION_ID,
90
+ CLAUDE_CONFIG_DIR: configDir,
91
+ }
92
+ : null,
93
+ });
94
+
95
+ const liveSessionRunner = (): Mocked<LocalCommandRunner> => {
96
+ const runner = createMockRunner();
97
+ runner.runCommand.mockImplementation(async (program, args) => {
98
+ if (program === 'tmux' && args[0] === 'list-sessions') {
99
+ return { stdout: `${SESSION_NAME}\n`, stderr: '', exitCode: 0 };
100
+ }
101
+ if (program === 'tmux' && args[0] === 'list-panes') {
102
+ return { stdout: `${PANE_PID}\n`, stderr: '', exitCode: 0 };
103
+ }
104
+ if (program === 'ps') {
105
+ return {
106
+ stdout: ` ${PANE_PID} 1 shell\n ${CLAUDE_PID} ${PANE_PID} claude --name ${SESSION_NAME}\n`,
107
+ stderr: '',
108
+ exitCode: 0,
109
+ };
110
+ }
111
+ return { stdout: '', stderr: '', exitCode: 0 };
112
+ });
113
+ return runner;
114
+ };
115
+
120
116
  const baseParams = (
121
117
  runner: LocalCommandRunner,
122
118
  ): Parameters<typeof notifySilentTmuxSessions>[0] => ({
123
- project: makeProject(),
124
- allowCacheMinutes: ALLOW_CACHE_MINUTES,
125
- issueRepository: createMockIssueRepository([makeIssue()]),
119
+ enabled: true,
126
120
  localCommandRunner: runner,
121
+ processEnvironReader: makeEnvironReader(),
127
122
  cacheRepository: makeCacheRepository(),
128
- sessionOutputRootDirectory: outputDirectory,
129
- sessionTranscriptRootDirectory: null,
130
123
  ownerCallMarker: null,
131
124
  subAgentOutputRootDirectory: null,
132
125
  subAgentProcessMatchPattern: null,
@@ -136,20 +129,9 @@ describe('notifySilentTmuxSessions', () => {
136
129
  ...DEFAULT_NOTIFY_SILENT_TMUX_SESSIONS_PARAMS,
137
130
  });
138
131
 
139
- const listSessionsRunner = (): Mocked<LocalCommandRunner> => {
140
- const runner = createMockRunner();
141
- runner.runCommand.mockImplementation(async (program, args) => {
142
- if (program === 'tmux' && args[0] === 'list-sessions') {
143
- return { stdout: `${SESSION_NAME}\n`, stderr: '', exitCode: 0 };
144
- }
145
- return { stdout: '', stderr: '', exitCode: 0 };
146
- });
147
- return runner;
148
- };
149
-
150
- it('sends a main stalled notification to a silent monitored live session', async () => {
151
- writeSilentOutputFile(SESSION_NAME);
152
- const runner = listSessionsRunner();
132
+ it('sends a main stalled notification to a silent plain-named live session', async () => {
133
+ silentAssistantTranscript();
134
+ const runner = liveSessionRunner();
153
135
 
154
136
  await notifySilentTmuxSessions(baseParams(runner));
155
137
 
@@ -160,52 +142,21 @@ describe('notifySilentTmuxSessions', () => {
160
142
  expect(sendCall?.[1][4]).toContain('You have produced no output for');
161
143
  });
162
144
 
163
- it('does nothing when neither output root nor sub-agent pattern is configured', async () => {
164
- writeSilentOutputFile(SESSION_NAME);
165
- const runner = createMockRunner();
145
+ it('does nothing when the step is not enabled', async () => {
146
+ silentAssistantTranscript();
147
+ const runner = liveSessionRunner();
166
148
 
167
149
  await notifySilentTmuxSessions({
168
150
  ...baseParams(runner),
169
- sessionOutputRootDirectory: null,
170
- subAgentProcessMatchPattern: null,
151
+ enabled: false,
171
152
  });
172
153
 
173
154
  expect(runner.runCommand.mock.calls).toHaveLength(0);
174
155
  });
175
156
 
176
- it('sends a sub-agent notification driven by the process match pattern even when the main session is not silent', async () => {
177
- const runner = createMockRunner();
178
- runner.runCommand.mockImplementation(async (program, args) => {
179
- if (program === 'tmux' && args[0] === 'list-sessions') {
180
- return { stdout: `${SESSION_NAME}\n`, stderr: '', exitCode: 0 };
181
- }
182
- if (program === 'ps') {
183
- return {
184
- stdout: ` 1200 worker session=${SESSION_NAME} label=task-a\n`,
185
- stderr: '',
186
- exitCode: 0,
187
- };
188
- }
189
- return { stdout: '', stderr: '', exitCode: 0 };
190
- });
191
-
192
- await notifySilentTmuxSessions({
193
- ...baseParams(runner),
194
- sessionOutputRootDirectory: null,
195
- subAgentProcessMatchPattern:
196
- 'session=(?<session>[^ ]+) label=(?<label>[^ ]+)',
197
- });
198
-
199
- const sendCall = runner.runCommand.mock.calls.find(
200
- (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
201
- );
202
- expect(sendCall?.[1][2]).toBe(SESSION_NAME);
203
- expect(sendCall?.[1][4]).toContain('task-a');
204
- });
205
-
206
157
  it('uses the configured main stalled message template when provided', async () => {
207
- writeSilentOutputFile(SESSION_NAME);
208
- const runner = listSessionsRunner();
158
+ silentAssistantTranscript();
159
+ const runner = liveSessionRunner();
209
160
 
210
161
  await notifySilentTmuxSessions({
211
162
  ...baseParams(runner),
@@ -223,41 +174,28 @@ describe('notifySilentTmuxSessions', () => {
223
174
  });
224
175
 
225
176
  it('suppresses the main stalled notification when the transcript shows an unanswered owner call', async () => {
226
- writeSilentOutputFile(SESSION_NAME);
227
- const transcriptDirectory = fs.mkdtempSync(
228
- path.join(os.tmpdir(), 'silent-transcript-'),
229
- );
230
- const transcriptPath = path.join(
231
- transcriptDirectory,
232
- `${SESSION_NAME.replace(/\//g, '_')}.jsonl`,
233
- );
234
- fs.writeFileSync(
235
- transcriptPath,
236
- [
237
- JSON.stringify({
238
- type: 'user',
239
- timestamp: '2026-06-25T23:00:00.000Z',
240
- message: { role: 'user', content: 'go ahead' },
241
- }),
242
- JSON.stringify({
243
- type: 'assistant',
244
- timestamp: '2026-06-25T23:50:00.000Z',
245
- message: {
246
- role: 'assistant',
247
- stop_reason: 'end_turn',
248
- content: [
249
- { type: 'text', text: 'waiting <<OWNER_CALL>> please decide' },
250
- ],
251
- },
252
- }),
253
- ].join('\n'),
254
- 'utf8',
255
- );
256
- const runner = listSessionsRunner();
177
+ writeTranscript([
178
+ {
179
+ type: 'user',
180
+ timestamp: '2026-06-25T23:00:00.000Z',
181
+ message: { role: 'user', content: 'go ahead' },
182
+ },
183
+ {
184
+ type: 'assistant',
185
+ timestamp: '2026-06-25T23:50:00.000Z',
186
+ message: {
187
+ role: 'assistant',
188
+ stop_reason: 'end_turn',
189
+ content: [
190
+ { type: 'text', text: 'waiting <<OWNER_CALL>> please decide' },
191
+ ],
192
+ },
193
+ },
194
+ ]);
195
+ const runner = liveSessionRunner();
257
196
 
258
197
  await notifySilentTmuxSessions({
259
198
  ...baseParams(runner),
260
- sessionTranscriptRootDirectory: transcriptDirectory,
261
199
  ownerCallMarker: '<<OWNER_CALL>>',
262
200
  });
263
201
 
@@ -265,65 +203,19 @@ describe('notifySilentTmuxSessions', () => {
265
203
  (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
266
204
  );
267
205
  expect(sendCall).toBeUndefined();
268
- fs.rmSync(transcriptDirectory, { force: true, recursive: true });
269
- });
270
-
271
- it('sends a sub-agent notification driven by a running transcript even when the main session is not silent', async () => {
272
- const transcriptRoot = fs.mkdtempSync(
273
- path.join(os.tmpdir(), 'silent-subagent-tx-'),
274
- );
275
- const subAgentsDir = path.join(
276
- transcriptRoot,
277
- SESSION_NAME.replace(/\//g, '_'),
278
- 'subagents',
279
- );
280
- fs.mkdirSync(subAgentsDir, { recursive: true });
281
- const agentPath = path.join(subAgentsDir, 'agent-running1.jsonl');
282
- fs.writeFileSync(
283
- agentPath,
284
- [
285
- JSON.stringify({
286
- type: 'user',
287
- timestamp: '2026-06-25T23:30:00.000Z',
288
- message: { role: 'user' },
289
- }),
290
- JSON.stringify({
291
- type: 'assistant',
292
- timestamp: '2026-06-25T23:40:00.000Z',
293
- message: { role: 'assistant', stop_reason: 'tool_use' },
294
- }),
295
- ].join('\n'),
296
- 'utf8',
297
- );
298
- const silentEpoch = NOW_EPOCH_SECONDS - 6 * 60;
299
- fs.utimesSync(agentPath, silentEpoch, silentEpoch);
300
- const runner = listSessionsRunner();
301
-
302
- await notifySilentTmuxSessions({
303
- ...baseParams(runner),
304
- sessionOutputRootDirectory: null,
305
- subAgentTranscriptRootDirectory: transcriptRoot,
306
- });
307
-
308
- const sendCall = runner.runCommand.mock.calls.find(
309
- (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
310
- );
311
- expect(sendCall?.[1][2]).toBe(SESSION_NAME);
312
- expect(sendCall?.[1][4]).toContain('agent-running1');
313
- fs.rmSync(transcriptRoot, { force: true, recursive: true });
314
206
  });
315
207
 
316
208
  it('does not re-notify the same silent session on the next cycle within cooldown', async () => {
317
- writeSilentOutputFile(SESSION_NAME);
209
+ silentAssistantTranscript();
318
210
  const cacheRepository = makeCacheRepository();
319
- const firstRunner = listSessionsRunner();
211
+ const firstRunner = liveSessionRunner();
320
212
 
321
213
  await notifySilentTmuxSessions({
322
214
  ...baseParams(firstRunner),
323
215
  cacheRepository,
324
216
  });
325
217
 
326
- const secondRunner = listSessionsRunner();
218
+ const secondRunner = liveSessionRunner();
327
219
  await notifySilentTmuxSessions({
328
220
  ...baseParams(secondRunner),
329
221
  cacheRepository,
@@ -1,11 +1,9 @@
1
- import { Project } from '../../../domain/entities/Project';
2
1
  import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
- import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
4
2
  import { SessionSubAgentActivityRepository } from '../../../domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository';
5
3
  import { OwnerCallStatusProvider } from '../../../domain/usecases/adapter-interfaces/OwnerCallStatusProvider';
4
+ import { ProcessEnvironReader } from '../../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
6
5
  import {
7
6
  NotifySilentLiveSessionsUseCase,
8
- DEFAULT_MONITORED_STATUS,
9
7
  DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
10
8
  DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS,
11
9
  DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS,
@@ -13,7 +11,9 @@ import {
13
11
  DEFAULT_NOTIFICATION_STAGGER_SECONDS,
14
12
  } from '../../../domain/usecases/NotifySilentLiveSessionsUseCase';
15
13
  import { DefaultSilentSessionMessageComposer } from '../../../domain/usecases/DefaultSilentSessionMessageComposer';
16
- import { NodeTmuxSessionRepository } from '../../repositories/NodeTmuxSessionRepository';
14
+ import { LocalProcessLiveSessionProcessSnapshotProvider } from '../../repositories/LocalProcessLiveSessionProcessSnapshotProvider';
15
+ import { ProcFsProcessEnvironReader } from '../../repositories/ProcFsProcessEnvironReader';
16
+ import { FileSystemInteractiveLiveSessionTranscriptResolver } from '../../repositories/FileSystemInteractiveLiveSessionTranscriptResolver';
17
17
  import { FileSystemSessionOutputActivityRepository } from '../../repositories/FileSystemSessionOutputActivityRepository';
18
18
  import { TmuxSilentSessionNotificationRepository } from '../../repositories/TmuxSilentSessionNotificationRepository';
19
19
  import { LocalStorageCacheRepository } from '../../repositories/LocalStorageCacheRepository';
@@ -31,13 +31,10 @@ import {
31
31
  import { RealSleeper } from '../../repositories/RealSleeper';
32
32
 
33
33
  export type NotifySilentTmuxSessionsParams = {
34
- project: Project;
35
- allowCacheMinutes: number;
36
- issueRepository: Pick<IssueRepository, 'getAllOpened'>;
34
+ enabled: boolean;
37
35
  localCommandRunner: LocalCommandRunner;
36
+ processEnvironReader?: ProcessEnvironReader;
38
37
  cacheRepository: Pick<LocalStorageCacheRepository, 'getLatest' | 'set'>;
39
- sessionOutputRootDirectory: string | null;
40
- sessionTranscriptRootDirectory: string | null;
41
38
  ownerCallMarker: string | null;
42
39
  subAgentOutputRootDirectory: string | null;
43
40
  subAgentProcessMatchPattern: string | null;
@@ -52,18 +49,10 @@ export type NotifySilentTmuxSessionsParams = {
52
49
  };
53
50
 
54
51
  const createOwnerCallStatusProvider = (
55
- sessionTranscriptRootDirectory: string | null,
56
52
  ownerCallMarker: string | null,
57
53
  ): OwnerCallStatusProvider => {
58
- if (
59
- sessionTranscriptRootDirectory !== null &&
60
- ownerCallMarker !== null &&
61
- ownerCallMarker.length > 0
62
- ) {
63
- return new TranscriptOwnerCallStatusProvider(
64
- sessionTranscriptRootDirectory,
65
- ownerCallMarker,
66
- );
54
+ if (ownerCallMarker !== null && ownerCallMarker.length > 0) {
55
+ return new TranscriptOwnerCallStatusProvider(ownerCallMarker);
67
56
  }
68
57
  return new NoUnansweredOwnerCallStatusProvider();
69
58
  };
@@ -97,13 +86,10 @@ export const notifySilentTmuxSessions = async (
97
86
  params: NotifySilentTmuxSessionsParams,
98
87
  ): Promise<void> => {
99
88
  const {
100
- project,
101
- allowCacheMinutes,
102
- issueRepository,
89
+ enabled,
103
90
  localCommandRunner,
91
+ processEnvironReader,
104
92
  cacheRepository,
105
- sessionOutputRootDirectory,
106
- sessionTranscriptRootDirectory,
107
93
  ownerCallMarker,
108
94
  subAgentOutputRootDirectory,
109
95
  subAgentProcessMatchPattern,
@@ -116,13 +102,9 @@ export const notifySilentTmuxSessions = async (
116
102
  messageTemplates,
117
103
  now,
118
104
  } = params;
119
- if (
120
- sessionOutputRootDirectory === null &&
121
- subAgentProcessMatchPattern === null &&
122
- subAgentTranscriptRootDirectory === null
123
- ) {
105
+ if (!enabled) {
124
106
  console.log(
125
- 'Silent live session notification skipped: no session output root directory, sub-agent process match pattern, or sub-agent transcript root directory is configured.',
107
+ 'Silent live session notification skipped: not enabled (set silentNotificationEnabled or TDPM_SILENT_NOTIFICATION_ENABLED=true to enable).',
126
108
  );
127
109
  return;
128
110
  }
@@ -131,9 +113,12 @@ export const notifySilentTmuxSessions = async (
131
113
  new DefaultSilentSessionMessageComposer(),
132
114
  );
133
115
  const useCase = new NotifySilentLiveSessionsUseCase(
134
- issueRepository,
135
- new NodeTmuxSessionRepository(localCommandRunner),
136
- new FileSystemSessionOutputActivityRepository(sessionOutputRootDirectory),
116
+ new LocalProcessLiveSessionProcessSnapshotProvider(
117
+ localCommandRunner,
118
+ processEnvironReader ?? new ProcFsProcessEnvironReader(),
119
+ ),
120
+ new FileSystemInteractiveLiveSessionTranscriptResolver(),
121
+ new FileSystemSessionOutputActivityRepository(),
137
122
  createSubAgentActivityRepository(
138
123
  subAgentTranscriptRootDirectory,
139
124
  subAgentProcessMatchPattern,
@@ -141,10 +126,7 @@ export const notifySilentTmuxSessions = async (
141
126
  localCommandRunner,
142
127
  now,
143
128
  ),
144
- createOwnerCallStatusProvider(
145
- sessionTranscriptRootDirectory,
146
- ownerCallMarker,
147
- ),
129
+ createOwnerCallStatusProvider(ownerCallMarker),
148
130
  new TmuxSilentSessionNotificationRepository(
149
131
  localCommandRunner,
150
132
  cacheRepository,
@@ -153,9 +135,6 @@ export const notifySilentTmuxSessions = async (
153
135
  new RealSleeper(),
154
136
  );
155
137
  await useCase.run({
156
- project,
157
- allowCacheMinutes,
158
- monitoredStatus: DEFAULT_MONITORED_STATUS,
159
138
  mainSilentThresholdSeconds,
160
139
  subAgentSilentThresholdSeconds,
161
140
  subAgentRunningThresholdSeconds,
@@ -15,6 +15,7 @@ import {
15
15
  writeRateLimit,
16
16
  writeSubscriptionDisabled,
17
17
  } from './RateLimitCache';
18
+ import { OauthTokenSelectUseCase } from '../../domain/usecases/OauthTokenSelectUseCase';
18
19
 
19
20
  const isRecord = (v: unknown): v is Record<string, unknown> =>
20
21
  v !== null && typeof v === 'object' && !Array.isArray(v);
@@ -863,4 +864,66 @@ describe('RateLimitCache', () => {
863
864
  expect(snapshot?.subscriptionDisabled).toBe(false);
864
865
  });
865
866
  });
867
+
868
+ describe('writeRateLimit preserves subscriptionDisabledEpoch across header writes', () => {
869
+ const headerBearingResponse: Record<string, string> = {
870
+ 'anthropic-ratelimit-unified-status': 'allowed',
871
+ 'anthropic-ratelimit-unified-5h-status': 'allowed',
872
+ 'anthropic-ratelimit-unified-5h-reset': '2000100',
873
+ 'anthropic-ratelimit-unified-5h-utilization': '0.1',
874
+ 'anthropic-ratelimit-unified-7d-status': 'allowed',
875
+ 'anthropic-ratelimit-unified-7d-reset': '2000200',
876
+ 'anthropic-ratelimit-unified-7d-utilization': '0.1',
877
+ };
878
+
879
+ it('keeps subscriptionDisabled: true after a later header-bearing 200 response', () => {
880
+ const token = 'sub-disabled-then-200-token';
881
+ writeSubscriptionDisabled(token);
882
+ writeRateLimit(token, headerBearingResponse, 200);
883
+ const snapshot = readRateLimit(token);
884
+ expect(snapshot?.subscriptionDisabled).toBe(true);
885
+ expect(snapshot?.fiveHourReset).toBe(2000100);
886
+ });
887
+
888
+ it('keeps subscriptionDisabled: true after a later header-bearing 429 response', () => {
889
+ const token = 'sub-disabled-then-429-token';
890
+ writeSubscriptionDisabled(token);
891
+ writeRateLimit(token, headerBearingResponse, 429);
892
+ const snapshot = readRateLimit(token);
893
+ expect(snapshot?.subscriptionDisabled).toBe(true);
894
+ });
895
+
896
+ it('still excludes the token from selection after a header-bearing response', () => {
897
+ const token = 'sub-disabled-then-200-selection-token';
898
+ writeSubscriptionDisabled(token);
899
+ writeRateLimit(token, headerBearingResponse, 200);
900
+ const snapshot = readRateLimit(token);
901
+ const useCase = new OauthTokenSelectUseCase();
902
+ const result = useCase.run(
903
+ [
904
+ {
905
+ name: 'disabled-token',
906
+ token,
907
+ snapshot:
908
+ snapshot === null
909
+ ? null
910
+ : {
911
+ fiveHourUtilization: snapshot.fiveHourUtilization,
912
+ fiveHourReset: snapshot.fiveHourReset,
913
+ sevenDayUtilization: snapshot.sevenDayUtilization,
914
+ sevenDayReset: snapshot.sevenDayReset,
915
+ },
916
+ subscriptionDisabled: snapshot?.subscriptionDisabled ?? false,
917
+ unifiedRejected: snapshot?.unifiedRejected ?? false,
918
+ },
919
+ ],
920
+ Date.now() / 1000,
921
+ );
922
+ expect(result.selected).toBeNull();
923
+ expect(result.metrics[0]?.eligible).toBe(false);
924
+ expect(result.metrics[0]?.exclusionReason).toBe(
925
+ 'organization has disabled Claude subscription access for Claude Code',
926
+ );
927
+ });
928
+ });
866
929
  });