github-issue-tower-defence-management 1.110.0 → 1.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +8 -7
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +9 -21
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js +10 -15
  6. package/bin/adapter/entry-points/handlers/notifySilentTmuxSessions.js.map +1 -1
  7. package/bin/adapter/proxy/RateLimitCache.js +8 -0
  8. package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
  9. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js +104 -0
  10. package/bin/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.js.map +1 -0
  11. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js +55 -19
  12. package/bin/adapter/repositories/FileSystemSessionOutputActivityRepository.js.map +1 -1
  13. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js +75 -0
  14. package/bin/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.js.map +1 -0
  15. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js +1 -1
  16. package/bin/adapter/repositories/NoUnansweredOwnerCallStatusProvider.js.map +1 -1
  17. package/bin/adapter/repositories/ProcFsProcessEnvironReader.js +73 -0
  18. package/bin/adapter/repositories/ProcFsProcessEnvironReader.js.map +1 -0
  19. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js +8 -16
  20. package/bin/adapter/repositories/TranscriptOwnerCallStatusProvider.js.map +1 -1
  21. package/bin/domain/entities/InteractiveLiveSession.js +3 -0
  22. package/bin/domain/entities/InteractiveLiveSession.js.map +1 -0
  23. package/bin/domain/entities/LiveSessionProcessSnapshot.js +3 -0
  24. package/bin/domain/entities/LiveSessionProcessSnapshot.js.map +1 -0
  25. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +20 -33
  26. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  27. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js +89 -0
  28. package/bin/domain/usecases/ResolveInteractiveLiveSessionsUseCase.js.map +1 -0
  29. package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js +3 -0
  30. package/bin/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.js.map +1 -0
  31. package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js +3 -0
  32. package/bin/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.js.map +1 -0
  33. package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js +3 -0
  34. package/bin/domain/usecases/adapter-interfaces/ProcessEnvironReader.js.map +1 -0
  35. package/package.json +1 -1
  36. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +5 -15
  37. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.test.ts +96 -204
  38. package/src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts +19 -40
  39. package/src/adapter/proxy/RateLimitCache.test.ts +63 -0
  40. package/src/adapter/proxy/RateLimitCache.ts +11 -0
  41. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.test.ts +147 -0
  42. package/src/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.ts +75 -0
  43. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.test.ts +82 -44
  44. package/src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts +62 -24
  45. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.test.ts +138 -0
  46. package/src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts +93 -0
  47. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.test.ts +6 -4
  48. package/src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts +1 -1
  49. package/src/adapter/repositories/ProcFsProcessEnvironReader.test.ts +60 -0
  50. package/src/adapter/repositories/ProcFsProcessEnvironReader.ts +38 -0
  51. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +61 -81
  52. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.ts +8 -25
  53. package/src/domain/entities/InteractiveLiveSession.ts +5 -0
  54. package/src/domain/entities/LiveSessionProcessSnapshot.ts +17 -0
  55. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +125 -349
  56. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +33 -55
  57. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.test.ts +220 -0
  58. package/src/domain/usecases/ResolveInteractiveLiveSessionsUseCase.ts +105 -0
  59. package/src/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.ts +7 -0
  60. package/src/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.ts +5 -0
  61. package/src/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.ts +1 -1
  62. package/src/domain/usecases/adapter-interfaces/ProcessEnvironReader.ts +3 -0
  63. package/src/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.ts +1 -1
  64. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  65. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts +3 -7
  66. package/types/adapter/entry-points/handlers/notifySilentTmuxSessions.d.ts.map +1 -1
  67. package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
  68. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts +18 -0
  69. package/types/adapter/repositories/FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
  70. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts +9 -5
  71. package/types/adapter/repositories/FileSystemSessionOutputActivityRepository.d.ts.map +1 -1
  72. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts +21 -0
  73. package/types/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map +1 -0
  74. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts +1 -1
  75. package/types/adapter/repositories/NoUnansweredOwnerCallStatusProvider.d.ts.map +1 -1
  76. package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts +12 -0
  77. package/types/adapter/repositories/ProcFsProcessEnvironReader.d.ts.map +1 -0
  78. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts +2 -4
  79. package/types/adapter/repositories/TranscriptOwnerCallStatusProvider.d.ts.map +1 -1
  80. package/types/domain/entities/InteractiveLiveSession.d.ts +6 -0
  81. package/types/domain/entities/InteractiveLiveSession.d.ts.map +1 -0
  82. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts +16 -0
  83. package/types/domain/entities/LiveSessionProcessSnapshot.d.ts.map +1 -0
  84. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts +6 -11
  85. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  86. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts +23 -0
  87. package/types/domain/usecases/ResolveInteractiveLiveSessionsUseCase.d.ts.map +1 -0
  88. package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts +5 -0
  89. package/types/domain/usecases/adapter-interfaces/InteractiveLiveSessionTranscriptResolver.d.ts.map +1 -0
  90. package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts +5 -0
  91. package/types/domain/usecases/adapter-interfaces/LiveSessionProcessSnapshotProvider.d.ts.map +1 -0
  92. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts +1 -1
  93. package/types/domain/usecases/adapter-interfaces/OwnerCallStatusProvider.d.ts.map +1 -1
  94. package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts +4 -0
  95. package/types/domain/usecases/adapter-interfaces/ProcessEnvironReader.d.ts.map +1 -0
  96. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts +1 -1
  97. package/types/domain/usecases/adapter-interfaces/SessionOutputActivityRepository.d.ts.map +1 -1
