feat(run): spawn codex in a lane's tmux pane
This commit is contained in:
@@ -214,4 +214,88 @@ describe("pty-run", () => {
|
|||||||
assert.equal(retrieved.promptPreview, "the live prompt");
|
assert.equal(retrieved.promptPreview, "the live prompt");
|
||||||
assert.equal(retrieved.status, "running");
|
assert.equal(retrieved.status, "running");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function captureNewSession(fn) {
|
||||||
|
const calls = [];
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
calls.push(args);
|
||||||
|
if (args[0] === "has-session") {
|
||||||
|
const e = new Error("no such session");
|
||||||
|
e.status = 1;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
fn();
|
||||||
|
return calls.find((c) => c[0] === "new-session");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("spawnRun builds a codex argv for a codex lane", () => {
|
||||||
|
const call = captureNewSession(() =>
|
||||||
|
pty.spawnRun({
|
||||||
|
laneId: 7,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
tool: "codex",
|
||||||
|
model: "gpt-5.1-codex",
|
||||||
|
sandbox: "workspace-write",
|
||||||
|
approval: "on-request",
|
||||||
|
effort: "high",
|
||||||
|
initialPrompt: "hello",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const argv = call.slice(7);
|
||||||
|
assert.deepEqual(argv, [
|
||||||
|
"codex",
|
||||||
|
"-m",
|
||||||
|
"gpt-5.1-codex",
|
||||||
|
"-s",
|
||||||
|
"workspace-write",
|
||||||
|
"-a",
|
||||||
|
"on-request",
|
||||||
|
"-c",
|
||||||
|
"model_reasoning_effort=high",
|
||||||
|
"hello",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawnRun puts codex resume first, as a subcommand", () => {
|
||||||
|
const withId = captureNewSession(() =>
|
||||||
|
pty.spawnRun({
|
||||||
|
laneId: 8,
|
||||||
|
cwd: "/tmp/repo",
|
||||||
|
tool: "codex",
|
||||||
|
resumeSessionId: "0199aaaa-bbbb-cccc-dddd-eeeeffff0000",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.deepEqual(withId.slice(7, 10), [
|
||||||
|
"codex",
|
||||||
|
"resume",
|
||||||
|
"0199aaaa-bbbb-cccc-dddd-eeeeffff0000",
|
||||||
|
]);
|
||||||
|
|
||||||
|
tmux.__reset();
|
||||||
|
const last = captureNewSession(() =>
|
||||||
|
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", tool: "codex", resumeLast: true })
|
||||||
|
);
|
||||||
|
assert.deepEqual(last.slice(7, 10), ["codex", "resume", "--last"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawnRun rejects a codex sandbox/approval outside the allowlist", () => {
|
||||||
|
tmux.__setExecImpl(() => "");
|
||||||
|
assert.throws(
|
||||||
|
() => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", sandbox: "rm -rf" }),
|
||||||
|
(err) => err.code === "EBADSANDBOX"
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", approval: "always" }),
|
||||||
|
(err) => err.code === "EBADAPPROVAL"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("spawnRun never emits codex bypass flags", () => {
|
||||||
|
const call = captureNewSession(() =>
|
||||||
|
pty.spawnRun({ laneId: 11, cwd: "/tmp/repo", tool: "codex", sandbox: "danger-full-access" })
|
||||||
|
);
|
||||||
|
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+92
-8
@@ -19,6 +19,8 @@
|
|||||||
* the session (one-click Start/Resume) and is just one of possibly several
|
* the session (one-click Start/Resume) and is just one of possibly several
|
||||||
* attached clients tmux already keeps in sync.
|
* 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>
|
* @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"]);
|
const ALLOWED_PERMISSION_MODES = new Set(["acceptEdits", "default", "plan", "bypassPermissions"]);
|
||||||
// Pane commands that mean "idle shell prompt, safe to type a command into".
|
// 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 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) {
|
function runIdForLane(laneId) {
|
||||||
return `ccam-lane-${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
|
* Build the pane's command argv for Claude. Unlike the old stream-json spawner
|
||||||
* no headless/conversation split — every run is a live interactive pane, so
|
* there is no headless/conversation split — every run is a live interactive
|
||||||
* an initial prompt (when given) is a trailing POSITIONAL argument: `claude`
|
* pane, so an initial prompt (when given) is a trailing POSITIONAL argument:
|
||||||
* treats a bare positional as the first turn's message and stays interactive
|
* `claude` treats a bare positional as the first turn's message and stays
|
||||||
* afterward (unlike `-p`, which forces print-and-exit and closes stdin).
|
* 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"];
|
const argv = ["claude"];
|
||||||
argv.push("--permission-mode", permissionMode || "acceptEdits");
|
argv.push("--permission-mode", permissionMode || "acceptEdits");
|
||||||
// Puts "bypass permissions" INTO the pane's shift+tab cycle without turning
|
// 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;
|
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 {object} args
|
||||||
* @param {number} args.laneId
|
* @param {number} args.laneId
|
||||||
* @param {string} args.cwd
|
* @param {string} args.cwd
|
||||||
|
* @param {string} [args.tool="claude"]
|
||||||
* @param {string} [args.model]
|
* @param {string} [args.model]
|
||||||
* @param {string} [args.permissionMode]
|
* @param {string} [args.permissionMode]
|
||||||
* @param {string} [args.effort]
|
* @param {string} [args.effort]
|
||||||
|
* @param {string} [args.sandbox]
|
||||||
|
* @param {string} [args.approval]
|
||||||
* @param {string} [args.resumeSessionId]
|
* @param {string} [args.resumeSessionId]
|
||||||
|
* @param {boolean} [args.resumeLast]
|
||||||
* @param {string} [args.initialPrompt]
|
* @param {string} [args.initialPrompt]
|
||||||
*/
|
*/
|
||||||
function spawnRun(args) {
|
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)) {
|
if (typeof laneId !== "number" || !Number.isInteger(laneId)) {
|
||||||
throw makeErr("EBADLANE", "laneId must be an integer");
|
throw makeErr("EBADLANE", "laneId must be an integer");
|
||||||
}
|
}
|
||||||
if (typeof cwd !== "string" || !cwd) {
|
if (typeof cwd !== "string" || !cwd) {
|
||||||
throw makeErr("EBADCWD", "cwd is required");
|
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)) {
|
if (permissionMode != null && !ALLOWED_PERMISSION_MODES.has(permissionMode)) {
|
||||||
throw makeErr(
|
throw makeErr(
|
||||||
"EBADMODE",
|
"EBADMODE",
|
||||||
@@ -112,7 +184,17 @@ function spawnRun(args) {
|
|||||||
|
|
||||||
const id = runIdForLane(laneId);
|
const id = runIdForLane(laneId);
|
||||||
const startedAt = Date.now();
|
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 = () => {
|
const record = () => {
|
||||||
if (!dashboardRuns) return;
|
if (!dashboardRuns) return;
|
||||||
@@ -121,6 +203,7 @@ function spawnRun(args) {
|
|||||||
sessionId: resumeSessionId || null,
|
sessionId: resumeSessionId || null,
|
||||||
mode: null,
|
mode: null,
|
||||||
cwd,
|
cwd,
|
||||||
|
tool: tool === "codex" ? "codex" : "claude",
|
||||||
model: model || null,
|
model: model || null,
|
||||||
permissionMode: permissionMode || "acceptEdits",
|
permissionMode: permissionMode || "acceptEdits",
|
||||||
effort: effort || null,
|
effort: effort || null,
|
||||||
@@ -171,6 +254,7 @@ function publicRun(id) {
|
|||||||
id,
|
id,
|
||||||
laneId,
|
laneId,
|
||||||
status: live ? "running" : "gone",
|
status: live ? "running" : "gone",
|
||||||
|
tool: row?.tool || "claude",
|
||||||
cwd: row?.cwd || null,
|
cwd: row?.cwd || null,
|
||||||
model: row?.model || null,
|
model: row?.model || null,
|
||||||
permissionMode: row?.permission_mode || null,
|
permissionMode: row?.permission_mode || null,
|
||||||
|
|||||||
Reference in New Issue
Block a user