github-issue-tower-defence-management 1.113.0 → 1.114.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -2
  3. package/bin/adapter/entry-points/console/consoleReadApi.js +11 -1
  4. package/bin/adapter/entry-points/console/consoleReadApi.js.map +1 -1
  5. package/bin/adapter/entry-points/console/ui-dist/assets/{index-CEJmPNRK.css → index-Blb_xqIx.css} +1 -1
  6. package/bin/adapter/entry-points/console/ui-dist/assets/index-DQyZ8KpG.js +101 -0
  7. package/bin/adapter/entry-points/console/ui-dist/index.html +2 -2
  8. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +36 -27
  9. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -1
  10. package/bin/adapter/repositories/FileSystemSessionRecordReader.js +69 -0
  11. package/bin/adapter/repositories/FileSystemSessionRecordReader.js.map +1 -0
  12. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +9 -2
  13. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -1
  14. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +15 -0
  15. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -1
  16. package/bin/domain/usecases/adapter-interfaces/SessionRecordReader.js +3 -0
  17. package/bin/domain/usecases/adapter-interfaces/SessionRecordReader.js.map +1 -0
  18. package/package.json +1 -1
  19. package/src/adapter/entry-points/console/consoleReadApi.test.ts +82 -0
  20. package/src/adapter/entry-points/console/consoleReadApi.ts +21 -1
  21. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.stories.tsx +16 -0
  22. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.test.tsx +61 -0
  23. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleMarkdownContent.tsx +59 -5
  24. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleReferenceLink.stories.tsx +76 -0
  25. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleReferenceLink.test.tsx +56 -0
  26. package/src/adapter/entry-points/console/ui/src/features/console/components/content/ConsoleReferenceLink.tsx +39 -0
  27. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleCommentList.tsx +4 -0
  28. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.stories.tsx +24 -4
  29. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.test.tsx +13 -3
  30. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsoleItemDetail.tsx +6 -0
  31. package/src/adapter/entry-points/console/ui/src/features/console/components/detail/ConsolePullRequestDetail.tsx +4 -0
  32. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.test.ts +1 -0
  33. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleItemDetailData.ts +1 -0
  34. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleReferenceLink.test.ts +48 -0
  35. package/src/adapter/entry-points/console/ui/src/features/console/hooks/useConsoleReferenceLink.ts +39 -0
  36. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.test.ts +25 -2
  37. package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +2 -1
  38. package/src/adapter/entry-points/console/ui/src/features/console/logic/references.test.ts +80 -0
  39. package/src/adapter/entry-points/console/ui/src/features/console/logic/references.ts +44 -0
  40. package/src/adapter/entry-points/console/ui/src/features/console/logic/types.ts +1 -0
  41. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.test.tsx +1 -0
  42. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleItemDetailContainer.tsx +12 -0
  43. package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsoleReferenceLinkContainer.tsx +25 -0
  44. package/src/adapter/entry-points/console/ui/src/features/console/testing/fixtures.ts +40 -0
  45. package/src/adapter/entry-points/console/ui/src/index.css +23 -0
  46. package/src/adapter/entry-points/console/ui-dist/assets/{index-CEJmPNRK.css → index-Blb_xqIx.css} +1 -1
  47. package/src/adapter/entry-points/console/ui-dist/assets/index-DQyZ8KpG.js +101 -0
  48. package/src/adapter/entry-points/console/ui-dist/index.html +2 -2
  49. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +107 -26
  50. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +49 -29
  51. package/src/adapter/repositories/FileSystemSessionRecordReader.test.ts +59 -0
  52. package/src/adapter/repositories/FileSystemSessionRecordReader.ts +31 -0
  53. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +51 -0
  54. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +10 -1
  55. package/src/domain/entities/InteractiveLiveSession.ts +1 -0
  56. package/src/domain/entities/LiveSessionProcessSnapshot.ts +1 -0
  57. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +4 -0
  58. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +66 -59
  59. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +19 -0
  60. package/src/domain/usecases/adapter-interfaces/SessionRecordReader.ts +3 -0
  61. package/types/adapter/entry-points/console/consoleReadApi.d.ts +1 -0
  62. package/types/adapter/entry-points/console/consoleReadApi.d.ts.map +1 -1
  63. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +4 -11
  64. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -1
  65. package/types/adapter/repositories/FileSystemSessionRecordReader.d.ts +5 -0
  66. package/types/adapter/repositories/FileSystemSessionRecordReader.d.ts.map +1 -0
  67. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +3 -1
  68. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -1
  69. package/types/domain/entities/InteractiveLiveSession.d.ts +1 -0
  70. package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -1
  71. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +1 -0
  72. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -1
  73. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +1 -0
  74. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -1
  75. package/types/domain/usecases/adapter-interfaces/SessionRecordReader.d.ts +4 -0
  76. package/types/domain/usecases/adapter-interfaces/SessionRecordReader.d.ts.map +1 -0
  77. package/bin/adapter/entry-points/console/ui-dist/assets/index-DfNrA5uV.js +0 -101
  78. package/src/adapter/entry-points/console/ui-dist/assets/index-DfNrA5uV.js +0 -101
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>TDPM Console</title>
7
- <script type="module" crossorigin src="/assets/index-DfNrA5uV.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-CEJmPNRK.css">
7
+ <script type="module" crossorigin src="/assets/index-DQyZ8KpG.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-Blb_xqIx.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
@@ -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,100 @@ 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 transcript that lives under the shared projects directory', () => {
91
+ const configDir = path.join(configRoot, 'workbench');
92
+ const sharedPath = writeTranscript({
93
+ projectsDirectory: sharedProjectsDirectory,
94
+ cwdSlug: '-home-user',
95
+ sessionId: 'rotated-uuid',
96
+ });
97
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
98
+ sharedProjectsDirectory,
99
+ );
100
+
101
+ const result = resolver.resolveTranscriptPaths([
102
+ {
103
+ sessionName: 'workbench',
104
+ sessionId: 'launch-uuid',
105
+ candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
106
+ configDir,
107
+ },
108
+ ]);
109
+
110
+ expect(result.get('workbench')).toBe(sharedPath);
111
+ });
112
+
113
+ it('prefers the most recently modified match across both projects roots', () => {
60
114
  const configDir = path.join(configRoot, 'workbench');
61
115
  writeTranscript({
62
- configDir,
63
- cwdSlug: '-home-user-old',
64
- sessionId: 'wb-uuid',
116
+ projectsDirectory: path.join(configDir, 'projects'),
117
+ cwdSlug: '-home-user',
118
+ sessionId: 'rotated-uuid',
65
119
  mtimeEpochSeconds: 1700000000,
66
120
  });
67
121
  const newerPath = writeTranscript({
68
- configDir,
69
- cwdSlug: '-home-user-new',
70
- sessionId: 'wb-uuid',
122
+ projectsDirectory: sharedProjectsDirectory,
123
+ cwdSlug: '-home-user',
124
+ sessionId: 'rotated-uuid',
71
125
  mtimeEpochSeconds: 1700000500,
72
126
  });
73
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
127
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
128
+ sharedProjectsDirectory,
129
+ );
74
130
 
75
131
  const result = resolver.resolveTranscriptPaths([
76
- { sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
132
+ {
133
+ sessionName: 'workbench',
134
+ sessionId: 'launch-uuid',
135
+ candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
136
+ configDir,
137
+ },
77
138
  ]);
78
139
 
79
140
  expect(result.get('workbench')).toBe(newerPath);
@@ -83,26 +144,30 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
83
144
  const workbenchConfig = path.join(configRoot, 'workbench');
84
145
  const controlRoomConfig = path.join(configRoot, 'control-room');
85
146
  const workbenchPath = writeTranscript({
86
- configDir: workbenchConfig,
147
+ projectsDirectory: path.join(workbenchConfig, 'projects'),
87
148
  cwdSlug: '-home-user',
88
149
  sessionId: 'wb-uuid',
89
150
  });
90
151
  const controlRoomPath = writeTranscript({
91
- configDir: controlRoomConfig,
152
+ projectsDirectory: path.join(controlRoomConfig, 'projects'),
92
153
  cwdSlug: '-home-user',
93
154
  sessionId: 'cr-uuid',
94
155
  });
95
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
156
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
157
+ sharedProjectsDirectory,
158
+ );
96
159
 
97
160
  const result = resolver.resolveTranscriptPaths([
98
161
  {
99
162
  sessionName: 'workbench',
100
163
  sessionId: 'wb-uuid',
164
+ candidateSessionIds: ['wb-uuid'],
101
165
  configDir: workbenchConfig,
102
166
  },
103
167
  {
104
168
  sessionName: 'control-room',
105
169
  sessionId: 'cr-uuid',
170
+ candidateSessionIds: ['cr-uuid'],
106
171
  configDir: controlRoomConfig,
107
172
  },
108
173
  ]);
@@ -111,34 +176,50 @@ describe('FileSystemInteractiveLiveSessionTranscriptResolver', () => {
111
176
  expect(result.get('control-room')).toBe(controlRoomPath);
112
177
  });
113
178
 
114
- it('omits a session whose transcript file is absent', () => {
179
+ it('omits a session whose transcript file is absent in both roots', () => {
115
180
  const configDir = path.join(configRoot, 'workbench');
116
181
  fs.mkdirSync(path.join(configDir, 'projects', '-home-user'), {
117
182
  recursive: true,
118
183
  });
119
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
184
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
185
+ sharedProjectsDirectory,
186
+ );
120
187
 
121
188
  const result = resolver.resolveTranscriptPaths([
122
- { sessionName: 'workbench', sessionId: 'missing-uuid', configDir },
189
+ {
190
+ sessionName: 'workbench',
191
+ sessionId: 'missing-uuid',
192
+ candidateSessionIds: ['missing-uuid'],
193
+ configDir,
194
+ },
123
195
  ]);
124
196
 
125
197
  expect(result.has('workbench')).toBe(false);
126
198
  });
127
199
 
128
- it('omits a session whose config dir has no projects directory', () => {
200
+ it('omits a session when neither projects directory exists', () => {
129
201
  const configDir = path.join(configRoot, 'workbench');
130
202
  fs.mkdirSync(configDir, { recursive: true });
131
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
203
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
204
+ sharedProjectsDirectory,
205
+ );
132
206
 
133
207
  const result = resolver.resolveTranscriptPaths([
134
- { sessionName: 'workbench', sessionId: 'wb-uuid', configDir },
208
+ {
209
+ sessionName: 'workbench',
210
+ sessionId: 'wb-uuid',
211
+ candidateSessionIds: ['wb-uuid'],
212
+ configDir,
213
+ },
135
214
  ]);
136
215
 
137
216
  expect(result.has('workbench')).toBe(false);
138
217
  });
139
218
 
140
219
  it('returns an empty map for an empty session list', () => {
141
- const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver();
220
+ const resolver = new FileSystemInteractiveLiveSessionTranscriptResolver(
221
+ sharedProjectsDirectory,
222
+ );
142
223
 
143
224
  const result = resolver.resolveTranscriptPaths([]);
144
225
 
@@ -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
  }
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { LocalProcessLiveSessionProcessSnapshotProvider } from './LocalProcessLiveSessionProcessSnapshotProvider';
2
2
  import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
3
  import { ProcessEnvironReader } from '../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
4
+ import { SessionRecordReader } from '../../domain/usecases/adapter-interfaces/SessionRecordReader';
4
5
 
5
6
  describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
6
7
  const makeRunner = (
@@ -29,6 +30,13 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
29
30
  readEnviron: (pid: number) => byPid[pid] ?? null,
30
31
  });
31
32
 
33
+ const makeSessionRecordReader = (
34
+ byPid: Record<number, string>,
35
+ ): SessionRecordReader => ({
36
+ readCurrentSessionId: (_configDir: string, pid: number) =>
37
+ byPid[pid] ?? null,
38
+ });
39
+
32
40
  it('builds a snapshot of sessions, pane pids, and processes with environment', async () => {
33
41
  const runner = makeRunner({
34
42
  'tmux list-sessions -F #{session_name}': {
@@ -56,9 +64,14 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
56
64
  CLAUDE_CONFIG_DIR: '/config/workbench',
57
65
  },
58
66
  });
67
+ const sessionRecordReader = makeSessionRecordReader({
68
+ 101: 'issue-rotated-uuid',
69
+ 201: 'wb-rotated-uuid',
70
+ });
59
71
  const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
60
72
  runner,
61
73
  environReader,
74
+ sessionRecordReader,
62
75
  );
63
76
 
64
77
  const snapshot = await provider.getSnapshot();
@@ -75,6 +88,7 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
75
88
  ppid: 200,
76
89
  commandLine: 'claude --name workbench',
77
90
  sessionId: 'wb-uuid',
91
+ currentSessionId: 'wb-rotated-uuid',
78
92
  configDir: '/config/workbench',
79
93
  });
80
94
  expect(snapshot.processes).toContainEqual({
@@ -82,10 +96,44 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
82
96
  ppid: 1,
83
97
  commandLine: 'shell',
84
98
  sessionId: null,
99
+ currentSessionId: null,
85
100
  configDir: null,
86
101
  });
87
102
  });
88
103
 
104
+ it('leaves currentSessionId null when the session record is absent', async () => {
105
+ const runner = makeRunner({
106
+ 'tmux list-sessions -F #{session_name}': { stdout: 'workbench\n' },
107
+ 'tmux list-panes -t workbench -F #{pane_pid}': { stdout: '200\n' },
108
+ 'ps -eo pid=,ppid=,args=': {
109
+ stdout: ' 201 200 claude --name workbench',
110
+ },
111
+ });
112
+ const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
113
+ runner,
114
+ makeEnvironReader({
115
+ 201: {
116
+ CLAUDE_CODE_SESSION_ID: 'wb-uuid',
117
+ CLAUDE_CONFIG_DIR: '/config/workbench',
118
+ },
119
+ }),
120
+ makeSessionRecordReader({}),
121
+ );
122
+
123
+ const snapshot = await provider.getSnapshot();
124
+
125
+ expect(snapshot.processes).toEqual([
126
+ {
127
+ pid: 201,
128
+ ppid: 200,
129
+ commandLine: 'claude --name workbench',
130
+ sessionId: 'wb-uuid',
131
+ currentSessionId: null,
132
+ configDir: '/config/workbench',
133
+ },
134
+ ]);
135
+ });
136
+
89
137
  it('returns an empty snapshot when tmux reports no sessions', async () => {
90
138
  const runner = makeRunner({
91
139
  'tmux list-sessions -F #{session_name}': { stdout: '' },
@@ -94,6 +142,7 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
94
142
  const provider = new LocalProcessLiveSessionProcessSnapshotProvider(
95
143
  runner,
96
144
  makeEnvironReader({}),
145
+ makeSessionRecordReader({}),
97
146
  );
98
147
 
99
148
  const snapshot = await provider.getSnapshot();
@@ -121,6 +170,7 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
121
170
  CLAUDE_CONFIG_DIR: '/config/workbench',
122
171
  },
123
172
  }),
173
+ makeSessionRecordReader({ 201: 'wb-rotated-uuid' }),
124
174
  );
125
175
 
126
176
  const snapshot = await provider.getSnapshot();
@@ -131,6 +181,7 @@ describe('LocalProcessLiveSessionProcessSnapshotProvider', () => {
131
181
  ppid: 200,
132
182
  commandLine: 'claude --name workbench',
133
183
  sessionId: 'wb-uuid',
184
+ currentSessionId: 'wb-rotated-uuid',
134
185
  configDir: '/config/workbench',
135
186
  },
136
187
  ]);
@@ -1,11 +1,13 @@
1
1
  import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
2
2
  import { LiveSessionProcessSnapshotProvider } from '../../domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider';
3
3
  import { ProcessEnvironReader } from '../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
4
+ import { SessionRecordReader } from '../../domain/usecases/adapter-interfaces/SessionRecordReader';
4
5
  import {
5
6
  LiveSessionPaneProcess,
6
7
  LiveSessionProcessInfo,
7
8
  LiveSessionProcessSnapshot,
8
9
  } from '../../domain/entities/LiveSessionProcessSnapshot';
10
+ import { FileSystemSessionRecordReader } from './FileSystemSessionRecordReader';
9
11
 
10
12
  const SESSION_ID_ENV_KEY = 'CLAUDE_CODE_SESSION_ID';
11
13
  const CONFIG_DIR_ENV_KEY = 'CLAUDE_CONFIG_DIR';
@@ -21,6 +23,7 @@ export class LocalProcessLiveSessionProcessSnapshotProvider implements LiveSessi
21
23
  constructor(
22
24
  private readonly localCommandRunner: LocalCommandRunner,
23
25
  private readonly environReader: ProcessEnvironReader,
26
+ private readonly sessionRecordReader: SessionRecordReader = new FileSystemSessionRecordReader(),
24
27
  ) {}
25
28
 
26
29
  getSnapshot = async (): Promise<LiveSessionProcessSnapshot> => {
@@ -80,12 +83,18 @@ export class LocalProcessLiveSessionProcessSnapshotProvider implements LiveSessi
80
83
  const ppid = Number(match[2]);
81
84
  const commandLine = match[3].trim();
82
85
  const environ = this.environReader.readEnviron(pid);
86
+ const configDir = environ?.[CONFIG_DIR_ENV_KEY] ?? null;
87
+ const currentSessionId =
88
+ configDir === null
89
+ ? null
90
+ : this.sessionRecordReader.readCurrentSessionId(configDir, pid);
83
91
  processes.push({
84
92
  pid,
85
93
  ppid,
86
94
  commandLine,
87
95
  sessionId: environ?.[SESSION_ID_ENV_KEY] ?? null,
88
- configDir: environ?.[CONFIG_DIR_ENV_KEY] ?? null,
96
+ currentSessionId,
97
+ configDir,
89
98
  });
90
99
  }
91
100
  return processes;
@@ -1,5 +1,6 @@
1
1
  export type InteractiveLiveSession = {
2
2
  sessionName: string;
3
3
  sessionId: string;
4
+ candidateSessionIds: string[];
4
5
  configDir: string;
5
6
  };
@@ -8,6 +8,7 @@ export type LiveSessionProcessInfo = {
8
8
  ppid: number;
9
9
  commandLine: string;
10
10
  sessionId: string | null;
11
+ currentSessionId: string | null;
11
12
  configDir: string | null;
12
13
  };
13
14
 
@@ -60,6 +60,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
60
60
  const sessionFor = (sessionName: string): InteractiveLiveSession => ({
61
61
  sessionName,
62
62
  sessionId: `${sessionName}-uuid`,
63
+ candidateSessionIds: [`${sessionName}-uuid`],
63
64
  configDir: `/config/${sessionName}`,
64
65
  });
65
66
 
@@ -80,6 +81,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
80
81
  ppid: 100 + index * 10,
81
82
  commandLine: `claude --name ${sessionName}`,
82
83
  sessionId: `${sessionName}-uuid`,
84
+ currentSessionId: `${sessionName}-uuid`,
83
85
  configDir: `/config/${sessionName}`,
84
86
  }));
85
87
  return { sessions, processes };
@@ -299,6 +301,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
299
301
  commandLine:
300
302
  'claude --verbose -p Take ownership of https://example.com/issues/1 and finish it',
301
303
  sessionId: 'aw-uuid',
304
+ currentSessionId: 'aw-uuid',
302
305
  configDir: '/config/aw',
303
306
  },
304
307
  ],
@@ -407,6 +410,7 @@ describe('NotifySilentLiveSessionsUseCase', () => {
407
410
  expect(sessionFor('workbench')).toEqual({
408
411
  sessionName: 'workbench',
409
412
  sessionId: 'workbench-uuid',
413
+ candidateSessionIds: ['workbench-uuid'],
410
414
  configDir: '/config/workbench',
411
415
  });
412
416
  });