From c589662d7dad6ad44264e1c8d65e448d121df4f9 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 13 Aug 2026 14:44:52 -0600 Subject: [PATCH 1/3] Re-send the startup prompt when resuming a Codex agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex's resume branch in buildAgentCommand only re-applied flags (MCP config, model, passthrough args) after `codex resume ` — it never rebuilt or appended the startup prompt the way the non-resume branch, and OpenCode/Cursor's resume branches, already do. For a persona/review sub-agent, its entire identity and task live in `--append-system-prompt` inside agentArgs, which normalizeAgentArgsForType extracts into appendedSystemPrompt for non-Claude CLI types. On restart, Codex resume dropped that content entirely: the process came back alive and attached to its old session, but sat at a blank input prompt with no persona, no review target, and no instruction of any kind — indistinguishable from "the play button did nothing" to anyone watching the UI, since the agent never calls dispatch_event again without a new prompt to act on. Confirmed live: launched a real backend-security-review persona agent (codex type), stopped it, and restarted it. Before the fix the resumed tmux pane showed only the bare `› Implement {feature}` placeholder — no persona, no task. After the fix, the same restart re-sends launchGuidance + appendedSystemPrompt + personalityPrompt, and the resumed agent immediately re-reads its review assignment and continues. This also fixes regular (non-persona) Codex sub-agents in the same way — they were silently losing launchGuidance/personality on every resume too, just less visibly since they don't need a fresh instruction to have context to act on. Updates the docs page that previously described this as expected "Codex on resume doesn't get a new prompt" behavior, since it no longer does. --- .../server/src/agents/tmux/command-builder.ts | 35 ++++++++++++------- apps/server/test/tmux-command-builder.test.ts | 33 +++++++++++------ .../app/docs-sections/personalities.tsx | 8 ++--- 3 files changed, 49 insertions(+), 27 deletions(-) diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 3a389360..647fe22a 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -486,11 +486,24 @@ export function buildAgentCommand( ].join(" "); const codexEnvPrefix = `${envPrefix} ${codexDispatchAuthEnv}=${shellEscape(dispatchMcpToken)}`; const modelFlag = model ? `--model ${shellEscape(model)}` : ""; - // Codex resume: `codex resume [OPTIONS] `. Options go before the - // session id so it always binds to SESSION_ID and never slides into the - // trailing [PROMPT] positional. Passthrough args are re-applied here too — - // they carry `--dangerously-bypass-approvals-and-sandbox` for full-access - // agents, which a resumed session would otherwise silently lose. + const codexPromptParts = [ + launchGuidance, + appendedSystemPrompt, + personalityPrompt || null, + initialPrompt, + ].filter(Boolean); + const startupPrompt = codexPromptParts.join("\n\n"); + // Codex resume: `codex resume [OPTIONS] [PROMPT]`. Options go + // before the session id so it always binds to SESSION_ID and never slides + // into the trailing [PROMPT] positional. Passthrough args are re-applied + // here too — they carry `--dangerously-bypass-approvals-and-sandbox` for + // full-access agents, which a resumed session would otherwise silently + // lose. The startup prompt (persona/review identity via appendedSystemPrompt, + // personality, launch guidance) is re-sent as the trailing [PROMPT] the same + // way OpenCode/Cursor resume does — otherwise a resumed persona/review agent + // (whose entire identity and task live in appendedSystemPrompt) comes back + // with a live session but no prompt telling it what to do, and just sits at + // its input line forever. if (resume && cliSessionId) { const resumeFlags = [ codexMcpFlags, @@ -499,15 +512,11 @@ export function buildAgentCommand( ] .filter(Boolean) .join(" "); - return `${codexEnvPrefix} ${shellEscape(cliBin)} resume ${resumeFlags} ${shellEscape(cliSessionId)}`; + const resumeCommand = `${codexEnvPrefix} ${shellEscape(cliBin)} resume ${resumeFlags} ${shellEscape(cliSessionId)}`; + return startupPrompt + ? `${resumeCommand} ${shellEscape(startupPrompt)}` + : resumeCommand; } - const codexPromptParts = [ - launchGuidance, - appendedSystemPrompt, - personalityPrompt || null, - initialPrompt, - ].filter(Boolean); - const startupPrompt = codexPromptParts.join("\n\n"); if (launchArgs.length === 0) { return `${codexEnvPrefix} ${shellEscape(cliBin)} ${codexMcpFlags} ${modelFlag} ${shellEscape(startupPrompt)}`; } diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index bed5c2d2..6cd46a30 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -320,7 +320,7 @@ describe("buildAgentCommand", () => { expect(cmd).toContain("DISPATCH_AUTH_TOKEN="); }); - it("for codex resume, emits 'codex resume ' with the session id last", () => { + it("for codex resume, emits 'codex resume ' with the session id before the trailing prompt", () => { const cmd = buildAgentCommand( baseConfig, "codex", @@ -333,9 +333,13 @@ describe("buildAgentCommand", () => { ); expect(cmd).toContain("'/opt/codex' resume "); expect(cmd).toContain("mcp_servers.dispatch.url="); - // Session id must be the trailing positional so codex binds it to - // SESSION_ID rather than the optional PROMPT argument. - expect(cmd.endsWith("'codex-session'")).toBe(true); + // Session id must come before the trailing [PROMPT] positional so codex + // binds it to SESSION_ID rather than swallowing it into the prompt. + const sessionIdIndex = cmd.indexOf("'codex-session'"); + expect(sessionIdIndex).toBeGreaterThan(-1); + expect(cmd.indexOf("Dispatch startup rules")).toBeGreaterThan( + sessionIdIndex + ); }); it("for codex resume, re-applies passthrough args so full access survives a restart", () => { @@ -350,7 +354,7 @@ describe("buildAgentCommand", () => { { cliSessionId: "codex-session", resume: true } ); expect(cmd).toContain("'--dangerously-bypass-approvals-and-sandbox'"); - expect(cmd.endsWith("'codex-session'")).toBe(true); + expect(cmd.indexOf("'codex-session'")).toBeGreaterThan(-1); }); it("for codex resume with a model, passes --model before the session id", () => { @@ -368,12 +372,12 @@ describe("buildAgentCommand", () => { expect(cmd.indexOf("--model")).toBeLessThan(cmd.indexOf("'codex-session'")); }); - it("for codex resume, does not re-send the startup prompt", () => { + it("for codex resume, re-sends the startup prompt (a resumed persona/review agent needs its identity and task re-injected, not just a live session)", () => { const cmd = buildAgentCommand( baseConfig, "codex", - "standard", - [], + "review", + ["--append-system-prompt", "You are the security-review persona."], "/tmp/media", SESSION, false, @@ -383,8 +387,17 @@ describe("buildAgentCommand", () => { initialPrompt: "do the thing", } ); - expect(cmd).not.toContain("do the thing"); - expect(cmd).not.toContain("Dispatch startup rules"); + // initialPrompt is only ever supplied at original creation, not on + // restart (manager.ts doesn't pass it to startAgent's rebuild) — this + // exercises the codepath the same way a fresh launch would, to prove the + // resume branch no longer special-cases prompt content away. + expect(cmd).toContain("do the thing"); + expect(cmd).toContain("Dispatch startup rules"); + expect(cmd).toContain("You are the security-review persona."); + // Prompt must trail the session id, per `codex resume [OPTIONS] [PROMPT]`. + expect(cmd.indexOf("'codex-session'")).toBeLessThan( + cmd.indexOf("do the thing") + ); }); it("for opencode with fullAccess=true, sets OPENCODE_PERMISSION env", () => { diff --git a/apps/web/src/components/app/docs-sections/personalities.tsx b/apps/web/src/components/app/docs-sections/personalities.tsx index 19328de5..b86b1aa9 100644 --- a/apps/web/src/components/app/docs-sections/personalities.tsx +++ b/apps/web/src/components/app/docs-sections/personalities.tsx @@ -64,10 +64,10 @@ export function PersonalitiesContent() { The active personality is looked up fresh each time a standard agent launches or resumes: it goes into Claude's{" "} --append-system-prompt flag and into the launch prompt - for Codex, Cursor, and OpenCode. The one exception is Codex on resume - — codex resume continues the existing session without a - new prompt, so a resumed Codex agent keeps whatever personality it - launched with. Terminal agents have no CLI to inject into, so the + for Codex, Cursor, and OpenCode — including on resume, where it's + re-sent as the trailing prompt (codex resume accepts an + optional prompt after the session id, and Cursor/OpenCode always + resend theirs too). Terminal agents have no CLI to inject into, so the personality is silently skipped.

