Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion commands/__tests__/agent-fallback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ test("refuses headless start before spawning", async () => {
const spy = { called: false };
const res = await runAgentFallback("agent:start",
{ repo: REPO, cwd: "/tmp/x", prompt: "hi", surface: "headless" },
{ db, spawnHeadless: () => { spy.called = true; return { exited: Promise.resolve(0), stdout: async () => "" }; } });
{ db, spawnHeadless: () => { spy.called = true; return { exited: Promise.resolve(0), stdout: async () => "", sessionId: () => Promise.resolve(undefined) }; } });
expect(res.ok).toBe(false);
if (res.ok) throw new Error("unreachable");
expect(res.error).toBe(HEADLESS_NEEDS_DAEMON);
Expand Down Expand Up @@ -63,6 +63,21 @@ test("refuses bg start before spawning: bg needs the daemon-owned server this fa
expect(spy.called).toBe(false);
});

// The fallback is a short-lived CLI process: codex's session-id capture is a
// detached poll with a ten-minute budget, so scheduling it here would hang the
// process (or be killed mid-flight) and capture nothing either way.
test("a codex herdr start schedules no session-id poll: the CLI would exit before it resolved", async () => {
const db = openStateDb(tmp());
const calls: string[][] = [];
const res = await runAgentFallback("agent:start",
{ repo: REPO, cwd: "/tmp/x", prompt: "hi", surface: "herdr", provider: "codex" },
{ db, herdrRunner: okRunner(calls) });
expect(res.ok).toBe(true);
// Give a mis-scheduled poll a chance to make its first call before asserting.
await new Promise((r) => setTimeout(r, 50));
expect(calls.some((c) => c[0] === "agent" && c[1] === "get")).toBe(false);
});

test("list returns records", async () => {
const db = openStateDb(tmp());
const calls: string[][] = [];
Expand Down
29 changes: 29 additions & 0 deletions commands/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,35 @@ describe("parseStartArgs", () => {
expect(parseStartArgs(["--extra-args", "--bg"]).bg).toBeUndefined();
expect(parseStartArgs(["--extra-args", "--bg"]).extraArgs).toBe("--bg");
});

test("parseStartArgs: --provider codex", () => {
const parsed = __test__.parseStartArgs(["--provider", "codex", "--prompt", "go"]);
expect(parsed.provider).toBe("codex");
});

test("parseStartArgs: invalid --provider throws", () => {
expect(() => __test__.parseStartArgs(["--provider", "cursor"])).toThrow(/invalid provider/);
});

test("parseStartArgs: --yolo sets the flag", () => {
const parsed = __test__.parseStartArgs(["--yolo", "--prompt", "go"]);
expect(parsed.yolo).toBe(true);
});

test("parseStartArgs: no --yolo leaves it undefined", () => {
const parsed = __test__.parseStartArgs(["--prompt", "go"]);
expect(parsed.yolo).toBeUndefined();
});

// false, not undefined: the daemon reads `payload.yolo ?? the setting`, so
// only an explicit false can override a true agent.<provider>.yolo.
test("parseStartArgs: --no-yolo sets yolo false, distinct from omitting it", () => {
expect(__test__.parseStartArgs(["--no-yolo", "--prompt", "go"]).yolo).toBe(false);
});

test("parseStartArgs: --yolo and --no-yolo together throw", () => {
expect(() => __test__.parseStartArgs(["--yolo", "--no-yolo"])).toThrow(/not both/);
});
});

