feat(lanes): Antigravity lanes, release exited panes, fresh Codex starts
- antigravity: a third lane tool running Antigravity's `agy` CLI. `-i`
carries the first prompt, `--continue` resumes; `agy` has no model,
effort or permission flag, and `--dangerously-skip-permissions` is never
emitted. No pipeline, no agents install, and no AI Router (native only,
else EUNSUPPORTED). Add lane gains an Antigravity choice and the binary
probe maps it to `agy`.
- UI: the lane card no longer renders a frame holding only a "Codex" chip,
and the prompt box names the lane's CLI ("Ask Codex anything…"), via
LANE_TOOL_LABEL.
- runs: a pane that exits is released on a 10 s server beat
(healAllRunIds, DASHBOARD_RUN_HEAL_MS). Codex and Antigravity fire no
hook, so their lanes stayed "running" until something re-read them.
- codex: Start no longer sends resumeLast — only "Resume last session"
continues the previous thread.
This commit is contained in:
+1
-1
@@ -679,7 +679,7 @@ Reads — and carefully gated mutations for low-risk text-file artifacts — for
|
||||
|
||||
### Run a lane's CLI (`/api/run`)
|
||||
|
||||
HTTP surface for the tmux-backed terminal run: one session per lane, named `agenthub-lane-<laneId>`, running the CLI that lane declares (`claude` or `codex`). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
|
||||
HTTP surface for the tmux-backed terminal run: one session per lane, named `agenthub-lane-<laneId>`, running the CLI that lane declares (`claude`, `codex`, or `agy` for an `antigravity` lane). There is no in-memory handle map — every read is computed from `tmux list-sessions`, so a session killed out-of-band self-corrects on the next call. Every route enforces a same-origin / loopback-Origin guard against browser CSRF.
|
||||
|
||||
| Method | Path | Description |
|
||||
| -------- | ----------------------------- | ----------- |
|
||||
|
||||
@@ -116,15 +116,17 @@ describe("installAgents", () => {
|
||||
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents")));
|
||||
});
|
||||
|
||||
it("installs nothing for a codex lane — no .claude/agents/, no AGENTS.md", async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
|
||||
for (const tool of ["codex", "antigravity"]) {
|
||||
it(`installs nothing for a ${tool} lane — no .claude/agents/, no AGENTS.md`, async () => {
|
||||
const cwd = makeRepoDir();
|
||||
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool });
|
||||
|
||||
const result = await installAgents(lane);
|
||||
assert.deepEqual(result.installed, []);
|
||||
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
|
||||
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
|
||||
});
|
||||
const result = await installAgents(lane);
|
||||
assert.deepEqual(result.installed, []);
|
||||
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
|
||||
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("codex pipeline cleanup", () => {
|
||||
|
||||
@@ -1302,4 +1302,20 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
||||
restorePath();
|
||||
}
|
||||
});
|
||||
|
||||
it("healAllRunIds releases a lane whose pane exited, with no read to trigger it", async () => {
|
||||
// A codex/antigravity pane fires no hook on exit, so nothing re-reads its
|
||||
// lane; the server's periodic sweep is what has to notice.
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const { healAllRunIds } = require("../routes/lanes");
|
||||
const lane = await adoptedLane("heal-all-sweep");
|
||||
// No tmux session by this name exists on the suite's private server.
|
||||
lanesLib.updateLane(lane.id, { run_id: `agenthub-lane-${lane.id}`, status: "running" });
|
||||
|
||||
healAllRunIds();
|
||||
|
||||
const after = lanesLib.getLane(lane.id);
|
||||
assert.equal(after.run_id, null);
|
||||
assert.equal(after.status, "idle");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,6 +398,27 @@ describe("pty-run", () => {
|
||||
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
|
||||
});
|
||||
|
||||
it("spawnRun runs `agy` for an antigravity lane, dropping flags it has no use for", () => {
|
||||
const call = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
laneId: 13,
|
||||
cwd: "/tmp/repo",
|
||||
tool: "antigravity",
|
||||
model: "opus",
|
||||
effort: "high",
|
||||
permissionMode: "bypassPermissions",
|
||||
initialPrompt: "hello",
|
||||
})
|
||||
);
|
||||
assert.deepEqual(paneArgv(call), ["agy", "-i", "hello"]);
|
||||
|
||||
tmux.__reset();
|
||||
const last = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", tool: "antigravity", resumeLast: true })
|
||||
);
|
||||
assert.deepEqual(paneArgv(last), ["agy", "--continue"]);
|
||||
});
|
||||
|
||||
// `dashboard_runs.mode` is NOT NULL and recordRun swallows insert errors, so a
|
||||
// null there persisted nothing at all while every call still looked fine: the
|
||||
// runs modal rendered a row with no cwd, model, prompt or start time, and a
|
||||
|
||||
@@ -233,6 +233,24 @@ describe("router-apply — the written file must survive a lane reset", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — Antigravity lane", () => {
|
||||
const agyLane = () => ({ id: 9, cwd: LANE_CWD, tool: "antigravity" });
|
||||
|
||||
it("accepts native and writes nothing", () => {
|
||||
assert.equal(applyRouterToLane(agyLane(), "native").file, null);
|
||||
assert.equal(fs.existsSync(SETTINGS), false);
|
||||
});
|
||||
|
||||
it("refuses a real router instead of writing Claude's settings file", () => {
|
||||
const { name } = saveNineRouter();
|
||||
assert.throws(
|
||||
() => applyRouterToLane(agyLane(), name),
|
||||
(err) => err.code === "EUNSUPPORTED"
|
||||
);
|
||||
assert.equal(fs.existsSync(SETTINGS), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("router-apply — Codex lane", () => {
|
||||
it("writes a profile layer file, never the base config", () => {
|
||||
const base = path.join(process.env.CODEX_HOME, "config.toml");
|
||||
|
||||
@@ -504,6 +504,21 @@ function startBackgroundServices() {
|
||||
} catch (err) {
|
||||
console.warn("codex poll failed to start:", err.message);
|
||||
}
|
||||
// A codex/antigravity pane that exits fires no hook, so release its lane on
|
||||
// a beat instead (one `tmux has-session` per lane with a run). Off with
|
||||
// DASHBOARD_RUN_HEAL_MS=0.
|
||||
const HEAL_MS = Number(process.env.DASHBOARD_RUN_HEAL_MS ?? 10_000);
|
||||
if (Number.isFinite(HEAL_MS) && HEAL_MS > 0) {
|
||||
const { healAllRunIds } = require("./routes/lanes");
|
||||
const healTimer = setInterval(() => {
|
||||
try {
|
||||
healAllRunIds();
|
||||
} catch (err) {
|
||||
console.warn("run heal failed:", err.message);
|
||||
}
|
||||
}, HEAL_MS);
|
||||
if (healTimer.unref) healTimer.unref();
|
||||
}
|
||||
// Continuous discovery of sessions under ~/.claude/projects. The one-time
|
||||
// legacy backfill above runs only once (marker-gated), so a project added
|
||||
// later whose sessions never flow through hooks would otherwise stay invisible
|
||||
|
||||
@@ -89,7 +89,7 @@ function recordRun(handle) {
|
||||
started_at: startedAt,
|
||||
ended_at: endedAt,
|
||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||
tool: handle.tool === "codex" ? "codex" : "claude",
|
||||
tool: handle.tool === "codex" || handle.tool === "antigravity" ? handle.tool : "claude",
|
||||
});
|
||||
} catch (err) {
|
||||
// Best-effort by design (a live run must never fail because sqlite did),
|
||||
|
||||
@@ -66,8 +66,8 @@ async function commonGitDir(cwd) {
|
||||
* any existing copy — this is a reinstall, not a merge) and idempotently
|
||||
* git-exclude that directory in the shared common git dir.
|
||||
*
|
||||
* A `codex` lane gets nothing: Codex never reads `.claude/agents/`, and codex
|
||||
* lanes carry no pipeline to drive.
|
||||
* A `codex` or `antigravity` lane gets nothing: neither CLI reads
|
||||
* `.claude/agents/`, and those lanes carry no pipeline to drive.
|
||||
*
|
||||
* @param {{cwd: string, tool?: string}} lane
|
||||
* @returns {Promise<{installed: string[]}>}
|
||||
@@ -79,7 +79,7 @@ async function installAgents(lane) {
|
||||
});
|
||||
}
|
||||
|
||||
if (lane.tool === "codex") return { installed: [] };
|
||||
if (lane.tool && lane.tool !== "claude") return { installed: [] };
|
||||
|
||||
const dest = path.join(lane.cwd, ".claude", "agents");
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ function validateKind(kind) {
|
||||
}
|
||||
}
|
||||
|
||||
const VALID_TOOLS = new Set(["claude", "codex"]);
|
||||
const VALID_TOOLS = new Set(["claude", "codex", "antigravity"]);
|
||||
|
||||
function validateTool(tool) {
|
||||
if (!VALID_TOOLS.has(tool)) {
|
||||
|
||||
+20
-4
@@ -19,7 +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.
|
||||
* Spawns `claude`, `codex` or `agy` (Antigravity) in the tmux pane based on
|
||||
* the tool parameter.
|
||||
*
|
||||
* A lane can hold up to four sessions, one per Workspace split pane: pane 0 is
|
||||
* `agenthub-lane-<laneId>` (the lane's own `run_id`), panes 1–3 are
|
||||
@@ -55,7 +56,7 @@ const ALLOWED_PERMISSION_MODES = new Set([
|
||||
]);
|
||||
// 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 VALID_TOOLS = new Set(["claude", "codex", "antigravity"]);
|
||||
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.
|
||||
@@ -137,8 +138,23 @@ function buildCodexArgv({
|
||||
return argv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Antigravity's `agy` has no model, effort or permission-mode flag, so only
|
||||
* resume and the first prompt reach it. `-i` keeps the session interactive
|
||||
* after that prompt (`-p` would print and exit). `--dangerously-skip-permissions`
|
||||
* is never emitted, same rule as Codex's bypass flags.
|
||||
*/
|
||||
function buildAntigravityArgv({ resumeLast, initialPrompt }) {
|
||||
const argv = ["agy"];
|
||||
if (resumeLast) argv.push("--continue");
|
||||
if (initialPrompt) argv.push("-i", initialPrompt);
|
||||
return argv;
|
||||
}
|
||||
|
||||
function buildArgv(args) {
|
||||
return args.tool === "codex" ? buildCodexArgv(args) : buildClaudeArgv(args);
|
||||
if (args.tool === "codex") return buildCodexArgv(args);
|
||||
if (args.tool === "antigravity") return buildAntigravityArgv(args);
|
||||
return buildClaudeArgv(args);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -258,7 +274,7 @@ function spawnRun(args) {
|
||||
// separate `external` row. Every run here is one interactive pane.
|
||||
mode: "interactive",
|
||||
cwd,
|
||||
tool: tool === "codex" ? "codex" : "claude",
|
||||
tool,
|
||||
model: model || null,
|
||||
permissionMode: permissionMode || "auto",
|
||||
effort: effort || null,
|
||||
|
||||
@@ -187,6 +187,13 @@ function applyToCodexLane(lane, profile) {
|
||||
*/
|
||||
function applyRouterToLane(lane, profileName) {
|
||||
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
||||
// `agy` has no endpoint/model override to write, so only native fits.
|
||||
if (lane.tool === "antigravity") {
|
||||
if (profile.builtin) return { tool: "antigravity", file: null, profile: profile.name };
|
||||
throw Object.assign(new Error("an Antigravity lane cannot use an AI Router"), {
|
||||
code: "EUNSUPPORTED",
|
||||
});
|
||||
}
|
||||
if (lane.tool === "codex") {
|
||||
// Native is the base config as it already stands. A layer that overrode
|
||||
// nothing would still shadow whatever model the user set in the base.
|
||||
|
||||
+13
-1
@@ -106,6 +106,16 @@ function healRunId(lane) {
|
||||
return lanesLib.getLane(lane.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heal every lane at once. A claude pane that exits fires a hook, which
|
||||
* broadcasts the lane and so re-reads it; a codex or antigravity pane fires
|
||||
* nothing, so without this sweep its lane read "running" until something
|
||||
* else happened to fetch it.
|
||||
*/
|
||||
function healAllRunIds() {
|
||||
for (const lane of lanesLib.listLanes()) if (lane.run_id) healRunId(lane);
|
||||
}
|
||||
|
||||
function payload(lane) {
|
||||
const healed = healRunId(lane);
|
||||
return lanesLib.lanePayload(healed, lastEventAge(healed));
|
||||
@@ -697,7 +707,8 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
||||
broadcastLane(lane.id);
|
||||
res.json({ ...result, restart_required: live, run_id: live ? runId : null });
|
||||
} catch (err) {
|
||||
const status = { ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400 }[err.code] || 500;
|
||||
const status =
|
||||
{ ENOTFOUND: 404, EBADJSON: 409, EBADNAME: 400, EUNSUPPORTED: 400 }[err.code] || 500;
|
||||
res.status(status).json({ error: { code: err.code || "ERUNTIME", message: err.message } });
|
||||
}
|
||||
});
|
||||
@@ -1777,3 +1788,4 @@ router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
|
||||
module.exports = router;
|
||||
module.exports.broadcastLane = broadcastLane;
|
||||
module.exports.healAllRunIds = healAllRunIds;
|
||||
|
||||
@@ -290,12 +290,13 @@ router.get("/binary", (req, res) => {
|
||||
// taken from a fixed allowlist, never from the query string directly — this
|
||||
// string reaches `which`.
|
||||
const tool = req.query.tool === undefined ? "claude" : String(req.query.tool);
|
||||
if (tool !== "claude" && tool !== "codex") {
|
||||
const bins = { claude: "claude", codex: "codex", antigravity: "agy" };
|
||||
if (!Object.hasOwn(bins, tool)) {
|
||||
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||
}
|
||||
const which = require("node:child_process").spawnSync(
|
||||
process.platform === "win32" ? "where" : "which",
|
||||
[tool],
|
||||
[bins[tool]],
|
||||
{ encoding: "utf8" }
|
||||
);
|
||||
const stdout = (which.stdout || "").trim();
|
||||
|
||||
Reference in New Issue
Block a user