Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/loop/codex-app-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion src/loop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ const runIterations = async (

export const runLoop = async (task: string, opts: Options): Promise<void> => {
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;
Expand Down
8 changes: 3 additions & 5 deletions src/loop/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -94,7 +92,7 @@ const buildCommand = (
"stream-json",
"--verbose",
"--model",
claudeModel,
DEFAULT_CLAUDE_MODEL,
],
cmd: "claude",
};
Expand Down
168 changes: 131 additions & 37 deletions src/loop/tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 => ({
Expand Down Expand Up @@ -155,40 +219,22 @@ const defaultDeps = (): TmuxDeps => ({
},
});

export const runInTmux = (
argv: string[],
overrides: Partial<TmuxDeps> = {}
): 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",
Expand All @@ -199,30 +245,78 @@ 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<TmuxDeps> = {}
): 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(
"Failed to start tmux session: no free session name found."
);
}

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 = {
Expand Down
21 changes: 20 additions & 1 deletion tests/loop/runner.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -53,6 +54,11 @@ let runAgent: (
prompt: string,
opts: Options
) => Promise<RunResult>;
let buildCommand: (
agent: string,
prompt: string,
model: string
) => { args: string[]; cmd: string };
const startAppServer: MockFn<() => Promise<void>> = mock(async () => undefined);
const useAppServer: MockFn<() => boolean> = mock(
() => process.env[CODEX_TRANSPORT_ENV] !== CODEX_TRANSPORT_EXEC
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading