github-issue-tower-defence-management 1.122.3 → 1.122.5

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 (84) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +8 -4
  3. package/bin/adapter/entry-points/console/consoleDoneStore.js +19 -5
  4. package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +14 -2
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/consoleListsWriter.js +2 -0
  8. package/bin/adapter/entry-points/handlers/consoleListsWriter.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +4 -2
  10. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  11. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +12 -7
  12. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  13. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js +60 -11
  14. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js.map +1 -1
  15. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +2 -2
  16. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -1
  17. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js +1 -0
  18. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js.map +1 -1
  19. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +14 -11
  20. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
  21. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +64 -7
  22. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  23. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +18 -7
  24. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  25. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +64 -19
  26. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/adapter/entry-points/console/consoleDoneStore.test.ts +38 -0
  29. package/src/adapter/entry-points/console/consoleDoneStore.ts +29 -4
  30. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +11 -0
  31. package/src/adapter/entry-points/handlers/consoleListsWriter.test.ts +72 -0
  32. package/src/adapter/entry-points/handlers/consoleListsWriter.ts +3 -0
  33. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +46 -7
  34. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +6 -0
  35. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +102 -37
  36. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +22 -17
  37. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +1 -1
  38. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.test.ts +223 -0
  39. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.ts +94 -10
  40. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +7 -6
  41. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +3 -3
  42. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.test.ts +12 -2
  43. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.ts +1 -0
  44. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +99 -49
  45. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +24 -14
  46. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +196 -4
  47. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +91 -7
  48. package/src/domain/entities/LiveSessionActivitySnapshot.ts +2 -1
  49. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +187 -51
  50. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +32 -15
  51. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +356 -28
  52. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +94 -26
  53. package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +2 -2
  54. package/src/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.ts +8 -0
  55. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +7 -6
  56. package/types/adapter/entry-points/console/consoleDoneStore.d.ts +2 -0
  57. package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -1
  58. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  59. package/types/adapter/entry-points/handlers/consoleListsWriter.d.ts.map +1 -1
  60. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +2 -0
  61. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  62. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +4 -3
  63. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  64. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts +13 -2
  65. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts.map +1 -1
  66. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +1 -1
  67. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -1
  68. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts.map +1 -1
  69. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -2
  70. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
  71. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts +5 -1
  72. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  73. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +2 -1
  74. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
  75. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +3 -3
  76. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  77. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +4 -1
  78. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  79. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +1 -1
  80. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -1
  81. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts +8 -0
  82. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts.map +1 -1
  83. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +5 -4
  84. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -4,18 +4,17 @@ import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSe
4
4
 
5
5
  type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
6
6
 
7
- const THRESHOLDS = {
8
- subAgentSilentThresholdSeconds: 300,
9
- subAgentRunningThresholdSeconds: 900,
10
- };
11
-
12
7
  const createFallback = (): Mocked<SilentSessionMessageComposer> => ({
13
8
  composeMainStalledSection: jest.fn().mockReturnValue('FALLBACK_MAIN'),
9
+ composeMainStalledWithStaleOwnerCallSection: jest
10
+ .fn()
11
+ .mockReturnValue('FALLBACK_STALE_OWNER_CALL'),
14
12
  composeSubAgentSection: jest.fn().mockReturnValue('FALLBACK_SUB'),
15
13
  });
16
14
 
17
15
  const noTemplates = {
18
16
  mainStalledMessage: null,
17
+ mainStalledStaleOwnerCallMessage: null,
19
18
  subAgentIdleMessageHeader: null,
20
19
  subAgentIdleMessageFooter: null,
21
20
  subAgentLongRunningMessageHeader: null,
@@ -72,22 +71,66 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
72
71
  );
73
72
  });
74
73
 
