@sylphx/flow 3.19.1 → 3.21.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @sylphx/flow
2
2
 
3
+ ## 3.21.0 (2026-02-08)
4
+
5
+ Add SessionStart memory hook and use stdin for notifications
6
+
7
+ ### ✨ Features
8
+
9
+ - **flow:** replace state-flag session management with PID-based ground truth ([692344d](https://github.com/SylphxAI/flow/commit/692344d7cd2b2b8c3b460347b3111ac5b08e375a))
10
+
11
+ ### 🐛 Bug Fixes
12
+
13
+ - close three gaps found in challenge round 2 ([52bf789](https://github.com/SylphxAI/flow/commit/52bf789323a30d3a245375bdd4442a2532984823))
14
+
15
+ ### ♻️ Refactoring
16
+
17
+ - extract restoreAndFinalize helper, improve cleanup safety ([959f255](https://github.com/SylphxAI/flow/commit/959f25507fb9db78191c7ed3e540319948e1288c))
18
+
19
+ ### 💅 Styles
20
+
21
+ - format package.json with biome (tabs → spaces) ([8560a3f](https://github.com/SylphxAI/flow/commit/8560a3f7c5debf220bf6fd02da19276911e75886))
22
+
23
+ ## 3.20.0 (2026-02-08)
24
+
25
+ ### ✨ Features
26
+
27
+ - **flow:** add SessionStart memory hook and use stdin for notifications ([6580500](https://github.com/SylphxAI/flow/commit/65805004f164a8f9a05d821e528c7412f7573e2f))
28
+
3
29
  ## 3.19.1 (2026-02-07)
4
30
 
5
31
  ### 🐛 Bug Fixes
@@ -101,13 +101,22 @@ State-of-the-art industrial standard. Every time. Would you stake your reputatio
101
101
 
102
102
  ## Memory
103
103
 
104
+ Two-layer durable memory:
105
+
106
+ - **`MEMORY.md`** — Curated long-term memory. Decisions, preferences, durable facts.
107
+ - **`memory/YYYY-MM-DD.md`** — Daily log (append-only). Running context, day-to-day notes.
108
+
109
+ **Rules:**
110
+ - If someone says "remember this," write it down immediately (do not keep it in RAM).
111
+ - Decisions and preferences → `MEMORY.md`
112
+ - Day-to-day notes and running context → `memory/YYYY-MM-DD.md`
113
+ - SessionStart hook auto-loads MEMORY.md + today/yesterday daily logs.
114
+
104
115
  **Atomic commits.** Commit continuously. Each commit = one logical change. Semantic commit messages (feat, fix, docs, refactor, test, chore). This is your memory of what was done.
105
116
 
106
117
  **Todos.** Use TaskCreate/TaskUpdate to track what needs to be done. This is your memory of what to do.
107
118
 
108
- **CLAUDE.md** Your persistent memory file. Commands, env setup, architecture decisions, patterns, gotchas. Read first. Summarize, don't append. Remove resolved. Consolidate duplicates.
109
-
110
- **Recovery:** Lost context? → `git log`. Forgot next steps? → TaskList.
119
+ **Recovery:** Lost context? `git log`. Forgot next steps? TaskList. Need old memories? read `memory/` directory.
111
120
 
112
121
  ## Issue Ownership
113
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/flow",
3
- "version": "3.19.1",
3
+ "version": "3.21.0",
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,27 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Hook command - OS notification for Claude Code startup
3
+ * Hook command - Claude Code hook handlers
4
4
  *
5
- * Purpose: Send OS-level notifications when Claude Code starts
5
+ * Purpose: Handle Claude Code lifecycle hooks:
6
+ * - notification: OS-level notification when Claude Code starts
7
+ * - session-start: Load durable memory (MEMORY.md + recent daily logs)
6
8
  *
7
9
  * DESIGN RATIONALE:
8
- * - Simple notification: Just notify user when Claude Code is ready
9
- * - Cross-platform: Supports macOS, Linux, and Windows
10
- * - Non-intrusive: Fails silently if notification system not available
10
+ * - Each hook type returns content to stdout (Claude Code injects as context)
11
+ * - Cross-platform notifications (macOS, Linux, Windows)
12
+ * - SessionStart hook loads cross-session memory at startup
11
13
  */
12
14
 
13
15
  import { exec } from 'node:child_process';
16
+ import { readFile } from 'node:fs/promises';
14
17
  import os from 'node:os';
18
+ import path from 'node:path';
15
19
  import { promisify } from 'node:util';
16
20
  import { Command } from 'commander';
17
21
  import { cli } from '../utils/display/cli-output.js';
18
22
 
23
+ /**
24
+ * Hook input from Claude Code via stdin.
25
+ * Claude Code sends JSON context about the event — we read what we need.
26
+ */
27
+ interface HookInput {
28
+ /** Notification message text */
29
+ message?: string;
30
+ /** Notification title */
31
+ title?: string;
32
+ /** Notification type (permission_prompt, idle_prompt, etc.) */
33
+ notification_type?: string;
34
+ /** How the session started (startup, resume, clear, compact) */
35
+ source?: string;
36
+ /** Current working directory */
37
+ cwd?: string;
38
+ }
39
+
19
40
  const execAsync = promisify(exec);
20
41
 
21
42
  /**
22
- * Hook types supported
43
+ * Hook types supported — single source of truth for both type and runtime validation
23
44
  */
24
- type HookType = 'notification';
45
+ const VALID_HOOK_TYPES = ['notification', 'session-start'] as const;
46
+ type HookType = (typeof VALID_HOOK_TYPES)[number];
47
+ const VALID_HOOK_TYPE_SET = new Set<string>(VALID_HOOK_TYPES);
25
48
 
26
49
  /**
27
50
  * Target platforms supported
@@ -32,18 +55,20 @@ type TargetPlatform = 'claude-code';
32
55
  * Create the hook command
33
56
  */
34
57
  export const hookCommand = new Command('hook')
35
- .description('Load dynamic system information for Claude Code hooks')
36
- .requiredOption('--type <type>', 'Hook type (notification)')
58
+ .description('Handle Claude Code lifecycle hooks (notification, memory)')
59
+ .requiredOption('--type <type>', 'Hook type (notification | session-start)')
37
60
  .option('--target <target>', 'Target platform (claude-code)', 'claude-code')
38
61
  .option('--verbose', 'Show verbose output', false)
39
62
  .action(async (options) => {
40
63
  try {
41
- const hookType = options.type as HookType;
64
+ const hookType = options.type as string;
42
65
  const target = options.target as TargetPlatform;
43
66
 
44
67
  // Validate hook type
45
- if (hookType !== 'notification') {
46
- throw new Error(`Invalid hook type: ${hookType}. Must be 'notification'`);
68
+ if (!VALID_HOOK_TYPE_SET.has(hookType)) {
69
+ throw new Error(
70
+ `Invalid hook type: ${hookType}. Must be one of: ${VALID_HOOK_TYPES.join(', ')}`
71
+ );
47
72
  }
48
73
 
49
74
  // Validate target
@@ -51,8 +76,11 @@ export const hookCommand = new Command('hook')
51
76
  throw new Error(`Invalid target: ${target}. Only 'claude-code' is currently supported`);
52
77
  }
53
78
 
79
+ // Read hook input from stdin (Claude Code passes JSON context)
80
+ const hookInput = await readStdinInput();
81
+
54
82
  // Load and display content based on hook type
55
- const content = await loadHookContent(hookType, target, options.verbose);
83
+ const content = await loadHookContent(hookType as HookType, target, hookInput, options.verbose);
56
84
 
57
85
  // Output the content (no extra formatting, just the content)
58
86
  console.log(content);
@@ -72,27 +100,140 @@ export const hookCommand = new Command('hook')
72
100
  }
73
101
  });
74
102
 
103
+ /**
104
+ * Read JSON input from stdin (non-blocking, returns empty object if no input)
105
+ * Claude Code sends event context as JSON on stdin for all hook types.
106
+ */
107
+ async function readStdinInput(): Promise<HookInput> {
108
+ // stdin is not a TTY when piped from Claude Code
109
+ if (process.stdin.isTTY) {
110
+ return {};
111
+ }
112
+
113
+ return new Promise((resolve) => {
114
+ let data = '';
115
+ process.stdin.setEncoding('utf-8');
116
+ process.stdin.on('data', (chunk) => {
117
+ data += chunk;
118
+ });
119
+ process.stdin.on('end', () => {
120
+ if (!data.trim()) {
121
+ resolve({});
122
+ return;
123
+ }
124
+ try {
125
+ resolve(JSON.parse(data) as HookInput);
126
+ } catch {
127
+ resolve({});
128
+ }
129
+ });
130
+ // Safety timeout — don't hang if stdin never closes
131
+ setTimeout(() => resolve({}), 1000);
132
+ });
133
+ }
134
+
75
135
  /**
76
136
  * Load content for a specific hook type and target
77
137
  */
78
138
  async function loadHookContent(
79
139
  hookType: HookType,
80
140
  _target: TargetPlatform,
141
+ input: HookInput,
81
142
  verbose: boolean = false
82
143
  ): Promise<string> {
83
- if (hookType === 'notification') {
84
- return await sendNotification(verbose);
144
+ switch (hookType) {
145
+ case 'notification':
146
+ return await sendNotification(input, verbose);
147
+ case 'session-start':
148
+ return await loadSessionStartContent(verbose);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Read a file and return its contents, or empty string if not found
154
+ */
155
+ async function readFileIfExists(filePath: string): Promise<string> {
156
+ try {
157
+ return await readFile(filePath, 'utf-8');
158
+ } catch {
159
+ return '';
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Format a date as YYYY-MM-DD
165
+ */
166
+ function formatDate(date: Date): string {
167
+ const year = date.getFullYear();
168
+ const month = String(date.getMonth() + 1).padStart(2, '0');
169
+ const day = String(date.getDate()).padStart(2, '0');
170
+ return `${year}-${month}-${day}`;
171
+ }
172
+
173
+ /**
174
+ * Load memory files for session start:
175
+ * - MEMORY.md (curated long-term memory)
176
+ * - memory/{today}.md (today's daily log)
177
+ * - memory/{yesterday}.md (yesterday's daily log)
178
+ *
179
+ * Returns concatenated content to stdout for Claude Code context injection
180
+ */
181
+ async function loadSessionStartContent(verbose: boolean): Promise<string> {
182
+ const cwd = process.cwd();
183
+ const sections: string[] = [];
184
+
185
+ // Load MEMORY.md
186
+ const memoryPath = path.join(cwd, 'MEMORY.md');
187
+ const memoryContent = await readFileIfExists(memoryPath);
188
+ if (memoryContent.trim()) {
189
+ sections.push(`## MEMORY.md\n${memoryContent.trim()}`);
190
+ if (verbose) {
191
+ cli.info('Loaded MEMORY.md');
192
+ }
193
+ }
194
+
195
+ // Calculate today and yesterday dates
196
+ const today = new Date();
197
+ const yesterday = new Date(today);
198
+ yesterday.setDate(yesterday.getDate() - 1);
199
+
200
+ const todayStr = formatDate(today);
201
+ const yesterdayStr = formatDate(yesterday);
202
+
203
+ // Load today's daily log
204
+ const todayPath = path.join(cwd, 'memory', `${todayStr}.md`);
205
+ const todayContent = await readFileIfExists(todayPath);
206
+ if (todayContent.trim()) {
207
+ sections.push(`## memory/${todayStr}.md\n${todayContent.trim()}`);
208
+ if (verbose) {
209
+ cli.info(`Loaded memory/${todayStr}.md`);
210
+ }
211
+ }
212
+
213
+ // Load yesterday's daily log
214
+ const yesterdayPath = path.join(cwd, 'memory', `${yesterdayStr}.md`);
215
+ const yesterdayContent = await readFileIfExists(yesterdayPath);
216
+ if (yesterdayContent.trim()) {
217
+ sections.push(`## memory/${yesterdayStr}.md\n${yesterdayContent.trim()}`);
218
+ if (verbose) {
219
+ cli.info(`Loaded memory/${yesterdayStr}.md`);
220
+ }
221
+ }
222
+
223
+ if (verbose && sections.length === 0) {
224
+ cli.info('No memory files found');
85
225
  }
86
226
 
87
- return '';
227
+ return sections.join('\n\n');
88
228
  }
89
229
 
90
230
  /**
91
- * Send OS-level notification
231
+ * Send OS-level notification using event data from Claude Code.
232
+ * Falls back to generic message when stdin input is missing.
92
233
  */
93
- async function sendNotification(verbose: boolean): Promise<string> {
94
- const title = '🔮 Sylphx Flow';
95
- const message = 'Claude Code is ready';
234
+ async function sendNotification(input: HookInput, verbose: boolean): Promise<string> {
235
+ const title = input.title || '🔮 Sylphx Flow';
236
+ const message = input.message || 'Claude Code is ready';
96
237
  const platform = os.platform();
97
238
 
98
239
  if (verbose) {
@@ -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();