github-issue-tower-defence-management 1.111.3 → 1.112.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 (38) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +9 -6
  3. package/bin/adapter/entry-points/console/ui-dist/assets/{index-BPH6W1yn.css → index-BaVsPpWs.css} +1 -1
  4. package/bin/adapter/entry-points/console/ui-dist/assets/{index-Bu5bQZyP.js → index-ITb3VBcs.js} +1 -1
  5. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +19 -3
  7. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  8. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +6 -0
  9. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  10. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +9 -0
  11. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  12. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +13 -3
  13. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  14. package/package.json +1 -1
  15. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.stories.tsx +72 -0
  16. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.test.tsx +140 -0
  17. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +8 -0
  18. package/src/adapter/entry-points/console/ui/src/index.css +88 -4
  19. package/src/adapter/entry-points/console/ui-dist/assets/{index-BPH6W1yn.css → index-BaVsPpWs.css} +1 -1
  20. package/src/adapter/entry-points/console/ui-dist/assets/{index-Bu5bQZyP.js → index-ITb3VBcs.js} +1 -1
  21. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  22. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +20 -1
  23. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +105 -2
  24. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +44 -0
  25. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +8 -0
  26. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +17 -1
  27. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +11 -0
  28. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +86 -1
  29. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +20 -7
  30. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +1 -0
  31. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  32. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +2 -0
  33. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  34. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +1 -0
  35. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  36. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  37. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +1 -0
  38. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -21,6 +21,7 @@ const CLAUDE_PID = 201;
21
21
 
22
22
  const EMPTY_TEMPLATES: SilentSessionMessageTemplates = {
23
23
  mainStalledMessage: null,
24
+ ownerReNotificationMessage: null,
24
25
  subAgentMessageHeader: null,
25
26
  subAgentMessageFooter: null,
26
27
  };
