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
@@ -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
  }
@@ -54,7 +54,12 @@ describe('ProcessListSessionSubAgentActivityRepository', () => {
54
54
  );
55
55
  const result = await repository.listSubAgentActivitiesBySessionName(['s1']);
56
56
  expect(result.get('s1')).toEqual([
57
- { label: 'task-a', silentSeconds: 180, runningSeconds: 1200 },
57
+ {
58
+ label: 'task-a',
59
+ silentSeconds: 180,
60
+ runningSeconds: 1200,
61
+ waitingOnExternalProcess: false,
62
+ },
58
63
  ]);
59
64
  });
60
65
 
@@ -78,7 +83,12 @@ describe('ProcessListSessionSubAgentActivityRepository', () => {
78
83
  );
79
84
  const result = await repository.listSubAgentActivitiesBySessionName(['s1']);
80
85
  expect(result.get('s1')).toEqual([
81
- { label: 's1', silentSeconds: 0, runningSeconds: 30 },
86
+ {
87
+ label: 's1',
88
+ silentSeconds: 0,
89
+ runningSeconds: 30,
90
+ waitingOnExternalProcess: false,
91
+ },
82
92
  ]);
83
93
  });
84
94
 
@@ -43,6 +43,7 @@ export class ProcessListSessionSubAgentActivityRepository implements SessionSubA
43
43
  label,
44
44
  silentSeconds: this.silentSecondsResolver.resolveSilentSeconds(label),
45
45
  runningSeconds: process.elapsedSeconds,
46
+ waitingOnExternalProcess: false,
46
47
  };
47
48
  const existing = result.get(sessionName);
48
49
  if (existing === undefined) {
@@ -2,6 +2,10 @@ import * as fs from 'fs';
2
2
  import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import { SubAgentTranscriptDirectoryResolver } from '../../domain/usecases/adapter-interfaces/SubAgentTranscriptDirectoryResolver';
5
+ import {
6
+ SubAgentProcess,
7
+ SubAgentProcessLister,
8
+ } from '../../domain/usecases/adapter-interfaces/SubAgentProcessLister';
5
9
  import { FileSystemSubAgentTranscriptDirectoryResolver } from './FileSystemSubAgentTranscriptDirectoryResolver';
6
10
  import { TranscriptSessionSubAgentActivityRepository } from './TranscriptSessionSubAgentActivityRepository';
7
11
 
@@ -70,6 +74,14 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
70
74
  const createResolver = (): SubAgentTranscriptDirectoryResolver =>
71
75
  new FileSystemSubAgentTranscriptDirectoryResolver(rootDirectory);
72
76
 
77
+ const processListerWith = (
78
+ processes: SubAgentProcess[],
79
+ ): SubAgentProcessLister => ({
80
+ listProcesses: async () => processes,
81
+ });
82
+
83
+ const emptyProcessLister = (): SubAgentProcessLister => processListerWith([]);
84
+
73
85
  const runningEntries = (startTimestamp: string): object[] => [
74
86
  { type: 'user', timestamp: startTimestamp, message: { role: 'user' } },
75
87
  {
@@ -251,6 +263,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
251
263
  );
252
264
  const repository = new TranscriptSessionSubAgentActivityRepository(
253
265
  createResolver(),
266
+ emptyProcessLister(),
254
267
  now,
255
268
  );
256
269
 
@@ -260,7 +273,12 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
260
273
  );
261
274
 
262
275
  expect(result.get(sessionName)).toEqual([
263
- { label: 'agent-aaa111', silentSeconds: 120, runningSeconds: 900 },
276
+ {
277
+ label: 'agent-aaa111',
278
+ silentSeconds: 120,
279
+ runningSeconds: 900,
280
+ waitingOnExternalProcess: false,
281
+ },
264
282
  ]);
265
283
  });
266
284
 
@@ -274,6 +292,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
274
292
  );
275
293
  const repository = new TranscriptSessionSubAgentActivityRepository(
276
294
  createResolver(),
295
+ emptyProcessLister(),
277
296
  now,
278
297
  );
