From 1eb32a38a4df2faae0819fbcef14b28cdf63aea2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rog=C3=A9rio=20Chaves?= Date: Sat, 22 Aug 2026 20:31:28 +0200 Subject: [PATCH] feat(agents): a codex session carries the agent's name A claude agent's session reads `tech-debt-fixer` everywhere the session is listed, because the launcher passes `claude --name `. A codex agent's read nothing at all: codex takes no name at launch, so every codex agent was an untitled session in its own resume picker and in LangWatch's sessions screen, where the row for a background agent could not say which agent it was. Codex does name a thread, through its own `/rename`. That writes the name into the index `codex resume ` resolves against, which is the same index the LangWatch session harvest reads, so one command labels the session in both. The launcher now sends it, once, right after it starts the session and before any prompt reaches the agent, so it can never land in the middle of a turn. Two things the runtime table now carries, rather than a codex branch in the launch path: the command that names a started session, and what the pane shows once the runtime will accept one. Claude has neither, and nothing is sent for it. Waiting for that second one is what keeps this safe. Codex spends a few seconds on its first frame, and asks about directory trust before drawing it at all; a command typed into either is lost, or answers a question about trust by picking one of its options. So the pane is read until the runtime's status line is the last thing on it, and a session that never gets there is left unnamed. An unnamed session costs a label. A command left sitting in the composer would ride out with the agent's first real prompt and cost the turn. The Enter is its own keystroke, 100ms after the text. Sent together, codex reads the newline as part of a paste and keeps the whole line in its composer, which is what happened on the first run of this: the keys went out, `send` reported success, and nothing was named. So the result is confirmed rather than assumed, by reading the command back off the pane, and a reconcile prints `(unnamed)` beside a launch whose name did not take. Claude-Session: https://claude.ai/code/session_01BUKKUiZbSBHmrLK9JJj4Ba --- cli/src/agents/launch.ts | 130 ++++++++++++++++++- cli/src/agents/runtime.ts | 16 +++ cli/src/codex-runtime.test.ts | 150 +++++++++++++++++++++- cli/src/kanban.ts | 12 +- specs/sessions/headless-reconcile.feature | 16 +++ 5 files changed, 319 insertions(+), 5 deletions(-) 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