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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **A Codex card is titled again.** Codex 0.147.0 and later write no
`user_message` event into the rollout, which is where the title was read
from, so a Codex card kept the name lich gave it. The first user message is
now also read from the completed `UserMessage` item those versions write
(`docs/session-title.md`).
- **An omp card no longer shows omp's title card.** omp heads a generated title
with an icon and a code (`󰧿 LIGHT: Write lighthouse fog haiku`), and the card
showed all of it. The title is sent without them.

- **A lich worker answers its errand when its `classic.Stop` hook is
bypassed.** On a machine with managed settings, Claude Code's builtin
`cc-plugin-sec-default` sends `classic.Stop` on past user plugins, and the
Expand Down
8 changes: 6 additions & 2 deletions docs/session-title.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ stdin), in their own shape — the script reads whichever is there:
- **Codex** — the first `user_message` event, field `payload.message`, first
line. Codex generates no title: it names a thread after its first user message
verbatim (`threads.title` in its state database), so this reports the same
label Codex shows, only trimmed to card size.
label Codex shows, only trimmed to card size. Codex 0.147.0 and later write
no `user_message` event; there it is the first `item_completed` event whose
`payload.item.type` is `UserMessage`, its first `text` content.
- **Antigravity** — the first `USER_INPUT` entry's `content` text, unwrapping
`<USER_REQUEST>` block tags and keeping the first non-blank line.

Expand All @@ -66,6 +68,8 @@ written in English never shows it.
turn settles and when the next one starts, and sends it only when it differs
from the last one sent. A session whose only turn is its last still reports
its title, one turn late; a session that never gets a second turn reports
none.
none. A title headed by omp's card (`<icon> <CODE>: <title>`, its
`title.icons` setting, on by default) is sent without the card, matched the
way omp's own `splitCardTitle` matches it.
- **Crush** — nothing to report. Its one event, `PreToolUse`, is about the tool;
a Crush card keeps the name lich gave it.
9 changes: 9 additions & 0 deletions hooks/report-title.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,15 @@ if [ -z "$title" ]; then
| head -n 1)
fi

# Codex 0.147.0 and later write no user_message event: the paginated rollout
# carries the message as a completed UserMessage item instead.
if [ -z "$title" ]; then
title=$(grep '"type":"UserMessage"' "$transcript_path" 2>/dev/null \
| jq -r 'select(.payload.type == "item_completed")
| [.payload.item.content[]? | select(.type == "text") | .text][0] // empty' 2>/dev/null \
| head -n 1)
fi

