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
@@ -1,18 +1,15 @@
1
- import { Issue } from '../entities/Issue';
2
1
  import { LiveSessionActivitySnapshot } from '../entities/LiveSessionActivitySnapshot';
3
- import { Project } from '../entities/Project';
4
- import { IN_TMUX_STATUS_NAME } from '../entities/WorkflowStatus';
5
- import { IssueRepository } from './adapter-interfaces/IssueRepository';
2
+ import { InteractiveLiveSession } from '../entities/InteractiveLiveSession';
3
+ import { LiveSessionProcessSnapshotProvider } from './adapter-interfaces/LiveSessionProcessSnapshotProvider';
4
+ import { InteractiveLiveSessionTranscriptResolver } from './adapter-interfaces/InteractiveLiveSessionTranscriptResolver';
6
5
  import { OwnerCallStatusProvider } from './adapter-interfaces/OwnerCallStatusProvider';
7
6
  import { SessionOutputActivityRepository } from './adapter-interfaces/SessionOutputActivityRepository';
8
7
  import { SessionSubAgentActivityRepository } from './adapter-interfaces/SessionSubAgentActivityRepository';
9
8
  import { SilentSessionMessageComposer } from './adapter-interfaces/SilentSessionMessageComposer';
10
9
  import { SilentSessionNotificationRepository } from './adapter-interfaces/SilentSessionNotificationRepository';
11
10
  import { Sleeper } from './adapter-interfaces/Sleeper';
12
- import { TmuxSessionRepository } from './adapter-interfaces/TmuxSessionRepository';
13
- import { toTmuxSessionName } from './intmux/InTmuxByHumanSessionReconcileUseCase';
11
+ import { ResolveInteractiveLiveSessionsUseCase } from './ResolveInteractiveLiveSessionsUseCase';
14
12
 
15
- export const DEFAULT_MONITORED_STATUS = IN_TMUX_STATUS_NAME;
16
13
  export const DEFAULT_MAIN_SILENT_THRESHOLD_SECONDS = 10 * 60;
17
14
  export const DEFAULT_SUBAGENT_SILENT_THRESHOLD_SECONDS = 5 * 60;
18
15
  export const DEFAULT_SUBAGENT_RUNNING_THRESHOLD_SECONDS = 15 * 60;
@@ -25,12 +22,12 @@ type NotifyCandidate = {
25
22
  };
26
23
 