74
+ it('uses the fallback stale-owner-call section when no stale-owner-call template is configured', () => {
75
+ const fallback = createFallback();
76
+ const composer = new ConfigurableSilentSessionMessageComposer(
77
+ noTemplates,
78
+ fallback,
79
+ );
80
+ expect(
81
+ composer.composeMainStalledWithStaleOwnerCallSection(600, 3600),
82
+ ).toBe('FALLBACK_STALE_OWNER_CALL');
83
+ expect(
84
+ fallback.composeMainStalledWithStaleOwnerCallSection,
85
+ ).toHaveBeenCalledWith(600, 3600);
86
+ });
87
+
88
+ it('uses the configured stale-owner-call template when provided', () => {
89
+ const fallback = createFallback();
90
+ const composer = new ConfigurableSilentSessionMessageComposer(
91
+ {
92
+ ...noTemplates,
93
+ mainStalledStaleOwnerCallMessage: 'CUSTOM_STALE_OWNER_CALL',
94
+ },
95
+ fallback,
96
+ '<<OWNER_CALL>>',
97
+ );
98
+ const section = composer.composeMainStalledWithStaleOwnerCallSection(
99
+ 600,
100
+ 3600,
101
+ );
102
+ expect(section).toContain('CUSTOM_STALE_OWNER_CALL');
103
+ expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
104
+ expect(section).toContain(
105
+ 'the configured owner-call marker tag "<<OWNER_CALL>>" as a complete matching pair',
106
+ );
107
+ expect(
108
+ fallback.composeMainStalledWithStaleOwnerCallSection,
109
+ ).not.toHaveBeenCalled();
110
+ });
111
+
75
112
  it('uses the fallback sub-agent section when no sub-agent template is configured', () => {
76
113
  const fallback = createFallback();
77
114
  const composer = new ConfigurableSilentSessionMessageComposer(
78
115
  noTemplates,
79
116
  fallback,
80
117
  );
118
+ const subAgent = {
119
+ label: 'task-a',
120
+ silentSeconds: 360,
121
+ runningSeconds: 1200,
122
+ waitingOnExternalProcess: false,
123
+ };
81
124
  expect(
82
- composer.composeSubAgentSection(
83
- [{ label: 'task-a', silentSeconds: 360, runningSeconds: 1200 }],
84
- THRESHOLDS,
85
- ),
125
+ composer.composeSubAgentSection({
126
+ idleSubAgents: [subAgent],
127
+ longRunningSubAgents: [subAgent],
128
+ }),
86
129
  ).toBe('FALLBACK_SUB');
87
- expect(fallback.composeSubAgentSection).toHaveBeenCalledWith(
88
- [{ label: 'task-a', silentSeconds: 360, runningSeconds: 1200 }],
89
- THRESHOLDS,
90
- );
130
+ expect(fallback.composeSubAgentSection).toHaveBeenCalledWith({
131
+ idleSubAgents: [subAgent],
132
+ longRunningSubAgents: [subAgent],
133
+ });
91
134
  });
92
135
 
93
136
  it('renders the configured idle header, list, and footer for an idle sub-agent', () => {
@@ -100,10 +143,17 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
100
143
  },
101
144
  fallback,
102
145
  );
103
- const section = composer.composeSubAgentSection(
104
- [{ label: 'task-a', silentSeconds: 360, runningSeconds: 60 }],
105
- THRESHOLDS,
106
- );
146
+ const section = composer.composeSubAgentSection({
147
+ idleSubAgents: [
148
+ {
149
+ label: 'task-a',
150
+ silentSeconds: 360,
151
+ runningSeconds: 60,
152
+ waitingOnExternalProcess: false,
153
+ },
154
+ ],
155
+ longRunningSubAgents: [],
156
+ });
107
157
  expect(section).toContain('IDLE_HEADER');
108
158
  expect(section).toContain('task-a');
109
159
  expect(section).toContain('no output for 6m');
@@ -122,10 +172,17 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
122
172
  },
123
173
  fallback,
124
174
  );
