diff --git a/cli/src/agents/launch.ts b/cli/src/agents/launch.ts index 99fbcfc9..73e22568 100644 --- a/cli/src/agents/launch.ts +++ b/cli/src/agents/launch.ts @@ -2,6 +2,9 @@ import { AgentIdentity } from "./identity.js"; import { hasTmuxSession, createTmuxSession, + captureTmuxPane, + sendTmuxKey, + sendTmuxEnter, findSessionJsonl, findCodexRollout, readLinks, @@ -9,7 +12,7 @@ import { 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 { @@ -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"; @@ -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 = { @@ -87,6 +97,7 @@ export function ensureAgentSession( let action: LaunchAction; let command: string | undefined; + let named = false; if (tmuxAlive) { action = "noop-running"; @@ -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); @@ -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 { diff --git a/cli/src/agents/runtime.ts b/cli/src/agents/runtime.ts index cb7161b7..dd9bff63 100644 --- a/cli/src/agents/runtime.ts +++ b/cli/src/agents/runtime.ts @@ -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 = { @@ -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 ` 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 diff --git a/cli/src/codex-runtime.test.ts b/cli/src/codex-runtime.test.ts index 41d3a663..cd70b673 100644 --- a/cli/src/codex-runtime.test.ts +++ b/cli/src/codex-runtime.test.ts @@ -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"; @@ -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")); @@ -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; @@ -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"); }); }); diff --git a/cli/src/kanban.ts b/cli/src/kanban.ts index 12bf432c..75ff7691 100644 --- a/cli/src/kanban.ts +++ b/cli/src/kanban.ts @@ -36,6 +36,7 @@ import { agentIdentity } from "./agents/identity.js"; import { ensureAgentSession } from "./agents/launch.js"; import { loadAgentsConfig } from "./agents/config.js"; import { reconcileAll } from "./agents/reconcile.js"; +import { runtimeSpec } from "./agents/runtime.js"; import { installHooks } from "./hooks.js"; import { Daemon } from "./agents/daemon.js"; import { slackAppManifest, MANIFEST_INSTRUCTIONS } from "./slack/manifest.js"; @@ -560,7 +561,16 @@ program const repoNote = a.repos .map((r) => `${r.name}${r.worktreeCreated ? " (worktree+)" : ""}`) .join(", "); - lines.push(`${a.slug}: ${a.launch.action} [${repoNote}]`); + // Only a runtime that is named after it starts can come up unnamed, + // and only then is it worth a word: a session with no name is still + // a working session, but an operator looking for it by name will not + // find it. + const wantsName = !!runtimeSpec(a.launch.identity.runtime).nameCommand; + const nameNote = + wantsName && a.launch.action !== "noop-running" && !a.launch.named + ? " (unnamed)" + : ""; + lines.push(`${a.slug}: ${a.launch.action}${nameNote} [${repoNote}]`); } if (result.pruned.length) lines.push(`pruned: ${result.pruned.join(", ")}`); output(lines.join("\n") || "no agents configured", opts); diff --git a/specs/sessions/headless-reconcile.feature b/specs/sessions/headless-reconcile.feature index e714ccb1..78806ef5 100644 --- a/specs/sessions/headless-reconcile.feature +++ b/specs/sessions/headless-reconcile.feature @@ -36,6 +36,22 @@ Feature: Headless agent session reconciliation (CLI) And Claude is started with "--resume " in the existing worktree And prior conversation history is preserved + Scenario: A runtime with no name flag is named once it is up + Given the agent's runtime takes no session name at launch + When the reconciler starts its session + Then it waits for the runtime to show it will accept a command + And it renames the session to the slug, before any prompt is sent + And the name reaches everything that reads the runtime's own session list + # Codex is that runtime. Claude takes --name and needs none of this. + + Scenario: A runtime that never comes up is left unnamed + Given a started session that does not show it will accept a command + When the reconciler waits for it + Then nothing is typed into that session and the launch still succeeds + # A command typed blind sits in the composer and rides out with the + # agent's first real prompt. An unnamed session costs a label; a + # corrupted first prompt costs the turn. + Scenario: Kanban Code does not clean or clone repos Given a canonical clone is missing When the reconciler runs for that agent