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
76 changes: 5 additions & 71 deletions src/loop/tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,10 @@ const CLAUDE_EXIT_OPTION = "No, exit";
const CLAUDE_DEV_CHANNELS_PROMPT = "WARNING: Loading development channels";
const CLAUDE_DEV_CHANNELS_CONFIRM = "I am using this for local development";
const CLAUDE_CHANNEL_SCOPE = "local";
const CLAUDE_PROMPT_INITIAL_POLLS = 8;
const CLAUDE_PROMPT_INITIAL_POLLS = 4;
const CLAUDE_PROMPT_POLL_DELAY_MS = 250;
const CLAUDE_PROMPT_SETTLE_POLLS = 2;
const CODEX_READY_POLL_DELAY_MS = 250;
const CODEX_READY_POLLS = 20;
const CODEX_SEND_FOOTER = "Ctrl+J newline";
const MCP_ALREADY_EXISTS_RE = /already exists/i;
const PROMPT_DISPATCH_DELAY_MS = 500;
const REVIEWER_BOOT_DELAY_MS = 1500;

interface SpawnResult {
exitCode: number;
Expand Down Expand Up @@ -614,8 +609,8 @@ const buildSessionCommand = (

const tmuxStartupMessage = (paired: boolean): string =>
paired
? "[loop] starting paired tmux workspace. This can take a few seconds..."
: "[loop] starting tmux session. This can take a few seconds...";
? "[loop] starting paired tmux workspace..."
: "[loop] starting tmux session...";

const updatePairedManifest = (
deps: TmuxDeps,
Expand Down Expand Up @@ -749,8 +744,6 @@ const detectClaudePrompt = (text: string): "bypass" | "confirm" | undefined => {
return undefined;
};

const codexReady = (text: string): boolean => text.includes(CODEX_SEND_FOOTER);

const unblockClaudePane = async (
session: string,
deps: TmuxDeps
Expand Down Expand Up @@ -793,49 +786,6 @@ const unblockClaudePane = async (
}
};

const waitForCodexReady = async (
session: string,
deps: TmuxDeps
): Promise<void> => {
const pane = `${session}:0.1`;
for (let attempt = 0; attempt < CODEX_READY_POLLS; attempt += 1) {
if (codexReady(deps.capturePane(pane))) {
return;
}
await deps.sleep(CODEX_READY_POLL_DELAY_MS);
}
};

const seedPanePrompt = async (
pane: string,
prompt: string,
deps: TmuxDeps
): Promise<void> => {
const lines = prompt.split("\n");
for (let index = 0; index < lines.length; index += 1) {
deps.sendText(pane, lines[index] ?? "");
if (index < lines.length - 1) {
deps.sendKeys(pane, ["C-j"]);
}
}
await deps.sleep(100);
deps.sendKeys(pane, ["Enter"]);
};

const submitCodexPrompt = async (
session: string,
prompt: string,
deps: TmuxDeps
): Promise<void> => {
const pane = `${session}:0.1`;
await waitForCodexReady(session, deps);
await seedPanePrompt(pane, prompt, deps);
await deps.sleep(CODEX_READY_POLL_DELAY_MS);
if (codexReady(deps.capturePane(pane))) {
deps.sendKeys(pane, ["Enter"]);
}
};

const startPairedSession = async (
deps: TmuxDeps,
launch: PairedTmuxLaunch
Expand Down Expand Up @@ -890,7 +840,8 @@ const startPairedSession = async (
...buildCodexCommand(
codexProxyUrl,
resolveTmuxModel("codex", launch.opts),
launch.opts.codexMcpConfigArgs ?? []
launch.opts.codexMcpConfigArgs ?? [],
hadCodexThread ? undefined : codexPrompt
),
]);

Expand Down Expand Up @@ -927,25 +878,8 @@ const startPairedSession = async (
"even-horizontal",
]);
await unblockClaudePane(session, deps);
await deps.sleep(PROMPT_DISPATCH_DELAY_MS);
const peerPane =
launch.opts.agent === "claude" ? `${session}:0.1` : `${session}:0.0`;
const primaryPane =
launch.opts.agent === "claude" ? `${session}:0.0` : `${session}:0.1`;
const peerPrompt =
launch.opts.agent === "claude" ? codexPrompt : claudePrompt;
const primaryPrompt =
launch.opts.agent === "claude" ? claudePrompt : codexPrompt;

if (!hadCodexThread && peerPane.endsWith(":0.1")) {
await submitCodexPrompt(session, peerPrompt, deps);
}
if (!(hadClaudeSession && hadCodexThread)) {
await deps.sleep(REVIEWER_BOOT_DELAY_MS);
}
if (!hadCodexThread && primaryPane.endsWith(":0.1")) {
await submitCodexPrompt(session, primaryPrompt, deps);
}
deps.spawn(["tmux", "select-pane", "-t", primaryPane]);
return session;
};
Expand Down
208 changes: 170 additions & 38 deletions tests/loop/tmux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,9 +131,7 @@ test("runInTmux starts detached session and strips --tmux", async () => {
"remain-on-exit",
"on",
]);
expect(logs[0]).toBe(
"[loop] starting tmux session. This can take a few seconds..."
);
expect(logs[0]).toBe("[loop] starting tmux session...");
expect(logs).toContain('[loop] started tmux session "repo-loop-1"');
expect(logs).toContain("[loop] attach with: tmux attach -t repo-loop-1");
expect(attaches).toEqual(["repo-loop-1"]);
Expand Down Expand Up @@ -312,7 +310,8 @@ test("runInTmux starts paired tmux panes for Claude and Codex", async () => {
...tmuxInternals.buildCodexCommand(
codexProxyUrl,
"test-model",
codexMcpConfigArgs
codexMcpConfigArgs,
tmuxInternals.buildPrimaryPrompt("Ship feature", opts, "1")
),
]);

Expand Down Expand Up @@ -375,19 +374,8 @@ test("runInTmux starts paired tmux panes for Claude and Codex", async () => {
],
["tmux", "has-session", "-t", "repo-loop-1"],
]);
const typedByPane = new Map<string, string[]>();
for (const entry of typed) {
const values = typedByPane.get(entry.pane) ?? [];
values.push(entry.text);
typedByPane.set(entry.pane, values);
}
expect(typedByPane.get("repo-loop-1:0.0")).toBeUndefined();
expect(typedByPane.get("repo-loop-1:0.1")?.join("\n")).toBe(
tmuxInternals.buildPrimaryPrompt("Ship feature", opts, "1")
);
expect(logs[0]).toBe(
"[loop] starting paired tmux workspace. This can take a few seconds..."
);
expect(typed).toEqual([]);
expect(logs[0]).toBe("[loop] starting paired tmux workspace...");
expect(logs).toContain('[loop] started tmux session "repo-loop-1"');
expect(logs).toContain("[loop] attach with: tmux attach -t repo-loop-1");
expect(manifest.claudeSessionId).toBe("claude-session-1");
Expand Down Expand Up @@ -667,19 +655,97 @@ test("runInTmux starts paired interactive tmux panes without a task", async () =
"/repo",
claudeCommand,
]);
const typedByPane = new Map<string, string[]>();
for (const entry of typed) {
const values = typedByPane.get(entry.pane) ?? [];
values.push(entry.text);
typedByPane.set(entry.pane, values);
}
expect(typedByPane.get("repo-loop-1:0.0")).toBeUndefined();
expect(typedByPane.get("repo-loop-1:0.1")?.join("\n")).toBe(
tmuxInternals.buildInteractivePrimaryPrompt(opts, "1")
);
const codexCommand = tmuxInternals.buildShellCommand([
"env",
...env,
...tmuxInternals.buildCodexCommand(
"ws://127.0.0.1:4600/",
"test-model",
["-c", 'mcp_servers.loop-bridge.command="loop"'],
tmuxInternals.buildInteractivePrimaryPrompt(opts, "1")
),
]);
expect(calls[3]).toEqual([
"tmux",
"split-window",
"-h",
"-t",
"repo-loop-1:0",
"-c",
"/repo",
codexCommand,
]);
expect(typed).toEqual([]);
expect(manifest.tmuxSession).toBe("repo-loop-1");
});

