github-issue-tower-defence-management 1.110.0 → 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.
Files changed (97) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -7
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +9 -21
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +10 -15
  6. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  7. package/bin/adapter/proxy/RateLimitCache.js +8 -0
  8. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  9. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +104 -0
  10. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -0
  11. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +55 -19
  12. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
  13. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +75 -0
  14. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -0
  15. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +1 -1
  16. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -1
  17. package/bin/adapter/repositories/ProcFsProcessEnvironReader.js +73 -0
  18. package/bin/adapter/repositories/ProcFsProcessEnvironReader.js.map +1 -0
  19. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +8 -16
  20. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
  21. package/bin/domain/entities/InteractiveLiveSession.js +3 -0
  22. package/bin/domain/entities/InteractiveLiveSession.js.map +1 -0
  23. package/bin/domain/entities/LiveSessionProcessSnapshot.js +3 -0
  24. package/bin/domain/entities/LiveSessionProcessSnapshot.js.map +1 -0
  25. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +20 -33
  26. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  27. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +89 -0
  28. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -0
  29. package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js +3 -0
  30. package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js.map +1 -0
  31. package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js +3 -0
  32. package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js.map +1 -0
  33. package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js +3 -0
  34. package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -15
  37. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +96 -204
  38. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +19 -40
  39. package/src/adapter/proxy/RateLimitCache.test.ts +63 -0
  40. package/src/adapter/proxy/RateLimitCache.ts +11 -0
  41. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +147 -0
  42. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +75 -0
  43. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +82 -44
  44. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +62 -24
  45. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +138 -0
  46. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +93 -0
  47. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +6 -4
  48. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +1 -1
  49. package/src/adapter/repositories/ProcFsProcessEnvironReader.test.ts +60 -0
  50. package/src/adapter/repositories/ProcFsProcessEnvironReader.ts +38 -0
  51. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +61 -81
  52. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +8 -25
  53. package/src/domain/entities/InteractiveLiveSession.ts +5 -0
  54. package/src/domain/entities/LiveSessionProcessSnapshot.ts +17 -0
  55. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +125 -349
  56. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +33 -55
  57. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +220 -0
  58. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +105 -0
  59. package/src/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.ts +7 -0
  60. package/src/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.ts +5 -0
  61. package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +1 -1
  62. package/src/domain/usecases/adapter-interfaces/ProcessEnvironReader.ts +3 -0
  63. package/src/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.ts +1 -1
  64. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  65. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -7
  66. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  67. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  68. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +18 -0
  69. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
  70. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +9 -5
  71. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
  72. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +21 -0
  73. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -0
  74. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +1 -1
  75. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -1
  76. package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts +12 -0
  77. package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts.map +1 -0
  78. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -4
  79. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
  80. package/types/domain/entities/InteractiveLiveSession.d.ts +6 -0
  81. package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -0
  82. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +16 -0
  83. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -0
  84. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +6 -11
  85. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  86. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +23 -0
  87. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -0
  88. package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts +5 -0
  89. package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
  90. package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts +5 -0
  91. package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts.map +1 -0
  92. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +1 -1
  93. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -1
  94. package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts +4 -0
  95. package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts.map +1 -0
  96. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts +1 -1
  97. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts.map +1 -1
@@ -80,6 +80,16 @@ const readModelWeeklyLimits = (
80
80
  return result;
81
81
  };
82
82
 
