Skip to content

Commit 6748292

Browse files
centdixclaude
andauthored
fix: resume agent sessions on reopen (#116)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 6caf670 commit 6748292

4 files changed

Lines changed: 145 additions & 5 deletions

File tree

backend/src/__tests__/agent-service.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,28 @@ describe("agent-service command builders", () => {
2828
expect(claude).toContain("set +a");
2929
expect(claude).toContain("claude");
3030
expect(claude).toContain("fix the tests");
31+
expect(claude).not.toContain("--continue");
3132
expect(claude).not.toContain("agent-started");
3233
expect(claude).not.toContain("title-changed");
3334
expect(claude).not.toContain("runtime-error");
3435
});
3536

37+
it("uses claude continue on resume without replaying the initial prompt", () => {
38+
const command = buildAgentPaneCommand({
39+
agent: "claude",
40+
runtimeEnvPath: "/tmp/gitdir/webmux/runtime.env",
41+
yolo: true,
42+
systemPrompt: "stay focused",
43+
prompt: "fix the tests",
44+
launchMode: "resume",
45+
});
46+
47+
expect(command).toContain("claude --dangerously-skip-permissions --continue");
48+
expect(command).not.toContain("--append-system-prompt");
49+
expect(command).not.toContain("fix the tests");
50+
expect(command).not.toContain("stay focused");
51+
});
52+
3653
it("builds docker commands that exec inside the container", () => {
3754
const shell = buildDockerShellCommand(
3855
"wm-feature-container",
@@ -56,6 +73,22 @@ describe("agent-service command builders", () => {
5673
expect(agent).not.toContain("agent-stopped");
5774
});
5875

76+
it("uses codex resume --last on resume without replaying the initial prompt", () => {
77+
const command = buildAgentPaneCommand({
78+
agent: "codex",
79+
runtimeEnvPath: "/tmp/gitdir/webmux/runtime.env",
80+
yolo: true,
81+
systemPrompt: "stay focused",
82+
prompt: "ship the fix",
83+
launchMode: "resume",
84+
});
85+
86+
expect(command).toContain("codex --yolo resume --last");
87+
expect(command).not.toContain("developer_instructions=");
88+
expect(command).not.toContain("ship the fix");
89+
expect(command).not.toContain("stay focused");
90+
});
91+
5992
it("adds the claude permissions bypass flag only when profile yolo is enabled", () => {
6093
const normal = buildAgentPaneCommand({
6194
agent: "claude",

backend/src/__tests__/lifecycle-service.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,9 +476,94 @@ describe("LifecycleService", () => {
476476
expect(meta).not.toBeNull();
477477
expect(meta?.branch).toBe("feature-open");
478478
expect(tmux.listWindows()[0]?.windowName).toBe(buildWorktreeWindowName("feature-open"));
479+
expect(tmux.commands[0]?.command).toContain("claude");
480+
expect(tmux.commands[0]?.command).not.toContain("--continue");
479481
expect(runtime.getWorktreeByBranch("feature-open")?.worktreeId).toBe(opened.worktreeId);
480482
});
481483

484+
it("reopens a managed claude worktree with claude continue", async () => {
485+
const repoRoot = await initRepo();
486+
const runtime = new ProjectRuntime();
487+
const tmux = new FakeTmuxGateway();
488+
const lifecycle = makeLifecycleService(
489+
repoRoot,
490+
tmux,
491+
runtime,
492+
new FakeDockerGateway(),
493+
new FakeHookRunner(),
494+
{
495+
...TEST_CONFIG,
496+
profiles: {
497+
...TEST_CONFIG.profiles,
498+
default: {
499+
...TEST_CONFIG.profiles.default,
500+
systemPrompt: "Database: ${FRONTEND_PORT}",
501+
},
502+
},
503+
},
504+
);
505+
506+
await lifecycle.createWorktree({
507+
branch: "feature-continue",
508+
prompt: "fix the tests",
509+
});
510+
511+
tmux.commands.length = 0;
512+
await lifecycle.closeWorktree("feature-continue");
513+
await lifecycle.openWorktree("feature-continue");
514+
515+
const agentCommand = tmux.commands.at(-1)?.command;
516+
517+
expect(agentCommand).toContain("claude --continue");
518+
expect(agentCommand).not.toContain("--append-system-prompt");
519+
expect(agentCommand).not.toContain("Database:");
520+
expect(agentCommand).not.toContain("fix the tests");
521+
});
522+
523+
it("reopens a managed codex worktree with codex resume --last", async () => {
524+
const repoRoot = await initRepo();
525+
const runtime = new ProjectRuntime();
526+
const tmux = new FakeTmuxGateway();
527+
const lifecycle = makeLifecycleService(
528+
repoRoot,
529+
tmux,
530+
runtime,
531+
new FakeDockerGateway(),
532+
new FakeHookRunner(),
533+
{
534+
...TEST_CONFIG,
535+
workspace: {
536+
...TEST_CONFIG.workspace,
537+
defaultAgent: "codex",
538+
},
539+
profiles: {
540+
...TEST_CONFIG.profiles,
541+
default: {
542+
...TEST_CONFIG.profiles.default,
543+
yolo: true,
544+
systemPrompt: "Database: ${FRONTEND_PORT}",
545+
},
546+
},
547+
},
548+
);
549+
550+
await lifecycle.createWorktree({
551+
branch: "feature-codex-resume",
552+
prompt: "ship the fix",
553+
});
554+
555+
tmux.commands.length = 0;
556+
await lifecycle.closeWorktree("feature-codex-resume");
557+
await lifecycle.openWorktree("feature-codex-resume");
558+
559+
const agentCommand = tmux.commands.at(-1)?.command;
560+
561+
expect(agentCommand).toContain("codex --yolo resume --last");
562+
expect(agentCommand).not.toContain("developer_instructions=");
563+
expect(agentCommand).not.toContain("Database:");
564+
expect(agentCommand).not.toContain("ship the fix");
565+
});
566+
482567
it("closes the tmux window without removing the worktree or branch", async () => {
483568
const repoRoot = await initRepo();
484569
const runtime = new ProjectRuntime();

backend/src/services/agent-service.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { AgentKind } from "../domain/config";
22

3+
export type AgentLaunchMode = "fresh" | "resume";
4+
35
function quoteShell(value: string): string {
46
return `'${value.replaceAll("'", "'\\''")}'`;
57
}
@@ -13,18 +15,25 @@ function buildAgentInvocation(input: {
1315
yolo?: boolean;
1416
systemPrompt?: string;
1517
prompt?: string;
18+
launchMode?: AgentLaunchMode;
1619
}): string {
17-
const promptSuffix = input.prompt ? ` ${quoteShell(input.prompt)}` : "";
18-
1920
if (input.agent === "codex") {
2021
const yoloFlag = input.yolo ? " --yolo" : "";
22+
if (input.launchMode === "resume") {
23+
return `codex${yoloFlag} resume --last`;
24+
}
25+
const promptSuffix = input.prompt ? ` ${quoteShell(input.prompt)}` : "";
2126
if (input.systemPrompt) {
2227
return `codex${yoloFlag} -c ${quoteShell(`developer_instructions=${input.systemPrompt}`)}${promptSuffix}`;
2328
}
2429
return `codex${yoloFlag}${promptSuffix}`;
2530
}
2631

2732
const yoloFlag = input.yolo ? " --dangerously-skip-permissions" : "";
33+
if (input.launchMode === "resume") {
34+
return `claude${yoloFlag} --continue`;
35+
}
36+
const promptSuffix = input.prompt ? ` ${quoteShell(input.prompt)}` : "";
2837
if (input.systemPrompt) {
2938
return `claude${yoloFlag} --append-system-prompt ${quoteShell(input.systemPrompt)}${promptSuffix}`;
3039
}
@@ -37,6 +46,7 @@ function buildAgentCommand(input: {
3746
yolo?: boolean;
3847
systemPrompt?: string;
3948
prompt?: string;
49+
launchMode?: AgentLaunchMode;
4050
}): string {
4151
return `${buildRuntimeBootstrap(input.runtimeEnvPath)}; ${buildAgentInvocation(input)}`;
4252
}
@@ -62,6 +72,7 @@ export function buildAgentPaneCommand(input: {
6272
yolo?: boolean;
6373
systemPrompt?: string;
6474
prompt?: string;
75+
launchMode?: AgentLaunchMode;
6576
}): string {
6677
return buildAgentCommand(input);
6778
}
@@ -87,6 +98,7 @@ export function buildDockerAgentPaneCommand(input: {
8798
yolo?: boolean;
8899
systemPrompt?: string;
89100
prompt?: string;
101+
launchMode?: AgentLaunchMode;
90102
}): string {
91103
return buildDockerExecCommand(
92104
input.containerName,

backend/src/services/lifecycle-service.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type { WorktreeCreationPhase, WorktreeMeta } from "../domain/model";
2121
import { allocateServicePorts, isValidBranchName, isValidEnvKey } from "../domain/policies";
2222
import type { AutoNameGenerator } from "./auto-name-service";
2323
import {
24+
type AgentLaunchMode,
2425
buildAgentPaneCommand,
2526
buildDockerAgentPaneCommand,
2627
buildDockerShellCommand,
@@ -184,6 +185,7 @@ export class LifecycleService {
184185
initialized,
185186
worktreePath,
186187
prompt: input.prompt,
188+
launchMode: "fresh",
187189
});
188190

189191
await this.reportCreateProgress({
@@ -218,6 +220,7 @@ export class LifecycleService {
218220
}> {
219221
try {
220222
const resolved = await this.resolveExistingWorktree(branch);
223+
const launchMode: AgentLaunchMode = resolved.meta ? "resume" : "fresh";
221224
const initialized = resolved.meta
222225
? await this.refreshManagedArtifacts(resolved)
223226
: await this.initializeUnmanagedWorktree(resolved);
@@ -233,6 +236,7 @@ export class LifecycleService {
233236
agent: initialized.meta.agent,
234237
initialized,
235238
worktreePath: resolved.entry.path,
239+
launchMode,
236240
});
237241

238242
await this.deps.reconciliation.reconcile(this.deps.projectRoot);
@@ -467,6 +471,7 @@ export class LifecycleService {
467471
initialized: InitializeManagedWorktreeResult;
468472
worktreePath: string;
469473
prompt?: string;
474+
launchMode: AgentLaunchMode;
470475
}): Promise<void> {
471476
if (input.profile.runtime === "docker") {
472477
const dockerProfile = this.requireDockerProfile(input.profile);
@@ -485,6 +490,7 @@ export class LifecycleService {
485490
initialized: input.initialized,
486491
worktreePath: input.worktreePath,
487492
prompt: input.prompt,
493+
launchMode: input.launchMode,
488494
containerName,
489495
}));
490496
return;
@@ -497,6 +503,7 @@ export class LifecycleService {
497503
initialized: input.initialized,
498504
worktreePath: input.worktreePath,
499505
prompt: input.prompt,
506+
launchMode: input.launchMode,
500507
}));
501508
}
502509

@@ -507,9 +514,10 @@ export class LifecycleService {
507514
initialized: InitializeManagedWorktreeResult;
508515
worktreePath: string;
509516
prompt?: string;
517+
launchMode: AgentLaunchMode;
510518
containerName?: string;
511519
}) {
512-
const systemPrompt = input.profile.systemPrompt
520+
const systemPrompt = input.launchMode === "fresh" && input.profile.systemPrompt
513521
? expandTemplate(input.profile.systemPrompt, input.initialized.runtimeEnv)
514522
: undefined;
515523
const containerName = input.containerName;
@@ -530,7 +538,8 @@ export class LifecycleService {
530538
runtimeEnvPath: input.initialized.paths.runtimeEnvPath,
531539
yolo: input.profile.yolo === true,
532540
systemPrompt,
533-
prompt: input.prompt,
541+
prompt: input.launchMode === "fresh" ? input.prompt : undefined,
542+
launchMode: input.launchMode,
534543
}),
535544
shell: buildDockerShellCommand(
536545
containerName,
@@ -544,7 +553,8 @@ export class LifecycleService {
544553
runtimeEnvPath: input.initialized.paths.runtimeEnvPath,
545554
yolo: input.profile.yolo === true,
546555
systemPrompt,
547-
prompt: input.prompt,
556+
prompt: input.launchMode === "fresh" ? input.prompt : undefined,
557+
launchMode: input.launchMode,
548558
}),
549559
shell: buildManagedShellCommand(input.initialized.paths.runtimeEnvPath),
550560
},

0 commit comments

Comments
 (0)