github-issue-tower-defence-management 1.114.0 → 1.114.2

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 (45) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -2
  3. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +36 -27
  4. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -1
  5. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +14 -14
  6. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
  7. package/bin/adapter/repositories/FileSystemSessionRecordReader.js +69 -0
  8. package/bin/adapter/repositories/FileSystemSessionRecordReader.js.map +1 -0
  9. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +9 -2
  10. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -1
  11. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +43 -0
  12. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -1
  13. package/bin/domain/usecases/adapter-interfaces/SessionRecordReader.js +3 -0
  14. package/bin/domain/usecases/adapter-interfaces/SessionRecordReader.js.map +1 -0
  15. package/package.json +1 -1
  16. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +153 -26
  17. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +49 -29
  18. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +57 -5
  19. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +14 -14
  20. package/src/adapter/repositories/FileSystemSessionRecordReader.test.ts +59 -0
  21. package/src/adapter/repositories/FileSystemSessionRecordReader.ts +31 -0
  22. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +51 -0
  23. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +10 -1
  24. package/src/domain/entities/InteractiveLiveSession.ts +1 -0
  25. package/src/domain/entities/LiveSessionProcessSnapshot.ts +1 -0
  26. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +4 -0
  27. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +142 -59
  28. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +50 -0
  29. package/src/domain/usecases/adapter-interfaces/SessionRecordReader.ts +3 -0
  30. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +4 -11
  31. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -1
  32. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +8 -5
  33. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
  34. package/types/adapter/repositories/FileSystemSessionRecordReader.d.ts +5 -0
  35. package/types/adapter/repositories/FileSystemSessionRecordReader.d.ts.map +1 -0
  36. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +3 -1
  37. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -1
  38. package/types/domain/entities/InteractiveLiveSession.d.ts +1 -0
  39. package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -1
  40. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +1 -0
  41. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -1
  42. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +13 -0
  43. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -1
  44. package/types/domain/usecases/adapter-interfaces/SessionRecordReader.d.ts +4 -0
  45. package/types/domain/usecases/adapter-interfaces/SessionRecordReader.d.ts.map +1 -0
@@ -5,11 +5,13 @@ import { FileSystemInteractiveLiveSessionTranscriptResolver } from './FileSystem
5
5
 
6
6
  describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
7
7
  let configRoot: string;
8
+ let sharedProjectsDirectory: string;
8
9
 
9
10
  beforeEach(() => {
10
11
  configRoot = fs.mkdtempSync(
11
12
  path.join(os.tmpdir(), 'interactive-transcript-'),
12
13
  );
14
+ sharedProjectsDirectory = path.join(configRoot, 'shared', 'projects');
13
15
  });
14
16
 
15
17
  afterEach(() => {
@@ -17,14 +19,13 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
17
19
  });
18
20
 
19
21
  const writeTranscript = (params: {
20
- configDir: string;
22
+ projectsDirectory: string;
21
23
  cwdSlug: string;
22
24
  sessionId: string;
23
25
  mtimeEpochSeconds?: number;
24
26
  }): string => {
25
27
  const projectDirectory = path.join(
26
- params.configDir,
27
- 'projects',
28
+ params.projectsDirectory,
28
29
  params.cwdSlug,
29
30
  );
30
31
  fs.mkdirSync(projectDirectory, { recursive: true });
@@ -40,40 +41,146 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
40
41
  return filePath;
41
42
  };
42
43
 
43
- it('resolves a transcript by config dir and session id for a plain-named session', () => {
44
+ it('resolves a resume-case transcript by config dir and session id', () => {
44
45
  const configDir = path.join(configRoot, 'workbench');
45
46
  const filePath = writeTranscript({
46
- configDir,
47
+ projectsDirectory: path.join(configDir, 'projects'),
47
48
  cwdSlug: '-home-user',
48
49
  sessionId: 'wb-uuid',
49
50
  });
50
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
51
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
52
+ sharedProjectsDirectory,
53
+ );
51
54
 
52
55
  const result = resolver.resolveTranscriptPaths([
53
- { sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
56
+ {
57
+ sessionName: 'workbench',
58
+ sessionId: 'wb-uuid',
59
+ candidateSessionIds: ['wb-uuid'],
60
+ configDir,
61
+ },
54
62
  ]);
55
63
 
56
64
  expect(result.get('workbench')).toBe(filePath);
57
65
  });
58
66
 
59
- it('chooses the most recently modified match across project directories', () => {
67
+ it('resolves a rotated-id transcript via a later candidate session id', () => {
68
+ const configDir = path.join(configRoot, 'workbench');
69
+ const rotatedPath = writeTranscript({
70
+ projectsDirectory: path.join(configDir, 'projects'),
71
+ cwdSlug: '-home-user',
72
+ sessionId: 'rotated-uuid',
73
+ });
74
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
75
+ sharedProjectsDirectory,
76
+ );
77
+
78
+ const result = resolver.resolveTranscriptPaths([
79
+ {
80
+ sessionName: 'workbench',
81
+ sessionId: 'launch-uuid',
82
+ candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
83
+ configDir,
84
+ },
85
+ ]);
86
+
87
+ expect(result.get('workbench')).toBe(rotatedPath);
88
+ });
89
+
90
+ it('resolves a non-resume session to the descendant id file when the own launch id file is absent', () => {
91
+ const configDir = path.join(configRoot, 'non-resume');
92
+ const harnessPath = writeTranscript({
93
+ projectsDirectory: path.join(configDir, 'projects'),
94
+ cwdSlug: '-home-user',
95
+ sessionId: 'harness-id',
96
+ });
97
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
98
+ sharedProjectsDirectory,
99
+ );
100
+
101
+ const result = resolver.resolveTranscriptPaths([
102
+ {
103
+ sessionName: 'non-resume',
104
+ sessionId: 'launch-id',
105
+ candidateSessionIds: ['launch-id', 'harness-id'],
106
+ configDir,
107
+ },
108
+ ]);
109
+
110
+ expect(result.get('non-resume')).toBe(harnessPath);
111
+ });
112
+
113
+ it('resolves a resume session to the own id file when descendants share the own id', () => {
114
+ const configDir = path.join(configRoot, 'resume');
115
+ const ownPath = writeTranscript({
116
+ projectsDirectory: path.join(configDir, 'projects'),
117
+ cwdSlug: '-home-user',
118
+ sessionId: 'own-id',
119
+ });
120
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
121
+ sharedProjectsDirectory,
122
+ );
123
+
124
+ const result = resolver.resolveTranscriptPaths([
125
+ {
126
+ sessionName: 'resume',
127
+ sessionId: 'own-id',
128
+ candidateSessionIds: ['own-id', 'own-id'],
129
+ configDir,
130
+ },
131
+ ]);
132
+
133
+ expect(result.get('resume')).toBe(ownPath);
134
+ });
135
+
136
+ it('resolves a transcript that lives under the shared projects directory', () => {
137
+ const configDir = path.join(configRoot, 'workbench');
138
+ const sharedPath = writeTranscript({
139
+ projectsDirectory: sharedProjectsDirectory,
140
+ cwdSlug: '-home-user',
141
+ sessionId: 'rotated-uuid',
142
+ });
143
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
144
+ sharedProjectsDirectory,
145
+ );
146
+
147
+ const result = resolver.resolveTranscriptPaths([
148
+ {
149
+ sessionName: 'workbench',
150
+ sessionId: 'launch-uuid',
151
+ candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
152
+ configDir,
153
+ },
154
+ ]);
155
+
156
+ expect(result.get('workbench')).toBe(sharedPath);
157
+ });
158
+
159
+ it('prefers the most recently modified match across both projects roots', () => {
60
160
  const configDir = path.join(configRoot, 'workbench');
61
161
  writeTranscript({
62
- configDir,
63
- cwdSlug: '-home-user-old',
64
- sessionId: 'wb-uuid',
162
+ projectsDirectory: path.join(configDir, 'projects'),
163
+ cwdSlug: '-home-user',
164
+ sessionId: 'rotated-uuid',
65
165
  mtimeEpochSeconds: 1700000000,
66
166
  });
67
167
  const newerPath = writeTranscript({
68
- configDir,
69
- cwdSlug: '-home-user-new',
70
- sessionId: 'wb-uuid',
168
+ projectsDirectory: sharedProjectsDirectory,
169
+ cwdSlug: '-home-user',
170
+ sessionId: 'rotated-uuid',
71
171
  mtimeEpochSeconds: 1700000500,
72
172
  });
73
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
173
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
174
+ sharedProjectsDirectory,
175
+ );
74
176
 
75
177
  const result = resolver.resolveTranscriptPaths([
76
- { sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
178
+ {
179
+ sessionName: 'workbench',
180
+ sessionId: 'launch-uuid',
181
+ candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
182
+ configDir,
183
+ },
77
184
  ]);
78
185
 
79
186
  expect(result.get('workbench')).toBe(newerPath);
@@ -83,26 +190,30 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
83
190
  const workbenchConfig = path.join(configRoot, 'workbench');
84
191
  const controlRoomConfig = path.join(configRoot, 'control-room');
85
192
  const workbenchPath = writeTranscript({
86
- configDir: workbenchConfig,
193
+ projectsDirectory: path.join(workbenchConfig, 'projects'),
87
194
  cwdSlug: '-home-user',
88
195
  sessionId: 'wb-uuid',
89
196
  });
90
197
  const controlRoomPath = writeTranscript({
91
- configDir: controlRoomConfig,
198
+ projectsDirectory: path.join(controlRoomConfig, 'projects'),
92
199
  cwdSlug: '-home-user',
93
200
  sessionId: 'cr-uuid',
94
201
  });
95
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
202
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
203
+ sharedProjectsDirectory,
204
+ );
96
205
 
97
206
  const result = resolver.resolveTranscriptPaths([
98
207
  {
99
208
  sessionName: 'workbench',
100
209
  sessionId: 'wb-uuid',
210
+ candidateSessionIds: ['wb-uuid'],
101
211
  configDir: workbenchConfig,
102
212
  },
103
213
  {
104
214
  sessionName: 'control-room',
105
215
  sessionId: 'cr-uuid',
216
+ candidateSessionIds: ['cr-uuid'],
106
217
  configDir: controlRoomConfig,
107
218
  },
108
219
  ]);
@@ -111,34 +222,50 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
111
222
  expect(result.get('control-room')).toBe(controlRoomPath);
112
223
  });
