github-issue-tower-defence-management 1.110.1 → 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 (92) hide show
  1. package/CHANGELOG.md +7 -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/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +104 -0
  8. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -0
  9. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +55 -19
  10. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
  11. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +75 -0
  12. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -0
  13. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +1 -1
  14. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -1
  15. package/bin/adapter/repositories/ProcFsProcessEnvironReader.js +73 -0
  16. package/bin/adapter/repositories/ProcFsProcessEnvironReader.js.map +1 -0
  17. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +8 -16
  18. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
  19. package/bin/domain/entities/InteractiveLiveSession.js +3 -0
  20. package/bin/domain/entities/InteractiveLiveSession.js.map +1 -0
  21. package/bin/domain/entities/LiveSessionProcessSnapshot.js +3 -0
  22. package/bin/domain/entities/LiveSessionProcessSnapshot.js.map +1 -0
  23. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +20 -33
  24. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  25. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +89 -0
  26. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -0
  27. package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js +3 -0
  28. package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js.map +1 -0
  29. package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js +3 -0
  30. package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js.map +1 -0
  31. package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js +3 -0
  32. package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -15
  35. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +96 -204
  36. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +19 -40
  37. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +147 -0
  38. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +75 -0
  39. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +82 -44
  40. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +62 -24
  41. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +138 -0
  42. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +93 -0
  43. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +6 -4
  44. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +1 -1
  45. package/src/adapter/repositories/ProcFsProcessEnvironReader.test.ts +60 -0
  46. package/src/adapter/repositories/ProcFsProcessEnvironReader.ts +38 -0
  47. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +61 -81
  48. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +8 -25
  49. package/src/domain/entities/InteractiveLiveSession.ts +5 -0
  50. package/src/domain/entities/LiveSessionProcessSnapshot.ts +17 -0
  51. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +125 -349
  52. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +33 -55
  53. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +220 -0
  54. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +105 -0
  55. package/src/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.ts +7 -0
  56. package/src/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.ts +5 -0
  57. package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +1 -1
  58. package/src/domain/usecases/adapter-interfaces/ProcessEnvironReader.ts +3 -0
  59. package/src/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.ts +1 -1
  60. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  61. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -7
  62. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  63. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +18 -0
  64. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
  65. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +9 -5
  66. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
  67. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +21 -0
  68. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -0
  69. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +1 -1
  70. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -1
  71. package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts +12 -0
  72. package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts.map +1 -0
  73. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -4
  74. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
  75. package/types/domain/entities/InteractiveLiveSession.d.ts +6 -0
  76. package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -0
  77. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +16 -0
  78. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -0
  79. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +6 -11
  80. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  81. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +23 -0
  82. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -0
  83. package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts +5 -0
  84. package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
  85. package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts +5 -0
  86. package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts.map +1 -0
  87. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +1 -1
  88. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -1
  89. package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts +4 -0
  90. package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts.map +1 -0
  91. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts +1 -1
  92. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts.map +1 -1
@@ -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
+ });
@@ -0,0 +1,93 @@
1
+ import { LocalCommandRunner } from '../../domain/usecases/adapter-interfaces/LocalCommandRunner';
2
+ import { LiveSessionProcessSnapshotProvider } from '../../domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider';
3
+ import { ProcessEnvironReader } from '../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
4
+ import {
5
+ LiveSessionPaneProcess,
6
+ LiveSessionProcessInfo,
7
+ LiveSessionProcessSnapshot,
8
+ } from '../../domain/entities/LiveSessionProcessSnapshot';
9
+
10
+ const SESSION_ID_ENV_KEY = 'CLAUDE_CODE_SESSION_ID';
11
+ const CONFIG_DIR_ENV_KEY = 'CLAUDE_CONFIG_DIR';
12
+
13
+ /**
14
+ * Builds a live-session process snapshot from the local host: it lists the live
15
+ * tmux sessions and each session's pane process ids, lists the full process
16
+ * tree with command lines, and reads each process environment to expose the
17
+ * Claude Code session id and config directory. The snapshot is consumed by a
18
+ * pure resolver, so all host access is contained in this adapter.
19
+ */
20
+ export class LocalProcessLiveSessionProcessSnapshotProvider implements LiveSessionProcessSnapshotProvider {
21
+ constructor(
22
+ private readonly localCommandRunner: LocalCommandRunner,
23
+ private readonly environReader: ProcessEnvironReader,
24
+ ) {}
25
+
26
+ getSnapshot = async (): Promise<LiveSessionProcessSnapshot> => {
27
+ const sessionNames = await this.listSessionNames();
28
+ const sessions: LiveSessionPaneProcess[] = [];
29
+ for (const sessionName of sessionNames) {
30
+ const panePids = await this.listPanePids(sessionName);
31
+ sessions.push({ sessionName, panePids });
32
+ }
33
+ const processes = await this.listProcesses();
34
+ return { sessions, processes };
35
+ };
36
+
37
+ private listSessionNames = async (): Promise<string[]> => {
38
+ const { stdout, exitCode } = await this.localCommandRunner.runCommand(
39
+ 'tmux',
40
+ ['list-sessions', '-F', '#{session_name}'],
41
+ );
42
+ if (exitCode !== 0) {
43
+ return [];
44
+ }
45
+ return stdout
46
+ .split('\n')
47
+ .map((line) => line.trim())
48
+ .filter((line) => line.length > 0);
49
+ };
50
+
51
+ private listPanePids = async (sessionName: string): Promise<number[]> => {
52
+ const { stdout, exitCode } = await this.localCommandRunner.runCommand(
53
+ 'tmux',
54
+ ['list-panes', '-t', sessionName, '-F', '#{pane_pid}'],
55
+ );
56
+ if (exitCode !== 0) {
57
+ return [];
58
+ }
59
+ return stdout
60
+ .split('\n')
61
+ .map((line) => Number(line.trim()))
62
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
63
+ };
64
+
65
+ private listProcesses = async (): Promise<LiveSessionProcessInfo[]> => {
66
+ const { stdout, exitCode } = await this.localCommandRunner.runCommand(
67
+ 'ps',
68
+ ['-eo', 'pid=,ppid=,args='],
69
+ );
70
+ if (exitCode !== 0) {
71
+ return [];
72
+ }
73
+ const processes: LiveSessionProcessInfo[] = [];
74
+ for (const line of stdout.split('\n')) {
75
+ const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
76
+ if (match === null) {
77
+ continue;
78
+ }
79
+ const pid = Number(match[1]);
80
+ const ppid = Number(match[2]);
81
+ const commandLine = match[3].trim();
82
+ const environ = this.environReader.readEnviron(pid);
83
+ processes.push({
84
+ pid,
85
+ ppid,
86
+ commandLine,
87
+ sessionId: environ?.[SESSION_ID_ENV_KEY] ?? null,
88
+ configDir: environ?.[CONFIG_DIR_ENV_KEY] ?? null,
89
+ });
90
+ }
91
+ return processes;
92
+ };
93
+ }
@@ -3,10 +3,12 @@ import { NoUnansweredOwnerCallStatusProvider } from './NoUnansweredOwnerCallStat
3
3
  describe('NoUnansweredOwnerCallStatusProvider', () => {
4
4
  it('always reports no session as having an unanswered owner call', async () => {
5
5
  const provider = new NoUnansweredOwnerCallStatusProvider();
6
- const result = await provider.listSessionNamesWithUnansweredOwnerCall([
7
- 'session-a',
8
- 'session-b',
9
- ]);
6
+ const result = await provider.listSessionNamesWithUnansweredOwnerCall(
7
+ new Map([
8
+ ['session-a', '/transcripts/a.jsonl'],
9
+ ['session-b', '/transcripts/b.jsonl'],
10
+ ]),
11
+ );
10
12
  expect(result.size).toBe(0);
11
13
  });
12
14
  });
@@ -2,7 +2,7 @@ import { OwnerCallStatusProvider } from '../../domain/usecases/adapter-interface
2
2
 
3
3
  export class NoUnansweredOwnerCallStatusProvider implements OwnerCallStatusProvider {
4
4
  listSessionNamesWithUnansweredOwnerCall = (
5
- _sessionNames: string[],
5
+ _transcriptPathBySessionName: Map<string, string>,
6
6
  ): Promise<Set<string>> => {
7
7
  return Promise.resolve(new Set<string>());
8
8
  };
@@ -0,0 +1,60 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import { ProcFsProcessEnvironReader } from './ProcFsProcessEnvironReader';
5
+
6
+ describe('ProcFsProcessEnvironReader', () => {
7
+ let procDirectory: string;
8
+
9
+ beforeEach(() => {
10
+ procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'proc-environ-'));
11
+ });
12
+
13
+ afterEach(() => {
14
+ fs.rmSync(procDirectory, { force: true, recursive: true });
15
+ });
16
+
17
+ const writeEnviron = (pid: number, entries: string[]): void => {
18
+ const pidDirectory = path.join(procDirectory, String(pid));
19
+ fs.mkdirSync(pidDirectory, { recursive: true });
20
+ fs.writeFileSync(
21
+ path.join(pidDirectory, 'environ'),
22
+ entries.join('\0') + '\0',
23
+ 'utf8',
24
+ );
25
+ };
26
+
27
+ it('parses NUL-separated KEY=value pairs from procfs', () => {
28
+ writeEnviron(123, [
29
+ 'PATH=/usr/bin',
30
+ 'CLAUDE_CODE_SESSION_ID=abc-123',
31
+ 'CLAUDE_CONFIG_DIR=/config/workbench',
32
+ ]);
33
+ const reader = new ProcFsProcessEnvironReader(procDirectory);
34
+
35
+ const result = reader.readEnviron(123);
36
+
37
+ expect(result).toEqual({
38
+ PATH: '/usr/bin',
39
+ CLAUDE_CODE_SESSION_ID: 'abc-123',
40
+ CLAUDE_CONFIG_DIR: '/config/workbench',
41
+ });
42
+ });
43
+
44
+ it('keeps values that themselves contain an equals sign', () => {
45
+ writeEnviron(124, ['QUERY=a=b=c']);
46
+ const reader = new ProcFsProcessEnvironReader(procDirectory);
47
+
48
+ const result = reader.readEnviron(124);
49
+
50
+ expect(result).toEqual({ QUERY: 'a=b=c' });
51
+ });
52
+
53
+ it('returns null when the process environ cannot be read', () => {
54
+ const reader = new ProcFsProcessEnvironReader(procDirectory);
55
+
56
+ const result = reader.readEnviron(999);
57
+
58
+ expect(result).toBeNull();
59
+ });
60
+ });