@sylphx/flow 3.24.1 → 3.25.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,21 @@
1
1
  # @sylphx/flow
2
2
 
3
+ ## 3.25.0 (2026-02-17)
4
+
5
+ ### ✨ Features
6
+
7
+ - **flow:** smart --resume with auto-detection and sessions command ([ce6a3f8](https://github.com/SylphxAI/flow/commit/ce6a3f820c99fe47cb5c6701fc219bd599583470))
8
+
9
+ ### 🔧 Chores
10
+
11
+ - fix biome formatting in settings.json and package.json ([02bd051](https://github.com/SylphxAI/flow/commit/02bd051b88d29b645786c8812610400960adbcfe))
12
+
13
+ ## 3.24.2 (2026-02-17)
14
+
15
+ ### 🐛 Bug Fixes
16
+
17
+ - **flow:** graceful exit when Claude Code exits with non-zero code ([44892b5](https://github.com/SylphxAI/flow/commit/44892b51beda34b49020ed1d0324cfcc3e4b28f9))
18
+
3
19
  ## 3.24.1 (2026-02-15)
4
20
 
5
21
  ### 🐛 Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/flow",
3
- "version": "3.24.1",
3
+ "version": "3.25.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": {
@@ -19,7 +19,7 @@ import { TargetInstaller } from '../../services/target-installer.js';
19
19
  import type { RunCommandOptions } from '../../types.js';
20
20
  import { extractAgentInstructions, loadAgentContent } from '../../utils/agent-enhancer.js';
21
21
  import { showAttachSummary, showHeader } from '../../utils/display/banner.js';
22
- import { CLIError, UserCancelledError } from '../../utils/errors.js';
22
+ import { CLIError, ChildProcessExitError, UserCancelledError } from '../../utils/errors.js';
23
23
  import { log, promptConfirm, promptSelect } from '../../utils/prompts/index.js';
24
24
  import { ensureTargetInstalled, promptForTargetSelection } from '../../utils/target-selection.js';
25
25
  import { resolvePrompt } from './prompt.js';
@@ -340,6 +340,17 @@ export async function executeFlowV2(
340
340
  process.exit(0);
341
341
  }
342
342
 
343
+ // Child process already displayed its own error via inherited stdio —
344
+ // cleanup silently and exit with the same code.
345
+ if (error instanceof ChildProcessExitError) {
346
+ try {
347
+ await executor.cleanup(projectPath);
348
+ } catch (cleanupError) {
349
+ debug('cleanup after child exit failed:', cleanupError);
350
+ }
351
+ process.exit(error.exitCode);
352
+ }
353
+
343
354
  console.error(chalk.red('\n Error:'), error);
344
355
 
345
356
  try {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Sessions Command
3
+ *
4
+ * Lists Claude Code sessions for the current project so users can
5
+ * pick one to resume, instead of relying on Claude Code's unreliable picker.
6
+ */
7
+
8
+ import chalk from 'chalk';
9
+ import { Command } from 'commander';
10
+ import { type SessionInfo, listProjectSessions } from '../targets/functional/claude-session.js';
11
+
12
+ /**
13
+ * Format bytes into a human-readable size string.
14
+ */
15
+ function formatSize(bytes: number): string {
16
+ if (bytes < 1024) return `${bytes} B`;
17
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
18
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;
19
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
20
+ }
21
+
22
+ /**
23
+ * Format a date into a relative time string.
24
+ */
25
+ function formatRelativeTime(date: Date): string {
26
+ const now = Date.now();
27
+ const diffMs = now - date.getTime();
28
+ const diffSec = Math.floor(diffMs / 1000);
29
+ const diffMin = Math.floor(diffSec / 60);
30
+ const diffHour = Math.floor(diffMin / 60);
31
+ const diffDay = Math.floor(diffHour / 24);
32
+
33
+ if (diffSec < 60) return 'just now';
34
+ if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`;
35
+ if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`;
36
+ if (diffDay === 1) return 'yesterday';
37
+ if (diffDay < 7) return `${diffDay} days ago`;
38
+ if (diffDay < 30) return `${Math.floor(diffDay / 7)} week${Math.floor(diffDay / 7) === 1 ? '' : 's'} ago`;
39
+ return date.toLocaleDateString();
40
+ }
41
+
42
+ /**
43
+ * Format a session row for the table output.
44
+ */
45
+ function formatSessionRow(session: SessionInfo, isLatest: boolean): string {
46
+ const id = chalk.white(session.id);
47
+ const time = formatRelativeTime(session.modifiedAt).padEnd(18);
48
+ const size = formatSize(session.sizeBytes).padStart(8);
49
+ const marker = isLatest ? chalk.green(' \u2190 latest') : '';
50
+
51
+ return ` ${id} ${chalk.dim(time)} ${chalk.dim(size)}${marker}`;
52
+ }
53
+
54
+ export const sessionsCommand = new Command('sessions')
55
+ .description('List Claude Code sessions for the current project')
56
+ .action(async () => {
57
+ const cwd = process.cwd();
58
+ const sessions = await listProjectSessions(cwd);
59
+
60
+ if (sessions.length === 0) {
61
+ console.log(chalk.yellow('\n No sessions found for this project.\n'));
62
+ console.log(chalk.dim(` Project: ${cwd}`));
63
+ console.log(chalk.dim(' Start a new session with: flow\n'));
64
+ return;
65
+ }
66
+
67
+ console.log(`\n${chalk.cyan.bold(' Sessions')} ${chalk.dim(`for ${cwd}`)}\n`);
68
+
69
+ // Header
70
+ console.log(
71
+ ` ${chalk.dim.bold('ID'.padEnd(36))} ${chalk.dim.bold('Last active'.padEnd(18))} ${chalk.dim.bold('Size'.padStart(8))}`
72
+ );
73
+ console.log(chalk.dim(` ${'─'.repeat(36)} ${'─'.repeat(18)} ${'─'.repeat(8)}`));
74
+
75
+ // Rows
76
+ for (let i = 0; i < sessions.length; i++) {
77
+ console.log(formatSessionRow(sessions[i], i === 0));
78
+ }
79
+
80
+ // Footer hint
81
+ const latestId = sessions[0].id;
82
+ const shortId = latestId.slice(0, 8);
83
+ console.log('');
84
+ console.log(chalk.dim(` Resume latest: ${chalk.white(`flow --resume`)}`));
85
+ console.log(chalk.dim(` Resume by ID: ${chalk.white(`flow --resume ${shortId}`)}`));
86
+ console.log('');
87
+ });
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  upgradeCommand,
18
18
  } from './commands/flow-command.js';
19
19
  import { hookCommand } from './commands/hook-command.js';
20
+ import { sessionsCommand } from './commands/sessions-command.js';
20
21
  import { settingsCommand } from './commands/settings-command.js';
21
22
  import { UserCancelledError } from './utils/errors.js';
22
23
 
@@ -73,6 +74,7 @@ export function createCLI(): Command {
73
74
  program.addCommand(doctorCommand);
74
75
  program.addCommand(upgradeCommand);
75
76
  program.addCommand(hookCommand);
77
+ program.addCommand(sessionsCommand);
76
78
  program.addCommand(settingsCommand);
77
79
 
78
80
  return program;
@@ -11,9 +11,10 @@ import {
11
11
  pathUtils,
12
12
  yamlUtils,
13
13
  } from '../utils/config/target-utils.js';
14
- import { CLIError } from '../utils/errors.js';
14
+ import { CLIError, ChildProcessExitError } from '../utils/errors.js';
15
15
  import { sanitize } from '../utils/security/security.js';
16
16
  import { DEFAULT_CLAUDE_CODE_ENV } from './functional/claude-code-logic.js';
17
+ import { findLastSessionId } from './functional/claude-session.js';
17
18
  import {
18
19
  detectTargetConfig,
19
20
  stripFrontMatter,
@@ -32,11 +33,6 @@ interface ClaudeCodeAgentMetadata {
32
33
  model?: string;
33
34
  }
34
35
 
35
- /** Error with exit code from child process */
36
- interface ProcessExitError extends Error {
37
- code: number | null;
38
- }
39
-
40
36
  /** Type guard for Node.js errors with errno/code properties */
41
37
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {
42
38
  return error instanceof Error && 'code' in error;
@@ -211,6 +207,24 @@ export const claudeCodeTarget: Target = {
211
207
 
212
208
  Please begin your response with a comprehensive summary of all the instructions and context provided above.`;
213
209
 
210
+ // Resolve session ID for --resume (auto-detect or passthrough)
211
+ let resolvedResumeId: string | undefined;
212
+ if (options.resume) {
213
+ if (typeof options.resume === 'string') {
214
+ resolvedResumeId = options.resume;
215
+ } else {
216
+ // Auto-resolve: find last session for current project
217
+ const lastSession = await findLastSessionId(process.cwd());
218
+ if (lastSession) {
219
+ resolvedResumeId = lastSession;
220
+ if (options.verbose) {
221
+ console.log(chalk.dim(` Resolved session: ${resolvedResumeId}`));
222
+ }
223
+ }
224
+ // If null, fall through to bare --resume (Claude's own picker as fallback)
225
+ }
226
+ }
227
+
214
228
  if (options.dryRun) {
215
229
  // Build the command for display
216
230
  const dryRunArgs = ['claude', '--dangerously-skip-permissions'];
@@ -222,8 +236,8 @@ Please begin your response with a comprehensive summary of all the instructions
222
236
  }
223
237
  if (options.resume) {
224
238
  dryRunArgs.push('--resume');
225
- if (typeof options.resume === 'string') {
226
- dryRunArgs.push(options.resume);
239
+ if (resolvedResumeId) {
240
+ dryRunArgs.push(resolvedResumeId);
227
241
  }
228
242
  }
229
243
  dryRunArgs.push('--system-prompt', '"<agent content>"');
@@ -255,8 +269,8 @@ Please begin your response with a comprehensive summary of all the instructions
255
269
  }
256
270
  if (options.resume) {
257
271
  args.push('--resume');
258
- if (typeof options.resume === 'string') {
259
- args.push(options.resume);
272
+ if (resolvedResumeId) {
273
+ args.push(resolvedResumeId);
260
274
  }
261
275
  }
262
276
 
@@ -299,9 +313,9 @@ Please begin your response with a comprehensive summary of all the instructions
299
313
  if (code === 0) {
300
314
  resolve();
301
315
  } else {
302
- const error = new Error(`Claude Code exited with code ${code}`) as ProcessExitError;
303
- error.code = code;
304
- reject(error);
316
+ // Child already displayed its error via inherited stdio use
317
+ // ChildProcessExitError so upstream handlers exit silently.
318
+ reject(new ChildProcessExitError(code ?? 1));
305
319
  }
306
320
  });
307
321
 
@@ -313,13 +327,15 @@ Please begin your response with a comprehensive summary of all the instructions
313
327
  });
314
328
  });
315
329
  } catch (error: unknown) {
330
+ // ChildProcessExitError means the child already printed its own error —
331
+ // let it propagate unmodified so upstream can exit silently.
332
+ if (error instanceof ChildProcessExitError) {
333
+ throw error;
334
+ }
316
335
  if (isNodeError(error)) {
317
336
  if (error.code === 'ENOENT') {
318
337
  throw new CLIError('Claude Code not found. Please install it first.', 'CLAUDE_NOT_FOUND');
319
338
  }
320
- if (error.code !== undefined) {
321
- throw new CLIError(`Claude Code exited with code ${error.code}`, 'CLAUDE_ERROR');
322
- }
323
339
  throw new CLIError(`Failed to execute Claude Code: ${error.message}`, 'CLAUDE_ERROR');
324
340
  }
325
341
  if (error instanceof Error) {
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Claude Code Session Resolver
3
+ *
4
+ * Resolves session IDs from Claude Code's data files, bypassing the
5
+ * built-in session picker which is unreliable with large project directories.
6
+ *
7
+ * Data model:
8
+ * - ~/.claude/projects/{encoded-path}/ — session .jsonl files (filename = session ID)
9
+ * - ~/.claude/history.jsonl — global log with {timestamp, project, sessionId} per user message
10
+ * - Snapshot-only files have type: "file-history-snapshot" and are tiny (<5KB)
11
+ * - Real sessions have type: "user" messages and are much larger
12
+ */
13
+
14
+ import fs from 'node:fs/promises';
15
+ import { open } from 'node:fs/promises';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ /** Minimum file size in bytes to consider a session file "real" (not just a snapshot) */
20
+ const MIN_SESSION_SIZE_BYTES = 2048;
21
+
22
+ /** Number of bytes to read from the tail of history.jsonl */
23
+ const HISTORY_TAIL_BYTES = 65_536;
24
+
25
+ /** Session info returned by listProjectSessions */
26
+ export interface SessionInfo {
27
+ id: string;
28
+ modifiedAt: Date;
29
+ sizeBytes: number;
30
+ }
31
+
32
+ /**
33
+ * Encode a project path the same way Claude Code does.
34
+ * /Users/kyle/flow → -Users-kyle-flow
35
+ */
36
+ function encodeProjectPath(projectPath: string): string {
37
+ return projectPath.replace(/\//g, '-');
38
+ }
39
+
40
+ /**
41
+ * Get the Claude Code home directory.
42
+ */
43
+ function getClaudeHome(): string {
44
+ return path.join(os.homedir(), '.claude');
45
+ }
46
+
47
+ /**
48
+ * Find the most recent session ID for a project by reading the tail of history.jsonl.
49
+ *
50
+ * Reads only the last ~64KB of the file (not the full 20MB+ file) for performance.
51
+ * Returns null if no sessions found for the given project path.
52
+ */
53
+ export async function findLastSessionId(projectPath: string): Promise<string | null> {
54
+ const historyPath = path.join(getClaudeHome(), 'history.jsonl');
55
+
56
+ let fd: Awaited<ReturnType<typeof open>> | null = null;
57
+ try {
58
+ fd = await open(historyPath, 'r');
59
+ const stat = await fd.stat();
60
+
61
+ if (stat.size === 0) {
62
+ return null;
63
+ }
64
+
65
+ // Read the tail of the file
66
+ const readSize = Math.min(HISTORY_TAIL_BYTES, stat.size);
67
+ const offset = stat.size - readSize;
68
+ const buffer = Buffer.alloc(readSize);
69
+ await fd.read(buffer, 0, readSize, offset);
70
+
71
+ const content = buffer.toString('utf-8');
72
+ const lines = content.split('\n').filter(Boolean);
73
+
74
+ // Walk backwards through lines to find the last session for this project
75
+ for (let i = lines.length - 1; i >= 0; i--) {
76
+ const line = lines[i];
77
+
78
+ // Skip partial lines at the start of the buffer (if we started mid-line)
79
+ if (i === 0 && offset > 0) {
80
+ // First line in buffer may be truncated — skip it
81
+ continue;
82
+ }
83
+
84
+ try {
85
+ const entry = JSON.parse(line);
86
+ if (entry.sessionId && entry.project === projectPath) {
87
+ return entry.sessionId;
88
+ }
89
+ } catch {
90
+ // Skip malformed lines
91
+ continue;
92
+ }
93
+ }
94
+
95
+ return null;
96
+ } catch (error) {
97
+ // File doesn't exist or can't be read — no sessions
98
+ if (isNodeError(error) && error.code === 'ENOENT') {
99
+ return null;
100
+ }
101
+ throw error;
102
+ } finally {
103
+ await fd?.close();
104
+ }
105
+ }
106
+
107
+ /**
108
+ * List all real sessions for a project from the project directory.
109
+ *
110
+ * Reads ~/.claude/projects/{encoded-path}/, stats each .jsonl file,
111
+ * filters out snapshot-only files (< 2KB), returns sorted by mtime descending.
112
+ */
113
+ export async function listProjectSessions(projectPath: string): Promise<SessionInfo[]> {
114
+ const encoded = encodeProjectPath(projectPath);
115
+ const projectDir = path.join(getClaudeHome(), 'projects', encoded);
116
+
117
+ let entries: Awaited<ReturnType<typeof fs.readdir>>;
118
+ try {
119
+ entries = await fs.readdir(projectDir);
120
+ } catch (error) {
121
+ if (isNodeError(error) && error.code === 'ENOENT') {
122
+ return [];
123
+ }
124
+ throw error;
125
+ }
126
+
127
+ const jsonlFiles = entries.filter((f) => f.endsWith('.jsonl'));
128
+
129
+ // Stat all files in parallel
130
+ const statResults = await Promise.all(
131
+ jsonlFiles.map(async (filename) => {
132
+ const filePath = path.join(projectDir, filename);
133
+ try {
134
+ const stat = await fs.stat(filePath);
135
+ return {
136
+ id: filename.replace(/\.jsonl$/, ''),
137
+ modifiedAt: stat.mtime,
138
+ sizeBytes: stat.size,
139
+ };
140
+ } catch {
141
+ // File may have been deleted between readdir and stat
142
+ return null;
143
+ }
144
+ })
145
+ );
146
+
147
+ return statResults
148
+ .filter((s): s is SessionInfo => s !== null && s.sizeBytes >= MIN_SESSION_SIZE_BYTES)
149
+ .sort((a, b) => b.modifiedAt.getTime() - a.modifiedAt.getTime());
150
+ }
151
+
152
+ /** Type guard for Node.js errors with errno/code properties */
153
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
154
+ return error instanceof Error && 'code' in error;
155
+ }
@@ -20,3 +20,15 @@ export class CLIError extends Error {
20
20
  this.name = 'CLIError';
21
21
  }
22
22
  }
23
+
24
+ /**
25
+ * Child process exited with non-zero code.
26
+ * Since stdio is inherited, the child already displayed its own error —
27
+ * upstream handlers should cleanup and exit silently without printing anything.
28
+ */
29
+ export class ChildProcessExitError extends Error {
30
+ constructor(public exitCode: number) {
31
+ super(`Child process exited with code ${exitCode}`);
32
+ this.name = 'ChildProcessExitError';
33
+ }
34
+ }