@sylphx/flow 3.24.2 → 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,15 @@
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
+
3
13
  ## 3.24.2 (2026-02-17)
4
14
 
5
15
  ### 🐛 Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sylphx/flow",
3
- "version": "3.24.2",
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": {
@@ -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;
@@ -14,6 +14,7 @@ import {
14
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,
@@ -206,6 +207,24 @@ export const claudeCodeTarget: Target = {
206
207
 
207
208
  Please begin your response with a comprehensive summary of all the instructions and context provided above.`;
208
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
+
209
228
  if (options.dryRun) {
210
229
  // Build the command for display
211
230
  const dryRunArgs = ['claude', '--dangerously-skip-permissions'];
@@ -217,8 +236,8 @@ Please begin your response with a comprehensive summary of all the instructions
217
236
  }
218
237
  if (options.resume) {
219
238
  dryRunArgs.push('--resume');
220
- if (typeof options.resume === 'string') {
221
- dryRunArgs.push(options.resume);
239
+ if (resolvedResumeId) {
240
+ dryRunArgs.push(resolvedResumeId);
222
241
  }
223
242
  }
224
243
  dryRunArgs.push('--system-prompt', '"<agent content>"');
@@ -250,8 +269,8 @@ Please begin your response with a comprehensive summary of all the instructions
250
269
  }
251
270
  if (options.resume) {
252
271
  args.push('--resume');
253
- if (typeof options.resume === 'string') {
254
- args.push(options.resume);
272
+ if (resolvedResumeId) {
273
+ args.push(resolvedResumeId);
255
274
  }
256
275
  }
257
276
 
@@ -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
+ }