27
24
  export class NotifySilentLiveSessionsUseCase {
25
+ private readonly resolveInteractiveLiveSessions =
26
+ new ResolveInteractiveLiveSessionsUseCase();
27
+
28
28
  constructor(
29
- private readonly issueRepository: Pick<IssueRepository, 'getAllOpened'>,
30
- private readonly tmuxSessionRepository: Pick<
31
- TmuxSessionRepository,
32
- 'listLiveSessionNames'
33
- >,
29
+ private readonly liveSessionProcessSnapshotProvider: LiveSessionProcessSnapshotProvider,
30
+ private readonly interactiveLiveSessionTranscriptResolver: InteractiveLiveSessionTranscriptResolver,
34
31
  private readonly sessionOutputActivityRepository: SessionOutputActivityRepository,
35
32
  private readonly subAgentActivityRepository: SessionSubAgentActivityRepository,
36
33
  private readonly ownerCallStatusProvider: OwnerCallStatusProvider,
@@ -40,9 +37,6 @@ export class NotifySilentLiveSessionsUseCase {
40
37
  ) {}
41
38
 
42
39
  run = async (params: {
43
- project: Project;
44
- allowCacheMinutes: number;
45
- monitoredStatus: string;
46
40
  mainSilentThresholdSeconds: number;
47
41
  subAgentSilentThresholdSeconds: number;
48
42
  subAgentRunningThresholdSeconds: number;
@@ -50,29 +44,26 @@ export class NotifySilentLiveSessionsUseCase {
50
44
  staggerSeconds: number;
51
45
  now: Date;
52
46
  }): Promise<void> => {
53
- const liveSessionNames = new Set(
54
- await this.tmuxSessionRepository.listLiveSessionNames(),
55
- );
56
- const openIssues = await this.issueRepository.getAllOpened(
57
- params.project,
58
- params.allowCacheMinutes,
59
- );
60
- const monitoredSessionNames = this.selectMonitoredSessionNames(
61
- openIssues,
62
- liveSessionNames,
63
- params.monitoredStatus,
64
- );
47
+ const snapshot =
48
+ await this.liveSessionProcessSnapshotProvider.getSnapshot();
49
+ const interactiveSessions =
50
+ this.resolveInteractiveLiveSessions.resolve(snapshot);
51
+ const transcriptPathBySessionName =
52
+ this.interactiveLiveSessionTranscriptResolver.resolveTranscriptPaths(
53
+ interactiveSessions,
54
+ );
65
55
 
66
56
  const snapshots = await this.collectSnapshots(
67
- monitoredSessionNames,
57
+ interactiveSessions,
58
+ transcriptPathBySessionName,
68
59
  params.now,
69
60
  );
70
61
 
71
62
  const candidates: NotifyCandidate[] = [];
72
- for (const snapshot of snapshots) {
73
- const message = this.composeMessage(snapshot, params);
63
+ for (const sessionSnapshot of snapshots) {
64
+ const message = this.composeMessage(sessionSnapshot, params);
74
65
  if (message !== null) {
75
- candidates.push({ sessionName: snapshot.sessionName, message });
66
+ candidates.push({ sessionName: sessionSnapshot.sessionName, message });
76
67
  }
77
68
  }
78
69
  candidates.sort((left, right) =>
@@ -84,7 +75,7 @@ export class NotifySilentLiveSessionsUseCase {
84
75
  );
85
76
 
86
77
  console.log(
87
- `Silent live session notification: ${candidates.length} candidate(s) of ${monitoredSessionNames.length} monitored session(s).`,
78
+ `Silent live session notification: ${candidates.length} candidate(s) of ${interactiveSessions.length} interactive session(s).`,
88
79
  );
89
80
 
90
81
  const nowEpochSeconds = Math.floor(params.now.getTime() / 1000);
@@ -122,12 +113,17 @@ export class NotifySilentLiveSessionsUseCase {
122
113
  };
123
114
 
124
115
  private collectSnapshots = async (
125
- monitoredSessionNames: string[],
116
+ interactiveSessions: InteractiveLiveSession[],
117
+ transcriptPathBySessionName: Map<string, string>,
126
118
  now: Date,
127
119
  ): Promise<LiveSessionActivitySnapshot[]> => {
120
+ const sessionNames = interactiveSessions.map(
121
+ (session) => session.sessionName,
122
+ );
123
+
128
124
  const activities =
129
125
  await this.sessionOutputActivityRepository.listSessionOutputActivities(
130
- monitoredSessionNames,
126
+ transcriptPathBySessionName,
131
127
  );
132
128
  const lastOutputBySessionName = new Map<string, number>();
133
129
  for (const activity of activities) {
@@ -139,16 +135,16 @@ export class NotifySilentLiveSessionsUseCase {
139
135
 
140
136
  const subAgentsBySessionName =
141
137
  await this.subAgentActivityRepository.listSubAgentActivitiesBySessionName(
142
- monitoredSessionNames,
138
+ sessionNames,
143
139
  );
144
140
 
145
141
  const sessionNamesWithUnansweredOwnerCall =
146
142
  await this.ownerCallStatusProvider.listSessionNamesWithUnansweredOwnerCall(
147
- monitoredSessionNames,
143
+ transcriptPathBySessionName,
148
144
  );
149
145
 
150
146
  const nowEpochSeconds = Math.floor(now.getTime() / 1000);
151
- return monitoredSessionNames.map((sessionName) => {
147
+ return sessionNames.map((sessionName) => {
152
148
  const lastOutputEpochSeconds = lastOutputBySessionName.get(sessionName);
153
149
  const mainSilentSeconds =
154
150
  lastOutputEpochSeconds === undefined
@@ -202,22 +198,4 @@ export class NotifySilentLiveSessionsUseCase {
202
198
  }
203
199
  return sections.join('\n\n');
204
200
  };
205
-
206
- private selectMonitoredSessionNames = (
207
- openIssues: Issue[],
208
- liveSessionNames: Set<string>,
209
- monitoredStatus: string,
210
- ): string[] => {
211
- const monitoredSessionNames: string[] = [];
212
- for (const issue of openIssues) {
213
- if (issue.status !== monitoredStatus) {
214
- continue;
215
- }
216
- const sessionName = toTmuxSessionName(issue.url);
217
- if (liveSessionNames.has(sessionName)) {
218
- monitoredSessionNames.push(sessionName);
219
- }
220
- }
221
- return monitoredSessionNames;
222
- };
223
201
  }
@@ -0,0 +1,220 @@
1
+ import { ResolveInteractiveLiveSessionsUseCase } from './ResolveInteractiveLiveSessionsUseCase';
2
+ import { LiveSessionProcessSnapshot } from '../entities/LiveSessionProcessSnapshot';
3
+
4
+ describe('ResolveInteractiveLiveSessionsUseCase', () => {
5
+ const useCase = new ResolveInteractiveLiveSessionsUseCase();
6
+
7
+ it('resolves an issue-url-named session through its pane child process', () => {
8
+ const snapshot: LiveSessionProcessSnapshot = {
9
+ sessions: [
10
+ {
11
+ sessionName: 'https_//github_com/owner/repo/issues/9',
12
+ panePids: [100],
13
+ },
14
+ ],
15
+ processes: [
16
+ {
17
+ pid: 100,
18
+ ppid: 1,
19
+ commandLine: 'tmux pane shell',
20
+ sessionId: null,
21
+ configDir: null,
22
+ },
23
+ {
24
+ pid: 101,
25
+ ppid: 100,
26
+ commandLine: 'claude --model opus --resume abc',
27
+ sessionId: 'abc',
28
+ configDir: '/config/issues-9',
29
+ },
30
+ ],
31
+ };
32
+
33
+ const result = useCase.resolve(snapshot);
34
+
35
+ expect(result).toEqual([
36
+ {
37
+ sessionName: 'https_//github_com/owner/repo/issues/9',
38
+ sessionId: 'abc',
39
+ configDir: '/config/issues-9',
40
+ },
41
+ ]);
42
+ });
43
+
44
+ it('resolves a plain-named session such as workbench', () => {
45
+ const snapshot: LiveSessionProcessSnapshot = {
46
+ sessions: [{ sessionName: 'workbench', panePids: [200] }],
47
+ processes: [
48
+ {
49
+ pid: 200,
50
+ ppid: 1,
51
+ commandLine: 'shell',
52
+ sessionId: null,
53
+ configDir: null,
54
+ },
55
+ {
56
+ pid: 201,
57
+ ppid: 200,
58
+ commandLine: 'claude --model opus --name workbench',
59
+ sessionId: 'wb-uuid',
60
+ configDir: '/config/workbench',
61
+ },
62
+ ],
63
+ };
64
+
65
+ const result = useCase.resolve(snapshot);
66
+
67
+ expect(result).toEqual([
68
+ {
69
+ sessionName: 'workbench',
70
+ sessionId: 'wb-uuid',
71
+ configDir: '/config/workbench',
72
+ },
73
+ ]);
74
+ });
75
+
76
+ it('finds the interactive process several levels below the pane', () => {
77
+ const snapshot: LiveSessionProcessSnapshot = {
78
+ sessions: [{ sessionName: 'control-room', panePids: [300] }],
79
+ 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
+ {
95
+ pid: 302,
96
+ ppid: 301,
97
+ commandLine: 'claude --name control-room',
98
+ sessionId: 'cr-uuid',
99
+ configDir: '/config/control-room',
100
+ },
101
+ ],
102
+ };
103
+
104
+ const result = useCase.resolve(snapshot);
105
+
106
+ expect(result).toEqual([
107
+ {
108
+ sessionName: 'control-room',
109
+ sessionId: 'cr-uuid',
110
+ configDir: '/config/control-room',
111
+ },
112
+ ]);
113
+ });
114
+
115
+ it('excludes a session whose only claude process is an owner-handover spawn', () => {
116
+ const snapshot: LiveSessionProcessSnapshot = {
117
+ sessions: [{ sessionName: 'aw-host', panePids: [400] }],
118
+ processes: [
119
+ {
120
+ pid: 400,
121
+ ppid: 1,
122
+ commandLine: 'shell',
123
+ sessionId: null,
124
+ configDir: null,
125
+ },
126
+ {
127
+ pid: 401,
128
+ ppid: 400,
129
+ commandLine:
130
+ 'claude --verbose -p Take ownership of https://example.com/issues/1 and finish it',
131
+ sessionId: 'aw-uuid',
132
+ configDir: '/config/aw',
133
+ },
134
+ ],
135
+ };
136
+
137
+ const result = useCase.resolve(snapshot);
138
+
139
+ expect(result).toEqual([]);
140
+ });
141
+
142
+ it('skips a session whose interactive process exposes no session id or config dir', () => {
143
+ const snapshot: LiveSessionProcessSnapshot = {
144
+ sessions: [{ sessionName: 'partial', panePids: [500] }],
145
+ processes: [
146
+ {
147
+ pid: 501,
148
+ ppid: 500,
149
+ commandLine: 'claude --model opus',
150
+ sessionId: null,
151
+ configDir: '/config/partial',
152
+ },
153
+ ],
154
+ };
155
+
156
+ const result = useCase.resolve(snapshot);
157
+
158
+ expect(result).toEqual([]);
159
+ });
160
+
161
+ it('ignores non-claude descendants such as monitors', () => {
162
+ const snapshot: LiveSessionProcessSnapshot = {
163
+ sessions: [{ sessionName: 'monitored', panePids: [600] }],
164
+ processes: [
165
+ {
166
+ pid: 601,
167
+ ppid: 600,
168
+ commandLine: 'node monitor.js',
169
+ sessionId: 'mon-uuid',
170
+ configDir: '/config/monitor',
171
+ },
172
+ ],
173
+ };
174
+
175
+ const result = useCase.resolve(snapshot);
176
+
177
+ expect(result).toEqual([]);
178
+ });
179
+
180
+ it('resolves multiple sessions and drops the ones without an interactive process', () => {
181
+ const snapshot: LiveSessionProcessSnapshot = {
182
+ sessions: [
183
+ { sessionName: 'workbench', panePids: [700] },
184
+ { sessionName: 'empty', panePids: [800] },
185
+ ],
186
+ processes: [
187
+ {
188
+ pid: 701,
189
+ ppid: 700,
190
+ commandLine: 'claude --name workbench',
191
+ sessionId: 'wb-uuid',
192
+ configDir: '/config/workbench',
193
+ },
194
+ {
195
+ pid: 801,
196
+ ppid: 800,
197
+ commandLine: 'bash idle',
198
+ sessionId: null,
199
+ configDir: null,
200
+ },
201
+ ],
202
+ };
203
+
204
+ const result = useCase.resolve(snapshot);
205
+
206
+ expect(result).toEqual([
207
+ {
208
+ sessionName: 'workbench',
209
+ sessionId: 'wb-uuid',
210
+ configDir: '/config/workbench',
211
+ },
212
+ ]);
213
+ });
214
+
215
+ it('returns an empty list when there are no sessions', () => {
216
+ const result = useCase.resolve({ sessions: [], processes: [] });
217
+
218
+ expect(result).toEqual([]);
219
+ });
220
+ });
@@ -0,0 +1,105 @@
1
+ import { InteractiveLiveSession } from '../entities/InteractiveLiveSession';
2
+ import {
3
+ LiveSessionProcessInfo,
4
+ LiveSessionProcessSnapshot,
5
+ } from '../entities/LiveSessionProcessSnapshot';
6
+
7
+ // Command-line substring that identifies an "owner-handover" agent spawn (for
8
+ // example a background implementation agent started with a prompt beginning
9
+ // "Take ownership of ..."). These spawns are not interactive owner sessions and
10
+ // MUST NOT be notified, so any process whose command line contains this marker
11
+ // is excluded even if it somehow appears under a monitored pane.
12
+ export const OWNER_HANDOVER_COMMAND_MARKER = 'Take ownership of';
13
+
14
+ // Command-name substring that identifies the interactive Claude Code process.
15
+ const INTERACTIVE_PROCESS_COMMAND_MARKER = 'claude';
16
+
17
+ const isInteractiveCandidate = (process: LiveSessionProcessInfo): boolean => {
18
+ if (process.commandLine.includes(OWNER_HANDOVER_COMMAND_MARKER)) {
19
+ return false;
20
+ }
21
+ if (!process.commandLine.includes(INTERACTIVE_PROCESS_COMMAND_MARKER)) {
22
+ return false;
23
+ }
24
+ return process.sessionId !== null && process.configDir !== null;
25
+ };
26
+
27
+ /**
28
+ * Selects the interactive Claude Code sessions that are eligible for the silent
29
+ * self-check notification, independently of session naming or any GitHub issue
30
+ * linkage.
31
+ *
32
+ * Selection works purely from a process snapshot: for each live tmux session,
33
+ * the descendants of its pane processes are walked, and the first descendant
34
+ * that looks like an interactive Claude Code process (command line contains
35
+ * `claude`, does not contain the owner-handover marker, and exposes both a
36
+ * session id and a config directory in its environment) is taken as the
37
+ * session's interactive process. Because the walk is anchored on tmux panes,
38
+ * background owner-handover spawns, the preparation daemon, and monitor
39
+ * processes — none of which run inside an interactive tmux pane — are naturally
40
+ * excluded; the explicit owner-handover marker check is an additional guard.
41
+ */
42
+ export class ResolveInteractiveLiveSessionsUseCase {
43
+ resolve = (
44
+ snapshot: LiveSessionProcessSnapshot,
45
+ ): InteractiveLiveSession[] => {
46
+ const processByPid = new Map<number, LiveSessionProcessInfo>();
47
+ const childrenByPpid = new Map<number, LiveSessionProcessInfo[]>();
48
+ for (const process of snapshot.processes) {
49
+ processByPid.set(process.pid, process);
50
+ const siblings = childrenByPpid.get(process.ppid) ?? [];
51
+ siblings.push(process);
52
+ childrenByPpid.set(process.ppid, siblings);
53
+ }
54
+
55
+ const sessions: InteractiveLiveSession[] = [];
56
+ for (const session of snapshot.sessions) {
57
+ const interactiveProcess = this.findInteractiveProcess(
58
+ session.panePids,
59
+ processByPid,
60
+ childrenByPpid,
61
+ );
62
+ if (
63
+ interactiveProcess === null ||
64
+ interactiveProcess.sessionId === null ||
65
+ interactiveProcess.configDir === null
66
+ ) {
67
+ continue;
68
+ }
69
+ sessions.push({
70
+ sessionName: session.sessionName,
71
+ sessionId: interactiveProcess.sessionId,
72
+ configDir: interactiveProcess.configDir,
73
+ });
74
+ }
75
+ return sessions;
76
+ };
77
+
78
+ private findInteractiveProcess = (
79
+ panePids: number[],
80
+ processByPid: Map<number, LiveSessionProcessInfo>,
81
+ childrenByPpid: Map<number, LiveSessionProcessInfo[]>,
82
+ ): LiveSessionProcessInfo | null => {
83
+ const visited = new Set<number>();
84
+ const queue: number[] = [...panePids];
85
+ let head = 0;
86
+ while (head < queue.length) {
87
+ const pid = queue[head];
88
+ head += 1;
89
+ if (visited.has(pid)) {
90
+ continue;
91
+ }
92
+ visited.add(pid);
93
+ const process = processByPid.get(pid);
94
+ if (process !== undefined && isInteractiveCandidate(process)) {
95
+ return process;
96
+ }
97
+ for (const child of childrenByPpid.get(pid) ?? []) {
98
+ if (!visited.has(child.pid)) {
99
+ queue.push(child.pid);
100
+ }
101
+ }
102
+ }
103
+ return null;
104
+ };
105
+ }
@@ -0,0 +1,7 @@
1
+ import { InteractiveLiveSession } from '../../entities/InteractiveLiveSession';
2
+
3
+ export interface InteractiveLiveSessionTranscriptResolver {
4
+ resolveTranscriptPaths: (
5
+ sessions: InteractiveLiveSession[],
6
+ ) => Map<string, string>;
7
+ }
@@ -0,0 +1,5 @@
1
+ import { LiveSessionProcessSnapshot } from '../../entities/LiveSessionProcessSnapshot';
2
+
3
+ export interface LiveSessionProcessSnapshotProvider {
4
+ getSnapshot: () => Promise<LiveSessionProcessSnapshot>;
5
+ }
@@ -1,5 +1,5 @@
1
1
  export interface OwnerCallStatusProvider {
2
2
  listSessionNamesWithUnansweredOwnerCall: (
3
- sessionNames: string[],
3
+ transcriptPathBySessionName: Map<string, string>,
4
4
  ) => Promise<Set<string>>;
5
5
  }
@@ -0,0 +1,3 @@
1
+ export interface ProcessEnvironReader {
2
+ readEnviron: (pid: number) => Record<string, string> | null;
3
+ }
@@ -2,6 +2,6 @@ import { LiveSessionOutputActivity } from '../../entities/LiveSessionOutputActiv
2
2
 
3
3
  export interface SessionOutputActivityRepository {
4
4
  listSessionOutputActivities: (
5
- sessionNames: string[],
5
+ transcriptPathBySessionName: Map<string, string>,
6
6
  ) => Promise<LiveSessionOutputActivity[]>;
7
7
  }
@@ -1 +1 @@
1
- {"version":3,"file":"HandleScheduledEventUseCaseHandler.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts"],"names":[],"mappings":"AAkCA,OAAO,EAAE,KAAK,EAAE,MAAM,gCAAgC,CAAC;AACvD,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAiD3D,qBAAa,kCAAkC;IAC7C,MAAM,GACJ,gBAAgB,MAAM,EACtB,UAAU,OAAO,KAChB,OAAO,CAAC;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,MAAM,EAAE,KAAK,EAAE,CAAC;QAChB,SAAS,EAAE,OAAO,CAAC;QACnB,eAAe,EAAE,IAAI,EAAE,CAAC;KACzB,GAAG,IAAI,CAAC,CA8iBP;CACH"}
1
+ {"version":3,"file":"HandleScheduledEventUseCaseHandler.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts"],"names":[],"mappings":"AAkCA,OAAO,EAAE,KAAK,EAAE,MAAM,gCAAgC,CAAC;AACvD,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAiD3D,qBAAa,kCAAkC;IAC7C,MAAM,GACJ,gBAAgB,MAAM,EACtB,UAAU,OAAO,KAChB,OAAO,CAAC;QACT,OAAO,EAAE,OAAO,CAAC;QACjB,MAAM,EAAE,KAAK,EAAE,CAAC;QAChB,SAAS,EAAE,OAAO,CAAC;QACnB,eAAe,EAAE,IAAI,EAAE,CAAC;KACzB,GAAG,IAAI,CAAC,CAoiBP;CACH"}
@@ -1,16 +1,12 @@
1
- import { Project } from '../../../domain/entities/Project';
2
1
  import { LocalCommandRunner } from '../../../domain/usecases/adapter-interfaces/LocalCommandRunner';
3
- import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
2
+ import { ProcessEnvironReader } from '../../../domain/usecases/adapter-interfaces/ProcessEnvironReader';
4
3
  import { LocalStorageCacheRepository } from '../../repositories/LocalStorageCacheRepository';
5
4
  import { SilentSessionMessageTemplates } from '../../repositories/ConfigurableSilentSessionMessageComposer';
6
5
  export type NotifySilentTmuxSessionsParams = {
7
- project: Project;
8
- allowCacheMinutes: number;
9
- issueRepository: Pick<IssueRepository, 'getAllOpened'>;
6
+ enabled: boolean;
10
7
  localCommandRunner: LocalCommandRunner;
8
+ processEnvironReader?: ProcessEnvironReader;
11
9
  cacheRepository: Pick<LocalStorageCacheRepository, 'getLatest' | 'set'>;
12
- sessionOutputRootDirectory: string | null;
13
- sessionTranscriptRootDirectory: string | null;
14
10
  ownerCallMarker: string | null;
15
11
  subAgentOutputRootDirectory: string | null;
16
12
  subAgentProcessMatchPattern: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"notifySilentTmuxSessions.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,kCAAkC,CAAC;AAC3D,OAAO,EAAE,kBAAkB,EAAE,MAAM,gEAAgE,CAAC;AACpG,OAAO,EAAE,eAAe,EAAE,MAAM,6DAA6D,CAAC;AAgB9F,OAAO,EAAE,2BAA2B,EAAE,MAAM,gDAAgD,CAAC;AAQ7F,OAAO,EAEL,6BAA6B,EAC9B,MAAM,6DAA6D,CAAC;AAGrE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,OAAO,EAAE,OAAO,CAAC;IACjB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,eAAe,EAAE,IAAI,CAAC,eAAe,EAAE,cAAc,CAAC,CAAC;IACvD,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,eAAe,EAAE,IAAI,CAAC,2BAA2B,EAAE,WAAW,GAAG,KAAK,CAAC,CAAC;IACxE,0BAA0B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,8BAA8B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9C,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,+BAA+B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,0BAA0B,EAAE,MAAM,CAAC;IACnC,8BAA8B,EAAE,MAAM,CAAC;IACvC,+BAA+B,EAAE,MAAM,CAAC;IACxC,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,6BAA6B,CAAC;IAChD,GAAG,EAAE,IAAI,CAAC;CACX,CAAC;AA4CF,eAAO,MAAM,wBAAwB,GACnC,QAAQ,8BAA8B,KACrC,OAAO,CAAC,IAAI,CAoEd,CAAC;AAEF,eAAO,MAAM,0CAA0C;;;;;;CAM7C,CAAC"}
1
+ {"version":3,"file":"notifySilentTmuxSessions.d.ts","sourceRoot":"","sources":["../../../../src/adapter/entry-points/handlers/notifySilentTmuxSessions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,gEAAgE,CAAC;AAGpG,OAAO,EAAE,oBAAoB,EAAE,MAAM,kEAAkE,CAAC;AAexG,OAAO,EAAE,2BAA2B,EAAE,MAAM,gDAAgD,CAAC;AAQ7F,OAAO,EAEL,6BAA6B,EAC9B,MAAM,6DAA6D,CAAC;AAGrE,MAAM,MAAM,8BAA8B,GAAG;IAC3C,OAAO,EAAE,OAAO,CAAC;IACjB,kBAAkB,EAAE,kBAAkB,CAAC;IACvC,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,eAAe,EAAE,IAAI,CAAC,2BAA2B,EAAE,WAAW,GAAG,KAAK,CAAC,CAAC;IACxE,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,2BAA2B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,+BAA+B,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/C,0BAA0B,EAAE,MAAM,CAAC;IACnC,8BAA8B,EAAE,MAAM,CAAC;IACvC,+BAA+B,EAAE,MAAM,CAAC;IACxC,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,6BAA6B,CAAC;IAChD,GAAG,EAAE,IAAI,CAAC;CACX,CAAC;AAoCF,eAAO,MAAM,wBAAwB,GACnC,QAAQ,8BAA8B,KACrC,OAAO,CAAC,IAAI,CA0Dd,CAAC;AAEF,eAAO,MAAM,0CAA0C;;;;;;CAM7C,CAAC"}
@@ -0,0 +1,18 @@
1
+ import { InteractiveLiveSession } from '../../domain/entities/InteractiveLiveSession';
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
+ export declare class FileSystemInteractiveLiveSessionTranscriptResolver implements InteractiveLiveSessionTranscriptResolver {
15
+ resolveTranscriptPaths: (sessions: InteractiveLiveSession[]) => Map<string, string>;
16
+ private resolveTranscriptPath;
17
+ }
18
+ //# sourceMappingURL=FileSystemInteractiveLiveSessionTranscriptResolver.d.ts.map
@@ -0,0 +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,10 +1,14 @@
1
1
  import { LiveSessionOutputActivity } from '../../domain/entities/LiveSessionOutputActivity';
2
2
  import { SessionOutputActivityRepository } from '../../domain/usecases/adapter-interfaces/SessionOutputActivityRepository';
3
+ /**
4
+ * Reads the last main-session output time for each live session from its
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.
9
+ */
3
10
  export declare class FileSystemSessionOutputActivityRepository implements SessionOutputActivityRepository {
4
- private readonly rootDirectory;
5
- constructor(rootDirectory: string | null);
6
- listSessionOutputActivities: (sessionNames: string[]) => Promise<LiveSessionOutputActivity[]>;
7
- private readLastOutputEpochSeconds;
8
- private toOutputFileName;
11
+ listSessionOutputActivities: (transcriptPathBySessionName: Map<string, string>) => Promise<LiveSessionOutputActivity[]>;
12
+ private readLastAssistantOutputEpochSeconds;
9
13
  }
10
14
  //# sourceMappingURL=FileSystemSessionOutputActivityRepository.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"FileSystemSessionOutputActivityRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/FileSystemSessionOutputActivityRepository.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,yBAAyB,EAAE,MAAM,iDAAiD,CAAC;AAC5F,OAAO,EAAE,+BAA+B,EAAE,MAAM,0EAA0E,CAAC;AAE3H,qBAAa,yCAA0C,YAAW,+BAA+B;IACnF,OAAO,CAAC,QAAQ,CAAC,aAAa;gBAAb,aAAa,EAAE,MAAM,GAAG,IAAI;IAEzD,2BAA2B,GACzB,cAAc,MAAM,EAAE,KACrB,OAAO,CAAC,yBAAyB,EAAE,CAAC,CAarC;IAEF,OAAO,CAAC,0BAA0B,CAkBhC;IAEF,OAAO,CAAC,gBAAgB,CACU;CACnC"}
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"}
@@ -0,0 +1,21 @@
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 { LiveSessionProcessSnapshot } from '../../domain/entities/LiveSessionProcessSnapshot';
5
+ /**
6
+ * Builds a live-session process snapshot from the local host: it lists the live
7
+ * tmux sessions and each session's pane process ids, lists the full process
8
+ * tree with command lines, and reads each process environment to expose the
9
+ * Claude Code session id and config directory. The snapshot is consumed by a
10
+ * pure resolver, so all host access is contained in this adapter.
11
+ */
12
+ export declare class LocalProcessLiveSessionProcessSnapshotProvider implements LiveSessionProcessSnapshotProvider {
13
+ private readonly localCommandRunner;
14
+ private readonly environReader;
15
+ constructor(localCommandRunner: LocalCommandRunner, environReader: ProcessEnvironReader);
16
+ getSnapshot: () => Promise<LiveSessionProcessSnapshot>;
17
+ private listSessionNames;
18
+ private listPanePids;
19
+ private listProcesses;
20
+ }
21
+ //# sourceMappingURL=LocalProcessLiveSessionProcessSnapshotProvider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LocalProcessLiveSessionProcessSnapshotProvider.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/LocalProcessLiveSessionProcessSnapshotProvider.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,6DAA6D,CAAC;AACjG,OAAO,EAAE,kCAAkC,EAAE,MAAM,6EAA6E,CAAC;AACjI,OAAO,EAAE,oBAAoB,EAAE,MAAM,+DAA+D,CAAC;AACrG,OAAO,EAGL,0BAA0B,EAC3B,MAAM,kDAAkD,CAAC;AAK1D;;;;;;GAMG;AACH,qBAAa,8CAA+C,YAAW,kCAAkC;IAErG,OAAO,CAAC,QAAQ,CAAC,kBAAkB;IACnC,OAAO,CAAC,QAAQ,CAAC,aAAa;gBADb,kBAAkB,EAAE,kBAAkB,EACtC,aAAa,EAAE,oBAAoB;IAGtD,WAAW,QAAa,OAAO,CAAC,0BAA0B,CAAC,CASzD;IAEF,OAAO,CAAC,gBAAgB,CAYtB;IAEF,OAAO,CAAC,YAAY,CAYlB;IAEF,OAAO,CAAC,aAAa,CA2BnB;CACH"}
@@ -1,5 +1,5 @@
1
1
  import { OwnerCallStatusProvider } from '../../domain/usecases/adapter-interfaces/OwnerCallStatusProvider';
2
2
  export declare class NoUnansweredOwnerCallStatusProvider implements OwnerCallStatusProvider {
3
- listSessionNamesWithUnansweredOwnerCall: (_sessionNames: string[]) => Promise<Set<string>>;
3
+ listSessionNamesWithUnansweredOwnerCall: (_transcriptPathBySessionName: Map<string, string>) => Promise<Set<string>>;
4
4
  }
5
5
  //# sourceMappingURL=NoUnansweredOwnerCallStatusProvider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"NoUnansweredOwnerCallStatusProvider.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,kEAAkE,CAAC;AAE3G,qBAAa,mCAAoC,YAAW,uBAAuB;IACjF,uCAAuC,GACrC,eAAe,MAAM,EAAE,KACtB,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAErB;CACH"}
1
+ {"version":3,"file":"NoUnansweredOwnerCallStatusProvider.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/NoUnansweredOwnerCallStatusProvider.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,kEAAkE,CAAC;AAE3G,qBAAa,mCAAoC,YAAW,uBAAuB;IACjF,uCAAuC,GACrC,8BAA8B,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,KAChD,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAErB;CACH"}