# Antigravity: names a thread after its first USER_INPUT content.
if [ -z "$title" ]; then
title=$(grep '"type":"USER_INPUT"' "$transcript_path" 2>/dev/null \
Expand Down
20 changes: 19 additions & 1 deletion omp/lich.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,24 @@ function conversation(ctx) {

// omp catches what a handler throws, but a throwing extension is still a
// visible startup warning and a line in its log. Nothing here is worth either.
// omp heads a generated title with a card, `<icon> <CODE>: <title>` (its
// `title.icons` setting, on by default; measured on 18.8.9), which a lich card
// would show as a Nerd Font glyph and a shouting prefix. The pattern and the
// icon test mirror omp's own `splitCardTitle` (src/utils/title-card.ts): an
// icon is non-ASCII and at most 8 codepoints, so `Fix: the parser` stays whole.
const CARD_LINE = /^(\S+) ([A-Z0-9]{1,6}): (\S.*)$/u
const MAX_ICON_CODEPOINTS = 8

function cardIcon(icon) {
const chars = [...icon]
return chars.length <= MAX_ICON_CODEPOINTS && chars.every((char) => char.charCodeAt(0) >= 0x80)
}

function withoutCard(title) {
const line = CARD_LINE.exec(title)
return line && cardIcon(line[1]) ? line[3] : title
}

const safely = (fn) => (event, ctx) => {
try {
fn(event, ctx)
Expand All @@ -106,7 +124,7 @@ export default function lichPlugin(pi) {
// is its last still reports the title that turn produced, one turn later.
const reportTitle = (ctx) => {
const title = ctx?.sessionManager?.getSessionName?.()
const trimmed = typeof title === "string" ? title.trim() : ""
const trimmed = typeof title === "string" ? withoutCard(title.trim()) : ""
if (!trimmed || trimmed === reportedTitle) return
reportedTitle = trimmed
report("session-title", { title: trimmed })
Expand Down
43 changes: 43 additions & 0 deletions tests/hooks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,33 @@ function codexTranscript(message, name = 'codex.jsonl') {
return file
}

/**
* Codex 0.147.0 and later: a paginated rollout writes no user_message event,
* only the message as a completed UserMessage item (measured on 0.162.1).
*/
function codexPaginatedTranscript(message, name = 'codex-paginated.jsonl') {
const file = path.join(TMP, name)
const lines = [
JSON.stringify({ timestamp: '2026-10-21T12:00:00.000Z', type: 'session_meta', payload: { cli_version: '0.162.1' } }),
...(message === null
? []
: [
JSON.stringify({
timestamp: '2026-10-21T12:00:01.000Z',
type: 'event_msg',
payload: { type: 'item_started', item: { type: 'UserMessage', content: [{ type: 'text', text: message }] } },
}),
JSON.stringify({
timestamp: '2026-10-21T12:00:01.000Z',
type: 'event_msg',
payload: { type: 'item_completed', item: { type: 'UserMessage', content: [{ type: 'text', text: message }] } },
}),
]),
]
writeFileSync(file, lines.join('\n') + '\n')
return file
}

/** Antigravity names a thread after its first USER_INPUT content. */
function antigravityTranscript(content, name = 'antigravity.jsonl') {
const file = path.join(TMP, name)
Expand Down Expand Up @@ -819,6 +846,8 @@ const NO_TITLE = [
['codex rollout without a user message', () => codexTranscript(null, 'no-message.jsonl')],
['codex first line that is whitespace', () => codexTranscript(' \nreal work', 'blank-line.jsonl')],
['codex message that is a newline', () => codexTranscript('\nreal work', 'leading-nl.jsonl')],
['codex paginated rollout without a user message', () => codexPaginatedTranscript(null, 'paginated-no-message.jsonl')],
['codex paginated first line that is whitespace', () => codexPaginatedTranscript(' \nreal work', 'paginated-blank-line.jsonl')],
['antigravity transcript without user input', () => antigravityTranscript(null, 'no-user-input.jsonl')],
['antigravity user input that is whitespace', () => antigravityTranscript(' ', 'blank-user-input.jsonl')],
]
Expand Down Expand Up @@ -848,6 +877,7 @@ for (const [label, make] of NO_TITLE) {
// cannot do. A title written in English would pass either way.
for (const [provider, make] of [
['codex', codexTranscript],
['codex paginated', codexPaginatedTranscript],
['antigravity', antigravityTranscript],
]) {
test(`session-title caps ${provider} by character, not by byte`, async () => {
Expand All @@ -871,6 +901,19 @@ for (const [provider, make] of [
})
}

test('session-title reports the first line of a paginated Codex rollout', async () => {
await withStub(async (stub) => {
const file = codexPaginatedTranscript('Fix the replay ring overflow\nand its second line')
const result = await runHook(`"${ROOT}/hooks/report-title.sh"`, {
env: lichEnv(stub.port),
stdin: JSON.stringify({ session_id: 's', transcript_path: file }),
})
assertHookSucceeded(result)
assert.equal(stub.requests.length, 1)
assert.equal(assertContractHonoured('/session-title', stub.requests[0]).body.title, 'Fix the replay ring overflow')
})
})

test('session-title trims the title it reports', async () => {
await withStub(async (stub) => {
const file = claudeTranscript(' Fix the replay ring overflow ', 'padded-title.jsonl')
Expand Down
19 changes: 19 additions & 0 deletions tests/omp.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,25 @@ test('a title that only lands after the turn is reported on the next one', async
assert.equal(assertContractHonoured('/session-title', titles[0]).body.title, 'Add the omp client')
})

// omp's title cards (`title.icons`, on by default) head the title with an icon
// and a code; the card is omp's, the title after it is the conversation's.
for (const [label, title, want] of [
['a Nerd Font card', '\u{F09FF} LIGHT: Write lighthouse fog haiku', 'Write lighthouse fog haiku'],
['an emoji card', '🧪 FLAKY: Fix flaky tests', 'Fix flaky tests'],
['a plain title shaped like one', 'Fix: the parser', 'Fix: the parser'],
['an ASCII word before the code', 'Bug FIX: the parser', 'Bug FIX: the parser'],
]) {
test(`the title is reported without omp's card: ${label}`, async () => {
const requests = await reportsFor(
({ emit }) => emit('session_stop', stopEvent(), ctxWith({ title })),
{ expected: 2 },
)
const titles = only(requests, '/session-title')
assert.equal(titles.length, 1)
assert.equal(assertContractHonoured('/session-title', titles[0]).body.title, want)
})
}

test('an unchanged title is not re-sent', async () => {
const requests = await reportsFor(
({ emit }) => {
Expand Down
Loading