test("runInTmux keeps the no-prompt Claude startup wait short", async () => {
const sleeps: number[] = [];
let sessionStarted = false;
const manifest = createRunManifest({
cwd: "/repo",
mode: "paired",
pid: 1234,
repoId: "repo-123",
runId: "1",
status: "running",
});
const storage = {
manifestPath: "/repo/.loop/runs/1/manifest.json",
repoId: "repo-123",
runDir: "/repo/.loop/runs/1",
runId: "1",
storageRoot: "/repo/.loop/runs",
transcriptPath: "/repo/.loop/runs/1/transcript.jsonl",
};

await runInTmux(
["--tmux"],
{
capturePane: () => "",
cwd: "/repo",
env: {},
findBinary: () => true,
getCodexAppServerUrl: () => "ws://127.0.0.1:4500",
getLastCodexThreadId: () => "codex-thread-1",
isInteractive: () => false,
launchArgv: ["bun", "/repo/src/cli.ts"],
log: (): void => undefined,
makeClaudeSessionId: () => "claude-session-1",
preparePairedRun: (nextOpts) => {
nextOpts.codexMcpConfigArgs = [
"-c",
'mcp_servers.loop-bridge.command="loop"',
];
return { manifest, storage };
},
sendKeys: (): void => undefined,
sendText: (): void => undefined,
sleep: (ms: number) => {
sleeps.push(ms);
return Promise.resolve();
},
startCodexProxy: () => Promise.resolve("ws://127.0.0.1:4600/"),
startPersistentAgentSession: () => Promise.resolve(undefined),
spawn: (args: string[]) => {
if (args[0] === "tmux" && args[1] === "has-session") {
return sessionStarted
? { exitCode: 0, stderr: "" }
: { exitCode: 1, stderr: "" };
}
if (args[0] === "tmux" && args[1] === "new-session") {
sessionStarted = true;
}
return { exitCode: 0, stderr: "" };
},
updateRunManifest: (_path, update) => update(manifest),
},
{ opts: makePairedOptions({ proof: "" }) }
);

expect(sleeps).toEqual([250, 250, 250]);
});