@@ -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
+ });
@@ -0,0 +1,38 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { ProcessEnvironReader } from '../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
4
+
5
+ /**
6
+ * Reads a process environment from the Linux procfs (`/proc/<pid>/environ`),
7
+ * where entries are NUL-separated `KEY=value` pairs. Returns null when the
8
+ * environment cannot be read (the process has exited or is not accessible).
9
+ */
10
+ export class ProcFsProcessEnvironReader implements ProcessEnvironReader {
11
+ constructor(private readonly procDirectory: string = '/proc') {}
12
+
13
+ readEnviron = (pid: number): Record<string, string> | null => {
14
+ let raw: string;
15
+ try {
16
+ raw = fs.readFileSync(
17
+ path.join(this.procDirectory, String(pid), 'environ'),
18
+ 'utf8',
19
+ );
20
+ } catch {
21
+ return null;
22
+ }
23
+ const environ: Record<string, string> = {};
24
+ for (const entry of raw.split('\0')) {
25
+ if (entry.length === 0) {
26
+ continue;
27
+ }
28
+ const separatorIndex = entry.indexOf('=');
29
+ if (separatorIndex === -1) {
30
+ continue;
31
+ }
32
+ const key = entry.slice(0, separatorIndex);
33
+ const value = entry.slice(separatorIndex + 1);
34
+ environ[key] = value;
35
+ }
36
+ return environ;
37
+ };
38
+ }
@@ -16,14 +16,14 @@ describe('TranscriptOwnerCallStatusProvider', () => {
16
16
  fs.rmSync(rootDirectory, { force: true, recursive: true });
17
17
  });
18
18
 
19
- const writeTranscript = (sessionName: string, lines: object[]): void => {
20
- const fileName = `${sessionName.replace(/\//g, '_')}.jsonl`;
19
+ const writeTranscript = (fileName: string, lines: object[]): string => {
21
20
  const filePath = path.join(rootDirectory, fileName);
22
21
  fs.writeFileSync(
23
22
  filePath,
24
23
  lines.map((line) => JSON.stringify(line)).join('\n'),
25
24
  'utf8',
26
25
  );
26
+ return filePath;
27
27
  };
28
28
 
29
29
  const assistantWithMarker = (timestamp: string): object => ({
@@ -63,141 +63,104 @@ describe('TranscriptOwnerCallStatusProvider', () => {
63
63
  },
64
64
  });
65
65
 
66
+ const sessionName = 'workbench';
67
+
66
68
  it('reports a session as waiting when the last owner call is newer than the last owner reply', async () => {
67
- const sessionName = 'https_//github_com/owner/repo/issues/9';
68
- writeTranscript(sessionName, [
69
+ const transcriptPath = writeTranscript('workbench.jsonl', [
69
70
  ownerReply('2026-06-27T10:00:00.000Z'),
70
71
  assistantWithMarker('2026-06-27T10:05:00.000Z'),
71
72
  ]);
72
- const provider = new TranscriptOwnerCallStatusProvider(
73
- rootDirectory,
74
- '<<OWNER_CALL>>',
75
- );
73
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
76
74
 
77
- const result = await provider.listSessionNamesWithUnansweredOwnerCall([
78
- sessionName,
79
- ]);
75
+ const result = await provider.listSessionNamesWithUnansweredOwnerCall(
76
+ new Map([[sessionName, transcriptPath]]),
77
+ );
80
78
 
81
79
  expect(result.has(sessionName)).toBe(true);
82
80
  });
83
81
 
84
82
  it('does not report a session as waiting when a later owner reply followed the call', async () => {
85
- const sessionName = 'https_//github_com/owner/repo/issues/9';
86
- writeTranscript(sessionName, [
83
+ const transcriptPath = writeTranscript('workbench.jsonl', [
87
84
  assistantWithMarker('2026-06-27T10:00:00.000Z'),
88
85
  ownerReply('2026-06-27T10:05:00.000Z'),
89
86
  ]);
90
- const provider = new TranscriptOwnerCallStatusProvider(
91
- rootDirectory,
92
- '<<OWNER_CALL>>',
93
- );
87
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
94
88
 
95
- const result = await provider.listSessionNamesWithUnansweredOwnerCall([
96
- sessionName,
97
- ]);
89
+ const result = await provider.listSessionNamesWithUnansweredOwnerCall(
90
+ new Map([[sessionName, transcriptPath]]),
91
+ );
98
92
 
99
93
  expect(result.has(sessionName)).toBe(false);
100
94
  });
101
95
 
102
96
  it('compares full datetimes rather than only the time of day', async () => {
103
- const sessionName = 'https_//github_com/owner/repo/issues/9';
104
- writeTranscript(sessionName, [
97
+ const transcriptPath = writeTranscript('workbench.jsonl', [
105
98
  assistantWithMarker('2026-06-27T23:00:00.000Z'),
106
99
  ownerReply('2026-06-28T01:00:00.000Z'),
107
100
  ]);
108
- const provider = new TranscriptOwnerCallStatusProvider(
109
- rootDirectory,
110
- '<<OWNER_CALL>>',
111
- );
101
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
112
102
 
113
- const result = await provider.listSessionNamesWithUnansweredOwnerCall([
114
- sessionName,
115
- ]);
103
+ const result = await provider.listSessionNamesWithUnansweredOwnerCall(
104
+ new Map([[sessionName, transcriptPath]]),
105
+ );
116
106
 
117
107
  expect(result.has(sessionName)).toBe(false);
118
108
  });
119
109
 
120
110
  it('treats tool results as not being owner replies', async () => {
121
- const sessionName = 'https_//github_com/owner/repo/issues/9';
122
- writeTranscript(sessionName, [
111
+ const transcriptPath = writeTranscript('workbench.jsonl', [
123
112
  assistantWithMarker('2026-06-27T10:00:00.000Z'),
124
113
  toolResult('2026-06-27T10:05:00.000Z'),
125
114
  ]);
126
- const provider = new TranscriptOwnerCallStatusProvider(
127
- rootDirectory,
128
- '<<OWNER_CALL>>',
129
- );
115
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
130
116
 
131
- const result = await provider.listSessionNamesWithUnansweredOwnerCall([
132
- sessionName,
133
- ]);
117
+ const result = await provider.listSessionNamesWithUnansweredOwnerCall(
118
+ new Map([[sessionName, transcriptPath]]),
119
+ );
134
120
 
135
121
  expect(result.has(sessionName)).toBe(true);
136
122
  });
137
123
 
138
124
  it('does not report a session that never raised an owner call', async () => {
139
- const sessionName = 'https_//github_com/owner/repo/issues/9';
140
- writeTranscript(sessionName, [
125
+ const transcriptPath = writeTranscript('workbench.jsonl', [
141
126
  assistantPlain('2026-06-27T10:00:00.000Z'),
142
127
  ownerReply('2026-06-27T10:05:00.000Z'),
143
128
  ]);
144
- const provider = new TranscriptOwnerCallStatusProvider(
145
- rootDirectory,
146
- '<<OWNER_CALL>>',
147
- );
129
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
148
130
 
149
- const result = await provider.listSessionNamesWithUnansweredOwnerCall([
150
- sessionName,
151
- ]);
152
-
153
- expect(result.has(sessionName)).toBe(false);
154
- });
155
-
156
- it('returns an empty set when the root directory is null', async () => {
157
- const provider = new TranscriptOwnerCallStatusProvider(
158
- null,
159
- '<<OWNER_CALL>>',
131
+ const result = await provider.listSessionNamesWithUnansweredOwnerCall(
132
+ new Map([[sessionName, transcriptPath]]),
160
133
  );
161
134
 
162
- const result = await provider.listSessionNamesWithUnansweredOwnerCall([
163
- 'https_//github_com/owner/repo/issues/9',
164
- ]);
165
-
166
- expect(result.size).toBe(0);
135
+ expect(result.has(sessionName)).toBe(false);
167
136
  });
168
137
 
169
138
  it('returns an empty set when the marker is null', async () => {
170
- const sessionName = 'https_//github_com/owner/repo/issues/9';
171
- writeTranscript(sessionName, [
139
+ const transcriptPath = writeTranscript('workbench.jsonl', [
172
140
  ownerReply('2026-06-27T10:00:00.000Z'),
173
141
  assistantWithMarker('2026-06-27T10:05:00.000Z'),
174
142
  ]);
175
- const provider = new TranscriptOwnerCallStatusProvider(rootDirectory, null);
143
+ const provider = new TranscriptOwnerCallStatusProvider(null);
176
144
 
177
- const result = await provider.listSessionNamesWithUnansweredOwnerCall([
178
- sessionName,
179
- ]);
145
+ const result = await provider.listSessionNamesWithUnansweredOwnerCall(
146
+ new Map([[sessionName, transcriptPath]]),
147
+ );
180
148
 
181
149
  expect(result.size).toBe(0);
182
150
  });
183
151
 
184
152
  it('ignores a session whose transcript file is missing', async () => {
185
- const provider = new TranscriptOwnerCallStatusProvider(
186
- rootDirectory,
187
- '<<OWNER_CALL>>',
188
- );
153
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
189
154
 
190
- const result = await provider.listSessionNamesWithUnansweredOwnerCall([
191
- 'https_//github_com/owner/repo/issues/9',
192
- ]);
155
+ const result = await provider.listSessionNamesWithUnansweredOwnerCall(
156
+ new Map([[sessionName, path.join(rootDirectory, 'missing.jsonl')]]),
157
+ );
193
158
 
194
159
  expect(result.size).toBe(0);
195
160
  });
196
161
 
197
162
  it('ignores malformed transcript lines', async () => {
198
- const sessionName = 'https_//github_com/owner/repo/issues/9';
199
- const fileName = `${sessionName.replace(/\//g, '_')}.jsonl`;
200
- const filePath = path.join(rootDirectory, fileName);
163
+ const filePath = path.join(rootDirectory, 'workbench.jsonl');
201
164
  fs.writeFileSync(
202
165
  filePath,
203
166
  [
@@ -206,14 +169,31 @@ describe('TranscriptOwnerCallStatusProvider', () => {
206
169
  ].join('\n'),
207
170
  'utf8',
208
171
  );
209
- const provider = new TranscriptOwnerCallStatusProvider(
210
- rootDirectory,
211
- '<<OWNER_CALL>>',
172
+ const provider = new TranscriptOwnerCallStatusProvider('<<OWNER_CALL>>');
173
+
174
+ const result = await provider.listSessionNamesWithUnansweredOwnerCall(
175
+ new Map([[sessionName, filePath]]),
212
176
  );
213
177
 
214
- const result = await provider.listSessionNamesWithUnansweredOwnerCall([
215
- sessionName,
178
+ expect(result.has(sessionName)).toBe(true);
179
+ });
180
+
181
+ it('uses the canonical call-to-user marker string', async () => {
182
+ const transcriptPath = writeTranscript('workbench.jsonl', [
183
+ {
184
+ type: 'assistant',
185
+ timestamp: '2026-06-27T10:05:00.000Z',
186
+ message: {
187
+ role: 'assistant',
188
+ content: [{ type: 'text', text: 'Please decide <call-to-user>' }],
189
+ },
190
+ },
216
191
  ]);
192
+ const provider = new TranscriptOwnerCallStatusProvider('<call-to-user>');
193
+
194
+ const result = await provider.listSessionNamesWithUnansweredOwnerCall(
195
+ new Map([[sessionName, transcriptPath]]),
196
+ );
217
197
 
218
198
  expect(result.has(sessionName)).toBe(true);
219
199
  });
@@ -1,5 +1,4 @@
1
1
  import * as fs from 'fs';
2
- import * as path from 'path';
3
2
  import { OwnerCallStatusProvider } from '../../domain/usecases/adapter-interfaces/OwnerCallStatusProvider';
4
3
 
5
4
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -51,24 +50,18 @@ const hasOwnerTextReply = (content: unknown): boolean => {
51
50
  };
52
51
 
53
52
  export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvider {
54
- constructor(
55
- private readonly rootDirectory: string | null,
56
- private readonly ownerCallMarker: string | null,
57
- ) {}
53
+ constructor(private readonly ownerCallMarker: string | null) {}
58
54
 
59
55
  listSessionNamesWithUnansweredOwnerCall = async (
60
- sessionNames: string[],
56
+ transcriptPathBySessionName: Map<string, string>,
61
57
  ): Promise<Set<string>> => {
62
58
  const waiting = new Set<string>();
63
- if (
64
- this.rootDirectory === null ||
65
- this.ownerCallMarker === null ||
66
- this.ownerCallMarker.length === 0
67
- ) {
59
+ if (this.ownerCallMarker === null || this.ownerCallMarker.length === 0) {
68
60
  return waiting;
69
61
  }
70
- for (const sessionName of sessionNames) {
71
- if (this.isWaitingForOwnerReply(sessionName, this.ownerCallMarker)) {
62
+ const marker = this.ownerCallMarker;
63
+ for (const [sessionName, transcriptPath] of transcriptPathBySessionName) {
64
+ if (this.isWaitingForOwnerReply(transcriptPath, marker)) {
72
65
  waiting.add(sessionName);
73
66
  }
74
67
  }
@@ -76,19 +69,12 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
76
69
  };
77
70
 
78
71
  private isWaitingForOwnerReply = (
79
- sessionName: string,
72
+ transcriptPath: string,
80
73
  marker: string,
81
74
  ): boolean => {
82
- if (this.rootDirectory === null) {
83
- return false;
84
- }
85
- const filePath = path.join(
86
- this.rootDirectory,
87
- this.toTranscriptFileName(sessionName),
88
- );
89
75
  let content: string;
90
76
  try {
91
- content = fs.readFileSync(filePath, 'utf8');
77
+ content = fs.readFileSync(transcriptPath, 'utf8');
92
78
  } catch {
93
79
  return false;
94
80
  }
@@ -133,7 +119,4 @@ export class TranscriptOwnerCallStatusProvider implements OwnerCallStatusProvide
133
119
  lastOwnerCallEpochMs > lastOwnerReplyEpochMs
134
120
  );
135
121
  };
136
-
137
- private toTranscriptFileName = (sessionName: string): string =>
138
- `${sessionName.replace(/\//g, '_')}.jsonl`;
139
122
  }
@@ -0,0 +1,5 @@
1
+ export type InteractiveLiveSession = {
2
+ sessionName: string;
3
+ sessionId: string;
4
+ configDir: string;
5
+ };
@@ -0,0 +1,17 @@
1
+ export type LiveSessionPaneProcess = {
2
+ sessionName: string;
3
+ panePids: number[];
4
+ };
5
+
6
+ export type LiveSessionProcessInfo = {
7
+ pid: number;
8
+ ppid: number;
9
+ commandLine: string;
10
+ sessionId: string | null;
11
+ configDir: string | null;
12
+ };
13
+
14
+ export type LiveSessionProcessSnapshot = {
15
+ sessions: LiveSessionPaneProcess[];
16
+ processes: LiveSessionProcessInfo[];
17
+ };