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
2 changes: 1 addition & 1 deletion adapters/claude-mod/lcu-approve/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
8 changes: 4 additions & 4 deletions adapters/claude-mod/lcu-approve/hooks/register.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
}
Expand Down
50 changes: 25 additions & 25 deletions adapters/claude-mod/lcu-approve/tests/register.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { [LCU]: '' }) {
Expand All @@ -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'])
})

Expand All @@ -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([])
})

Expand All @@ -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()
Expand Down Expand Up @@ -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)
})

Expand All @@ -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) => {
Expand All @@ -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)
})

Expand All @@ -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) => {
Expand Down
41 changes: 41 additions & 0 deletions adapters/pi/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string>(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>${key}</key>\\s*<string>([^<]*)</string>`).exec(plist)?.[1];

Expand Down Expand Up @@ -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 => [
Expand Down
49 changes: 48 additions & 1 deletion adapters/test/pi.test.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 });
}
});
Loading
Loading