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
24 changes: 19 additions & 5 deletions src/loop/tmux.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ const RUN_BASE_ENV = "LOOP_RUN_BASE";
const RUN_ID_ENV = "LOOP_RUN_ID";
const CLAUDE_TRUST_PROMPT = "Is this a project you created or one you trust?";
const CLAUDE_BYPASS_PROMPT = "running in Bypass Permissions mode";
const CLAUDE_BYPASS_MODE = "Bypass Permissions mode";
const CLAUDE_BYPASS_ACCEPT = "Yes, I accept";
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";
Expand Down Expand Up @@ -700,16 +703,25 @@ const runTmuxCommand = (
throw new Error(`${message}${suffix}`);
};

const normalizePaneText = (text: string): string =>
text.replace(/\s+/g, " ").trim();

const detectClaudePrompt = (text: string): "bypass" | "confirm" | undefined => {
if (text.includes(CLAUDE_BYPASS_PROMPT)) {
const normalized = normalizePaneText(text);
if (
normalized.includes(CLAUDE_BYPASS_PROMPT) ||
(normalized.includes(CLAUDE_BYPASS_MODE) &&
normalized.includes(CLAUDE_BYPASS_ACCEPT) &&
normalized.includes(CLAUDE_EXIT_OPTION))
) {
return "bypass";
}
if (text.includes(CLAUDE_TRUST_PROMPT)) {
if (normalized.includes(CLAUDE_TRUST_PROMPT)) {
return "confirm";
}
if (
text.includes(CLAUDE_DEV_CHANNELS_PROMPT) &&
text.includes(CLAUDE_DEV_CHANNELS_CONFIRM)
normalized.includes(CLAUDE_DEV_CHANNELS_PROMPT) &&
normalized.includes(CLAUDE_DEV_CHANNELS_CONFIRM)
) {
return "confirm";
}
Expand Down Expand Up @@ -740,7 +752,9 @@ const unblockClaudePane = async (
continue;
}
if (prompt === "bypass") {
deps.sendKeys(pane, ["Down", "Enter"]);
deps.sendKeys(pane, ["Down"]);
await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS);
deps.sendKeys(pane, ["Enter"]);
Comment on lines +755 to +757
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Separating the sendKeys for "Down" and "Enter" with a sleep call is a crucial improvement. This ensures that the UI has sufficient time to register the "Down" key press and update the selection before the "Enter" key is sent, preventing potential race conditions or incorrect selections in the Claude prompt. This significantly enhances the reliability of the bypass confirmation process.

handledPrompt = true;
quietPolls = 0;
await deps.sleep(CLAUDE_PROMPT_POLL_DELAY_MS);
Expand Down
195 changes: 194 additions & 1 deletion tests/loop/tmux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,7 +665,11 @@ test("runInTmux auto-confirms Claude startup prompts in paired mode", async () =

expect(keyCalls[0]).toEqual({ keys: ["Enter"], pane: "repo-loop-1:0.0" });
expect(keyCalls[1]).toEqual({
keys: ["Down", "Enter"],
keys: ["Down"],
pane: "repo-loop-1:0.0",
});
expect(keyCalls[2]).toEqual({
keys: ["Enter"],
pane: "repo-loop-1:0.0",
});
expect(
Expand Down Expand Up @@ -696,6 +700,176 @@ test("runInTmux auto-confirms Claude startup prompts in paired mode", async () =
);
});

test("runInTmux confirms wrapped Claude dev-channel prompts", async () => {
const keyCalls: Array<{ keys: string[]; pane: string }> = [];
let sessionStarted = false;
let pollCount = 0;
const devChannelsPrompt = [
"WARNING: Loading development channels",
"",
"--dangerously-load-development-channels is for local channel development only.",
"",
"1. I am using this for local",
"development",
].join("\n");
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;
if (pollCount === 1) {
return devChannelsPrompt;
}
return "";
},
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 confirms the current Claude bypass prompt wording", async () => {
const keyCalls: Array<{ keys: string[]; pane: string }> = [];
let sessionStarted = false;
let pollCount = 0;
const bypassPrompt = [
"Bypass Permissions mode",
"",
"1. No, exit",
"2. Yes, I accept",
].join("\n");
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;
if (pollCount === 1) {
return bypassPrompt;
}
return "";
},
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: ["Down"],
pane: "repo-loop-1:0.0",
});
expect(keyCalls).toContainEqual({
keys: ["Enter"],
pane: "repo-loop-1:0.0",
});
});

test("runInTmux still confirms Claude trust prompts in paired mode", async () => {
const keyCalls: Array<{ keys: string[]; pane: string }> = [];
let sessionStarted = false;
Expand Down Expand Up @@ -1576,6 +1750,25 @@ test("tmux internals build shell command with escaping", () => {
);
});

test("tmux internals launch Claude in bypass mode", () => {
expect(
tmuxInternals.buildClaudeCommand(
"claude-session-1",
"opus",
"loop-bridge-1",
false
)
).toContain("--dangerously-skip-permissions");
expect(
tmuxInternals.buildClaudeCommand(
"claude-session-1",
"opus",
"loop-bridge-1",
false
)
).not.toContain("--permission-mode");
});

test("tmux internals build run names", () => {
expect(tmuxInternals.buildRunName("repo", 3)).toBe("repo-loop-3");
});
Expand Down
Loading