Skip to content

Commit 06f84cc

Browse files
committed
feat(engines): add session resuming support for ccr and cursor providers
add resumeSessionId option to command builders and runners implement session ID capture and resume prompt handling update stdin handling to support both new and resumed sessions
1 parent 9042784 commit 06f84cc

File tree

5 files changed

+78
-14
lines changed

5 files changed

+78
-14
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"optionalDependencies": {
3636
"codemachine-darwin-arm64": "0.8.0",
3737
"codemachine-darwin-x64": "0.8.0",
38+
"codemachine-linux-arm64": "0.8.0",
3839
"codemachine-linux-x64": "0.8.0",
3940
"codemachine-windows-x64": "0.8.0",
4041
},

src/infra/engines/providers/ccr/execution/commands.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export interface CcrCommandOptions {
22
workingDir: string;
3-
prompt: string;
3+
resumeSessionId?: string;
44
model?: string;
55
}
66

@@ -43,7 +43,7 @@ function mapModel(model?: string): string | undefined {
4343
}
4444

4545
export function buildCcrExecCommand(options: CcrCommandOptions): CcrCommand {
46-
const { model } = options;
46+
const { resumeSessionId, model } = options;
4747

4848
// Base args: --print for non-interactive mode, similar to Claude but using ccr code
4949
const args: string[] = [
@@ -57,14 +57,18 @@ export function buildCcrExecCommand(options: CcrCommandOptions): CcrCommand {
5757
'bypassPermissions',
5858
];
5959

60+
// Add resume flag if resuming a session
61+
if (resumeSessionId?.trim()) {
62+
args.push('--resume', resumeSessionId.trim());
63+
}
64+
6065
// Add model if specified and valid
6166
const mappedModel = mapModel(model);
6267
if (mappedModel) {
6368
args.push('--model', mappedModel);
6469
}
6570

66-
// Prompt is now passed via stdin instead of as an argument
67-
// Call ccr code - the runner passes cwd and prompt to spawnProcess
71+
// Prompt is passed via stdin
6872
return {
6973
command: 'ccr',
7074
args,

src/infra/engines/providers/ccr/execution/runner.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,13 @@ import { formatThinking, formatCommand, formatResult } from '../../../../../shar
1212
export interface RunCcrOptions {
1313
prompt: string;
1414
workingDir: string;
15+
resumeSessionId?: string;
16+
resumePrompt?: string;
1517
model?: string;
1618
env?: NodeJS.ProcessEnv;
1719
onData?: (chunk: string) => void;
1820
onErrorData?: (chunk: string) => void;
21+
onSessionId?: (sessionId: string) => void;
1922
abortSignal?: AbortSignal;
2023
timeout?: number; // Timeout in milliseconds (default: 1800000ms = 30 minutes)
2124
}
@@ -27,6 +30,20 @@ export interface RunCcrResult {
2730

2831
const ANSI_ESCAPE_SEQUENCE = new RegExp(String.raw`\u001B\[[0-9;?]*[ -/]*[@-~]`, 'g');
2932

33+
/**
34+
* Build the final resume prompt combining steering instruction with user message
35+
*/
36+
function buildResumePrompt(userPrompt?: string): string {
37+
const defaultPrompt = 'Continue from where you left off.';
38+
39+
if (!userPrompt) {
40+
return defaultPrompt;
41+
}
42+
43+
// Combine steering instruction with user's message
44+
return `[USER STEERING] The user paused this session to give you new direction. Continue from where you left off, but prioritize the user's request: "${userPrompt}"`;
45+
}
46+
3047
// Track tool names for associating with results
3148
const toolNameMap = new Map<string, string>();
3249

@@ -105,7 +122,7 @@ function formatStreamJsonLine(line: string): string | null {
105122
}
106123

107124
export async function runCcr(options: RunCcrOptions): Promise<RunCcrResult> {
108-
const { prompt, workingDir, model, env, onData, onErrorData, abortSignal, timeout = 1800000 } = options;
125+
const { prompt, workingDir, resumeSessionId, resumePrompt, model, env, onData, onErrorData, onSessionId, abortSignal, timeout = 1800000 } = options;
109126

110127
if (!prompt) {
111128
throw new Error('runCcr requires a prompt.');
@@ -153,7 +170,7 @@ export async function runCcr(options: RunCcrOptions): Promise<RunCcrResult> {
153170
return result;
154171
};
155172

156-
const { command, args } = buildCcrExecCommand({ workingDir, prompt, model });
173+
const { command, args } = buildCcrExecCommand({ workingDir, resumeSessionId, model });
157174

158175
logger.debug(`CCR runner - prompt length: ${prompt.length}, lines: ${prompt.split('\n').length}`);
159176
logger.debug(`CCR runner - args count: ${args.length}, model: ${model ?? 'default'}`);
@@ -163,6 +180,7 @@ export async function runCcr(options: RunCcrOptions): Promise<RunCcrResult> {
163180

164181
// Track JSON error events (CCR may exit 0 even on errors)
165182
let capturedError: string | null = null;
183+
let sessionIdCaptured = false;
166184

167185
let result;
168186
try {
@@ -171,7 +189,7 @@ export async function runCcr(options: RunCcrOptions): Promise<RunCcrResult> {
171189
args,
172190
cwd: workingDir,
173191
env: mergedEnv,
174-
stdinInput: prompt, // Pass prompt via stdin instead of command-line argument
192+
stdinInput: resumeSessionId ? buildResumePrompt(resumePrompt) : prompt,
175193
onStdout: inheritTTY
176194
? undefined
177195
: (chunk) => {
@@ -188,6 +206,13 @@ export async function runCcr(options: RunCcrOptions): Promise<RunCcrResult> {
188206
// Check for error events (CCR may exit 0 even on errors like invalid model)
189207
try {
190208
const json = JSON.parse(line);
209+
210+
// Capture session ID from first event that contains it
211+
if (!sessionIdCaptured && json.session_id && onSessionId) {
212+
sessionIdCaptured = true;
213+
onSessionId(json.session_id);
214+
}
215+
191216
// Check for error in result type
192217
if (json.type === 'result' && json.is_error && json.result && !capturedError) {
193218
capturedError = json.result;

src/infra/engines/providers/cursor/execution/commands.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export interface CursorCommandOptions {
22
workingDir: string;
3-
prompt: string;
3+
resumeSessionId?: string;
44
model?: string;
55
cursorConfigDir?: string;
66
}
@@ -49,7 +49,7 @@ function mapModel(model?: string): string | undefined {
4949
}
5050

5151
export function buildCursorExecCommand(options: CursorCommandOptions): CursorCommand {
52-
const { model, cursorConfigDir } = options;
52+
const { resumeSessionId, model, cursorConfigDir } = options;
5353

5454
// Base args: -p for print mode, --force, streaming JSON output
5555
const args: string[] = [
@@ -59,6 +59,11 @@ export function buildCursorExecCommand(options: CursorCommandOptions): CursorCom
5959
'stream-json',
6060
];
6161

62+
// Add resume flag if resuming a session
63+
if (resumeSessionId?.trim()) {
64+
args.push(`--resume=${resumeSessionId.trim()}`);
65+
}
66+
6267
// Add model if specified and valid
6368
const mappedModel = mapModel(model);
6469
if (mappedModel) {
@@ -70,8 +75,7 @@ export function buildCursorExecCommand(options: CursorCommandOptions): CursorCom
7075
args.push(cursorConfigDir);
7176
}
7277

73-
// Prompt is now passed via stdin instead of as an argument
74-
// Call cursor-agent directly - the runner passes cwd and prompt to spawnProcess
78+
// Prompt is passed via stdin
7579
return {
7680
command: 'cursor-agent',
7781
args,

src/infra/engines/providers/cursor/execution/runner.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import { debug } from '../../../../../shared/logging/logger.js';
1111
export interface RunCursorOptions {
1212
prompt: string;
1313
workingDir: string;
14+
resumeSessionId?: string;
15+
resumePrompt?: string;
1416
model?: string;
1517
env?: NodeJS.ProcessEnv;
1618
onData?: (chunk: string) => void;
1719
onErrorData?: (chunk: string) => void;
20+
onSessionId?: (sessionId: string) => void;
1821
abortSignal?: AbortSignal;
1922
timeout?: number; // Timeout in milliseconds (default: 1800000ms = 30 minutes)
2023
}
@@ -32,6 +35,20 @@ const toolNameMap = new Map<string, string>();
3235
// Track accumulated thinking text for delta updates
3336
let accumulatedThinking = '';
3437

38+
/**
39+
* Build the final resume prompt combining steering instruction with user message
40+
*/
41+
function buildResumePrompt(userPrompt?: string): string {
42+
const defaultPrompt = 'Continue from where you left off.';
43+
44+
if (!userPrompt) {
45+
return defaultPrompt;
46+
}
47+
48+
// Combine steering instruction with user's message
49+
return `[USER STEERING] The user paused this session to give you new direction. Continue from where you left off, but prioritize the user's request: "${userPrompt}"`;
50+
}
51+
3552
/**
3653
* Formats a Cursor stream-json line for display
3754
*/
@@ -137,7 +154,7 @@ function formatStreamJsonLine(line: string): string | null {
137154
}
138155

139156
export async function runCursor(options: RunCursorOptions): Promise<RunCursorResult> {
140-
const { prompt, workingDir, model, env, onData, onErrorData, abortSignal, timeout = 1800000 } = options;
157+
const { prompt, workingDir, resumeSessionId, resumePrompt, model, env, onData, onErrorData, onSessionId, abortSignal, timeout = 1800000 } = options;
141158

142159
if (!prompt) {
143160
throw new Error('runCursor requires a prompt.');
@@ -187,7 +204,7 @@ export async function runCursor(options: RunCursorOptions): Promise<RunCursorRes
187204

188205
const { command, args } = buildCursorExecCommand({
189206
workingDir,
190-
prompt,
207+
resumeSessionId,
191208
model,
192209
cursorConfigDir
193210
});
@@ -196,14 +213,16 @@ export async function runCursor(options: RunCursorOptions): Promise<RunCursorRes
196213
debug(`Cursor runner - prompt length: ${prompt.length}, lines: ${prompt.split('\n').length}`);
197214
debug(`Cursor runner - args count: ${args.length}, model: ${model ?? 'auto'}`);
198215

216+
let sessionIdCaptured = false;
217+
199218
let result;
200219
try {
201220
result = await spawnProcess({
202221
command,
203222
args,
204223
cwd: workingDir,
205224
env: mergedEnv,
206-
stdinInput: prompt, // Pass prompt via stdin instead of command-line argument
225+
stdinInput: resumeSessionId ? buildResumePrompt(resumePrompt) : prompt,
207226
onStdout: inheritTTY
208227
? undefined
209228
: (chunk) => {
@@ -214,6 +233,17 @@ export async function runCursor(options: RunCursorOptions): Promise<RunCursorRes
214233
for (const line of lines) {
215234
if (!line.trim()) continue;
216235

236+
// Capture session ID from first event that contains it
237+
try {
238+
const json = JSON.parse(line);
239+
if (!sessionIdCaptured && json.session_id && onSessionId) {
240+
sessionIdCaptured = true;
241+
onSessionId(json.session_id);
242+
}
243+
} catch {
244+
// Ignore parse errors
245+
}
246+
217247
const formatted = formatStreamJsonLine(line);
218248
if (formatted) {
219249
onData?.(formatted + '\n');

0 commit comments

Comments
 (0)