github-issue-tower-defence-management 1.122.11 → 1.122.12

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 (22) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js +12 -50
  3. package/bin/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.js.map +1 -1
  4. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +32 -3
  5. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  6. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +14 -34
  7. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.test.ts +42 -213
  10. package/src/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.ts +11 -83
  11. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +68 -5
  12. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +43 -3
  13. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +74 -127
  14. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +15 -51
  15. package/src/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.ts +0 -8
  16. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts +0 -10
  17. package/types/adapter/repositories/FileSystemSilentSessionCandidateStateRepository.d.ts.map +1 -1
  18. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  19. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +0 -1
  20. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  21. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts +0 -8
  22. package/types/domain/usecases/adapter-interfaces/SilentSessionCandidateStateRepository.d.ts.map +1 -1
@@ -3,7 +3,6 @@ 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,
7
6
  FileSystemSilentSessionCandidateStateRepository,
8
7
  } from './FileSystemSilentSessionCandidateStateRepository';
9
8
 
@@ -142,7 +141,6 @@ describe('FileSystemSilentSessionCandidateStateRepository', () => {
142
141
  recordedEpochSeconds: Math.floor(secondSaveAt.getTime() / 1000),
143
142
  },
144
143
  ],
145
- announcedRunningSubAgents: [],
146
144
  });
147
145
  });
148
146
 
@@ -201,224 +199,55 @@ describe('FileSystemSilentSessionCandidateStateRepository', () => {
201
199
  expect(DEFAULT_STATE_RETENTION_WINDOW_SECONDS).toBe(60 * 60);
202
200
  });
203
201
 
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
- });
202
+ it('reads a legacy state file containing the removed announced-label key and drops the key on the next save', async () => {
203
+ const at = new Date('2026-06-26T00:00:00Z');
204
+ fs.writeFileSync(
205
+ stateFilePath,
206
+ JSON.stringify({
207
+ candidates: [
208
+ {
209
+ sessionName: sessionAlpha,
210
+ recordedEpochSeconds: Math.floor(at.getTime() / 1000),
211
+ },
212
+ ],
213
+ announcedRunningSubAgents: [
214
+ {
215
+ sessionName: sessionAlpha,
216
+ labels: ['sub-process-1'],
217
+ recordedEpochSeconds: Math.floor(at.getTime() / 1000),
218
+ },
219
+ ],
220
+ }),
221
+ );
222
+ const repository = new FileSystemSilentSessionCandidateStateRepository(
223
+ stateFilePath,
224
+ );
285
225
 
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']));
226
+ const loaded = await repository.loadRecentCandidateSessionNames({
227
+ now: new Date('2026-06-26T00:01:00Z'),
228
+ recencyWindowSeconds: 15 * 60,
313
229
  });
230
+ expect(loaded).toEqual(new Set([sessionAlpha]));
314
231
 
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']));
232
+ const saveAt = new Date('2026-06-26T00:02:00Z');
233
+ await repository.saveCandidateSessionNames({
234
+ sessionNames: [sessionBravo],
235
+ now: saveAt,
346
236
  });
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(
237
+ const persisted: unknown = JSON.parse(
238
+ fs.readFileSync(stateFilePath, 'utf8'),
239
+ );
240
+ expect(persisted).toEqual({
241
+ candidates: [
369
242
  {
370
- now: new Date('2026-06-26T00:02:00Z'),
371
- recencyWindowSeconds: 15 * 60,
243
+ sessionName: sessionAlpha,
244
+ recordedEpochSeconds: Math.floor(at.getTime() / 1000),
372
245
  },
373
- );
374
- expect(loadedCandidates).toEqual(new Set([sessionAlpha]));
375
- expect(
376
- await repository.loadAnnouncedRunningSubAgentLabels({
246
+ {
377
247
  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);
248
+ recordedEpochSeconds: Math.floor(saveAt.getTime() / 1000),
249
+ },
250
+ ],
422
251
  });
423
252
  });
424
253
  });
@@ -8,23 +8,24 @@ type StoredCandidateEntry = {
8
8
  recordedEpochSeconds: number;
9
9
  };
10
10
 
11
- type StoredAnnouncedRunningEntry = {
12
- sessionName: string;
13
- labels: string[];
14
- recordedEpochSeconds: number;
15
- };
16
-
17
11
  const isRecord = (value: unknown): value is Record<string, unknown> =>
18
12
  typeof value === 'object' && value !== null && !Array.isArray(value);
19
13
 
20
14
  export const DEFAULT_STATE_RETENTION_WINDOW_SECONDS = 60 * 60;
21
- export const ANNOUNCED_RUNNING_RETENTION_WINDOW_SECONDS = 24 * 60 * 60;
22
15
 
23
16
  const defaultStateFilePath = (): string => {
24
17
  const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
25
18
  return path.join(base, 'tdpm', 'silent-session-candidates.json');
26
19
  };
27
20
 
