@sylphx/flow 3.20.0 → 3.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/package.json +1 -1
- package/src/commands/flow/execute-v2.ts +19 -1
- package/src/core/__tests__/cleanup-handler.test.ts +152 -25
- package/src/core/__tests__/session-cleanup.test.ts +329 -35
- package/src/core/backup-manager.ts +99 -9
- package/src/core/cleanup-handler.ts +134 -64
- package/src/core/flow-executor.ts +35 -25
- package/src/core/project-manager.ts +16 -11
- package/src/core/session-manager.ts +223 -147
- package/src/targets/claude-code.ts +8 -23
|
@@ -1,31 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Session Manager
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Session Manager — PID-based ground truth
|
|
3
|
+
*
|
|
4
|
+
* Replaces state-flag session tracking with PID liveness checks.
|
|
5
|
+
* PID liveness (`kill(pid, 0)`) is the single source of truth.
|
|
6
|
+
*
|
|
7
|
+
* Storage structure:
|
|
8
|
+
* ~/.sylphx-flow/sessions/<project-hash>/
|
|
9
|
+
* backup.json — Backup reference (exists = workspace modified)
|
|
10
|
+
* pids/
|
|
11
|
+
* <pid>.json — Per-process lock file
|
|
5
12
|
*/
|
|
6
13
|
|
|
7
14
|
import { existsSync } from 'node:fs';
|
|
8
15
|
import fs from 'node:fs/promises';
|
|
9
16
|
import path from 'node:path';
|
|
10
|
-
import
|
|
17
|
+
import createDebug from 'debug';
|
|
11
18
|
import { readJsonFileSafe } from '../utils/files/file-operations.js';
|
|
12
19
|
import type { ProjectManager } from './project-manager.js';
|
|
13
20
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
const debug = createDebug('flow:session');
|
|
22
|
+
|
|
23
|
+
/** Per-process lock file content */
|
|
24
|
+
export interface PidLock {
|
|
18
25
|
pid: number;
|
|
19
26
|
startTime: string;
|
|
27
|
+
target: string;
|
|
28
|
+
projectPath: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Backup reference — written by first session, deleted after restore */
|
|
32
|
+
export interface BackupRef {
|
|
33
|
+
sessionId: string;
|
|
20
34
|
backupPath: string;
|
|
21
|
-
|
|
35
|
+
projectPath: string;
|
|
22
36
|
target: string;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
isOriginal: boolean; // First session that created backup
|
|
26
|
-
sharedBackupId: string; // Shared backup ID for all sessions
|
|
27
|
-
refCount: number; // Number of active sessions
|
|
28
|
-
activePids: number[]; // All active PIDs sharing this session
|
|
37
|
+
createdAt: string;
|
|
38
|
+
createdByPid: number;
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
export class SessionManager {
|
|
@@ -36,177 +46,262 @@ export class SessionManager {
|
|
|
36
46
|
}
|
|
37
47
|
|
|
38
48
|
/**
|
|
39
|
-
*
|
|
49
|
+
* Acquire a session slot for this process.
|
|
50
|
+
*
|
|
51
|
+
* Uses atomic `mkdir(pidsDir)` (without `recursive`) for first-session detection:
|
|
52
|
+
* - EEXIST → another session already exists (join)
|
|
53
|
+
* - Success → this is the first session (create backup.json)
|
|
40
54
|
*/
|
|
41
|
-
async
|
|
42
|
-
projectPath: string,
|
|
55
|
+
async acquireSession(
|
|
43
56
|
projectHash: string,
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
): Promise<{
|
|
48
|
-
// Get target ID for storage
|
|
49
|
-
const targetId = typeof targetOrId === 'string' ? targetOrId : targetOrId.id;
|
|
57
|
+
projectPath: string,
|
|
58
|
+
target: string,
|
|
59
|
+
backupInfo?: { sessionId: string; backupPath: string }
|
|
60
|
+
): Promise<{ isFirstSession: boolean; backupRef: BackupRef | null }> {
|
|
50
61
|
const paths = this.projectManager.getProjectPaths(projectHash);
|
|
51
62
|
|
|
52
|
-
// Ensure
|
|
53
|
-
await fs.mkdir(
|
|
63
|
+
// Ensure the session directory exists
|
|
64
|
+
await fs.mkdir(paths.sessionDir, { recursive: true });
|
|
54
65
|
|
|
55
|
-
|
|
56
|
-
const existingSession = await this.getActiveSession(projectHash);
|
|
66
|
+
let isFirstSession = false;
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
68
|
+
try {
|
|
69
|
+
// Atomic mkdir — fails with EEXIST if pids/ already exists
|
|
70
|
+
await fs.mkdir(paths.pidsDir);
|
|
71
|
+
isFirstSession = true;
|
|
72
|
+
} catch (error: unknown) {
|
|
73
|
+
if (isErrnoException(error) && error.code === 'EEXIST') {
|
|
74
|
+
// Another session already created pids/ — we're joining
|
|
75
|
+
isFirstSession = false;
|
|
76
|
+
} else {
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
69
79
|
}
|
|
70
80
|
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
-
const session: Session = {
|
|
74
|
-
projectHash,
|
|
75
|
-
projectPath,
|
|
76
|
-
sessionId: newSessionId,
|
|
81
|
+
// Write our PID lock file
|
|
82
|
+
const pidLock: PidLock = {
|
|
77
83
|
pid: process.pid,
|
|
78
84
|
startTime: new Date().toISOString(),
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
target: targetId,
|
|
82
|
-
cleanupRequired: true,
|
|
83
|
-
isOriginal: true,
|
|
84
|
-
sharedBackupId: newSessionId,
|
|
85
|
-
refCount: 1,
|
|
86
|
-
activePids: [process.pid],
|
|
85
|
+
target,
|
|
86
|
+
projectPath,
|
|
87
87
|
};
|
|
88
|
+
await fs.writeFile(
|
|
89
|
+
path.join(paths.pidsDir, `${process.pid}.json`),
|
|
90
|
+
JSON.stringify(pidLock, null, 2)
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
if (isFirstSession && backupInfo) {
|
|
94
|
+
// First session — write backup reference
|
|
95
|
+
const backupRef: BackupRef = {
|
|
96
|
+
sessionId: backupInfo.sessionId,
|
|
97
|
+
backupPath: backupInfo.backupPath,
|
|
98
|
+
projectPath,
|
|
99
|
+
target,
|
|
100
|
+
createdAt: new Date().toISOString(),
|
|
101
|
+
createdByPid: process.pid,
|
|
102
|
+
};
|
|
103
|
+
await fs.writeFile(paths.backupRefFile, JSON.stringify(backupRef, null, 2));
|
|
104
|
+
return { isFirstSession: true, backupRef };
|
|
105
|
+
}
|
|
88
106
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
session,
|
|
93
|
-
isFirstSession: true,
|
|
94
|
-
};
|
|
107
|
+
// Join — read existing backup ref
|
|
108
|
+
const backupRef = await this.getBackupRef(projectHash);
|
|
109
|
+
return { isFirstSession, backupRef };
|
|
95
110
|
}
|
|
96
111
|
|
|
97
112
|
/**
|
|
98
|
-
*
|
|
113
|
+
* Release this process's session slot.
|
|
114
|
+
*
|
|
115
|
+
* Deletes own PID file, scans remaining, checks liveness.
|
|
116
|
+
* Returns `shouldRestore=true` only when no alive PIDs remain.
|
|
117
|
+
*
|
|
118
|
+
* Note: There is a tiny TOCTOU window between delete and scan where two
|
|
119
|
+
* concurrent exits could both see 0 alive PIDs. This is safe because
|
|
120
|
+
* restoreBackup() is idempotent (both restore the same backup), and
|
|
121
|
+
* the window is microseconds. Adding file locking would add complexity
|
|
122
|
+
* disproportionate to the risk.
|
|
99
123
|
*/
|
|
100
|
-
async
|
|
124
|
+
async releaseSession(
|
|
101
125
|
projectHash: string
|
|
102
|
-
): Promise<{ shouldRestore: boolean;
|
|
126
|
+
): Promise<{ shouldRestore: boolean; backupRef: BackupRef | null }> {
|
|
127
|
+
const paths = this.projectManager.getProjectPaths(projectHash);
|
|
128
|
+
|
|
129
|
+
// Delete own PID file
|
|
103
130
|
try {
|
|
104
|
-
|
|
131
|
+
await fs.unlink(path.join(paths.pidsDir, `${process.pid}.json`));
|
|
132
|
+
} catch {
|
|
133
|
+
// PID file might not exist (double-cleanup)
|
|
134
|
+
}
|
|
105
135
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
136
|
+
// Scan remaining PID files
|
|
137
|
+
const alivePids = await this.scanAndCleanPids(paths.pidsDir);
|
|
109
138
|
|
|
110
|
-
|
|
139
|
+
if (alivePids.length > 0) {
|
|
140
|
+
// Other sessions still running
|
|
141
|
+
return { shouldRestore: false, backupRef: null };
|
|
142
|
+
}
|
|
111
143
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
144
|
+
// No alive PIDs — this is the last session
|
|
145
|
+
const backupRef = await this.getBackupRef(projectHash);
|
|
146
|
+
return { shouldRestore: backupRef !== null, backupRef };
|
|
147
|
+
}
|
|
115
148
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
149
|
+
/**
|
|
150
|
+
* Finalize session cleanup — called AFTER restoreBackup() succeeds.
|
|
151
|
+
* Deletes backup.json, pids/, and the session directory.
|
|
152
|
+
*/
|
|
153
|
+
async finalizeSessionCleanup(projectHash: string): Promise<void> {
|
|
154
|
+
const paths = this.projectManager.getProjectPaths(projectHash);
|
|
155
|
+
const flowHome = this.projectManager.getFlowHomeDir();
|
|
120
156
|
|
|
121
|
-
|
|
157
|
+
// Archive backup ref to history before deleting
|
|
158
|
+
const backupRef = await this.getBackupRef(projectHash);
|
|
159
|
+
if (backupRef) {
|
|
160
|
+
const historyPath = path.join(
|
|
161
|
+
flowHome,
|
|
162
|
+
'sessions',
|
|
163
|
+
'history',
|
|
164
|
+
`${backupRef.sessionId}.json`
|
|
165
|
+
);
|
|
166
|
+
await fs.mkdir(path.dirname(historyPath), { recursive: true });
|
|
167
|
+
await fs.writeFile(
|
|
168
|
+
historyPath,
|
|
169
|
+
JSON.stringify({ ...backupRef, status: 'completed', finalizedAt: new Date().toISOString() }, null, 2)
|
|
170
|
+
);
|
|
171
|
+
}
|
|
122
172
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
173
|
+
// Remove entire session directory (backup.json + pids/)
|
|
174
|
+
try {
|
|
175
|
+
await fs.rm(paths.sessionDir, { recursive: true, force: true });
|
|
176
|
+
} catch {
|
|
177
|
+
// Directory might already be gone
|
|
178
|
+
}
|
|
179
|
+
}
|
|
127
180
|
|
|
128
|
-
|
|
129
|
-
|
|
181
|
+
/**
|
|
182
|
+
* Detect orphaned sessions — backup.json exists but no alive PIDs.
|
|
183
|
+
* Scans all session directories, not individual files.
|
|
184
|
+
*/
|
|
185
|
+
async detectOrphanedSessions(): Promise<Map<string, BackupRef>> {
|
|
186
|
+
const orphaned = new Map<string, BackupRef>();
|
|
187
|
+
const projects = await this.projectManager.getActiveProjects();
|
|
130
188
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
189
|
+
for (const { hash } of projects) {
|
|
190
|
+
const paths = this.projectManager.getProjectPaths(hash);
|
|
191
|
+
|
|
192
|
+
// Must have backup.json to be a recoverable session
|
|
193
|
+
const backupRef = await this.getBackupRef(hash);
|
|
194
|
+
if (!backupRef) {
|
|
195
|
+
// No backup.json — clean up stale session directory
|
|
196
|
+
try {
|
|
197
|
+
await fs.rm(paths.sessionDir, { recursive: true, force: true });
|
|
198
|
+
} catch {
|
|
199
|
+
debug('failed to clean stale session dir for %s', hash);
|
|
200
|
+
}
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Check if any PIDs are still alive
|
|
205
|
+
const alivePids = await this.scanAndCleanPids(paths.pidsDir);
|
|
135
206
|
|
|
136
|
-
|
|
207
|
+
if (alivePids.length === 0) {
|
|
208
|
+
// All PIDs dead — orphaned session
|
|
209
|
+
orphaned.set(hash, backupRef);
|
|
137
210
|
}
|
|
138
|
-
|
|
139
|
-
// Session file might not exist
|
|
140
|
-
return { shouldRestore: false, session: null };
|
|
211
|
+
// If alive PIDs exist, session is active — leave it alone
|
|
141
212
|
}
|
|
213
|
+
|
|
214
|
+
return orphaned;
|
|
142
215
|
}
|
|
143
216
|
|
|
144
217
|
/**
|
|
145
|
-
* Get
|
|
218
|
+
* Get backup reference for a project (null if no active session)
|
|
146
219
|
*/
|
|
147
|
-
|
|
220
|
+
async getBackupRef(projectHash: string): Promise<BackupRef | null> {
|
|
148
221
|
const paths = this.projectManager.getProjectPaths(projectHash);
|
|
149
|
-
return readJsonFileSafe<
|
|
222
|
+
return readJsonFileSafe<BackupRef | null>(paths.backupRefFile, null);
|
|
150
223
|
}
|
|
151
224
|
|
|
152
225
|
/**
|
|
153
|
-
*
|
|
154
|
-
* Handles multi-session by checking all PIDs
|
|
226
|
+
* Check if a session is active (any alive PIDs for this project)
|
|
155
227
|
*/
|
|
156
|
-
async
|
|
157
|
-
const
|
|
228
|
+
async isSessionActive(projectHash: string): Promise<boolean> {
|
|
229
|
+
const paths = this.projectManager.getProjectPaths(projectHash);
|
|
158
230
|
|
|
159
|
-
|
|
160
|
-
|
|
231
|
+
if (!existsSync(paths.backupRefFile)) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
161
234
|
|
|
162
|
-
|
|
163
|
-
|
|
235
|
+
const alivePids = await this.scanAndCleanPids(paths.pidsDir);
|
|
236
|
+
return alivePids.length > 0;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Scan PID directory: check liveness, delete dead PID files, return alive PIDs.
|
|
241
|
+
* Side effect: removes files for PIDs that are no longer running.
|
|
242
|
+
*/
|
|
243
|
+
private async scanAndCleanPids(pidsDir: string): Promise<number[]> {
|
|
244
|
+
if (!existsSync(pidsDir)) {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
164
247
|
|
|
165
|
-
|
|
248
|
+
let files: string[];
|
|
249
|
+
try {
|
|
250
|
+
files = await fs.readdir(pidsDir);
|
|
251
|
+
} catch {
|
|
252
|
+
return [];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const alivePids: number[] = [];
|
|
256
|
+
|
|
257
|
+
for (const file of files) {
|
|
258
|
+
if (!file.endsWith('.json')) {
|
|
166
259
|
continue;
|
|
167
260
|
}
|
|
168
261
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
if (await this.checkPIDRunning(pid)) {
|
|
173
|
-
stillRunning.push(pid);
|
|
174
|
-
}
|
|
262
|
+
const pid = parseInt(file.replace('.json', ''), 10);
|
|
263
|
+
if (isNaN(pid)) {
|
|
264
|
+
continue;
|
|
175
265
|
}
|
|
176
266
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const paths = this.projectManager.getProjectPaths(hash);
|
|
187
|
-
await fs.writeFile(paths.sessionFile, JSON.stringify(session, null, 2));
|
|
267
|
+
if (this.isPidAlive(pid)) {
|
|
268
|
+
alivePids.push(pid);
|
|
269
|
+
} else {
|
|
270
|
+
// Clean dead PID file
|
|
271
|
+
try {
|
|
272
|
+
await fs.unlink(path.join(pidsDir, file));
|
|
273
|
+
} catch {
|
|
274
|
+
// Ignore — might be cleaned by another process
|
|
275
|
+
}
|
|
188
276
|
}
|
|
189
277
|
}
|
|
190
278
|
|
|
191
|
-
return
|
|
279
|
+
return alivePids;
|
|
192
280
|
}
|
|
193
281
|
|
|
194
282
|
/**
|
|
195
|
-
* Check if process is
|
|
283
|
+
* Check if a process is alive via kill(pid, 0).
|
|
284
|
+
* - Success → alive (same user)
|
|
285
|
+
* - EPERM → alive (different user, e.g. PID 1)
|
|
286
|
+
* - ESRCH → dead
|
|
196
287
|
*/
|
|
197
|
-
private
|
|
288
|
+
private isPidAlive(pid: number): boolean {
|
|
198
289
|
try {
|
|
199
|
-
// Send signal 0 to check if process exists
|
|
200
290
|
process.kill(pid, 0);
|
|
201
291
|
return true;
|
|
202
|
-
} catch (
|
|
292
|
+
} catch (error: unknown) {
|
|
293
|
+
// EPERM = process exists but we can't signal it (different user)
|
|
294
|
+
if (isErrnoException(error) && error.code === 'EPERM') {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
// ESRCH = no such process
|
|
203
298
|
return false;
|
|
204
299
|
}
|
|
205
300
|
}
|
|
206
301
|
|
|
207
302
|
/**
|
|
208
|
-
* Prune old session history files to prevent unbounded accumulation
|
|
209
|
-
* Keeps the most recent N history entries
|
|
303
|
+
* Prune old session history files to prevent unbounded accumulation.
|
|
304
|
+
* Keeps the most recent N history entries.
|
|
210
305
|
*/
|
|
211
306
|
async cleanupSessionHistory(keepLast: number = 50): Promise<void> {
|
|
212
307
|
const flowHome = this.projectManager.getFlowHomeDir();
|
|
@@ -231,27 +326,8 @@ export class SessionManager {
|
|
|
231
326
|
}
|
|
232
327
|
}
|
|
233
328
|
}
|
|
329
|
+
}
|
|
234
330
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
*/
|
|
238
|
-
async recoverSession(projectHash: string, session: Session): Promise<void> {
|
|
239
|
-
session.status = 'crashed';
|
|
240
|
-
session.cleanupRequired = false;
|
|
241
|
-
|
|
242
|
-
const flowHome = this.projectManager.getFlowHomeDir();
|
|
243
|
-
const paths = this.projectManager.getProjectPaths(projectHash);
|
|
244
|
-
|
|
245
|
-
// Archive to history
|
|
246
|
-
const historyPath = path.join(flowHome, 'sessions', 'history', `${session.sessionId}.json`);
|
|
247
|
-
await fs.mkdir(path.dirname(historyPath), { recursive: true });
|
|
248
|
-
await fs.writeFile(historyPath, JSON.stringify(session, null, 2));
|
|
249
|
-
|
|
250
|
-
// Remove active session
|
|
251
|
-
try {
|
|
252
|
-
await fs.unlink(paths.sessionFile);
|
|
253
|
-
} catch {
|
|
254
|
-
// File might not exist
|
|
255
|
-
}
|
|
256
|
-
}
|
|
331
|
+
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
|
332
|
+
return error instanceof Error && 'code' in error;
|
|
257
333
|
}
|
|
@@ -381,30 +381,19 @@ Please begin your response with a comprehensive summary of all the instructions
|
|
|
381
381
|
const { processSettings, generateHookCommands } = await import(
|
|
382
382
|
'./functional/claude-code-logic.js'
|
|
383
383
|
);
|
|
384
|
-
const { pathExists, createDirectory, readFile, writeFile } = await import(
|
|
385
|
-
'../composables/functional/useFileSystem.js'
|
|
386
|
-
);
|
|
387
384
|
|
|
388
385
|
const claudeConfigDir = path.join(cwd, '.claude');
|
|
389
386
|
const settingsPath = path.join(claudeConfigDir, 'settings.json');
|
|
390
387
|
|
|
391
388
|
// Ensure .claude directory exists
|
|
392
|
-
|
|
393
|
-
if (dirExistsResult._tag === 'Success' && !dirExistsResult.value) {
|
|
394
|
-
const createResult = await createDirectory(claudeConfigDir, { recursive: true });
|
|
395
|
-
if (createResult._tag === 'Failure') {
|
|
396
|
-
throw new Error(`Failed to create .claude directory: ${createResult.error.message}`);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
389
|
+
await fsPromises.mkdir(claudeConfigDir, { recursive: true });
|
|
399
390
|
|
|
400
391
|
// Read existing settings or null if doesn't exist
|
|
401
392
|
let existingContent: string | null = null;
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
existingContent = readResult.value;
|
|
407
|
-
}
|
|
393
|
+
try {
|
|
394
|
+
existingContent = await fsPromises.readFile(settingsPath, 'utf-8');
|
|
395
|
+
} catch {
|
|
396
|
+
// File doesn't exist yet — will create fresh settings
|
|
408
397
|
}
|
|
409
398
|
|
|
410
399
|
// Generate hooks based on how CLI was invoked
|
|
@@ -418,15 +407,11 @@ Please begin your response with a comprehensive summary of all the instructions
|
|
|
418
407
|
}
|
|
419
408
|
|
|
420
409
|
// Write updated settings
|
|
421
|
-
|
|
422
|
-
if (writeResult._tag === 'Failure') {
|
|
423
|
-
throw new Error(`Failed to write settings: ${writeResult.error.message}`);
|
|
424
|
-
}
|
|
410
|
+
await fsPromises.writeFile(settingsPath, settingsResult.value, 'utf-8');
|
|
425
411
|
|
|
426
|
-
// Return 1 hook configured (Notification only)
|
|
427
412
|
return {
|
|
428
|
-
count:
|
|
429
|
-
message: 'Configured
|
|
413
|
+
count: 2,
|
|
414
|
+
message: 'Configured Notification and SessionStart hooks',
|
|
430
415
|
};
|
|
431
416
|
},
|
|
432
417
|
};
|