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
@@ -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
  });
@@ -1,9 +1,22 @@
1
1
  import { ResolveInteractiveLiveSessionsUseCase } from './ResolveInteractiveLiveSessionsUseCase';
2
- import { LiveSessionProcessSnapshot } from '../entities/LiveSessionProcessSnapshot';
2
+ import {
3
+ LiveSessionProcessInfo,
4
+ LiveSessionProcessSnapshot,
5
+ } from '../entities/LiveSessionProcessSnapshot';
3
6
 
4
7
  describe('ResolveInteractiveLiveSessionsUseCase', () => {
5
8
  const useCase = new ResolveInteractiveLiveSessionsUseCase();
6
9
 
10
+ const processInfo = (
11
+ overrides: Partial<LiveSessionProcessInfo> &
12
+ Pick<LiveSessionProcessInfo, 'pid' | 'ppid' | 'commandLine'>,
13
+ ): LiveSessionProcessInfo => ({
14
+ sessionId: null,
15
+ currentSessionId: null,
16
+ configDir: null,
17
+ ...overrides,
18
+ });
19
+
7
20
  it('resolves an issue-url-named session through its pane child process', () => {
8
21
  const snapshot: LiveSessionProcessSnapshot = {
9
22
  sessions: [
@@ -13,20 +26,14 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
13
26
  },
14
27
  ],
15
28
  processes: [
16
- {
17
- pid: 100,
18
- ppid: 1,
19
- commandLine: 'tmux pane shell',
20
- sessionId: null,
21
- configDir: null,
22
- },
23
- {
29
+ processInfo({ pid: 100, ppid: 1, commandLine: 'tmux pane shell' }),
30
+ processInfo({
24
31
  pid: 101,
25
32
  ppid: 100,
26
33
  commandLine: 'claude --model opus --resume abc',
27
34
  sessionId: 'abc',
28
35
  configDir: '/config/issues-9',
29
- },
36
+ }),
30
37
  ],
31
38
  };
32
39
 
@@ -36,29 +43,51 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
36
43
  {
37
44
  sessionName: 'https_//github_com/owner/repo/issues/9',
38
45
  sessionId: 'abc',
46
+ candidateSessionIds: ['abc'],
39
47
  configDir: '/config/issues-9',
40
48
  },
41
49
  ]);
42
50
  });
43
51
 
44
- it('resolves a plain-named session such as workbench', () => {
52
+ it('puts the rotated current session id before the launch env id', () => {
45
53
  const snapshot: LiveSessionProcessSnapshot = {
46
54
  sessions: [{ sessionName: 'workbench', panePids: [200] }],
47
55
  processes: [
48
- {
49
- pid: 200,
50
- ppid: 1,
51
- commandLine: 'shell',
52
- sessionId: null,
53
- configDir: null,
54
- },
55
- {
56
+ processInfo({
57
+ pid: 201,
58
+ ppid: 200,
59
+ commandLine: 'claude --name workbench',
60
+ sessionId: 'launch-uuid',
61
+ currentSessionId: 'rotated-uuid',
62
+ configDir: '/config/workbench',
63
+ }),
64
+ ],
65
+ };
66
+
67
+ const result = useCase.resolve(snapshot);
68
+
69
+ expect(result).toEqual([
70
+ {
71
+ sessionName: 'workbench',
72
+ sessionId: 'launch-uuid',
73
+ candidateSessionIds: ['rotated-uuid', 'launch-uuid'],
74
+ configDir: '/config/workbench',
75
+ },
76
+ ]);
77
+ });
78
+
79
+ it('keeps a single candidate id when the current id equals the launch id', () => {
80
+ const snapshot: LiveSessionProcessSnapshot = {
81
+ sessions: [{ sessionName: 'workbench', panePids: [200] }],
82
+ processes: [
83
+ processInfo({
56
84
  pid: 201,
57
85
  ppid: 200,
58
86
  commandLine: 'claude --model opus --name workbench',
59
87
  sessionId: 'wb-uuid',
88
+ currentSessionId: 'wb-uuid',
60
89
  configDir: '/config/workbench',
61
- },
90
+ }),
62
91
  ],
63
92
  };
64
93
 
@@ -68,6 +97,7 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
68
97
  {
69
98
  sessionName: 'workbench',
70
99
  sessionId: 'wb-uuid',
100
+ candidateSessionIds: ['wb-uuid'],
71
101
  configDir: '/config/workbench',
72
102
  },
73
103
  ]);
@@ -77,27 +107,15 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
77
107
  const snapshot: LiveSessionProcessSnapshot = {
78
108
  sessions: [{ sessionName: 'control-room', panePids: [300] }],
79
109
  processes: [
80
- {
81
- pid: 300,
82
- ppid: 1,
83
- commandLine: 'shell',
84
- sessionId: null,
85
- configDir: null,
86
- },
87
- {
88
- pid: 301,
89
- ppid: 300,
90
- commandLine: 'node wrapper',
91
- sessionId: null,
92
- configDir: null,
93
- },
94
- {
110
+ processInfo({ pid: 300, ppid: 1, commandLine: 'shell' }),
111
+ processInfo({ pid: 301, ppid: 300, commandLine: 'node wrapper' }),
112
+ processInfo({
95
113
  pid: 302,
96
114
  ppid: 301,
97
115
  commandLine: 'claude --name control-room',
98
116
  sessionId: 'cr-uuid',
99
117
  configDir: '/config/control-room',
100
- },
118
+ }),
101
119
  ],
102
120
  };
103
121
 
@@ -107,6 +125,7 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
107
125
  {
108
126
  sessionName: 'control-room',
109
127
  sessionId: 'cr-uuid',
128
+ candidateSessionIds: ['cr-uuid'],
110
129
  configDir: '/config/control-room',
111
130
  },
112
131
  ]);
@@ -116,21 +135,15 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
116
135
  const snapshot: LiveSessionProcessSnapshot = {
117
136
  sessions: [{ sessionName: 'aw-host', panePids: [400] }],
118
137
  processes: [
119
- {
120
- pid: 400,
121
- ppid: 1,
122
- commandLine: 'shell',
123
- sessionId: null,
124
- configDir: null,
125
- },
126
- {
138
+ processInfo({ pid: 400, ppid: 1, commandLine: 'shell' }),
139
+ processInfo({
127
140
  pid: 401,
128
141
  ppid: 400,
129
142
  commandLine:
130
143
  'claude --verbose -p Take ownership of https://example.com/issues/1 and finish it',
131
144
  sessionId: 'aw-uuid',
132
145
  configDir: '/config/aw',
133
- },
146
+ }),
134
147
  ],
135
148
  };
136
149
 
@@ -143,13 +156,12 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
143
156
  const snapshot: LiveSessionProcessSnapshot = {
144
157
  sessions: [{ sessionName: 'partial', panePids: [500] }],
145
158
  processes: [
146
- {
159
+ processInfo({
147
160
  pid: 501,
148
161
  ppid: 500,
149
162
  commandLine: 'claude --model opus',
150
- sessionId: null,
151
163
  configDir: '/config/partial',
152
- },
164
+ }),
153
165
  ],
154
166
  };
155
167
 
@@ -162,13 +174,13 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
162
174
  const snapshot: LiveSessionProcessSnapshot = {
163
175
  sessions: [{ sessionName: 'monitored', panePids: [600] }],
164
176
  processes: [
165
- {
177
+ processInfo({
166
178
  pid: 601,
167
179
  ppid: 600,
168
180
  commandLine: 'node monitor.js',
169
181
  sessionId: 'mon-uuid',
170
182
  configDir: '/config/monitor',
171
- },
183
+ }),
172
184
  ],
173
185
  };
174
186
 
@@ -184,20 +196,14 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
184
196
  { sessionName: 'empty', panePids: [800] },
185
197
  ],
186
198
  processes: [
187
- {
199
+ processInfo({
188
200
  pid: 701,
189
201
  ppid: 700,
190
202
  commandLine: 'claude --name workbench',
191
203
  sessionId: 'wb-uuid',
192
204
  configDir: '/config/workbench',
193
- },
194
- {
195
- pid: 801,
196
- ppid: 800,
197
- commandLine: 'bash idle',
198
- sessionId: null,
199
- configDir: null,
200
- },
205
+ }),
206
+ processInfo({ pid: 801, ppid: 800, commandLine: 'bash idle' }),
201
207
  ],
202
208
  };
203
209
 
@@ -207,11 +213,88 @@ describe('ResolveInteractiveLiveSessionsUseCase', () => {
207
213
  {
208
214
  sessionName: 'workbench',
209
215
  sessionId: 'wb-uuid',
216
+ candidateSessionIds: ['wb-uuid'],
210
217
  configDir: '/config/workbench',
211
218
  },
212
219
  ]);
213
220
  });
214
221
 
222
+ it('collects the descendant-propagated session id after the own launch id for a non-resume session', () => {
223
+ const snapshot: LiveSessionProcessSnapshot = {
224
+ sessions: [{ sessionName: 'non-resume', panePids: [900] }],
225
+ processes: [
226
+ processInfo({ pid: 900, ppid: 1, commandLine: 'shell' }),
227
+ processInfo({
228
+ pid: 901,
229
+ ppid: 900,
230
+ commandLine: 'claude --model opus',
231
+ sessionId: 'launch-id',
232
+ configDir: '/config/non-resume',
233
+ }),
234
+ processInfo({
235
+ pid: 902,
236
+ ppid: 901,
237
+ commandLine: 'node tool worker',
238
+ sessionId: 'harness-id',
239
+ configDir: '/config/non-resume',
240
+ }),
241
+ processInfo({
242
+ pid: 903,
243
+ ppid: 902,
244
+ commandLine: 'node nested worker',
245
+ sessionId: 'harness-id',
246
+ configDir: '/config/non-resume',
247
+ }),
248
+ ],
249
+ };
250
+
251
+ const result = useCase.resolve(snapshot);
252
+
253
+ expect(result).toEqual([
254
+ {
255
+ sessionName: 'non-resume',
256
+ sessionId: 'launch-id',
257
+ candidateSessionIds: ['launch-id', 'harness-id'],
258
+ configDir: '/config/non-resume',
259
+ },
260
+ ]);
261
+ });
262
+
263
+ it('orders the rotated current id, then the launch id, then the descendant id', () => {
264
+ const snapshot: LiveSessionProcessSnapshot = {
265
+ sessions: [{ sessionName: 'non-resume', panePids: [910] }],
266
+ processes: [
267
+ processInfo({ pid: 910, ppid: 1, commandLine: 'shell' }),
268
+ processInfo({
269
+ pid: 911,
270
+ ppid: 910,
271
+ commandLine: 'claude --model opus',
272
+ sessionId: 'launch-id',
273
+ currentSessionId: 'rotated-id',
274
+ configDir: '/config/non-resume',
275
+ }),
276
+ processInfo({
277
+ pid: 912,
278
+ ppid: 911,
279
+ commandLine: 'node tool worker',
280
+ sessionId: 'harness-id',
281
+ configDir: '/config/non-resume',
282
+ }),
283
+ ],
284
+ };
285
+
286
+ const result = useCase.resolve(snapshot);
287
+
288
+ expect(result).toEqual([
289
+ {
290
+ sessionName: 'non-resume',
291
+ sessionId: 'launch-id',
292
+ candidateSessionIds: ['rotated-id', 'launch-id', 'harness-id'],
293
+ configDir: '/config/non-resume',
294
+ },
295
+ ]);
296
+ });
297
+
215
298
  it('returns an empty list when there are no sessions', () => {
216
299
  const result = useCase.resolve({ sessions: [], processes: [] });
217
300
 
@@ -66,15 +66,65 @@ export class ResolveInteractiveLiveSessionsUseCase {
66
66
  ) {
67
67
  continue;
68
68
  }
69
+ const candidateSessionIds = this.collectCandidateSessionIds(
70
+ interactiveProcess,
71
+ childrenByPpid,
72
+ );
69
73
  sessions.push({
70
74
  sessionName: session.sessionName,
71
75
  sessionId: interactiveProcess.sessionId,
76
+ candidateSessionIds,
72
77
  configDir: interactiveProcess.configDir,
73
78
  });
74
79
  }
75
80
  return sessions;
76
81
  };
77
82
 
83
+ /**
84
+ * Collects the distinct session ids that may name the actively-written
85
+ * transcript on disk, in priority order. The rotated current session id
86
+ * recorded for the interactive process is first, because for a resumed or
87
+ * compacted session the id rotates and the live transcript is named by the
88
+ * current id. The interactive process's own launch id follows, then the
89
+ * distinct ids propagated to its descendant processes. For a `--resume`
90
+ * session these ids coincide, yielding a single id; for a non-resume session
91
+ * the own (launch) id names no transcript and the live transcript is named by
92
+ * the descendant-propagated id, which is included here so the resolver can
93
+ * find the actively-written file.
94
+ */
95
+ private collectCandidateSessionIds = (
96
+ interactiveProcess: LiveSessionProcessInfo,
97
+ childrenByPpid: Map<number, LiveSessionProcessInfo[]>,
98
+ ): string[] => {
99
+ const candidateSessionIds: string[] = [];
100
+ const seenSessionIds = new Set<string>();
101
+ const pushSessionId = (sessionId: string | null): void => {
102
+ if (sessionId !== null && !seenSessionIds.has(sessionId)) {
103
+ seenSessionIds.add(sessionId);
104
+ candidateSessionIds.push(sessionId);
105
+ }
106
+ };
107
+ pushSessionId(interactiveProcess.currentSessionId);
108
+ const visitedPids = new Set<number>();
109
+ const queue: LiveSessionProcessInfo[] = [interactiveProcess];
110
+ let head = 0;
111
+ while (head < queue.length) {
112
+ const process = queue[head];
113
+ head += 1;
114
+ if (visitedPids.has(process.pid)) {
115
+ continue;
116
+ }
117
+ visitedPids.add(process.pid);
118
+ pushSessionId(process.sessionId);
119
+ for (const child of childrenByPpid.get(process.pid) ?? []) {
120
+ if (!visitedPids.has(child.pid)) {
121
+ queue.push(child);
122
+ }
123
+ }
124
+ }
125
+ return candidateSessionIds;
126
+ };
127
+
78
128
  private findInteractiveProcess = (
79
129
  panePids: number[],
80
130
  processByPid: Map<number, LiveSessionProcessInfo>,
@@ -0,0 +1,3 @@
1
+ export interface SessionRecordReader {
2
+ readCurrentSessionId: (configDir: string, pid: number) => string | null;
3
+ }
@@ -1,18 +1,11 @@
1
1
  import { InteractiveLiveSession } from '../../domain/entities/InteractiveLiveSession';
2
2
  import { InteractiveLiveSessionTranscriptResolver } from '../../domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver';
3
- /**
4
- * Resolves the real transcript path of an interactive Claude Code session from
5
- * the session id and config directory taken from the session's process
6
- * environment. The transcript lives at
7
- * `<configDir>/projects/<cwd-slug>/<sessionId>.jsonl`; this resolver scans the
8
- * `projects` subdirectories for a file named `<sessionId>.jsonl` and returns the
9
- * most recently modified match. Because resolution is keyed on the process
10
- * session id rather than on a session name or issue URL, a plain-named session
11
- * (for example one named `workbench`) resolves just as well as an issue-url
12
- * named one.
13
- */
14
3
  export declare class FileSystemInteractiveLiveSessionTranscriptResolver implements InteractiveLiveSessionTranscriptResolver {
4
+ private readonly sharedProjectsDirectory;
5
+ constructor(sharedProjectsDirectory?: string);
15
6
  resolveTranscriptPaths: (sessions: InteractiveLiveSession[]) => Map<string, string>;
16
7
  private resolveTranscriptPath;
8
+ private listProjectsDirectories;
9
+ private listCandidatePaths;
17
10
  }
18
11
  //# sourceMappingURL=FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"FileSystemInteractiveLiveSessionTranscriptResolver.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,sBAAsB,EAAE,MAAM,8CAA8C,CAAC;AACtF,OAAO,EAAE,wCAAwC,EAAE,MAAM,mFAAmF,CAAC;AAW7I;;;;;;;;;;GAUG;AACH,qBAAa,kDAAmD,YAAW,wCAAwC;IACjH,sBAAsB,GACpB,UAAU,sBAAsB,EAAE,KACjC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CASpB;IAEF,OAAO,CAAC,qBAAqB,CAkC3B;CACH"}
1
+ {"version":3,"file":"FileSystemInteractiveLiveSessionTranscriptResolver.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,sBAAsB,EAAE,MAAM,8CAA8C,CAAC;AACtF,OAAO,EAAE,wCAAwC,EAAE,MAAM,mFAAmF,CAAC;AAc7I,qBAAa,kDAAmD,YAAW,wCAAwC;IAE/G,OAAO,CAAC,QAAQ,CAAC,uBAAuB;gBAAvB,uBAAuB,GAAE,MAAyC;IAGrF,sBAAsB,GACpB,UAAU,sBAAsB,EAAE,KACjC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CASpB;IAEF,OAAO,CAAC,qBAAqB,CAyB3B;IAEF,OAAO,CAAC,uBAAuB,CAM7B;IAEF,OAAO,CAAC,kBAAkB,CAsBxB;CACH"}
@@ -1,14 +1,17 @@
1
1
  import { LiveSessionOutputActivity } from '../../domain/entities/LiveSessionOutputActivity';
2
2
  import { SessionOutputActivityRepository } from '../../domain/usecases/adapter-interfaces/SessionOutputActivityRepository';
3
3
  /**
4
- * Reads the last main-session output time for each live session from its
4
+ * Reads the last main-session activity time for each live session from its
5
5
  * already-resolved transcript path. Idle time is computed from the timestamp of
6
- * the latest `assistant` entry rather than from the transcript file modification
7
- * time, so a transcript touched only by tool results or owner replies still
8
- * counts as silent.
6
+ * the latest entry of any kind (assistant text, owner replies, tool results, or
7
+ * any other entry type) rather than from the transcript file modification time.
8
+ * Because a session that is actively running tool calls keeps appending entries
9
+ * such as `user` and `tool_result` even while it emits no assistant text, every
10
+ * entry with a parseable timestamp counts as activity, so a working session is
11
+ * not mistaken for a silent one.
9
12
  */
10
13
  export declare class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
11
14
  listSessionOutputActivities: (transcriptPathBySessionName: Map<string, string>) => Promise<LiveSessionOutputActivity[]>;
12
- private readLastAssistantOutputEpochSeconds;
15
+ private readLastActivityEpochSeconds;
13
16
  }
14
17
  //# sourceMappingURL=FileSystemSessionOutputActivityRepository.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"FileSystemSessionOutputActivityRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,yBAAyB,EAAE,MAAM,iDAAiD,CAAC;AAC5F,OAAO,EAAE,+BAA+B,EAAE,MAAM,0EAA0E,CAAC;AAqB3H;;;;;;GAMG;AACH,qBAAa,yCAA0C,YAAW,+BAA+B;IAC/F,2BAA2B,GACzB,6BAA6B,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,KAC/C,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAUrC;IAEF,OAAO,CAAC,mCAAmC,CAuCzC;CACH"}
1
+ {"version":3,"file":"FileSystemSessionOutputActivityRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,yBAAyB,EAAE,MAAM,iDAAiD,CAAC;AAC5F,OAAO,EAAE,+BAA+B,EAAE,MAAM,0EAA0E,CAAC;AAqB3H;;;;;;;;;GASG;AACH,qBAAa,yCAA0C,YAAW,+BAA+B;IAC/F,2BAA2B,GACzB,6BAA6B,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,KAC/C,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAUrC;IAEF,OAAO,CAAC,4BAA4B,CAoClC;CACH"}
@@ -0,0 +1,5 @@
1
+ import { SessionRecordReader } from '../../domain/usecases/adapter-interfaces/SessionRecordReader';
2
+ export declare class FileSystemSessionRecordReader implements SessionRecordReader {
3
+ readCurrentSessionId: (configDir: string, pid: number) => string | null;
4
+ }
5
+ //# sourceMappingURL=FileSystemSessionRecordReader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FileSystemSessionRecordReader.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/FileSystemSessionRecordReader.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,MAAM,8DAA8D,CAAC;AAKnG,qBAAa,6BAA8B,YAAW,mBAAmB;IACvE,oBAAoB,GAAI,WAAW,MAAM,EAAE,KAAK,MAAM,KAAG,MAAM,GAAG,IAAI,CAqBpE;CACH"}