diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e43e12..f715766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/session-title.md b/docs/session-title.md index 0611d37..1e90316 100644 --- a/docs/session-title.md +++ b/docs/session-title.md @@ -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 `` block tags and keeping the first non-blank line. @@ -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 (` : `, 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. diff --git a/hooks/report-title.sh b/hooks/report-title.sh index 1968c62..a94c442 100755 --- a/hooks/report-title.sh +++ b/hooks/report-title.sh @@ -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 \ diff --git a/omp/lich.js b/omp/lich.js index 5427b72..382aaa3 100644 --- a/omp/lich.js +++ b/omp/lich.js @@ -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) @@ -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 }) diff --git a/tests/hooks.test.mjs b/tests/hooks.test.mjs index dafee3c..83fca95 100644 --- a/tests/hooks.test.mjs +++ b/tests/hooks.test.mjs @@ -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) @@ -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')], ] @@ -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 () => { @@ -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') diff --git a/tests/omp.test.mjs b/tests/omp.test.mjs index 37cebf9..3761742 100644 --- a/tests/omp.test.mjs +++ b/tests/omp.test.mjs @@ -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 }) => {