diff --git a/adapters/claude-mod/lcu-approve/.claude-plugin/plugin.json b/adapters/claude-mod/lcu-approve/.claude-plugin/plugin.json index e1d2cf0b..2e1bcc4b 100644 --- a/adapters/claude-mod/lcu-approve/.claude-plugin/plugin.json +++ b/adapters/claude-mod/lcu-approve/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "lcu-approve", - "version": "0.5.2", + "version": "0.6.0", "description": "Native per-app approval for LCU computer use in the Claude app and Claude Code (Allow this conversation, Always allow, Deny), and the /computer-use-apps panel of approved apps.", "author": { "name": "LCU" diff --git a/adapters/claude-mod/lcu-approve/hooks/register.tsx b/adapters/claude-mod/lcu-approve/hooks/register.tsx index 243211db..c6d20b8c 100644 --- a/adapters/claude-mod/lcu-approve/hooks/register.tsx +++ b/adapters/claude-mod/lcu-approve/hooks/register.tsx @@ -145,8 +145,8 @@ export const register: Register = on => { return next(e) }) - // An update notice from the cache `lcu update --notice` keeps: the model gets the message as session - // context (`session.start` cannot carry any; the classic SessionStart hook can), the person one toast. + // An update notice from the cache `lcu update --notice` keeps: the person gets one toast. The model never + // receives it; the hook's result is returned unchanged. on('classic.SessionStart', async ($, e, next) => { const result = await next(e) const state = sessionState(e) @@ -155,7 +155,7 @@ export const register: Register = on => { if (!notice) return result state.announced = notice.latest ?? notice.message await toast($, notice) - return { ...result, additionalContext: [...(result.additionalContext ?? []), notice.message] } + return result }) // A release published while the session is open: the cache refreshes in the background, so the first @@ -174,7 +174,7 @@ export const register: Register = on => { if (version === state.announced) return result state.announced = version await toast($, notice) - return { ...result, additionalContext: [...(result.additionalContext ?? []), notice.message] } + return result } catch { return result } diff --git a/adapters/claude-mod/lcu-approve/tests/register.test.tsx b/adapters/claude-mod/lcu-approve/tests/register.test.tsx index a809f5cc..00e7335b 100644 --- a/adapters/claude-mod/lcu-approve/tests/register.test.tsx +++ b/adapters/claude-mod/lcu-approve/tests/register.test.tsx @@ -458,7 +458,7 @@ test('a terminal that cannot draw pictures gets no icon cell before the name', a const NOTICE = { current: '0.9.1', latest: '0.9.2', severity: 'normal', release_url: 'https://example.test/r', command: LCU, - message: 'LCU 0.9.2 is available (installed: 0.9.1). Tell the user and offer to run `lcu update`.', + message: 'LCU 0.9.2 is available (installed: 0.9.1). Run `lcu update`, then restart the agents that use LCU.', } function noticeHost(on: any, processes: (argv: string[]) => any, surfaces: string[] = ['terminal'], files: Record = { [LCU]: '' }) { @@ -469,12 +469,13 @@ function noticeHost(on: any, processes: (argv: string[]) => any, surfaces: strin const noticeRun = (stdout: string) => (argv: string[]) => (argv[1] === 'update' ? { stdout } : undefined) -test('an update notice becomes session context and one toast', async ($, on) => { +// The notice is for the person: a toast, and the hook's result unchanged, so the model never receives it. +test('an update notice becomes one toast and never model context', async ($, on) => { const { ran, toasts } = noticeHost(on, noticeRun(JSON.stringify(NOTICE))) const result: any = await $.classic.SessionStart({ source: 'startup' } as any) expect(ran.find(argv => argv[1] === 'update')?.slice(0, 4)).toEqual([LCU, 'update', '--notice', '--json']) expect(ran.find(argv => argv[1] === 'update')?.[4]).toMatch(/^--announce/) - expect(result.additionalContext).toEqual([NOTICE.message]) + expect(result.additionalContext).toBeUndefined() expect(toasts).toEqual(['LCU 0.9.2 is available \u2014 ask Claude to update it, or run lcu update']) }) @@ -484,19 +485,18 @@ test('the session id goes to lcu, which applies the account-wide cooldown', asyn expect(ran.find(argv => argv[1] === 'update')).toEqual([LCU, 'update', '--notice', '--json', '--announce=s1']) }) -test('a release lcu already announced on the account today: no context, no toast', async ($, on) => { +test('a release lcu already announced on the account today: no toast', async ($, on) => { const { toasts } = noticeHost(on, argv => (argv[1] === 'update' ? { stdout: argv[4] === '--announce=s2' ? '{}' : JSON.stringify(NOTICE) } : undefined)) - const first: any = await $.classic.SessionStart({ source: 'startup', session_id: 's1' } as any) - const second: any = await $.classic.SessionStart({ source: 'startup', session_id: 's2' } as any) - expect(first.additionalContext).toEqual([NOTICE.message]) - expect(second.additionalContext).toBeUndefined() + await $.classic.SessionStart({ source: 'startup', session_id: 's1' } as any) + expect(toasts.length).toBe(1) + await $.classic.SessionStart({ source: 'startup', session_id: 's2' } as any) expect(toasts.length).toBe(1) }) -test('a headless run gets the context without a toast', async ($, on) => { +test('a headless run gets neither context nor a toast', async ($, on) => { const { toasts } = noticeHost(on, noticeRun(JSON.stringify(NOTICE)), []) const result: any = await $.classic.SessionStart({ source: 'startup' } as any) - expect(result.additionalContext).toEqual([NOTICE.message]) + expect(result.additionalContext).toBeUndefined() expect(toasts).toEqual([]) }) @@ -514,7 +514,7 @@ const quiet: [string, (argv: string[]) => any][] = [ ['a command that cannot run', () => { throw new Error('no processes') }], ] for (const [name, processes] of quiet) { - test(`${name}: no context, no toast, no throw`, async ($, on) => { + test(`${name}: no toast, no throw`, async ($, on) => { const { toasts } = noticeHost(on, processes) const result: any = await $.classic.SessionStart({ source: 'startup' } as any) expect(result.additionalContext).toBeUndefined() @@ -542,7 +542,7 @@ test('the first prompt checks for a notice', async ($, on) => { const { ran, toasts } = promptHost(on, noticeRun(JSON.stringify(NOTICE))) const result: any = await prompt($) expect(checks(ran)).toBe(1) - expect(result.additionalContext).toEqual([NOTICE.message]) + expect(result.additionalContext).toBeUndefined() expect(toasts.length).toBe(1) }) @@ -560,12 +560,12 @@ test('a prompt within ten minutes of a check does not run the command', async ($ test('a version announced at session start is not repeated', async ($, on) => { const clock = mock.clock(on) - const { ran } = promptHost(on, noticeRun(JSON.stringify(NOTICE))) + const { ran, toasts } = promptHost(on, noticeRun(JSON.stringify(NOTICE))) await $.classic.SessionStart({ source: 'startup', session_id: 's1' } as any) await clock.advance(11 * MIN) - const result: any = await prompt($) + await prompt($) expect(checks(ran)).toBe(2) - expect(result.additionalContext).toBeUndefined() + expect(toasts.length).toBe(1) }) test('after the cooldown lcu announces again: a session told nothing yet gets it once', async ($, on) => { @@ -574,11 +574,12 @@ test('after the cooldown lcu announces again: a session told nothing yet gets it const { toasts } = promptHost(on, argv => (argv[1] === 'update' ? { stdout: out } : undefined)) await $.classic.SessionStart({ source: 'startup', session_id: 's1' } as any) out = JSON.stringify(NOTICE) + expect(toasts.length).toBe(0) await clock.advance(11 * MIN) - expect(((await prompt($)) as any).additionalContext).toEqual([NOTICE.message]) + await prompt($) expect(toasts.length).toBe(1) await clock.advance(11 * MIN) - expect(((await prompt($)) as any).additionalContext).toBeUndefined() + await prompt($) expect(toasts.length).toBe(1) }) @@ -589,20 +590,19 @@ test('a newer version is announced once', async ($, on) => { await $.classic.SessionStart({ source: 'startup', session_id: 's1' } as any) out = JSON.stringify(NEWER) await clock.advance(11 * MIN) - const result: any = await prompt($) - expect(result.additionalContext).toEqual([NEWER.message]) + await prompt($) expect(toasts.length).toBe(2) await clock.advance(11 * MIN) - expect(((await prompt($)) as any).additionalContext).toBeUndefined() + await prompt($) + expect(toasts.length).toBe(2) }) test('sessions are independent', async ($, on) => { - const { ran } = promptHost(on, noticeRun(JSON.stringify(NOTICE))) - const a: any = await prompt($, 'a') - const b: any = await prompt($, 'b') + const { ran, toasts } = promptHost(on, noticeRun(JSON.stringify(NOTICE))) + await prompt($, 'a') + await prompt($, 'b') expect(checks(ran)).toBe(2) - expect(a.additionalContext).toEqual([NOTICE.message]) - expect(b.additionalContext).toEqual([NOTICE.message]) + expect(toasts.length).toBe(2) }) test('a failing check on a prompt gives nothing and does not throw', async ($, on) => { diff --git a/adapters/pi/index.ts b/adapters/pi/index.ts index b64eeb48..8930c1e5 100644 --- a/adapters/pi/index.ts +++ b/adapters/pi/index.ts @@ -1,3 +1,4 @@ +import { execFile } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs'; import { userInfo } from 'node:os'; @@ -158,6 +159,35 @@ function launchedLauncher(command: string[]) { return isLauncher(target, 'lcu', 'runtime.mjs') ? target : undefined; } +const NOTICE_TIMEOUT_MS = 3000; + +/** + * The cached update notice for the person, from `lcu update --notice --json --announce[=SESSION_ID]` of the + * release `command` launches (launchedLauncher), or undefined. The command reads only LCU's local cache, + * returns `{}` when update checks are off (LCU_NO_UPDATE_CHECK, a source checkout) or the release was already + * announced (once per session, once a day per account, shared with Codex and Claude Code), and never fails + * the session: any error, a timeout or unreadable output means no notice. + */ +export async function updateNotice(command: string[], sessionId?: string) { + const launcher = launchedLauncher(command); + if (launcher === undefined) return undefined; + const stdout = await new Promise(resolve => { + try { + execFile(launcher, ['update', '--notice', '--json', sessionId ? `--announce=${sessionId}` : '--announce'], + { timeout: NOTICE_TIMEOUT_MS, windowsHide: true }, (error, out) => resolve(error ? '' : String(out))); + } catch { + resolve(''); + } + }); + try { + const value = JSON.parse(stdout); + if (!value || typeof value !== 'object' || typeof value.message !== 'string' || !value.message.trim()) return undefined; + return { message: value.message as string, severity: typeof value.severity === 'string' ? value.severity as string : 'normal' }; + } catch { + return undefined; + } +} + const plistString = (plist: string, key: string) => new RegExp(`${key}\\s*([^<]*)`).exec(plist)?.[1]; @@ -833,6 +863,17 @@ export default function (pi: ExtensionAPI, options: { } }); pi.on('session_shutdown', leaveSession); + // An update notice for the person, as a UI notification; the model never receives it. Only interactive + // sessions ask, so a session without UI does not use up the release's once-a-day announcement. + pi.on('session_start', async (_event, ctx) => { + try { + if (!ctx.hasUI || typeof ctx.ui?.notify !== 'function') return; + const notice = await updateNotice(commandFromEnvironment(options.command), ctx.sessionManager?.getSessionId?.()); + if (notice) ctx.ui.notify(notice.message, ['security', 'breaking'].includes(notice.severity) ? 'warning' : 'info'); + } catch { + // a notice never fails the session + } + }); pi.registerCommand('lcu', { description: 'Stop active original Computer Use or pick a target for the editor.', getArgumentCompletions: prefix => [ diff --git a/adapters/test/pi.test.mjs b/adapters/test/pi.test.mjs index 11917a40..9e2284c5 100644 --- a/adapters/test/pi.test.mjs +++ b/adapters/test/pi.test.mjs @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fileURLToPath } from 'node:url'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, isAbsolute, join } from 'node:path'; import piExtension from '../pi/index.ts'; @@ -716,3 +716,50 @@ test('Pi does not start another host to end a turn when the host never connected rmSync(directory, { recursive: true, force: true }); } }); + +test('Pi shows the cached update notice to the person at session start, never to the model', + { skip: process.platform === 'win32' && 'the release launcher is a POSIX script' }, async () => { + const oldCommand = process.env.LCU_MCP_COMMAND; + const directory = mkdtempSync(join(tmpdir(), 'lcu-pi-notice-')); + // A release-shaped tree: bin/lcu records its arguments and prints what `lcu update --notice --json` would. + const out = join(directory, 'out.json'); + const args = join(directory, 'args'); + mkdirSync(join(directory, 'bin')); + mkdirSync(join(directory, 'lcu')); + writeFileSync(join(directory, 'lcu', 'runtime.mjs'), ''); + writeFileSync(join(directory, 'bin', 'lcu'), `#!/bin/sh\nprintf '%s\\n' "$@" >> '${args}'\ncat '${out}'\n`, { mode: 0o755 }); + process.env.LCU_MCP_COMMAND = JSON.stringify([join(directory, 'bin', 'lcu')]); + const handlers = new Map(); + const pi = { on(event, handler) { handlers.set(event, handler); }, registerTool() {}, registerCommand() {} }; + const notes = []; + const ctx = (hasUI) => ({ sessionManager: { getSessionId: () => 'pi-notice-session' }, hasUI, + ui: { notify: (message, level) => notes.push({ message, level }) } }); + const message = 'LCU 0.9.2 is available (installed: 0.9.1). Run `lcu update`, then restart the agents that use LCU.'; + try { + piExtension(pi); + const start = handlers.get('session_start'); + writeFileSync(out, JSON.stringify({ latest: '0.9.2', severity: 'normal', message })); + assert.equal(await start({ type: 'session_start', reason: 'startup' }, ctx(false)), undefined); + assert.equal(existsSync(args), false); // no UI: lcu is not asked, so the announcement is not used up + assert.equal(await start({ type: 'session_start', reason: 'startup' }, ctx(true)), undefined); + assert.deepEqual(readFileSync(args, 'utf8').trim().split('\n'), + ['update', '--notice', '--json', '--announce=pi-notice-session']); + assert.deepEqual(notes, [{ message, level: 'info' }]); + writeFileSync(out, JSON.stringify({ latest: '0.9.3', severity: 'security', message: `Security update: ${message}` })); + await start({ type: 'session_start', reason: 'resume' }, ctx(true)); + assert.deepEqual(notes.at(-1), { message: `Security update: ${message}`, level: 'warning' }); + for (const quiet of ['{}', 'not json', '[1]']) { + writeFileSync(out, quiet); + await start({ type: 'session_start', reason: 'new' }, ctx(true)); + } + assert.equal(notes.length, 2); + // A command that is not a release launcher is never run for the notice. + process.env.LCU_MCP_COMMAND = JSON.stringify([process.execPath, '-e', 'process.exit(1)']); + await start({ type: 'session_start', reason: 'new' }, ctx(true)); + assert.equal(notes.length, 2); + } finally { + if (oldCommand === undefined) delete process.env.LCU_MCP_COMMAND; + else process.env.LCU_MCP_COMMAND = oldCommand; + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/docs/ADAPTERS.md b/docs/ADAPTERS.md index bb005b28..c06ba664 100644 --- a/docs/ADAPTERS.md +++ b/docs/ADAPTERS.md @@ -155,7 +155,7 @@ LCU checks the actual `codex` on `PATH` for the native MCP tool hook type. The i The Codex relay preserves progress before successful and failed tool responses using the SDK's public notification API; the deterministic regression and upstream race are recorded in [Codex progress verification](verification/codex-progress-2026-09-28.md). -Setup also adds two LCU-owned command hooks: `SessionStart` (matcher `startup|resume`) running `lcu update --notice --hook SessionStart`, and `UserPromptSubmit` running `lcu update --notice --hook UserPromptSubmit`. Each prints Codex's `hookSpecificOutput.additionalContext` only when a newer LCU release is known, that session has not been told about it yet, and no session on the account (Codex or Claude Code) was told about that release in the last 24 hours: a release is announced at most once a day per account, a newer release at once (sessions are tracked by Codex's `session_id`, and the account by a `*` entry, in a per-account `announced.json` beside the cache; a `SessionStart` without a `session_id` follows only the account-wide cooldown, a `UserPromptSubmit` without one prints nothing) (see [update notices](INSTALLATION.md#update-notices)), reads only a local cache, and is trusted by its exact hash like the lifecycle hooks; other hooks on those events are left in place and untrusted. Rerunning setup replaces them. It is not part of the original lifecycle contract or the plugin export, and has not yet been observed in a live Codex turn. +Setup also adds two LCU-owned command hooks: `SessionStart` (matcher `startup|resume`) running `lcu update --notice --hook SessionStart`, and `UserPromptSubmit` running `lcu update --notice --hook UserPromptSubmit`. Each prints a `systemMessage`, which Codex shows the user as a warning and does not give the model, only when a newer LCU release is known, that session has not been told about it yet, and no session on the account (Codex or Claude Code) was told about that release in the last 24 hours: a release is announced at most once a day per account, a newer release at once (sessions are tracked by Codex's `session_id`, and the account by a `*` entry, in a per-account `announced.json` beside the cache; a `SessionStart` without a `session_id` follows only the account-wide cooldown, a `UserPromptSubmit` without one prints nothing) (see [update notices](INSTALLATION.md#update-notices)), reads only a local cache, and is trusted by its exact hash like the lifecycle hooks; other hooks on those events are left in place and untrusted. Rerunning setup replaces them. It is not part of the original lifecycle contract or the plugin export, and has not yet been observed in a live Codex turn (nor has the warning). ## Claude Code adapter @@ -220,7 +220,9 @@ The relay reads `cross-turn.json` (`adapters/cross-turn.mjs`, shipped in the arc ### Update notices -The `lcu-approve` mod also runs `lcu update --notice --json --announce=` when a session starts and on prompts, at most every 10 minutes per session (a cache read; the cache itself refreshes in the background every 10 minutes). `--announce` applies the same rule as the Codex hooks, in the same `announced.json`: a release is announced at most once a day per account, across all Claude Code and Codex sessions, each session is told about a given release once, and a newer release is announced at once. A session not told during the cooldown may be told at its next prompt after it. The toast follows the same decision; the mod keeps no cooldown of its own. When a newer release is cached, the agent receives the notice as context, told to tell the user and offer `lcu update` and not to upgrade without asking, and the user sees a toast. The command reads only the local cache and never blocks the session start; see [update notices](INSTALLATION.md#update-notices). `LCU_NO_UPDATE_CHECK=1` disables it. Pi, Oh My Pi and Hermes have no equivalent session hook; their notice appears in `lcu status`, `lcu doctor` and `lcu update --check`. +The `lcu-approve` mod also runs `lcu update --notice --json --announce=` when a session starts and on prompts, at most every 10 minutes per session (a cache read; the cache itself refreshes in the background every 10 minutes). `--announce` applies the same rule as the Codex hooks, in the same `announced.json`: a release is announced at most once a day per account, across all Claude Code and Codex sessions, each session is told about a given release once, and a newer release is announced at once. A session not told during the cooldown may be told at its next prompt after it. The toast follows the same decision; the mod keeps no cooldown of its own. When a newer release is cached, the user sees a toast; the hook's result is returned unchanged, so the model never receives the notice. The command reads only the local cache and never blocks the session start; see [update notices](INSTALLATION.md#update-notices). `LCU_NO_UPDATE_CHECK=1` disables it. + +The Pi and Oh My Pi extension (`adapters/pi/index.ts`) runs the same command, with Pi's session id, on `session_start` when `ctx.hasUI` is true, through the release launcher its MCP command starts (3 s timeout; any failure means no notice), and shows the message with `ctx.ui.notify` (a warning for security and breaking releases, else info). It returns nothing to Pi, so the model never receives the notice. `adapters/test/pi.test.mjs` covers it with a fake launcher; it has not yet been observed in a live Pi or Oh My Pi session. Hermes and DeepSeek Harness have no equivalent session hook; their notice appears in `lcu status`, `lcu doctor` and `lcu update --check`. ## Harnesses installed after setup diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index ff812aff..1d4414b1 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -458,16 +458,17 @@ Reclaim that space with `lcu prune [--keep N] [--yes]`. It removes old LCU relea ### Update notices -LCU can tell you when a newer release exists without you running `lcu update --check`. `lcu update --notice` reads only a local cache and never blocks: it prints a notice for an agent when a newer release is known, and nothing otherwise. When the cache is older than 10 minutes it starts a background refresh (about 1 hour after a failed check, so an offline machine does not retry on every prompt), and concurrent sessions start at most one refresh between them. A release published while sessions are open therefore reaches the first of them to ask within about 10 minutes, at its next prompt. The refresh makes the same single request to the releases redirect as `--check`, then reads that release's `docs/releases/.md` for an optional severity marker; nothing else is sent. The cache is per account: `~/Library/Caches/lcu/update.json` on macOS, `$XDG_CACHE_HOME/lcu/update.json` (default `~/.cache`) on Linux, and `%LOCALAPPDATA%\LCU\cache\update.json` on Windows. +LCU can tell you when a newer release exists without you running `lcu update --check`. `lcu update --notice` reads only a local cache and never blocks: it prints a notice when a newer release is known, and nothing otherwise. The notice is for you: the agent integrations below show it in the agent's interface, and the model never receives it. When the cache is older than 10 minutes it starts a background refresh (about 1 hour after a failed check, so an offline machine does not retry on every prompt), and concurrent sessions start at most one refresh between them. A release published while sessions are open therefore reaches the first of them to ask within about 10 minutes, at its next prompt. The refresh makes the same single request to the releases redirect as `--check`, then reads that release's `docs/releases/.md` for an optional severity marker; nothing else is sent. The cache is per account: `~/Library/Caches/lcu/update.json` on macOS, `$XDG_CACHE_HOME/lcu/update.json` (default `~/.cache`) on Linux, and `%LOCALAPPDATA%\LCU\cache\update.json` on Windows. Where the notice appears: -Agent sessions are told about a given release at most once a day per account: once one session (Claude Code or Codex, any project) has been given the notice, the others on the account get nothing about that release for 24 hours. After that, the next session to start or send a prompt is told once, and a session is never told twice about the same release. A newer release is announced at once, then has its own 24-hour cooldown. Security and breaking releases follow the same rule; their notice stays visible in `lcu status`, `lcu doctor` and `lcu update --check`, which are never throttled. What was announced is kept in `announced.json` beside the cache (one `*` entry for the account and one entry per session id, pruned after 7 days). Two sessions starting within the same instant can both be told. +Agent sessions are told about a given release at most once a day per account: once one session (Claude Code, Codex, Pi or Oh My Pi, any project) has shown the notice, the others on the account get nothing about that release for 24 hours. After that, the next session to start or send a prompt is told once, and a session is never told twice about the same release. A newer release is announced at once, then has its own 24-hour cooldown. Security and breaking releases follow the same rule; their notice stays visible in `lcu status`, `lcu doctor` and `lcu update --check`, which are never throttled. What was announced is kept in `announced.json` beside the cache (one `*` entry for the account and one entry per session id, pruned after 7 days). Two sessions starting within the same instant can both be told. - `lcu status` (text, and the `update` key of `--json`) and `lcu doctor` show the cached notice every time. It is informational and never makes `doctor` fail. So does `lcu update --notice` run by hand. -- Claude Code and the Claude app: the `lcu-approve` mod, installed by `lcu setup --agent claude-code`, runs the notice when a session starts and again on prompts, at most every 10 minutes, under the once-a-day account-wide rule above. The agent receives it as context, with an instruction to tell you and offer `lcu update` rather than upgrade on its own, and you see a toast. -- Codex CLI: `lcu setup --agent codex` adds LCU-owned `SessionStart` (startup and resume) and `UserPromptSubmit` hooks (`lcu update --notice --hook `) that give the agent the same notice as context, under the same once-a-day account-wide rule. They are separate from the original Stop, Interrupt and SubagentStop hooks, which are unchanged, and are trusted the same way, by exact hash. Codex discovers and trusts them; they have not yet been observed in a live Codex turn. -- Pi, Oh My Pi, Hermes and DeepSeek Harness have no session hook for this. Their users see the notice in `lcu status` and `lcu doctor`, or by running `lcu update --check`. +- Claude Code and the Claude app: the `lcu-approve` mod, installed by `lcu setup --agent claude-code`, runs the notice when a session starts and again on prompts, at most every 10 minutes, under the once-a-day account-wide rule above, and shows it as a toast. +- Codex CLI: `lcu setup --agent codex` adds LCU-owned `SessionStart` (startup and resume) and `UserPromptSubmit` hooks (`lcu update --notice --hook `) that print the same notice as the hook's `systemMessage`, which Codex shows you as a warning, under the same once-a-day account-wide rule. They are separate from the original Stop, Interrupt and SubagentStop hooks, which are unchanged, and are trusted the same way, by exact hash. Codex discovers and trusts them; they, and the warning, have not yet been observed in a live Codex turn. +- Pi and Oh My Pi: the LCU extension runs the notice when an interactive session starts, under the same rule, and shows it as a UI notification. A session without UI does not run it. This has not yet been observed in a live Pi or Oh My Pi session. +- Hermes and DeepSeek Harness have no session hook for this. Their users see the notice in `lcu status` and `lcu doctor`, or by running `lcu update --check`. A release whose notes carry a severity marker prefixes the notice with "Security update:" or "Breaking update:"; see [Development](DEVELOPMENT.md#release-severity-marker). Set `LCU_NO_UPDATE_CHECK=1` to disable the background check and all notices. A source checkout never reports updates. Offline installs are not recorded, so set the variable on machines without network access; otherwise the check fails quietly and retries about once an hour. diff --git a/lcu/codex_hooks.mjs b/lcu/codex_hooks.mjs index d9289d11..68e5d469 100644 --- a/lcu/codex_hooks.mjs +++ b/lcu/codex_hooks.mjs @@ -38,9 +38,9 @@ const NOTICE_MATCHER = 'startup|resume'; /** * LCU's own command hook for `event` (harness integration, not an original lifecycle hook). `update --notice - * --hook EVENT` prints Codex's `hookSpecificOutput.additionalContext` for the model once per session and - * release, and at most once a day per release across the account; it is cache-only, exits 0 and prints nothing - * otherwise. SessionStart runs on startup and resume, UserPromptSubmit on every prompt (no matcher, no status + * --hook EVENT` prints a `systemMessage`, which Codex shows the person as a warning and never gives the model, + * once per session and release, and at most once a day per release across the account; it is cache-only, exits + * 0 and prints nothing otherwise. SessionStart runs on startup and resume, UserPromptSubmit on every prompt (no matcher, no status * message: it must stay quiet). Hook trust applies as for any other hook. */ export function noticeHook(lcu, event = 'SessionStart') { diff --git a/lcu/update.mjs b/lcu/update.mjs index c988f824..8fe4678b 100644 --- a/lcu/update.mjs +++ b/lcu/update.mjs @@ -1,4 +1,4 @@ -// Find out whether a newer LCU release exists, cache the answer and tell the agent. +// Find out whether a newer LCU release exists, cache the answer and tell the person using the agent. // `lcu update --notice --hook ...` runs on agent prompts: it reads only the cache and never waits on the network. const { mkdirSync, readFileSync, readSync, renameSync, statSync, utimesSync, writeFileSync, closeSync, openSync } = process.getBuiltinModule('node:fs'); const { dirname, join } = process.getBuiltinModule('node:path'); @@ -270,9 +270,8 @@ export function refreshClaimed(now = deps.now()) { function message(root, info, current) { const prefix = { security: 'Security update: ', breaking: 'Breaking update: ' }[info.severity] ?? ''; - return `${prefix}LCU ${info.version} is available (installed: ${current}). Tell the user and offer to run ` + - `\`${stableCommand(root)} update\`; do not upgrade without asking. Agents using LCU must be ` + - `restarted afterwards. Release notes: ${info.release_url}`; + return `${prefix}LCU ${info.version} is available (installed: ${current}). Run \`${stableCommand(root)} update\`, ` + + `then restart the agents that use LCU. Release notes: ${info.release_url}`; } /** The notice from the cache only (no refresh), or null. */ @@ -428,7 +427,7 @@ const OPTIONS = { }; const USAGE = 'Usage: lcu update [-h] [--check | --notice] [--json] [--yes]\n\n' + 'Find out whether a newer LCU release exists and install it.\n\n' + - ' --check check now without installing\n --notice print the cached update notice for an agent (never uses the network)\n' + + ' --check check now without installing\n --notice print the cached update notice (never uses the network)\n' + ' --json print JSON (with --check or --notice)\n --yes do not ask before installing\n'; function parse(argv) { @@ -461,11 +460,11 @@ export async function main(root, argv = []) { try { let found = notice(root); if (args.hook) { - // A hook's documented way to add model context: once per session and release, and at most once a day - // per release across the account, else silent. + // A hook's documented message for the person (`systemMessage`, never model context): once per session and + // release, and at most once a day per release across the account, else silent. const session = found && hookSessionId(); if (found && (session || args.hook === 'SessionStart') && announce(session, found.latest)) { - process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: args.hook, additionalContext: found.message } })}\n`); + process.stdout.write(`${JSON.stringify({ systemMessage: found.message })}\n`); } } else { // An agent integration (the Claude Code mod) is under the same cooldown as the hooks. diff --git a/tests/node/update.test.mjs b/tests/node/update.test.mjs index 25d03072..49a77c28 100644 --- a/tests/node/update.test.mjs +++ b/tests/node/update.test.mjs @@ -190,7 +190,8 @@ test('the notice names the stable command, the severity and the release', (t) => assert.deepEqual(Object.keys(found).sort(), ['command', 'current', 'latest', 'message', 'release_url', 'severity']); assert.deepEqual([found.current, found.latest, found.severity, found.command], ['0.9.1', '0.9.2', 'normal', command]); assert.ok(found.message.startsWith('LCU 0.9.2 is available')); - assert.ok(found.message.includes(`\`${command} update\``) && found.message.includes('without asking')); + assert.equal(found.message, `LCU 0.9.2 is available (installed: 0.9.1). Run \`${command} update\`, then restart ` + + `the agents that use LCU. Release notes: ${INFO.release_url}`); cache({ ...INFO, severity: 'security' }); assert.ok(update.notice(root).message.startsWith('Security update: ')); cache({ ...INFO, severity: 'breaking' }); @@ -253,19 +254,22 @@ async function hook(t, root, event, stdin = '{"session_id": "s1"}') { override(t, update.deps, 'readStdin', () => stdin); const [status, out] = await cli(t, root, '--notice', '--hook', event); assert.equal(status, 0); - return out ? JSON.parse(out).hookSpecificOutput : null; + if (!out) return null; + const printed = JSON.parse(out); + // Shown to the person only: a `systemMessage`, never `hookSpecificOutput.additionalContext` for the model. + assert.deepEqual(Object.keys(printed), ['systemMessage']); + return printed; } test('hooks announce a release once per session and once a day per account', async (t) => { const { root } = setup(t); cache(); const first = await hook(t, root, 'SessionStart'); - assert.equal(first.hookEventName, 'SessionStart'); - assert.ok(first.additionalContext.startsWith('LCU 0.9.2 is available')); + assert.ok(first.systemMessage.startsWith('LCU 0.9.2 is available')); assert.equal(await hook(t, root, 'SessionStart'), null); assert.equal(await hook(t, root, 'UserPromptSubmit', '{"session_id": "s2"}'), null); cache({ ...INFO, version: '0.9.3', tag: 'v0.9.3' }); - assert.match((await hook(t, root, 'UserPromptSubmit')).additionalContext, /0\.9\.3/); + assert.match((await hook(t, root, 'UserPromptSubmit')).systemMessage, /0\.9\.3/); assert.equal(await hook(t, root, 'SessionStart', '{"session_id": "s2"}'), null); });