feat(run): spawn codex in a lane's tmux pane

This commit is contained in:
2026-08-27 10:37:38 +07:00
parent a66331f008
commit e8070f04d1
2 changed files with 176 additions and 8 deletions
+92 -8
View File
@@ -19,6 +19,8 @@
* the session (one-click Start/Resume) and is just one of possibly several
* attached clients tmux already keeps in sync.
*
* Spawns `claude` or `codex` in the tmux pane based on the tool parameter.
*
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -36,6 +38,12 @@ const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
// Pane commands that mean "idle shell prompt, safe to type a command into".
const SHELL_COMMANDS = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "csh", "tcsh"]);
const VALID_TOOLS = new Set(["claude", "codex"]);
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
const CODEX_APPROVALS = new Set(["on-request", "never"]);
// Codex has no `--effort` flag; reasoning effort is a config override.
const CODEX_EFFORTS = new Set(["low", "medium", "high", "xhigh"]);
const CODEX_SESSION_RE = /^[0-9a-fA-F-]{8,}$/;
function runIdForLane(laneId) {
return `ccam-lane-${laneId}`;
@@ -53,13 +61,14 @@ function makeErr(code, message) {
}
/**
* Build the pane's command argv. Unlike the old stream-json spawner there is
* no headless/conversation split — every run is a live interactive pane, so
* an initial prompt (when given) is a trailing POSITIONAL argument: `claude`
* treats a bare positional as the first turn's message and stays interactive
* afterward (unlike `-p`, which forces print-and-exit and closes stdin).
* Build the pane's command argv for Claude. Unlike the old stream-json spawner
* there is no headless/conversation split — every run is a live interactive
* pane, so an initial prompt (when given) is a trailing POSITIONAL argument:
* `claude` treats a bare positional as the first turn's message and stays
* interactive afterward (unlike `-p`, which forces print-and-exit and closes
* stdin).
*/
function buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) {
function buildClaudeArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt }) {
const argv = ["claude"];
argv.push("--permission-mode", permissionMode || "acceptEdits");
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
@@ -76,24 +85,87 @@ function buildArgv({ model, permissionMode, effort, resumeSessionId, initialProm
return argv;
}
/**
* Codex's CLI shape differs from Claude's in two ways that matter here:
* `resume` is a SUBCOMMAND (so it precedes every flag) and reasoning effort has
* no flag of its own — it rides on a `-c` config override. The prompt stays a
* trailing positional, same as Claude, so the pane stays interactive.
*/
function buildCodexArgv({
model,
effort,
sandbox,
approval,
resumeSessionId,
resumeLast,
initialPrompt,
}) {
const argv = ["codex"];
if (resumeSessionId) argv.push("resume", resumeSessionId);
else if (resumeLast) argv.push("resume", "--last");
if (model) argv.push("-m", model);
if (sandbox) argv.push("-s", sandbox);
if (approval) argv.push("-a", approval);
if (effort && CODEX_EFFORTS.has(effort)) argv.push("-c", `model_reasoning_effort=${effort}`);
if (initialPrompt) argv.push(initialPrompt);
return argv;
}
function buildArgv(args) {
return args.tool === "codex" ? buildCodexArgv(args) : buildClaudeArgv(args);
}
/**
* @param {object} args
* @param {number} args.laneId
* @param {string} args.cwd
* @param {string} [args.tool="claude"]
* @param {string} [args.model]
* @param {string} [args.permissionMode]
* @param {string} [args.effort]
* @param {string} [args.sandbox]
* @param {string} [args.approval]
* @param {string} [args.resumeSessionId]
* @param {boolean} [args.resumeLast]
* @param {string} [args.initialPrompt]
*/
function spawnRun(args) {
const { laneId, cwd, model, permissionMode, effort, resumeSessionId, initialPrompt } = args || {};
const {
laneId,
cwd,
tool = "claude",
model,
permissionMode,
effort,
sandbox,
approval,
resumeSessionId,
resumeLast,
initialPrompt,
} = args || {};
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
throw makeErr("EBADLANE", "laneId must be an integer");
}
if (typeof cwd !== "string" || !cwd) {
throw makeErr("EBADCWD", "cwd is required");
}
if (!VALID_TOOLS.has(tool)) {
throw makeErr("EBADTOOL", `tool must be one of: ${Array.from(VALID_TOOLS).join(", ")}`);
}
if (tool === "codex") {
if (sandbox != null && sandbox !== "" && !CODEX_SANDBOXES.has(sandbox)) {
throw makeErr(
"EBADSANDBOX",
`sandbox must be one of: ${Array.from(CODEX_SANDBOXES).join(", ")}`
);
}
if (approval != null && approval !== "" && !CODEX_APPROVALS.has(approval)) {
throw makeErr(
"EBADAPPROVAL",
`approval must be one of: ${Array.from(CODEX_APPROVALS).join(", ")}`
);
}
}
if (permissionMode != null && !ALLOWED_PERMISSION_MODES.has(permissionMode)) {
throw makeErr(
"EBADMODE",
@@ -112,7 +184,17 @@ function spawnRun(args) {
const id = runIdForLane(laneId);
const startedAt = Date.now();
const argv = buildArgv({ model, permissionMode, effort, resumeSessionId, initialPrompt });
const argv = buildArgv({
tool,
model,
permissionMode,
effort,
sandbox,
approval,
resumeSessionId,
resumeLast,
initialPrompt,
});
const record = () => {
if (!dashboardRuns) return;
@@ -121,6 +203,7 @@ function spawnRun(args) {
sessionId: resumeSessionId || null,
mode: null,
cwd,
tool: tool === "codex" ? "codex" : "claude",
model: model || null,
permissionMode: permissionMode || "acceptEdits",
effort: effort || null,
@@ -171,6 +254,7 @@ function publicRun(id) {
id,
laneId,
status: live ? "running" : "gone",
tool: row?.tool || "claude",
cwd: row?.cwd || null,
model: row?.model || null,
permissionMode: row?.permission_mode || null,