Skip to content

Commit ce6a3f8

Browse files
committed
feat(flow): smart --resume with auto-detection and sessions command
flow --resume now resolves the last session ID automatically by reading the tail of ~/.claude/history.jsonl, bypassing Claude Code's unreliable session picker. Falls back to Claude's own picker if no sessions found. New `flow sessions` command lists all sessions for the current project with ID, timestamp, and size for easy resume selection.
1 parent 59551e1 commit ce6a3f8

File tree

4 files changed

+267
-4
lines changed

4 files changed

+267
-4
lines changed
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

packages/flow/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
upgradeCommand,
1818
} from './commands/flow-command.js';
1919
import { hookCommand } from './commands/hook-command.js';
20+
import { sessionsCommand } from './commands/sessions-command.js';
2021
import { settingsCommand } from './commands/settings-command.js';
2122
import { UserCancelledError } from './utils/errors.js';
2223

@@ -73,6 +74,7 @@ export function createCLI(): Command {
7374
program.addCommand(doctorCommand);
7475
program.addCommand(upgradeCommand);
7576
program.addCommand(hookCommand);
77+
program.addCommand(sessionsCommand);
7678
program.addCommand(settingsCommand);
7779

7880
return program;

packages/flow/src/targets/claude-code.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import { CLIError, ChildProcessExitError } from '../utils/errors.js';
1515
import { sanitize } from '../utils/security/security.js';
1616
import { DEFAULT_CLAUDE_CODE_ENV } from './functional/claude-code-logic.js';
17+
import { findLastSessionId } from './functional/claude-session.js';
1718
import {
1819
detectTargetConfig,
1920
stripFrontMatter,
@@ -206,6 +207,24 @@ export const claudeCodeTarget: Target = {
206207
207208
Please begin your response with a comprehensive summary of all the instructions and context provided above.`;
208209

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+
209228
if (options.dryRun) {
210229
// Build the command for display
211230
const dryRunArgs = ['claude', '--dangerously-skip-permissions'];
@@ -217,8 +236,8 @@ Please begin your response with a comprehensive summary of all the instructions
217236
}
218237
if (options.resume) {
219238
dryRunArgs.push('--resume');
220-
if (typeof options.resume === 'string') {
221-
dryRunArgs.push(options.resume);
239+
if (resolvedResumeId) {
240+
dryRunArgs.push(resolvedResumeId);
222241
}
223242
}
224243
dryRunArgs.push('--system-prompt', '"<agent content>"');
@@ -250,8 +269,8 @@ Please begin your response with a comprehensive summary of all the instructions
250269
}
251270
if (options.resume) {
252271
args.push('--resume');
253-
if (typeof options.resume === 'string') {
254-
args.push(options.resume);
272+
if (resolvedResumeId) {
273+
args.push(resolvedResumeId);
255274
}
256275
}
257276

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

0 commit comments

Comments
 (0)