125
- const section = composer.composeSubAgentSection(
126
- [{ label: 'task-b', silentSeconds: 30, runningSeconds: 1200 }],
127
- THRESHOLDS,
128
- );
175
+ const section = composer.composeSubAgentSection({
176
+ idleSubAgents: [],
177
+ longRunningSubAgents: [
178
+ {
179
+ label: 'task-b',
180
+ silentSeconds: 30,
181
+ runningSeconds: 1200,
182
+ waitingOnExternalProcess: false,
183
+ },
184
+ ],
185
+ });
129
186
  expect(section).toContain('LONG_HEADER');
130
187
  expect(section).toContain('task-b');
131
188
  expect(section).toContain('running for 20m');
@@ -144,10 +201,16 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
144
201
  },
145
202
  fallback,
146
203
  );
147
- const section = composer.composeSubAgentSection(
148
- [{ label: 'task-both', silentSeconds: 360, runningSeconds: 1200 }],
149
- THRESHOLDS,
150
- );
204
+ const subAgent = {
205
+ label: 'task-both',
206
+ silentSeconds: 360,
207
+ runningSeconds: 1200,
208
+ waitingOnExternalProcess: false,
209
+ };
210
+ const section = composer.composeSubAgentSection({
211
+ idleSubAgents: [subAgent],
212
+ longRunningSubAgents: [subAgent],
213
+ });
151
214
  const [idleSection, longRunningSection] = section
152
215
  .replace(`${SILENT_SESSION_REMINDER_SENTINEL} `, '')
153
216
  .split('\n\n');
@@ -167,19 +230,21 @@ describe('ConfigurableSilentSessionMessageComposer', () => {
167
230
  },
168
231
  fallback,
169
232
  );
170
- const section = composer.composeSubAgentSection(
171
- [{ label: 'task-idle', silentSeconds: 360, runningSeconds: 60 }],
172
- THRESHOLDS,
173
- );
233
+ const idleSubAgent = {
234
+ label: 'task-idle',
235
+ silentSeconds: 360,
236
+ runningSeconds: 60,
237
+ waitingOnExternalProcess: false,
238
+ };
239
+ const section = composer.composeSubAgentSection({
240
+ idleSubAgents: [idleSubAgent],
241
+ longRunningSubAgents: [],
242
+ });
174
243
  expect(section).toContain('BUILTIN_IDLE');
175
- expect(fallback.composeSubAgentSection).toHaveBeenCalledWith(
176
- [{ label: 'task-idle', silentSeconds: 360, runningSeconds: 60 }],
177
- {
178
- subAgentSilentThresholdSeconds:
179
- THRESHOLDS.subAgentSilentThresholdSeconds,
180
- subAgentRunningThresholdSeconds: Number.POSITIVE_INFINITY,
181
- },
182
- );
244
+ expect(fallback.composeSubAgentSection).toHaveBeenCalledWith({
245
+ idleSubAgents: [idleSubAgent],
246
+ longRunningSubAgents: [],
247
+ });
183
248
  });
184
249
 
185
250
  it('does not double-prepend the sentinel when the template already carries it', () => {
@@ -2,7 +2,7 @@ import { SubAgentActivity } from '../../domain/entities/LiveSessionActivitySnaps
2
2
  import { composeOwnerCallFormatGuidance } from '../../domain/usecases/DefaultSilentSessionMessageComposer';
3
3
  import {
4
4
  SilentSessionMessageComposer,
5
- SubAgentStallThresholds,
5
+ SubAgentStallSections,
6
6
  } from '../../domain/usecases/adapter-interfaces/SilentSessionMessageComposer';
7
7
  import { SILENT_SESSION_REMINDER_SENTINEL } from '../../domain/usecases/silentSessionReminderSentinel';
8
8
 
@@ -13,6 +13,7 @@ const withReminderSentinel = (message: string): string =>
13
13
 
14
14
  export type SilentSessionMessageTemplates = {
15
15
  mainStalledMessage: string | null;
16
+ mainStalledStaleOwnerCallMessage: string | null;
16
17
  subAgentIdleMessageHeader: string | null;
17
18
  subAgentIdleMessageFooter: string | null;
18
19
  subAgentLongRunningMessageHeader: string | null;
@@ -40,10 +41,22 @@ export class ConfigurableSilentSessionMessageComposer implements SilentSessionMe
40
41
  );
41
42
  };
42
43
 
43
- composeSubAgentSection = (
44
- subAgents: SubAgentActivity[],
45
- thresholds: SubAgentStallThresholds,
44
+ composeMainStalledWithStaleOwnerCallSection = (
45
+ mainSilentSeconds: number,
46
+ unansweredOwnerCallAgeSeconds: number,
46
47
  ): string => {
48
+ if (this.templates.mainStalledStaleOwnerCallMessage === null) {
49
+ return this.fallback.composeMainStalledWithStaleOwnerCallSection(
50
+ mainSilentSeconds,
51
+ unansweredOwnerCallAgeSeconds,
52
+ );
53
+ }
54
+ return withReminderSentinel(
55
+ `${this.templates.mainStalledStaleOwnerCallMessage} ${composeOwnerCallFormatGuidance(this.ownerCallMarker)}`,
56
+ );
57
+ };
58
+
59
+ composeSubAgentSection = (stallSections: SubAgentStallSections): string => {
47
60
  const hasIdleTemplate =
48
61
  this.templates.subAgentIdleMessageHeader !== null ||
49
62
  this.templates.subAgentIdleMessageFooter !== null;
@@ -51,17 +64,10 @@ export class ConfigurableSilentSessionMessageComposer implements SilentSessionMe
51
64
  this.templates.subAgentLongRunningMessageHeader !== null ||
52
65
  this.templates.subAgentLongRunningMessageFooter !== null;
53
66
  if (!hasIdleTemplate && !hasLongRunningTemplate) {
54
- return this.fallback.composeSubAgentSection(subAgents, thresholds);
67
+ return this.fallback.composeSubAgentSection(stallSections);
55
68
  }
56
69
 
57
- const idleSubAgents = subAgents.filter(
58
- (subAgent) =>
59
- subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
60
- );
61
- const longRunningSubAgents = subAgents.filter(
62
- (subAgent) =>
63
- subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds,
64
- );
70
+ const { idleSubAgents, longRunningSubAgents } = stallSections;
65
71
 
66
72
  const sections: string[] = [];
67
73
  if (idleSubAgents.length > 0 && hasIdleTemplate) {
@@ -74,10 +80,9 @@ export class ConfigurableSilentSessionMessageComposer implements SilentSessionMe
74
80
  );
75
81
  } else if (idleSubAgents.length > 0) {
76
82
  sections.push(
77
- this.fallback.composeSubAgentSection(idleSubAgents, {
78
- subAgentSilentThresholdSeconds:
79
- thresholds.subAgentSilentThresholdSeconds,
80
- subAgentRunningThresholdSeconds: Number.POSITIVE_INFINITY,
83
+ this.fallback.composeSubAgentSection({
84
+ idleSubAgents,
85
+ longRunningSubAgents: [],
81
86
  }),
82
87
  );
83
88
  }
@@ -506,7 +506,7 @@ describe('parent transcript resolution feeding owner-call detection', () => {
506
506
  ]);
507
507
 
508
508
  return ownerCallStatusProvider
509
- .listSessionNamesWithUnansweredOwnerCall(transcriptPaths)
509
+ .listUnansweredOwnerCallEpochSecondsBySessionName(transcriptPaths)
510
510
  .then((waiting) => {
511
511
  expect(waiting.has('waiting')).toBe(true);
512
512
  });
@@ -3,6 +3,7 @@ import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import {
5
5
  DEFAULT_STATE_RETENTION_WINDOW_SECONDS,
6
+ ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS,
6
7
  FileSystemSilentSessionCandidateStateRepository,
7
8
  } from './FileSystemSilentSessionCandidateStateRepository';
8
9
 
@@ -141,6 +142,7 @@ describe('FileSystemSilentSessionCandidateStateRepository', () => {
141
142
  recordedEpochSeconds: Math.floor(secondSaveAt.getTime() / 1000),
142
143
  },
143
144
  ],
145
+ announcedRunningSubAgents: [],
144
146
  });
145
147
  });
146
148
 
@@ -198,4 +200,225 @@ describe('FileSystemSilentSessionCandidateStateRepository', () => {
198
200
  it('exposes the default retention window as a named constant', () => {
199
201
  expect(DEFAULT_STATE_RETENTION_WINDOW_SECONDS).toBe(60 * 60);
200
202
  });
203
+
204
+ describe('announced running sub-agent labels', () => {
205
+ it('round-trips saved announced labels so the next cycle reads them back', async () => {
206
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
207
+ stateFilePath,
208
+ );
209
+
210
+ await repository.saveAnnouncedRunningSubAgentLabels({
211
+ sessionName: sessionAlpha,
212
+ labels: ['sub-process-1', 'sub-process-2'],
213
+ now: new Date('2026-06-26T00:00:00Z'),
214
+ });
215
+ const loaded = await repository.loadAnnouncedRunningSubAgentLabels({
216
+ sessionName: sessionAlpha,
217
+ });
218
+
219
+ expect(loaded).toEqual(new Set(['sub-process-1', 'sub-process-2']));
220
+ });
221
+
222
+ it('returns an empty set when no announcement has been recorded for the session', async () => {
223
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
224
+ stateFilePath,
225
+ );
226
+
227
+ const loaded = await repository.loadAnnouncedRunningSubAgentLabels({
228
+ sessionName: sessionAlpha,
229
+ });
230
+
231
+ expect(loaded).toEqual(new Set<string>());
232
+ });
233
+
234
+ it('replaces the previous announced labels of the same session', async () => {
235
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
236
+ stateFilePath,
237
+ );
238
+
239
+ await repository.saveAnnouncedRunningSubAgentLabels({
240
+ sessionName: sessionAlpha,
241
+ labels: ['sub-process-1'],
242
+ now: new Date('2026-06-26T00:00:00Z'),
243
+ });
244
+ await repository.saveAnnouncedRunningSubAgentLabels({
245
+ sessionName: sessionAlpha,
246
+ labels: ['sub-process-2'],
247
+ now: new Date('2026-06-26T00:01:00Z'),
248
+ });
249
+ const loaded = await repository.loadAnnouncedRunningSubAgentLabels({
250
+ sessionName: sessionAlpha,
251
+ });
252
+
253
+ expect(loaded).toEqual(new Set(['sub-process-2']));
254
+ });
255
+
256
+ it('removes the stored entry when saving an empty label set', async () => {
257
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
258
+ stateFilePath,
259
+ );
260
+
261
+ await repository.saveAnnouncedRunningSubAgentLabels({
262
+ sessionName: sessionAlpha,
263
+ labels: ['sub-process-1'],
264
+ now: new Date('2026-06-26T00:00:00Z'),
265
+ });
266
+ await repository.saveAnnouncedRunningSubAgentLabels({
267
+ sessionName: sessionAlpha,
268
+ labels: [],
269
+ now: new Date('2026-06-26T00:01:00Z'),
270
+ });
271
+
272
+ expect(
273
+ await repository.loadAnnouncedRunningSubAgentLabels({
274
+ sessionName: sessionAlpha,
275
+ }),
276
+ ).toEqual(new Set<string>());
277
+ const persisted: unknown = JSON.parse(
278
+ fs.readFileSync(stateFilePath, 'utf8'),
279
+ );
280
+ expect(persisted).toEqual({
281
+ candidates: [],
282
+ announcedRunningSubAgents: [],
283
+ });
284
+ });
285
+
286
+ it('keeps the announced labels of another session when saving', async () => {
287
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
288
+ stateFilePath,
289
+ );
290
+ const at = new Date('2026-06-26T00:00:00Z');
291
+
292
+ await repository.saveAnnouncedRunningSubAgentLabels({
293
+ sessionName: sessionAlpha,
294
+ labels: ['sub-process-1'],
295
+ now: at,
296
+ });
297
+ await repository.saveAnnouncedRunningSubAgentLabels({
298
+ sessionName: sessionBravo,
299
+ labels: ['sub-process-2'],
300
+ now: at,
301
+ });
302
+
303
+ expect(
304
+ await repository.loadAnnouncedRunningSubAgentLabels({
305
+ sessionName: sessionAlpha,
306
+ }),
307
+ ).toEqual(new Set(['sub-process-1']));
308
+ expect(
309
+ await repository.loadAnnouncedRunningSubAgentLabels({
310
+ sessionName: sessionBravo,
311
+ }),
312
+ ).toEqual(new Set(['sub-process-2']));
313
+ });
314
+
315
+ it('drops an announced entry that has aged beyond the retention window on the next save', async () => {
316
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
317
+ stateFilePath,
318
+ );
319
+ const firstSaveAt = new Date('2026-06-26T00:00:00Z');
320
+ const secondSaveAt = new Date(
321
+ firstSaveAt.getTime() +
322
+ (ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS + 1) * 1000,
323
+ );
324
+
325
+ await repository.saveAnnouncedRunningSubAgentLabels({
326
+ sessionName: sessionAlpha,
327
+ labels: ['sub-process-1'],
328
+ now: firstSaveAt,
329
+ });
330
+ await repository.saveAnnouncedRunningSubAgentLabels({
331
+ sessionName: sessionBravo,
332
+ labels: ['sub-process-2'],
333
+ now: secondSaveAt,
334
+ });
335
+
336
+ expect(
337
+ await repository.loadAnnouncedRunningSubAgentLabels({
338
+ sessionName: sessionAlpha,
339
+ }),
340
+ ).toEqual(new Set<string>());
341
+ expect(
342
+ await repository.loadAnnouncedRunningSubAgentLabels({
343
+ sessionName: sessionBravo,
344
+ }),
345
+ ).toEqual(new Set(['sub-process-2']));
346
+ });
347
+
348
+ it('preserves the candidate set when saving announced labels and preserves announced labels when saving candidates', async () => {
349
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
350
+ stateFilePath,
351
+ );
352
+ const at = new Date('2026-06-26T00:00:00Z');
353
+
354
+ await repository.saveCandidateSessionNames({
355
+ sessionNames: [sessionAlpha],
356
+ now: at,
357
+ });
358
+ await repository.saveAnnouncedRunningSubAgentLabels({
359
+ sessionName: sessionBravo,
360
+ labels: ['sub-process-1'],
361
+ now: at,
362
+ });
363
+ await repository.saveCandidateSessionNames({
364
+ sessionNames: [sessionAlpha],
365
+ now: new Date('2026-06-26T00:01:00Z'),
366
+ });
367
+
368
+ const loadedCandidates = await repository.loadRecentCandidateSessionNames(
369
+ {
370
+ now: new Date('2026-06-26T00:02:00Z'),
371
+ recencyWindowSeconds: 15 * 60,
372
+ },
373
+ );
374
+ expect(loadedCandidates).toEqual(new Set([sessionAlpha]));
375
+ expect(
376
+ await repository.loadAnnouncedRunningSubAgentLabels({
377
+ sessionName: sessionBravo,
378
+ }),
379
+ ).toEqual(new Set(['sub-process-1']));
380
+ });
381
+
382
+ it('treats a corrupt state file as no recorded announcements', async () => {
383
+ fs.writeFileSync(stateFilePath, 'not valid json');
384
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
385
+ stateFilePath,
386
+ );
387
+
388
+ const loaded = await repository.loadAnnouncedRunningSubAgentLabels({
389
+ sessionName: sessionAlpha,
390
+ });
391
+
392
+ expect(loaded).toEqual(new Set<string>());
393
+ });
394
+
395
+ it('ignores a stored announced entry whose labels are malformed', async () => {
396
+ fs.writeFileSync(
397
+ stateFilePath,
398
+ JSON.stringify({
399
+ candidates: [],
400
+ announcedRunningSubAgents: [
401
+ {
402
+ sessionName: sessionAlpha,
403
+ labels: ['sub-process-1', 42],
404
+ recordedEpochSeconds: 1782000000,
405
+ },
406
+ ],
407
+ }),
408
+ );
409
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
410
+ stateFilePath,
411
+ );
412
+
413
+ const loaded = await repository.loadAnnouncedRunningSubAgentLabels({
414
+ sessionName: sessionAlpha,
415
+ });
416
+
417
+ expect(loaded).toEqual(new Set<string>());
418
+ });
419
+
420
+ it('exposes the announced-label retention window as a named constant', () => {
421
+ expect(ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS).toBe(24 * 60 * 60);
422
+ });
423
+ });
201
424
  });
