@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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # @sylphx/flow
2
2
 
3
+ ## 3.21.1 (2026-02-08)
4
+
5
+ ### 🐛 Bug Fixes
6
+
7
+ - resolve broken SessionStart hook — dead import silently killed applySettings ([5c9fb60](https://github.com/SylphxAI/flow/commit/5c9fb604894df7c3392ab6cc8caf4a1fc6f09c7e))
8
+
9
+ ## 3.21.0 (2026-02-08)
10
+
11
+ Add SessionStart memory hook and use stdin for notifications
12
+
13
+ ### ✨ Features
14
+
15
+ - **flow:** replace state-flag session management with PID-based ground truth ([692344d](https://github.com/SylphxAI/flow/commit/692344d7cd2b2b8c3b460347b3111ac5b08e375a))
16
+
17
+ ### 🐛 Bug Fixes
18
+
19
+ - close three gaps found in challenge round 2 ([52bf789](https://github.com/SylphxAI/flow/commit/52bf789323a30d3a245375bdd4442a2532984823))
20
+
21
+ ### ♻️ Refactoring
22
+
23
+ - extract restoreAndFinalize helper, improve cleanup safety ([959f255](https://github.com/SylphxAI/flow/commit/959f25507fb9db78191c7ed3e540319948e1288c))
24
+
25
+ ### 💅 Styles
26
+
27
+ - format package.json with biome (tabs → spaces) ([8560a3f](https://github.com/SylphxAI/flow/commit/8560a3f7c5debf220bf6fd02da19276911e75886))
28
+
3
29
  ## 3.20.0 (2026-02-08)
4
30
 
5
31
  ### ✨ Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/flow",
3
- "version": "3.20.0",
3
+ "version": "3.21.1",
4
4
  "description": "One CLI to rule them all. Unified orchestration layer for AI coding assistants. Auto-detection, auto-installation, auto-upgrade.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -308,7 +308,25 @@ export async function executeFlowV2(
308
308
  continue: options.continue,
309
309
  };
310
310
 
311
- await executeTargetCommand(selectedTargetId, systemPrompt, userPrompt, runOptions);
311
+ // Suppress SIGINT in parent — child process handles its own Ctrl+C.
312
+ // Without this, SIGINT propagates to parent and triggers premature cleanup
313
+ // while the child is still running.
314
+ const originalSigintListeners = process.listeners('SIGINT') as ((...args: unknown[]) => void)[];
315
+ process.removeAllListeners('SIGINT');
316
+ process.on('SIGINT', () => {
317
+ // Intentionally empty — child handles Ctrl+C
318
+ });
319
+
320
+ try {
321
+ await executeTargetCommand(selectedTargetId, systemPrompt, userPrompt, runOptions);
322
+ } finally {
323
+ // Restore original SIGINT listeners after child exits
324
+ process.removeAllListeners('SIGINT');
325
+ for (const listener of originalSigintListeners) {
326
+ process.on('SIGINT', listener);
327
+ }
328
+ }
329
+
312
330
  await executor.cleanup(projectPath);
313
331
  } catch (error) {
314
332
  if (error instanceof UserCancelledError) {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Tests for CleanupHandler
3
- * Covers: signal cleanup, crash recovery, orphaned project detection,
4
- * periodic cleanup gating, and session history pruning coordination
3
+ * Covers: cleanup flow, crash recovery, orphaned project detection,
4
+ * periodic cleanup gating, finalize-after-restore ordering,
5
+ * and legacy migration
5
6
  */
6
7
 
7
8
  import fs from 'node:fs';
@@ -16,21 +17,26 @@ function createMockProjectManager(flowHome: string) {
16
17
  return {
17
18
  getFlowHomeDir: () => flowHome,
18
19
  getProjectPaths: (hash: string) => ({
19
- sessionFile: path.join(flowHome, 'sessions', `${hash}.json`),
20
+ sessionDir: path.join(flowHome, 'sessions', hash),
21
+ backupRefFile: path.join(flowHome, 'sessions', hash, 'backup.json'),
22
+ pidsDir: path.join(flowHome, 'sessions', hash, 'pids'),
20
23
  backupsDir: path.join(flowHome, 'backups', hash),
21
24
  secretsDir: path.join(flowHome, 'secrets', hash),
22
25
  latestBackup: path.join(flowHome, 'backups', hash, 'latest'),
23
26
  }),
27
+ getActiveProjects: vi.fn().mockResolvedValue([]),
24
28
  };
25
29
  }
26
30
 
27
31
  function createMockSessionManager() {
28
32
  return {
29
33
  detectOrphanedSessions: vi.fn().mockResolvedValue(new Map()),
30
- endSession: vi.fn().mockResolvedValue({ shouldRestore: false, session: null }),
31
- recoverSession: vi.fn().mockResolvedValue(undefined),
32
- getActiveSession: vi.fn().mockResolvedValue(null),
34
+ releaseSession: vi.fn().mockResolvedValue({ shouldRestore: false, backupRef: null }),
35
+ finalizeSessionCleanup: vi.fn().mockResolvedValue(undefined),
36
+ isSessionActive: vi.fn().mockResolvedValue(false),
33
37
  cleanupSessionHistory: vi.fn().mockResolvedValue(undefined),
38
+ getBackupRef: vi.fn().mockResolvedValue(null),
39
+ acquireSession: vi.fn().mockResolvedValue({ isFirstSession: false, backupRef: null }),
34
40
  };
35
41
  }
36
42
 
@@ -38,6 +44,7 @@ function createMockBackupManager() {
38
44
  return {
39
45
  restoreBackup: vi.fn().mockResolvedValue(undefined),
40
46
  cleanupOldBackups: vi.fn().mockResolvedValue(undefined),
47
+ cleanupOrphanedRestores: vi.fn().mockResolvedValue(undefined),
41
48
  };
42
49
  }
43
50
 
@@ -94,24 +101,74 @@ describe('CleanupHandler', () => {
94
101
  // --- cleanup() ---
95
102
 
96
103
  describe('cleanup()', () => {
97
- it('should restore backup and clean up when last session ends', async () => {
98
- const session = { sessionId: 'session-1', projectPath: '/tmp/project' };
99
- mockSessionManager.endSession.mockResolvedValue({ shouldRestore: true, session });
104
+ it('should restore backup and finalize when last session ends', async () => {
105
+ const backupRef = {
106
+ sessionId: 'session-1',
107
+ projectPath: '/tmp/project',
108
+ backupPath: '/tmp/backup',
109
+ target: 'claude-code',
110
+ };
111
+ mockSessionManager.releaseSession.mockResolvedValue({ shouldRestore: true, backupRef });
100
112
 
101
113
  await handler.cleanup('abc123');
102
114
 
103
115
  expect(mockBackupManager.restoreBackup).toHaveBeenCalledWith('abc123', 'session-1');
116
+ // CRITICAL: finalize called AFTER restore
117
+ expect(mockSessionManager.finalizeSessionCleanup).toHaveBeenCalledWith('abc123');
104
118
  expect(mockBackupManager.cleanupOldBackups).toHaveBeenCalledWith('abc123', 3);
105
119
  expect(mockGitStash.popSettingsChanges).toHaveBeenCalledWith('/tmp/project');
106
120
  expect(mockSecrets.clearSecrets).toHaveBeenCalledWith('abc123');
107
121
  });
108
122
 
123
+ it('should call finalize AFTER restore (ordering)', async () => {
124
+ const callOrder: string[] = [];
125
+ mockSessionManager.releaseSession.mockResolvedValue({
126
+ shouldRestore: true,
127
+ backupRef: {
128
+ sessionId: 's1',
129
+ projectPath: '/tmp/p',
130
+ backupPath: '/tmp/b',
131
+ target: 'claude-code',
132
+ },
133
+ });
134
+ mockBackupManager.restoreBackup.mockImplementation(async () => {
135
+ callOrder.push('restore');
136
+ });
137
+ mockSessionManager.finalizeSessionCleanup.mockImplementation(async () => {
138
+ callOrder.push('finalize');
139
+ });
140
+
141
+ await handler.cleanup('abc123');
142
+
143
+ expect(callOrder).toEqual(['restore', 'finalize']);
144
+ });
145
+
146
+ it('should NOT call finalize if restore fails', async () => {
147
+ mockSessionManager.releaseSession.mockResolvedValue({
148
+ shouldRestore: true,
149
+ backupRef: {
150
+ sessionId: 's1',
151
+ projectPath: '/tmp/p',
152
+ backupPath: '/tmp/b',
153
+ target: 'claude-code',
154
+ },
155
+ });
156
+ mockBackupManager.restoreBackup.mockRejectedValue(new Error('restore failed'));
157
+
158
+ // cleanup() will throw since restoreBackup throws
159
+ await expect(handler.cleanup('abc123')).rejects.toThrow('restore failed');
160
+
161
+ // finalize should NOT have been called
162
+ expect(mockSessionManager.finalizeSessionCleanup).not.toHaveBeenCalled();
163
+ });
164
+
109
165
  it('should not restore when other sessions still active', async () => {
110
- mockSessionManager.endSession.mockResolvedValue({ shouldRestore: false, session: null });
166
+ mockSessionManager.releaseSession.mockResolvedValue({ shouldRestore: false, backupRef: null });
111
167
 
112
168
  await handler.cleanup('abc123');
113
169
 
114
170
  expect(mockBackupManager.restoreBackup).not.toHaveBeenCalled();
171
+ expect(mockSessionManager.finalizeSessionCleanup).not.toHaveBeenCalled();
115
172
  expect(mockGitStash.popSettingsChanges).not.toHaveBeenCalled();
116
173
  expect(mockSecrets.clearSecrets).not.toHaveBeenCalled();
117
174
  });
@@ -120,30 +177,31 @@ describe('CleanupHandler', () => {
120
177
  // --- recoverOnStartup() ---
121
178
 
122
179
  describe('recoverOnStartup()', () => {
123
- it('should recover orphaned sessions', async () => {
124
- const session = {
180
+ it('should recover orphaned sessions with finalize after restore', async () => {
181
+ const backupRef = {
125
182
  sessionId: 'session-crashed',
126
183
  projectPath: '/tmp/crashed-project',
127
- projectHash: 'hash1',
184
+ backupPath: '/tmp/backup',
185
+ target: 'claude-code',
128
186
  };
129
- const orphaned = new Map([['hash1', session]]);
187
+ const orphaned = new Map([['hash1', backupRef]]);
130
188
  mockSessionManager.detectOrphanedSessions.mockResolvedValue(orphaned);
131
189
 
132
190
  await handler.recoverOnStartup();
133
191
 
134
192
  expect(mockBackupManager.restoreBackup).toHaveBeenCalledWith('hash1', 'session-crashed');
135
- expect(mockSessionManager.recoverSession).toHaveBeenCalledWith('hash1', session);
193
+ expect(mockSessionManager.finalizeSessionCleanup).toHaveBeenCalledWith('hash1');
136
194
  expect(mockGitStash.popSettingsChanges).toHaveBeenCalledWith('/tmp/crashed-project');
137
195
  expect(mockSecrets.clearSecrets).toHaveBeenCalledWith('hash1');
138
196
  expect(mockBackupManager.cleanupOldBackups).toHaveBeenCalledWith('hash1', 3);
139
197
  });
140
198
 
141
199
  it('should handle multiple orphaned sessions independently', async () => {
142
- const session1 = { sessionId: 's1', projectPath: '/p1' };
143
- const session2 = { sessionId: 's2', projectPath: '/p2' };
200
+ const ref1 = { sessionId: 's1', projectPath: '/p1', backupPath: '/b1', target: 'claude-code' };
201
+ const ref2 = { sessionId: 's2', projectPath: '/p2', backupPath: '/b2', target: 'claude-code' };
144
202
  const orphaned = new Map([
145
- ['h1', session1],
146
- ['h2', session2],
203
+ ['h1', ref1],
204
+ ['h2', ref2],
147
205
  ]);
148
206
  mockSessionManager.detectOrphanedSessions.mockResolvedValue(orphaned);
149
207
 
@@ -156,7 +214,9 @@ describe('CleanupHandler', () => {
156
214
 
157
215
  // Second session should still be recovered despite first failure
158
216
  expect(mockBackupManager.restoreBackup).toHaveBeenCalledTimes(2);
159
- expect(mockSessionManager.recoverSession).toHaveBeenCalledWith('h2', session2);
217
+ expect(mockSessionManager.finalizeSessionCleanup).toHaveBeenCalledWith('h2');
218
+ // First session's finalize should NOT have been called (restore failed)
219
+ expect(mockSessionManager.finalizeSessionCleanup).not.toHaveBeenCalledWith('h1');
160
220
  });
161
221
 
162
222
  it('should skip heavy cleanup when marker is fresh', async () => {
@@ -209,6 +269,76 @@ describe('CleanupHandler', () => {
209
269
  });
210
270
  });
211
271
 
272
+ // --- Legacy migration ---
273
+
274
+ describe('migrateLegacySessions (via recoverOnStartup)', () => {
275
+ it('should migrate legacy session files with cleanupRequired=true', async () => {
276
+ // Create a legacy session file
277
+ const legacySession = {
278
+ projectHash: 'abc123',
279
+ projectPath: '/tmp/project',
280
+ sessionId: 'session-legacy',
281
+ pid: 999999,
282
+ startTime: '2026-01-01T00:00:00.000Z',
283
+ backupPath: '/tmp/backups/session-legacy',
284
+ status: 'active',
285
+ target: 'claude-code',
286
+ cleanupRequired: true,
287
+ isOriginal: true,
288
+ sharedBackupId: 'session-legacy',
289
+ refCount: 1,
290
+ activePids: [999999],
291
+ };
292
+ fs.writeFileSync(
293
+ path.join(flowHome, 'sessions', 'abc123.json'),
294
+ JSON.stringify(legacySession)
295
+ );
296
+
297
+ // Fresh cleanup marker to skip heavy cleanup
298
+ fs.writeFileSync(path.join(flowHome, '.last-cleanup'), new Date().toISOString());
299
+
300
+ await handler.recoverOnStartup();
301
+
302
+ // Legacy file should be gone
303
+ expect(fs.existsSync(path.join(flowHome, 'sessions', 'abc123.json'))).toBe(false);
304
+
305
+ // New directory structure should exist with backup.json
306
+ const paths = mockProjectManager.getProjectPaths('abc123');
307
+ expect(fs.existsSync(paths.backupRefFile)).toBe(true);
308
+
309
+ const backupRef = JSON.parse(fs.readFileSync(paths.backupRefFile, 'utf-8'));
310
+ expect(backupRef.sessionId).toBe('session-legacy');
311
+ expect(backupRef.projectPath).toBe('/tmp/project');
312
+ });
313
+
314
+ it('should migrate legacy session files with cleanupRequired=false (no backup.json)', async () => {
315
+ const legacySession = {
316
+ sessionId: 'session-done',
317
+ backupPath: '/tmp/backup',
318
+ projectPath: '/tmp/project',
319
+ target: 'claude-code',
320
+ cleanupRequired: false,
321
+ pid: 999999,
322
+ startTime: '2026-01-01T00:00:00.000Z',
323
+ };
324
+ fs.writeFileSync(
325
+ path.join(flowHome, 'sessions', 'def456.json'),
326
+ JSON.stringify(legacySession)
327
+ );
328
+
329
+ fs.writeFileSync(path.join(flowHome, '.last-cleanup'), new Date().toISOString());
330
+
331
+ await handler.recoverOnStartup();
332
+
333
+ // Legacy file should be gone
334
+ expect(fs.existsSync(path.join(flowHome, 'sessions', 'def456.json'))).toBe(false);
335
+
336
+ // No backup.json should be created (cleanupRequired was false)
337
+ const paths = mockProjectManager.getProjectPaths('def456');
338
+ expect(fs.existsSync(paths.backupRefFile)).toBe(false);
339
+ });
340
+ });
341
+
212
342
  // --- Orphaned project cleanup ---
213
343
 
214
344
  describe('cleanupOrphanedProjects (via recoverOnStartup)', () => {
@@ -248,11 +378,8 @@ describe('CleanupHandler', () => {
248
378
  );
249
379
 
250
380
  // Mock: this project has an active session
251
- mockSessionManager.getActiveSession.mockImplementation(async (hash: string) => {
252
- if (hash === activeHash) {
253
- return { projectHash: activeHash };
254
- }
255
- return null;
381
+ mockSessionManager.isSessionActive.mockImplementation(async (hash: string) => {
382
+ return hash === activeHash;
256
383
  });
257
384
 
258
385
  await handler.recoverOnStartup();