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
130 changes: 129 additions & 1 deletion cli/src/agents/launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { AgentIdentity } from "./identity.js";
import {
hasTmuxSession,
createTmuxSession,
captureTmuxPane,
sendTmuxKey,
sendTmuxEnter,
findSessionJsonl,
findCodexRollout,
readLinks,
} from "../data.js";
import { upsertCard, isoNow } from "../cards.js";
import { generateKsuid } from "../ksuid.js";
import { Link, ManualOverrides } from "../types.js";
import { runtimeSpec } from "./runtime.js";
import { runtimeSpec, RuntimeSpec } from "./runtime.js";
import { randomUUID } from "node:crypto";

export interface LaunchOptions {
Expand All @@ -29,6 +32,9 @@ export interface LaunchOptions {
/// agents (e.g. a room's swarm) whose readable slug is recycled: resuming
/// would reload a stale or unrelated conversation under the same id.
forceFresh?: boolean;
/// How long a runtime that is named after launch gets to come up before the
/// name is given up on. A loaded box draws its first frame slowly.
nameTimeoutMs?: number;
}

export type LaunchAction = "noop-running" | "launched" | "resumed";
Expand All @@ -40,6 +46,10 @@ export interface LaunchResult {
tmuxName: string;
command?: string;
card: Link;
/// Whether the session was named after it started. False for a runtime named
/// by a launch flag, for a session already running, and for one that never
/// came up in time.
named: boolean;
}

const DEFAULT_OVERRIDES: ManualOverrides = {
Expand Down Expand Up @@ -87,6 +97,7 @@ export function ensureAgentSession(

let action: LaunchAction;
let command: string | undefined;
let named = false;

if (tmuxAlive) {
action = "noop-running";
Expand Down Expand Up @@ -116,6 +127,12 @@ export function ensureAgentSession(
if (!res.ok) {
throw new Error(`Failed to create tmux session "${identity.tmuxName}": ${res.error}`);
}
named = nameStartedSession({
tmuxName: launchIdentity.tmuxName,
slug: launchIdentity.slug,
spec,
timeoutMs: opts.nameTimeoutMs,
});
}

const card = upsertAgentCard(launchIdentity, opts.cwd);
Expand All @@ -126,9 +143,120 @@ export function ensureAgentSession(
tmuxName: launchIdentity.tmuxName,
command,
card,
named,
};
}

/// How long a runtime named after launch gets to come up. Codex spends a few
/// seconds on its first frame, and a command typed before then is lost.
const NAME_READY_TIMEOUT_MS = 15_000;
/// The gap between pane reads while waiting for that first frame.
const NAME_POLL_MS = 500;
/// The gap between typing the command and the Enter that submits it. A TUI
/// that sees the whole line and the Enter arrive together reads the Enter as
/// part of a paste and keeps the line in its composer instead of running it.
const NAME_SUBMIT_MS = 100;
/// How long the command gets to leave the composer before it counts as not
/// submitted.
const NAME_CONFIRM_MS = 3_000;

/// What naming a started session needs from the outside, so the wait can be
/// driven by a test without a real runtime on the other end.
export interface NameSessionIO {
capture(tmuxName: string): string;
type(tmuxName: string, text: string): { ok: boolean; error?: string };
enter(tmuxName: string): { ok: boolean; error?: string };
alive(tmuxName: string): boolean;
sleep(ms: number): void;
now(): number;
}

const REAL_NAME_SESSION_IO: NameSessionIO = {
capture: (tmuxName) => captureTmuxPane(tmuxName),
type: (tmuxName, text) => sendTmuxKey(tmuxName, text),
enter: (tmuxName) => sendTmuxEnter(tmuxName),
alive: (tmuxName) => hasTmuxSession(tmuxName),
sleep: sleepMs,
now: () => Date.now(),
};

/// Give a just-started session the name its runtime takes no launch flag for.
/// Answers whether the runtime took the name, not merely whether keys were
/// sent: a command that stayed in the composer named nothing.
///
/// Only ever called on a session that has just started and has been given no
/// prompt, so the command cannot land in the middle of a turn. A runtime that
/// does not come up in time is left unnamed rather than typed into blind: a
/// command sitting in the composer would ride out with the agent's first real
/// prompt.
export function nameStartedSession(
{
tmuxName,
slug,
spec,
timeoutMs = NAME_READY_TIMEOUT_MS,
}: {
tmuxName: string;
slug: string;
spec: RuntimeSpec;
timeoutMs?: number;
},
io: NameSessionIO = REAL_NAME_SESSION_IO
): boolean {
const command = spec.nameCommand?.(slug);
const marker = spec.readyMarker;
if (!command || !marker) return false;

const deadline = io.now() + timeoutMs;
while (io.now() < deadline) {
if (!io.alive(tmuxName)) return false;
if (paneAccepts(io.capture(tmuxName), marker)) {
if (!io.type(tmuxName, command).ok) return false;
io.sleep(NAME_SUBMIT_MS);
if (!io.enter(tmuxName).ok) return false;
return commandLeftTheComposer({ tmuxName, command, io });
}
io.sleep(NAME_POLL_MS);
}
return false;
}

/// A submitted command is gone from the pane: the runtime clears its composer
/// and answers on a line of its own. One still on screen was typed and never
/// run, which is a session that is not named.
function commandLeftTheComposer({
tmuxName,
command,
io,
}: {
tmuxName: string;
command: string;
io: NameSessionIO;
}): boolean {
const deadline = io.now() + NAME_CONFIRM_MS;
while (io.now() < deadline) {
if (!io.capture(tmuxName).includes(command)) return true;
io.sleep(NAME_POLL_MS);
}
return false;
}

/// A runtime's status line is the last thing it draws, so the marker is looked
/// for there rather than anywhere in the pane: a banner scrolled off the top
/// must not read as ready.
function paneAccepts(pane: string, marker: string): boolean {
const lines = pane.split("\n").filter((line) => line.trim() !== "");
const last = lines[lines.length - 1];
return last !== undefined && last.includes(marker);
}

/// Synchronous sleep: the launch path is synchronous end to end, so a wait
/// must actually hold it.
function sleepMs(ms: number): void {
if (ms <= 0) return;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}

/// Reconcile the agent's card to current truth. Writes only when something
/// meaningful changed, so a healthy reconcile is a true no-op on disk.
function upsertAgentCard(identity: AgentIdentity, cwd: string): Link {
Expand Down
16 changes: 16 additions & 0 deletions cli/src/agents/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export interface RuntimeSpec {
selfCompact: boolean;
/// Config dir under $HOME (for hooks/skills install).
configDirName: string;
/// The command that names a started session, for a runtime that takes no
/// name at launch. Claude has none: --name in buildArgs already named it.
nameCommand?(slug: string): string;
/// What the pane shows once the runtime will accept that command. Nothing
/// is typed into a session that has not shown it.
readyMarker?: string;
}

const claude: RuntimeSpec = {
Expand All @@ -65,6 +71,16 @@ const codex: RuntimeSpec = {
canResume: true,
selfCompact: false,
configDirName: ".codex",
// Codex takes no name at launch. It names a thread through its own /rename,
// which is what `codex resume <name>` and the LangWatch session harvest both
// read, so the agent's slug is what labels the session in either.
nameCommand: (slug) => `/rename ${slug}`,
// The separator codex draws in the status line under its composer, and only
// once it will accept a command: before that the pane holds its banner, or
// the directory-trust question, and a command typed into either is lost or
// answers the wrong question. A separator rather than any wording, so a
// codex that rewrites its own UI text still names its sessions.
readyMarker: "·",
buildArgs({ resume, 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
Expand Down
150 changes: 147 additions & 3 deletions cli/src/codex-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { formatCodexRolloutLines } from "./slack/format.js";
import { writeThreadRoot, readThreadRoot } from "./slack/thread-root.js";
import { parseAgentsConfig } from "./agents/config.js";
import { agentIdentity } from "./agents/identity.js";
import { ensureAgentSession } from "./agents/launch.js";
import { ensureAgentSession, nameStartedSession } from "./agents/launch.js";
import { installCodexHooks } from "./hooks.js";
import { readLinks } from "./data.js";

Expand Down Expand Up @@ -89,6 +89,11 @@ describe("runtime descriptor", () => {
}
});

test("codex names a started session, claude was named at launch", () => {
assert.equal(runtimeSpec("codex").nameCommand?.("pr-reviewer"), "/rename pr-reviewer");
assert.equal(runtimeSpec("claude").nameCommand, undefined);
});

test("isRuntime guards the union", () => {
assert.ok(isRuntime("claude"));
assert.ok(isRuntime("codex"));
Expand Down Expand Up @@ -232,6 +237,141 @@ function hasTmux(): boolean {
try { execSync("tmux -V", { stdio: "ignore" }); return true; } catch { return false; }
}

describe("naming a session the runtime takes no launch flag for", () => {
const BANNER = " >_ OpenAI Codex (v0.149.0)\n Tip: Try the Desktop app.";
const TRUST = `${BANNER}\n› 1. Yes, continue\n 2. No, quit\n Press enter to continue`;
const COMPOSER = `${BANNER}\n› Ask Codex to do anything\n gpt-5.6-sol low · /home/ubuntu/agent-workspaces/pr-reviewer`;
const RENAMED = `${BANNER}\n• Session renamed to pr-reviewer.\n› Ask Codex to do anything\n gpt-5.6-sol low · /home/ubuntu/agent-workspaces/pr-reviewer`;

/// A pane driven by a script of frames: one read per call, the last frame
/// repeating, which is how a runtime stuck on one screen is expressed.
/// Typed text lands in the composer and stays there until an Enter is what
/// the runtime accepted, which is what the real TUI does.
function fakeIo(
frames: string[],
opts: { alive?: boolean; submits?: boolean; after?: string } = {}
) {
const typed: string[] = [];
const enters: number[] = [];
let clock = 0;
let read = 0;
let composer: string | null = null;
let submitted = false;
const pane = () => {
const frame = submitted
? (opts.after ?? RENAMED)
: frames[Math.min(read++, frames.length - 1)];
return composer === null ? frame : `${frame}\n› ${composer}`;
};
return {
typed,
enters,
elapsed: () => clock,
io: {
capture: () => pane(),
type: (_tmuxName: string, text: string) => {
typed.push(text);
composer = text;
return { ok: true };
},
enter: (_tmuxName: string) => {
enters.push(clock);
if (opts.submits ?? true) {
composer = null;
submitted = true;
}
return { ok: true };
},
alive: () => opts.alive ?? true,
sleep: (ms: number) => {
clock += ms;
},
now: () => clock,
},
};
}

test("waits for the composer, then renames the thread to the slug", () => {
const { io, typed, enters } = fakeIo([BANNER, BANNER, COMPOSER]);
const named = nameStartedSession(
{ tmuxName: "pr-reviewer", slug: "pr-reviewer", spec: runtimeSpec("codex") },
io
);
assert.equal(named, true);
assert.deepEqual(typed, ["/rename pr-reviewer"]);
assert.equal(enters.length, 1);
});

test("submits the command with its own keystroke, not alongside the text", () => {
// Sent together, a TUI reads the newline as pasted text and the command
// sits unrun in the composer.
const { io, enters } = fakeIo([COMPOSER]);
nameStartedSession(
{ tmuxName: "pr-reviewer", slug: "pr-reviewer", spec: runtimeSpec("codex") },
io
);
assert.deepEqual(enters, [100]);
});

test("reports a command that stayed in the composer as unnamed", () => {
const { io, typed } = fakeIo([COMPOSER], { submits: false });
const named = nameStartedSession(
{ tmuxName: "pr-reviewer", slug: "pr-reviewer", spec: runtimeSpec("codex") },
io
);
// The keys went out and the runtime kept them: the session has no name,
// and saying otherwise would send an operator looking in the wrong place.
assert.deepEqual(typed, ["/rename pr-reviewer"]);
assert.equal(named, false);
});

test("types nothing into the directory-trust question", () => {
const { io, typed, elapsed } = fakeIo([TRUST]);
const named = nameStartedSession(
{ tmuxName: "pr-reviewer", slug: "pr-reviewer", spec: runtimeSpec("codex"), timeoutMs: 2_000 },
io
);
// Answering it with a rename would pick one of its options at random.
assert.equal(named, false);
assert.deepEqual(typed, []);
assert.ok(elapsed() >= 2_000);
});

test("gives up as soon as the session is gone, rather than waiting it out", () => {
const { io, typed, elapsed } = fakeIo([BANNER], { alive: false });
const named = nameStartedSession(
{ tmuxName: "pr-reviewer", slug: "pr-reviewer", spec: runtimeSpec("codex"), timeoutMs: 60_000 },
io
);
assert.equal(named, false);
assert.deepEqual(typed, []);
assert.equal(elapsed(), 0);
});

test("sends nothing for a runtime that was named at launch", () => {
const { io, typed } = fakeIo([COMPOSER]);
const named = nameStartedSession(
{ tmuxName: "docs-writer", slug: "docs-writer", spec: runtimeSpec("claude") },
io
);
assert.equal(named, false);
assert.deepEqual(typed, []);
});

test("reads the marker off the last line, so scrollback cannot pass for ready", () => {
// The status line of the session BEFORE this one, still in the scrollback
// above a fresh banner.
const stale = ` gpt-5.6-sol low · /home/ubuntu/agent-workspaces/pr-reviewer\n${BANNER}`;
const { io, typed } = fakeIo([stale]);
const named = nameStartedSession(
{ tmuxName: "pr-reviewer", slug: "pr-reviewer", spec: runtimeSpec("codex"), timeoutMs: 1_000 },
io
);
assert.equal(named, false);
assert.deepEqual(typed, []);
});
});

describe("codex agent launch (real tmux)", { skip: !hasTmux() }, () => {
let home: string;
let workspace: string;
Expand All @@ -251,13 +391,17 @@ describe("codex agent launch (real tmux)", { skip: !hasTmux() }, () => {
});

test("launches codex fresh (no resume) and tags the card assistant=codex", () => {
const result = ensureAgentSession(identity, { cwd: workspace, bin: "true" });
// nameTimeoutMs 0: `true` is not codex, so it never draws the status line
// the rename waits for, and this test is about the launch, not the name.
const launch = { cwd: workspace, bin: "true", nameTimeoutMs: 0 };
const result = ensureAgentSession(identity, launch);
assert.equal(result.action, "launched");
assert.match(result.command!, /true --no-alt-screen --dangerously-bypass-approvals-and-sandbox/);
assert.equal(result.named, false);
const card = readLinks().find((l) => l.name === slug);
assert.equal(card?.assistant, "codex");
// A second reconcile is a no-op while the session is alive.
const again = ensureAgentSession(identity, { cwd: workspace, bin: "true" });
const again = ensureAgentSession(identity, launch);
assert.equal(again.action, "noop-running");
});
});
Loading