279
298
 
@@ -295,6 +314,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
295
314
  );
296
315
  const repository = new TranscriptSessionSubAgentActivityRepository(
297
316
  createResolver(),
317
+ emptyProcessLister(),
298
318
  now,
299
319
  );
300
320
 
@@ -316,6 +336,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
316
336
  );
317
337
  const repository = new TranscriptSessionSubAgentActivityRepository(
318
338
  createResolver(),
339
+ emptyProcessLister(),
319
340
  now,
320
341
  );
321
342
 
@@ -337,6 +358,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
337
358
  );
338
359
  const repository = new TranscriptSessionSubAgentActivityRepository(
339
360
  createResolver(),
361
+ emptyProcessLister(),
340
362
  now,
341
363
  );
342
364
 
@@ -358,6 +380,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
358
380
  );
359
381
  const repository = new TranscriptSessionSubAgentActivityRepository(
360
382
  createResolver(),
383
+ emptyProcessLister(),
361
384
  now,
362
385
  );
363
386
 
@@ -379,6 +402,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
379
402
  );
380
403
  const repository = new TranscriptSessionSubAgentActivityRepository(
381
404
  createResolver(),
405
+ emptyProcessLister(),
382
406
  now,
383
407
  );
384
408
 
@@ -400,6 +424,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
400
424
  );
401
425
  const repository = new TranscriptSessionSubAgentActivityRepository(
402
426
  createResolver(),
427
+ emptyProcessLister(),
403
428
  now,
404
429
  );
405
430
 
@@ -421,6 +446,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
421
446
  );
422
447
  const repository = new TranscriptSessionSubAgentActivityRepository(
423
448
  createResolver(),
449
+ emptyProcessLister(),
424
450
  now,
425
451
  );
426
452
 
@@ -430,7 +456,12 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
430
456
  );
431
457
 
432
458
  expect(result.get(sessionName)).toEqual([
433
- { label: 'agent-pending', silentSeconds: 600, runningSeconds: 900 },
459
+ {
460
+ label: 'agent-pending',
461
+ silentSeconds: 600,
462
+ runningSeconds: 900,
463
+ waitingOnExternalProcess: false,
464
+ },
434
465
  ]);
435
466
  });
436
467
 
@@ -445,6 +476,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
445
476
  );
446
477
  const repository = new TranscriptSessionSubAgentActivityRepository(
447
478
  createResolver(),
479
+ emptyProcessLister(),
448
480
  now,
449
481
  );
450
482
 
@@ -458,6 +490,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
458
490
  label: 'agent-longstall',
459
491
  silentSeconds: fourDaysSeconds,
460
492
  runningSeconds: fourDaysSeconds,
493
+ waitingOnExternalProcess: false,
461
494
  },
462
495
  ]);
463
496
  });
@@ -480,6 +513,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
480
513
  fs.symlinkSync(targetPath, path.join(dir, 'agent-link1.jsonl'));
481
514
  const repository = new TranscriptSessionSubAgentActivityRepository(
482
515
  createResolver(),
516
+ emptyProcessLister(),
483
517
  now,
484
518
  );
485
519
 
@@ -489,7 +523,12 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
489
523
  );
490
524
 
491
525
  expect(result.get(sessionName)).toEqual([
492
- { label: 'agent-link1', silentSeconds: 60, runningSeconds: 600 },
526
+ {
527
+ label: 'agent-link1',
528
+ silentSeconds: 60,
529
+ runningSeconds: 600,
530
+ waitingOnExternalProcess: false,
531
+ },
493
532
  ]);
494
533
  fs.rmSync(targetDir, { force: true, recursive: true });
495
534
  });
