@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 +10 -0
- package/package.json +1 -1
- package/src/commands/sessions-command.ts +87 -0
- package/src/index.ts +2 -0
- package/src/targets/claude-code.ts +23 -4
- package/src/targets/functional/claude-session.ts +155 -0
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
|
@@ -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 (
|
|
221
|
-
dryRunArgs.push(
|
|
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 (
|
|
254
|
-
args.push(
|
|
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
|
+
}
|