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
8 changes: 8 additions & 0 deletions cli/src/agents/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { homedir } from "node:os";
import { join } from "node:path";
import { parse as parseYaml } from "yaml";
import { isValidSlug } from "./identity.js";
import { Runtime, isRuntime } from "./runtime.js";

/// One long-lived agent, defined declaratively. Used by the reconciler (slug,
/// repos, model), the scheduler (schedule, dailyPrompt) and the Slack bridge
/// (slackChannel). Prompts live here so the whole agent is one config object.
export interface AgentConfig {
slug: string;
/// Which agent CLI drives this agent. Optional; defaults to "claude".
runtime?: Runtime;
/// GitHub repos the agent works on, as "owner/name".
repos: string[];
/// Model alias or full name (claude --model). Optional.
Expand Down Expand Up @@ -49,6 +52,10 @@ export function parseAgentsConfig(text: string): AgentsFile {
if (!isValidSlug(a.slug)) throw new Error(`agents[${i}].slug invalid: ${JSON.stringify(a.slug)}`);
if (seen.has(a.slug)) throw new Error(`duplicate agent slug: ${a.slug}`);
seen.add(a.slug);
const runtime = a.runtime ?? "claude";
if (!isRuntime(runtime)) {
throw new Error(`agents[${i}] (${a.slug}) has invalid runtime ${JSON.stringify(a.runtime)} (expected "claude" or "codex")`);
}
const repos = Array.isArray(a.repos) ? a.repos : [];
for (const r of repos) {
if (typeof r !== "string" || !REPO_RE.test(r)) {
Expand All @@ -57,6 +64,7 @@ export function parseAgentsConfig(text: string): AgentsFile {
}
return {
slug: a.slug,
runtime,
repos,
model: a.model,
slackChannel: a.slackChannel,
Expand Down
14 changes: 10 additions & 4 deletions cli/src/agents/identity.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { uuidv5 } from "../uuid.js";
import { Runtime } from "./runtime.js";

/// A stable, readable identity for a long-lived agent. Everything humans see or
/// type is the readable slug; only the Claude session id is a (deterministic)
/// UUID, because `claude --session-id` requires a valid UUID.
/// type is the readable slug; the session id is a deterministic UUID. For Claude
/// it is the --session-id / --resume key; for Codex (which mints its own id) it
/// is still the stable hook-events correlation key, passed to the hook via env.
export interface AgentIdentity {
/// Readable slug, e.g. "dependabot-scout". Source of truth for the identity.
slug: string;
/// Deterministic UUIDv5 of the slug — the Claude --session-id / --resume key.
/// Which agent CLI drives this agent.
runtime: Runtime;
/// Deterministic UUIDv5 of the slug. Claude --session-id/--resume key, and the
/// hook-events correlation key for both runtimes.
sessionId: string;
/// tmux session name (== slug).
tmuxName: string;
Expand All @@ -22,14 +27,15 @@ export function isValidSlug(slug: string): boolean {
return SLUG_RE.test(slug) && slug.length <= 60;
}

export function agentIdentity(slug: string): AgentIdentity {
export function agentIdentity(slug: string, runtime: Runtime = "claude"): AgentIdentity {
if (!isValidSlug(slug)) {
throw new Error(
`Invalid agent slug "${slug}" (use lowercase letters, digits and hyphens; max 60 chars)`
);
}
return {
slug,
runtime,
sessionId: uuidv5(slug),
tmuxName: slug,
cardName: slug,
Expand Down
54 changes: 30 additions & 24 deletions cli/src/agents/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,21 @@ import {
import { upsertCard, isoNow } from "../cards.js";
import { generateKsuid } from "../ksuid.js";
import { Link, ManualOverrides } from "../types.js";
import { runtimeSpec } from "./runtime.js";

export interface LaunchOptions {
/// Working directory for the session (the agent's workspace / worktree root).
cwd: string;
/// Extra args appended to the claude invocation.
/// Extra args appended to the agent invocation.
extraArgs?: string[];
/// Environment variables exported into the tmux session.
env?: Record<string, string>;
/// Model alias or full name (claude --model).
/// Model alias or full name.
model?: string;
/// Autonomous agents skip permission prompts by default.
skipPermissions?: boolean;
/// Override the claude binary (tests).
claudeBin?: string;
/// Override the agent binary (tests).
bin?: string;
}

export type LaunchAction = "noop-running" | "launched" | "resumed";
Expand All @@ -44,41 +45,46 @@ const DEFAULT_OVERRIDES: ManualOverrides = {
issueLink: false,
};

/// Idempotently ensure an agent's Claude session is running in tmux and its
/// kanban card reflects reality. Decides launch vs resume vs no-op:
/// - tmux session already alive -> no-op (never restart a live agent)
/// - a transcript exists for the session -> resume (--resume <uuid>)
/// - neither -> fresh launch (--session-id <uuid>)
/// Idempotently ensure an agent's session is running in tmux and its kanban card
/// reflects reality. Decides launch vs resume vs no-op:
/// - tmux session already alive -> no-op (never restart a live agent)
/// - runtime can resume + transcript exists -> resume
/// - otherwise -> fresh launch
/// Codex mints its own session id and the reviewer is per-PR, so it always
/// launches fresh (canResume=false); tmux keeps it alive between prompts.
export function ensureAgentSession(
identity: AgentIdentity,
opts: LaunchOptions
): LaunchResult {
const claudeBin = opts.claudeBin ?? "claude";
const spec = runtimeSpec(identity.runtime);
const bin = opts.bin ?? spec.bin;
const skipPerms = opts.skipPermissions ?? true;

const tmuxAlive = hasTmuxSession(identity.tmuxName);
const sessionExists = !!findSessionJsonl(identity.sessionId);
const sessionExists = spec.canResume && !!findSessionJsonl(identity.sessionId);

let action: LaunchAction;
let command: string | undefined;

if (tmuxAlive) {
action = "noop-running";
} else {
const args: string[] = [];
if (sessionExists) {
action = "resumed";
args.push("--resume", identity.sessionId);
} else {
action = "launched";
args.push("--session-id", identity.sessionId, "--name", identity.slug);
}
if (skipPerms) args.push("--dangerously-skip-permissions");
if (opts.model) args.push("--model", opts.model);
const args = spec.buildArgs({
sessionId: identity.sessionId,
slug: identity.slug,
resume: sessionExists,
skipPermissions: skipPerms,
model: opts.model,
});
action = sessionExists ? "resumed" : "launched";
if (opts.extraArgs?.length) args.push(...opts.extraArgs);
command = [claudeBin, ...args].join(" ");
command = [bin, ...args].join(" ");

const res = createTmuxSession(identity.tmuxName, opts.cwd, command, opts.env ?? {});
// Both runtimes' hooks correlate events to this agent via this env var, so
// the daemon/bridge key on our stable session id regardless of the id the
// runtime mints internally.
const env = { ...(opts.env ?? {}), KANBAN_SESSION_ID: identity.sessionId, KANBAN_SLUG: identity.slug };
const res = createTmuxSession(identity.tmuxName, opts.cwd, command, env);
if (!res.ok) {
throw new Error(`Failed to create tmux session "${identity.tmuxName}": ${res.error}`);
}
Expand Down Expand Up @@ -124,7 +130,7 @@ function upsertAgentCard(identity: AgentIdentity, cwd: string): Link {
sessionLink: { sessionId: identity.sessionId, sessionPath },
tmuxLink: { sessionName: identity.tmuxName },
worktreeLink: { path: cwd },
assistant: "claude",
assistant: identity.runtime,
isRemote: false,
};
upsertCard(card);
Expand Down
8 changes: 4 additions & 4 deletions cli/src/agents/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { readLinks, killTmuxSession } from "../data.js";
import { upsertCard, isoNow } from "../cards.js";

export interface ReconcileOptions {
/// Override the claude binary (tests).
claudeBin?: string;
/// Override the agent binary (tests).
bin?: string;
/// Tear down agent-managed sessions/cards/worktrees no longer in config.
prune?: boolean;
}
Expand Down Expand Up @@ -61,10 +61,10 @@ export function reconcileAgent(
repos.push({ name, worktreeCreated: created, worktree });
}

const launch = ensureAgentSession(agentIdentity(agent.slug), {
const launch = ensureAgentSession(agentIdentity(agent.slug, agent.runtime), {
cwd: workspace,
model: agent.model,
claudeBin: opts.claudeBin,
bin: opts.bin,
});

return { slug: agent.slug, workspace, repos, launch };
Expand Down
82 changes: 82 additions & 0 deletions cli/src/agents/runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/// Runtime abstraction for the headless agent engine. The engine was built for
/// Claude Code; this table lets a single agent be driven by a different CLI
/// (currently Codex) without branching all over launch/daemon/bridge. It mirrors
/// the macOS app's CodingAssistant entity, scoped to what the headless path needs.

export type Runtime = "claude" | "codex";

export const RUNTIMES: readonly Runtime[] = ["claude", "codex"] as const;

export function isRuntime(v: unknown): v is Runtime {
return v === "claude" || v === "codex";
}

export interface BuildArgsInput {
/// The stable session id (uuidv5 of the slug). Used as Claude's --session-id
/// and, for both runtimes, as the hook-events correlation key via env.
sessionId: string;
/// Readable agent slug.
slug: string;
/// Resume an existing session rather than launching fresh.
resume: boolean;
/// Skip permission/approval prompts (autonomous agents).
skipPermissions: boolean;
/// Model alias/name, if pinned.
model?: string;
}

export interface RuntimeSpec {
/// The CLI binary.
bin: string;
/// Build the argv after the binary.
buildArgs(input: BuildArgsInput): string[];
/// Whether this runtime can resume a prior session by our session id. Claude
/// can (--resume <uuid>); Codex generates its own id and the headless reviewer
/// is per-PR, so we always launch it fresh and rely on tmux to keep it alive.
canResume: boolean;
/// Whether the daemon's context-threshold self-compaction applies. Codex
/// auto-compacts on its own and exposes no context introspection, so off.
selfCompact: boolean;
/// Config dir under $HOME (for hooks/skills install).
configDirName: string;
}

const claude: RuntimeSpec = {
bin: "claude",
canResume: true,
selfCompact: true,
configDirName: ".claude",
buildArgs({ sessionId, slug, resume, skipPermissions, model }) {
const args: string[] = [];
if (resume) args.push("--resume", sessionId);
else args.push("--session-id", sessionId, "--name", slug);
if (skipPermissions) args.push("--dangerously-skip-permissions");
if (model) args.push("--model", model);
return args;
},
};

const codex: RuntimeSpec = {
bin: "codex",
canResume: false,
selfCompact: false,
configDirName: ".codex",
buildArgs({ skipPermissions, model }) {
// --no-alt-screen keeps Codex inline so tmux send-keys paste works (no TUI
// alt-screen). The bypass flags are Codex's equivalent of Claude's
// --dangerously-skip-permissions; --dangerously-bypass-hook-trust skips the
// interactive hook-trust gate so our hooks run unattended.
const args = ["--no-alt-screen"];
if (skipPermissions) {
args.push("--dangerously-bypass-approvals-and-sandbox", "--dangerously-bypass-hook-trust");
}
if (model) args.push("-m", model);
return args;
},
};

const SPECS: Record<Runtime, RuntimeSpec> = { claude, codex };

export function runtimeSpec(runtime: Runtime): RuntimeSpec {
return SPECS[runtime];
}
Loading
Loading