github-issue-tower-defence-management 1.138.0 → 1.139.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +3 -2
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +22 -2
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +9 -5
  6. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  7. package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js +125 -0
  8. package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js.map +1 -0
  9. package/bin/adapter/repositories/FileSystemSubAgentLivenessResolver.js +81 -0
  10. package/bin/adapter/repositories/FileSystemSubAgentLivenessResolver.js.map +1 -0
  11. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +4 -1
  12. package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -1
  13. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +30 -12
  14. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  15. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +48 -1
  16. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  17. package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js +3 -0
  18. package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js.map +1 -0
  19. package/bin/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.js +3 -0
  20. package/bin/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.js.map +1 -0
  21. package/package.json +1 -1
  22. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +16 -0
  23. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +31 -0
  24. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +14 -0
  25. package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.test.ts +127 -0
  26. package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.ts +108 -0
  27. package/src/adapter/repositories/FileSystemSubAgentLivenessResolver.test.ts +87 -0
  28. package/src/adapter/repositories/FileSystemSubAgentLivenessResolver.ts +57 -0
  29. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +23 -2
  30. package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +5 -1
  31. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +136 -6
  32. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +37 -12
  33. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +218 -8
  34. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +60 -0
  35. package/src/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.ts +10 -0
  36. package/src/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.ts +6 -0
  37. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  38. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +2 -0
  39. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  40. package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts +16 -0
  41. package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts.map +1 -0
  42. package/types/adapter/repositories/FileSystemSubAgentLivenessResolver.d.ts +11 -0
  43. package/types/adapter/repositories/FileSystemSubAgentLivenessResolver.d.ts.map +1 -0
  44. package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -1
  45. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts +4 -2
  46. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  47. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +3 -1
  48. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  49. package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts +11 -0
  50. package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts.map +1 -0
  51. package/types/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.d.ts +7 -0
  52. package/types/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.d.ts.map +1 -0
@@ -0,0 +1,127 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { FileSystemSilentSessionNotifiedStateRepository } from './FileSystemSilentSessionNotifiedStateRepository';
5
+
6
+ describe('FileSystemSilentSessionNotifiedStateRepository', () => {
7
+ let rootDirectory: string;
8
+ let stateFilePath: string;
9
+
10
+ beforeEach(() => {
11
+ rootDirectory = fs.mkdtempSync(
12
+ path.join(os.tmpdir(), 'silent-session-notified-state-'),
13
+ );
14
+ stateFilePath = path.join(rootDirectory, 'silent-session-notified.json');
15
+ });
16
+
17
+ afterEach(() => {
18
+ fs.rmSync(rootDirectory, { force: true, recursive: true });
19
+ });
20
+
21
+ const sessionAlpha = 'https://github.com/HiromiShikata/repo/issues/100';
22
+ const sessionBravo = 'https://github.com/HiromiShikata/repo/issues/200';
23
+
24
+ it('round-trips a saved notified set so the next cycle reads it back', async () => {
25
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
26
+ stateFilePath,
27
+ );
28
+ const now = new Date('2026-06-26T00:00:00Z');
29
+
30
+ await repository.saveNotifiedSessionNames({
31
+ sessionNames: [sessionAlpha, sessionBravo],
32
+ now,
33
+ });
34
+ const loaded = await repository.loadRecentNotifiedSessionNames({
35
+ now: new Date('2026-06-26T00:01:00Z'),
36
+ recencyWindowSeconds: 15 * 60,
37
+ });
38
+
39
+ expect(loaded).toEqual(new Set([sessionAlpha, sessionBravo]));
40
+ });
41
+
42
+ it('returns an empty set when no state file exists yet', async () => {
43
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
44
+ stateFilePath,
45
+ );
46
+
47
+ const loaded = await repository.loadRecentNotifiedSessionNames({
48
+ now: new Date('2026-06-26T00:00:00Z'),
49
+ recencyWindowSeconds: 15 * 60,
50
+ });
51
+
52
+ expect(loaded).toEqual(new Set<string>());
53
+ });
54
+
55
+ it('excludes an entry older than the recency window when loading', async () => {
56
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
57
+ stateFilePath,
58
+ );
59
+
60
+ await repository.saveNotifiedSessionNames({
61
+ sessionNames: [sessionAlpha],
62
+ now: new Date('2026-06-26T00:00:00Z'),
63
+ });
64
+ const loaded = await repository.loadRecentNotifiedSessionNames({
65
+ now: new Date('2026-06-26T00:20:00Z'),
66
+ recencyWindowSeconds: 15 * 60,
67
+ });
68
+
69
+ expect(loaded).toEqual(new Set<string>());
70
+ });
71
+
72
+ it('overwrites the latch on save so a pruned session no longer lingers', async () => {
73
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
74
+ stateFilePath,
75
+ );
76
+
77
+ await repository.saveNotifiedSessionNames({
78
+ sessionNames: [sessionAlpha, sessionBravo],
79
+ now: new Date('2026-06-26T00:00:00Z'),
80
+ });
81
+ await repository.saveNotifiedSessionNames({
82
+ sessionNames: [sessionAlpha],
83
+ now: new Date('2026-06-26T00:01:30Z'),
84
+ });
85
+ const loaded = await repository.loadRecentNotifiedSessionNames({
86
+ now: new Date('2026-06-26T00:02:00Z'),
87
+ recencyWindowSeconds: 15 * 60,
88
+ });
89
+
90
+ expect(loaded).toEqual(new Set([sessionAlpha]));
91
+ });
92
+
93
+ it('refreshes the recorded timestamp on each save so a continuously latched session stays within the window', async () => {
94
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
95
+ stateFilePath,
96
+ );
97
+
98
+ await repository.saveNotifiedSessionNames({
99
+ sessionNames: [sessionAlpha],
100
+ now: new Date('2026-06-26T00:00:00Z'),
101
+ });
102
+ await repository.saveNotifiedSessionNames({
103
+ sessionNames: [sessionAlpha],
104
+ now: new Date('2026-06-26T00:14:00Z'),
105
+ });
106
+ const loaded = await repository.loadRecentNotifiedSessionNames({
107
+ now: new Date('2026-06-26T00:20:00Z'),
108
+ recencyWindowSeconds: 15 * 60,
109
+ });
110
+
111
+ expect(loaded).toEqual(new Set([sessionAlpha]));
112
+ });
113
+
114
+ it('returns an empty set when the state file is not valid JSON', async () => {
115
+ fs.writeFileSync(stateFilePath, 'not json');
116
+ const repository = new FileSystemSilentSessionNotifiedStateRepository(
117
+ stateFilePath,
118
+ );
119
+
120
+ const loaded = await repository.loadRecentNotifiedSessionNames({
121
+ now: new Date('2026-06-26T00:00:00Z'),
122
+ recencyWindowSeconds: 15 * 60,
123
+ });
124
+
125
+ expect(loaded).toEqual(new Set<string>());
126
+ });
127
+ });
@@ -0,0 +1,108 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { SilentSessionNotifiedStateRepository } from '../../domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository';
5
+
6
+ type StoredNotifiedEntry = {
7
+ sessionName: string;
8
+ recordedEpochSeconds: number;
9
+ };
10
+
11
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
12
+ typeof value === 'object' && value !== null && !Array.isArray(value);
13
+
14
+ const defaultStateFilePath = (): string => {
15
+ const base = process.env.XDG_CACHE_HOME ?? path.join(os.homedir(), '.cache');
16
+ return path.join(base, 'tdpm', 'silent-session-notified.json');
17
+ };
18
+
19
+ // Persists the fire-once latch: the set of session names that have already
20
+ // received the current silent-episode reminder. The use case re-writes the
21
+ // full latch each cycle with the current timestamp, keeping a session latched
22
+ // for as long as it stays a reminder candidate (a continuous silent episode
23
+ // produces exactly one reminder), and prunes a session the moment it stops
24
+ // being a candidate so a later re-qualification fires again. The save
25
+ // intentionally OVERWRITES rather than merges: the use case supplies the
26
+ // complete latch to persist, so a pruned session must not linger.
27
+ export class FileSystemSilentSessionNotifiedStateRepository implements SilentSessionNotifiedStateRepository {
28
+ constructor(
29
+ private readonly stateFilePath: string = defaultStateFilePath(),
30
+ ) {}
31
+
32
+ loadRecentNotifiedSessionNames = async (params: {
33
+ now: Date;
34
+ recencyWindowSeconds: number;
35
+ }): Promise<Set<string>> => {
36
+ const nowEpochSeconds = Math.floor(params.now.getTime() / 1000);
37
+ const oldestAllowedEpochSeconds =
38
+ nowEpochSeconds - params.recencyWindowSeconds;
39
+ const recentSessionNames = new Set<string>();
40
+ for (const entry of this.readNotifiedEntries()) {
41
+ if (entry.recordedEpochSeconds >= oldestAllowedEpochSeconds) {
42
+ recentSessionNames.add(entry.sessionName);
43
+ }
44
+ }
45
+ return recentSessionNames;
46
+ };
47
+
48
+ saveNotifiedSessionNames = async (params: {
49
+ sessionNames: string[];
50
+ now: Date;
51
+ }): Promise<void> => {
52
+ const recordedEpochSeconds = Math.floor(params.now.getTime() / 1000);
53
+ const notifiedBySessionName = new Map<string, StoredNotifiedEntry>();
54
+ for (const sessionName of params.sessionNames) {
55
+ notifiedBySessionName.set(sessionName, {
56
+ sessionName,
57
+ recordedEpochSeconds,
58
+ });
59
+ }
60
+ this.writeState(Array.from(notifiedBySessionName.values()));
61
+ };
62
+
63
+ private readNotifiedEntries = (): StoredNotifiedEntry[] => {
64
+ let raw: string;
65
+ try {
66
+ raw = fs.readFileSync(this.stateFilePath, 'utf8');
67
+ } catch {
68
+ return [];
69
+ }
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ } catch {
74
+ return [];
75
+ }
76
+ if (!isRecord(parsed)) {
77
+ return [];
78
+ }
79
+ const storedEntries = parsed.notified;
80
+ if (!Array.isArray(storedEntries)) {
81
+ return [];
82
+ }
83
+ const entries: StoredNotifiedEntry[] = [];
84
+ for (const storedEntry of storedEntries) {
85
+ if (!isRecord(storedEntry)) {
86
+ continue;
87
+ }
88
+ const sessionName = storedEntry.sessionName;
89
+ const recordedEpochSeconds = storedEntry.recordedEpochSeconds;
90
+ if (
91
+ typeof sessionName === 'string' &&
92
+ typeof recordedEpochSeconds === 'number' &&
93
+ Number.isFinite(recordedEpochSeconds)
94
+ ) {
95
+ entries.push({ sessionName, recordedEpochSeconds });
96
+ }
97
+ }
98
+ return entries;
99
+ };
100
+
101
+ private writeState = (notified: StoredNotifiedEntry[]): void => {
102
+ const directory = path.dirname(this.stateFilePath);
103
+ fs.mkdirSync(directory, { recursive: true });
104
+ const temporaryPath = `${this.stateFilePath}.${process.pid}.tmp`;
105
+ fs.writeFileSync(temporaryPath, JSON.stringify({ notified }));
106
+ fs.renameSync(temporaryPath, this.stateFilePath);
107
+ };
108
+ }
@@ -0,0 +1,87 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { FileSystemSubAgentLivenessResolver } from './FileSystemSubAgentLivenessResolver';
5
+
6
+ describe('FileSystemSubAgentLivenessResolver', () => {
7
+ let runtimeRoot: string;
8
+ const cwdSlug = '-home-user-worktrees-issue-9';
9
+ const sessionUuid = 'ba0637e1-9ff1-41a8-b13c-f45e6a71efc5';
10
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
11
+ const mainTranscriptPath = path.join(
12
+ '/home/user/.config/projects',
13
+ cwdSlug,
14
+ `${sessionUuid}.jsonl`,
15
+ );
16
+
17
+ beforeEach(() => {
18
+ runtimeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'subagent-live-'));
19
+ });
20
+
21
+ afterEach(() => {
22
+ fs.rmSync(runtimeRoot, { force: true, recursive: true });
23
+ });
24
+
25
+ const writeRunningFile = (contents: string): void => {
26
+ const dir = path.join(runtimeRoot, cwdSlug, sessionUuid);
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ fs.writeFileSync(path.join(dir, 'running-subagents.txt'), contents, 'utf8');
29
+ };
30
+
31
+ it('returns the trimmed non-empty ids listed in running-subagents.txt', async () => {
32
+ writeRunningFile('a70a08e0587d6f484\n\n ad5d15d239d7e72e8 \n');
33
+ const resolver = new FileSystemSubAgentLivenessResolver(runtimeRoot);
34
+
35
+ const result = await resolver.resolveLiveSubAgentIds({
36
+ sessionName,
37
+ mainTranscriptPath,
38
+ });
39
+
40
+ expect(result).toEqual(new Set(['a70a08e0587d6f484', 'ad5d15d239d7e72e8']));
41
+ });
42
+
43
+ it('returns an empty set when the running file exists but lists no ids', async () => {
44
+ writeRunningFile('\n \n');
45
+ const resolver = new FileSystemSubAgentLivenessResolver(runtimeRoot);
46
+
47
+ const result = await resolver.resolveLiveSubAgentIds({
48
+ sessionName,
49
+ mainTranscriptPath,
50
+ });
51
+
52
+ expect(result).toEqual(new Set());
53
+ });
54
+
55
+ it('returns null when the running file does not exist', async () => {
56
+ const resolver = new FileSystemSubAgentLivenessResolver(runtimeRoot);
57
+
58
+ const result = await resolver.resolveLiveSubAgentIds({
59
+ sessionName,
60
+ mainTranscriptPath,
61
+ });
62
+
63
+ expect(result).toBeNull();
64
+ });
65
+
66
+ it('returns null when the runtime root directory is null', async () => {
67
+ const resolver = new FileSystemSubAgentLivenessResolver(null);
68
+
69
+ const result = await resolver.resolveLiveSubAgentIds({
70
+ sessionName,
71
+ mainTranscriptPath,
72
+ });
73
+
74
+ expect(result).toBeNull();
75
+ });
76
+
77
+ it('returns null when the main transcript path is null', async () => {
78
+ const resolver = new FileSystemSubAgentLivenessResolver(runtimeRoot);
79
+
80
+ const result = await resolver.resolveLiveSubAgentIds({
81
+ sessionName,
82
+ mainTranscriptPath: null,
83
+ });
84
+
85
+ expect(result).toBeNull();
86
+ });
87
+ });
@@ -0,0 +1,57 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { SubAgentLivenessResolver } from '../../domain/usecases/adapter-interfaces/SubAgentLivenessResolver';
4
+
5
+ const RUNNING_SUBAGENTS_FILE_NAME = 'running-subagents.txt';
6
+
7
+ export class FileSystemSubAgentLivenessResolver implements SubAgentLivenessResolver {
8
+ constructor(private readonly runtimeRootDirectory: string | null) {}
9
+
10
+ resolveLiveSubAgentIds = async (params: {
11
+ sessionName: string;
12
+ mainTranscriptPath: string | null;
13
+ }): Promise<Set<string> | null> => {
14
+ const runningFilePath = this.resolveRunningSubAgentsFilePath(
15
+ params.mainTranscriptPath,
16
+ );
17
+ if (runningFilePath === null) {
18
+ return null;
19
+ }
20
+ let content: string;
21
+ try {
22
+ content = fs.readFileSync(runningFilePath, 'utf8');
23
+ } catch {
24
+ return null;
25
+ }
26
+ const liveIds = new Set<string>();
27
+ for (const line of content.split('\n')) {
28
+ const trimmed = line.trim();
29
+ if (trimmed.length > 0) {
30
+ liveIds.add(trimmed);
31
+ }
32
+ }
33
+ return liveIds;
34
+ };
35
+
36
+ private resolveRunningSubAgentsFilePath = (
37
+ mainTranscriptPath: string | null,
38
+ ): string | null => {
39
+ if (this.runtimeRootDirectory === null || mainTranscriptPath === null) {
40
+ return null;
41
+ }
42
+ const sessionUuid = path.basename(mainTranscriptPath, '.jsonl');
43
+ if (sessionUuid.length === 0 || sessionUuid.endsWith('.jsonl')) {
44
+ return null;
45
+ }
46
+ const cwdSlug = path.basename(path.dirname(mainTranscriptPath));
47
+ if (cwdSlug.length === 0 || cwdSlug === '.') {
48
+ return null;
49
+ }
50
+ return path.join(
51
+ this.runtimeRootDirectory,
52
+ cwdSlug,
53
+ sessionUuid,
54
+ RUNNING_SUBAGENTS_FILE_NAME,
55
+ );
56
+ };
57
+ }
@@ -13,7 +13,7 @@ const createMockRunner = (): Mocked<LocalCommandRunner> => ({
13
13
 
14
14
  describe('TmuxSilentSessionNotificationRepository', () => {
15
15
  describe('sendSelfCheckNotification', () => {
16
- it('sends the message literally then submits it with Enter', async () => {
16
+ it('wraps the message in bracketed-paste framing, sends it literally, then submits it with Enter', async () => {
17
17
  const runner = createMockRunner();
18
18
  const repository = new TmuxSilentSessionNotificationRepository(runner);
19
19
 
@@ -29,7 +29,7 @@ describe('TmuxSilentSessionNotificationRepository', () => {
29
29
  '-t',
30
30
  'https_//github_com/owner/repo/issues/9',
31
31
  '-l',
32
- 'self check message',
32
+ '\x1b[200~self check message\x1b[201~',
33
33
  ],
34
34
  ]);
35
35
  expect(runner.runCommand.mock.calls[1]).toEqual([
@@ -38,6 +38,27 @@ describe('TmuxSilentSessionNotificationRepository', () => {
38
38
  ]);
39
39
  });
40
40
 
41
+ it('preserves an unchanged multi-line message body inside the bracketed-paste frame', async () => {
42
+ const runner = createMockRunner();
43
+ const repository = new TmuxSilentSessionNotificationRepository(runner);
44
+ const multiLineMessage = 'first line\nsecond line\n\nthird line';
45
+
46
+ await repository.sendSelfCheckNotification(
47
+ 'https_//github_com/owner/repo/issues/9',
48
+ multiLineMessage,
49
+ );
50
+
51
+ const literalArgument = runner.runCommand.mock.calls[0][1][4];
52
+ expect(literalArgument.startsWith('\x1b[200~')).toBe(true);
53
+ expect(literalArgument.endsWith('\x1b[201~')).toBe(true);
54
+ expect(
55
+ literalArgument.slice('\x1b[200~'.length, -'\x1b[201~'.length),
56
+ ).toBe(multiLineMessage);
57
+ const literalBytes = Buffer.from(literalArgument, 'utf8').toString('hex');
58
+ expect(literalBytes.startsWith('1b5b3230307e')).toBe(true);
59
+ expect(literalBytes.endsWith('1b5b3230317e')).toBe(true);
60
+ });
61
+
41
62
  it('throws when sending the message literally fails', async () => {
42
63
  const runner = createMockRunner();
43
64
  runner.runCommand.mockResolvedValueOnce({
@@ -1,6 +1,9 @@
1
1
  import { SilentSessionNotificationRepository } from '../../domain/usecases/adapter-interfaces/SilentSessionNotificationRepository';
2
2
  import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
3
 
4
+ const BRACKETED_PASTE_START = '\x1b[200~';
5
+ const BRACKETED_PASTE_END = '\x1b[201~';
6
+
4
7
  export class TmuxSilentSessionNotificationRepository implements SilentSessionNotificationRepository {
5
8
  constructor(private readonly localCommandRunner: LocalCommandRunner) {}
6
9
 
@@ -8,12 +11,13 @@ export class TmuxSilentSessionNotificationRepository implements SilentSessionNot
8
11
  sessionName: string,
9
12
  message: string,
10
13
  ): Promise<void> => {
14
+ const framedMessage = `${BRACKETED_PASTE_START}${message}${BRACKETED_PASTE_END}`;
11
15
  const literalResult = await this.localCommandRunner.runCommand('tmux', [
12
16
  'send-keys',
13
17
  '-t',
14
18
  sessionName,
15
19
  '-l',
16
- message,
20
+ framedMessage,
17
21
  ]);
18
22
  if (literalResult.exitCode !== 0) {
19
23
  throw new Error(
@@ -6,6 +6,7 @@ import {
6
6
  SubAgentProcess,
7
7
  SubAgentProcessLister,
8
8
  } from '../../domain/usecases/adapter-interfaces/SubAgentProcessLister';
9
+ import { SubAgentLivenessResolver } from '../../domain/usecases/adapter-interfaces/SubAgentLivenessResolver';
9
10
  import { FileSystemSubAgentTranscriptDirectoryResolver } from './FileSystemSubAgentTranscriptDirectoryResolver';
10
11
  import { TranscriptSessionSubAgentActivityRepository } from './TranscriptSessionSubAgentActivityRepository';
11
12
 
@@ -1085,6 +1086,23 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1085
1086
  content: `<task-notification>\n<task-id>${agentId}</task-id>\n<status>completed</status>\n</task-notification>`,
1086
1087
  });
1087
1088
 
1089
+ const stoppedNotificationEntry = (
1090
+ agentId: string,
1091
+ timestamp: string,
1092
+ ): object => ({
1093
+ type: 'queue-operation',
1094
+ operation: 'enqueue',
1095
+ timestamp,
1096
+ content: `<task-notification>\n<task-id>${agentId}</task-id>\n<status>stopped</status>\n</task-notification>`,
1097
+ });
1098
+
1099
+ const livenessResolverWith = (
1100
+ liveIds: string[] | null,
1101
+ ): SubAgentLivenessResolver => ({
1102
+ resolveLiveSubAgentIds: async () =>
1103
+ liveIds === null ? null : new Set(liveIds),
1104
+ });
1105
+
1088
1106
  it('excludes a sub-agent killed via TaskStop when the parent transcript records a killed notification for its id', async () => {
1089
1107
  const sessionName = 'https_//github_com/owner/repo/issues/9';
1090
1108
  const killedAgentId = 'aaabbbbcccc00001';
@@ -1137,7 +1155,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1137
1155
  expect(result.size).toBe(0);
1138
1156
  });
1139
1157
 
1140
- it('does not exclude a sub-agent whose parent transcript records only a completed notification for its id', async () => {
1158
+ it('excludes a sub-agent whose parent transcript records a completed notification for its id', async () => {
1141
1159
  const sessionName = 'https_//github_com/owner/repo/issues/9';
1142
1160
  const startTimestamp = '2026-06-27T11:45:00.000Z';
1143
1161
  const completedAgentId = 'aaabbbbcccc00003';
@@ -1161,14 +1179,71 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1161
1179
  transcriptMapFor([sessionName]),
1162
1180
  );
1163
1181
 
1164
- expect(result.get(sessionName)).toEqual([
1182
+ expect(result.size).toBe(0);
1183
+ });
1184
+
1185
+ it('excludes a sub-agent whose parent transcript records a stopped notification for its id', async () => {
1186
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1187
+ const stoppedAgentId = 'aaabbbbcccc00006';
1188
+ writeAgentTranscript(
1189
+ sessionName,
1190
+ stoppedAgentId,
1191
+ pendingToolUseEntries('2026-06-27T11:00:00.000Z'),
1192
+ nowEpochSeconds - 300,
1193
+ );
1194
+ writeMainTranscript(sessionName, [
1195
+ stoppedNotificationEntry(stoppedAgentId, '2026-06-27T11:55:00.000Z'),
1196
+ ]);
1197
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1198
+ createResolver(),
1199
+ emptyProcessLister(),
1200
+ now,
1201
+ );
1202
+
1203
+ const result = await repository.listSubAgentActivitiesBySessionName(
1204
+ [sessionName],
1205
+ transcriptMapFor([sessionName]),
1206
+ );
1207
+
1208
+ expect(result.size).toBe(0);
1209
+ });
1210
+
1211
+ it('excludes every task id listed in a single multi-id terminal notification', async () => {
1212
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1213
+ const firstAgentId = 'aaabbbbcccc10001';
1214
+ const secondAgentId = 'aaabbbbcccc10002';
1215
+ writeAgentTranscript(
1216
+ sessionName,
1217
+ firstAgentId,
1218
+ pendingToolUseEntries('2026-06-27T11:00:00.000Z'),
1219
+ nowEpochSeconds - 300,
1220
+ );
1221
+ writeAgentTranscript(
1222
+ sessionName,
1223
+ secondAgentId,
1224
+ pendingToolUseEntries('2026-06-27T11:00:00.000Z'),
1225
+ nowEpochSeconds - 300,
1226
+ );
1227
+ writeMainTranscript(sessionName, [
1165
1228
  {
1166
- label: `agent-${completedAgentId}`,
1167
- silentSeconds: 120,
1168
- runningSeconds: 900,
1169
- waitingOnExternalProcess: false,
1229
+ type: 'queue-operation',
1230
+ operation: 'enqueue',
1231
+ timestamp: '2026-06-27T11:55:00.000Z',
1232
+ content: `<task-notification>\n<task-id>${firstAgentId}</task-id>\n<task-id>${secondAgentId}</task-id>\n<status>completed</status>\n</task-notification>`,
1170
1233
  },
1171
1234
  ]);
1235
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1236
+ createResolver(),
1237
+ emptyProcessLister(),
1238
+ now,
1239
+ );
1240
+
1241
+ const result = await repository.listSubAgentActivitiesBySessionName(
1242
+ [sessionName],
1243
+ transcriptMapFor([sessionName]),
1244
+ );
1245
+
1246
+ expect(result.size).toBe(0);
1172
1247
  });
1173
1248
 
1174
1249
  it('excludes a killed sub-agent but reports a still-active sibling in the same session', async () => {
@@ -1211,6 +1286,61 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1211
1286
  ]);
1212
1287
  });
1213
1288
 
1289
+ it('excludes a stale sub-agent whose tail is a plain tool result when no live process exists for its id', async () => {
1290
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1291
+ const deadAgentId = 'aaabbbbcccc20001';
1292
+ writeAgentTranscript(
1293
+ sessionName,
1294
+ deadAgentId,
1295
+ inFlightToolResultTailEntries('2026-06-27T11:00:00.000Z'),
1296
+ nowEpochSeconds - 3600,
1297
+ );
1298
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1299
+ createResolver(),
1300
+ emptyProcessLister(),
1301
+ now,
1302
+ livenessResolverWith(['aaabbbbcccc20002']),
1303
+ );
1304
+
1305
+ const result = await repository.listSubAgentActivitiesBySessionName(
1306
+ [sessionName],
1307
+ transcriptMapFor([sessionName]),
1308
+ );
1309
+
1310
+ expect(result.size).toBe(0);
1311
+ });
1312
+
1313
+ it('flags a genuinely-running silent sub-agent whose id is present in the live process set', async () => {
1314
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1315
+ const liveAgentId = 'aaabbbbcccc20003';
1316
+ writeAgentTranscript(
1317
+ sessionName,
1318
+ liveAgentId,
1319
+ pendingToolUseEntries('2026-06-27T11:45:00.000Z'),
1320
+ nowEpochSeconds - 600,
1321
+ );
1322
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1323
+ createResolver(),
1324
+ emptyProcessLister(),
1325
+ now,
1326
+ livenessResolverWith([liveAgentId]),
1327
+ );
1328
+
1329
+ const result = await repository.listSubAgentActivitiesBySessionName(
1330
+ [sessionName],
1331
+ transcriptMapFor([sessionName]),
1332
+ );
1333
+
1334
+ expect(result.get(sessionName)).toEqual([
1335
+ {
1336
+ label: `agent-${liveAgentId}`,
1337
+ silentSeconds: 600,
1338
+ runningSeconds: 900,
1339
+ waitingOnExternalProcess: false,
1340
+ },
1341
+ ]);
1342
+ });
1343
+
1214
1344
  it('reports a sub-agent even when the parent transcript path does not exist', async () => {
1215
1345
  const sessionName = 'https_//github_com/owner/repo/issues/9';
1216
1346
  const startTimestamp = '2026-06-27T11:45:00.000Z';