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:
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
## Lane stage
|
## 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
|
them and never infers or advances one, so there is no `agenthub stage` call to
|
||||||
make while you work here as Codex.
|
make while you work here as Codex.
|
||||||
|
|
||||||
|
|||||||
@@ -429,6 +429,15 @@ export function AddLaneModal({
|
|||||||
label={t("addLaneToolCodex")}
|
label={t("addLaneToolCodex")}
|
||||||
onClick={() => setTool("codex")}
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -635,7 +644,10 @@ export function AddLaneModal({
|
|||||||
hid it. The pipeline picker is gone entirely: every lane is created
|
hid it. The pipeline picker is gone entirely: every lane is created
|
||||||
on `default` (the server's own default) and the template is changed
|
on `default` (the server's own default) and the template is changed
|
||||||
from the lane's own header afterwards. */}
|
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>
|
<div>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
<legend className="mb-1 text-xs text-fg-secondary">{t("addLaneRouterLabel")}</legend>
|
||||||
|
|||||||
@@ -363,8 +363,8 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
|||||||
role="menu"
|
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"
|
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. */}
|
{/* Claude-only: other CLIs have no agents to install. */}
|
||||||
{lane.tool !== "codex" && (
|
{lane.tool === "claude" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -379,7 +379,7 @@ export function LaneToolsMenu({ lane }: { lane: Lane }) {
|
|||||||
{t("actions.agentsInstall")}
|
{t("actions.agentsInstall")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{lane.tool !== "codex" && (
|
{lane.tool === "claude" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@@ -487,18 +487,11 @@ export default function LaneCard({
|
|||||||
// renders no frame at all rather than an empty box.
|
// 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"
|
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]">
|
<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">
|
||||||
<span className="rounded bg-surface-2 px-1.5 py-0.5 text-fg-secondary">
|
CI {lane.ci_status}
|
||||||
CI {lane.ci_status}
|
</span>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
|
import { LANE_TOOL_LABEL } from "../../lib/types";
|
||||||
|
|
||||||
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
const LIVENESS_DOT: Record<Lane["liveness"], string> = {
|
||||||
active: "bg-status-success",
|
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">
|
<span className="shrink-0 text-[10px] font-semibold tracking-widest text-fg-muted">
|
||||||
#{lane.id}
|
#{lane.id}
|
||||||
</span>
|
</span>
|
||||||
{/* Codex lanes carry no pipeline, so no stage chip — the Codex tag
|
{/* Only claude lanes carry a pipeline; the others show their CLI's
|
||||||
takes its place. */}
|
tag in place of the stage chip. */}
|
||||||
{lane.tool === "codex" ? (
|
{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">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
<span className="truncate rounded bg-surface-4 px-1.5 py-0.5 text-fg-secondary">
|
||||||
{lane.stage}
|
{lane.stage}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{lane.tool !== "codex" && detectionLeads(lane) && (
|
{lane.tool === "claude" && detectionLeads(lane) && (
|
||||||
<span
|
<span
|
||||||
data-testid={`lane-tile-auto-${lane.id}`}
|
data-testid={`lane-tile-auto-${lane.id}`}
|
||||||
title={lane.detected_signal || undefined}
|
title={lane.detected_signal || undefined}
|
||||||
|
|||||||
@@ -96,13 +96,16 @@ function makeLane(overrides: Partial<Lane> = {}): Lane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("LaneCard lane tool badge", () => {
|
describe("LaneCard lane tool badge", () => {
|
||||||
it("badges a codex lane and leaves a claude lane unbadged", () => {
|
// The strip tile already names the CLI; a card holding only that tag was an
|
||||||
const { rerender } = render(
|
// empty frame in the lane detail.
|
||||||
<LaneCard {...{ lane: makeLane({ tool: "codex" }), onAction: vi.fn() }} />
|
it("renders nothing for a codex or antigravity lane with nothing else to show", () => {
|
||||||
);
|
for (const tool of ["codex", "antigravity"] as const) {
|
||||||
expect(screen.getByText("Codex")).toBeInTheDocument();
|
const { container, unmount } = render(
|
||||||
rerender(<LaneCard {...{ lane: makeLane({ tool: "claude" }), onAction: vi.fn() }} />);
|
<LaneCard {...{ lane: makeLane({ tool }), onAction: vi.fn() }} />
|
||||||
expect(screen.queryByText("Codex")).not.toBeInTheDocument();
|
);
|
||||||
|
expect(container.querySelector('[data-testid="lane-card-1"]')).toBeEmptyDOMElement();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type {
|
|||||||
CodexApproval,
|
CodexApproval,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { Session, Lane, LaneTool } from "../../lib/types";
|
import type { Session, Lane, LaneTool } from "../../lib/types";
|
||||||
|
import { LANE_TOOL_LABEL } from "../../lib/types";
|
||||||
import { TerminalView } from "./TerminalView";
|
import { TerminalView } from "./TerminalView";
|
||||||
import { RunSetup } from "./RunSetup";
|
import { RunSetup } from "./RunSetup";
|
||||||
import { ActiveRunsSwitcher } from "./RunHistory";
|
import { ActiveRunsSwitcher } from "./RunHistory";
|
||||||
@@ -149,7 +150,7 @@ export function LaneConsolePane({
|
|||||||
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
|
: (externalSessions ?? []).filter((s) => s.cwd && owningLaneId(s.cwd, lanes) === laneId),
|
||||||
[externalSessions, laneId, lanes]
|
[externalSessions, laneId, lanes]
|
||||||
);
|
);
|
||||||
const toolLabel = laneTool === "codex" ? "Codex" : "Claude";
|
const toolLabel = LANE_TOOL_LABEL[laneTool];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onHasActiveRunChange?.(handle !== null);
|
onHasActiveRunChange?.(handle !== null);
|
||||||
@@ -568,7 +569,12 @@ export function LaneConsolePane({
|
|||||||
{binaryStatus && !binaryStatus[laneTool]?.found && (
|
{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">
|
<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" />
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import type {
|
|||||||
CcMdItem,
|
CcMdItem,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { Session, LaneTool } from "../../lib/types";
|
import type { Session, LaneTool } from "../../lib/types";
|
||||||
|
import { LANE_TOOL_LABEL } from "../../lib/types";
|
||||||
import { Select } from "../Select";
|
import { Select } from "../Select";
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
@@ -430,10 +431,13 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t("fields.model")}>
|
{/* `agy` takes no model, permission or effort flag. */}
|
||||||
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
{props.tool !== "antigravity" && (
|
||||||
</Field>
|
<Field label={t("fields.model")}>
|
||||||
{props.tool === "codex" ? (
|
<ModelPicker value={props.model} onChange={props.onModelChange} tool={props.tool} />
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{props.tool === "antigravity" ? null : props.tool === "codex" ? (
|
||||||
<>
|
<>
|
||||||
<Field label={t("fields.sandbox")}>
|
<Field label={t("fields.sandbox")}>
|
||||||
<Select<CodexSandbox>
|
<Select<CodexSandbox>
|
||||||
@@ -472,17 +476,19 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
<Field label={t("fields.effort")}>
|
{props.tool !== "antigravity" && (
|
||||||
<Select<EffortLevel>
|
<Field label={t("fields.effort")}>
|
||||||
value={props.effort}
|
<Select<EffortLevel>
|
||||||
onChange={props.onEffortChange}
|
value={props.effort}
|
||||||
options={RUN_EFFORT_CHOICES.map((c) => ({
|
onChange={props.onEffortChange}
|
||||||
value: c.id,
|
options={RUN_EFFORT_CHOICES.map((c) => ({
|
||||||
label: c.label,
|
value: c.id,
|
||||||
hint: c.hint,
|
label: c.label,
|
||||||
}))}
|
hint: c.hint,
|
||||||
/>
|
}))}
|
||||||
</Field>
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
<div className="flex shrink-0 items-center rounded-md border border-border bg-surface-2 p-0.5 text-[11.5px]">
|
||||||
<Seg
|
<Seg
|
||||||
@@ -542,9 +548,9 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
value={props.prompt}
|
value={props.prompt}
|
||||||
onChange={props.onPromptChange}
|
onChange={props.onPromptChange}
|
||||||
onSubmit={() => handleStart(props)}
|
onSubmit={() => handleStart(props)}
|
||||||
placeholder={t("fields.promptPlaceholderTerminal")}
|
placeholder={t("fields.promptPlaceholderTerminal", { tool: LANE_TOOL_LABEL[props.tool] })}
|
||||||
rows={5}
|
rows={5}
|
||||||
slash={props.tool !== "codex"}
|
slash={props.tool === "claude"}
|
||||||
fileCwd={props.resumeSession?.cwd || props.cwd}
|
fileCwd={props.resumeSession?.cwd || props.cwd}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 text-[10px] text-fg-muted">
|
<div className="mt-1 text-[10px] text-fg-muted">
|
||||||
@@ -609,18 +615,18 @@ export function RunSetup(props: RunSetupProps) {
|
|||||||
function handleStart(props: RunSetupProps, session?: Session) {
|
function handleStart(props: RunSetupProps, session?: Session) {
|
||||||
const resume = session ?? props.resumeSession;
|
const resume = session ?? props.resumeSession;
|
||||||
const codex = props.tool === "codex";
|
const codex = props.tool === "codex";
|
||||||
|
const agy = props.tool === "antigravity";
|
||||||
props.onStart({
|
props.onStart({
|
||||||
laneId: props.laneId,
|
laneId: props.laneId,
|
||||||
// A resume is pinned to its own session's folder — that's what the locked
|
// 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 field shows, so it's what gets sent.
|
||||||
cwd: resume?.cwd || props.cwd || undefined,
|
cwd: resume?.cwd || props.cwd || undefined,
|
||||||
model: props.model || undefined,
|
model: agy ? undefined : props.model || undefined,
|
||||||
permissionMode: codex ? undefined : props.permissionMode || undefined,
|
permissionMode: codex || agy ? undefined : props.permissionMode || undefined,
|
||||||
sandbox: codex ? props.sandbox : undefined,
|
sandbox: codex ? props.sandbox : undefined,
|
||||||
approval: codex ? props.approval : undefined,
|
approval: codex ? props.approval : undefined,
|
||||||
effort: props.effort || undefined,
|
effort: agy ? undefined : props.effort || undefined,
|
||||||
resumeSessionId: codex ? undefined : resume?.id || undefined,
|
resumeSessionId: codex || agy ? undefined : resume?.id || undefined,
|
||||||
resumeLast: codex && !resume ? true : undefined,
|
|
||||||
// A resume continues a session that already has its own history — sending
|
// 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
|
// whatever is sitting in the prompt box would fire an extra turn nobody
|
||||||
// asked for, so a resume carries no prompt at all.
|
// asked for, so a resume carries no prompt at all.
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ function baseProps() {
|
|||||||
binaryStatus: {
|
binaryStatus: {
|
||||||
claude: { found: true, path: "/usr/local/bin/claude" },
|
claude: { found: true, path: "/usr/local/bin/claude" },
|
||||||
codex: { found: true, path: "/usr/local/bin/codex" },
|
codex: { found: true, path: "/usr/local/bin/codex" },
|
||||||
|
antigravity: { found: true, path: "/usr/local/bin/agy" },
|
||||||
},
|
},
|
||||||
cwdSuggestions: [],
|
cwdSuggestions: [],
|
||||||
activeRuns: { items: [] },
|
activeRuns: { items: [] },
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ describe("RunSetup — per-tool fields", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
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 () => {
|
it("lists the lane's commands, skills and the CLI built-ins on /, and Enter picks one", async () => {
|
||||||
const { spies } = renderSetup({ prompt: "" });
|
const { spies } = renderSetup({ prompt: "" });
|
||||||
@@ -423,3 +423,26 @@ describe("RunSetup — / and @ suggestions in the prompt box", () => {
|
|||||||
expect(api.ccConfig.commands).not.toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"addLaneTitlePlaceholder": "Optional",
|
"addLaneTitlePlaceholder": "Optional",
|
||||||
"addLaneToolClaude": "Claude Code",
|
"addLaneToolClaude": "Claude Code",
|
||||||
"addLaneToolCodex": "Codex",
|
"addLaneToolCodex": "Codex",
|
||||||
|
"addLaneToolAntigravity": "Antigravity",
|
||||||
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
|
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
|
||||||
"addLaneWorktreeDetached": "(detached)",
|
"addLaneWorktreeDetached": "(detached)",
|
||||||
"addLaneWorktreesMore": "+{{count}} more",
|
"addLaneWorktreesMore": "+{{count}} more",
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
"permissionPlan": "plan (read-only planning)",
|
"permissionPlan": "plan (read-only planning)",
|
||||||
"prompt": "Prompt",
|
"prompt": "Prompt",
|
||||||
"promptPlaceholder": "Ask Claude anything…",
|
"promptPlaceholder": "Ask Claude anything…",
|
||||||
"promptPlaceholderTerminal": "Ask Claude anything…",
|
"promptPlaceholderTerminal": "Ask {{tool}} anything…",
|
||||||
"sandbox": "Sandbox",
|
"sandbox": "Sandbox",
|
||||||
"sandboxFullAccess": "Full access (dangerous)",
|
"sandboxFullAccess": "Full access (dangerous)",
|
||||||
"sandboxReadOnly": "Read only",
|
"sandboxReadOnly": "Read only",
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||||
"addLaneToolClaude": "Claude Code",
|
"addLaneToolClaude": "Claude Code",
|
||||||
"addLaneToolCodex": "Codex",
|
"addLaneToolCodex": "Codex",
|
||||||
|
"addLaneToolAntigravity": "Antigravity",
|
||||||
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
|
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
|
||||||
"addLaneWorktreeDetached": "(detached)",
|
"addLaneWorktreeDetached": "(detached)",
|
||||||
"addLaneWorktreesMore": "+{{count}} nữa",
|
"addLaneWorktreesMore": "+{{count}} nữa",
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
"permissionPlan": "plan (chỉ đọc, lập kế hoạch)",
|
||||||
"prompt": "Prompt",
|
"prompt": "Prompt",
|
||||||
"promptPlaceholder": "Hỏi Claude bất cứ điều gì…",
|
"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",
|
"sandbox": "Sandbox",
|
||||||
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
|
"sandboxFullAccess": "Truy cập đầy đủ (nguy hiểm)",
|
||||||
"sandboxReadOnly": "Chỉ đọc",
|
"sandboxReadOnly": "Chỉ đọc",
|
||||||
|
|||||||
@@ -2410,7 +2410,14 @@ export interface ProofFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Which CLI drives a lane's pane. Fixed at lane creation and never patched. */
|
/** 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 {
|
export interface Lane {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -395,8 +395,8 @@ export function Workspace() {
|
|||||||
}, [lanes, searchParams, setSearchParams]);
|
}, [lanes, searchParams, setSearchParams]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([api.run.binary("claude"), api.run.binary("codex")])
|
Promise.all([api.run.binary("claude"), api.run.binary("codex"), api.run.binary("antigravity")])
|
||||||
.then(([claude, codex]) => setBinaryStatus({ claude, codex }))
|
.then(([claude, codex, antigravity]) => setBinaryStatus({ claude, codex, antigravity }))
|
||||||
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
|
// Fetch failure (server unreachable, proxy misrouted, etc.) isn't proof
|
||||||
// `claude` or `codex` is missing from PATH — leave the probe unresolved rather than
|
// `claude` or `codex` is missing from PATH — leave the probe unresolved rather than
|
||||||
// showing a misleading "binary missing" banner for an unrelated fault.
|
// showing a misleading "binary missing" banner for an unrelated fault.
|
||||||
@@ -523,7 +523,8 @@ export function Workspace() {
|
|||||||
}, [refreshList]);
|
}, [refreshList]);
|
||||||
|
|
||||||
const currentLane = selectedLaneId !== null ? lanes.find((l) => l.id === selectedLaneId) : null;
|
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.
|
// Feature list follows the selected lane, resets the viewer on lane switch.
|
||||||
useEffect(() => {
|
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" />
|
<Pencil className="h-3 w-3 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{/* Codex lanes carry no pipeline: no time-at-stage, no pipeline
|
{/* Non-claude lanes carry no pipeline: no time-at-stage, no
|
||||||
or feature picker, no map. */}
|
pipeline or feature picker, no map. */}
|
||||||
{!isCodexLane && (
|
{!noPipeline && (
|
||||||
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
<span className="shrink-0 tabular-nums text-xs text-fg-muted">
|
||||||
{since(currentLane.stage_seconds)}
|
{since(currentLane.stage_seconds)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{showInfo && (
|
{showInfo && (
|
||||||
<>
|
<>
|
||||||
{!isCodexLane && (
|
{!noPipeline && (
|
||||||
<select
|
<select
|
||||||
data-testid="pipeline-picker"
|
data-testid="pipeline-picker"
|
||||||
aria-label={tLanes("pipelinePicker.label")}
|
aria-label={tLanes("pipelinePicker.label")}
|
||||||
@@ -855,13 +856,14 @@ export function Workspace() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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
|
{/* No stage chip and no "auto: <stage>" chip here: the
|
||||||
PipelineMap right below this header already draws both
|
PipelineMap right below this header already draws both
|
||||||
— the declared node as the current ring, the detected
|
— the declared node as the current ring, the detected
|
||||||
one as its amber badge — so the chips were a second,
|
one as its amber badge — so the chips were a second,
|
||||||
smaller copy of the same two facts. */}
|
smaller copy of the same two facts. */}
|
||||||
{!isCodexLane && features.length > 0 && (
|
{!noPipeline && features.length > 0 && (
|
||||||
<select
|
<select
|
||||||
data-testid="feature-picker"
|
data-testid="feature-picker"
|
||||||
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
className="rounded border border-border bg-surface-1 px-2 py-0.5 text-xs"
|
||||||
@@ -921,7 +923,7 @@ export function Workspace() {
|
|||||||
onSelectLane={setSelectedLaneId}
|
onSelectLane={setSelectedLaneId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isCodexLane && (
|
{!noPipeline && (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<PipelineMap
|
<PipelineMap
|
||||||
nodes={
|
nodes={
|
||||||
|
|||||||
+2
-2
@@ -2061,7 +2061,7 @@ The `/api/run/*` namespace starts and manages `claude` subprocesses in tmux-back
|
|||||||
```http
|
```http
|
||||||
GET /api/run List all live runs (computed fresh from tmux state)
|
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/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/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/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
|
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.
|
`: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.
|
**`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
@@ -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 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`.
|
- 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`.
|
- A relative or missing `cwd` returns `400 EBADCWD`.
|
||||||
|
|||||||
+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`)
|
### 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 |
|
| Method | Path | Description |
|
||||||
| -------- | ----------------------------- | ----------- |
|
| -------- | ----------------------------- | ----------- |
|
||||||
|
|||||||
@@ -116,15 +116,17 @@ describe("installAgents", () => {
|
|||||||
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents")));
|
assert.ok(fs.existsSync(path.join(cwd, ".claude", "agents")));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("installs nothing for a codex lane — no .claude/agents/, no AGENTS.md", async () => {
|
for (const tool of ["codex", "antigravity"]) {
|
||||||
const cwd = makeRepoDir();
|
it(`installs nothing for a ${tool} lane — no .claude/agents/, no AGENTS.md`, async () => {
|
||||||
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool: "codex" });
|
const cwd = makeRepoDir();
|
||||||
|
const lane = lanesLib.createLane({ title: "cx", cwd, kind: "managed", tool });
|
||||||
|
|
||||||
const result = await installAgents(lane);
|
const result = await installAgents(lane);
|
||||||
assert.deepEqual(result.installed, []);
|
assert.deepEqual(result.installed, []);
|
||||||
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
|
assert.equal(fs.existsSync(path.join(cwd, ".claude", "agents")), false);
|
||||||
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
|
assert.equal(fs.existsSync(path.join(cwd, "AGENTS.md")), false);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("codex pipeline cleanup", () => {
|
describe("codex pipeline cleanup", () => {
|
||||||
|
|||||||
@@ -1302,4 +1302,20 @@ describe("lane ensure, start mode, lane_id and releasing a finished run", () =>
|
|||||||
restorePath();
|
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")));
|
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
|
// `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
|
// 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
|
// 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", () => {
|
describe("router-apply — Codex lane", () => {
|
||||||
it("writes a profile layer file, never the base config", () => {
|
it("writes a profile layer file, never the base config", () => {
|
||||||
const base = path.join(process.env.CODEX_HOME, "config.toml");
|
const base = path.join(process.env.CODEX_HOME, "config.toml");
|
||||||
|
|||||||
@@ -504,6 +504,21 @@ function startBackgroundServices() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("codex poll failed to start:", err.message);
|
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
|
// Continuous discovery of sessions under ~/.claude/projects. The one-time
|
||||||
// legacy backfill above runs only once (marker-gated), so a project added
|
// legacy backfill above runs only once (marker-gated), so a project added
|
||||||
// later whose sessions never flow through hooks would otherwise stay invisible
|
// later whose sessions never flow through hooks would otherwise stay invisible
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function recordRun(handle) {
|
|||||||
started_at: startedAt,
|
started_at: startedAt,
|
||||||
ended_at: endedAt,
|
ended_at: endedAt,
|
||||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
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) {
|
} catch (err) {
|
||||||
// Best-effort by design (a live run must never fail because sqlite did),
|
// 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
|
* any existing copy — this is a reinstall, not a merge) and idempotently
|
||||||
* git-exclude that directory in the shared common git dir.
|
* git-exclude that directory in the shared common git dir.
|
||||||
*
|
*
|
||||||
* A `codex` lane gets nothing: Codex never reads `.claude/agents/`, and codex
|
* A `codex` or `antigravity` lane gets nothing: neither CLI reads
|
||||||
* lanes carry no pipeline to drive.
|
* `.claude/agents/`, and those lanes carry no pipeline to drive.
|
||||||
*
|
*
|
||||||
* @param {{cwd: string, tool?: string}} lane
|
* @param {{cwd: string, tool?: string}} lane
|
||||||
* @returns {Promise<{installed: string[]}>}
|
* @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");
|
const dest = path.join(lane.cwd, ".claude", "agents");
|
||||||
fs.mkdirSync(dest, { recursive: true });
|
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) {
|
function validateTool(tool) {
|
||||||
if (!VALID_TOOLS.has(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
|
* 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.
|
* 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
|
* 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
|
* `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".
|
// 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 VALID_TOOLS = new Set(["claude", "codex", "antigravity"]);
|
||||||
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
const CODEX_SANDBOXES = new Set(["read-only", "workspace-write", "danger-full-access"]);
|
||||||
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
const CODEX_APPROVALS = new Set(["on-request", "never"]);
|
||||||
// Codex has no `--effort` flag; reasoning effort is a config override.
|
// Codex has no `--effort` flag; reasoning effort is a config override.
|
||||||
@@ -137,8 +138,23 @@ function buildCodexArgv({
|
|||||||
return argv;
|
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) {
|
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.
|
// separate `external` row. Every run here is one interactive pane.
|
||||||
mode: "interactive",
|
mode: "interactive",
|
||||||
cwd,
|
cwd,
|
||||||
tool: tool === "codex" ? "codex" : "claude",
|
tool,
|
||||||
model: model || null,
|
model: model || null,
|
||||||
permissionMode: permissionMode || "auto",
|
permissionMode: permissionMode || "auto",
|
||||||
effort: effort || null,
|
effort: effort || null,
|
||||||
|
|||||||
@@ -187,6 +187,13 @@ function applyToCodexLane(lane, profile) {
|
|||||||
*/
|
*/
|
||||||
function applyRouterToLane(lane, profileName) {
|
function applyRouterToLane(lane, profileName) {
|
||||||
const profile = routers.getRouter(profileName); // throws ENOTFOUND
|
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") {
|
if (lane.tool === "codex") {
|
||||||
// Native is the base config as it already stands. A layer that overrode
|
// 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.
|
// 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);
|
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) {
|
function payload(lane) {
|
||||||
const healed = healRunId(lane);
|
const healed = healRunId(lane);
|
||||||
return lanesLib.lanePayload(healed, lastEventAge(healed));
|
return lanesLib.lanePayload(healed, lastEventAge(healed));
|
||||||
@@ -697,7 +707,8 @@ router.post("/:id/router/apply", sameOriginGuard, (req, res) => {
|
|||||||
broadcastLane(lane.id);
|
broadcastLane(lane.id);
|
||||||
res.json({ ...result, restart_required: live, run_id: live ? runId : null });
|
res.json({ ...result, restart_required: live, run_id: live ? runId : null });
|
||||||
} catch (err) {
|
} 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 } });
|
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 = router;
|
||||||
module.exports.broadcastLane = broadcastLane;
|
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
|
// taken from a fixed allowlist, never from the query string directly — this
|
||||||
// string reaches `which`.
|
// string reaches `which`.
|
||||||
const tool = req.query.tool === undefined ? "claude" : String(req.query.tool);
|
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" } });
|
return res.status(400).json({ error: { code: "EBADTOOL", message: "unknown tool" } });
|
||||||
}
|
}
|
||||||
const which = require("node:child_process").spawnSync(
|
const which = require("node:child_process").spawnSync(
|
||||||
process.platform === "win32" ? "where" : "which",
|
process.platform === "win32" ? "where" : "which",
|
||||||
[tool],
|
[bins[tool]],
|
||||||
{ encoding: "utf8" }
|
{ encoding: "utf8" }
|
||||||
);
|
);
|
||||||
const stdout = (which.stdout || "").trim();
|
const stdout = (which.stdout || "").trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user