@@ -497,6 +536,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
497
536
  it('returns an empty map when the resolver returns null', async () => {
498
537
  const repository = new TranscriptSessionSubAgentActivityRepository(
499
538
  { resolveSubAgentsDirectory: () => null },
539
+ emptyProcessLister(),
500
540
  now,
501
541
  );
502
542
 
@@ -511,6 +551,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
511
551
  it('ignores a session whose subagents directory does not exist', async () => {
512
552
  const repository = new TranscriptSessionSubAgentActivityRepository(
513
553
  createResolver(),
554
+ emptyProcessLister(),
514
555
  now,
515
556
  );
516
557
 
@@ -538,6 +579,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
538
579
  );
539
580
  const repository = new TranscriptSessionSubAgentActivityRepository(
540
581
  createResolver(),
582
+ emptyProcessLister(),
541
583
  now,
542
584
  );
543
585
 
@@ -559,6 +601,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
559
601
  );
560
602
  const repository = new TranscriptSessionSubAgentActivityRepository(
561
603
  createResolver(),
604
+ emptyProcessLister(),
562
605
  now,
563
606
  );
564
607
 
@@ -568,7 +611,12 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
568
611
  );
569
612
 
570
613
  expect(result.get(sessionName)).toEqual([
571
- { label: 'agent-future', silentSeconds: 0, runningSeconds: 0 },
614
+ {
615
+ label: 'agent-future',
616
+ silentSeconds: 0,
617
+ runningSeconds: 0,
618
+ waitingOnExternalProcess: false,
619
+ },
572
620
  ]);
573
621
  });
574
622
 
@@ -604,6 +652,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
604
652
 
605
653
  const repository = new TranscriptSessionSubAgentActivityRepository(
606
654
  new FileSystemSubAgentTranscriptDirectoryResolver(projectsRoot),
655
+ emptyProcessLister(),
607
656
  now,
608
657
  );
609
658
 
@@ -617,6 +666,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
617
666
  label: 'agent-afcbe335fdbec0a28',
618
667
  silentSeconds: 120,
619
668
  runningSeconds: 900,
669
+ waitingOnExternalProcess: false,
620
670
  },
621
671
  ]);
622
672
  });
@@ -638,6 +688,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
638
688
  );
639
689
  const repository = new TranscriptSessionSubAgentActivityRepository(
640
690
  createResolver(),
691
+ emptyProcessLister(),
641
692
  now,
642
693
  );
643
694
 
@@ -651,7 +702,148 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
651
702
  label: 'agent-stalledold',
652
703
  silentSeconds: twoDaysSeconds,
653
704
  runningSeconds: twoDaysSeconds,
705
+ waitingOnExternalProcess: false,
654
706
  },
655
707
  ]);
656
708
  });