83
+ const readSubscriptionDisabledEpoch = (
84
+ payload: Record<string, unknown>,
85
+ ): { subscriptionDisabledEpoch: number } | Record<string, never> => {
86
+ const stored = payload.subscriptionDisabledEpoch;
87
+ if (typeof stored === 'number') {
88
+ return { subscriptionDisabledEpoch: stored };
89
+ }
90
+ return {};
91
+ };
92
+
83
93
  const cooldownEndFromRetryAfter = (
84
94
  retryAfterSeconds: number | null,
85
95
  nowEpochSeconds: number,
@@ -141,6 +151,7 @@ export const writeRateLimit = (
141
151
  }
142
152
  const existing = readPayload(filePath);
143
153
  const payload = {
154
+ ...readSubscriptionDisabledEpoch(existing),
144
155
  ts: Date.now() / 1000,
145
156
  headers: rateLimitHeaders,
146
157
  modelWeeklyLimits: readModelWeeklyLimits(existing),
@@ -0,0 +1,147 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { FileSystemInteractiveLiveSessionTranscriptResolver } from './FileSystemInteractiveLiveSessionTranscriptResolver';
5
+
6
+ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
7
+ let configRoot: string;
8
+
9
+ beforeEach(() => {
10
+ configRoot = fs.mkdtempSync(
11
+ path.join(os.tmpdir(), 'interactive-transcript-'),
12
+ );
13
+ });
14
+
15
+ afterEach(() => {
16
+ fs.rmSync(configRoot, { force: true, recursive: true });
17
+ });
18
+
19
+ const writeTranscript = (params: {
20
+ configDir: string;
21
+ cwdSlug: string;
22
+ sessionId: string;
23
+ mtimeEpochSeconds?: number;
24
+ }): string => {
25
+ const projectDirectory = path.join(
26
+ params.configDir,
27
+ 'projects',
28
+ params.cwdSlug,
29
+ );
30
+ fs.mkdirSync(projectDirectory, { recursive: true });
31
+ const filePath = path.join(projectDirectory, `${params.sessionId}.jsonl`);
32
+ fs.writeFileSync(filePath, '{"type":"custom-title"}', 'utf8');
33
+ if (params.mtimeEpochSeconds !== undefined) {
34
+ fs.utimesSync(
35
+ filePath,
36
+ params.mtimeEpochSeconds,
37
+ params.mtimeEpochSeconds,
38
+ );
39
+ }
40
+ return filePath;
41
+ };
42
+
43
+ it('resolves a transcript by config dir and session id for a plain-named session', () => {
44
+ const configDir = path.join(configRoot, 'workbench');
45
+ const filePath = writeTranscript({
46
+ configDir,
47
+ cwdSlug: '-home-user',
48
+ sessionId: 'wb-uuid',
49
+ });
50
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
51
+
52
+ const result = resolver.resolveTranscriptPaths([
53
+ { sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
54
+ ]);
55
+
56
+ expect(result.get('workbench')).toBe(filePath);
57
+ });
58
+
59
+ it('chooses the most recently modified match across project directories', () => {
60
+ const configDir = path.join(configRoot, 'workbench');
61
+ writeTranscript({
62
+ configDir,
63
+ cwdSlug: '-home-user-old',
64
+ sessionId: 'wb-uuid',
65
+ mtimeEpochSeconds: 1700000000,
66
+ });
67
+ const newerPath = writeTranscript({
68
+ configDir,
69
+ cwdSlug: '-home-user-new',
70
+ sessionId: 'wb-uuid',
71
+ mtimeEpochSeconds: 1700000500,
72
+ });
73
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
74
+
75
+ const result = resolver.resolveTranscriptPaths([
76
+ { sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
77
+ ]);
78
+
79
+ expect(result.get('workbench')).toBe(newerPath);
80
+ });
81
+
82
+ it('resolves several sessions in one call', () => {
83
+ const workbenchConfig = path.join(configRoot, 'workbench');
84
+ const controlRoomConfig = path.join(configRoot, 'control-room');
85
+ const workbenchPath = writeTranscript({
86
+ configDir: workbenchConfig,
87
+ cwdSlug: '-home-user',
88
+ sessionId: 'wb-uuid',
89
+ });
90
+ const controlRoomPath = writeTranscript({
91
+ configDir: controlRoomConfig,
92
+ cwdSlug: '-home-user',
93
+ sessionId: 'cr-uuid',
94
+ });
95
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
96
+
97
+ const result = resolver.resolveTranscriptPaths([
98
+ {
99
+ sessionName: 'workbench',
100
+ sessionId: 'wb-uuid',
101
+ configDir: workbenchConfig,
102
+ },
103
+ {
104
+ sessionName: 'control-room',
105
+ sessionId: 'cr-uuid',
106
+ configDir: controlRoomConfig,
107
+ },
108
+ ]);
109
+
110
+ expect(result.get('workbench')).toBe(workbenchPath);
111
+ expect(result.get('control-room')).toBe(controlRoomPath);
112
+ });
113
+
114
+ it('omits a session whose transcript file is absent', () => {
115
+ const configDir = path.join(configRoot, 'workbench');
116
+ fs.mkdirSync(path.join(configDir, 'projects', '-home-user'), {
117
+ recursive: true,
118
+ });
119
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
120
+
121
+ const result = resolver.resolveTranscriptPaths([
122
+ { sessionName: 'workbench', sessionId: 'missing-uuid', configDir },
123
+ ]);
124
+
125
+ expect(result.has('workbench')).toBe(false);
126
+ });
127
+
128
+ it('omits a session whose config dir has no projects directory', () => {
129
+ const configDir = path.join(configRoot, 'workbench');
130
+ fs.mkdirSync(configDir, { recursive: true });
131
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
132
+
133
+ const result = resolver.resolveTranscriptPaths([
134
+ { sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
135
+ ]);
136
+
137
+ expect(result.has('workbench')).toBe(false);
138
+ });
139
+
140
+ it('returns an empty map for an empty session list', () => {
141
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
142
+
143
+ const result = resolver.resolveTranscriptPaths([]);
144
+
145
+ expect(result.size).toBe(0);
146
+ });
147
+ });
@@ -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 writeOutputFileWithMtime = (
20
- sessionName: string,
21
- epochSeconds: number,
22
- ): void => {
23
- const fileName = sessionName.replace(/\//g, '_');
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(filePath, 'output', 'utf8');
26
- fs.utimesSync(filePath, epochSeconds, epochSeconds);
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 output epoch derived from each session output file mtime', async () => {
30
- const sessionName = 'https_//github_com/owner/repo/issues/9';
31
- writeOutputFileWithMtime(sessionName, 1700000000);
32
- const repository = new FileSystemSessionOutputActivityRepository(
33
- rootDirectory,
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([sessionName]);
52
+ const result = await repository.listSessionOutputActivities(
53
+ new Map([['workbench', transcriptPath]]),
54
+ );
37
55
 
38
56
  expect(result).toEqual([
39
- { sessionName, lastOutputEpochSeconds: 1700000000 },
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('omits sessions that have no output file', async () => {
44
- const presentSessionName = 'https_//github_com/owner/repo/issues/9';
45
- const missingSessionName = 'https_//github_com/owner/repo/issues/10';
46
- writeOutputFileWithMtime(presentSessionName, 1700000000);
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
- { sessionName: presentSessionName, lastOutputEpochSeconds: 1700000000 },
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('returns an empty list when the root directory does not exist', async () => {
62
- const repository = new FileSystemSessionOutputActivityRepository(
63
- path.join(rootDirectory, 'does-not-exist'),
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
- const result = await repository.listSessionOutputActivities([
67
- 'https_//github_com/owner/repo/issues/9',
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('returns an empty list when the root directory is null', async () => {
74
- const repository = new FileSystemSessionOutputActivityRepository(null);
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 session names are requested', async () => {
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
- export class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
7
- constructor(private readonly rootDirectory: string | null) {}
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
- sessionNames: string[],
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 sessionNames) {
36
+ for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
17
37
  const lastOutputEpochSeconds =
18
- this.readLastOutputEpochSeconds(sessionName);
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 readLastOutputEpochSeconds = (sessionName: string): number | null => {
27
- if (this.rootDirectory === null) {
28
- return null;
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
- stats = fs.statSync(filePath);
51
+ content = fs.readFileSync(transcriptPath, 'utf8');
37
52
  } catch {
38
53
  return null;
39
54
  }
40
- if (!stats.isFile()) {
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(stats.mtimeMs / 1000);
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
+ });