feat(run): spawn codex in a lane's tmux pane
This commit is contained in:
+92
-8
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user