test("tmux prompts keep the paired review workflow explicit", () => {
const opts = makePairedOptions();
const primaryPrompt = tmuxInternals.buildPrimaryPrompt(
Expand Down Expand Up @@ -860,17 +926,8 @@ test("runInTmux auto-confirms Claude startup prompts in paired mode", async () =
call.keys.length === 1 &&
call.keys[0] === "Enter"
)
).toBe(true);
const typedByPane = new Map<string, string[]>();
for (const entry of typed) {
const values = typedByPane.get(entry.pane) ?? [];
values.push(entry.text);
typedByPane.set(entry.pane, values);
}
expect(typedByPane.get("repo-loop-1:0.0")).toBeUndefined();
expect(typedByPane.get("repo-loop-1:0.1")?.join("\n")).toBe(
tmuxInternals.buildPrimaryPrompt("Ship feature", opts, "1")
);
).toBe(false);
expect(typed).toEqual([]);
});

test("runInTmux confirms wrapped Claude dev-channel prompts", async () => {
Expand Down Expand Up @@ -1119,6 +1176,81 @@ test("runInTmux still confirms Claude trust prompts in paired mode", async () =>
});
});

test("runInTmux still catches a delayed Claude trust prompt", async () => {
const keyCalls: Array<{ keys: string[]; pane: string }> = [];
let sessionStarted = false;
let pollCount = 0;
const manifest = createRunManifest({
cwd: "/repo",
mode: "paired",
pid: 1234,
repoId: "repo-123",
runId: "1",
status: "running",
});
const storage = {
manifestPath: "/repo/.loop/runs/1/manifest.json",
repoId: "repo-123",
runDir: "/repo/.loop/runs/1",
runId: "1",
storageRoot: "/repo/.loop/runs",
transcriptPath: "/repo/.loop/runs/1/transcript.jsonl",
};

await runInTmux(
["--tmux", "--proof", "verify with tests"],
{
capturePane: () => {
pollCount += 1;
return pollCount === 4
? "Is this a project you created or one you trust?"
: "";
},
cwd: "/repo",
env: {},
findBinary: () => true,
getCodexAppServerUrl: () => "ws://127.0.0.1:4500",
getLastCodexThreadId: () => "codex-thread-1",
isInteractive: () => false,
launchArgv: ["bun", "/repo/src/cli.ts"],
log: (): void => undefined,
makeClaudeSessionId: () => "claude-session-1",
preparePairedRun: (nextOpts) => {
nextOpts.codexMcpConfigArgs = [
"-c",
'mcp_servers.loop-bridge.command="loop"',
];
return { manifest, storage };
},
sendKeys: (pane: string, keys: string[]) => {
keyCalls.push({ keys, pane });
},
sendText: (): void => undefined,
sleep: () => Promise.resolve(),
startCodexProxy: () => Promise.resolve("ws://127.0.0.1:4600/"),
startPersistentAgentSession: () => Promise.resolve(undefined),
spawn: (args: string[]) => {
if (args[0] === "tmux" && args[1] === "has-session") {
return sessionStarted
? { exitCode: 0, stderr: "" }
: { exitCode: 1, stderr: "" };
}
if (args[0] === "tmux" && args[1] === "new-session") {
sessionStarted = true;
}
return { exitCode: 0, stderr: "" };
},
updateRunManifest: (_path, update) => update(manifest),
},
{ opts: makePairedOptions(), task: "Ship feature" }
);

expect(keyCalls).toContainEqual({
keys: ["Enter"],
pane: "repo-loop-1:0.0",
});
});

test("runInTmux reopens paired tmux panes without replaying the task", async () => {
const calls: string[][] = [];
const typed: Array<{ pane: string; text: string }> = [];
Expand Down
Loading