@@ -83,6 +84,11 @@ describe('notifySilentTmuxSessions', () => {
83
84
  cacheDirectory,
84
85
  );
85
86
 
87
+ const makeCacheRepositoryAt = (
88
+ basePath: string,
89
+ ): LocalStorageCacheRepository =>
90
+ new LocalStorageCacheRepository(new LocalStorageRepository(), basePath);
91
+
86
92
  const makeEnvironReader = (): ProcessEnvironReader => ({
87
93
  readEnviron: (pid: number) =>
88
94
  pid === CLAUDE_PID
@@ -163,6 +169,7 @@ describe('notifySilentTmuxSessions', () => {
163
169
  ...baseParams(runner),
164
170
  messageTemplates: {
165
171
  mainStalledMessage: 'CUSTOM_MAIN_TEMPLATE',
172
+ ownerReNotificationMessage: null,
166
173
  subAgentMessageHeader: null,
167
174
  subAgentMessageFooter: null,
168
175
  },
@@ -176,7 +183,10 @@ describe('notifySilentTmuxSessions', () => {
176
183
  );
177
184
  });
178
185
 
179
- it('suppresses the main stalled notification when the transcript shows an unanswered owner call', async () => {
186
+ it('sends no notification while an owner call is unanswered and the wait is within the threshold', async () => {
187
+ const recentOwnerCall = new Date(
188
+ (NOW_EPOCH_SECONDS - 5 * 60) * 1000,
189
+ ).toISOString();
180
190
  writeTranscript([
181
191
  {
182
192
  type: 'user',
@@ -185,7 +195,7 @@ describe('notifySilentTmuxSessions', () => {
185
195
  },
186
196
  {
187
197
  type: 'assistant',
188
- timestamp: '2026-06-25T23:50:00.000Z',
198
+ timestamp: recentOwnerCall,
189
199
  message: {
190
200
  role: 'assistant',
191
201
  stop_reason: 'end_turn',
@@ -208,6 +218,44 @@ describe('notifySilentTmuxSessions', () => {
208
218
  expect(sendCall).toBeUndefined();
209
219
  });
210
220
 
221
+ it('re-notifies the owner when an owner call has been unanswered past the threshold', async () => {
222
+ const stalePendingOwnerCall = new Date(
223
+ (NOW_EPOCH_SECONDS - 11 * 60) * 1000,
224
+ ).toISOString();
225
+ writeTranscript([
226
+ {
227
+ type: 'user',
228
+ timestamp: '2026-06-25T23:00:00.000Z',
229
+ message: { role: 'user', content: 'go ahead' },
230
+ },
231
+ {
232
+ type: 'assistant',
233
+ timestamp: stalePendingOwnerCall,
234
+ message: {
235
+ role: 'assistant',
236
+ stop_reason: 'end_turn',
237
+ content: [
238
+ { type: 'text', text: 'waiting <<OWNER_CALL>> please decide' },
239
+ ],
240
+ },
241
+ },
242
+ ]);
243
+ const runner = liveSessionRunner();
244
+
245
+ await notifySilentTmuxSessions({
246
+ ...baseParams(runner),
247
+ ownerCallMarker: '<<OWNER_CALL>>',
248
+ });
249
+
250
+ const sendCall = runner.runCommand.mock.calls.find(
251
+ (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
252
+ );
253
+ expect(sendCall?.[1][2]).toBe(SESSION_NAME);
254
+ expect(sendCall?.[1][4]).toContain(SILENT_SESSION_REMINDER_SENTINEL);
255
+ expect(sendCall?.[1][4]).toContain('Re-raise your pending call-to-user');
256
+ expect(sendCall?.[1][4]).not.toContain('You have produced no output for');
257
+ });
258
+
211
259
  it('does not re-notify the same silent session on the next cycle within cooldown', async () => {
212
260
  silentAssistantTranscript();
213
261
  const cacheRepository = makeCacheRepository();
@@ -230,4 +278,59 @@ describe('notifySilentTmuxSessions', () => {
230
278
  );
231
279
  expect(secondSendCall).toBeUndefined();
232
280
  });
281
+
282
+ it('shares the cooldown across project passes when a single project-independent cache scope is used', async () => {
283
+ silentAssistantTranscript();
284
+ // The handler runs once per project over the same global set of tmux
285
+ // sessions. When the silent-session cooldown lives in one shared cache
286
+ // scope, a second per-project pass for the same session within the cooldown
287
+ // window must NOT re-send, even though it is a different project pass.
288
+ const sharedCacheBasePath = path.join(cacheDirectory, 'shared');
289
+ const firstRunner = liveSessionRunner();
290
+
291
+ await notifySilentTmuxSessions({
292
+ ...baseParams(firstRunner),
293
+ cacheRepository: makeCacheRepositoryAt(sharedCacheBasePath),
294
+ });
295
+
296
+ const secondRunner = liveSessionRunner();
297
+ await notifySilentTmuxSessions({
298
+ ...baseParams(secondRunner),
299
+ cacheRepository: makeCacheRepositoryAt(sharedCacheBasePath),
300
+ now: new Date(NOW.getTime() + 60 * 1000),
301
+ });
302
+
303
+ const secondSendCall = secondRunner.runCommand.mock.calls.find(
304
+ (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
305
+ );
306
+ expect(secondSendCall).toBeUndefined();
307
+ });
308
+
309
+ it('re-sends within the cooldown window when each project pass uses its own cache scope', async () => {
310
+ silentAssistantTranscript();
311
+ // This reproduces the per-project cooldown defect: when each project pass
312
+ // stores its cooldown under its own project-scoped cache path, the same
313
+ // session is notified once per project within the cooldown window.
314
+ const firstRunner = liveSessionRunner();
315
+ await notifySilentTmuxSessions({
316
+ ...baseParams(firstRunner),
317
+ cacheRepository: makeCacheRepositoryAt(
318
+ path.join(cacheDirectory, 'umino'),
319
+ ),
320
+ });
321
+
322
+ const secondRunner = liveSessionRunner();
323
+ await notifySilentTmuxSessions({
324
+ ...baseParams(secondRunner),
325
+ cacheRepository: makeCacheRepositoryAt(
326
+ path.join(cacheDirectory, 'xmile'),
327
+ ),
328
+ now: new Date(NOW.getTime() + 60 * 1000),
329
+ });
330
+
331
+ const secondSendCall = secondRunner.runCommand.mock.calls.find(
332
+ (call) => call[0] === 'tmux' && call[1][0] === 'send-keys',
333
+ );
334
+ expect(secondSendCall?.[1][2]).toBe(SESSION_NAME);
335
+ });
233
336
  });
@@ -6,6 +6,9 @@ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
6
6
 
7
7
  const createFallback = (): Mocked<SilentSessionMessageComposer> => ({
8
8
  composeMainStalledSection: jest.fn().mockReturnValue('FALLBACK_MAIN'),
9
+ composeOwnerReNotificationSection: jest
10
+ .fn()
11
+ .mockReturnValue('FALLBACK_OWNER'),
9
12
  composeSubAgentSection: jest.fn().mockReturnValue('FALLBACK_SUB'),
10
13
  });
11
14
 
@@ -15,6 +18,7 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
15
18
  const composer = new ConfigurableSilentSessionMessageComposer(
16
19
  {
17
20
  mainStalledMessage: null,
21
+ ownerReNotificationMessage: null,
18
22
  subAgentMessageHeader: null,
19
23
  subAgentMessageFooter: null,
20
24
  },
@@ -29,6 +33,7 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
29
33
  const composer = new ConfigurableSilentSessionMessageComposer(
30
34
  {
31
35
  mainStalledMessage: 'CUSTOM_MAIN',
36
+ ownerReNotificationMessage: null,
32
37
  subAgentMessageHeader: null,
33
38
  subAgentMessageFooter: null,
34
39
  },
@@ -45,6 +50,7 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
45
50
  const composer = new ConfigurableSilentSessionMessageComposer(
46
51
  {
47
52
  mainStalledMessage: null,
53
+ ownerReNotificationMessage: null,
48
54
  subAgentMessageHeader: null,
49
55
  subAgentMessageFooter: null,
50
56
  },
@@ -62,6 +68,7 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
62
68
  const composer = new ConfigurableSilentSessionMessageComposer(
63
69
  {
64
70
  mainStalledMessage: null,
71
+ ownerReNotificationMessage: null,
65
72
  subAgentMessageHeader: 'HEADER',
66
73
  subAgentMessageFooter: 'FOOTER',
67
74
  },
@@ -79,11 +86,48 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
79
86
  expect(fallback.composeSubAgentSection).not.toHaveBeenCalled();
80
87
  });
81
88
 
89
+ it('uses the fallback owner-re-notification section when no template is configured', () => {
90
+ const fallback = createFallback();
91
+ const composer = new ConfigurableSilentSessionMessageComposer(
92
+ {
93
+ mainStalledMessage: null,
94
+ ownerReNotificationMessage: null,
95
+ subAgentMessageHeader: null,
96
+ subAgentMessageFooter: null,
97
+ },
98
+ fallback,
99
+ );
100
+ expect(composer.composeOwnerReNotificationSection(600)).toBe(
101
+ 'FALLBACK_OWNER',
102
+ );
103
+ expect(fallback.composeOwnerReNotificationSection).toHaveBeenCalledWith(
104
+ 600,
105
+ );
106
+ });
107
+
108
+ it('uses the configured owner-re-notification template when provided', () => {
109
+ const fallback = createFallback();
110
+ const composer = new ConfigurableSilentSessionMessageComposer(
111
+ {
112
+ mainStalledMessage: null,
113
+ ownerReNotificationMessage: 'CUSTOM_OWNER',
114
+ subAgentMessageHeader: null,
115
+ subAgentMessageFooter: null,
116
+ },
117
+ fallback,
118
+ );
119
+ const section = composer.composeOwnerReNotificationSection(600);
120
+ expect(section).toContain('CUSTOM_OWNER');
121
+ expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
122
+ expect(fallback.composeOwnerReNotificationSection).not.toHaveBeenCalled();
123
+ });
124
+
82
125
  it('does not double-prepend the sentinel when the template already carries it', () => {
83
126
  const fallback = createFallback();
84
127
  const composer = new ConfigurableSilentSessionMessageComposer(
85
128
  {
86
129
  mainStalledMessage: `${SILENT_SESSION_REMINDER_SENTINEL} CUSTOM_MAIN`,
130
+ ownerReNotificationMessage: null,
87
131
  subAgentMessageHeader: null,
88
132
  subAgentMessageFooter: null,
89
133
  },
@@ -9,6 +9,7 @@ const withReminderSentinel = (message: string): string =>
9
9
 
10
10
  export type SilentSessionMessageTemplates = {
11
11
  mainStalledMessage: string | null;
12
+ ownerReNotificationMessage: string | null;
12
13
  subAgentMessageHeader: string | null;
13
14
  subAgentMessageFooter: string | null;
14
15
  };
@@ -31,6 +32,13 @@ export class ConfigurableSilentSessionMessageComposer implements SilentSessionMe
31
32
  return withReminderSentinel(this.templates.mainStalledMessage);
32
33
  };
33
34
 
35
+ composeOwnerReNotificationSection = (waitingSeconds: number): string => {
36
+ if (this.templates.ownerReNotificationMessage === null) {
37
+ return this.fallback.composeOwnerReNotificationSection(waitingSeconds);
38
+ }
39
+ return withReminderSentinel(this.templates.ownerReNotificationMessage);
40
+ };
41
+
34
42
  composeSubAgentSection = (subAgents: SubAgentActivity[]): string => {
35
43
  if (
36
44
  this.templates.subAgentMessageHeader === null &&
@@ -9,6 +9,20 @@ describe('DefaultSilentSessionMessageComposer', () => {
9
9
  expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
10
10
  });
11
11
 
12
+ it('embeds the reminder sentinel in the owner-re-notification section', () => {
13
+ const section = composer.composeOwnerReNotificationSection(1800);
14
+ expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
15
+ });
16
+
17
+ it('instructs the agent to re-raise its pending call-to-user without treating the reminder as an owner reply', () => {
18
+ const section = composer.composeOwnerReNotificationSection(1800);
19
+ expect(section).toContain('waiting on the owner for 30 minutes');
20
+ expect(section).toContain('NOT the owner replying');
21
+ expect(section).toContain('Re-raise your pending call-to-user');
22
+ expect(section).toContain('you are not stalled or idle');
23
+ expect(section.toLowerCase()).not.toContain('no output for');
24
+ });
25
+
12
26
  it('embeds the reminder sentinel in the sub-agent section', () => {
13
27
  const section = composer.composeSubAgentSection([
14
28
  { label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 },
@@ -53,10 +67,12 @@ describe('DefaultSilentSessionMessageComposer', () => {
53
67
 
54
68
  it('does not contain any host-specific or internal identifiers', () => {
55
69
  const mainSection = composer.composeMainStalledSection(600);
70
+ const ownerSection = composer.composeOwnerReNotificationSection(1800);
56
71
  const subSection = composer.composeSubAgentSection([
57
72
  { label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 },
58
73
  ]);
59
- const combined = `${mainSection}\n${subSection}`.toLowerCase();
74
+ const combined =
75
+ `${mainSection}\n${ownerSection}\n${subSection}`.toLowerCase();
60
76
  expect(combined).not.toContain('claude');
61
77
  expect(combined).not.toContain('take ownership');
62
78
  expect(combined).not.toContain('/home/');
@@ -19,11 +19,22 @@ const composeMainStalledMessage = (mainSilentSeconds: number): string => {
19
19
  ].join('\n');
20
20
  };
21
21
 
22
+ const composeOwnerReNotificationMessage = (waitingSeconds: number): string => {
23
+ const minutes = Math.floor(waitingSeconds / 60);
24
+ return [
25
+ `${SILENT_SESSION_REMINDER_SENTINEL} This is an automated monitor reminder, NOT the owner replying. Do NOT treat this message as an owner answer to your outstanding call-to-user; your pending question is still unanswered. You have been waiting on the owner for ${minutes} minutes and the owner has not yet replied. The owner is only notified when a fresh call-to-user is raised, so a single past call-to-user that has gone quiet will never reach them. Re-raise your pending call-to-user to the owner now: re-emit it so a new call-to-user marker fires and the owner is notified again. Keep your question and proposed options unchanged unless you have new information. You are correctly waiting on the owner — you are not stalled or idle — so do not abandon the wait and do not invent an owner decision yourself; simply re-surface the same request so the owner sees it.`,
26
+ ].join('\n');
27
+ };
28
+
22
29
  export class DefaultSilentSessionMessageComposer implements SilentSessionMessageComposer {
23
30
  composeMainStalledSection = (mainSilentSeconds: number): string => {
24
31
  return composeMainStalledMessage(mainSilentSeconds);
25
32
  };
26
33
 
34
+ composeOwnerReNotificationSection = (waitingSeconds: number): string => {
35
+ return composeOwnerReNotificationMessage(waitingSeconds);
36
+ };
37
+
27
38
  composeSubAgentSection = (subAgents: SubAgentActivity[]): string => {
28
39
  const lines = subAgents.map(
29
40
  (subAgent) =>
@@ -21,6 +21,7 @@ import { InteractiveLiveSession } from '../entities/InteractiveLiveSession';
21
21
  type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
22
22
 
23
23
  const MAIN_STALLED_SECTION = 'MAIN_STALLED_SECTION';
24
+ const OWNER_RENOTIFICATION_SECTION = 'OWNER_RENOTIFICATION_SECTION';
24
25
  const SUBAGENT_SECTION = 'SUBAGENT_SECTION';
25
26
 
26
27
  describe('NotifySilentLiveSessionsUseCase', () => {
@@ -124,6 +125,9 @@ describe('NotifySilentLiveSessionsUseCase', () => {
124
125
  composeMainStalledSection: jest
125
126
  .fn()
126
127
  .mockReturnValue(MAIN_STALLED_SECTION),
128
+ composeOwnerReNotificationSection: jest
129
+ .fn()
130
+ .mockReturnValue(OWNER_RENOTIFICATION_SECTION),
127
131
  composeSubAgentSection: jest.fn().mockReturnValue(SUBAGENT_SECTION),
128
132
  };
129
133
  mockSleeper = {
@@ -197,7 +201,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
197
201
  ).toHaveBeenCalledWith(['workbench']);
198
202
  });
199
203
 
200
- it('does not send the main stalled section when an owner call is pending', async () => {
204
+ it('sends the owner-re-notification section instead of the stalled section when an owner call is pending past the threshold', async () => {
201
205
  setupLiveInteractiveSession('workbench');
202
206
  mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
203
207
  [
@@ -217,11 +221,92 @@ describe('NotifySilentLiveSessionsUseCase', () => {
217
221
  expect(
218
222
  mockMessageComposer.composeMainStalledSection,
219
223
  ).not.toHaveBeenCalled();
224
+ expect(
225
+ mockMessageComposer.composeOwnerReNotificationSection,
226
+ ).toHaveBeenCalledWith(DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS);
227
+ expect(
228
+ mockNotificationRepository.sendSelfCheckNotification,
229
+ ).toHaveBeenCalledWith('workbench', OWNER_RENOTIFICATION_SECTION);
230
+ });
231
+
232
+ it('does not send the owner-re-notification section when the owner-call wait is within the threshold', async () => {
233
+ setupLiveInteractiveSession('workbench');
234
+ mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
235
+ [
236
+ {
237
+ sessionName: 'workbench',
238
+ lastOutputEpochSeconds:
239
+ nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS + 1,
240
+ },
241
+ ],
242
+ );
243
+ mockOwnerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall.mockResolvedValue(
244
+ new Set(['workbench']),
245
+ );
246
+
247
+ await useCase.run(runParams());
248
+
249
+ expect(
250
+ mockMessageComposer.composeOwnerReNotificationSection,
251
+ ).not.toHaveBeenCalled();
220
252
  expect(
221
253
  mockNotificationRepository.sendSelfCheckNotification,
222
254
  ).not.toHaveBeenCalled();
223
255
  });
224
256
 
257
+ it('does not re-notify the owner within the cooldown window while still waiting on the owner', async () => {
258
+ setupLiveInteractiveSession('workbench');
259
+ mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
260
+ [
261
+ {
262
+ sessionName: 'workbench',
263
+ lastOutputEpochSeconds:
264
+ nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
265
+ },
266
+ ],
267
+ );
268
+ mockOwnerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall.mockResolvedValue(
269
+ new Set(['workbench']),
270
+ );
271
+ mockNotificationRepository.getLastNotifiedEpochSeconds.mockResolvedValue(
272
+ nowEpochSeconds - DEFAULT_NOTIFICATION_COOLDOWN_SECONDS + 1,
273
+ );
274
+
275
+ await useCase.run(runParams());
276
+
277
+ expect(
278
+ mockNotificationRepository.sendSelfCheckNotification,
279
+ ).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it('stops re-notifying the owner once a genuine owner reply clears the unanswered call', async () => {
283
+ setupLiveInteractiveSession('workbench');
284
+ mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
285
+ [
286
+ {
287
+ sessionName: 'workbench',
288
+ lastOutputEpochSeconds:
289
+ nowEpochSeconds - DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
290
+ },
291
+ ],
292
+ );
293
+ mockOwnerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall.mockResolvedValue(
294
+ new Set<string>(),
295
+ );
296
+
297
+ await useCase.run(runParams());
298
+
299
+ expect(
300
+ mockMessageComposer.composeOwnerReNotificationSection,
301
+ ).not.toHaveBeenCalled();
302
+ expect(mockMessageComposer.composeMainStalledSection).toHaveBeenCalledWith(
303
+ DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS,
304
+ );
305
+ expect(
306
+ mockNotificationRepository.sendSelfCheckNotification,
307
+ ).toHaveBeenCalledWith('workbench', MAIN_STALLED_SECTION);
308
+ });
309
+
225
310
  it('does not send the main stalled section when output is within the threshold', async () => {
226
311
  setupLiveInteractiveSession('workbench');
227
312
  mockSessionOutputActivityRepository.listSessionOutputActivities.mockResolvedValue(
@@ -172,14 +172,27 @@ export class NotifySilentLiveSessionsUseCase {
172
172
 
173
173
  if (
174
174
  snapshot.mainSilentSeconds !== null &&
175
- snapshot.mainSilentSeconds >= thresholds.mainSilentThresholdSeconds &&
176
- !snapshot.hasUnansweredOwnerCall
175
+ snapshot.mainSilentSeconds >= thresholds.mainSilentThresholdSeconds
177
176
  ) {
178
- sections.push(
179
- this.messageComposer.composeMainStalledSection(
180
- snapshot.mainSilentSeconds,
181
- ),
182
- );
177
+ if (snapshot.hasUnansweredOwnerCall) {
178
+ // The session is correctly waiting on the owner, but it has been silent
179
+ // long enough that the owner has effectively not been re-notified. Rather
180
+ // than suppress, instruct the agent to re-raise its pending call-to-user
181
+ // so the owner's marker re-fires. The per-session cooldown applied in
182
+ // run() limits this to roughly once per cooldown window, and it stops
183
+ // automatically once the owner replies (hasUnansweredOwnerCall clears).
184
+ sections.push(
185
+ this.messageComposer.composeOwnerReNotificationSection(
186
+ snapshot.mainSilentSeconds,
187
+ ),
188
+ );
189
+ } else {
190
+ sections.push(
191
+ this.messageComposer.composeMainStalledSection(
192
+ snapshot.mainSilentSeconds,
193
+ ),
194
+ );
195
+ }
183
196
  }
184
197
 
185
198
  const stalledSubAgents = snapshot.subAgents.filter(
@@ -2,5 +2,6 @@ import { SubAgentActivity } from '../../entities/LiveSessionActivitySnapshot';
2
2
 
3
3
  export interface SilentSessionMessageComposer {
4
4
  composeMainStalledSection: (mainSilentSeconds: number) => string;
5
+ composeOwnerReNotificationSection: (waitingSeconds: number) => string;
5
6
  composeSubAgentSection: (subAgents: SubAgentActivity[]) => string;
6
7
  }
@@ -1 +1 @@
1
- {"version":3,"file":"HandleScheduledEventUseCaseHandler.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts"],"names":[],"mappings":"AAkCA,OAAO,EAAE,KAAK,EAAE,MAAM,gCAAgC,CAAC;AACvD,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAiD3D,qBAAa,kCAAkC;IAC7C,MAAM,GACJ,gBAAgB,MAAM,EACtB,UAAU,OAAO,KAChB,OAAO,CAAC;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,MAAM,EAAE,KAAK,EAAE,CAAC;QAChB,SAAS,EAAE,OAAO,CAAC;QACnB,eAAe,EAAE,IAAI,EAAE,CAAC;KACzB,GAAG,IAAI,CAAC,CAoiBP;CACH"}
1
+ {"version":3,"file":"HandleScheduledEventUseCaseHandler.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts"],"names":[],"mappings":"AAkCA,OAAO,EAAE,KAAK,EAAE,MAAM,gCAAgC,CAAC;AACvD,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AA0D3D,qBAAa,kCAAkC;IAC7C,MAAM,GACJ,gBAAgB,MAAM,EACtB,UAAU,OAAO,KAChB,OAAO,CAAC;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,MAAM,EAAE,KAAK,EAAE,CAAC;QAChB,SAAS,EAAE,OAAO,CAAC;QACnB,eAAe,EAAE,IAAI,EAAE,CAAC;KACzB,GAAG,IAAI,CAAC,CA8iBP;CACH"}
@@ -2,6 +2,7 @@ import { SubAgentActivity } from '../../domain/entities/LiveSessionActivitySnaps
2
2
  import { SilentSessionMessageComposer } from '../../domain/usecases/adapter-interfaces/SilentSessionMessageComposer';
3
3
  export type SilentSessionMessageTemplates = {
4
4
  mainStalledMessage: string | null;
5
+ ownerReNotificationMessage: string | null;
5
6
  subAgentMessageHeader: string | null;
6
7
  subAgentMessageFooter: string | null;
7
8
  };
@@ -10,6 +11,7 @@ export declare class ConfigurableSilentSessionMessageComposer implements SilentS
10
11
  private readonly fallback;
11
12
  constructor(templates: SilentSessionMessageTemplates, fallback: SilentSessionMessageComposer);
12
13
  composeMainStalledSection: (mainSilentSeconds: number) => string;
14
+ composeOwnerReNotificationSection: (waitingSeconds: number) => string;
13
15
  composeSubAgentSection: (subAgents: SubAgentActivity[]) => string;
14
16
  }
15
17
  //# sourceMappingURL=ConfigurableSilentSessionMessageComposer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ConfigurableSilentSessionMessageComposer.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,mDAAmD,CAAC;AACrF,OAAO,EAAE,4BAA4B,EAAE,MAAM,uEAAuE,CAAC;AAQrH,MAAM,MAAM,6BAA6B,GAAG;IAC1C,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC,CAAC;AAOF,qBAAa,wCAAyC,YAAW,4BAA4B;IAEzF,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBADR,SAAS,EAAE,6BAA6B,EACxC,QAAQ,EAAE,4BAA4B;IAGzD,yBAAyB,GAAI,mBAAmB,MAAM,KAAG,MAAM,CAK7D;IAEF,sBAAsB,GAAI,WAAW,gBAAgB,EAAE,KAAG,MAAM,CAsB9D;CACH"}
1
+ {"version":3,"file":"ConfigurableSilentSessionMessageComposer.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,mDAAmD,CAAC;AACrF,OAAO,EAAE,4BAA4B,EAAE,MAAM,uEAAuE,CAAC;AAQrH,MAAM,MAAM,6BAA6B,GAAG;IAC1C,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,0BAA0B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC,qBAAqB,EAAE,MAAM,GAAG,IAAI,CAAC;CACtC,CAAC;AAOF,qBAAa,wCAAyC,YAAW,4BAA4B;IAEzF,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBADR,SAAS,EAAE,6BAA6B,EACxC,QAAQ,EAAE,4BAA4B;IAGzD,yBAAyB,GAAI,mBAAmB,MAAM,KAAG,MAAM,CAK7D;IAEF,iCAAiC,GAAI,gBAAgB,MAAM,KAAG,MAAM,CAKlE;IAEF,sBAAsB,GAAI,WAAW,gBAAgB,EAAE,KAAG,MAAM,CAsB9D;CACH"}
@@ -2,6 +2,7 @@ import { SubAgentActivity } from '../entities/LiveSessionActivitySnapshot';
2
2
  import { SilentSessionMessageComposer } from './adapter-interfaces/SilentSessionMessageComposer';
3
3
  export declare class DefaultSilentSessionMessageComposer implements SilentSessionMessageComposer {
4
4
  composeMainStalledSection: (mainSilentSeconds: number) => string;
5
+ composeOwnerReNotificationSection: (waitingSeconds: number) => string;
5
6
  composeSubAgentSection: (subAgents: SubAgentActivity[]) => string;
6
7
  }
7
8
  //# sourceMappingURL=DefaultSilentSessionMessageComposer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"DefaultSilentSessionMessageComposer.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/DefaultSilentSessionMessageComposer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,yCAAyC,CAAC;AAC3E,OAAO,EAAE,4BAA4B,EAAE,MAAM,mDAAmD,CAAC;AAoBjG,qBAAa,mCAAoC,YAAW,4BAA4B;IACtF,yBAAyB,GAAI,mBAAmB,MAAM,KAAG,MAAM,CAE7D;IAEF,sBAAsB,GAAI,WAAW,gBAAgB,EAAE,KAAG,MAAM,CAY9D;CACH"}
1
+ {"version":3,"file":"DefaultSilentSessionMessageComposer.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/DefaultSilentSessionMessageComposer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,yCAAyC,CAAC;AAC3E,OAAO,EAAE,4BAA4B,EAAE,MAAM,mDAAmD,CAAC;AA2BjG,qBAAa,mCAAoC,YAAW,4BAA4B;IACtF,yBAAyB,GAAI,mBAAmB,MAAM,KAAG,MAAM,CAE7D;IAEF,iCAAiC,GAAI,gBAAgB,MAAM,KAAG,MAAM,CAElE;IAEF,sBAAsB,GAAI,WAAW,gBAAgB,EAAE,KAAG,MAAM,CAY9D;CACH"}
@@ -1 +1 @@
1
- {"version":3,"file":"NotifySilentLiveSessionsUseCase.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/NotifySilentLiveSessionsUseCase.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,kCAAkC,EAAE,MAAM,yDAAyD,CAAC;AAC7G,OAAO,EAAE,wCAAwC,EAAE,MAAM,+DAA+D,CAAC;AACzH,OAAO,EAAE,uBAAuB,EAAE,MAAM,8CAA8C,CAAC;AACvF,OAAO,EAAE,+BAA+B,EAAE,MAAM,sDAAsD,CAAC;AACvG,OAAO,EAAE,iCAAiC,EAAE,MAAM,wDAAwD,CAAC;AAC3G,OAAO,EAAE,4BAA4B,EAAE,MAAM,mDAAmD,CAAC;AACjG,OAAO,EAAE,mCAAmC,EAAE,MAAM,0DAA0D,CAAC;AAC/G,OAAO,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAGvD,eAAO,MAAM,qCAAqC,QAAU,CAAC;AAC7D,eAAO,MAAM,yCAAyC,QAAS,CAAC;AAChE,eAAO,MAAM,0CAA0C,QAAU,CAAC;AAClE,eAAO,MAAM,qCAAqC,QAAU,CAAC;AAC7D,eAAO,MAAM,oCAAoC,KAAK,CAAC;AAOvD,qBAAa,+BAA+B;IAKxC,OAAO,CAAC,QAAQ,CAAC,kCAAkC;IACnD,OAAO,CAAC,QAAQ,CAAC,wCAAwC;IACzD,OAAO,CAAC,QAAQ,CAAC,+BAA+B;IAChD,OAAO,CAAC,QAAQ,CAAC,0BAA0B;IAC3C,OAAO,CAAC,QAAQ,CAAC,uBAAuB;IACxC,OAAO,CAAC,QAAQ,CAAC,sBAAsB;IACvC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO;IAX1B,OAAO,CAAC,QAAQ,CAAC,8BAA8B,CACD;gBAG3B,kCAAkC,EAAE,kCAAkC,EACtE,wCAAwC,EAAE,wCAAwC,EAClF,+BAA+B,EAAE,+BAA+B,EAChE,0BAA0B,EAAE,iCAAiC,EAC7D,uBAAuB,EAAE,uBAAuB,EAChD,sBAAsB,EAAE,mCAAmC,EAC3D,eAAe,EAAE,4BAA4B,EAC7C,OAAO,EAAE,OAAO;IAGnC,GAAG,GAAU,QAAQ;QACnB,0BAA0B,EAAE,MAAM,CAAC;QACnC,8BAA8B,EAAE,MAAM,CAAC;QACvC,+BAA+B,EAAE,MAAM,CAAC;QACxC,eAAe,EAAE,MAAM,CAAC;QACxB,cAAc,EAAE,MAAM,CAAC;QACvB,GAAG,EAAE,IAAI,CAAC;KACX,KAAG,OAAO,CAAC,IAAI,CAAC,CAmEf;IAEF,OAAO,CAAC,gBAAgB,CA8CtB;IAEF,OAAO,CAAC,cAAc,CAqCpB;CACH"}
1
+ {"version":3,"file":"NotifySilentLiveSessionsUseCase.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/NotifySilentLiveSessionsUseCase.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,kCAAkC,EAAE,MAAM,yDAAyD,CAAC;AAC7G,OAAO,EAAE,wCAAwC,EAAE,MAAM,+DAA+D,CAAC;AACzH,OAAO,EAAE,uBAAuB,EAAE,MAAM,8CAA8C,CAAC;AACvF,OAAO,EAAE,+BAA+B,EAAE,MAAM,sDAAsD,CAAC;AACvG,OAAO,EAAE,iCAAiC,EAAE,MAAM,wDAAwD,CAAC;AAC3G,OAAO,EAAE,4BAA4B,EAAE,MAAM,mDAAmD,CAAC;AACjG,OAAO,EAAE,mCAAmC,EAAE,MAAM,0DAA0D,CAAC;AAC/G,OAAO,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAGvD,eAAO,MAAM,qCAAqC,QAAU,CAAC;AAC7D,eAAO,MAAM,yCAAyC,QAAS,CAAC;AAChE,eAAO,MAAM,0CAA0C,QAAU,CAAC;AAClE,eAAO,MAAM,qCAAqC,QAAU,CAAC;AAC7D,eAAO,MAAM,oCAAoC,KAAK,CAAC;AAOvD,qBAAa,+BAA+B;IAKxC,OAAO,CAAC,QAAQ,CAAC,kCAAkC;IACnD,OAAO,CAAC,QAAQ,CAAC,wCAAwC;IACzD,OAAO,CAAC,QAAQ,CAAC,+BAA+B;IAChD,OAAO,CAAC,QAAQ,CAAC,0BAA0B;IAC3C,OAAO,CAAC,QAAQ,CAAC,uBAAuB;IACxC,OAAO,CAAC,QAAQ,CAAC,sBAAsB;IACvC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO;IAX1B,OAAO,CAAC,QAAQ,CAAC,8BAA8B,CACD;gBAG3B,kCAAkC,EAAE,kCAAkC,EACtE,wCAAwC,EAAE,wCAAwC,EAClF,+BAA+B,EAAE,+BAA+B,EAChE,0BAA0B,EAAE,iCAAiC,EAC7D,uBAAuB,EAAE,uBAAuB,EAChD,sBAAsB,EAAE,mCAAmC,EAC3D,eAAe,EAAE,4BAA4B,EAC7C,OAAO,EAAE,OAAO;IAGnC,GAAG,GAAU,QAAQ;QACnB,0BAA0B,EAAE,MAAM,CAAC;QACnC,8BAA8B,EAAE,MAAM,CAAC;QACvC,+BAA+B,EAAE,MAAM,CAAC;QACxC,eAAe,EAAE,MAAM,CAAC;QACxB,cAAc,EAAE,MAAM,CAAC;QACvB,GAAG,EAAE,IAAI,CAAC;KACX,KAAG,OAAO,CAAC,IAAI,CAAC,CAmEf;IAEF,OAAO,CAAC,gBAAgB,CA8CtB;IAEF,OAAO,CAAC,cAAc,CAkDpB;CACH"}
@@ -1,6 +1,7 @@
1
1
  import { SubAgentActivity } from '../../entities/LiveSessionActivitySnapshot';
2
2
  export interface SilentSessionMessageComposer {
3
3
  composeMainStalledSection: (mainSilentSeconds: number) => string;
4
+ composeOwnerReNotificationSection: (waitingSeconds: number) => string;
4
5
  composeSubAgentSection: (subAgents: SubAgentActivity[]) => string;
5
6
  }
6
7
  //# sourceMappingURL=SilentSessionMessageComposer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"SilentSessionMessageComposer.d.ts","sourceRoot":"","sources":["../../../../src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,4CAA4C,CAAC;AAE9E,MAAM,WAAW,4BAA4B;IAC3C,yBAAyB,EAAE,CAAC,iBAAiB,EAAE,MAAM,KAAK,MAAM,CAAC;IACjE,sBAAsB,EAAE,CAAC,SAAS,EAAE,gBAAgB,EAAE,KAAK,MAAM,CAAC;CACnE"}
1
+ {"version":3,"file":"SilentSessionMessageComposer.d.ts","sourceRoot":"","sources":["../../../../src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,4CAA4C,CAAC;AAE9E,MAAM,WAAW,4BAA4B;IAC3C,yBAAyB,EAAE,CAAC,iBAAiB,EAAE,MAAM,KAAK,MAAM,CAAC;IACjE,iCAAiC,EAAE,CAAC,cAAc,EAAE,MAAM,KAAK,MAAM,CAAC;IACtE,sBAAsB,EAAE,CAAC,SAAS,EAAE,gBAAgB,EAAE,KAAK,MAAM,CAAC;CACnE"}