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.
- package/CHANGELOG.md +14 -0
- package/README.md +3 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +22 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +9 -5
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js +125 -0
- package/bin/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.js.map +1 -0
- package/bin/adapter/repositories/FileSystemSubAgentLivenessResolver.js +81 -0
- package/bin/adapter/repositories/FileSystemSubAgentLivenessResolver.js.map +1 -0
- package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js +4 -1
- package/bin/adapter/repositories/TmuxSilentSessionNotificationRepository.js.map +1 -1
- package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +30 -12
- package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +48 -1
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +16 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +31 -0
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +14 -0
- package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.test.ts +127 -0
- package/src/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.ts +108 -0
- package/src/adapter/repositories/FileSystemSubAgentLivenessResolver.test.ts +87 -0
- package/src/adapter/repositories/FileSystemSubAgentLivenessResolver.ts +57 -0
- package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.test.ts +23 -2
- package/src/adapter/repositories/TmuxSilentSessionNotificationRepository.ts +5 -1
- package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +136 -6
- package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +37 -12
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +218 -8
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +60 -0
- package/src/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.ts +10 -0
- package/src/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.ts +6 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +2 -0
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts +16 -0
- package/types/adapter/repositories/FileSystemSilentSessionNotifiedStateRepository.d.ts.map +1 -0
- package/types/adapter/repositories/FileSystemSubAgentLivenessResolver.d.ts +11 -0
- package/types/adapter/repositories/FileSystemSubAgentLivenessResolver.d.ts.map +1 -0
- package/types/adapter/repositories/TmuxSilentSessionNotificationRepository.d.ts.map +1 -1
- package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts +4 -2
- package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +3 -1
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts +11 -0
- package/types/domain/usecases/adapter-interfaces/SilentSessionNotifiedStateRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SubAgentLivenessResolver.d.ts +7 -0
- 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('
|
|
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
|
-
|
|
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('
|
|
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.
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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';
|