Skip to content

feat(cli): let builtin Codex and Grok own their ACP session titles - #522

Merged
lodystage[bot] merged 12 commits into
mainfrom
feat/migrate-title-generator-to-acp
Sep 10, 2026
Merged

feat(cli): let builtin Codex and Grok own their ACP session titles#522
lodystage[bot] merged 12 commits into
mainfrom
feat/migrate-title-generator-to-acp

Conversation

@lodystage

@lodystage lodystage Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Related issue

Problem / pressure

Lody ran its own isolated title-generator ACP session for every agent, carrying a
per-agent titleGeneration session config to pick a cheap model and a
least-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:

Adapter Generates a title? _meta.lody.titleSource
acp-extension-claude 0.70.0 yes — SDK generate_session_title control request none
acp-extension-codex 1.10.0 (since 1.8.0) yes — cheap-model turn on an ephemeral thread explicit
acp-extension-grok 0.1.0 (runtime 1.0.13) yes — upstream title_refresh.rs, verified by live probe none
acp-extension-kimi acp-server 0.0.1 no — first prompt truncated to 200 chars none
acp-extension-dsh 0.1.1 no — never mounts its upstream title plugin none

Grok 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. A
live probe on a credentialed machine confirmed exactly one session_info_update
per session, forwarded untouched by our proxy, matching the non-empty
session_summary written 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 different
questions at three call sites, and the three adapters need different answers, so it
is split:

  • acpOwnsSessionTitleGeneration() — claude, codex, grok. Skips the isolated
    generator and hides the title-generation config.
  • trustsUntaggedAcpSessionTitle() — claude, grok. Gates the agent-client trust
    check for adapters that push a bare session_info_update with no _meta.

Codex must stay out of the second set. It emits a first-prompt fallback preview
before its generated explicit title, so trusting untagged titles for it would
promote 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 titleGeneration for whatever agent it was naming a branch for, so a
value 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

Before After
Codex: Lody generated a title and the adapter generated its own Codex: adapter only
Grok: Lody generated a title; the adapter's was received and discarded Grok: adapter only
Title config visible for Codex and Grok Hidden; also unreachable from branch naming
One predicate conflating "owns generation" with "trust untagged" Two predicates, Codex deliberately in only one

Test plan

  • pnpm -w run typecheck — 0 errors
  • pnpm -w run lint — 0 errors (pre-existing warnings unchanged)
  • pnpm run docs check — passes; apps/cli/src/agent/AGENTS.md kept under the
    8192-byte gate (8183)
  • pnpm check:public-boundary — passes
  • pnpm format — clean
  • packages/shared/tests/title-generation-defaults.test.ts — 16 passed
  • apps/cli/tests/message-handler-title.test.ts + title-generator.test.ts — 27 passed

Both 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.tsx has 24
act is not a function failures. Confirmed identical on unmodified main via a
temporary 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)

  • Branch naming still falls back to the isolated generator when no ACP title has
    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/info answers -32601 under grok agent stdio, and
    acp-extension-grok silently drops that error — so builtin Grok's context-window
    usage notification is dead against runtime 1.0.13.
  • Kimi needs titleKind mapped to titleSource at its ACP boundary; the DeepSeek
    Harness needs dsh-session-title mounted in its Cordis config.

Notes

Title wording now belongs to the adapters: DEFAULT_TITLE_GENERATION_PROMPT no
longer 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

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]

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/cli/src/lib/message-handler.ts Outdated
};

if (!reusableTitlePromise) {
return toBranchName(taskPrompt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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]

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +160 to +163
'\\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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge 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]

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/cli/src/lib/message-handler.ts Outdated
);
}
}
return tryBranchName(title?.trim() || taskPrompt);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Badge 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
@lodystage
lodystage Bot merged commit 63ae7ad into main Sep 10, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant