github-issue-tower-defence-management 1.122.2 → 1.122.4

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 (95) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +3 -3
  3. package/bin/adapter/entry-points/cli/index.js +9 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/console/consoleDoneStore.js +19 -5
  6. package/bin/adapter/entry-points/console/consoleDoneStore.js.map +1 -1
  7. package/bin/adapter/entry-points/console/consoleOperationApi.js +78 -39
  8. package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
  9. package/bin/adapter/entry-points/console/consoleProjectResolver.js +8 -1
  10. package/bin/adapter/entry-points/console/consoleProjectResolver.js.map +1 -1
  11. package/bin/adapter/entry-points/console/webServer.js +5 -1
  12. package/bin/adapter/entry-points/console/webServer.js.map +1 -1
  13. package/bin/adapter/entry-points/handlers/consoleListsWriter.js +2 -0
  14. package/bin/adapter/entry-points/handlers/consoleListsWriter.js.map +1 -1
  15. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +1 -1
  16. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  17. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +6 -7
  18. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  19. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js +60 -11
  20. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js.map +1 -1
  21. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js +1 -0
  22. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js.map +1 -1
  23. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +64 -7
  24. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  25. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +23 -4
  26. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  27. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +5 -7
  28. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  29. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +51 -16
  30. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/adapter/entry-points/cli/index.test.ts +3 -1
  33. package/src/adapter/entry-points/cli/index.ts +11 -0
  34. package/src/adapter/entry-points/console/consoleDoneStore.test.ts +38 -0
  35. package/src/adapter/entry-points/console/consoleDoneStore.ts +29 -4
  36. package/src/adapter/entry-points/console/consoleOperationApi.test.ts +190 -11
  37. package/src/adapter/entry-points/console/consoleOperationApi.ts +97 -56
  38. package/src/adapter/entry-points/console/consoleProjectResolver.test.ts +13 -0
  39. package/src/adapter/entry-points/console/consoleProjectResolver.ts +11 -0
  40. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +3 -0
  41. package/src/adapter/entry-points/console/webServer.test.ts +4 -0
  42. package/src/adapter/entry-points/console/webServer.ts +9 -1
  43. package/src/adapter/entry-points/handlers/consoleListsWriter.test.ts +72 -0
  44. package/src/adapter/entry-points/handlers/consoleListsWriter.ts +3 -0
  45. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +1 -0
  46. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +60 -37
  47. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +7 -18
  48. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.test.ts +223 -0
  49. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.ts +94 -10
  50. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.test.ts +12 -2
  51. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.ts +1 -0
  52. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +196 -4
  53. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +91 -7
  54. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +86 -0
  55. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +34 -7
  56. package/src/domain/entities/LiveSessionActivitySnapshot.ts +1 -0
  57. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +112 -51
  58. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +8 -17
  59. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +250 -18
  60. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +72 -22
  61. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +1 -0
  62. package/src/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.ts +8 -0
  63. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +4 -7
  64. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  65. package/types/adapter/entry-points/console/consoleDoneStore.d.ts +2 -0
  66. package/types/adapter/entry-points/console/consoleDoneStore.d.ts.map +1 -1
  67. package/types/adapter/entry-points/console/consoleOperationApi.d.ts +2 -0
  68. package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
  69. package/types/adapter/entry-points/console/consoleProjectResolver.d.ts +2 -1
  70. package/types/adapter/entry-points/console/consoleProjectResolver.d.ts.map +1 -1
  71. package/types/adapter/entry-points/console/webServer.d.ts +2 -1
  72. package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
  73. package/types/adapter/entry-points/handlers/consoleListsWriter.d.ts.map +1 -1
  74. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  75. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +2 -3
  76. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  77. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts +13 -2
  78. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts.map +1 -1
  79. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts.map +1 -1
  80. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts +5 -1
  81. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  82. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +2 -1
  83. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  84. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +1 -0
  85. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
  86. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -3
  87. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  88. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +2 -1
  89. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  90. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +1 -1
  91. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  92. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts +8 -0
  93. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts.map +1 -1
  94. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +4 -4
  95. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -2,6 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { SubAgentActivity } from '../../domain/entities/LiveSessionActivitySnapshot';
4
4
  import { SessionSubAgentActivityRepository } from '../../domain/usecases/adapter-interfaces/SessionSubAgentActivityRepository';
5
+ import { SubAgentProcessLister } from '../../domain/usecases/adapter-interfaces/SubAgentProcessLister';
5
6
  import { SubAgentTranscriptDirectoryResolver } from '../../domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver';
6
7
 
7
8
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -23,9 +24,18 @@ const parseEpochSeconds = (timestamp: string | null): number | null => {
23
24
  return Number.isNaN(parsed) ? null : Math.floor(parsed / 1000);
24
25
  };
25
26
 
27
+ const PENDING_TOOL_COMMAND_FRAGMENT_LENGTH = 60;
28
+
29
+ export const normalizeCommandFragment = (command: string): string =>
30
+ command
31
+ .replace(/\s+/g, ' ')
32
+ .trim()
33
+ .slice(0, PENDING_TOOL_COMMAND_FRAGMENT_LENGTH);
34
+
26
35
  type ParsedTranscript = {
27
36
  firstEntryEpochSeconds: number | null;
28
37
  lastEntryIndicatesCompletion: boolean;
38
+ pendingToolCommands: string[];
29
39
  };
30
40
 
31
41
  const readContentBlocks = (
@@ -65,9 +75,33 @@ const entryIndicatesCompletion = (entry: Record<string, unknown>): boolean => {
65
75
  return false;
66
76
  };
67
77
 
78
+ const entryPendingToolCommands = (entry: Record<string, unknown>): string[] => {
79
+ const type = readString(entry, 'type');
80
+ const message = entry.message;
81
+ if (type !== 'assistant' || !isRecord(message)) {
82
+ return [];
83
+ }
84
+ const commands: string[] = [];
85
+ for (const block of readContentBlocks(message)) {
86
+ if (readString(block, 'type') !== 'tool_use') {
87
+ continue;
88
+ }
89
+ const input = block.input;
90
+ if (!isRecord(input)) {
91
+ continue;
92
+ }
93
+ const command = readString(input, 'command');
94
+ if (command !== null && command.trim().length > 0) {
95
+ commands.push(command);
96
+ }
97
+ }
98
+ return commands;
99
+ };
100
+
68
101
  const parseTranscript = (content: string): ParsedTranscript => {
69
102
  let firstEntryEpochSeconds: number | null = null;
70
103
  let lastEntryIndicatesCompletion = false;
104
+ let pendingToolCommands: string[] = [];
71
105
  for (const line of content.split('\n')) {
72
106
  const trimmed = line.trim();
73
107
  if (trimmed.length === 0) {
@@ -88,9 +122,14 @@ const parseTranscript = (content: string): ParsedTranscript => {
88
122
  }
89
123
  if (isRecord(parsed.message)) {
90
124
  lastEntryIndicatesCompletion = entryIndicatesCompletion(parsed);
125
+ pendingToolCommands = entryPendingToolCommands(parsed);
91
126
  }
92
127
  }
93
- return { firstEntryEpochSeconds, lastEntryIndicatesCompletion };
128
+ return {
129
+ firstEntryEpochSeconds,
130
+ lastEntryIndicatesCompletion,
131
+ pendingToolCommands,
132
+ };
94
133
  };
95
134
 
96
135
  const clampToZero = (value: number): number => (value > 0 ? value : 0);
@@ -98,6 +137,7 @@ const clampToZero = (value: number): number => (value > 0 ? value : 0);
98
137
  export class TranscriptSessionSubAgentActivityRepository implements SessionSubAgentActivityRepository {
99
138
  constructor(
100
139
  private readonly directoryResolver: SubAgentTranscriptDirectoryResolver,
140
+ private readonly processLister: SubAgentProcessLister,
101
141
  private readonly now: Date,
102
142
  ) {}
103
143
 
@@ -107,6 +147,16 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
107
147
  ): Promise<Map<string, SubAgentActivity[]>> => {
108
148
  const result = new Map<string, SubAgentActivity[]>();
109
149
  const nowEpochSeconds = Math.floor(this.now.getTime() / 1000);
150
+ let normalizedProcessCommandLines: string[] | null = null;
151
+ const loadNormalizedProcessCommandLines = async (): Promise<string[]> => {
152
+ if (normalizedProcessCommandLines === null) {
153
+ const processes = await this.processLister.listProcesses();
154
+ normalizedProcessCommandLines = processes.map((process) =>
155
+ process.commandLine.replace(/\s+/g, ' ').trim(),
156
+ );
157
+ }
158
+ return normalizedProcessCommandLines;
159
+ };
110
160
  for (const sessionName of sessionNames) {
111
161
  const mainTranscriptPath =
112
162
  transcriptPathBySessionName.get(sessionName) ?? null;
@@ -117,7 +167,11 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
117
167
  if (directory === null) {
118
168
  continue;
119
169
  }
120
- const activities = this.collectActivities(directory, nowEpochSeconds);
170
+ const activities = await this.collectActivities(
171
+ directory,
172
+ nowEpochSeconds,
173
+ loadNormalizedProcessCommandLines,
174
+ );
121
175
  if (activities.length > 0) {
122
176
  result.set(sessionName, activities);
123
177
  }
@@ -125,10 +179,11 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
125
179
  return result;
126
180
  };
127
181
 
128
- private collectActivities = (
182
+ private collectActivities = async (
129
183
  directory: string,
130
184
  nowEpochSeconds: number,
131
- ): SubAgentActivity[] => {
185
+ loadNormalizedProcessCommandLines: () => Promise<string[]>,
186
+ ): Promise<SubAgentActivity[]> => {
132
187
  let entries: fs.Dirent[];
133
188
  try {
134
189
  entries = fs.readdirSync(directory, { withFileTypes: true });
@@ -142,7 +197,12 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
142
197
  continue;
143
198
  }
144
199
  const filePath = path.join(directory, fileName);
145
- const activity = this.toActivity(filePath, fileName, nowEpochSeconds);
200
+ const activity = await this.toActivity(
201
+ filePath,
202
+ fileName,
203
+ nowEpochSeconds,
204
+ loadNormalizedProcessCommandLines,
205
+ );
146
206
  if (activity !== null) {
147
207
  activities.push(activity);
148
208
  }
@@ -150,11 +210,12 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
150
210
  return activities;
151
211
  };
152
212
 
153
- private toActivity = (
213
+ private toActivity = async (
154
214
  filePath: string,
155
215
  fileName: string,
156
216
  nowEpochSeconds: number,
157
- ): SubAgentActivity | null => {
217
+ loadNormalizedProcessCommandLines: () => Promise<string[]>,
218
+ ): Promise<SubAgentActivity | null> => {
158
219
  let content: string;
159
220
  let stats: fs.Stats;
160
221
  try {
@@ -178,6 +239,29 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
178
239
  label: fileName.replace(/\.jsonl$/, ''),
179
240
  silentSeconds,
180
241
  runningSeconds,
242
+ waitingOnExternalProcess: await this.hasLiveMatchingProcess(
243
+ transcript.pendingToolCommands,
244
+ loadNormalizedProcessCommandLines,
245
+ ),
181
246
  };
182
247
  };
248
+
249
+ private hasLiveMatchingProcess = async (
250
+ pendingToolCommands: string[],
251
+ loadNormalizedProcessCommandLines: () => Promise<string[]>,
252
+ ): Promise<boolean> => {
253
+ if (pendingToolCommands.length === 0) {
254
+ return false;
255
+ }
256
+ const fragments = pendingToolCommands
257
+ .map(normalizeCommandFragment)
258
+ .filter((fragment) => fragment.length > 0);
259
+ if (fragments.length === 0) {
260
+ return false;
261
+ }
262
+ const processCommandLines = await loadNormalizedProcessCommandLines();
263
+ return fragments.some((fragment) =>
264
+ processCommandLines.some((commandLine) => commandLine.includes(fragment)),
265
+ );
266
+ };
183
267
  }
@@ -593,6 +593,92 @@ describe('ApiV3CheerioRestIssueRepository', () => {
593
593
  });
594
594
  });
595
595
 
596
+ describe('getCachedProject', () => {
597
+ it('returns the daemon-cached project without any GraphQL project load', async () => {
598
+ const { repository, localStorageCacheRepository, projectRepository } =
599
+ createApiV3CheerioRestIssueRepository();
600
+ const cachedProject = buildTestProject('cached-project');
601
+ localStorageCacheRepository.getSingle.mockResolvedValue({
602
+ lastFetchedAt: '2026-07-07T00:30:00.000Z',
603
+ lastFullFetchAt: '2026-07-07T00:00:00.000Z',
604
+ project: cachedProject,
605
+ issues: [],
606
+ });
607
+
608
+ const result = await repository.getCachedProject('cached-project');
609
+
610
+ expect(result).toEqual(cachedProject);
611
+ expect(localStorageCacheRepository.getSingle).toHaveBeenCalledWith(
612
+ 'allIssues-cached-project',
613
+ );
614
+ expect(projectRepository.getProject).not.toHaveBeenCalled();
615
+ });
616
+
617
+ it('returns null on a cache miss so the caller can fall back to GraphQL', async () => {
618
+ const { repository, localStorageCacheRepository, projectRepository } =
619
+ createApiV3CheerioRestIssueRepository();
620
+ localStorageCacheRepository.getSingle.mockResolvedValue(null);
621
+
622
+ const result = await repository.getCachedProject('missing-project');
623
+
624
+ expect(result).toBeNull();
625
+ expect(projectRepository.getProject).not.toHaveBeenCalled();
626
+ });
627
+ });
628
+
629
+ describe('updateNextActionDate', () => {
630
+ const projectWithNextActionDate = (): Project => ({
631
+ ...buildTestProject('nad-project'),
632
+ nextActionDate: { name: 'Next Action Date', fieldId: 'nad-field' },
633
+ });
634
+
635
+ it('uses the provided project item id and skips the GraphQL item fetch', async () => {
636
+ const { repository, graphqlProjectItemRepository } =
637
+ createApiV3CheerioRestIssueRepository();
638
+ graphqlProjectItemRepository.updateProjectField.mockResolvedValue();
639
+
640
+ await repository.updateNextActionDate(
641
+ 'https://github.com/o/r/issues/1',
642
+ projectWithNextActionDate(),
643
+ new Date('2026-07-20T00:00:00.000Z'),
644
+ 'given-item-id',
645
+ );
646
+
647
+ expect(
648
+ graphqlProjectItemRepository.fetchProjectItemByUrl,
649
+ ).not.toHaveBeenCalled();
650
+ expect(
651
+ graphqlProjectItemRepository.updateProjectField,
652
+ ).toHaveBeenCalledWith('nad-project', 'nad-field', 'given-item-id', {
653
+ date: '2026-07-20',
654
+ });
655
+ });
656
+
657
+ it('falls back to fetchProjectItemByUrl when no project item id is provided', async () => {
658
+ const { repository, graphqlProjectItemRepository } =
659
+ createApiV3CheerioRestIssueRepository();
660
+ graphqlProjectItemRepository.updateProjectField.mockResolvedValue();
661
+ graphqlProjectItemRepository.fetchProjectItemByUrl.mockResolvedValue(
662
+ buildProjectItem('https://github.com/o/r/issues/1', 'fallback'),
663
+ );
664
+
665
+ await repository.updateNextActionDate(
666
+ 'https://github.com/o/r/issues/1',
667
+ projectWithNextActionDate(),
668
+ new Date('2026-07-20T00:00:00.000Z'),
669
+ );
670
+
671
+ expect(
672
+ graphqlProjectItemRepository.fetchProjectItemByUrl,
673
+ ).toHaveBeenCalledWith('https://github.com/o/r/issues/1', 'nad-project');
674
+ expect(
675
+ graphqlProjectItemRepository.updateProjectField,
676
+ ).toHaveBeenCalledWith('nad-project', 'nad-field', 'item-fallback', {
677
+ date: '2026-07-20',
678
+ });
679
+ });
680
+ });
681
+
596
682
  describe('setDependedIssueUrl', () => {
597
683
  const projectWithDependedIssueUrlField = {
598
684
  ...mock<Project>(),
@@ -680,6 +680,25 @@ export class ApiV3CheerioRestIssueRepository
680
680
  };
681
681
  };
682
682
 
683
+ // Reads the Project (status/story option ids and field ids) that the TDPM
684
+ // daemon persisted into the `allIssues-${projectId}` cache, without any
685
+ // GraphQL call. Returns null on cache miss so callers can fall back to a
686
+ // GraphQL project load only when the daemon has not populated the cache yet.
687
+ getCachedProject = async (
688
+ projectId: Project['id'],
689
+ ): Promise<Project | null> => {
690
+ const raw = await this.localStorageCacheRepository.getSingle(
691
+ `allIssues-${projectId}`,
692
+ );
693
+ if (typeof raw !== 'object' || raw === null || !('project' in raw)) {
694
+ return null;
695
+ }
696
+ if (!typia.is<Project>(raw.project)) {
697
+ return null;
698
+ }
699
+ return raw.project;
700
+ };
701
+
683
702
  private toDateString = (date: Date): string =>
684
703
  `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
685
704
 
@@ -855,22 +874,30 @@ export class ApiV3CheerioRestIssueRepository
855
874
  issueUrl: string,
856
875
  project: Project,
857
876
  date: Date,
877
+ projectItemId?: string,
858
878
  ): Promise<void> => {
859
879
  if (!project.nextActionDate) {
860
880
  return;
861
881
  }
862
- const projectItem =
863
- await this.graphqlProjectItemRepository.fetchProjectItemByUrl(
864
- issueUrl,
865
- project.id,
866
- );
867
- if (!projectItem) {
882
+ // When the caller already knows the project item id (e.g. the console,
883
+ // which receives it in the request body), use it directly and skip the
884
+ // GraphQL fetchProjectItemByUrl lookup. Fall back to the lookup only when
885
+ // no id was supplied, preserving the original behavior for other callers.
886
+ const itemId =
887
+ projectItemId ??
888
+ (
889
+ await this.graphqlProjectItemRepository.fetchProjectItemByUrl(
890
+ issueUrl,
891
+ project.id,
892
+ )
893
+ )?.id;
894
+ if (!itemId) {
868
895
  return;
869
896
  }
870
897
  return this.graphqlProjectItemRepository.updateProjectField(
871
898
  project.id,
872
899
  project.nextActionDate.fieldId,
873
- projectItem.id,
900
+ itemId,
874
901
  { date: date.toISOString().split('T')[0] },
875
902
  );
876
903
  };
@@ -2,6 +2,7 @@ export type SubAgentActivity = {
2
2
  label: string;
3
3
  silentSeconds: number;
4
4
  runningSeconds: number;
5
+ waitingOnExternalProcess: boolean;
5
6
  };
6
7
 
7
8
  export type LiveSessionActivitySnapshot = {
@@ -1,11 +1,6 @@
1
1
  import { DefaultSilentSessionMessageComposer } from './DefaultSilentSessionMessageComposer';
2
2
  import { SILENT_SESSION_REMINDER_SENTINEL } from './silentSessionReminderSentinel';
3
3
 
4
- const THRESHOLDS = {
5
- subAgentSilentThresholdSeconds: 300,
6
- subAgentRunningThresholdSeconds: 900,
7
- };
8
-
9
4
  describe('DefaultSilentSessionMessageComposer', () => {
10
5
  const composer = new DefaultSilentSessionMessageComposer();
11
6
 
@@ -15,10 +10,16 @@ describe('DefaultSilentSessionMessageComposer', () => {
15
10
  });
16
11
 
17
12
  it('embeds the reminder sentinel in the sub-agent section', () => {
18
- const section = composer.composeSubAgentSection(
19
- [{ label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 }],
20
- THRESHOLDS,
21
- );
13
+ const subAgent = {
14
+ label: 'sub-process-1',
15
+ silentSeconds: 360,
16
+ runningSeconds: 1200,
17
+ waitingOnExternalProcess: false,
18
+ };
19
+ const section = composer.composeSubAgentSection({
20
+ idleSubAgents: [subAgent],
21
+ longRunningSubAgents: [subAgent],
22
+ });
22
23
  expect(section).toContain(SILENT_SESSION_REMINDER_SENTINEL);
23
24
  });
24
25
 
@@ -116,10 +117,17 @@ describe('DefaultSilentSessionMessageComposer', () => {
116
117
  });
117
118
 
118
119
  it('emits a distinct idle message for a sub-agent that is only output-idle', () => {
119
- const section = composer.composeSubAgentSection(
120
- [{ label: 'sub-process-idle', silentSeconds: 360, runningSeconds: 60 }],
121
- THRESHOLDS,
122
- );
120
+ const section = composer.composeSubAgentSection({
121
+ idleSubAgents: [
122
+ {
123
+ label: 'sub-process-idle',
124
+ silentSeconds: 360,
125
+ runningSeconds: 60,
126
+ waitingOnExternalProcess: false,
127
+ },
128
+ ],
129
+ longRunningSubAgents: [],
130
+ });
123
131
  expect(section).toContain('sub-process-idle');
124
132
  expect(section).toContain('no output for 6m');
125
133
  expect(section).toContain('restart, hand off, or replace it');
@@ -128,10 +136,17 @@ describe('DefaultSilentSessionMessageComposer', () => {
128
136
  });
129
137
 
130
138
  it('presents the system-detected idle duration as the authoritative signal in the idle message', () => {
131
- const section = composer.composeSubAgentSection(
132
- [{ label: 'sub-process-idle', silentSeconds: 360, runningSeconds: 60 }],
133
- THRESHOLDS,
134
- );
139
+ const section = composer.composeSubAgentSection({
140
+ idleSubAgents: [
141
+ {
142
+ label: 'sub-process-idle',
143
+ silentSeconds: 360,
144
+ runningSeconds: 60,
145
+ waitingOnExternalProcess: false,
146
+ },
147
+ ],
148
+ longRunningSubAgents: [],
149
+ });
135
150
  expect(section).toContain(
136
151
  "The system has already detected, from each sub-process's last tool activity, that it has produced no output for about the minutes shown below.",
137
152
  );
@@ -142,10 +157,17 @@ describe('DefaultSilentSessionMessageComposer', () => {
142
157
  });
143
158
 
144
159
  it('forbids speculation and dismissal without evidence in the idle message', () => {
145
- const section = composer.composeSubAgentSection(
146
- [{ label: 'sub-process-idle', silentSeconds: 360, runningSeconds: 60 }],
147
- THRESHOLDS,
148
- );
160
+ const section = composer.composeSubAgentSection({
161
+ idleSubAgents: [
162
+ {
163
+ label: 'sub-process-idle',
164
+ silentSeconds: 360,
165
+ runningSeconds: 60,
166
+ waitingOnExternalProcess: false,
167
+ },
168
+ ],
169
+ longRunningSubAgents: [],
170
+ });
149
171
  expect(section).toContain(
150
172
  'Around five minutes of silence is a real warning of a possible hang',
151
173
  );
@@ -155,10 +177,17 @@ describe('DefaultSilentSessionMessageComposer', () => {
155
177
  });
156
178
 
157
179
  it('requires a concrete cause-check covering recent activity and external-dependency waiting in the idle message', () => {
158
- const section = composer.composeSubAgentSection(
159
- [{ label: 'sub-process-idle', silentSeconds: 360, runningSeconds: 60 }],
160
- THRESHOLDS,
161
- );
180
+ const section = composer.composeSubAgentSection({
181
+ idleSubAgents: [
182
+ {
183
+ label: 'sub-process-idle',
184
+ silentSeconds: 360,
185
+ runningSeconds: 60,
186
+ waitingOnExternalProcess: false,
187
+ },
188
+ ],
189
+ longRunningSubAgents: [],
190
+ });
162
191
  expect(section).toContain('determine the CAUSE by a concrete check');
163
192
  expect(section).toContain(
164
193
  'a very recent push or commit, or output from any nested sub-processes this sub-process itself started',
@@ -175,26 +204,34 @@ describe('DefaultSilentSessionMessageComposer', () => {
175
204
  });
176
205
 
177
206
  it('requires logging the investigation result even though owner notification is not required', () => {
178
- const section = composer.composeSubAgentSection(
179
- [{ label: 'sub-process-idle', silentSeconds: 360, runningSeconds: 60 }],
180
- THRESHOLDS,
181
- );
207
+ const section = composer.composeSubAgentSection({
208
+ idleSubAgents: [
209
+ {
210
+ label: 'sub-process-idle',
211
+ silentSeconds: 360,
212
+ runningSeconds: 60,
213
+ waitingOnExternalProcess: false,
214
+ },
215
+ ],
216
+ longRunningSubAgents: [],
217
+ });
182
218
  expect(section).toContain(
183
219
  'Owner notification is not required, but you MUST output your investigation result in this session so it remains as a log.',
184
220
  );
185
221
  });
186
222
 
187
223
  it('emits a distinct long-running message for a sub-agent that has only run too long', () => {
188
- const section = composer.composeSubAgentSection(
189
- [
224
+ const section = composer.composeSubAgentSection({
225
+ idleSubAgents: [],
226
+ longRunningSubAgents: [
190
227
  {
191
228
  label: 'sub-process-long',
192
229
  silentSeconds: 30,
193
230
  runningSeconds: 1200,
231
+ waitingOnExternalProcess: false,
194
232
  },
195
233
  ],
196
- THRESHOLDS,
197
- );
234
+ });
198
235
  expect(section).toContain('sub-process-long');
199
236
  expect(section).toContain('running for 20m');
200
237
  expect(section).toContain('infinite loop');
@@ -209,25 +246,32 @@ describe('DefaultSilentSessionMessageComposer', () => {
209
246
  });
210
247
 
211
248
  it('does not foreground the short idle time in the long-running message', () => {
212
- const section = composer.composeSubAgentSection(
213
- [
249
+ const section = composer.composeSubAgentSection({
250
+ idleSubAgents: [],
251
+ longRunningSubAgents: [
214
252
  {
215
253
  label: 'sub-process-long',
216
254
  silentSeconds: 30,
217
255
  runningSeconds: 1200,
256
+ waitingOnExternalProcess: false,
218
257
  },
219
258
  ],
220
- THRESHOLDS,
221
- );
259
+ });
222
260
  expect(section).not.toContain('no output for');
223
261
  expect(section).not.toContain('silent for');
224
262
  });
225
263
 
226
264
  it('emits both distinct messages for a sub-agent matching both conditions, kept separate', () => {
227
- const section = composer.composeSubAgentSection(
228
- [{ label: 'sub-process-both', silentSeconds: 360, runningSeconds: 1200 }],
229
- THRESHOLDS,
230
- );
265
+ const subAgent = {
266
+ label: 'sub-process-both',
267
+ silentSeconds: 360,
268
+ runningSeconds: 1200,
269
+ waitingOnExternalProcess: false,
270
+ };
271
+ const section = composer.composeSubAgentSection({
272
+ idleSubAgents: [subAgent],
273
+ longRunningSubAgents: [subAgent],
274
+ });
231
275
  expect(section).toContain('no output for 6m');
232
276
  expect(section).toContain('determine the CAUSE by a concrete check');
233
277
  expect(section).toContain('running for 20m');
@@ -238,13 +282,24 @@ describe('DefaultSilentSessionMessageComposer', () => {
238
282
  });
239
283
 
240
284
  it('groups each sub-agent under the condition it matched', () => {
241
- const section = composer.composeSubAgentSection(
242
- [
243
- { label: 'idle-only', silentSeconds: 360, runningSeconds: 60 },
244
- { label: 'long-only', silentSeconds: 10, runningSeconds: 960 },
285
+ const section = composer.composeSubAgentSection({
286
+ idleSubAgents: [
287
+ {
288
+ label: 'idle-only',
289
+ silentSeconds: 360,
290
+ runningSeconds: 60,
291
+ waitingOnExternalProcess: false,
292
+ },
245
293
  ],
246
- THRESHOLDS,
247
- );
294
+ longRunningSubAgents: [
295
+ {
296
+ label: 'long-only',
297
+ silentSeconds: 10,
298
+ runningSeconds: 960,
299
+ waitingOnExternalProcess: false,
300
+ },
301
+ ],
302
+ });
248
303
  const [idleSection, longRunningSection] = section.split('\n\n');
249
304
  expect(idleSection).toContain('idle-only');
250
305
  expect(idleSection).not.toContain('long-only');
@@ -254,10 +309,16 @@ describe('DefaultSilentSessionMessageComposer', () => {
254
309
 
255
310
  it('does not contain any host-specific or internal identifiers', () => {
256
311
  const mainSection = composer.composeMainStalledSection(600);
257
- const subSection = composer.composeSubAgentSection(
258
- [{ label: 'sub-process-1', silentSeconds: 360, runningSeconds: 1200 }],
259
- THRESHOLDS,
260
- );
312
+ const subAgent = {
313
+ label: 'sub-process-1',
314
+ silentSeconds: 360,
315
+ runningSeconds: 1200,
316
+ waitingOnExternalProcess: false,
317
+ };
318
+ const subSection = composer.composeSubAgentSection({
319
+ idleSubAgents: [subAgent],
320
+ longRunningSubAgents: [subAgent],
321
+ });
261
322
  const combined = `${mainSection}\n${subSection}`.toLowerCase();
262
323
  expect(combined).not.toContain('claude');
263
324
  expect(combined).not.toContain('take ownership');
@@ -1,7 +1,7 @@
1
1
  import { SubAgentActivity } from '../entities/LiveSessionActivitySnapshot';
2
2
  import {
3
3
  SilentSessionMessageComposer,
4
- SubAgentStallThresholds,
4
+ SubAgentStallSections,
5
5
  } from './adapter-interfaces/SilentSessionMessageComposer';
6
6
  import { SILENT_SESSION_REMINDER_SENTINEL } from './silentSessionReminderSentinel';
7
7
 
@@ -74,24 +74,15 @@ export class DefaultSilentSessionMessageComposer implements SilentSessionMessage
74
74
  return composeMainStalledMessage(mainSilentSeconds, this.ownerCallMarker);
75
75
  };
76
76
 
77
- composeSubAgentSection = (
78
- subAgents: SubAgentActivity[],
79
- thresholds: SubAgentStallThresholds,
80
- ): string => {
81
- const idleSubAgents = subAgents.filter(
82
- (subAgent) =>
83
- subAgent.silentSeconds >= thresholds.subAgentSilentThresholdSeconds,
84
- );
85
- const longRunningSubAgents = subAgents.filter(
86
- (subAgent) =>
87
- subAgent.runningSeconds >= thresholds.subAgentRunningThresholdSeconds,
88
- );
77
+ composeSubAgentSection = (stallSections: SubAgentStallSections): string => {
89
78
  const sections: string[] = [];
90
- if (idleSubAgents.length > 0) {
91
- sections.push(composeIdleSubAgentSection(idleSubAgents));
79
+ if (stallSections.idleSubAgents.length > 0) {
80
+ sections.push(composeIdleSubAgentSection(stallSections.idleSubAgents));
92
81
  }
93
- if (longRunningSubAgents.length > 0) {
94
- sections.push(composeLongRunningSubAgentSection(longRunningSubAgents));
82
+ if (stallSections.longRunningSubAgents.length > 0) {
83
+ sections.push(
84
+ composeLongRunningSubAgentSection(stallSections.longRunningSubAgents),
85
+ );
95
86
  }
96
87
  return sections.join('\n\n');
97
88
  };