github-issue-tower-defence-management 1.110.1 → 1.111.0
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 +7 -0
- package/README.md +8 -7
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +9 -21
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +10 -15
- package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
- package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +104 -0
- package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -0
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +55 -19
- package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
- package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +75 -0
- package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -0
- package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +1 -1
- package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -1
- package/bin/adapter/repositories/ProcFsProcessEnvironReader.js +73 -0
- package/bin/adapter/repositories/ProcFsProcessEnvironReader.js.map +1 -0
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +8 -16
- package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
- package/bin/domain/entities/InteractiveLiveSession.js +3 -0
- package/bin/domain/entities/InteractiveLiveSession.js.map +1 -0
- package/bin/domain/entities/LiveSessionProcessSnapshot.js +3 -0
- package/bin/domain/entities/LiveSessionProcessSnapshot.js.map +1 -0
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +20 -33
- package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
- package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +89 -0
- package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -15
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +96 -204
- package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +19 -40
- package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +147 -0
- package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +75 -0
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +82 -44
- package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +62 -24
- package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +138 -0
- package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +93 -0
- package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +6 -4
- package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +1 -1
- package/src/adapter/repositories/ProcFsProcessEnvironReader.test.ts +60 -0
- package/src/adapter/repositories/ProcFsProcessEnvironReader.ts +38 -0
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +61 -81
- package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +8 -25
- package/src/domain/entities/InteractiveLiveSession.ts +5 -0
- package/src/domain/entities/LiveSessionProcessSnapshot.ts +17 -0
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +125 -349
- package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +33 -55
- package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +220 -0
- package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +105 -0
- package/src/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.ts +7 -0
- package/src/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.ts +5 -0
- package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +1 -1
- package/src/domain/usecases/adapter-interfaces/ProcessEnvironReader.ts +3 -0
- package/src/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.ts +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -7
- package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
- package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +18 -0
- package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
- package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +9 -5
- package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
- package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +21 -0
- package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -0
- package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +1 -1
- package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -1
- package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts +12 -0
- package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts.map +1 -0
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -4
- package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
- package/types/domain/entities/InteractiveLiveSession.d.ts +6 -0
- package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -0
- package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +16 -0
- package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -0
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +6 -11
- package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
- package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +23 -0
- package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts +5 -0
- package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts +5 -0
- package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +1 -1
- package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts +4 -0
- package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts +1 -1
- package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts.map +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { InteractiveLiveSession } from '../../domain/entities/InteractiveLiveSession';
|
|
4
|
+
import { InteractiveLiveSessionTranscriptResolver } from '../../domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver';
|
|
5
|
+
|
|
6
|
+
const modifiedEpochMs = (filePath: string): number | null => {
|
|
7
|
+
try {
|
|
8
|
+
const stats = fs.statSync(filePath);
|
|
9
|
+
return stats.isFile() ? stats.mtimeMs : null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves the real transcript path of an interactive Claude Code session from
|
|
17
|
+
* the session id and config directory taken from the session's process
|
|
18
|
+
* environment. The transcript lives at
|
|
19
|
+
* `<configDir>/projects/<cwd-slug>/<sessionId>.jsonl`; this resolver scans the
|
|
20
|
+
* `projects` subdirectories for a file named `<sessionId>.jsonl` and returns the
|
|
21
|
+
* most recently modified match. Because resolution is keyed on the process
|
|
22
|
+
* session id rather than on a session name or issue URL, a plain-named session
|
|
23
|
+
* (for example one named `workbench`) resolves just as well as an issue-url
|
|
24
|
+
* named one.
|
|
25
|
+
*/
|
|
26
|
+
export class FileSystemInteractiveLiveSessionTranscriptResolver implements InteractiveLiveSessionTranscriptResolver {
|
|
27
|
+
resolveTranscriptPaths = (
|
|
28
|
+
sessions: InteractiveLiveSession[],
|
|
29
|
+
): Map<string, string> => {
|
|
30
|
+
const resolved = new Map<string, string>();
|
|
31
|
+
for (const session of sessions) {
|
|
32
|
+
const transcriptPath = this.resolveTranscriptPath(session);
|
|
33
|
+
if (transcriptPath !== null) {
|
|
34
|
+
resolved.set(session.sessionName, transcriptPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return resolved;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
private resolveTranscriptPath = (
|
|
41
|
+
session: InteractiveLiveSession,
|
|
42
|
+
): string | null => {
|
|
43
|
+
const projectsDirectory = path.join(session.configDir, 'projects');
|
|
44
|
+
let projectEntries: fs.Dirent[];
|
|
45
|
+
try {
|
|
46
|
+
projectEntries = fs.readdirSync(projectsDirectory, {
|
|
47
|
+
withFileTypes: true,
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const fileName = `${session.sessionId}.jsonl`;
|
|
53
|
+
let latestPath: string | null = null;
|
|
54
|
+
let latestEpochMs = -Infinity;
|
|
55
|
+
for (const projectEntry of projectEntries) {
|
|
56
|
+
if (!projectEntry.isDirectory()) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const candidate = path.join(
|
|
60
|
+
projectsDirectory,
|
|
61
|
+
projectEntry.name,
|
|
62
|
+
fileName,
|
|
63
|
+
);
|
|
64
|
+
const epochMs = modifiedEpochMs(candidate);
|
|
65
|
+
if (epochMs === null) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (epochMs > latestEpochMs) {
|
|
69
|
+
latestEpochMs = epochMs;
|
|
70
|
+
latestPath = candidate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return latestPath;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -16,76 +16,114 @@ describe('FileSystemSessionOutputActivityRepository', () => {
|
|
|
16
16
|
fs.rmSync(rootDirectory, { force: true, recursive: true });
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
const assistantEntry = (timestamp: string): object => ({
|
|
20
|
+
type: 'assistant',
|
|
21
|
+
timestamp,
|
|
22
|
+
message: {
|
|
23
|
+
role: 'assistant',
|
|
24
|
+
content: [{ type: 'text', text: 'progress update' }],
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const userEntry = (timestamp: string): object => ({
|
|
29
|
+
type: 'user',
|
|
30
|
+
timestamp,
|
|
31
|
+
message: { role: 'user', content: 'go ahead' },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const writeTranscript = (fileName: string, lines: object[]): string => {
|
|
24
35
|
const filePath = path.join(rootDirectory, fileName);
|
|
25
|
-
fs.writeFileSync(
|
|
26
|
-
|
|
36
|
+
fs.writeFileSync(
|
|
37
|
+
filePath,
|
|
38
|
+
lines.map((line) => JSON.stringify(line)).join('\n'),
|
|
39
|
+
'utf8',
|
|
40
|
+
);
|
|
41
|
+
return filePath;
|
|
27
42
|
};
|
|
28
43
|
|
|
29
|
-
it('returns the latest
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
);
|
|
44
|
+
it('returns the latest assistant entry timestamp as the last output epoch', async () => {
|
|
45
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
46
|
+
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
47
|
+
userEntry('2026-06-27T10:30:00.000Z'),
|
|
48
|
+
assistantEntry('2026-06-27T10:05:00.000Z'),
|
|
49
|
+
]);
|
|
50
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
35
51
|
|
|
36
|
-
const result = await repository.listSessionOutputActivities(
|
|
52
|
+
const result = await repository.listSessionOutputActivities(
|
|
53
|
+
new Map([['workbench', transcriptPath]]),
|
|
54
|
+
);
|
|
37
55
|
|
|
38
56
|
expect(result).toEqual([
|
|
39
|
-
{
|
|
57
|
+
{
|
|
58
|
+
sessionName: 'workbench',
|
|
59
|
+
lastOutputEpochSeconds: Math.floor(
|
|
60
|
+
Date.parse('2026-06-27T10:05:00.000Z') / 1000,
|
|
61
|
+
),
|
|
62
|
+
},
|
|
40
63
|
]);
|
|
41
64
|
});
|
|
42
65
|
|
|
43
|
-
it('
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const repository = new FileSystemSessionOutputActivityRepository(
|
|
48
|
-
rootDirectory,
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
const result = await repository.listSessionOutputActivities([
|
|
52
|
-
presentSessionName,
|
|
53
|
-
missingSessionName,
|
|
66
|
+
it('ignores user entries when computing the last output epoch', async () => {
|
|
67
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
68
|
+
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
69
|
+
userEntry('2026-06-27T11:00:00.000Z'),
|
|
54
70
|
]);
|
|
71
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
72
|
+
|
|
73
|
+
const result = await repository.listSessionOutputActivities(
|
|
74
|
+
new Map([['workbench', transcriptPath]]),
|
|
75
|
+
);
|
|
55
76
|
|
|
56
77
|
expect(result).toEqual([
|
|
57
|
-
{
|
|
78
|
+
{
|
|
79
|
+
sessionName: 'workbench',
|
|
80
|
+
lastOutputEpochSeconds: Math.floor(
|
|
81
|
+
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
82
|
+
),
|
|
83
|
+
},
|
|
58
84
|
]);
|
|
59
85
|
});
|
|
60
86
|
|
|
61
|
-
it('
|
|
62
|
-
const
|
|
63
|
-
|
|
87
|
+
it('omits sessions whose transcript file is missing', async () => {
|
|
88
|
+
const presentPath = writeTranscript('present.jsonl', [
|
|
89
|
+
assistantEntry('2026-06-27T10:00:00.000Z'),
|
|
90
|
+
]);
|
|
91
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
92
|
+
|
|
93
|
+
const result = await repository.listSessionOutputActivities(
|
|
94
|
+
new Map([
|
|
95
|
+
['present', presentPath],
|
|
96
|
+
['missing', path.join(rootDirectory, 'missing.jsonl')],
|
|
97
|
+
]),
|
|
64
98
|
);
|
|
65
99
|
|
|
66
|
-
|
|
67
|
-
|
|
100
|
+
expect(result).toEqual([
|
|
101
|
+
{
|
|
102
|
+
sessionName: 'present',
|
|
103
|
+
lastOutputEpochSeconds: Math.floor(
|
|
104
|
+
Date.parse('2026-06-27T10:00:00.000Z') / 1000,
|
|
105
|
+
),
|
|
106
|
+
},
|
|
68
107
|
]);
|
|
69
|
-
|
|
70
|
-
expect(result).toEqual([]);
|
|
71
108
|
});
|
|
72
109
|
|
|
73
|
-
it('
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
const result = await repository.listSessionOutputActivities([
|
|
77
|
-
'https_//github_com/owner/repo/issues/9',
|
|
110
|
+
it('omits sessions whose transcript has no assistant entry', async () => {
|
|
111
|
+
const transcriptPath = writeTranscript('workbench.jsonl', [
|
|
112
|
+
userEntry('2026-06-27T10:00:00.000Z'),
|
|
78
113
|
]);
|
|
114
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
115
|
+
|
|
116
|
+
const result = await repository.listSessionOutputActivities(
|
|
117
|
+
new Map([['workbench', transcriptPath]]),
|
|
118
|
+
);
|
|
79
119
|
|
|
80
120
|
expect(result).toEqual([]);
|
|
81
121
|
});
|
|
82
122
|
|
|
83
|
-
it('returns an empty list when no
|
|
84
|
-
const repository = new FileSystemSessionOutputActivityRepository(
|
|
85
|
-
rootDirectory,
|
|
86
|
-
);
|
|
123
|
+
it('returns an empty list when no sessions are requested', async () => {
|
|
124
|
+
const repository = new FileSystemSessionOutputActivityRepository();
|
|
87
125
|
|
|
88
|
-
const result = await repository.listSessionOutputActivities(
|
|
126
|
+
const result = await repository.listSessionOutputActivities(new Map());
|
|
89
127
|
|
|
90
128
|
expect(result).toEqual([]);
|
|
91
129
|
});
|
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
|
-
import * as path from 'path';
|
|
3
2
|
import { LiveSessionOutputActivity } from '../../domain/entities/LiveSessionOutputActivity';
|
|
4
3
|
import { SessionOutputActivityRepository } from '../../domain/usecases/adapter-interfaces/SessionOutputActivityRepository';
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
6
|
+
typeof value === 'object' && value !== null;
|
|
7
|
+
|
|
8
|
+
const readString = (
|
|
9
|
+
value: Record<string, unknown>,
|
|
10
|
+
key: string,
|
|
11
|
+
): string | null => {
|
|
12
|
+
const candidate = value[key];
|
|
13
|
+
return typeof candidate === 'string' ? candidate : null;
|
|
14
|
+
};
|
|
8
15
|
|
|
16
|
+
const parseEpochMilliseconds = (timestamp: string | null): number | null => {
|
|
17
|
+
if (timestamp === null) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const parsed = Date.parse(timestamp);
|
|
21
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Reads the last main-session output time for each live session from its
|
|
26
|
+
* already-resolved transcript path. Idle time is computed from the timestamp of
|
|
27
|
+
* the latest `assistant` entry rather than from the transcript file modification
|
|
28
|
+
* time, so a transcript touched only by tool results or owner replies still
|
|
29
|
+
* counts as silent.
|
|
30
|
+
*/
|
|
31
|
+
export class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
|
|
9
32
|
listSessionOutputActivities = async (
|
|
10
|
-
|
|
33
|
+
transcriptPathBySessionName: Map<string, string>,
|
|
11
34
|
): Promise<LiveSessionOutputActivity[]> => {
|
|
12
|
-
if (this.rootDirectory === null) {
|
|
13
|
-
return [];
|
|
14
|
-
}
|
|
15
35
|
const activities: LiveSessionOutputActivity[] = [];
|
|
16
|
-
for (const sessionName of
|
|
36
|
+
for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
|
|
17
37
|
const lastOutputEpochSeconds =
|
|
18
|
-
this.
|
|
38
|
+
this.readLastAssistantOutputEpochSeconds(transcriptPath);
|
|
19
39
|
if (lastOutputEpochSeconds !== null) {
|
|
20
40
|
activities.push({ sessionName, lastOutputEpochSeconds });
|
|
21
41
|
}
|
|
@@ -23,26 +43,44 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
|
|
|
23
43
|
return activities;
|
|
24
44
|
};
|
|
25
45
|
|
|
26
|
-
private
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const filePath = path.join(
|
|
31
|
-
this.rootDirectory,
|
|
32
|
-
this.toOutputFileName(sessionName),
|
|
33
|
-
);
|
|
34
|
-
let stats: fs.Stats;
|
|
46
|
+
private readLastAssistantOutputEpochSeconds = (
|
|
47
|
+
transcriptPath: string,
|
|
48
|
+
): number | null => {
|
|
49
|
+
let content: string;
|
|
35
50
|
try {
|
|
36
|
-
|
|
51
|
+
content = fs.readFileSync(transcriptPath, 'utf8');
|
|
37
52
|
} catch {
|
|
38
53
|
return null;
|
|
39
54
|
}
|
|
40
|
-
|
|
55
|
+
let lastAssistantEpochMs: number | null = null;
|
|
56
|
+
for (const line of content.split('\n')) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (trimmed.length === 0) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
let parsed: unknown;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(trimmed);
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!isRecord(parsed)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (readString(parsed, 'type') !== 'assistant') {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const epochMs = parseEpochMilliseconds(readString(parsed, 'timestamp'));
|
|
74
|
+
if (epochMs === null) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (lastAssistantEpochMs === null || epochMs > lastAssistantEpochMs) {
|
|
78
|
+
lastAssistantEpochMs = epochMs;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (lastAssistantEpochMs === null) {
|
|
41
82
|
return null;
|
|
42
83
|
}
|
|
43
|
-
return Math.floor(
|
|
84
|
+
return Math.floor(lastAssistantEpochMs / 1000);
|
|
44
85
|
};
|
|
45
|
-
|
|
46
|
-
private toOutputFileName = (sessionName: string): string =>
|
|
47
|
-
sessionName.replace(/\//g, '_');
|
|
48
86
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { LocalProcessLiveSessionProcessSnapshotProvider } from './LocalProcessLiveSessionProcessSnapshotProvider';
|
|
2
|
+
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
3
|
+
import { ProcessEnvironReader } from '../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
|
|
4
|
+
|
|
5
|
+
describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
|
|
6
|
+
const makeRunner = (
|
|
7
|
+
handlers: Record<
|
|
8
|
+
string,
|
|
9
|
+
{ stdout: string; stderr?: string; exitCode?: number }
|
|
10
|
+
>,
|
|
11
|
+
): LocalCommandRunner => ({
|
|
12
|
+
runCommand: jest.fn(async (program: string, args: string[]) => {
|
|
13
|
+
const key = `${program} ${args.join(' ')}`;
|
|
14
|
+
const handler = handlers[key];
|
|
15
|
+
if (handler === undefined) {
|
|
16
|
+
return { stdout: '', stderr: '', exitCode: 1 };
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
stdout: handler.stdout,
|
|
20
|
+
stderr: handler.stderr ?? '',
|
|
21
|
+
exitCode: handler.exitCode ?? 0,
|
|
22
|
+
};
|
|
23
|
+
}),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const makeEnvironReader = (
|
|
27
|
+
byPid: Record<number, Record<string, string>>,
|
|
28
|
+
): ProcessEnvironReader => ({
|
|
29
|
+
readEnviron: (pid: number) => byPid[pid] ?? null,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('builds a snapshot of sessions, pane pids, and processes with environment', async () => {
|
|
33
|
+
const runner = makeRunner({
|
|
34
|
+
'tmux list-sessions -F #{session_name}': {
|
|
35
|
+
stdout: 'workbench\nhttps_//github_com/owner/repo/issues/9\n',
|
|
36
|
+
},
|
|
37
|
+
'tmux list-panes -t workbench -F #{pane_pid}': { stdout: '200\n' },
|
|
38
|
+
'tmux list-panes -t https_//github_com/owner/repo/issues/9 -F #{pane_pid}':
|
|
39
|
+
{ stdout: '100\n' },
|
|
40
|
+
'ps -eo pid=,ppid=,args=': {
|
|
41
|
+
stdout: [
|
|
42
|
+
' 100 1 shell',
|
|
43
|
+
' 101 100 claude --name issues-9',
|
|
44
|
+
' 200 1 shell',
|
|
45
|
+
' 201 200 claude --name workbench',
|
|
46
|
+
].join('\n'),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
const environReader = makeEnvironReader({
|
|
50
|
+
101: {
|
|
51
|
+
CLAUDE_CODE_SESSION_ID: 'issue-uuid',
|
|
52
|
+
CLAUDE_CONFIG_DIR: '/config/issues-9',
|
|
53
|
+
},
|
|
54
|
+
201: {
|
|
55
|
+
CLAUDE_CODE_SESSION_ID: 'wb-uuid',
|
|
56
|
+
CLAUDE_CONFIG_DIR: '/config/workbench',
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
|
|
60
|
+
runner,
|
|
61
|
+
environReader,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const snapshot = await provider.getSnapshot();
|
|
65
|
+
|
|
66
|
+
expect(snapshot.sessions).toEqual([
|
|
67
|
+
{ sessionName: 'workbench', panePids: [200] },
|
|
68
|
+
{
|
|
69
|
+
sessionName: 'https_//github_com/owner/repo/issues/9',
|
|
70
|
+
panePids: [100],
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
expect(snapshot.processes).toContainEqual({
|
|
74
|
+
pid: 201,
|
|
75
|
+
ppid: 200,
|
|
76
|
+
commandLine: 'claude --name workbench',
|
|
77
|
+
sessionId: 'wb-uuid',
|
|
78
|
+
configDir: '/config/workbench',
|
|
79
|
+
});
|
|
80
|
+
expect(snapshot.processes).toContainEqual({
|
|
81
|
+
pid: 100,
|
|
82
|
+
ppid: 1,
|
|
83
|
+
commandLine: 'shell',
|
|
84
|
+
sessionId: null,
|
|
85
|
+
configDir: null,
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns an empty snapshot when tmux reports no sessions', async () => {
|
|
90
|
+
const runner = makeRunner({
|
|
91
|
+
'tmux list-sessions -F #{session_name}': { stdout: '' },
|
|
92
|
+
'ps -eo pid=,ppid=,args=': { stdout: '' },
|
|
93
|
+
});
|
|
94
|
+
const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
|
|
95
|
+
runner,
|
|
96
|
+
makeEnvironReader({}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const snapshot = await provider.getSnapshot();
|
|
100
|
+
|
|
101
|
+
expect(snapshot.sessions).toEqual([]);
|
|
102
|
+
expect(snapshot.processes).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('skips malformed ps lines', async () => {
|
|
106
|
+
const runner = makeRunner({
|
|
107
|
+
'tmux list-sessions -F #{session_name}': { stdout: 'workbench\n' },
|
|
108
|
+
'tmux list-panes -t workbench -F #{pane_pid}': { stdout: '200\n' },
|
|
109
|
+
'ps -eo pid=,ppid=,args=': {
|
|
110
|
+
stdout: [
|
|
111
|
+
'garbage line',
|
|
112
|
+
' 201 200 claude --name workbench',
|
|
113
|
+
].join('\n'),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
|
|
117
|
+
runner,
|
|
118
|
+
makeEnvironReader({
|
|
119
|
+
201: {
|
|
120
|
+
CLAUDE_CODE_SESSION_ID: 'wb-uuid',
|
|
121
|
+
CLAUDE_CONFIG_DIR: '/config/workbench',
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const snapshot = await provider.getSnapshot();
|
|
127
|
+
|
|
128
|
+
expect(snapshot.processes).toEqual([
|
|
129
|
+
{
|
|
130
|
+
pid: 201,
|
|
131
|
+
ppid: 200,
|
|
132
|
+
commandLine: 'claude --name workbench',
|
|
133
|
+
sessionId: 'wb-uuid',
|
|
134
|
+
configDir: '/config/workbench',
|
|
135
|
+
},
|
|
136
|
+
]);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
|
|
2
|
+
import { LiveSessionProcessSnapshotProvider } from '../../domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider';
|
|
3
|
+
import { ProcessEnvironReader } from '../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
|
|
4
|
+
import {
|
|
5
|
+
LiveSessionPaneProcess,
|
|
6
|
+
LiveSessionProcessInfo,
|
|
7
|
+
LiveSessionProcessSnapshot,
|
|
8
|
+
} from '../../domain/entities/LiveSessionProcessSnapshot';
|
|
9
|
+
|
|
10
|
+
const SESSION_ID_ENV_KEY = 'CLAUDE_CODE_SESSION_ID';
|
|
11
|
+
const CONFIG_DIR_ENV_KEY = 'CLAUDE_CONFIG_DIR';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Builds a live-session process snapshot from the local host: it lists the live
|
|
15
|
+
* tmux sessions and each session's pane process ids, lists the full process
|
|
16
|
+
* tree with command lines, and reads each process environment to expose the
|
|
17
|
+
* Claude Code session id and config directory. The snapshot is consumed by a
|
|
18
|
+
* pure resolver, so all host access is contained in this adapter.
|
|
19
|
+
*/
|
|
20
|
+
export class LocalProcessLiveSessionProcessSnapshotProvider implements LiveSessionProcessSnapshotProvider {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly localCommandRunner: LocalCommandRunner,
|
|
23
|
+
private readonly environReader: ProcessEnvironReader,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
getSnapshot = async (): Promise<LiveSessionProcessSnapshot> => {
|
|
27
|
+
const sessionNames = await this.listSessionNames();
|
|
28
|
+
const sessions: LiveSessionPaneProcess[] = [];
|
|
29
|
+
for (const sessionName of sessionNames) {
|
|
30
|
+
const panePids = await this.listPanePids(sessionName);
|
|
31
|
+
sessions.push({ sessionName, panePids });
|
|
32
|
+
}
|
|
33
|
+
const processes = await this.listProcesses();
|
|
34
|
+
return { sessions, processes };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
private listSessionNames = async (): Promise<string[]> => {
|
|
38
|
+
const { stdout, exitCode } = await this.localCommandRunner.runCommand(
|
|
39
|
+
'tmux',
|
|
40
|
+
['list-sessions', '-F', '#{session_name}'],
|
|
41
|
+
);
|
|
42
|
+
if (exitCode !== 0) {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
return stdout
|
|
46
|
+
.split('\n')
|
|
47
|
+
.map((line) => line.trim())
|
|
48
|
+
.filter((line) => line.length > 0);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
private listPanePids = async (sessionName: string): Promise<number[]> => {
|
|
52
|
+
const { stdout, exitCode } = await this.localCommandRunner.runCommand(
|
|
53
|
+
'tmux',
|
|
54
|
+
['list-panes', '-t', sessionName, '-F', '#{pane_pid}'],
|
|
55
|
+
);
|
|
56
|
+
if (exitCode !== 0) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return stdout
|
|
60
|
+
.split('\n')
|
|
61
|
+
.map((line) => Number(line.trim()))
|
|
62
|
+
.filter((pid) => Number.isInteger(pid) && pid > 0);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
private listProcesses = async (): Promise<LiveSessionProcessInfo[]> => {
|
|
66
|
+
const { stdout, exitCode } = await this.localCommandRunner.runCommand(
|
|
67
|
+
'ps',
|
|
68
|
+
['-eo', 'pid=,ppid=,args='],
|
|
69
|
+
);
|
|
70
|
+
if (exitCode !== 0) {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const processes: LiveSessionProcessInfo[] = [];
|
|
74
|
+
for (const line of stdout.split('\n')) {
|
|
75
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
76
|
+
if (match === null) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const pid = Number(match[1]);
|
|
80
|
+
const ppid = Number(match[2]);
|
|
81
|
+
const commandLine = match[3].trim();
|
|
82
|
+
const environ = this.environReader.readEnviron(pid);
|
|
83
|
+
processes.push({
|
|
84
|
+
pid,
|
|
85
|
+
ppid,
|
|
86
|
+
commandLine,
|
|
87
|
+
sessionId: environ?.[SESSION_ID_ENV_KEY] ?? null,
|
|
88
|
+
configDir: environ?.[CONFIG_DIR_ENV_KEY] ?? null,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return processes;
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -3,10 +3,12 @@ import { NoUnansweredOwnerCallStatusProvider } from './NoUnansweredOwnerCallStat
|
|
|
3
3
|
describe('NoUnansweredOwnerCallStatusProvider', () => {
|
|
4
4
|
it('always reports no session as having an unanswered owner call', async () => {
|
|
5
5
|
const provider = new NoUnansweredOwnerCallStatusProvider();
|
|
6
|
-
const result = await provider.listSessionNamesWithUnansweredOwnerCall(
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
const result = await provider.listSessionNamesWithUnansweredOwnerCall(
|
|
7
|
+
new Map([
|
|
8
|
+
['session-a', '/transcripts/a.jsonl'],
|
|
9
|
+
['session-b', '/transcripts/b.jsonl'],
|
|
10
|
+
]),
|
|
11
|
+
);
|
|
10
12
|
expect(result.size).toBe(0);
|
|
11
13
|
});
|
|
12
14
|
});
|
|
@@ -2,7 +2,7 @@ import { OwnerCallStatusProvider } from '../../domain/usecases/adapter-interface
|
|
|
2
2
|
|
|
3
3
|
export class NoUnansweredOwnerCallStatusProvider implements OwnerCallStatusProvider {
|
|
4
4
|
listSessionNamesWithUnansweredOwnerCall = (
|
|
5
|
-
|
|
5
|
+
_transcriptPathBySessionName: Map<string, string>,
|
|
6
6
|
): Promise<Set<string>> => {
|
|
7
7
|
return Promise.resolve(new Set<string>());
|
|
8
8
|
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { ProcFsProcessEnvironReader } from './ProcFsProcessEnvironReader';
|
|
5
|
+
|
|
6
|
+
describe('ProcFsProcessEnvironReader', () => {
|
|
7
|
+
let procDirectory: string;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'proc-environ-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
fs.rmSync(procDirectory, { force: true, recursive: true });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const writeEnviron = (pid: number, entries: string[]): void => {
|
|
18
|
+
const pidDirectory = path.join(procDirectory, String(pid));
|
|
19
|
+
fs.mkdirSync(pidDirectory, { recursive: true });
|
|
20
|
+
fs.writeFileSync(
|
|
21
|
+
path.join(pidDirectory, 'environ'),
|
|
22
|
+
entries.join('\0') + '\0',
|
|
23
|
+
'utf8',
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
it('parses NUL-separated KEY=value pairs from procfs', () => {
|
|
28
|
+
writeEnviron(123, [
|
|
29
|
+
'PATH=/usr/bin',
|
|
30
|
+
'CLAUDE_CODE_SESSION_ID=abc-123',
|
|
31
|
+
'CLAUDE_CONFIG_DIR=/config/workbench',
|
|
32
|
+
]);
|
|
33
|
+
const reader = new ProcFsProcessEnvironReader(procDirectory);
|
|
34
|
+
|
|
35
|
+
const result = reader.readEnviron(123);
|
|
36
|
+
|
|
37
|
+
expect(result).toEqual({
|
|
38
|
+
PATH: '/usr/bin',
|
|
39
|
+
CLAUDE_CODE_SESSION_ID: 'abc-123',
|
|
40
|
+
CLAUDE_CONFIG_DIR: '/config/workbench',
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('keeps values that themselves contain an equals sign', () => {
|
|
45
|
+
writeEnviron(124, ['QUERY=a=b=c']);
|
|
46
|
+
const reader = new ProcFsProcessEnvironReader(procDirectory);
|
|
47
|
+
|
|
48
|
+
const result = reader.readEnviron(124);
|
|
49
|
+
|
|
50
|
+
expect(result).toEqual({ QUERY: 'a=b=c' });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns null when the process environ cannot be read', () => {
|
|
54
|
+
const reader = new ProcFsProcessEnvironReader(procDirectory);
|
|
55
|
+
|
|
56
|
+
const result = reader.readEnviron(999);
|
|
57
|
+
|
|
58
|
+
expect(result).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
});
|