describe("parseResumeArgs", () => {
Expand Down
5 changes: 5 additions & 0 deletions commands/agent-fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export async function runAgentFallback<T>(
const handlers = createAgentHandlers({
db,
emitEvent: () => 0,
// Same reason headless is refused above: this process exits as soon as the
// verb returns, and codex's session-id capture is a detached poll that can
// outlive it by minutes. Either it holds the CLI open for the whole
// timeout or it dies mid-flight; neither is a capture.
skipSessionCapture: true,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
...(deps.herdrRunner !== undefined && { herdrRunner: deps.herdrRunner }),
...(deps.spawnHeadless !== undefined && { spawnHeadless: deps.spawnHeadless }),
});
Expand Down
26 changes: 23 additions & 3 deletions commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
* rt agent: hand a prompt to a Claude Code agent and keep the receipt.
*
* rt agent start [--repo <path>] [--prompt <text> | --prompt-file <path>]
* [--surface herdr|headless] [--model M] [--effort E]
* [--surface herdr|headless] [--provider claude|codex]
* [--model M] [--effort E] [--yolo | --no-yolo]
* [--account A] [--label L] [--caller C]
* [--workspace W] [--tab T] [--extra-args "<tail>"]
* [--bg] [--json]
Expand All @@ -28,7 +29,7 @@ import type { RtResponse } from "../packages/rt-client/src/index.ts";

const FLAGS_WITH_VALUES = new Set([
"--repo", "--prompt", "--prompt-file", "--surface", "--model", "--effort",
"--account", "--label", "--caller", "--workspace", "--tab", "--extra-args",
"--account", "--label", "--caller", "--workspace", "--tab", "--extra-args", "--provider",
]);

function fail(msg: string): never {
Expand Down Expand Up @@ -92,7 +93,7 @@ function parseSurface(s: string | undefined): AgentSurface | undefined {
interface StartArgs {
prompt?: string; surface?: AgentSurface; model?: string; effort?: string;
account?: string; label?: string; caller?: string; workspace?: string;
tab?: string; extraArgs?: string; bg?: boolean;
tab?: string; extraArgs?: string; bg?: boolean; provider?: "claude" | "codex"; yolo?: boolean;
}

function parseStartArgs(args: string[]): StartArgs {
Expand All @@ -104,6 +105,11 @@ function parseStartArgs(args: string[]): StartArgs {
if (resolved !== undefined) out.prompt = resolved;
const surface = parseSurface(flagValue(args, "--surface"));
if (surface !== undefined) out.surface = surface;
const provider = flagValue(args, "--provider");
if (provider !== undefined) {
if (provider !== "claude" && provider !== "codex") throw new Error(`invalid provider "${provider}": expected claude or codex`);
out.provider = provider;
}
for (const [flag, key] of [
["--model", "model"], ["--effort", "effort"], ["--account", "account"],
["--label", "label"], ["--caller", "caller"], ["--workspace", "workspace"],
Expand All @@ -116,6 +122,13 @@ function parseStartArgs(args: string[]): StartArgs {
if (surface === "headless") throw new Error("--bg is a herdr-surface option");
out.bg = true;
}
// Three states, not two: --yolo forces on, --no-yolo forces off, and
// omitting both leaves yolo undefined so agent.<provider>.yolo decides.
const yes = hasFlag(args, "--yolo");
const no = hasFlag(args, "--no-yolo");
if (yes && no) throw new Error("pass one of --yolo / --no-yolo, not both");
if (yes) out.yolo = true;
else if (no) out.yolo = false;
return out;
}

Expand Down Expand Up @@ -155,10 +168,12 @@ async function repoAndCwd(args: string[]): Promise<{ repo: string; cwd: string }
function renderRecord(r: AgentRecord): string {
const bits = [
`${r.id} ${repoLabel(r.repo)} ${r.surface}`,
`provider ${r.provider}`,
`session ${r.sessionId}`,
r.handle && `handle ${r.handle}`,
r.model && `model ${r.model}`,
r.account && `account ${r.account}`,
r.yolo && "yolo",
r.paneId && `pane ${r.paneId}`,
r.finishedAt !== undefined && `exit ${r.exitCode}`,
r.lastResumedAt !== undefined && "resumed",
Expand All @@ -177,10 +192,15 @@ async function runStart(args: string[]): Promise<void> {
const payload = { repo, cwd, ...parsed };
const data = unwrap(await dispatch("agent:start", payload, () => agentStart(payload)), "start");
if (args.includes("--json")) {
// Deliberately unannotated: a machine consumer needs the raw record, and
// the session id it carries for codex is provisional (see the note below).
console.log(JSON.stringify({ ok: true, agent: data }));
return;
}
console.log(renderRecord(data));
if (data.provider === "codex") {
console.log(`note: codex mints its own session id; the one above is provisional. Capturing the real one needs the rt daemon running (start it with \`rt daemon start\` if it isn't) -- once it is, \`rt agent show ${data.id}\` confirms the real id before resuming.`);
}
}

async function runResume(args: string[]): Promise<void> {
Expand Down
78 changes: 78 additions & 0 deletions lib/__tests__/agent-argv-codex.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, test } from "bun:test";
import { buildCodexArgv, buildCodexPaneCommand } from "../agent-argv/index.ts";

const UUID = "6e225e74-4cb7-4aea-8807-6aa9011d4112";

describe("buildCodexArgv", () => {
const bins = { codex: "/abs/codex" };

test("headless start: exec --json, no session id emitted", () => {
const argv = buildCodexArgv({ session: { kind: "start", sessionId: UUID }, headless: true, prompt: "do it" }, bins);
expect(argv).toEqual(["/abs/codex", "exec", "--json", "do it"]);
expect(argv).not.toContain(UUID);
});

test("all knobs, headless start", () => {
const argv = buildCodexArgv({
model: "gpt-6-astra", effort: "high", yolo: true, extraArgs: "--search",
session: { kind: "start", sessionId: UUID }, headless: true, prompt: "do it",
}, bins);
expect(argv).toEqual([
"/abs/codex", "exec", "--json",
"-m", "gpt-6-astra", "-c", "model_reasoning_effort=high",
"--dangerously-bypass-approvals-and-sandbox", "--search", "do it",
]);
});

test("headless resume: exec resume --json <flags> <id> <prompt>", () => {
const argv = buildCodexArgv({ model: "gpt-6-astra", session: { kind: "resume", sessionId: UUID }, headless: true, prompt: "q" }, bins);
expect(argv).toEqual(["/abs/codex", "exec", "resume", "--json", "-m", "gpt-6-astra", UUID, "q"]);
});

test("non-headless resume emits no --json", () => {
const argv = buildCodexArgv({ session: { kind: "resume", sessionId: UUID }, headless: false }, bins);
expect(argv).toEqual(["/abs/codex", "exec", "resume", UUID]);
});

// Pins the --json gate, not a production shape: a herdr launch goes through
// buildCodexPaneCommand, so nothing in production calls this with
// headless: false on a start.
test("start with headless: false omits --json", () => {
const argv = buildCodexArgv({ session: { kind: "start", sessionId: UUID }, headless: false }, bins);
expect(argv).not.toContain("--json");
expect(argv).toEqual(["/abs/codex", "exec"]);
});

test("headless without a prompt throws", () => {
expect(() => buildCodexArgv({ session: { kind: "start", sessionId: UUID }, headless: true }, bins)).toThrow(/prompt/);
});
});

describe("buildCodexPaneCommand", () => {
test("start: cd + bare codex + flags + quoted prompt", () => {
const cmd = buildCodexPaneCommand("/repo dir", {
model: "gpt-6-astra",
session: { kind: "start", sessionId: UUID }, headless: false, prompt: "hi 'there'",
});
expect(cmd).toBe(`cd '/repo dir' && codex '-m' 'gpt-6-astra' 'hi '\\''there'\\'''`);
});

test("resume: codex resume <flags> <quoted id>", () => {
const cmd = buildCodexPaneCommand("/r", { session: { kind: "resume", sessionId: UUID }, headless: false });
expect(cmd).toBe(`cd '/r' && codex resume '${UUID}'`);
});

test("env assignments precede the codex head", () => {
const cmd = buildCodexPaneCommand("/w/x", {
session: { kind: "start", sessionId: UUID },
headless: false,
env: { RT_AGENT_ID: "ag-1" },
});
expect(cmd).toContain("cd '/w/x' && RT_AGENT_ID='ag-1' codex");
});

test("yolo maps to --dangerously-bypass-approvals-and-sandbox", () => {
const cmd = buildCodexPaneCommand("/r", { yolo: true, session: { kind: "start", sessionId: UUID }, headless: false });
expect(cmd).toContain("--dangerously-bypass-approvals-and-sandbox");
});
});
12 changes: 11 additions & 1 deletion lib/__tests__/agent-argv.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { buildClaudeArgv, buildPaneCommand, isValidSessionUuid, shellSingleQuote } from "../agent-argv.ts";
import { buildClaudeArgv, buildPaneCommand, isValidSessionUuid, shellSingleQuote } from "../agent-argv/index.ts";

const UUID = "6e225e74-4cb7-4aea-8807-6aa9011d4112";

Expand Down Expand Up @@ -102,6 +102,16 @@ describe("buildClaudeArgv", () => {
expect(argv).toContain("--settings");
expect(argv).toContain("/hooks/ag-2.json");
});

test("yolo maps to --dangerously-skip-permissions", () => {
const argv = buildClaudeArgv({ yolo: true, session: { kind: "start", sessionId: UUID }, headless: false }, bins);
expect(argv).toContain("--dangerously-skip-permissions");
});

test("no yolo emits no bypass flag", () => {
const argv = buildClaudeArgv({ session: { kind: "start", sessionId: UUID }, headless: false }, bins);
expect(argv).not.toContain("--dangerously-skip-permissions");
});
});

describe("buildPaneCommand", () => {
Expand Down
28 changes: 27 additions & 1 deletion lib/__tests__/agent-herdr.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { expect, test } from "bun:test";
import { mkdtempSync, writeFileSync, chmodSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { defaultHerdrRunner, herdrAgentWait, launchInWorkspace, resolveHerdrBin, type HerdrRunner } from "../agent-herdr.ts";
import { defaultHerdrRunner, herdrAgentSessionId, herdrAgentWait, launchInWorkspace, resolveHerdrBin, type HerdrRunner } from "../agent-herdr.ts";

function scripted(responses: Record<string, { stdout: string; exitCode?: number }>) {
const calls: string[][] = [];
Expand Down Expand Up @@ -94,6 +94,32 @@ test("herdrAgentWait builds the current verb (agent wait --until)", async () =>
expect(calls[0]).toEqual(["agent", "wait", "wA:p1", "--until", "idle", "--until", "done", "--timeout", "45000"]);
});

// Real `herdr agent get` output, observed 2026-09-15, after codex's first
// prompt completed: {"result":{"agent":{"agent_session":{"agent":"codex",
// "kind":"id","source":"herdr:codex","value":"<uuid>"},...},"type":"agent_info"}}
// A freshly launched, not-yet-prompted pane has no `agent_session` key at
// all -- that shape is what the "still empty" call below models.
test("herdrAgentSessionId returns the id once herdr reports it", async () => {
let call = 0;
const runner: HerdrRunner = async (args) => {
call += 1;
if (args[0] === "agent" && args[1] === "get") {
return call < 2
? { stdout: JSON.stringify({ result: { agent: {} } }), exitCode: 0 }
: { stdout: JSON.stringify({ result: { agent: { agent_session: { value: "s_herdr_456" } } } }), exitCode: 0 };
}
throw new Error(`unexpected herdr call: ${args.join(" ")}`);
};
const sid = await herdrAgentSessionId("pane-1", 5000, runner);
expect(sid).toBe("s_herdr_456");
});

test("herdrAgentSessionId gives up at the timeout", async () => {
const runner: HerdrRunner = async () => ({ stdout: JSON.stringify({ result: { agent: {} } }), exitCode: 0 });
const sid = await herdrAgentSessionId("pane-1", 600, runner);
expect(sid).toBeUndefined();
});

test("resolveHerdrBin prefers HERDR_BIN over everything else", () => {
const bin = resolveHerdrBin({ HERDR_BIN: "/custom/herdr", HOME: "/home/x" }, () => "/opt/homebrew/bin/herdr");
expect(bin).toBe("/custom/herdr");
Expand Down
33 changes: 7 additions & 26 deletions lib/agent-argv.ts → lib/agent-argv/claude.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* lib/agent-argv.ts ... pure claude/cswap invocation building for `rt agent`.
* lib/agent-argv/claude.ts ... pure claude/cswap invocation building for `rt agent`.
*
* Session uuids are validated here because the claude CLI fails soft:
* `--session-id ""` is silently ignored (random id minted) and
Expand All @@ -14,6 +14,7 @@

import { homedir } from "os";
import { join } from "path";
import type { AgentInvocation } from "./types.ts";

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

Expand All @@ -25,28 +26,7 @@ export function shellSingleQuote(s: string): string {
return `'${s.replaceAll("'", `'\\''`)}'`;
}

export interface ClaudeInvocation {
account?: string;
model?: string;
effort?: string;
/** Chat handle reserved for this agent (lib/chat-names.ts pool); interactive only, see claudeArgs. */
name?: string;
extraArgs?: string;
session: { kind: "start"; sessionId: string } | { kind: "resume"; sessionId: string };
headless: boolean;
prompt?: string;
/** Extra environment for the pane shell, exported before the claude head. Values are single-quoted verbatim. */
env?: Record<string, string>;
/**
* Absolute path to a settings JSON file. A launch never emits two
* `--settings` flags (repeated-flag semantics are unverified against the
* real CLI): when set, this REPLACES the `--name`-triggered inline
* CROSS_SESSION_INBOUND_SETTINGS JSON below -- the caller (lib/agent-hooks.ts's
* mergeGateForkHookSettings, via lib/daemon/handlers/agent.ts) is
* responsible for folding that same object into the file's content first.
*/
settingsPath?: string;
}
export type ClaudeInvocation = AgentInvocation;

/** The inline `--settings` JSON a reserved chat handle triggers on its own (no settingsPath). Exported so a settingsPath caller can merge it into the SAME file instead of the flag being emitted twice. */
export const CROSS_SESSION_INBOUND_SETTINGS = { crossSessionInbound: "accept" } as const;
Expand All @@ -59,7 +39,7 @@ export function resolveCswapBin(): string {
return Bun.which("cswap") ?? join(process.env.HOME ?? homedir(), ".local", "bin", "cswap");
}

function claudeArgs(inv: ClaudeInvocation): string[] {
function claudeArgs(inv: AgentInvocation): string[] {
if (!isValidSessionUuid(inv.session.sessionId)) {
throw new Error(`invalid session uuid "${inv.session.sessionId}" ... refusing to spawn`);
}
Expand All @@ -68,6 +48,7 @@ function claudeArgs(inv: ClaudeInvocation): string[] {
}
const args: string[] = [];
if (inv.headless) args.push("-p", "--output-format", "json");
if (inv.yolo) args.push("--dangerously-skip-permissions");
if (inv.model) args.push("--model", inv.model);
if (inv.effort) args.push("--effort", inv.effort);
// Headless (-p) never signs into chat, so a reserved handle is not passed
Expand All @@ -86,15 +67,15 @@ function claudeArgs(inv: ClaudeInvocation): string[] {
return args;
}

export function buildClaudeArgv(inv: ClaudeInvocation, bins?: { claude?: string; cswap?: string }): string[] {
export function buildClaudeArgv(inv: AgentInvocation, bins?: { claude?: string; cswap?: string }): string[] {
const args = claudeArgs(inv);
// claude args live only after "--"; the literal word "claude" is never
// among them since cswap runs claude itself.
if (inv.account) return [bins?.cswap ?? resolveCswapBin(), "run", inv.account, "--", ...args];
return [bins?.claude ?? resolveClaudeBin(), ...args];
}

export function buildPaneCommand(cwd: string, inv: ClaudeInvocation): string {
export function buildPaneCommand(cwd: string, inv: AgentInvocation): string {
// Every token is single-quoted, including flag names (no allowlist), so a
// prompt equal to a flag like "-p" is still treated as data.
const quoted = claudeArgs(inv).map(shellSingleQuote);
Expand Down
Loading
Loading