@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.
@@ -1,31 +1,41 @@
1
1
  /**
2
- * Session Manager (Updated for ~/.sylphx-flow)
3
- * Manages temporary Flow sessions with multi-project support
4
- * All sessions stored in ~/.sylphx-flow/sessions/
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 type { Target } from '../types/target.types.js';
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
- export interface Session {
15
- projectHash: string;
16
- projectPath: string;
17
- sessionId: string;
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
- status: 'active' | 'completed' | 'crashed';
35
+ projectPath: string;
22
36
  target: string;
23
- cleanupRequired: boolean;
24
- // Multi-session support
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
- * Start a new session for a project (with multi-session support)
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 startSession(
42
- projectPath: string,
55
+ async acquireSession(
43
56
  projectHash: string,
44
- targetOrId: Target | string,
45
- backupPath: string,
46
- sessionId?: string
47
- ): Promise<{ session: Session; isFirstSession: boolean }> {
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 sessions directory exists
53
- await fs.mkdir(path.dirname(paths.sessionFile), { recursive: true });
63
+ // Ensure the session directory exists
64
+ await fs.mkdir(paths.sessionDir, { recursive: true });
54
65
 
55
- // Check for existing session
56
- const existingSession = await this.getActiveSession(projectHash);
66
+ let isFirstSession = false;
57
67
 
58
- if (existingSession) {
59
- // Join existing session (don't create new backup)
60
- existingSession.refCount++;
61
- existingSession.activePids.push(process.pid);
62
-
63
- await fs.writeFile(paths.sessionFile, JSON.stringify(existingSession, null, 2));
64
-
65
- return {
66
- session: existingSession,
67
- isFirstSession: false,
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
- // First session - create new (use provided sessionId or generate one)
72
- const newSessionId = sessionId || `session-${Date.now()}`;
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
- backupPath,
80
- status: 'active',
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
- await fs.writeFile(paths.sessionFile, JSON.stringify(session, null, 2));
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
- * Mark session as completed (with reference counting)
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 endSession(
124
+ async releaseSession(
101
125
  projectHash: string
102
- ): Promise<{ shouldRestore: boolean; session: Session | null }> {
126
+ ): Promise<{ shouldRestore: boolean; backupRef: BackupRef | null }> {
127
+ const paths = this.projectManager.getProjectPaths(projectHash);
128
+
129
+ // Delete own PID file
103
130
  try {
104
- const session = await this.getActiveSession(projectHash);
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
- if (!session) {
107
- return { shouldRestore: false, session: null };
108
- }
136
+ // Scan remaining PID files
137
+ const alivePids = await this.scanAndCleanPids(paths.pidsDir);
109
138
 
110
- const paths = this.projectManager.getProjectPaths(projectHash);
139
+ if (alivePids.length > 0) {
140
+ // Other sessions still running
141
+ return { shouldRestore: false, backupRef: null };
142
+ }
111
143
 
112
- // Remove current PID from active PIDs
113
- session.activePids = session.activePids.filter((pid) => pid !== process.pid);
114
- session.refCount = session.activePids.length;
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
- if (session.refCount === 0) {
117
- // Last session - mark completed and cleanup
118
- session.status = 'completed';
119
- session.cleanupRequired = false;
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
- const flowHome = this.projectManager.getFlowHomeDir();
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
- // Archive to history
124
- const historyPath = path.join(flowHome, 'sessions', 'history', `${session.sessionId}.json`);
125
- await fs.mkdir(path.dirname(historyPath), { recursive: true });
126
- await fs.writeFile(historyPath, JSON.stringify(session, null, 2));
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
- // Remove active session file
129
- await fs.unlink(paths.sessionFile);
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
- return { shouldRestore: true, session };
132
- } else {
133
- // Still have active sessions, update session file
134
- await fs.writeFile(paths.sessionFile, JSON.stringify(session, null, 2));
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
- return { shouldRestore: false, session };
207
+ if (alivePids.length === 0) {
208
+ // All PIDs dead — orphaned session
209
+ orphaned.set(hash, backupRef);
137
210
  }
138
- } catch (_error) {
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 active session for a project
218
+ * Get backup reference for a project (null if no active session)
146
219
  */
147
- getActiveSession(projectHash: string): Promise<Session | null> {
220
+ async getBackupRef(projectHash: string): Promise<BackupRef | null> {
148
221
  const paths = this.projectManager.getProjectPaths(projectHash);
149
- return readJsonFileSafe<Session | null>(paths.sessionFile, null);
222
+ return readJsonFileSafe<BackupRef | null>(paths.backupRefFile, null);
150
223
  }
151
224
 
152
225
  /**
153
- * Detect orphaned sessions (from crashes) across all projects
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 detectOrphanedSessions(): Promise<Map<string, Session>> {
157
- const orphaned = new Map<string, Session>();
228
+ async isSessionActive(projectHash: string): Promise<boolean> {
229
+ const paths = this.projectManager.getProjectPaths(projectHash);
158
230
 
159
- // Get all active projects
160
- const projects = await this.projectManager.getActiveProjects();
231
+ if (!existsSync(paths.backupRefFile)) {
232
+ return false;
233
+ }
161
234
 
162
- for (const { hash } of projects) {
163
- const session = await this.getActiveSession(hash);
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
- if (!session) {
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
- // Check all active PIDs
170
- const stillRunning = [];
171
- for (const pid of session.activePids) {
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
- // Update active PIDs and refCount
178
- session.activePids = stillRunning;
179
- session.refCount = stillRunning.length;
180
-
181
- if (session.refCount === 0 && session.cleanupRequired) {
182
- // All sessions crashed
183
- orphaned.set(hash, session);
184
- } else if (session.refCount !== session.activePids.length) {
185
- // Some PIDs crashed, update session file
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 orphaned;
279
+ return alivePids;
192
280
  }
193
281
 
194
282
  /**
195
- * Check if process is still running
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 async checkPIDRunning(pid: number): Promise<boolean> {
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 (_error) {
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 (sorted by session timestamp in filename)
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
- * Recover from crashed session
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
- const dirExistsResult = await pathExists(claudeConfigDir);
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
- const fileExistsResult = await pathExists(settingsPath);
403
- if (fileExistsResult._tag === 'Success' && fileExistsResult.value) {
404
- const readResult = await readFile(settingsPath);
405
- if (readResult._tag === 'Success') {
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
- const writeResult = await writeFile(settingsPath, settingsResult.value);
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: 1,
429
- message: 'Configured notification hook',
413
+ count: 2,
414
+ message: 'Configured Notification and SessionStart hooks',
430
415
  };
431
416
  },
432
417
  };