feat(cli): let builtin Codex and Grok own their ACP session titles - #522
Conversation
acp-extension-codex >= 1.8.0 generates its own title on an ephemeral thread and publishes it as session_info_update tagged _meta.lody.titleSource: 'explicit'. Lody still spawned an isolated codex ACP session to generate a second title for it, so drop that duplicate work and hide the now-unreachable title-generation config for Codex. Split the single usesAcpProvidedSessionTitle() predicate, because the two ACP-owning adapters need opposite trust rules: - acpOwnsSessionTitleGeneration() (claude + codex) skips the isolated generator and hides the title config. - trustsUntaggedAcpSessionTitle() (claude only) gates the agent-client trust check. Codex must stay out of it: it emits a first-prompt `fallback` preview before its generated `explicit` title, and trusting untagged titles would promote that preview to the session title. An audit of all five builtin adapters found only Claude and Codex generate titles over ACP. Grok has no title code, Kimi's session_info_update carries the first prompt truncated to 200 chars with no _meta, and the DeepSeek Harness never mounts its upstream dsh-session-title plugin, so all three keep the isolated generator and its titleGeneration config. Model: claude-opus-5[1m]
Claude and Codex no longer expose a title-generation config, but branch naming still resolved the persisted titleGeneration for them, so a value stored before that change kept steering their isolated runs after the setting disappeared from the UI. Skip the lookup for ACP-owned agents and let computeTitleGenerationDefaults() pick from the live configOptions. Model: claude-opus-5[1m]
The earlier audit recorded that Grok has no title capability. That was read off acp-extension-grok, which is a pure stdio proxy with no title code, and it is wrong about Grok itself. The pinned @xai-official/grok 1.0.13 runtime ships a full automatic title generator: the shipped binary carries the session_title tool-call prompt, the "falling back to truncated user text" failure path, and docs for /rename and /rename --auto. The logic sits in xai-grok-shell/src/session/acp_session_impl/title_refresh.rs, inside the ACP session impl, so it is not TUI-only. Whether the title reaches the wire as a pushed session_info_update or only inside the x.ai/session/info response is still unverified: this machine has no Grok credentials and all stored sessions are zero-message capability probes. Both candidate integration points are recorded. No behavior change; Grok keeps the isolated generator either way. Model: claude-opus-5[1m]
A live probe on a credentialed machine settles the open question from the
previous commit. Grok pushes its generated title as a session_info_update
notification, once per session, and acp-extension-grok forwards it
untouched -- direct and proxied runs both saw exactly one push, matching
the non-empty session_summary written to disk.
The message carries no _meta, so Grok has the same shape as Claude: an
authoritative pushed title with no titleSource. Lody already receives it
today and discards it in handleAgentSessionTitleUpdate for want of a tag.
The pull path does not exist in the mode Lody runs: x.ai/session/info
answers -32601 under `grok agent stdio`. That also means proxy.js's
internalRequest('context', ...) always errors and is silently dropped, so
builtin Grok's context-window usage notification is dead against runtime
1.0.13. Recorded for separate follow-up.
No behavior change.
Model: claude-opus-5[1m]
A live probe confirmed the official Grok runtime generates its session title in its own ACP session impl and pushes exactly one session_info_update per session, which acp-extension-grok forwards untouched. Lody already received that title and dropped it for carrying no _meta.lody.titleSource, while separately spawning an isolated Grok ACP session to generate a second one. Add builtin Grok to both predicates. It joins Claude in the trusted-untagged set because it also pushes a bare session_info_update with no _meta, and unlike Codex it emits no first-prompt fallback preview that could win the title. Codex stays out of that set. Grok now skips the isolated generator, hides the title-generation config, and ignores any titleGeneration persisted before this change. Trade-off: title wording for all three ACP-owned agents now belongs to their adapters, so DEFAULT_TITLE_GENERATION_PROMPT no longer constrains them, and Grok keeps refining its title over the first few turns before freezing it. Model: claude-opus-5[1m]
The dialog test edited a builtin Codex config and asserted the Title generation section renders. Codex now generates its own title over ACP, so that section is hidden and the test asserted behavior this branch deliberately removed. Retarget the fixture to Kimi, which still owns the setting. Two fixture details the Codex version did not need: - provenance: 'runtime' -- builtin Kimi is held to an authoritative cache entry before the dialog renders config selectors, so without it the section rendered in its "Probing capabilities" state with no selectors. - modelReasoningEfforts -- Codex resolves its effort ladder through a dedicated branch, so the map was never consulted for it. Every other agent needs it for a stored effort to be seen as invalid and normalized. Also pin the behavior the moved test stopped covering: Claude, Codex and Grok hide the section. Model: claude-opus-5[1m]
Skipping isolated title generation for the ACP-owned agents did not reduce anything for worktree sessions: maybeRenameSessionBranchFromPrompt runs at session-ready, before any turn, so no pushed title can have arrived and it simply started its own agent instead. The isolated session moved from the title path to the branch path rather than disappearing. titleToBranchName was always a pure transform, so the agent only ever compressed the prompt into a shorter title first. Branch naming now prefers a title already stored or in flight for the session and otherwise converts the prompt directly, so nothing about session titles starts an extra ACP agent anymore. generateBranchNameWithTimeout drops its provider, env, launch spec and title config parameters, and the branch path no longer reads the agent config -- which also removes the stale title config guard added earlier in this branch. Two behaviour changes. A prompt yielding no valid name now leaves the managed session/<id> branch alone instead of renaming it to task/<timestamp>; kebab conversion strips non-ASCII, so that is the normal outcome for a Chinese prompt, and those sessions previously paid for an isolated agent that could never produce a usable name. A timeout now falls back to the prompt instead of abandoning the rename. Model: claude-opus-5[1m]
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5e799e685c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| }; | ||
|
|
||
| if (!reusableTitlePromise) { | ||
| return toBranchName(taskPrompt); |
There was a problem hiding this comment.
Use task-only text for branch naming
When an Agent Config or Agent Role has a prompt prefix, agentConfig.prompt is assembled prefix-first by buildAgentPrompt, so this direct fallback passes provider/role instructions—not just the user's task—to titleToBranchName, which truncates the result to 50 characters. Worktree sessions using such configurations will therefore repeatedly get generic branches such as feat/you-are-a-code-reviewer (plus collision suffixes) instead of task-specific names, degrading branch and PR identification for a normal supported configuration; pass the task-only input to this transform or explicitly remove the prefixes first.
AGENTS.md reference: AGENTS.md:L77-L79
Useful? React with 👍 / 👎.
Conflict in apps/cli/src/agent/README.md: main appended a new "Local project identity" section while this branch rewrote the "Session titles" section above it. Both are kept -- this branch's rewritten section, then main's new one. main moved the Codex, Grok and DeepSeek Harness submodule pins. Re-checked the three findings the audit note rests on against the new pins: the Grok proxy still has no session-title code, the Harness still only pins @deepseek-ai/dsh-session-title without mounting it, and Codex still ships TitleGenerator with explicit/fallback tagging. Only the version numbers in the note's table needed updating. Also corrected a paragraph in that note that still described the branch naming behaviour of an earlier commit on this branch, superseded by 5e799e6 removing the agent-config lookup entirely. Model: claude-opus-5[1m]
Cleanup pass over this branch's own diff; no behaviour change. - packages/shared/src/ai.ts: the two hand-synced agentType Sets become one Record<BuiltinAgentType, 'none' | 'untagged' | 'tagged'>. The trusted set is a strict subset of the owning set, which is now structural instead of a comment, and the Record is exhaustive so a new builtin agent cannot silently default (verified: dropping a key fails typecheck). - message-handler.ts: generateBranchNameWithTimeout no longer generates and its timeout applies to only one of its two paths, so it becomes deriveWorktreeBranchName. Its hand-rolled Promise.race/setTimeout/finally now calls withTimeoutOrUndefined, already defined in this file, and its inline toBranchName closure calls the shared helper below. 28 lines -> 16. - branch-name-generator.ts: ensureValidBranchName lost its last production caller in this branch, and its task/<timestamp> fallback is the behaviour the branch deliberately dropped. Replaced by tryBranchName, the nullable core the caller actually wanted, so the "kebab then validate" rule lives in one place again. - Merged a duplicated dialog test into its it.each table, renamed the components fixture that three of its four uses had made false, and folded two near-identical config literals into a factory. - Trimmed README prose that restated the decision note verbatim and named upstream internals that will drift, leaving the contract plus a link. Recorded in the note why capability negotiation is the right eventual shape and what deferred it, and the residual config-write surfaces. Model: claude-opus-5[1m]
…time overrides Two review findings on this branch. Naming a branch after the prompt publishes the prompt: a ref reaches the remote as soon as the session opens a PR, and "rotate sk_live_... before Friday" is an ordinary request. This was reachable before this branch too -- the old generateTitleIsolated returned sanitizeGeneratedTitle(taskPrompt) on every failure path -- but skipping title generation for the three ACP-owned agents turned a rare fallback into the common path. tryBranchName now strips credential-shaped tokens first: known prefixes (sk_, ghp_, AKIA, xox*), PEM blocks, and unprefixed runs of 20+ alphanumerics mixing letters and digits, which catches hex and base62 secrets while leaving prose alone. Stripping beats refusing -- "Fix API key sk_live_..." still yields fix/api-key -- and over-matching only shortens a branch name. BUILTIN_ACP_TITLE_OWNERSHIP describes the managed runtime each agent normally launches, but BuiltinRuntimeOverrides can aim the same agentType at any executable, including one predating the title behaviour: Grok's generation lives in the runtime itself and Codex's needs an ephemeral thread older builds lack. Such a session got no title at all -- generator skipped, nothing pushed, and the setting that would fix it hidden. acpOwnsSessionTitleGeneration now returns false while an override is set. The trust gate is deliberately unchanged: an override that does push a good title still gets it, as Claude already did before this branch. Model: claude-opus-5[1m]
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c80160adb2
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| '\\b(?:sk|pk|rk|ghp|gho|ghu|ghs|ghr|glpat|shpat|xox[abprs])[-_][A-Za-z0-9_-]{6,}', | ||
| '\\bgithub_pat_[A-Za-z0-9_]{10,}', | ||
| '\\b(?:AKIA|ASIA|AIza)[A-Za-z0-9]{6,}', | ||
| '\\b(?=[A-Za-z0-9]*[0-9])(?=[A-Za-z0-9]*[A-Za-z])[A-Za-z0-9]{20,}\\b', |
There was a problem hiding this comment.
Avoid deriving publishable refs from raw prompts
When a task contains an ordinary credential that is not one of these prefixes or a contiguous 20-character alphanumeric token, the new prompt fallback preserves it in the branch name; for example, Fix postgres://admin:hunter2@db.internal/users becomes fix/postgresadminhunter2dbinternalusers. Because worktree refs are pushed when a PR is opened, this leaks such passwords to the remote repository. A denylist cannot safely sanitize arbitrary prompt secrets, so fall back to a non-prompt identifier rather than publishing the raw prompt.
AGENTS.md reference: apps/cli/src/agent/AGENTS.md:L114-L115
Useful? React with 👍 / 👎.
The previous commit stripped credential-shaped tokens and named the branch from what was left. That is the wrong shape and review caught it: a secret has no reliable shape -- hunter2 is a password and an ordinary word -- so removing what looks secret leaves everything that does not. Fix DB_PASSWORD=hunter2 and Fix https://alice:hunter2@example.com both survived verbatim into the ref. tryBranchName now fails closed. It matches the syntax that carries secrets rather than the secrets themselves -- a value assigned to a sensitive name, URL userinfo, known key prefixes, PEM blocks, and 20+ alphanumeric runs mixing letters and digits -- and returns null on any hit, leaving the session on its session/<id> branch. Failing closed is only affordable because it rarely fires on real work, so the tests pin both directions: nine credential syntaxes refused, and six prompts that merely mention auth, token, secret or credential still named. Measuring that corpus is also what caught the regex missing its `i` flag, which had let the uppercase DB_PASSWORD and AWS_SECRET_ACCESS_KEY cases through. Still best-effort, and the note says so: prose like "the password is hunter2" carries no syntax to match. The sound alternative -- never deriving a ref from prompt text -- would cost branch naming entirely for the three ACP-owned agents, since no generated title exists when the branch is named. Model: claude-opus-5[1m]
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eb5c840f59
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| ); | ||
| } | ||
| } | ||
| return tryBranchName(title?.trim() || taskPrompt); |
There was a problem hiding this comment.
Check the original prompt before using a generated title
When an agent that still uses isolated title generation handles a prompt such as Fix DB_PASSWORD=hunter2, this path checks only the generated title; if the model returns Fix hunter2 login, the credential-syntax guard never sees the assignment and the eventual PR publishes fix/hunter2-login. Fresh evidence after the added syntax guard is that taskPrompt is bypassed whenever the reusable title promise resolves, so reject credential-bearing original prompts before selecting a title.
AGENTS.md reference: apps/cli/src/agent/AGENTS.md:L108-L115
Useful? React with 👍 / 👎.
A branch name is a ref: it reaches the remote as soon as the session opens a PR, so deriving one from prompt text publishes prompt text. Two filters were tried on this branch and both failed for the same reason. Stripping credential-shaped tokens leaves everything that does not look like one -- a secret has no reliable shape, since hunter2 is a password and an ordinary word. Failing closed on credential syntax caught DB_PASSWORD=hunter2 and URL userinfo, but any prompt-based check fails open on every miss, so plain prose like "the password is hunter2" still published. A boundary that fails open is not a boundary, so the path is removed rather than filtered again. Deleted: maybeRenameSessionBranchFromPrompt and deriveWorktreeBranchName from MessageHandler, its SessionExecutionDeps entry and startSession call, branch-name-generator.ts entirely, and the renameBranchWithAvailableSuffix / listLocalBranchNames / isManagedWorktreeBranchName exports it was the only caller of. resolveAvailableBranchName stays -- worktree-manager still allocates session branch names with it. Behaviour: a worktree session keeps the session/<id> branch worktree-manager gave it. Nothing is silently lost. syncSessionBranchName still records the session's real branch after every turn, so an agent that renames it is picked up, and GitHub-project prompts already ask the agent to name branches after the task. That instruction is not a replacement -- it is injected only in startSession, stripped before storage, and only for project.kind === 'github' -- which is recorded in the note. Restoring automatic naming needs a naming source provably isolated from the prompt. None exists at session-ready: the ACP title has not arrived, and the isolated generator's own fallback is the raw prompt. Model: claude-opus-5
Related issue
Problem / pressure
Lody ran its own isolated title-generator ACP session for every agent, carrying a
per-agent
titleGenerationsession config to pick a cheap model and aleast-privileged mode for it. Several builtin adapters now generate a session title
themselves, so that work was being done twice — and in Grok's case the adapter's
title was already arriving and being thrown away.
An audit of all five builtin adapters, at the commits this repo pins:
_meta.lody.titleSourceacp-extension-claude0.70.0generate_session_titlecontrol requestacp-extension-codex1.10.0 (since 1.8.0)explicitacp-extension-grok0.1.0 (runtime 1.0.13)title_refresh.rs, verified by live probeacp-extension-kimiacp-server 0.0.1acp-extension-dsh0.1.1Grok took two passes to get right. The adapter is a pure stdio proxy with no title
code, which reads as "Grok has no titles" — but the official runtime generates one
inside its own ACP session impl
(
xai-grok-shell/src/session/acp_session_impl/title_refresh.rs) and pushes it. Alive probe on a credentialed machine confirmed exactly one
session_info_updateper session, forwarded untouched by our proxy, matching the non-empty
session_summarywritten to disk.Summary
Claude, Codex and Grok now take the ACP title. Kimi and the DeepSeek Harness keep
the isolated generator and its config.
The single
usesAcpProvidedSessionTitle()predicate answered two differentquestions at three call sites, and the three adapters need different answers, so it
is split:
acpOwnsSessionTitleGeneration()— claude, codex, grok. Skips the isolatedgenerator and hides the title-generation config.
trustsUntaggedAcpSessionTitle()— claude, grok. Gates theagent-clienttrustcheck for adapters that push a bare
session_info_updatewith no_meta.Codex must stay out of the second set. It emits a first-prompt
fallbackpreviewbefore its generated
explicittitle, so trusting untagged titles for it wouldpromote the raw prompt to the session title. Grok emits no such preview.
"Config removed" also had to hold off the settings form: branch naming resolved the
persisted
titleGenerationfor whatever agent it was naming a branch for, so avalue stored before this change would have kept steering Claude/Codex/Grok after
the setting disappeared from the UI. That lookup is now skipped for ACP-owned
agents.
Before / after
Test plan
pnpm -w run typecheck— 0 errorspnpm -w run lint— 0 errors (pre-existing warnings unchanged)pnpm run docs check— passes;apps/cli/src/agent/AGENTS.mdkept under the8192-byte gate (8183)
pnpm check:public-boundary— passespnpm format— cleanpackages/shared/tests/title-generation-defaults.test.ts— 16 passedapps/cli/tests/message-handler-title.test.ts+title-generator.test.ts— 27 passedBoth behavioral changes were verified non-vacuous by reverting them and confirming
the new tests fail: the branch-name fix (2 failures) and the Grok predicates
(4 failures).
Not verified:
packages/components/tests/agent-config-dialog.test.tsxhas 24act is not a functionfailures. Confirmed identical on unmodifiedmainvia atemporary worktree, so they are pre-existing environment failures — but one
title-related case in that file is consequently unverified here. No live Codex,
Kimi or DeepSeek session was exercised; the Grok result rests on the probe above.
Follow-ups (not in this PR)
landed yet, so this removes duplicated work without eliminating the isolated
session. Making it wait for the pushed title needs the timeout path to fall back
to prompt text instead of abandoning the rename, which affects every provider.
x.ai/session/infoanswers-32601undergrok agent stdio, andacp-extension-groksilently drops that error — so builtin Grok's context-windowusage notification is dead against runtime 1.0.13.
titleKindmapped totitleSourceat its ACP boundary; the DeepSeekHarness needs
dsh-session-titlemounted in its Cordis config.Notes
Title wording now belongs to the adapters:
DEFAULT_TITLE_GENERATION_PROMPTnolonger constrains these three, and Grok keeps refining its title over the first few
turns before freezing it, so a Grok title can change after it first appears.
Decision record:
.agents/notes/implemented/architecture/2026-09-08-acp-owned-session-titles.md🤖 Generated with Claude Code