21
+ // Persists only the candidate set used by the two-consecutive-cycle
22
+ // debounce. The formerly persisted announced-running-sub-agent labels
23
+ // (fire-once state for the long-running advisory) were removed: the
24
+ // long-running advisory now re-fires on every qualifying cycle, so no
25
+ // per-label announcement state exists anymore. An old state file that
26
+ // still contains an `announcedRunningSubAgents` key is read without
27
+ // error (unknown keys are ignored) and the key is dropped on the next
28
+ // write.
28
29
  export class FileSystemSilentSessionCandidateStateRepository implements SilentSessionCandidateStateRepository {
29
30
  constructor(
30
31
  private readonly stateFilePath: string = defaultStateFilePath(),
@@ -71,42 +72,7 @@ export class FileSystemSilentSessionCandidateStateRepository implements SilentSe
71
72
  recordedEpochSeconds,
72
73
  });
73
74
  }
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);
75
+ this.writeState(Array.from(mergedBySessionName.values()));
110
76
  };
111
77
 
112
78
  private readState = (): Record<string, unknown> => {
@@ -151,49 +117,11 @@ export class FileSystemSilentSessionCandidateStateRepository implements SilentSe
151
117
  return entries;
152
118
  };
153
119
 
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 => {
120
+ private writeState = (candidates: StoredCandidateEntry[]): void => {
190
121
  const directory = path.dirname(this.stateFilePath);
191
122
  fs.mkdirSync(directory, { recursive: true });
192
123
  const temporaryPath = `${this.stateFilePath}.${process.pid}.tmp`;
193
- fs.writeFileSync(
194
- temporaryPath,
195
- JSON.stringify({ candidates, announcedRunningSubAgents }),
196
- );
124
+ fs.writeFileSync(temporaryPath, JSON.stringify({ candidates }));
197
125
  fs.renameSync(temporaryPath, this.stateFilePath);
198
126
  };
199
127
  }
@@ -164,7 +164,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
164
164
  },
165
165
  ];
166
166
 
