github-issue-tower-defence-management 1.114.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 (39) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +1 -1
  3. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +36 -27
  4. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -1
  5. package/bin/adapter/repositories/FileSystemSessionRecordReader.js +69 -0
  6. package/bin/adapter/repositories/FileSystemSessionRecordReader.js.map +1 -0
  7. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +9 -2
  8. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -1
  9. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +15 -0
  10. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -1
  11. package/bin/domain/usecases/adapter-interfaces/SessionRecordReader.js +3 -0
  12. package/bin/domain/usecases/adapter-interfaces/SessionRecordReader.js.map +1 -0
  13. package/package.json +1 -1
  14. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +107 -26
  15. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +49 -29
  16. package/src/adapter/repositories/FileSystemSessionRecordReader.test.ts +59 -0
  17. package/src/adapter/repositories/FileSystemSessionRecordReader.ts +31 -0
  18. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +51 -0
  19. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +10 -1
  20. package/src/domain/entities/InteractiveLiveSession.ts +1 -0
  21. package/src/domain/entities/LiveSessionProcessSnapshot.ts +1 -0
  22. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +4 -0
  23. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +66 -59
  24. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +19 -0
  25. package/src/domain/usecases/adapter-interfaces/SessionRecordReader.ts +3 -0
  26. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +4 -11
  27. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -1
  28. package/types/adapter/repositories/FileSystemSessionRecordReader.d.ts +5 -0
  29. package/types/adapter/repositories/FileSystemSessionRecordReader.d.ts.map +1 -0
  30. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +3 -1
  31. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -1
  32. package/types/domain/entities/InteractiveLiveSession.d.ts +1 -0
  33. package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -1
  34. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +1 -0
  35. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -1
  36. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +1 -0
  37. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -1
  38. package/types/domain/usecases/adapter-interfaces/SessionRecordReader.d.ts +4 -0
  39. package/types/domain/usecases/adapter-interfaces/SessionRecordReader.d.ts.map +1 -0
@@ -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
  });
@@ -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,6 +213,7 @@ 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
  ]);
@@ -69,12 +69,31 @@ export class ResolveInteractiveLiveSessionsUseCase {
69
69
  sessions.push({
70
70
  sessionName: session.sessionName,
71
71
  sessionId: interactiveProcess.sessionId,
72
+ candidateSessionIds:
73
+ this.collectCandidateSessionIds(interactiveProcess),
72
74
  configDir: interactiveProcess.configDir,
73
75
  });
74
76
  }
75
77
  return sessions;
76
78
  };
77
79
 
80
+ private collectCandidateSessionIds = (
81
+ interactiveProcess: LiveSessionProcessInfo,
82
+ ): string[] => {
83
+ const candidateSessionIds: string[] = [];
84
+ const seenSessionIds = new Set<string>();
85
+ for (const sessionId of [
86
+ interactiveProcess.currentSessionId,
87
+ interactiveProcess.sessionId,
88
+ ]) {
89
+ if (sessionId !== null && !seenSessionIds.has(sessionId)) {
90
+ seenSessionIds.add(sessionId);
91
+ candidateSessionIds.push(sessionId);
92
+ }
93
+ }
94
+ return candidateSessionIds;
95
+ };
96
+
78
97
  private findInteractiveProcess = (
79
98
  panePids: number[],
80
99
  processByPid: Map<number, LiveSessionProcessInfo>,
@@ -0,0 +1,3 @@
1
+ export interface SessionRecordReader {
2
+ readCurrentSessionId: (configDir: string, pid: number) => string | null;
3
+ }