/** * @file pane-mode.js * @description A live `claude` pane's permission mode, read off the pane and * changed the only way Claude Code offers to a running session: Shift+Tab, * which steps through its cycle (Manual → Accept edits → Plan → Auto, Bypass * too when the session was allowed it). Every step is VERIFIED on the mode * line Claude Code prints under its prompt before the next key goes in, so it * stops on exactly the mode asked for — and presses nothing at all while that * line cannot be read (a menu or dialog is open, where Shift+Tab means * something else). A mode not in this session's cycle brings it back to where * it began. One change at a time per pane. Only this pane is touched: the * mode belongs to the session, not to an account. * @author Nguyễn Ngọc Trí Vĩ */ const tmux = require("./tmux"); // Claude Code 2.1.x: "⏸ manual mode on · ← for agents", // "⏵⏵ accept edits on (shift+tab to cycle)". The marker keeps a sentence in // the conversation that merely mentions a mode from counting. const LINE = /(?:⏸|⏵⏵)\s+(manual mode|accept edits|plan mode|auto mode|bypass permissions|don't ask) on\b/; const MODES = { "manual mode": "default", "accept edits": "acceptEdits", "plan mode": "plan", "auto mode": "auto", "bypass permissions": "bypassPermissions", "don't ask": "dontAsk", }; const SETTABLE = new Set(["default", "acceptEdits", "plan", "auto", "bypassPermissions"]); // Longest cycle Claude Code has: its six modes. const MAX_PRESSES = 6; /** The mode on the pane's bottom lines, or null when none is shown. */ function parseMode(screen) { const lines = String(screen) .split("\n") .filter((l) => l.trim()) .slice(-4) .reverse(); for (const l of lines) { const m = LINE.exec(l); if (m) return MODES[m[1]]; } return null; } function readMode(runId) { try { return parseMode(tmux.capturePane(runId)); } catch { return null; } } const fail = (code, message) => Object.assign(new Error(message), { code }); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // Between two reads of the pane. Each read is a SYNCHRONOUS capture-pane // (through sudo for a member's pane) that holds up every other request and // socket while it runs, so a mode change polls gently rather than 20×/s. const POLL_MS = 150; // The mode once it differs from `from` — or what the pane shows at the deadline. async function nextMode(runId, from, waitMs) { const until = Date.now() + waitMs; for (;;) { const now = readMode(runId); if ((now && now !== from) || Date.now() >= until) return now; await sleep(POLL_MS); } } const busy = new Set(); /** Steps `runId`'s mode to `target`; resolves with the mode now shown. */ async function setMode(runId, target, { waitMs = 2000 } = {}) { if (!SETTABLE.has(target)) throw fail("EBADMODE", `mode must be one of: ${[...SETTABLE].join(", ")}`); if (busy.has(runId)) throw fail("EBUSY", "the mode is already being changed"); busy.add(runId); try { const start = readMode(runId); if (!start) throw fail("EMODEUNKNOWN", "the pane shows no mode line — close its menu first"); let cur = start; for (let i = 0; i < MAX_PRESSES && cur !== target; i++) { tmux.sendKey(runId, "BTab"); const next = await nextMode(runId, cur, waitMs); if (!next || next === cur) throw fail("EMODEUNKNOWN", "the mode did not change as expected"); cur = next; if (cur === start) break; } if (cur !== target) throw fail("EMODEUNAVAILABLE", `${target} is not available in this session`); return cur; } finally { busy.delete(runId); } } module.exports = { parseMode, readMode, setMode };