113
224
 
114
- it('omits a session whose transcript file is absent', () => {
225
+ it('omits a session whose transcript file is absent in both roots', () => {
115
226
  const configDir = path.join(configRoot, 'workbench');
116
227
  fs.mkdirSync(path.join(configDir, 'projects', '-home-user'), {
117
228
  recursive: true,
118
229
  });
119
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
230
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
231
+ sharedProjectsDirectory,
232
+ );
120
233
 
121
234
  const result = resolver.resolveTranscriptPaths([
122
- { sessionName: 'workbench', sessionId: 'missing-uuid', configDir },
235
+ {
236
+ sessionName: 'workbench',
237
+ sessionId: 'missing-uuid',
238
+ candidateSessionIds: ['missing-uuid'],
239
+ configDir,
240
+ },
123
241
  ]);
124
242
 
125
243
  expect(result.has('workbench')).toBe(false);
126
244
  });
127
245
 
128
- it('omits a session whose config dir has no projects directory', () => {
246
+ it('omits a session when neither projects directory exists', () => {
129
247
  const configDir = path.join(configRoot, 'workbench');
130
248
  fs.mkdirSync(configDir, { recursive: true });
131
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
249
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
250
+ sharedProjectsDirectory,
251
+ );
132
252
 
133
253
  const result = resolver.resolveTranscriptPaths([
134
- { sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
254
+ {
255
+ sessionName: 'workbench',
256
+ sessionId: 'wb-uuid',
257
+ candidateSessionIds: ['wb-uuid'],
258
+ configDir,
259
+ },
135
260
  ]);
136
261
 
137
262
  expect(result.has('workbench')).toBe(false);
138
263
  });
139
264
 
140
265
  it('returns an empty map for an empty session list', () => {
141
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
266
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
267
+ sharedProjectsDirectory,
268
+ );
142
269
 
143
270
  const result = resolver.resolveTranscriptPaths([]);
144
271
 
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'fs';
2
+ import * as os from 'os';
2
3
  import * as path from 'path';
3
4
  import { InteractiveLiveSession } from '../../domain/entities/InteractiveLiveSession';
4
5
  import { InteractiveLiveSessionTranscriptResolver } from '../../domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver';
@@ -12,18 +13,14 @@ const modifiedEpochMs = (filePath: string): number | null => {
12
13
  }
13
14
  };
14
15
 
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
- */
16
+ const defaultSharedProjectsDirectory = (): string =>
17
+ path.join(os.homedir(), '.claude', 'projects');
18
+
26
19
  export class FileSystemInteractiveLiveSessionTranscriptResolver implements InteractiveLiveSessionTranscriptResolver {
20
+ constructor(
21
+ private readonly sharedProjectsDirectory: string = defaultSharedProjectsDirectory(),
22
+ ) {}
23
+
27
24
  resolveTranscriptPaths = (
28
25
  sessions: InteractiveLiveSession[],
29
26
  ): Map<string, string> => {
@@ -40,36 +37,59 @@ export class FileSystemInteractiveLiveSessionTranscriptResolver implements Inter
40
37
  private resolveTranscriptPath = (
41
38
  session: InteractiveLiveSession,
42
39
  ): string | null => {
43
- const projectsDirectory = path.join(session.configDir, 'projects');
40
+ const projectsDirectories = this.listProjectsDirectories(session.configDir);
41
+ let latestPath: string | null = null;
42
+ let latestEpochMs = -Infinity;
43
+ for (const candidateSessionId of session.candidateSessionIds) {
44
+ const fileName = `${candidateSessionId}.jsonl`;
45
+ for (const projectsDirectory of projectsDirectories) {
46
+ for (const candidate of this.listCandidatePaths(
47
+ projectsDirectory,
48
+ fileName,
49
+ )) {
50
+ const epochMs = modifiedEpochMs(candidate);
51
+ if (epochMs === null) {
52
+ continue;
53
+ }
54
+ if (epochMs > latestEpochMs) {
55
+ latestEpochMs = epochMs;
56
+ latestPath = candidate;
57
+ }
58
+ }
59
+ }
60
+ }
61
+ return latestPath;
62
+ };
63
+
64
+ private listProjectsDirectories = (configDir: string): string[] => {
65
+ const perSessionProjectsDirectory = path.join(configDir, 'projects');
66
+ if (perSessionProjectsDirectory === this.sharedProjectsDirectory) {
67
+ return [perSessionProjectsDirectory];
68
+ }
69
+ return [perSessionProjectsDirectory, this.sharedProjectsDirectory];
70
+ };
71
+
72
+ private listCandidatePaths = (
73
+ projectsDirectory: string,
74
+ fileName: string,
75
+ ): string[] => {
44
76
  let projectEntries: fs.Dirent[];
45
77
  try {
46
78
  projectEntries = fs.readdirSync(projectsDirectory, {
47
79
  withFileTypes: true,
48
80
  });
49
81
  } catch {
50
- return null;
82
+ return [];
51
83
  }
52
- const fileName = `${session.sessionId}.jsonl`;
53
- let latestPath: string | null = null;
54
- let latestEpochMs = -Infinity;
84
+ const candidatePaths: string[] = [];
55
85
  for (const projectEntry of projectEntries) {
56
86
  if (!projectEntry.isDirectory()) {
57
87
  continue;
58
88
  }
59
- const candidate = path.join(
60
- projectsDirectory,
61
- projectEntry.name,
62
- fileName,
89
+ candidatePaths.push(
90
+ path.join(projectsDirectory, projectEntry.name, fileName),
63
91
  );
64
- const epochMs = modifiedEpochMs(candidate);
65
- if (epochMs === null) {
66
- continue;
67
- }
68
- if (epochMs > latestEpochMs) {
69
- latestEpochMs = epochMs;
70
- latestPath = candidate;
71
- }
72
92
  }
73
- return latestPath;
93
+ return candidatePaths;
74
94
  };
75
95
  }
@@ -31,6 +31,17 @@ describe('FileSystemSessionOutputActivityRepository', () => {
31
31
  message: { role: 'user', content: 'go ahead' },
32
32
  });
33
33
 
34
+ const toolResultEntry = (timestamp: string): object => ({
35
+ type: 'tool_result',
36
+ timestamp,
37
+ toolUseResult: { stdout: 'done', stderr: '' },
38
+ });
39
+
40
+ const untimestampedEntry = (): object => ({
41
+ type: 'summary',
42
+ summary: 'no timestamp on this entry',
43
+ });
44
+
34
45
  const writeTranscript = (fileName: string, lines: object[]): string => {
35
46
  const filePath = path.join(rootDirectory, fileName);
36
47
  fs.writeFileSync(
@@ -41,7 +52,7 @@ describe('FileSystemSessionOutputActivityRepository', () => {
41
52
  return filePath;
42
53
  };
43
54
 
44
- it('returns the latest assistant entry timestamp as the last output epoch', async () => {
55
+ it('returns the latest entry timestamp of any type as the last activity epoch', async () => {
45
56
  const transcriptPath = writeTranscript('workbench.jsonl', [
46
57
  assistantEntry('2026-06-27T10:00:00.000Z'),
47
58
  userEntry('2026-06-27T10:30:00.000Z'),
@@ -57,13 +68,13 @@ describe('FileSystemSessionOutputActivityRepository', () => {
57
68
  {
58
69
  sessionName: 'workbench',
59
70
  lastOutputEpochSeconds: Math.floor(
60
- Date.parse('2026-06-27T10:05:00.000Z') / 1000,
71
+ Date.parse('2026-06-27T10:30:00.000Z') / 1000,
61
72
  ),
62
73
  },
63
74
  ]);
64
75
  });
65
76
 
66
- it('ignores user entries when computing the last output epoch', async () => {
77
+ it('advances the last activity time when a later user entry follows an assistant entry', async () => {
67
78
  const transcriptPath = writeTranscript('workbench.jsonl', [
68
79
  assistantEntry('2026-06-27T10:00:00.000Z'),
69
80
  userEntry('2026-06-27T11:00:00.000Z'),
@@ -78,7 +89,28 @@ describe('FileSystemSessionOutputActivityRepository', () => {
78
89
  {
79
90
  sessionName: 'workbench',
80
91
  lastOutputEpochSeconds: Math.floor(
81
- Date.parse('2026-06-27T10:00:00.000Z') / 1000,
92
+ Date.parse('2026-06-27T11:00:00.000Z') / 1000,
93
+ ),
94
+ },
95
+ ]);
96
+ });
97
+
98
+ it('advances the last activity time when a tool_result entry is the latest entry', async () => {
99
+ const transcriptPath = writeTranscript('workbench.jsonl', [
100
+ assistantEntry('2026-06-27T10:00:00.000Z'),
101
+ toolResultEntry('2026-06-27T10:45:00.000Z'),
102
+ ]);
103
+ const repository = new FileSystemSessionOutputActivityRepository();
104
+
105
+ const result = await repository.listSessionOutputActivities(
106
+ new Map([['workbench', transcriptPath]]),
107
+ );
108
+
109
+ expect(result).toEqual([
110
+ {
111
+ sessionName: 'workbench',
112
+ lastOutputEpochSeconds: Math.floor(
113
+ Date.parse('2026-06-27T10:45:00.000Z') / 1000,
82
114
  ),
83
115
  },
84
116
  ]);
@@ -107,7 +139,7 @@ describe('FileSystemSessionOutputActivityRepository', () => {
107
139
  ]);
108
140
  });
109
141
 
110
- it('omits sessions whose transcript has no assistant entry', async () => {
142
+ it('resolves a transcript whose only entry is a non-assistant entry', async () => {
111
143
  const transcriptPath = writeTranscript('workbench.jsonl', [
112
144
  userEntry('2026-06-27T10:00:00.000Z'),
113
145
  ]);
@@ -117,6 +149,26 @@ describe('FileSystemSessionOutputActivityRepository', () => {
117
149
  new Map([['workbench', transcriptPath]]),
118
150
  );
119
151
 
152
+ expect(result).toEqual([
153
+ {
154
+ sessionName: 'workbench',
155
+ lastOutputEpochSeconds: Math.floor(
156
+ Date.parse('2026-06-27T10:00:00.000Z') / 1000,
157
+ ),
158
+ },
159
+ ]);
160
+ });
161
+
162
+ it('omits sessions whose transcript has no parseable timestamp', async () => {
163
+ const transcriptPath = writeTranscript('workbench.jsonl', [
164
+ untimestampedEntry(),
165
+ ]);
166
+ const repository = new FileSystemSessionOutputActivityRepository();
167
+
168
+ const result = await repository.listSessionOutputActivities(
169
+ new Map([['workbench', transcriptPath]]),
170
+ );
171
+
120
172
  expect(result).toEqual([]);
121
173
  });
122
174
 
@@ -22,11 +22,14 @@ const parseEpochMilliseconds = (timestamp: string | null): number | null => {
22
22
  };
23
23
 
24
24
  /**
25
- * Reads the last main-session output time for each live session from its
25
+ * Reads the last main-session activity time for each live session from its
26
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.
27
+ * the latest entry of any kind (assistant text, owner replies, tool results, or
28
+ * any other entry type) rather than from the transcript file modification time.
29
+ * Because a session that is actively running tool calls keeps appending entries
30
+ * such as `user` and `tool_result` even while it emits no assistant text, every
31
+ * entry with a parseable timestamp counts as activity, so a working session is
32
+ * not mistaken for a silent one.
30
33
  */
31
34
  export class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
32
35
  listSessionOutputActivities = async (
@@ -35,7 +38,7 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
35
38
  const activities: LiveSessionOutputActivity[] = [];
36
39
  for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
37
40
  const lastOutputEpochSeconds =
38
- this.readLastAssistantOutputEpochSeconds(transcriptPath);
41
+ this.readLastActivityEpochSeconds(transcriptPath);
39
42
  if (lastOutputEpochSeconds !== null) {
40
43
  activities.push({ sessionName, lastOutputEpochSeconds });
41
44
  }
@@ -43,7 +46,7 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
43
46
  return activities;
44
47
  };
45
48
 
46
- private readLastAssistantOutputEpochSeconds = (
49
+ private readLastActivityEpochSeconds = (
47
50
  transcriptPath: string,
48
51
  ): number | null => {
49
52
  let content: string;
@@ -52,7 +55,7 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
52
55
  } catch {
53
56
  return null;
54
57
  }
55
- let lastAssistantEpochMs: number | null = null;
58
+ let lastActivityEpochMs: number | null = null;
56
59
  for (const line of content.split('\n')) {
57
60
  const trimmed = line.trim();
58
61
  if (trimmed.length === 0) {
@@ -67,20 +70,17 @@ export class FileSystemSessionOutputActivityRepository implements SessionOutputA
67
70
  if (!isRecord(parsed)) {
68
71
  continue;
69
72
  }
70
- if (readString(parsed, 'type') !== 'assistant') {
71
- continue;
72
- }
73
73
  const epochMs = parseEpochMilliseconds(readString(parsed, 'timestamp'));
74
74
  if (epochMs === null) {
75
75
  continue;
76
76
  }
77
- if (lastAssistantEpochMs === null || epochMs > lastAssistantEpochMs) {
78
- lastAssistantEpochMs = epochMs;
77
+ if (lastActivityEpochMs === null || epochMs > lastActivityEpochMs) {
78
+ lastActivityEpochMs = epochMs;
79
79
  }
80
80
  }
81
- if (lastAssistantEpochMs === null) {
81
+ if (lastActivityEpochMs === null) {
82
82
  return null;
83
83
  }
84
- return Math.floor(lastAssistantEpochMs / 1000);
84
+ return Math.floor(lastActivityEpochMs / 1000);
85
85
  };
86
86
  }
@@ -0,0 +1,59 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { FileSystemSessionRecordReader } from './FileSystemSessionRecordReader';
5
+
6
+ describe('FileSystemSessionRecordReader', () => {
7
+ let configDir: string;
8
+
9
+ beforeEach(() => {
10
+ configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-record-'));
11
+ });
12
+
13
+ afterEach(() => {
14
+ fs.rmSync(configDir, { force: true, recursive: true });
15
+ });
16
+
17
+ const writeRecord = (pid: number, record: object): void => {
18
+ const sessionsDirectory = path.join(configDir, 'sessions');
19
+ fs.mkdirSync(sessionsDirectory, { recursive: true });
20
+ fs.writeFileSync(
21
+ path.join(sessionsDirectory, `${pid}.json`),
22
+ JSON.stringify(record),
23
+ 'utf8',
24
+ );
25
+ };
26
+
27
+ it('returns the current session id recorded for the pid', () => {
28
+ writeRecord(201, { pid: 201, sessionId: 'rotated-uuid', status: 'busy' });
29
+ const reader = new FileSystemSessionRecordReader();
30
+
31
+ expect(reader.readCurrentSessionId(configDir, 201)).toBe('rotated-uuid');
32
+ });
33
+
34
+ it('returns null when the session record file is absent', () => {
35
+ const reader = new FileSystemSessionRecordReader();
36
+
37
+ expect(reader.readCurrentSessionId(configDir, 999)).toBeNull();
38
+ });
39
+
40
+ it('returns null when the session record is not valid json', () => {
41
+ const sessionsDirectory = path.join(configDir, 'sessions');
42
+ fs.mkdirSync(sessionsDirectory, { recursive: true });
43
+ fs.writeFileSync(
44
+ path.join(sessionsDirectory, '201.json'),
45
+ 'not json',
46
+ 'utf8',
47
+ );
48
+ const reader = new FileSystemSessionRecordReader();
49
+
50
+ expect(reader.readCurrentSessionId(configDir, 201)).toBeNull();
51
+ });
52
+
53
+ it('returns null when the record has no string session id', () => {
54
+ writeRecord(201, { pid: 201, sessionId: 42 });
55
+ const reader = new FileSystemSessionRecordReader();
56
+
57
+ expect(reader.readCurrentSessionId(configDir, 201)).toBeNull();
58
+ });
59
+ });
@@ -0,0 +1,31 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { SessionRecordReader } from '../../domain/usecases/adapter-interfaces/SessionRecordReader';
4
+
5
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
6
+ typeof value === 'object' && value !== null;
7
+
8
+ export class FileSystemSessionRecordReader implements SessionRecordReader {
9
+ readCurrentSessionId = (configDir: string, pid: number): string | null => {
10
+ const recordPath = path.join(configDir, 'sessions', `${pid}.json`);
11
+ let content: string;
12
+ try {
13
+ content = fs.readFileSync(recordPath, 'utf8');
14
+ } catch {
15
+ return null;
16
+ }
17
+ let parsed: unknown;
18
+ try {
19
+ parsed = JSON.parse(content);
20
+ } catch {
21
+ return null;
22
+ }
23
+ if (!isRecord(parsed)) {
24
+ return null;
25
+ }
26
+ const sessionId = parsed.sessionId;
27
+ return typeof sessionId === 'string' && sessionId.length > 0
28
+ ? sessionId
29
+ : null;
30
+ };
31
+ }