From 9e068422f0e1e924277a26389bc24cbdcbfa375b Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 13 Aug 2026 15:02:47 -0600 Subject: [PATCH 2/3] Address review findings: tmux argv-length limit + narrow resume prompt to persona/review agents only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings from the initial fix: 1. (backend-security-review, release-readiness-review) Resuming a persona/review agent whose identity+task pushes the resume command past ~16KB now fails outright — tmux/imsg rejects a single argv entry over that size with "command too long". The restart path was the only launch path handing its command to tmux inline; fresh launch already avoided this by writing a setup script to disk and running `bash `. Fixed at the root: `prepareLaunch` now writes both payload kinds (`setup-script` and `agent-command`) to disk and runs them the same way, removing the size limit entirely rather than just shrinking what gets sent. 2. (release-readiness-review) Resending the full launch prompt on every Codex resume was too broad: it silently submitted a new turn on every ordinary restart (re-processing launch guidance/autoReview directives an agent already has), and specifically re-ran job agents against a stale job run id with no job MCP tools threaded through — job agents also carry their prompt via --append-system-prompt in agentArgs but are role "standard", not "review". Narrowed the resume gate from "has appendedSystemPrompt" to "role === 'review'", and trimmed the resent content to just the persona/review identity/task plus a short note not to redo already-finished work, dropping launchGuidance/personality/initialPrompt from the resume payload entirely. Verified live: resumed a persona/review codex agent with a synthetic 20KB prompt (tmux session stayed alive, script file confirms the full payload), and confirmed a simulated job-agent resume (role standard, --append-system-prompt present) stays a no-op exactly as before this fix. --- .../server/src/agents/tmux/command-builder.ts | 58 ++++++++++++----- apps/server/src/agents/tmux/runtime.ts | 25 +++++--- apps/server/test/agent-runtime-tmux.test.ts | 64 +++++++++++++++---- apps/server/test/db/agent-manager.test.ts | 13 +++- apps/server/test/tmux-command-builder.test.ts | 58 ++++++++++++----- .../app/docs-sections/personalities.tsx | 11 ++-- 6 files changed, 170 insertions(+), 59 deletions(-) diff --git a/apps/server/src/agents/tmux/command-builder.ts b/apps/server/src/agents/tmux/command-builder.ts index 647fe22a..a5e59fa0 100644 --- a/apps/server/src/agents/tmux/command-builder.ts +++ b/apps/server/src/agents/tmux/command-builder.ts @@ -25,6 +25,16 @@ const CLI_BY_AGENT_TYPE: Record< const DISPATCH_API_URL_ENV = "DISPATCH_API_URL"; const DISPATCH_RELEASE_UPDATE_TOKEN_ENV = "DISPATCH_RELEASE_UPDATE_TOKEN"; +/** + * Appended after a persona/review agent's identity/task when Codex resumes a + * stopped session, so it doesn't blindly redo work it already finished. + */ +const CODEX_RESUME_NOTE = + "Session resumed. Continue based on your role and task above using your " + + "existing progress and conversation history — do not restart or redo " + + "work you already completed (for example, do not resubmit a review that " + + "was already submitted)."; + /** * Pull a `--append-system-prompt ` pair out of an arg list (codex / * opencode put system prompts in their own flag). Claude doesn't need this @@ -486,24 +496,33 @@ export function buildAgentCommand( ].join(" "); const codexEnvPrefix = `${envPrefix} ${codexDispatchAuthEnv}=${shellEscape(dispatchMcpToken)}`; const modelFlag = model ? `--model ${shellEscape(model)}` : ""; - const codexPromptParts = [ - launchGuidance, - appendedSystemPrompt, - personalityPrompt || null, - initialPrompt, - ].filter(Boolean); - const startupPrompt = codexPromptParts.join("\n\n"); // Codex resume: `codex resume [OPTIONS] [PROMPT]`. Options go // before the session id so it always binds to SESSION_ID and never slides // into the trailing [PROMPT] positional. Passthrough args are re-applied // here too — they carry `--dangerously-bypass-approvals-and-sandbox` for // full-access agents, which a resumed session would otherwise silently - // lose. The startup prompt (persona/review identity via appendedSystemPrompt, - // personality, launch guidance) is re-sent as the trailing [PROMPT] the same - // way OpenCode/Cursor resume does — otherwise a resumed persona/review agent - // (whose entire identity and task live in appendedSystemPrompt) comes back - // with a live session but no prompt telling it what to do, and just sits at - // its input line forever. + // lose. + // + // The trailing [PROMPT] on resume is deliberately NOT the same startupPrompt + // used on fresh launch below. A resumed session already has launchGuidance + // and personalityPrompt in its history from when it first launched — + // resending them would silently submit a new turn on every ordinary + // restart, burning an unattended turn re-processing guidance it already + // has (and for an autoReview agent, plausibly re-triggering "commit, push, + // open a PR, launch a reviewer" on an agent that already finished that + // flow). So a plain sub-agent gets nothing on resume, exactly as before + // this fix. + // + // Only a `role === "review"` agent (a persona/review sub-agent — the case + // this fix targets) gets a prompt at all, and it's just its own identity/ + // task (appendedSystemPrompt) plus a short resume note telling it not to + // redo already-finished work — otherwise it comes back with a live session + // but nothing telling it what to do, and just sits at its input line + // forever. The gate is on role, not on "has appendedSystemPrompt": job + // agents also carry their prompt via --append-system-prompt in agentArgs + // (role "standard"), and re-sending a job's prompt on resume would re-run + // it against a stale job run id with none of the job's MCP tools threaded + // through — resume must stay a no-op for those, same as before this fix. if (resume && cliSessionId) { const resumeFlags = [ codexMcpFlags, @@ -513,10 +532,17 @@ export function buildAgentCommand( .filter(Boolean) .join(" "); const resumeCommand = `${codexEnvPrefix} ${shellEscape(cliBin)} resume ${resumeFlags} ${shellEscape(cliSessionId)}`; - return startupPrompt - ? `${resumeCommand} ${shellEscape(startupPrompt)}` - : resumeCommand; + if (role !== "review" || !appendedSystemPrompt) return resumeCommand; + const resumePrompt = [appendedSystemPrompt, CODEX_RESUME_NOTE].join("\n\n"); + return `${resumeCommand} ${shellEscape(resumePrompt)}`; } + const codexPromptParts = [ + launchGuidance, + appendedSystemPrompt, + personalityPrompt || null, + initialPrompt, + ].filter(Boolean); + const startupPrompt = codexPromptParts.join("\n\n"); if (launchArgs.length === 0) { return `${codexEnvPrefix} ${shellEscape(cliBin)} ${codexMcpFlags} ${modelFlag} ${shellEscape(startupPrompt)}`; } diff --git a/apps/server/src/agents/tmux/runtime.ts b/apps/server/src/agents/tmux/runtime.ts index 2f634cf6..8735e3f5 100644 --- a/apps/server/src/agents/tmux/runtime.ts +++ b/apps/server/src/agents/tmux/runtime.ts @@ -250,16 +250,25 @@ export function createTmuxRuntime(logger: FastifyBaseLogger): AgentRuntime { * per-session exit file — so the reconciler can read both via * `readSetupLogTail` and `readExitInfo` regardless of how the session * was launched. + * + * Both kinds are also written to disk and run via `bash ` rather + * than embedded inline in the outer `bash -c '...'` string. `tmux + * new-session` hands its arguments to the kernel as a single argv entry + * per arg, and imsg-backed platforms (macOS, some Linux configs) reject + * anything past ~16KB with "command too long" — an `agent-command` + * carrying a large startup prompt (e.g. a persona/review agent's full + * identity + diff, re-sent on resume) can exceed that. Writing to disk + * removes the size limit entirely, matching how `setup-script` already + * avoided it. */ async function prepareLaunch(input: LaunchInput): Promise { - let inner: string; - if (input.payload.kind === "setup-script") { - const scriptPath = setupScriptPath(input.agentId); - await writeFile(scriptPath, input.payload.scriptContent, { mode: 0o755 }); - inner = `bash ${scriptPath}`; - } else { - inner = input.payload.command; - } + const scriptContent = + input.payload.kind === "setup-script" + ? input.payload.scriptContent + : input.payload.command; + const scriptPath = setupScriptPath(input.agentId); + await writeFile(scriptPath, scriptContent, { mode: 0o755 }); + const inner = `bash ${scriptPath}`; const logFile = setupLogPath(input.agentId); const exitFile = exitFilePath(input.sessionName); diff --git a/apps/server/test/agent-runtime-tmux.test.ts b/apps/server/test/agent-runtime-tmux.test.ts index a2cce939..726b07cb 100644 --- a/apps/server/test/agent-runtime-tmux.test.ts +++ b/apps/server/test/agent-runtime-tmux.test.ts @@ -448,7 +448,13 @@ describe("TmuxRuntime — launch (setup-script payload)", () => { }); describe("TmuxRuntime — launch (agent-command payload)", () => { - it("wraps the inline command with stderr-tee and exit-code capture (paths runtime-internal)", async () => { + afterEach(async () => { + await unlink("/tmp/dispatch_setup_agt_y.sh").catch(() => {}); + await unlink("/tmp/dispatch_setup_agt_z.sh").catch(() => {}); + await unlink("/tmp/dispatch_setup_agt_big.sh").catch(() => {}); + }); + + it("writes the command to disk and runs `bash ` — same as setup-script, not embedded inline", async () => { vi.mocked(runCommand).mockImplementation(async (_cmd, args) => { if (args[0] === "has-session") return ok(); return ok(); @@ -462,6 +468,10 @@ describe("TmuxRuntime — launch (agent-command payload)", () => { payload: { kind: "agent-command", command: "/opt/claude --foo" }, }); + const expectedScriptPath = "/tmp/dispatch_setup_agt_y.sh"; + const written = await readFile(expectedScriptPath, "utf-8"); + expect(written).toBe("/opt/claude --foo"); + const newSessionCall = vi .mocked(runCommand) .mock.calls.find(([, a]) => a.includes("new-session")); @@ -470,20 +480,22 @@ describe("TmuxRuntime — launch (agent-command payload)", () => { ] as string; // The wrapper bakes in: stderr tee to the agent's setup log file - // (path derived from agentId), the agent command itself, and - // EXIT:$? to the per-session exit file (path derived from - // sessionName). The manager doesn't supply or know either path. + // (path derived from agentId), `bash ` (not the raw + // command), and EXIT:$? to the per-session exit file (path derived + // from sessionName). The manager doesn't supply or know either path. expect(wrappedCommand).toContain(`tee "/tmp/dispatch_setup_agt_y.log"`); - expect(wrappedCommand).toContain("/opt/claude --foo"); + expect(wrappedCommand).toContain(`bash ${expectedScriptPath}`); + expect(wrappedCommand).not.toContain("/opt/claude --foo"); expect(wrappedCommand).toContain( `echo "EXIT:$?" > /tmp/dispatch_dispatch_agt_y.exit` ); }); - it("escapes embedded single quotes in the command (security regression check)", async () => { - // The wrapper interpolates the command into a single-quoted bash - // string. An attacker-controlled value with `'` in it must not - // break out of the wrapping. + it("preserves embedded single quotes in the written script verbatim (no shell-interpolation mangling)", async () => { + // Since the command now goes to disk via writeFile rather than being + // interpolated into a single-quoted bash string, a `'` in the command + // (e.g. inside a persona prompt) must survive untouched, not get the + // classic '\'' escape treatment. vi.mocked(runCommand).mockImplementation(async (_cmd, args) => { if (args[0] === "has-session") return ok(); return ok(); @@ -500,13 +512,43 @@ describe("TmuxRuntime — launch (agent-command payload)", () => { }, }); + const written = await readFile("/tmp/dispatch_setup_agt_z.sh", "utf-8"); + expect(written).toBe(`echo 'inner-quote'`); + }); + + it("regression: a resume command carrying a large (>16KB) persona/review prompt still launches — tmux argv has a ~16KB limit that inline commands used to hit", async () => { + // Before this file-indirection fix, `agent-command` embedded the whole + // wrapped command as a single tmux argv entry, which tmux/imsg rejects + // past ~16KB — exactly the size a persona/review agent's re-sent + // identity+task prompt can reach on resume. Routing through disk (same + // as setup-script) removes the size limit entirely; this test proves a + // command well past that threshold reaches `runCommand` as a short, + // fixed-size argv regardless of payload size. + vi.mocked(runCommand).mockImplementation(async (_cmd, args) => { + if (args[0] === "has-session") return ok(); + return ok(); + }); + + const largeCommand = `codex resume --model gpt "${"x".repeat(20_000)}"`; + const runtime = createTmuxRuntime(noopLogger); + await runtime.launch({ + sessionName: "dispatch_agt_big", + cwd: "/tmp", + agentId: "agt_big", + payload: { kind: "agent-command", command: largeCommand }, + }); + + const written = await readFile("/tmp/dispatch_setup_agt_big.sh", "utf-8"); + expect(written).toBe(largeCommand); + const newSessionCall = vi .mocked(runCommand) .mock.calls.find(([, a]) => a.includes("new-session")); const wrappedCommand = newSessionCall?.[1]?.[ newSessionCall[1].length - 1 ] as string; - // The classic '\'' escape must appear in place of the embedded `'`. - expect(wrappedCommand).toContain(`echo '\\''inner-quote'\\''`); + // The argv entry tmux actually receives stays small — well under the + // ~16KB limit — no matter how large the underlying command is. + expect(wrappedCommand.length).toBeLessThan(1000); }); }); diff --git a/apps/server/test/db/agent-manager.test.ts b/apps/server/test/db/agent-manager.test.ts index 25cb34bd..e1254ced 100644 --- a/apps/server/test/db/agent-manager.test.ts +++ b/apps/server/test/db/agent-manager.test.ts @@ -1496,7 +1496,13 @@ describe("AgentManager", () => { await manager.startAgent(agent.id); expect(newSessionArgs.length).toBe(1); - const launchCommand = newSessionArgs[0]!.join(" "); + // The restart command is written to disk and run via `bash ` + // (same file-backed launch as a fresh session), not embedded inline + // in the tmux argv — so assert against the written script content. + const launchCommand = await readFile( + `/tmp/dispatch_setup_${agent.id}.sh`, + "utf-8" + ); expect(launchCommand).toContain("--resume"); expect(launchCommand).toContain(sessionId); }); @@ -1526,7 +1532,10 @@ describe("AgentManager", () => { await manager.startAgent(agent.id); expect(newSessionArgs.length).toBe(1); - const launchCommand = newSessionArgs[0]!.join(" "); + const launchCommand = await readFile( + `/tmp/dispatch_setup_${agent.id}.sh`, + "utf-8" + ); expect(launchCommand).not.toContain("--resume"); }); }); diff --git a/apps/server/test/tmux-command-builder.test.ts b/apps/server/test/tmux-command-builder.test.ts index 6cd46a30..8a61408f 100644 --- a/apps/server/test/tmux-command-builder.test.ts +++ b/apps/server/test/tmux-command-builder.test.ts @@ -320,7 +320,7 @@ describe("buildAgentCommand", () => { expect(cmd).toContain("DISPATCH_AUTH_TOKEN="); }); - it("for codex resume, emits 'codex resume ' with the session id before the trailing prompt", () => { + it("for codex resume with no persona/review identity, emits 'codex resume ' with nothing trailing", () => { const cmd = buildAgentCommand( baseConfig, "codex", @@ -333,13 +333,12 @@ describe("buildAgentCommand", () => { ); expect(cmd).toContain("'/opt/codex' resume "); expect(cmd).toContain("mcp_servers.dispatch.url="); - // Session id must come before the trailing [PROMPT] positional so codex - // binds it to SESSION_ID rather than swallowing it into the prompt. - const sessionIdIndex = cmd.indexOf("'codex-session'"); - expect(sessionIdIndex).toBeGreaterThan(-1); - expect(cmd.indexOf("Dispatch startup rules")).toBeGreaterThan( - sessionIdIndex - ); + // A plain sub-agent (no --append-system-prompt in agentArgs) gets no + // trailing prompt on resume, exactly as before this fix — re-sending + // launchGuidance/personality on every ordinary restart would silently + // submit a new turn. Session id stays the trailing positional. + expect(cmd.endsWith("'codex-session'")).toBe(true); + expect(cmd).not.toContain("Dispatch startup rules"); }); it("for codex resume, re-applies passthrough args so full access survives a restart", () => { @@ -354,7 +353,7 @@ describe("buildAgentCommand", () => { { cliSessionId: "codex-session", resume: true } ); expect(cmd).toContain("'--dangerously-bypass-approvals-and-sandbox'"); - expect(cmd.indexOf("'codex-session'")).toBeGreaterThan(-1); + expect(cmd.endsWith("'codex-session'")).toBe(true); }); it("for codex resume with a model, passes --model before the session id", () => { @@ -372,7 +371,7 @@ describe("buildAgentCommand", () => { expect(cmd.indexOf("--model")).toBeLessThan(cmd.indexOf("'codex-session'")); }); - it("for codex resume, re-sends the startup prompt (a resumed persona/review agent needs its identity and task re-injected, not just a live session)", () => { + it("for codex resume, re-sends only the persona/review identity plus a resume note — not launch guidance, personality, or initialPrompt", () => { const cmd = buildAgentCommand( baseConfig, "codex", @@ -384,20 +383,45 @@ describe("buildAgentCommand", () => { { cliSessionId: "codex-session", resume: true, + // initialPrompt is only ever supplied at original creation in + // practice (manager.ts doesn't pass it to startAgent's rebuild), + // but exercise it here to prove resume deliberately excludes it. initialPrompt: "do the thing", + personalityPrompt: "Be extra terse.", } ); - // initialPrompt is only ever supplied at original creation, not on - // restart (manager.ts doesn't pass it to startAgent's rebuild) — this - // exercises the codepath the same way a fresh launch would, to prove the - // resume branch no longer special-cases prompt content away. - expect(cmd).toContain("do the thing"); - expect(cmd).toContain("Dispatch startup rules"); expect(cmd).toContain("You are the security-review persona."); + expect(cmd).toContain("Session resumed."); + expect(cmd).toContain("do not resubmit a review"); + expect(cmd).not.toContain("Dispatch startup rules"); + expect(cmd).not.toContain("Be extra terse."); + expect(cmd).not.toContain("do the thing"); // Prompt must trail the session id, per `codex resume [OPTIONS] [PROMPT]`. expect(cmd.indexOf("'codex-session'")).toBeLessThan( - cmd.indexOf("do the thing") + cmd.indexOf("Session resumed.") + ); + }); + + it("for codex resume, does NOT re-send the prompt for a standard-role agent even when agentArgs carries --append-system-prompt (job agents)", () => { + // Job agents are role "standard" but, like persona/review agents, store + // their entire prompt via --append-system-prompt in agentArgs (see + // jobs/service.ts's buildJobPrompt). The resume gate must key off role, + // not "does appendedSystemPrompt exist" — otherwise a resumed job agent + // re-runs its job against a stale run id with no job MCP tools threaded + // through the resumed session. + const cmd = buildAgentCommand( + baseConfig, + "codex", + "standard", + ["--append-system-prompt", "You are running as a Dispatch Job agent."], + "/tmp/media", + SESSION, + false, + { cliSessionId: "codex-session", resume: true } ); + expect(cmd).not.toContain("You are running as a Dispatch Job agent."); + expect(cmd).not.toContain("Session resumed."); + expect(cmd.endsWith("'codex-session'")).toBe(true); }); it("for opencode with fullAccess=true, sets OPENCODE_PERMISSION env", () => { diff --git a/apps/web/src/components/app/docs-sections/personalities.tsx b/apps/web/src/components/app/docs-sections/personalities.tsx index b86b1aa9..8c1148f4 100644 --- a/apps/web/src/components/app/docs-sections/personalities.tsx +++ b/apps/web/src/components/app/docs-sections/personalities.tsx @@ -64,11 +64,12 @@ export function PersonalitiesContent() { The active personality is looked up fresh each time a standard agent launches or resumes: it goes into Claude's{" "} --append-system-prompt flag and into the launch prompt - for Codex, Cursor, and OpenCode — including on resume, where it's - re-sent as the trailing prompt (codex resume accepts an - optional prompt after the session id, and Cursor/OpenCode always - resend theirs too). Terminal agents have no CLI to inject into, so the - personality is silently skipped. + for Codex, Cursor, and OpenCode. The one exception is Codex on resume + — resuming re-sends only a persona/review agent's own identity and + task (never a plain personality or launch guidance), so an ordinary + restart doesn't silently submit a new turn. Cursor and OpenCode still + resend the personality on resume. Terminal agents have no CLI to + inject into, so the personality is silently skipped.

Three flows intentionally don't get the personality, since From cb7113c7156d7931cee72f78de39292e8dc78e15 Mon Sep 17 00:00:00 2001 From: Brad Harris Date: Thu, 13 Aug 2026 15:09:10 -0600 Subject: [PATCH 3/3] Clean up the restart script file after a successful launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-blocking follow-up from review 687/#1459: prepareLaunch now writes the agent-command payload to /tmp/dispatch_setup_.sh (same as setup-script) instead of embedding it inline, but only the fresh-launch path ever cleaned that file up (completeSetup, fired by the setup script's own callback). A restart has no such callback, so every resumed persona/review agent left its full identity/task prompt sitting in world-readable /tmp indefinitely. startAgent now unlinks the script right after runtime.launch() resolves — by then the fast-fail check has already confirmed the pane didn't die, meaning bash is already past reading the script into the long-running CLI process. --- apps/server/src/agents/manager.ts | 11 ++++++++ apps/server/test/db/agent-manager.test.ts | 34 ++++++++++++++--------- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/apps/server/src/agents/manager.ts b/apps/server/src/agents/manager.ts index 03f77c33..e65b147c 100644 --- a/apps/server/src/agents/manager.ts +++ b/apps/server/src/agents/manager.ts @@ -933,6 +933,17 @@ export class AgentManager { agentId: id, payload: { kind: "agent-command", command: agentCommand }, }); + // The tmux runtime writes agentCommand to /tmp/dispatch_setup_.sh + // and runs `bash ` (see prepareLaunch) rather than embedding it + // inline, so a restart doesn't hit tmux's ~16KB argv limit. Fresh + // launches clean up their equivalent script in completeSetup once the + // setup script's own callback fires; a restart has no such callback, + // so clean up here instead — by the time launch() resolves, the + // fast-fail check has already confirmed the pane didn't die, meaning + // bash is already past reading the script into the long-running CLI + // process. Left otherwise, every restart of a persona/review agent + // would leave its full identity/task prompt sitting in /tmp. + await unlink(`/tmp/dispatch_setup_${id}.sh`).catch(() => {}); await this.setAgentStatus(id, "running", null, tmuxSession); // Re-populate gitContext on every restart so existing agents that diff --git a/apps/server/test/db/agent-manager.test.ts b/apps/server/test/db/agent-manager.test.ts index e1254ced..28af4faa 100644 --- a/apps/server/test/db/agent-manager.test.ts +++ b/apps/server/test/db/agent-manager.test.ts @@ -1483,26 +1483,31 @@ describe("AgentManager", () => { }); const newSessionArgs: string[][] = []; + // The restart command is written to disk and run via `bash ` + // (same file-backed launch as a fresh session), not embedded inline + // in the tmux argv — and startAgent unlinks that script right after a + // successful launch (see manager.ts), so capture its content here, + // inside the mock, while it's still guaranteed to exist on disk. + let launchCommand = ""; vi.mocked(runCommand).mockImplementation(async (_cmd, args) => { if (args[0] === "has-session") { if (newSessionArgs.length === 0) return { exitCode: 1, stdout: "", stderr: "" }; return { exitCode: 0, stdout: "", stderr: "" }; } - if (args.includes("new-session")) newSessionArgs.push(args); + if (args.includes("new-session")) { + newSessionArgs.push(args); + launchCommand = await readFile( + `/tmp/dispatch_setup_${agent.id}.sh`, + "utf-8" + ); + } return { exitCode: 0, stdout: "", stderr: "" }; }); await manager.startAgent(agent.id); expect(newSessionArgs.length).toBe(1); - // The restart command is written to disk and run via `bash ` - // (same file-backed launch as a fresh session), not embedded inline - // in the tmux argv — so assert against the written script content. - const launchCommand = await readFile( - `/tmp/dispatch_setup_${agent.id}.sh`, - "utf-8" - ); expect(launchCommand).toContain("--resume"); expect(launchCommand).toContain(sessionId); }); @@ -1519,23 +1524,26 @@ describe("AgentManager", () => { ); const newSessionArgs: string[][] = []; + let launchCommand = ""; vi.mocked(runCommand).mockImplementation(async (_cmd, args) => { if (args[0] === "has-session") { if (newSessionArgs.length === 0) return { exitCode: 1, stdout: "", stderr: "" }; return { exitCode: 0, stdout: "", stderr: "" }; } - if (args.includes("new-session")) newSessionArgs.push(args); + if (args.includes("new-session")) { + newSessionArgs.push(args); + launchCommand = await readFile( + `/tmp/dispatch_setup_${agent.id}.sh`, + "utf-8" + ); + } return { exitCode: 0, stdout: "", stderr: "" }; }); await manager.startAgent(agent.id); expect(newSessionArgs.length).toBe(1); - const launchCommand = await readFile( - `/tmp/dispatch_setup_${agent.id}.sh`, - "utf-8" - ); expect(launchCommand).not.toContain("--resume"); }); });