@@ -8,10 +8,17 @@ type StoredCandidateEntry = {
8
8
  recordedEpochSeconds: number;
9
9
  };
10
10
 
11
+ type StoredAnnouncedRunningEntry = {
12
+ sessionName: string;
13
+ labels: string[];
14
+ recordedEpochSeconds: number;
15
+ };
16
+
11
17
  const isRecord = (value: unknown): value is Record<string, unknown> =>
12
18
  typeof value === 'object' && value !== null && !Array.isArray(value);
13
19
 
14
20
  export const DEFAULT_STATE_RETENTION_WINDOW_SECONDS = 60 * 60;
21
+ export const ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS = 24 * 60 * 60;
15
22
 
16
23
  const defaultStateFilePath = (): string => {
17
24
  const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
@@ -31,7 +38,7 @@ export class FileSystemSilentSessionCandidateStateRepository implements SilentSe
31
38
  const nowEpochSeconds = Math.floor(params.now.getTime() / 1000);
32
39
  const oldestAllowedEpochSeconds =
33
40
  nowEpochSeconds - params.recencyWindowSeconds;
34
- const entries = this.readEntries();
41
+ const entries = this.readCandidateEntries();
35
42
  const recentSessionNames = new Set<string>();
36
43
  for (const entry of entries) {
37
44
  if (entry.recordedEpochSeconds >= oldestAllowedEpochSeconds) {
@@ -50,7 +57,7 @@ export class FileSystemSilentSessionCandidateStateRepository implements SilentSe
50
57
  recordedEpochSeconds - this.retentionWindowSeconds;
51
58
  const currentSessionNames = new Set(params.sessionNames);
52
59
  const mergedBySessionName = new Map<string, StoredCandidateEntry>();
53
- for (const entry of this.readEntries()) {
60
+ for (const entry of this.readCandidateEntries()) {
54
61
  if (
55
62
  entry.recordedEpochSeconds >= oldestRetainedEpochSeconds &&
56
63
  !currentSessionNames.has(entry.sessionName)
@@ -64,26 +71,65 @@ export class FileSystemSilentSessionCandidateStateRepository implements SilentSe
64
71
  recordedEpochSeconds,
65
72
  });
66
73
  }
67
- this.writeEntries(Array.from(mergedBySessionName.values()));
74
+ this.writeState(
75
+ Array.from(mergedBySessionName.values()),
76
+ this.readAnnouncedRunningEntries(),
77
+ );
78
+ };
79
+
80
+ loadAnnouncedRunningSubAgentLabels = async (params: {
81
+ sessionName: string;
82
+ }): Promise<Set<string>> => {
83
+ const entry = this.readAnnouncedRunningEntries().find(
84
+ (candidate) => candidate.sessionName === params.sessionName,
85
+ );
86
+ return new Set(entry?.labels ?? []);
87
+ };
88
+
89
+ saveAnnouncedRunningSubAgentLabels = async (params: {
90
+ sessionName: string;
91
+ labels: string[];
92
+ now: Date;
93
+ }): Promise<void> => {
94
+ const recordedEpochSeconds = Math.floor(params.now.getTime() / 1000);
95
+ const oldestRetainedEpochSeconds =
96
+ recordedEpochSeconds - ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS;
97
+ const retainedEntries = this.readAnnouncedRunningEntries().filter(
98
+ (entry) =>
99
+ entry.sessionName !== params.sessionName &&
100
+ entry.recordedEpochSeconds >= oldestRetainedEpochSeconds,
101
+ );
102
+ if (params.labels.length > 0) {
103
+ retainedEntries.push({
104
+ sessionName: params.sessionName,
105
+ labels: params.labels,
106
+ recordedEpochSeconds,
107
+ });
108
+ }
109
+ this.writeState(this.readCandidateEntries(), retainedEntries);
68
110
  };
69
111
 
70
- private readEntries = (): StoredCandidateEntry[] => {
112
+ private readState = (): Record<string, unknown> => {
71
113
  let raw: string;
72
114
  try {
73
115
  raw = fs.readFileSync(this.stateFilePath, 'utf8');
74
116
  } catch {
75
- return [];
117
+ return {};
76
118
  }
77
119
  let parsed: unknown;
78
120
  try {
79
121
  parsed = JSON.parse(raw);
80
122
  } catch {
81
- return [];
123
+ return {};
82
124
  }
83
125
  if (!isRecord(parsed)) {
84
- return [];
126
+ return {};
85
127
  }
86
- const storedEntries = parsed.candidates;
128
+ return parsed;
129
+ };
130
+
131
+ private readCandidateEntries = (): StoredCandidateEntry[] => {
132
+ const storedEntries = this.readState().candidates;
87
133
  if (!Array.isArray(storedEntries)) {
88
134
  return [];
89
135
  }
@@ -105,11 +151,49 @@ export class FileSystemSilentSessionCandidateStateRepository implements SilentSe
105
151
  return entries;
106
152
  };
107
153
 
108
- private writeEntries = (entries: StoredCandidateEntry[]): void => {
154
+ private readAnnouncedRunningEntries = (): StoredAnnouncedRunningEntry[] => {
155
+ const storedEntries = this.readState().announcedRunningSubAgents;
156
+ if (!Array.isArray(storedEntries)) {
157
+ return [];
158
+ }
159
+ const entries: StoredAnnouncedRunningEntry[] = [];
160
+ for (const storedEntry of storedEntries) {
161
+ if (!isRecord(storedEntry)) {
162
+ continue;
163
+ }
164
+ const sessionName = storedEntry.sessionName;
165
+ const recordedEpochSeconds = storedEntry.recordedEpochSeconds;
166
+ const storedLabels = storedEntry.labels;
167
+ if (
168
+ typeof sessionName !== 'string' ||
169
+ typeof recordedEpochSeconds !== 'number' ||
170
+ !Number.isFinite(recordedEpochSeconds) ||
171
+ !Array.isArray(storedLabels)
172
+ ) {
173
+ continue;
174
+ }
175
+ const labels = storedLabels.filter(
176
+ (label): label is string => typeof label === 'string',
177
+ );
178
+ if (labels.length !== storedLabels.length) {
179
+ continue;
180
+ }
181
+ entries.push({ sessionName, labels, recordedEpochSeconds });
182
+ }
183
+ return entries;
184
+ };
185
+
186
+ private writeState = (
187
+ candidates: StoredCandidateEntry[],
188
+ announcedRunningSubAgents: StoredAnnouncedRunningEntry[],
189
+ ): void => {
109
190
  const directory = path.dirname(this.stateFilePath);
110
191
  fs.mkdirSync(directory, { recursive: true });
111
192
  const temporaryPath = `${this.stateFilePath}.${process.pid}.tmp`;
112
- fs.writeFileSync(temporaryPath, JSON.stringify({ candidates: entries }));
193
+ fs.writeFileSync(
194
+ temporaryPath,
195
+ JSON.stringify({ candidates, announcedRunningSubAgents }),
196
+ );
113
197
  fs.renameSync(temporaryPath, this.stateFilePath);
114
198
  };
115
199
  }