167
- const toolResultTerminalEntries = (startTimestamp: string): object[] => [
167
+ const inFlightToolResultTailEntries = (startTimestamp: string): object[] => [
168
168
  { type: 'user', timestamp: startTimestamp, message: { role: 'user' } },
169
169
  {
170
170
  type: 'assistant',
@@ -370,13 +370,17 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
370
370
  expect(result.size).toBe(0);
371
371
  });
372
372
 
373
- it('excludes a dead sub-agent whose transcript ends with an unconsumed tool result and no following assistant turn', async () => {
373
+ it('keeps an in-flight sub-agent whose transcript tail is an ordinary tool result awaiting the next assistant turn', async () => {
374
+ // The transcript tail of an actively working agent alternates between a
375
+ // pending tool_use (assistant) and a tool_result (user). Treating the
376
+ // tool_result tail as completion made an active agent flap in and out of
377
+ // the snapshot between samples; it must stay in the snapshot instead.
374
378
  const sessionName = 'https_//github_com/owner/repo/issues/9';
375
379
  writeAgentTranscript(
376
380
  sessionName,
377
381
  'toolresultend',
378
- toolResultTerminalEntries('2026-06-27T11:00:00.000Z'),
379
- nowEpochSeconds - 600,
382
+ inFlightToolResultTailEntries('2026-06-27T11:50:00.000Z'),
383
+ nowEpochSeconds - 60,
380
384
  );
381
385
  const repository = new TranscriptSessionSubAgentActivityRepository(
382
386
  createResolver(),
@@ -389,7 +393,14 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
389
393
  transcriptMapFor([sessionName]),
390
394
  );
391
395
 
392
- expect(result.size).toBe(0);
396
+ expect(result.get(sessionName)).toEqual([
397
+ {
398
+ label: 'agent-toolresultend',
399
+ silentSeconds: 60,
400
+ runningSeconds: 600,
401
+ waitingOnExternalProcess: false,
402
+ },
403
+ ]);
393
404
  });
394
405
 
395
406
  it('excludes a finished sub-agent whose transcript ends with the final StructuredOutput tool result', async () => {
@@ -414,6 +425,58 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
414
425
  expect(result.size).toBe(0);
415
426
  });
416
427
 
428
+ it('keeps an in-flight sub-agent whose tail tool result follows a Bash call even when an earlier turn used StructuredOutput', async () => {
429
+ // Only the assistant turn immediately preceding the tail tool_result
430
+ // decides completion: an earlier StructuredOutput call that was already
431
+ // consumed must not mark a later in-flight tool_result as terminal.
432
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
433
+ const startTimestamp = '2026-06-27T11:50:00.000Z';
434
+ writeAgentTranscript(
435
+ sessionName,
436
+ 'laterbash',
437
+ [
438
+ ...structuredOutputTerminalEntries(startTimestamp),
439
+ {
440
+ type: 'assistant',
441
+ timestamp: startTimestamp,
442
+ message: {
443
+ role: 'assistant',
444
+ stop_reason: 'tool_use',
445
+ content: [{ type: 'tool_use', name: 'Bash', input: {} }],
446
+ },
447
+ },
448
+ {
449
+ type: 'user',
450
+ timestamp: startTimestamp,
451
+ message: {
452
+ role: 'user',
453
+ content: [{ type: 'tool_result', content: 'command output' }],
454
+ },
455
+ },
456
+ ],
457
+ nowEpochSeconds - 60,
458
+ );
459
+ const repository = new TranscriptSessionSubAgentActivityRepository(
460
+ createResolver(),
461
+ emptyProcessLister(),
462
+ now,
463
+ );
464
+
465
+ const result = await repository.listSubAgentActivitiesBySessionName(
466
+ [sessionName],
467
+ transcriptMapFor([sessionName]),
468
+ );
469
+
470
+ expect(result.get(sessionName)).toEqual([
471
+ {
472
+ label: 'agent-laterbash',
473
+ silentSeconds: 60,
474
+ runningSeconds: 600,
475
+ waitingOnExternalProcess: false,
476
+ },
477
+ ]);
478
+ });
479
+
417
480
  it('excludes a dead sub-agent whose transcript ends with a user entry that has no content blocks', async () => {
418
481
  const sessionName = 'https_//github_com/owner/repo/issues/9';
419
482
  writeAgentTranscript(
@@ -48,7 +48,21 @@ const readContentBlocks = (
48
48
  return content.filter(isRecord);
49
49
  };
50
50
 
51
- const entryIndicatesCompletion = (entry: Record<string, unknown>): boolean => {
51
+ // Tool whose result marks the sub-agent's final structured answer: once its
52
+ // tool_result is recorded, the sub-agent has delivered its output and is
53
+ // genuinely complete even though the transcript tail is a user entry.
54
+ const COMPLETION_TOOL_NAMES = new Set(['StructuredOutput']);
55
+
56
+ const entryToolUseNames = (message: Record<string, unknown>): string[] =>
57
+ readContentBlocks(message)
58
+ .filter((block) => readString(block, 'type') === 'tool_use')
59
+ .map((block) => readString(block, 'name'))
60
+ .filter((name): name is string => name !== null);
61
+
62
+ const entryIndicatesCompletion = (
63
+ entry: Record<string, unknown>,
64
+ precedingAssistantToolUseNames: string[],
65
+ ): boolean => {
52
66
  const type = readString(entry, 'type');
53
67
  const message = entry.message;
54
68
  if (!isRecord(message)) {
@@ -70,7 +84,26 @@ const entryIndicatesCompletion = (entry: Record<string, unknown>): boolean => {
70
84
  }
71
85
  const lastBlock = blocks[blocks.length - 1];
72
86
  const lastBlockType = readString(lastBlock, 'type');
73
- return lastBlockType === 'text' || lastBlockType === 'tool_result';
87
+ if (lastBlockType === 'text') {
88
+ // A trailing user text entry is an interruption (e.g. "[Request
89
+ // interrupted by user]"): the sub-agent will not produce further
90
+ // output, so the transcript is terminal.
91
+ return true;
92
+ }
93
+ if (lastBlockType === 'tool_result') {
94
+ // A trailing tool_result is an IN-FLIGHT state, not completion: the
95
+ // sub-agent has just received a tool result and the next assistant
96
+ // turn is being generated. Treating it as completion made an active
97
+ // agent flap in and out of the activity snapshot between samples
98
+ // (tail alternates between pending tool_use and tool_result). Only
99
+ // the result of an explicit completion tool (see
100
+ // COMPLETION_TOOL_NAMES) is a genuine terminal state, so completed
101
+ // agents still drop out of the snapshot.
102
+ return precedingAssistantToolUseNames.some((name) =>
103
+ COMPLETION_TOOL_NAMES.has(name),
104
+ );
105
+ }
106
+ return false;
74
107
  }
75
108
  return false;
76
109
  };
@@ -102,6 +135,7 @@ const parseTranscript = (content: string): ParsedTranscript => {
102
135
  let firstEntryEpochSeconds: number | null = null;
103
136
  let lastEntryIndicatesCompletion = false;
104
137
  let pendingToolCommands: string[] = [];
138
+ let precedingAssistantToolUseNames: string[] = [];
105
139
  for (const line of content.split('\n')) {
106
140
  const trimmed = line.trim();
107
141
  if (trimmed.length === 0) {
@@ -121,8 +155,14 @@ const parseTranscript = (content: string): ParsedTranscript => {
121
155
  firstEntryEpochSeconds = epochSeconds;
122
156
  }
123
157
  if (isRecord(parsed.message)) {
124
- lastEntryIndicatesCompletion = entryIndicatesCompletion(parsed);
158
+ lastEntryIndicatesCompletion = entryIndicatesCompletion(
159
+ parsed,
160
+ precedingAssistantToolUseNames,
161
+ );
125
162
  pendingToolCommands = entryPendingToolCommands(parsed);
163
+ if (readString(parsed, 'type') === 'assistant') {
164
+ precedingAssistantToolUseNames = entryToolUseNames(parsed.message);
165
+ }
126
166
  }
127
167
  }
128
168
  return {