diff --git a/src/loop/codex-app-server.ts b/src/loop/codex-app-server.ts index 69e0424..65f6e85 100644 --- a/src/loop/codex-app-server.ts +++ b/src/loop/codex-app-server.ts @@ -33,7 +33,7 @@ interface TurnState { const APP_SERVER_CMD = "codex"; const APP_SERVER_ARGS = ["app-server"]; const USER_INPUT_TEXT_ELEMENTS = "text_elements"; -const WAIT_TIMEOUT_MS = 30_000; +const WAIT_TIMEOUT_MS = 600_000; export const CODEX_TRANSPORT_APP_SERVER: TransportMode = "app-server"; export const CODEX_TRANSPORT_EXEC: TransportMode = "exec"; diff --git a/src/loop/main.ts b/src/loop/main.ts index 8cc992f..39d8acf 100644 --- a/src/loop/main.ts +++ b/src/loop/main.ts @@ -78,7 +78,7 @@ const runIterations = async ( export const runLoop = async (task: string, opts: Options): Promise => { const reviewers = resolveReviewers(opts.review, opts.agent); - const interactive = process.stdin.isTTY || Boolean(process.env.TMUX); + const interactive = process.stdin.isTTY; const rl = interactive ? createInterface({ input: process.stdin, output: process.stdout }) : undefined; diff --git a/src/loop/runner.ts b/src/loop/runner.ts index ed5ce93..73db7d2 100644 --- a/src/loop/runner.ts +++ b/src/loop/runner.ts @@ -9,7 +9,7 @@ import { startAppServer, useAppServer, } from "./codex-app-server"; -import { DEFAULT_CLAUDE_MODEL, DEFAULT_CODEX_MODEL } from "./constants"; +import { DEFAULT_CLAUDE_MODEL } from "./constants"; import type { Agent, Options, RunResult } from "./types"; type ExitSignal = "SIGINT" | "SIGTERM"; @@ -77,14 +77,12 @@ const syncSignalHandlers = (): void => { } }; -const buildCommand = ( +export const buildCommand = ( agent: Agent, prompt: string, model: string ): SpawnConfig => { if (agent === "claude") { - const claudeModel = - model && model !== DEFAULT_CODEX_MODEL ? model : DEFAULT_CLAUDE_MODEL; return { args: [ "-p", @@ -94,7 +92,7 @@ const buildCommand = ( "stream-json", "--verbose", "--model", - claudeModel, + DEFAULT_CLAUDE_MODEL, ], cmd: "claude", }; diff --git a/src/loop/tmux.ts b/src/loop/tmux.ts index b654667..03af7da 100644 --- a/src/loop/tmux.ts +++ b/src/loop/tmux.ts @@ -40,9 +40,20 @@ const buildShellCommand = (argv: string[]): string => const stripTmuxFlag = (argv: string[]): string[] => argv.filter((arg) => arg !== TMUX_FLAG); +const isScriptPath = (path: string): boolean => + path.endsWith(".ts") || + path.endsWith(".tsx") || + path.endsWith(".js") || + path.endsWith(".mjs") || + path.endsWith(".cjs"); +const isBunExecutable = (value: string): boolean => { + const file = basename(value); + return file === "bun" || file === "bun.exe"; +}; const MAX_SESSION_ATTEMPTS = 10_000; const SESSION_CONFLICT_RE = /duplicate session|already exists/i; +const NO_SESSION_RE = /no sessions|couldn't find session|session .* not found/i; const resolveRunBase = (cwd: string): string => { try { const repoRoot = runGit(cwd, ["rev-parse", "--show-toplevel"], "ignore"); @@ -114,20 +125,73 @@ const commandExists = (cmd: string): boolean => { const isSessionConflict = (stderr: string): boolean => SESSION_CONFLICT_RE.test(stderr); +const sessionExists = ( + session: string, + spawnFn: TmuxDeps["spawn"] +): boolean => { + const result = spawnFn(["tmux", "has-session", "-t", session]); + return result.exitCode === 0; +}; + +const keepSessionAttached = ( + session: string, + spawnFn: TmuxDeps["spawn"] +): void => { + spawnFn([ + "tmux", + "set-window-option", + "-t", + `${session}:0`, + "remain-on-exit", + "on", + ]); +}; + +const isSessionGone = ( + session: string, + error: unknown, + spawnFn: TmuxDeps["spawn"] +): boolean => + !sessionExists(session, spawnFn) || + (error instanceof Error && NO_SESSION_RE.test(error.message)); + +const buildSessionCommand = ( + deps: TmuxDeps, + env: string[], + forwardedArgv: string[] +): string => { + return buildShellCommand([ + "env", + ...env, + ...deps.launchArgv, + ...forwardedArgv, + ]); +}; + const buildLaunchArgv = ( processArgv: string[] = process.argv, execPath: string = process.execPath ): string[] => { const scriptArg = processArgv[1]; + const commandPath = processArgv[0]; if ( !scriptArg || scriptArg.startsWith("-") || scriptArg.startsWith("/$bunfs/") ) { - return [execPath]; + if ( + !(commandPath && isAbsolute(commandPath)) || + isBunExecutable(commandPath) + ) { + return [execPath]; + } + return [commandPath]; } const scriptPath = isAbsolute(scriptArg) ? scriptArg : resolvePath(scriptArg); - return [execPath, scriptPath]; + if (isBunExecutable(execPath) || isScriptPath(scriptPath)) { + return [execPath, scriptPath]; + } + return commandPath ? [commandPath] : [execPath]; }; const defaultDeps = (): TmuxDeps => ({ @@ -155,40 +219,22 @@ const defaultDeps = (): TmuxDeps => ({ }, }); -export const runInTmux = ( - argv: string[], - overrides: Partial = {} -): boolean => { - if (!argv.includes(TMUX_FLAG)) { - return false; - } - - const deps = { ...defaultDeps(), ...overrides }; - if (deps.env.TMUX) { - return false; - } - - if (!deps.findBinary("tmux")) { - throw new Error(TMUX_MISSING_ERROR); - } - +const findSession = (argv: string[], deps: TmuxDeps): string => { const forwardedArgv = stripTmuxFlag(argv); const runBase = resolveRunBase(deps.cwd); const needsWorktree = argv.includes(WORKTREE_FLAG); - let session = ""; + for (let index = 1; index <= MAX_SESSION_ATTEMPTS; index++) { const candidate = buildRunName(runBase, index); if (needsWorktree && !worktreeAvailable(deps.cwd, candidate)) { continue; } - const command = buildShellCommand([ - "env", - `${RUN_BASE_ENV}=${runBase}`, - `${RUN_ID_ENV}=${index}`, - ...deps.launchArgv, - ...forwardedArgv, - ]); + const command = buildSessionCommand( + deps, + [`${RUN_BASE_ENV}=${runBase}`, `${RUN_ID_ENV}=${index}`], + forwardedArgv + ); const result = deps.spawn([ "tmux", "new-session", @@ -199,17 +245,62 @@ export const runInTmux = ( deps.cwd, command, ]); + if (result.exitCode === 0) { - session = candidate; - break; + return candidate; } - if (isSessionConflict(result.stderr)) { - continue; + + if (!isSessionConflict(result.stderr)) { + const suffix = result.stderr ? `: ${result.stderr}` : "."; + throw new Error(`Failed to start tmux session${suffix}`); } + } - const suffix = result.stderr ? `: ${result.stderr}` : "."; - throw new Error(`Failed to start tmux session${suffix}`); + return ""; +}; + +const attachSessionIfInteractive = ( + session: string, + deps: TmuxDeps +): boolean => { + if (!deps.isInteractive()) { + return true; + } + + try { + deps.attach(session); + return true; + } catch (error: unknown) { + if (isSessionGone(session, error, deps.spawn)) { + deps.log( + `[loop] tmux session "${session}" exited before attach, continuing here.` + ); + return false; + } + throw error instanceof Error + ? error + : new Error(`Failed to attach to tmux session "${session}".`); } +}; + +export const runInTmux = ( + argv: string[], + overrides: Partial = {} +): boolean => { + if (!argv.includes(TMUX_FLAG)) { + return false; + } + + const deps = { ...defaultDeps(), ...overrides }; + if (deps.env.TMUX) { + return false; + } + + if (!deps.findBinary("tmux")) { + throw new Error(TMUX_MISSING_ERROR); + } + + const session = findSession(argv, deps); if (!session) { throw new Error( @@ -217,12 +308,15 @@ export const runInTmux = ( ); } + if (!sessionExists(session, deps.spawn)) { + throw new Error(`tmux session "${session}" exited before attach.`); + } + + keepSessionAttached(session, deps.spawn); + deps.log(`[loop] started tmux session "${session}"`); deps.log(`[loop] attach with: tmux attach -t ${session}`); - if (deps.isInteractive()) { - deps.attach(session); - } - return true; + return attachSessionIfInteractive(session, deps); }; export const tmuxInternals = { diff --git a/tests/loop/runner.test.ts b/tests/loop/runner.test.ts index 3ec52a8..97e599e 100644 --- a/tests/loop/runner.test.ts +++ b/tests/loop/runner.test.ts @@ -1,5 +1,6 @@ import { afterAll, beforeEach, expect, mock, test } from "bun:test"; import { resolve } from "node:path"; +import { DEFAULT_CLAUDE_MODEL } from "../../src/loop/constants"; import type { Options, RunResult } from "../../src/loop/types"; interface AppServerModule { @@ -53,6 +54,11 @@ let runAgent: ( prompt: string, opts: Options ) => Promise; +let buildCommand: ( + agent: string, + prompt: string, + model: string +) => { args: string[]; cmd: string }; const startAppServer: MockFn<() => Promise> = mock(async () => undefined); const useAppServer: MockFn<() => boolean> = mock( () => process.env[CODEX_TRANSPORT_ENV] !== CODEX_TRANSPORT_EXEC @@ -85,7 +91,9 @@ installCodexServerMock(); beforeEach(async () => { mock.restore(); installCodexServerMock(); - ({ runAgent, runnerInternals } = await import(runnerImportPath)); + ({ runAgent, buildCommand, runnerInternals } = await import( + runnerImportPath + )); process.env[CODEX_TRANSPORT_ENV] = ""; startAppServer.mockReset(); startAppServer.mockResolvedValue(undefined); @@ -121,6 +129,17 @@ test("runAgent uses app-server transport by default", async () => { expect(runnerInternals).toBeDefined(); }); +test("buildCommand uses Opus for Claude regardless of codex-model override", () => { + const command = buildCommand( + "claude", + "summarize the issue", + "gpt-5.3-codex-spark" + ); + const modelArgIndex = command.args.indexOf("--model"); + expect(modelArgIndex).toBeGreaterThan(-1); + expect(command.args[modelArgIndex + 1]).toBe(DEFAULT_CLAUDE_MODEL); +}); + test("runAgent honors CODEX_TRANSPORT=exec and uses legacy codex exec", async () => { process.env[CODEX_TRANSPORT_ENV] = CODEX_TRANSPORT_EXEC; runnerInternals.setUseAppServer(() => false); diff --git a/tests/loop/tmux.test.ts b/tests/loop/tmux.test.ts index f45ff43..b01cc7d 100644 --- a/tests/loop/tmux.test.ts +++ b/tests/loop/tmux.test.ts @@ -34,6 +34,8 @@ test("runInTmux starts detached session and strips --tmux", () => { const calls: string[][] = []; const attaches: string[] = []; const logs: string[] = []; + const command = + "'env' 'LOOP_RUN_BASE=repo' 'LOOP_RUN_ID=1' 'bun' '/repo/src/loop.ts' '--proof' 'verify' 'fix bug'"; const delegated = runInTmux(["--tmux", "--proof", "verify", "fix bug"], { attach: (session: string) => { @@ -54,17 +56,24 @@ test("runInTmux starts detached session and strips --tmux", () => { }); expect(delegated).toBe(true); - expect(calls).toEqual([ - [ - "tmux", - "new-session", - "-d", - "-s", - "repo-loop-1", - "-c", - "/repo", - "'env' 'LOOP_RUN_BASE=repo' 'LOOP_RUN_ID=1' 'bun' '/repo/src/loop.ts' '--proof' 'verify' 'fix bug'", - ], + expect(calls[0]).toEqual([ + "tmux", + "new-session", + "-d", + "-s", + "repo-loop-1", + "-c", + "/repo", + command, + ]); + expect(calls[1]).toEqual(["tmux", "has-session", "-t", "repo-loop-1"]); + expect(calls[2]).toEqual([ + "tmux", + "set-window-option", + "-t", + "repo-loop-1:0", + "remain-on-exit", + "on", ]); expect(logs).toContain('[loop] started tmux session "repo-loop-1"'); expect(logs).toContain("[loop] attach with: tmux attach -t repo-loop-1"); @@ -85,6 +94,9 @@ test("runInTmux increments session index on conflicts", () => { if (name === "repo-loop-1") { return { exitCode: 1, stderr: "duplicate session: repo-loop-1" }; } + if (args[0] === "tmux" && args[1] === "has-session") { + return { exitCode: 0, stderr: "" }; + } return { exitCode: 0, stderr: "" }; }, }); @@ -92,6 +104,15 @@ test("runInTmux increments session index on conflicts", () => { expect(delegated).toBe(true); expect(calls[0]?.[4]).toBe("repo-loop-1"); expect(calls[1]?.[4]).toBe("repo-loop-2"); + expect(calls[2]).toEqual(["tmux", "has-session", "-t", "repo-loop-2"]); + expect(calls[3]).toEqual([ + "tmux", + "set-window-option", + "-t", + "repo-loop-2:0", + "remain-on-exit", + "on", + ]); }); test("runInTmux surfaces tmux startup errors", () => { @@ -122,6 +143,21 @@ test("runInTmux skips auto-attach for non-interactive sessions", () => { expect(attaches).toEqual([]); }); +test("runInTmux reports when tmux session exits before attach", () => { + expect(() => + runInTmux(["--tmux", "--proof", "verify"], { + env: {}, + findBinary: () => true, + spawn: (args: string[]) => { + if (args[0] === "tmux" && args[1] === "has-session") { + return { exitCode: 1, stderr: "session not found" }; + } + return { exitCode: 0, stderr: "" }; + }, + }) + ).toThrow('tmux session "loop-loop-1" exited before attach.'); +}); + test("tmux internals strip --tmux from forwarded args", () => { expect(tmuxInternals.stripTmuxFlag(["--tmux", "--proof", "verify"])).toEqual([ "--proof", @@ -153,6 +189,45 @@ test("tmux internals build launch argv for bun-compiled binary", () => { ).toEqual(["/private/tmp/loop"]); }); +test("tmux internals build launch argv for executable with no script arg", () => { + expect( + tmuxInternals.buildLaunchArgv( + ["/usr/local/bin/loop", "--tmux", "--proof", "verify"], + "/usr/local/bin/bun" + ) + ).toEqual(["/usr/local/bin/loop"]); +}); + +test("tmux internals build launch argv for installed executable", () => { + expect( + tmuxInternals.buildLaunchArgv( + [ + "/Users/lume/.local/bin/loop", + "build launch command", + "--tmux", + "--proof", + "verify", + ], + "/Users/lume/.local/bin/loop" + ) + ).toEqual(["/Users/lume/.local/bin/loop"]); +}); + +test("tmux internals build launch argv when bun executes installed binary", () => { + expect( + tmuxInternals.buildLaunchArgv( + [ + "/usr/local/bin/bun", + "/Users/lume/.local/bin/loop", + "--tmux", + "--proof", + "verify", + ], + "/usr/local/bin/bun" + ) + ).toEqual(["/usr/local/bin/bun", "/Users/lume/.local/bin/loop"]); +}); + test("tmux internals quote single quotes safely", () => { expect(tmuxInternals.quoteShellArg("a'b")).toBe("'a'\\''b'"); });