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:
2026-09-28 10:22:07 +07:00
parent 952e8584f0
commit 9d096b61f7
31 changed files with 257 additions and 93 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
## Lane stage
AgentHub's `codex` lanes carry no pipeline: the dashboard shows no stage for
AgentHub's `codex` (and `antigravity`) lanes carry no pipeline: the dashboard shows no stage for
them and never infers or advances one, so there is no `agenthub stage` call to
make while you work here as Codex.
+3 -3
View File
File diff suppressed because one or more lines are too long
+13 -1
View File
@@ -429,6 +429,15 @@ export function AddLaneModal({
label={t("addLaneToolCodex")}
onClick={() => setTool("codex")}
/>
<Seg
active={tool === "antigravity"}
label={t("addLaneToolAntigravity")}
onClick={() => {
setTool("antigravity");
// `agy` cannot run behind an AI Router (see router-apply.js).
setRouter("native");
}}
/>
</div>
</div>
@@ -635,7 +644,10 @@ export function AddLaneModal({
hid it. The pipeline picker is gone entirely: every lane is created
on `default` (the server's own default) and the template is changed
from the lane's own header afterwards. */}
<div className="rounded-md border border-border-light bg-surface-0 p-2">
<div
className="rounded-md border border-border-light bg-surface-0 p-2"
hidden={tool === "antigravity"}
>
<div>
<fieldset>
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
+7 -14
View File
@@ -363,8 +363,8 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
role="menu"
className="absolute right-0 z-10 mt-1 min-w-44 overflow-hidden rounded-md border border-border-light bg-surface-0 py-1 shadow-lg shadow-black/40"
>
{/* Claude-only: a codex lane has no agents to install. */}
{lane.tool !== "codex" && (
{/* Claude-only: other CLIs have no agents to install. */}
{lane.tool === "claude" && (
<button
type="button"
role="menuitem"
@@ -379,7 +379,7 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
{t("actions.agentsInstall")}
</button>
)}
{lane.tool !== "codex" && (
{lane.tool === "claude" && (
<button
type="button"
role="menuitem"
@@ -487,18 +487,11 @@ export default function LaneCard({
// renders no frame at all rather than an empty box.
className="flex flex-col rounded-lg border border-border bg-surface-4 p-2.5 transition-colors hover:border-border-light empty:hidden"
>
{(lane.ci_status || lane.tool === "codex") && (
{lane.ci_status && (
<div className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px]">
{lane.ci_status && (
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
)}
{lane.tool === "codex" && (
<span className="rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
Codex
</span>
)}
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
CI {lane.ci_status}
</span>
</div>
)}
@@ -18,6 +18,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { Lane } from "../../lib/types";
import { LANE_TOOL_LABEL } from "../../lib/types";
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
active: "bg-status-success",
@@ -115,18 +116,18 @@ export default function LaneStripCard({
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
#{lane.id}
</span>
{/* Codex lanes carry no pipeline, so no stage chip — the Codex tag
takes its place. */}
{lane.tool === "codex" ? (
{/* Only claude lanes carry a pipeline; the others show their CLI's
tag in place of the stage chip. */}
{lane.tool !== "claude" ? (
<span className="shrink-0 rounded border border-border bg-surface-2 px-1.5 py-0.5 text-[10px] text-fg-muted">
Codex
{LANE_TOOL_LABEL[lane.tool]}
</span>
) : (
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
{lane.stage}
</span>
)}
{lane.tool !== "codex" && detectionLeads(lane) && (
{lane.tool === "claude" && detectionLeads(lane) && (
<span
data-testid={`lane-tile-auto-${lane.id}`}
title={lane.detected_signal || undefined}
@@ -96,13 +96,16 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
}
describe("LaneCard lane tool badge", () => {
it("badges a codex lane and leaves a claude lane unbadged", () => {
const { rerender } = render(
<LaneCard {...{ lane: makeLane({ tool: "codex" }), onAction: vi.fn() }} />
);
expect(screen.getByText("Codex")).toBeInTheDocument();
rerender(<LaneCard {...{ lane: makeLane({ tool: "claude" }), onAction: vi.fn() }} />);
expect(screen.queryByText("Codex")).not.toBeInTheDocument();
// The strip tile already names the CLI; a card holding only that tag was an
// empty frame in the lane detail.
it("renders nothing for a codex or antigravity lane with nothing else to show", () => {
for (const tool of ["codex", "antigravity"] as const) {
const { container, unmount } = render(
<LaneCard {...{ lane: makeLane({ tool }), onAction: vi.fn() }} />
);
expect(container.querySelector('[data-testid="lane-card-1"]')).toBeEmptyDOMElement();
unmount();
}
});
});
@@ -44,6 +44,7 @@ import type {
CodexApproval,
} from "../../lib/api";
import type { Session, Lane, LaneTool } from "../../lib/types";
import { LANE_TOOL_LABEL } from "../../lib/types";
import { TerminalView } from "./TerminalView";
import { RunSetup } from "./RunSetup";
import { ActiveRunsSwitcher } from "./RunHistory";
@@ -149,7 +150,7 @@ export function LaneConsolePane({
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
[externalSessions, laneId, lanes]
);
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
const toolLabel = LANE_TOOL_LABEL[laneTool];
useEffect(() => {
onHasActiveRunChange?.(handle !== null);
@@ -568,7 +569,12 @@ export function LaneConsolePane({
{binaryStatus && !binaryStatus[laneTool]?.found && (
<div className="rounded-lg border border-status-danger/40 bg-status-danger/10 px-4 py-3 text-sm text-status-danger flex items-center gap-2">
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span>{t("binary.missing", { cli: laneTool, tool: toolLabel })}</span>
<span>
{t("binary.missing", {
cli: laneTool === "antigravity" ? "agy" : laneTool,
tool: toolLabel,
})}
</span>
</div>
)}
+28 -22
View File
@@ -56,6 +56,7 @@ import type {
CcMdItem,
} from "../../lib/api";
import type { Session, LaneTool } from "../../lib/types";
import { LANE_TOOL_LABEL } from "../../lib/types";
import { Select } from "../Select";
export interface SlashCommand {
@@ -430,10 +431,13 @@ export function RunSetup(props: RunSetupProps) {
</div>
)}
</Field>
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
{props.tool === "codex" ? (
{/* `agy` takes no model, permission or effort flag. */}
{props.tool !== "antigravity" && (
<Field label={t("fields.model")}>
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
</Field>
)}
{props.tool === "antigravity" ? null : props.tool === "codex" ? (
<>
<Field label={t("fields.sandbox")}>
<Select<CodexSandbox>
@@ -472,17 +476,19 @@ export function RunSetup(props: RunSetupProps) {
/>
</Field>
)}
<Field label={t("fields.effort")}>
<Select<EffortLevel>
value={props.effort}
onChange={props.onEffortChange}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
</Field>
{props.tool !== "antigravity" && (
<Field label={t("fields.effort")}>
<Select<EffortLevel>
value={props.effort}
onChange={props.onEffortChange}
options={RUN_EFFORT_CHOICES.map((c) => ({
value: c.id,
label: c.label,
hint: c.hint,
}))}
/>
</Field>
)}
</div>
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
<Seg
@@ -542,9 +548,9 @@ export function RunSetup(props: RunSetupProps) {
value={props.prompt}
onChange={props.onPromptChange}
onSubmit={() => handleStart(props)}
placeholder={t("fields.promptPlaceholderTerminal")}
placeholder={t("fields.promptPlaceholderTerminal", { tool: LANE_TOOL_LABEL[props.tool] })}
rows={5}
slash={props.tool !== "codex"}
slash={props.tool === "claude"}
fileCwd={props.resumeSession?.cwd || props.cwd}
/>
<div className="mt-1 text-[10px] text-fg-muted">
@@ -609,18 +615,18 @@ export function RunSetup(props: RunSetupProps) {
function handleStart(props: RunSetupProps, session?: Session) {
const resume = session ?? props.resumeSession;
const codex = props.tool === "codex";
const agy = props.tool === "antigravity";
props.onStart({
laneId: props.laneId,
// A resume is pinned to its own session's folder — that's what the locked
// cwd field shows, so it's what gets sent.
cwd: resume?.cwd || props.cwd || undefined,
model: props.model || undefined,
permissionMode: codex ? undefined : props.permissionMode || undefined,
model: agy ? undefined : props.model || undefined,
permissionMode: codex || agy ? undefined : props.permissionMode || undefined,
sandbox: codex ? props.sandbox : undefined,
approval: codex ? props.approval : undefined,
effort: props.effort || undefined,
resumeSessionId: codex ? undefined : resume?.id || undefined,
resumeLast: codex && !resume ? true : undefined,
effort: agy ? undefined : props.effort || undefined,
resumeSessionId: codex || agy ? undefined : resume?.id || undefined,
// A resume continues a session that already has its own history — sending
// whatever is sitting in the prompt box would fire an extra turn nobody
// asked for, so a resume carries no prompt at all.
@@ -84,6 +84,7 @@ function baseProps() {
binaryStatus: {
claude: { found: true, path: "/usr/local/bin/claude" },
codex: { found: true, path: "/usr/local/bin/codex" },
antigravity: { found: true, path: "/usr/local/bin/agy" },
},
cwdSuggestions: [],
activeRuns: { items: [] },
@@ -380,7 +380,7 @@ describe("RunSetup — per-tool fields", () => {
});
describe("RunSetup — / and @ suggestions in the prompt box", () => {
const box = () => screen.getByPlaceholderText(i18n.t("run:fields.promptPlaceholderTerminal"));
const box = () => screen.getByPlaceholderText(/ anything…$/);
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
const { spies } = renderSetup({ prompt: "" });
@@ -423,3 +423,26 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
expect(api.ccConfig.commands).not.toHaveBeenCalled();
});
});
describe("RunSetup — antigravity lanes", () => {
it("names the lane's CLI in the prompt box and drops the flags `agy` lacks", () => {
const { spies } = renderSetup({ tool: "antigravity", model: "opus", effort: "high" });
expect(screen.getByPlaceholderText("Ask Antigravity anything…")).toBeTruthy();
expect(screen.queryByText(i18n.t("run:fields.model"))).toBeNull();
expect(screen.queryByText(i18n.t("run:fields.effort"))).toBeNull();
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledWith(
expect.objectContaining({ model: undefined, effort: undefined, permissionMode: undefined })
);
});
});
describe("RunSetup — codex lanes", () => {
it("starts a fresh codex run instead of resuming the last one", () => {
const { spies } = renderSetup({ tool: "codex" });
fireEvent.click(screen.getByText(i18n.t("run:actions.start")));
expect(spies.onStart).toHaveBeenCalledOnce();
expect(spies.onStart.mock.calls[0][0].resumeLast).toBeUndefined();
});
});
+1
View File
@@ -59,6 +59,7 @@
"addLaneTitlePlaceholder": "Optional",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolAntigravity": "Antigravity",
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} more",
+1 -1
View File
@@ -81,7 +81,7 @@
"permissionPlan": "plan (read-only planning)",
"prompt": "Prompt",
"promptPlaceholder": "Ask Claude anything…",
"promptPlaceholderTerminal": "Ask Claude anything…",
"promptPlaceholderTerminal": "Ask {{tool}} anything…",
"sandbox": "Sandbox",
"sandboxFullAccess": "Full access (dangerous)",
"sandboxReadOnly": "Read only",
+1
View File
@@ -59,6 +59,7 @@
"addLaneTitlePlaceholder": "Không bắt buộc",
"addLaneToolClaude": "Claude Code",
"addLaneToolCodex": "Codex",
"addLaneToolAntigravity": "Antigravity",
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
"addLaneWorktreeDetached": "(detached)",
"addLaneWorktreesMore": "+{{count}} nữa",
+1 -1
View File
@@ -80,7 +80,7 @@
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
"prompt": "Prompt",
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
"promptPlaceholderTerminal": "Hỏi Claude bất cứ điều gì…",
"promptPlaceholderTerminal": "Hỏi {{tool}} bất cứ điều gì…",
"sandbox": "Sandbox",
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
"sandboxReadOnly": "Chỉ đọc",
+8 -1
View File
@@ -2410,7 +2410,14 @@ export interface ProofFeature {
}
/** Which CLI drives a lane's pane. Fixed at lane creation and never patched. */
export type LaneTool = "claude" | "codex";
export type LaneTool = "claude" | "codex" | "antigravity";
/** How each CLI is named on screen (strip tag, console title, prompt box). */
export const LANE_TOOL_LABEL: Record<LaneTool, string> = {
claude: "Claude",
codex: "Codex",
antigravity: "Antigravity",
};
export interface Lane {
id: number;
+12 -10
View File
@@ -395,8 +395,8 @@ export function Workspace() {
}, [lanes, searchParams, setSearchParams]);
useEffect(() => {
Promise.all([api.run.binary("claude"), api.run.binary("codex")])
.then(([claude, codex]) => setBinaryStatus({ claude, codex }))
Promise.all([api.run.binary("claude"), api.run.binary("codex"), api.run.binary("antigravity")])
.then(([claude, codex, antigravity]) => setBinaryStatus({ claude, codex, antigravity }))
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
// `claude` or `codex` is missing from PATH — leave the probe unresolved rather than
// showing a misleading "binary missing" banner for an unrelated fault.
@@ -523,7 +523,8 @@ export function Workspace() {
}, [refreshList]);
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
const isCodexLane = currentLane?.tool === "codex";
// Only claude lanes carry a pipeline (codex and antigravity emit no hooks).
const noPipeline = !!currentLane && currentLane.tool !== "claude";
// Feature list follows the selected lane, resets the viewer on lane switch.
useEffect(() => {
@@ -816,16 +817,16 @@ export function Workspace() {
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
</button>
)}
{/* Codex lanes carry no pipeline: no time-at-stage, no pipeline
or feature picker, no map. */}
{!isCodexLane && (
{/* Non-claude lanes carry no pipeline: no time-at-stage, no
pipeline or feature picker, no map. */}
{!noPipeline && (
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
{since(currentLane.stage_seconds)}
</span>
)}
{showInfo && (
<>
{!isCodexLane && (
{!noPipeline && (
<select
data-testid="pipeline-picker"
aria-label={tLanes("pipelinePicker.label")}
@@ -855,13 +856,14 @@ export function Workspace() {
))}
</select>
)}
<LaneRouterRow lane={currentLane} />
{/* `agy` has no endpoint to point at a router. */}
{currentLane.tool !== "antigravity" && <LaneRouterRow lane={currentLane} />}
{/* No stage chip and no "auto: <stage>" chip here: the
PipelineMap right below this header already draws both
— the declared node as the current ring, the detected
one as its amber badge — so the chips were a second,
smaller copy of the same two facts. */}
{!isCodexLane && features.length > 0 && (
{!noPipeline && features.length > 0 && (
<select
data-testid="feature-picker"
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
@@ -921,7 +923,7 @@ export function Workspace() {
onSelectLane={setSelectedLaneId}
/>
</div>
{!isCodexLane && (
{!noPipeline && (
<div className="mb-3">
<PipelineMap
nodes={
+2 -2
View File
@@ -2061,7 +2061,7 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
```http
GET /api/run List all live runs (computed fresh from tmux state)
GET /api/run/tmux { available: bool } — whether tmux is on PATH
GET /api/run/binary?tool= { found, path } for the `claude` or `codex` binary
GET /api/run/binary?tool= { found, path } for the `claude`, `codex` or `agy` binary
GET /api/run/cwds Suggested cwds (dashboard, home, recent)
GET /api/run/history?limit=&laneId= Persisted run history; laneId narrows to one lane's runs
GET /api/run/files?cwd=&q= Fuzzy file search inside cwd for the @-file autocomplete
@@ -2074,7 +2074,7 @@ DELETE /api/run/:id Kill (SIGTERM → SIGKILL after 5 s)
`:id` must be a lane pane name, `agenthub-lane-<n>`; anything else answers 404 without reaching tmux, so these two routes can never touch a tmux session the dashboard did not start.
**`GET /api/run/binary`:** Probes for the binary (`claude` or `codex`). Optional query parameter `?tool=codex` probes for Codex; omitted or `?tool=claude` probes for Claude. Returns `{ found: bool, path: string | null }`.
**`GET /api/run/binary`:** Probes for the binary (`claude`, `codex` or `agy`). Optional query parameter `?tool=codex` probes for Codex and `?tool=antigravity` for Antigravity's `agy`; omitted or `?tool=claude` probes for Claude. Returns `{ found: bool, path: string | null }`.
**`POST /api/run` (start/attach):** Requires `laneId` (the lane this run belongs to). Creates or attaches an existing tmux session named `agenthub-lane-<id>` in the lane's working directory. If that session already exists and its pane is idling at a shell prompt, the appropriate command line (including `--resume` and any initial prompt) is typed into that pane instead of being dropped; if the pane is running a program, the request adopts the session unchanged. Optionally accepts `initialPrompt` to immediately type/send into the session (if empty or omitted, the session is created/attached with no initial input). Returns `{ id, laneId, status, cwd, model, permissionMode, effort, resumeSessionId, sessionId, startedAt, promptPreview }` where `id` is the tmux session name. The dashboard self-heals a lane's `run_id`/`status` on every read if the tmux session has been killed externally.
+1 -1
View File
@@ -449,7 +449,7 @@ curl -X POST http://localhost:4820/api/lanes/ensure \
}
```
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude` or `codex`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
- If a lane already owns the `cwd` **for the requested `tool`**, returns that lane with `created: false` and HTTP `200`. `tool` (`claude`, `codex` or `antigravity`, default `claude`) is part of the identity: adopting the same `cwd` with a different `tool` creates a second, sibling lane rather than adopting the existing one — one `cwd` can back at most one `claude` lane and one `codex` lane at a time. This applies to Repo/Adopt mode only; Worktree mode always gets its own directory.
- If the `cwd` is a subdirectory of an existing lane with that same tool, returns the parent lane that owns it. Pass `"exact": true` to skip that: only a lane on this very directory is returned, and a nested one is created as its own lane — the Add-lane modal does this, and shows which lane already owns the folder when the answer is `created: false`.
- If no lane owns the `(cwd, tool)` pair, creates an **adopted** lane and returns it with `created: true` and HTTP `201`.
- A relative or missing `cwd` returns `400 EBADCWD`.
+1 -1
View File
@@ -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 |
| -------- | ----------------------------- | ----------- |
+10 -8
View File
@@ -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", () => {
+16
View File
@@ -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");
});
});
+21
View File
@@ -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
+18
View File
@@ -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");
+15
View File
@@ -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
+1 -1
View File
@@ -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),
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+7
View File
@@ -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
View File
@@ -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;
+3 -2
View File
@@ -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();