709
+
710
+ describe('external-wait classification from the pending tool command', () => {
711
+ const CI_WATCH_COMMAND =
712
+ 'gh pr checks 123 --watch -i 60 -R owner/repo 2>&1 | tail -5';
713
+
714
+ const pendingCommandEntries = (
715
+ startTimestamp: string,
716
+ command: string,
717
+ ): object[] => [
718
+ { type: 'user', timestamp: startTimestamp, message: { role: 'user' } },
719
+ {
720
+ type: 'assistant',
721
+ timestamp: startTimestamp,
722
+ message: {
723
+ role: 'assistant',
724
+ stop_reason: 'tool_use',
725
+ content: [{ type: 'tool_use', name: 'Bash', input: { command } }],
726
+ },
727
+ },
728
+ ];
729
+
730
+ it('classifies a sub-agent as waiting when its pending tool command matches a live process', async () => {
731
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
732
+ writeAgentTranscript(
733
+ sessionName,
734
+ 'ciwait',
735
+ pendingCommandEntries('2026-06-27T11:45:00.000Z', CI_WATCH_COMMAND),
736
+ nowEpochSeconds - 600,
737
+ );
738
+ const repository = new TranscriptSessionSubAgentActivityRepository(
739
+ createResolver(),
740
+ processListerWith([
741
+ {
742
+ commandLine: `/bin/bash -c ${CI_WATCH_COMMAND}`,
743
+ elapsedSeconds: 600,
744
+ },
745
+ ]),
746
+ now,
747
+ );
748
+
749
+ const result = await repository.listSubAgentActivitiesBySessionName(
750
+ [sessionName],
751
+ transcriptMapFor([sessionName]),
752
+ );
753
+
754
+ expect(result.get(sessionName)).toEqual([
755
+ {
756
+ label: 'agent-ciwait',
757
+ silentSeconds: 600,
758
+ runningSeconds: 900,
759
+ waitingOnExternalProcess: true,
760
+ },
761
+ ]);
762
+ });
763
+
764
+ it('classifies a sub-agent as hung when no live process matches its pending tool command', async () => {
765
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
766
+ writeAgentTranscript(
767
+ sessionName,
768
+ 'dead',
769
+ pendingCommandEntries('2026-06-27T11:45:00.000Z', CI_WATCH_COMMAND),
770
+ nowEpochSeconds - 600,
771
+ );
772
+ const repository = new TranscriptSessionSubAgentActivityRepository(
773
+ createResolver(),
774
+ processListerWith([
775
+ { commandLine: 'node unrelated-server.js', elapsedSeconds: 5000 },
776
+ ]),
777
+ now,
778
+ );
779
+
780
+ const result = await repository.listSubAgentActivitiesBySessionName(
781
+ [sessionName],
782
+ transcriptMapFor([sessionName]),
783
+ );
784
+
785
+ expect(result.get(sessionName)).toEqual([
786
+ {
787
+ label: 'agent-dead',
788
+ silentSeconds: 600,
789
+ runningSeconds: 900,
790
+ waitingOnExternalProcess: false,
791
+ },
792
+ ]);
793
+ });
794
+
795
+ it('classifies a sub-agent as hung when its pending tool call carries no command string', async () => {
796
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
797
+ writeAgentTranscript(
798
+ sessionName,
799
+ 'nocmd',
800
+ pendingToolUseEntries('2026-06-27T11:45:00.000Z'),
801
+ nowEpochSeconds - 600,
802
+ );
803
+ const repository = new TranscriptSessionSubAgentActivityRepository(
804
+ createResolver(),
805
+ processListerWith([
806
+ { commandLine: 'any process at all', elapsedSeconds: 1 },
807
+ ]),
808
+ now,
809
+ );
810
+
811
+ const result = await repository.listSubAgentActivitiesBySessionName(
812
+ [sessionName],
813
+ transcriptMapFor([sessionName]),
814
+ );
815
+
816
+ expect(result.get(sessionName)?.[0]?.waitingOnExternalProcess).toBe(
817
+ false,
818
+ );
819
+ });
820
+
821
+ it('matches on the whitespace-normalized first 60 characters of a long multi-line command', async () => {
822
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
823
+ const longCommand =
824
+ 'OWNER=owner; REPO=repo; PR=123\n SHA=$(gh api repos/$OWNER/$REPO/pulls/$PR --jq .head.sha)\n sleep 600';
825
+ writeAgentTranscript(
826
+ sessionName,
827
+ 'longcmd',
828
+ pendingCommandEntries('2026-06-27T11:45:00.000Z', longCommand),
829
+ nowEpochSeconds - 600,
830
+ );
831
+ const normalizedProcessLine =
832
+ 'bash -c OWNER=owner; REPO=repo; PR=123 SHA=$(gh api repos/$OWNER/$REPO/pulls/$PR --jq .head.sha) sleep 600';
833
+ const repository = new TranscriptSessionSubAgentActivityRepository(
834
+ createResolver(),
835
+ processListerWith([
836
+ { commandLine: normalizedProcessLine, elapsedSeconds: 600 },
837
+ ]),
838
+ now,
839
+ );
840
+
841
+ const result = await repository.listSubAgentActivitiesBySessionName(
842
+ [sessionName],
843
+ transcriptMapFor([sessionName]),
844
+ );
845
+
846
+ expect(result.get(sessionName)?.[0]?.waitingOnExternalProcess).toBe(true);
847
+ });
848
+ });
657
849
  });