From 8e24ba83e107b124931b55fd936053bad6cf59b8 Mon Sep 17 00:00:00 2001 From: Jose Antonio Alatorre Date: Sun, 1 Nov 2026 16:30:44 +0400 Subject: [PATCH] Show the plugin's version and offer Codex's own update in the panel header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header shows the running version beside MainSequence, and where it stands against the catalog Codex installs from (the mainsequence marketplace on main): Up to date, Update to x.y.z, x.y.z installed · start a new chat, or Check for updates when the catalog cannot be read. One successful check serves the chat's panels for 30 minutes. Update asks first, then runs the Codex binary that started the server: `codex plugin marketplace upgrade mainsequence` and `codex plugin add mainsequence@mainsequence`. When those commands cannot run, the plugin came from another marketplace, or the update fails, the header shows the two commands to run in a terminal. The open chat keeps its version until a new chat starts. The new mainsequence_plugin tool is app-only, so the model never updates the plugin. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- docs/agents-tools-panel.md | 15 ++++ scripts/check-panel-package.cjs | 2 +- scripts/verify-panel-host.mjs | 2 +- src/hosts/panel/panel.html | 42 ++++++++++- src/hosts/pluginUpdate.ts | 122 ++++++++++++++++++++++++++++++++ src/panel-server.ts | 7 +- tests/panel-server.test.cjs | 4 +- tests/panel-ui.test.cjs | 49 ++++++++++++- tests/plugin-update.test.cjs | 85 ++++++++++++++++++++++ 10 files changed, 325 insertions(+), 9 deletions(-) create mode 100644 src/hosts/pluginUpdate.ts create mode 100644 tests/plugin-update.test.cjs diff --git a/README.md b/README.md index e3ceb1b..e34c066 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,11 @@ with, so nothing else is downloaded or built. Then: 2. Start a new chat, or toggle MainSequence off and on in the desktop Plugins settings to reload an existing chat. -To update, refresh the marketplace and reinstall the entry, then start a new chat: +The panel header always shows the plugin's version and whether a newer one is published. +**Update to …** asks first, then runs Codex's own plugin commands; start a new chat to use +the new version. If those commands cannot run from the chat, the panel shows them to run +yourself. To update from a terminal, refresh the marketplace and reinstall the entry, then +start a new chat: ```sh codex plugin marketplace upgrade mainsequence diff --git a/docs/agents-tools-panel.md b/docs/agents-tools-panel.md index a8c4498..0e13b8a 100644 --- a/docs/agents-tools-panel.md +++ b/docs/agents-tools-panel.md @@ -129,6 +129,20 @@ dispatch are never retried; after a process dies during send, or a transport res ambiguous, the request is **uncertain** and resend is blocked. A new request must never be used to bypass an uncertain one. +Plugin version and update: the panel header always shows the running plugin version. When +the panel opens, the server compares it with the `mainsequence` catalog on this +repository's default branch and with the copy Codex has installed (`codex plugin list +--json`, run with the Codex binary that started the server; nothing else named `codex` is +run). One successful check serves the chat's panels for 30 minutes; **Check for updates** +reads again. The header then shows **Up to date**, **Update to** the published version, +or that a newer version is installed and needs a new chat. **Update to** asks for +confirmation, then runs `codex plugin marketplace upgrade mainsequence` and `codex plugin +add mainsequence@mainsequence`. The running chat keeps its version: the server captured its +files at start and keeps working after Codex removes the old version's folder. When Codex's +commands cannot run, the plugin came from another marketplace, or the update fails, the +panel shows the two commands to run in a terminal. The tool is app-only, so the model never +updates the plugin. + ## Boundaries | MCP tool | Public devtools operations | @@ -149,6 +163,7 @@ used to bypass an uncertain one. | `mainsequence_action` | Reviewed allowlisted devtools repository/project operations | | `mainsequence_operations` | Host-owned accepted-operation status and cancellation | | `mainsequence_settings` | Public shared preferences and existing shared-session login/renewal/sign-out | +| `mainsequence_plugin` | App-only plugin version check against the published catalog, and the confirmed update through Codex's plugin commands | The discovery tools are model- and app-accessible and return useful structured results without a UI. Their arguments are allowlisted; they do not accept arbitrary devtools diff --git a/scripts/check-panel-package.cjs b/scripts/check-panel-package.cjs index 2700bf8..0f5b267 100644 --- a/scripts/check-panel-package.cjs +++ b/scripts/check-panel-package.cjs @@ -13,7 +13,7 @@ module.exports = async function checkPanel(installed, env) { try { await rpc('initialize', { protocolVersion: '2025-11-25', clientInfo: { name: 'package-check', version: '1' }, capabilities: {} }); const { tools } = await rpc('tools/list'); - if (tools.length !== 16 || !tools.slice(0, 3).every(tool => tool.annotations.readOnlyHint) || tools[4].annotations.readOnlyHint) throw new Error('Expected discovery and durable agent delivery tools'); + if (tools.length !== 17 || !tools.slice(0, 3).every(tool => tool.annotations.readOnlyHint) || tools[4].annotations.readOnlyHint) throw new Error('Expected discovery and durable agent delivery tools'); const uri = tools.find(tool => tool.name === 'mainsequence_open')?._meta?.ui?.resourceUri; const { contents } = await rpc('resources/read', { uri }); if (!contents[0].text.includes('const BUILD =') || !contents[0].text.includes('"panelId":')) throw new Error('The panel must contain immutable build metadata'); diff --git a/scripts/verify-panel-host.mjs b/scripts/verify-panel-host.mjs index fecf05d..91f3668 100644 --- a/scripts/verify-panel-host.mjs +++ b/scripts/verify-panel-host.mjs @@ -29,7 +29,7 @@ try { } assert.ok(server, 'Codex discovers the packaged server'); const tools = Object.values(server.tools); - assert.deepEqual(tools.map(t => t.name).sort(), ['mainsequence_agent_request', 'mainsequence_agent_status', 'mainsequence_call_tool', 'mainsequence_connection', 'mainsequence_discover', 'mainsequence_open', 'mainsequence_stage_message', 'mainsequence_submission', 'mainsequence_tools', 'mainsequence_view','mainsequence_agent_task','mainsequence_workspace','mainsequence_action','mainsequence_operations','mainsequence_settings','mainsequence_agent_prepare'].sort()); + assert.deepEqual(tools.map(t => t.name).sort(), ['mainsequence_agent_request', 'mainsequence_agent_status', 'mainsequence_call_tool', 'mainsequence_connection', 'mainsequence_discover', 'mainsequence_open', 'mainsequence_stage_message', 'mainsequence_submission', 'mainsequence_tools', 'mainsequence_view','mainsequence_agent_task','mainsequence_workspace','mainsequence_action','mainsequence_operations','mainsequence_settings','mainsequence_agent_prepare','mainsequence_plugin'].sort()); assert.equal(tools.find(t => t.name === 'mainsequence_agent_request').annotations.readOnlyHint, false); assert.equal(tools.find(t => t.name === 'mainsequence_agent_status').annotations.readOnlyHint, true); const opener = tools.find(t => t.name === 'mainsequence_open'); diff --git a/src/hosts/panel/panel.html b/src/hosts/panel/panel.html index cdd8e99..b1969c1 100644 --- a/src/hosts/panel/panel.html +++ b/src/hosts/panel/panel.html @@ -6,12 +6,14 @@ :root{font-size:var(--font-text-sm-size,14px)}.header{padding:20px 20px 0}.content{padding:8px 20px 20px}.brand h1{font-size:16px}.brand-mark{width:24px;height:24px}.account-row{margin-top:12px;gap:8px}.account-row select,.account-name{font-size:13px}.account-row select{width:108px}.account-row{gap:6px}.utility{margin-left:auto;gap:4px}nav{border-bottom:0;gap:4px;padding:10px 0 0}.item{border:0;padding:9px 0}.item h3,button.name{font-size:14px}.caption,small{font-size:13px}.field{display:flex;flex-direction:column;gap:5px;margin:10px 0}.field input,.field select{width:100%}.selected{background:var(--surface)}.card .row{flex-wrap:wrap}.card{margin:10px 0 14px;padding:14px}.detail-heading{margin:8px 0}.detail-heading h2{font-size:16px}#results>h2{margin:10px 0}#results>h3{margin-top:16px}.toolbar{margin:0 0 6px}#footer{display:none}.shell{max-width:none}.account-name{overflow-wrap:anywhere;white-space:normal}.review p{white-space:pre-wrap} .account-name[data-status=connected]:before{color:var(--color-text-success,light-dark(#16803d,#40c977))}.account-name[data-status=error]:before{color:var(--color-text-danger,light-dark(#b42318,#ff6764))}.account-row select{width:auto;min-width:132px;max-width:220px} @media(max-width:400px){.header{padding:14px 12px 0}.content{padding:8px 12px 16px}.account-row{gap:4px}.utility{margin-left:0}.account-name{font-size:12px}.account-row select{max-width:180px}nav button{padding:5px 7px}.toolbar{flex-wrap:wrap}button{max-width:100%;white-space:normal}.row.between{flex-wrap:wrap}.card{padding:10px}} +.brand{flex-wrap:wrap;row-gap:2px}.version{color:var(--muted);font-size:12px}.release{gap:6px;font-size:12px;white-space:normal}.release button{min-height:24px;padding:2px 8px;font-size:12px}.release .ok{color:var(--color-text-success,light-dark(#16803d,#40c977))}.release .warn{color:var(--color-text-warning,light-dark(#9a6700,#e3b341))}.plugin-help{margin:10px 0 0}
-

MainSequence

+

MainSequence

+
@@ -44,6 +46,10 @@ // changed icon is redrawn, in place. The timer ends with the screen and pauses after 240 re-checks. const ICON_RECHECK_MS = 60000, ICON_RECHECK_LIMIT = 240, drawnIcons = new Map(); let iconWatch = null, iconChecks = 0; +// The running version is always in the header. The server compares it with the published marketplace catalog and, +// after the person confirms, updates through Codex's own plugin commands; the new version loads in a new chat. +const UPDATE_COMMANDS = ['codex plugin marketplace upgrade mainsequence', 'codex plugin add mainsequence@mainsequence']; +let pluginState = null, pluginBusy = ''; function watchIcons(run, load) { clearTimeout(iconWatch); iconWatch = setTimeout(async () => { @@ -93,6 +99,36 @@ function errorText(error) { return [error.result && error.code ? error.code + ': ' + error.message : error.message, error.effects?.message, error.result ? JSON.stringify(error.result.structuredContent || error.result.content || error.result, null, 2) : ''].filter(Boolean).join('\n'); } function failure(error) { $('notice').textContent = errorText(error); if (['CONTEXT_CHANGED', 'AUTH_REJECTED', 'NOT_AUTHENTICATED'].includes(error.code)) { snapshot = null; workspaceContext=null;operationCache=[];clearComposer(); ++deliveryGeneration; savedRequests = []; assetCache.clear(); $('deliveries').hidden = true; $('delivery-results').replaceChildren(); $('environment').value = ''; clearResults(); accountIndicator('Session needs refreshing','error'); } } async function call(name, args = {}) { const output = await rpc('tools/call', { name, arguments: args }); const data = output.structuredContent || JSON.parse(output.content?.find(c => c.type === 'text')?.text || '{}'); if (output.isError || data.error) throw Object.assign(new Error(data.error?.message || 'MainSequence could not complete this action.'), { code: data.error?.code, result: data.result, effects: data.effects }); if (!BUILD || data.build?.panelId !== BUILD.panelId) throw Object.assign(new Error('The panel and server versions differ. Reopen MainSequence from its newest app card.'), { code: 'CONTEXT_CHANGED' }); return data; } +function renderPlugin() { + const p = pluginState || {}, box = $('plugin-release'); + $('plugin-version').textContent = BUILD?.version ? 'v' + BUILD.version : ''; + box.replaceChildren(pluginBusy === 'update' ? node('span', 'Updating to ' + p.latest + '…', 'muted') : pluginBusy === 'check' ? node('span', 'Checking for updates…', 'muted') + : p.status === 'update_available' ? button('Update to ' + p.latest, updatePlugin, 'primary') + : p.status === 'restart_required' ? node('span', p.installed + ' installed · start a new chat', 'warn') + : p.status === 'current' ? node('span', '● Up to date', 'ok') + : connected ? button('Check for updates', () => checkPlugin(true), 'plain muted') : node('span')); +} +async function checkPlugin(recheck = false) { + if (pluginBusy) return; + pluginBusy = 'check'; renderPlugin(); + try { pluginState = await call('mainsequence_plugin', { action: 'version', ...(recheck ? { recheck: true } : {}) }); } + catch { pluginState = { ...pluginState, status: pluginState?.status === 'restart_required' ? 'restart_required' : 'unknown' }; } + finally { pluginBusy = ''; renderPlugin(); } +} +function pluginHelp(message) { + const help = $('plugin-help'); help.hidden = !message; + help.replaceChildren(...(message ? [node('p', message), caption('Update from a terminal, then start a new chat:'), node('pre', UPDATE_COMMANDS.join('\n')), button('Dismiss', () => pluginHelp(''), 'plain muted')] : [])); +} +function updatePlugin() { + const p = pluginState; + if (!p?.updateSupported) return pluginHelp('This chat cannot run Codex’s plugin commands, or MainSequence was not installed from the mainsequence marketplace.'); + review('Update MainSequence', 'Codex refreshes the mainsequence marketplace and installs MainSequence ' + p.latest + '. This chat keeps ' + BUILD.version + ' until you start a new chat.', generation, async () => { + pluginBusy = 'update'; pluginHelp(''); renderPlugin(); + try { pluginState = { ...pluginState, ...await call('mainsequence_plugin', { action: 'update' }) }; } + catch (error) { pluginHelp(error.message); } + finally { pluginBusy = ''; renderPlugin(); } + }); +} function selection() { return { contextId: snapshot.contextId, environmentUid: $('environment').value }; } const environmentName = () => snapshot?.environments.find(e => e.uid === $('environment').value)?.name || 'this environment'; async function view(kind, args = {}, target = selection()) { return call('mainsequence_view', { ...target, view: kind, ...args }); } @@ -280,11 +316,11 @@ if (message.method === 'ui/resource-teardown') { teardown(); parent.postMessage({ jsonrpc: '2.0', id: message.id, result: {} }, '*'); } }); addEventListener('pagehide', teardown); -controls(); +controls(); renderPlugin(); if (window.parent === window) { $('account').textContent = 'Open MainSequence in Codex'; $('notice').textContent = 'This panel uses the Codex MCP App connection to read your existing devtools session.'; } else try { const initialized = await rpc('ui/initialize', { appInfo: { name: 'mainsequence', version: BUILD?.version || 'preview' }, appCapabilities: {}, protocolVersion: '2026-01-26' }); parent.postMessage({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} }, '*'); - styles(initialized.hostContext); connected = true; await refresh(); + styles(initialized.hostContext); connected = true; void checkPlugin(); await refresh(); } catch (error) { failure(error); controls(); } diff --git a/src/hosts/pluginUpdate.ts b/src/hosts/pluginUpdate.ts new file mode 100644 index 0000000..c00e48d --- /dev/null +++ b/src/hosts/pluginUpdate.ts @@ -0,0 +1,122 @@ +import { readlink, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { createNodeHost, type HostServices } from '@dev-mainsequence/devtools'; +import { DiscoveryError } from './discovery'; + +/** The running plugin's version against the published catalog, and Codex's own plugin commands. */ +export const CODEX_MARKETPLACE = 'mainsequence'; +export const CODEX_PLUGIN_ID = `mainsequence@${CODEX_MARKETPLACE}`; +/** What `codex plugin add` installs from: the marketplace catalog on this repository's default branch. */ +export const CODEX_CATALOG_URL = 'https://raw.githubusercontent.com/mainsequence-sdk/mainsequence-codex-plugin/main/.agents/plugins/marketplace.json'; +export type PluginUpdateOptions = { + /** The running plugin's version, from its build-info.json. */ + version: string; + host?: HostServices; fetch?: typeof fetch; env?: NodeJS.ProcessEnv; now?: () => number; + /** The process that started this server; Codex when it runs the plugin. */ + parentExecutable?: () => Promise; +}; + +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const CODEX = /^codex(?:\.exe)?$/; +const CATALOG_LIMIT = 256_000; +// One check serves every panel this chat opens for a while; Check for updates reads again. +const CHECK_TTL_MS = 30 * 60_000; + +const split = (value: string): [number[], string] => { + const dash = value.indexOf('-'); + return [(dash < 0 ? value : value.slice(0, dash)).split('.').map(Number), dash < 0 ? '' : value.slice(dash + 1)]; +}; +/** Orders x.y.z versions; a prerelease sorts before its release. Anything else compares equal. */ +export function compareVersions(a: string, b: string): number { + if (!VERSION.test(a) || !VERSION.test(b)) return 0; + const [coreA, preA] = split(a), [coreB, preB] = split(b); + for (let i = 0; i < 3; i++) if (coreA[i] !== coreB[i]) return coreA[i] > coreB[i] ? 1 : -1; + if (preA === preB) return 0; + if (!preA || !preB) return preA ? -1 : 1; + return Math.sign(preA.localeCompare(preB, 'en', { numeric: true })); +} +/** Where the plugin stands: an update to install, one installed that needs a new chat, or neither. */ +export function updateStatus(running: string, installed: string | null, latest: string | null) { + if (!VERSION.test(running)) return 'unknown'; + const newest = installed && compareVersions(installed, running) > 0 ? installed : running; + if (latest && compareVersions(latest, newest) > 0) return 'update_available'; + if (newest !== running) return 'restart_required'; + return latest ? 'current' : 'unknown'; +} + +export function createPluginUpdate(options: PluginUpdateOptions) { + const host = options.host ?? createNodeHost(); + const env = { ...process.env, ...options.env }; + const now = options.now ?? Date.now; + let checked: { at: number; data: Record } | undefined; + + async function parentExecutable(signal: AbortSignal): Promise { + if (process.platform === 'linux') return readlink(`/proc/${process.ppid}/exe`).catch(() => undefined); + if (process.platform !== 'darwin') return undefined; + const result = await host.runProcess({ executable: '/bin/ps', args: ['-o', 'comm=', '-p', String(process.ppid)], cwd: '/', signal, timeoutMs: 5_000 }); + return result.exitCode === 0 ? result.stdout.trim() || undefined : undefined; + } + /** Codex itself: the process that started this server. Nothing else named codex on PATH is run. */ + async function codex(signal: AbortSignal): Promise { + const candidate = await (options.parentExecutable ?? (() => parentExecutable(signal)))(); + if (typeof candidate !== 'string' || !path.isAbsolute(candidate) || !CODEX.test(path.basename(candidate))) return undefined; + return await stat(candidate).then(item => item.isFile(), () => false) ? candidate : undefined; + } + async function run(executable: string, args: string[], signal: AbortSignal, timeoutMs: number) { + const result = await host.runProcess({ executable, args, cwd: env.HOME || '/', env, signal, timeoutMs }); + if (result.cancelled) throw new DiscoveryError('CANCELLED', 'The action was cancelled.'); + return result; + } + /** The copy Codex loads in the next chat; it can be newer than the running one. */ + async function installed(executable: string, signal: AbortSignal): Promise { + const result = await run(executable, ['plugin', 'list', '--json'], signal, 20_000); + if (result.exitCode !== 0) return undefined; + let list: { installed?: unknown }; + try { list = JSON.parse(result.stdout); } catch { return undefined; } + const row = Array.isArray(list?.installed) ? list.installed.find(item => item?.pluginId === CODEX_PLUGIN_ID) : undefined; + return typeof row?.version === 'string' && VERSION.test(row.version) ? row.version : undefined; + } + async function latest(signal: AbortSignal): Promise { + const response = await (options.fetch ?? fetch)(CODEX_CATALOG_URL, { signal: AbortSignal.any([AbortSignal.timeout(10_000), signal]), headers: { accept: 'application/json' } }); + const text = response.ok ? await response.text() : ''; + if (!text || text.length > CATALOG_LIMIT) throw new Error('catalog'); + const plugins = JSON.parse(text)?.plugins; + const version = Array.isArray(plugins) ? plugins.find(item => item?.name === 'mainsequence')?.source?.version : undefined; + if (typeof version !== 'string' || !VERSION.test(version)) throw new Error('catalog'); + return version; + } + + async function version(recheck: boolean, signal: AbortSignal) { + if (!recheck && checked && now() - checked.at < CHECK_TTL_MS) return checked.data; + const executable = await codex(signal); + const [current, newest] = await Promise.all([executable ? installed(executable, signal) : undefined, latest(signal).catch(() => { + if (signal.aborted) throw new DiscoveryError('CANCELLED', 'The action was cancelled.'); + return null; + })]); + const data = { running: options.version, installed: current ?? null, latest: newest, status: updateStatus(options.version, current ?? null, newest), + updateSupported: Boolean(current), ...(newest ? {} : { catalogError: 'The MainSequence plugin catalog could not be reached.' }) }; + if (newest) checked = { at: now(), data }; + return data; + } + async function update(signal: AbortSignal) { + const executable = await codex(signal); + const before = executable ? await installed(executable, signal) : undefined; + if (!executable || !before) throw new DiscoveryError('UNSUPPORTED_ACTION', `Codex's plugin commands are not available to this chat, or MainSequence was not installed from the ${CODEX_MARKETPLACE} marketplace.`); + checked = undefined; + if ((await run(executable, ['plugin', 'marketplace', 'upgrade', CODEX_MARKETPLACE], signal, 90_000)).exitCode !== 0) { + throw new DiscoveryError('PROCESS_FAILED', `Codex could not refresh the ${CODEX_MARKETPLACE} marketplace.`); + } + if ((await run(executable, ['plugin', 'add', CODEX_PLUGIN_ID], signal, 150_000)).exitCode !== 0) throw new DiscoveryError('PROCESS_FAILED', 'Codex could not install the MainSequence update.'); + const after = await installed(executable, signal); + if (!after) throw new DiscoveryError('PROCESS_FAILED', 'Codex installed the update but did not report the installed version.'); + return { running: options.version, previous: before, installed: after, status: updateStatus(options.version, after, null) === 'restart_required' ? 'restart_required' : 'current' }; + } + return { + async call(args: Record, signal: AbortSignal) { + if (args.recheck !== undefined && typeof args.recheck !== 'boolean') throw new DiscoveryError('INVALID_REQUEST', 'recheck must be a boolean.'); + if (args.action === 'version') return version(args.recheck === true, signal); + if (args.action === 'update') return update(signal); + throw new DiscoveryError('INVALID_REQUEST', 'mainsequence_plugin action must be "version" or "update".'); + }, + }; +} diff --git a/src/panel-server.ts b/src/panel-server.ts index c998a34..561a607 100644 --- a/src/panel-server.ts +++ b/src/panel-server.ts @@ -8,6 +8,7 @@ import { createHash } from 'node:crypto'; import { createDiscovery, DiscoveryError } from './hosts/discovery'; import { createWorkspace } from './hosts/workspace'; import { createMessaging, conversationIdentity } from './hosts/messaging'; +import { createPluginUpdate } from './hosts/pluginUpdate'; type RecordValue = Record; const root = path.resolve(__dirname, '..'); @@ -15,7 +16,7 @@ const build = JSON.parse(readFileSync(path.join(root, 'build-info.json'), 'utf8' // Capture an immutable resource at process start; both server and panel compare its identity. const template = readFileSync(path.join(__dirname, 'hosts/panel/panel.html'), 'utf8'); const brandBytes = readFileSync(path.join(root, 'assets/mainsequence-mark.svg')); -const panelId = createHash('sha256').update(brandBytes).update(build.version).update(JSON.stringify(build.devtools)).update(template).update(readFileSync(__filename)).update(readFileSync(path.join(__dirname, 'hosts/discovery.js'))).update(readFileSync(path.join(__dirname, 'hosts/messaging.js'))).update(readFileSync(path.join(__dirname, 'hosts/workspace.js'))).digest('hex'); +const panelId = createHash('sha256').update(brandBytes).update(build.version).update(JSON.stringify(build.devtools)).update(template).update(readFileSync(__filename)).update(readFileSync(path.join(__dirname, 'hosts/discovery.js'))).update(readFileSync(path.join(__dirname, 'hosts/messaging.js'))).update(readFileSync(path.join(__dirname, 'hosts/workspace.js'))).update(readFileSync(path.join(__dirname, 'hosts/pluginUpdate.js'))).digest('hex'); const uri = `ui://mainsequence/panel-${build.version}-${panelId.slice(0, 16)}.html`; const brand = 'data:image/svg+xml;base64,' + brandBytes.toString('base64'); const html = template.replace('--brand-image: none', '--brand-image: url(\"' + brand + '\")').replace('/* MAINSEQUENCE_PANEL_BUILD */ null', JSON.stringify({ version: build.version, panelId })); @@ -37,6 +38,8 @@ const workspace = createWorkspace(discovery,{fetch:transport.fetch, sessionLock: // Devtools owns the bounded connection cache; this host only supplies its lifetime. const mcpCache = createMcpConnectionCache(); const messaging = createMessaging(discovery, signal => createDevTools({ host: createNodeHost(), signal, fetch:transport.fetch, sessionLock:transport.sessionLock }).dispatch); +// The panel header's version check and update; Codex's own plugin commands do the installing. +const plugin = createPluginUpdate({ version: build.version }); const object = (v: unknown): v is RecordValue => v !== null && typeof v === 'object' && !Array.isArray(v); // A cold project MCP (a scaled-to-zero FastAPI release) has taken over a minute to answer // initialize. Since devtools 0.3.12 project requests have their own 120 s budget and a tool's @@ -65,6 +68,7 @@ const definitions: Definition[] = [ {name:'mainsequence_settings',title:'MainSequence account and workspace settings',description:'Read settings, or explicitly save workspace preferences, sign in, renew or sign out using the same shared machine session as other MainSequence clients. Signing out affects the shared account.',properties:{action:{type:'string',enum:['get','save','login','logout','refresh-token']},backend:{type:'string'},baseDir:{type:'string'},commandCenterUrl:{type:'string'}},required:['action'],mutation:true}, {name:'mainsequence_agent_prepare',title:'Prepare a MainSequence agent conversation',description:'Open or resume the selected agent session and wait for readiness. Does not send any message or introduction.',properties:{...fields,agentUid:{type:'string'},conversationKey:{type:'string'}},required:['contextId','environmentUid','agentUid'],mutation:true}, + {name:'mainsequence_plugin',title:'MainSequence plugin version and update',description:'App-only. Read the running plugin version against the published marketplace catalog and the copy Codex has installed, or update the plugin with Codex\'s own plugin commands after the person confirms in the panel. The new version loads in a new chat.',properties:{action:{type:'string',enum:['version','update']},recheck:{type:'boolean'}},required:['action'],mutation:true,appOnly:true}, ]; const send = (value: unknown) => process.stdout.write(JSON.stringify(value) + '\n'); // Saved project MCP tools, registered directly with the names, definitions and results the @@ -99,6 +103,7 @@ async function toolCall(params: RecordValue, signal: AbortSignal) { : definition.name === 'mainsequence_view' ? await discovery.view(dispatch, args, signal) : definition.name === 'mainsequence_connection' ? await discovery.connection(dispatch, args) : definition.name === 'mainsequence_call_tool' ? await discovery.invoke(dispatch, args) + : definition.name === 'mainsequence_plugin' ? await plugin.call(args, signal) : ['mainsequence_workspace','mainsequence_action','mainsequence_operations','mainsequence_settings'].includes(definition.name) ? await workspace.call(definition.name,args,dispatch,signal,conversationId) : await messaging.call(definition.name, args, dispatch, conversationId, signal); // Connection and account changes re-register the project tools; a fresh catalog read updates them in place. diff --git a/tests/panel-server.test.cjs b/tests/panel-server.test.cjs index d0bf0a9..afb7cae 100644 --- a/tests/panel-server.test.cjs +++ b/tests/panel-server.test.cjs @@ -37,7 +37,7 @@ test('detached packaged MCP server: tools, immutable UI, validation, no startup assert.equal(init.result.capabilities.tools.listChanged, true); assert.match(init.result.instructions, /ms_\* tools/); const listed = (await rpc('tools/list')).result.tools; - assert.deepEqual(listed.map(tool => tool.name), ['mainsequence_open', 'mainsequence_discover', 'mainsequence_tools', 'mainsequence_stage_message', 'mainsequence_agent_request', 'mainsequence_agent_status', 'mainsequence_submission', 'mainsequence_view', 'mainsequence_connection', 'mainsequence_call_tool','mainsequence_agent_task','mainsequence_workspace','mainsequence_action','mainsequence_operations','mainsequence_settings','mainsequence_agent_prepare', registered]); + assert.deepEqual(listed.map(tool => tool.name), ['mainsequence_open', 'mainsequence_discover', 'mainsequence_tools', 'mainsequence_stage_message', 'mainsequence_agent_request', 'mainsequence_agent_status', 'mainsequence_submission', 'mainsequence_view', 'mainsequence_connection', 'mainsequence_call_tool','mainsequence_agent_task','mainsequence_workspace','mainsequence_action','mainsequence_operations','mainsequence_settings','mainsequence_agent_prepare','mainsequence_plugin', registered]); assert.ok(listed.slice(0, 3).every(tool => tool.annotations.readOnlyHint)); assert.equal(listed.some(tool => tool.name === foreign), false, 'another backend\'s cached tools never appear in this server\'s list'); // The saved project's tool is a first-class tool with the remote schema, as in Claude Desktop. @@ -79,6 +79,8 @@ test('detached packaged MCP server: tools, immutable UI, validation, no startup assert.equal(missingId.structuredContent.error.code, 'INVALID_REQUEST'); assert.equal(listed[5].annotations.readOnlyHint, true); assert.deepEqual(listed[6]._meta.ui.visibility, ['app']); + // Only the person updates the plugin, from the panel; the model never sees the tool. + assert.deepEqual(listed.find(tool => tool.name === 'mainsequence_plugin')._meta.ui.visibility, ['app']); assert.equal(stderr, ''); assert.equal(fs.existsSync(path.join(installed, 'node_modules')), false); // Reproduce Codex's update cleanup while the old server still serves a chat. diff --git a/tests/panel-ui.test.cjs b/tests/panel-ui.test.cjs index c8b20af..ef367c1 100644 --- a/tests/panel-ui.test.cjs +++ b/tests/panel-ui.test.cjs @@ -39,7 +39,7 @@ test('theme changes, safe text, explicit environment selection, read-only bridge assert.equal(ui.el('account').textContent, 'Analyst'); assert.equal(ui.el('account').attributes['data-status'],'connected'); assert.equal(ui.el('account').attributes.title,'Connected'); - assert.equal(ui.requests.filter(r => r.method === 'tools/call').length, 1, 'No environment is guessed when several exist'); + assert.equal(ui.requests.filter(r => r.method === 'tools/call' && r.params.name !== 'mainsequence_plugin').length, 1, 'No environment is guessed when several exist'); ui.el('environment').value = 'env-a'; await ui.el('environment').onchange(); await button(ui,'results','Analyst').onclick(); assert.match(flatten(ui.el('results')), /r.params?.name==='mainsequence_discover').length,1); assert.equal(ui.requests.some(r=>r.params?.arguments?.view==='assets'),false); }); + +const settle = async () => { for (let i = 0; i < 5; i++) await new Promise(resolve => setImmediate(resolve)); }; +function pluginPanel(version, update = () => ({ running: 'test', previous: '0.7.11', installed: '0.7.12', status: 'restart_required' })) { + const calls = []; + return panel(async (name, args) => { + if (name === 'mainsequence_open') return status; + if (name === 'mainsequence_plugin') { calls.push(JSON.parse(JSON.stringify(args))); return args.action === 'update' ? update() : { running: 'test', installed: '0.7.11', updateSupported: true, ...version(args) }; } + return page('Analyst'); + }).then(async ui => { await settle(); return Object.assign(ui, { pluginCalls: calls }); }); +} + +test('the header names the running version and installs a published update only after confirmation', async () => { + const ui = await pluginPanel(() => ({ latest: '0.7.12', status: 'update_available' })); + assert.equal(ui.el('plugin-version').textContent, 'vtest'); + assert.deepEqual(ui.pluginCalls, [{ action: 'version' }]); + await button(ui, 'plugin-release', 'Update to 0.7.12').onclick(); + assert.equal(ui.pluginCalls.length, 1, 'updating waits for confirmation'); + assert.match(flatten(ui.el('confirmation')), /installs MainSequence 0\.7\.12\. This chat keeps test until you start a new chat/); + await button(ui, 'confirmation', 'Confirm').onclick(); + assert.deepEqual(ui.pluginCalls.at(-1), { action: 'update' }); + assert.match(flatten(ui.el('plugin-release')), /0\.7\.12 installed · start a new chat/); + assert.equal(button(ui, 'plugin-release', 'Update to 0.7.12'), undefined); + assert.equal(ui.el('plugin-help').hidden, true); +}); + +test("without Codex's plugin commands, or when they fail, the header shows the terminal steps", async () => { + const unsupported = await pluginPanel(() => ({ installed: null, latest: '0.7.12', status: 'update_available', updateSupported: false })); + await button(unsupported, 'plugin-release', 'Update to 0.7.12').onclick(); + assert.equal(unsupported.pluginCalls.some(args => args.action === 'update'), false); + assert.equal(unsupported.el('plugin-help').hidden, false); + assert.match(flatten(unsupported.el('plugin-help')), /codex plugin marketplace upgrade mainsequence\ncodex plugin add mainsequence@mainsequence/); + await button(unsupported, 'plugin-help', 'Dismiss').onclick(); + assert.equal(unsupported.el('plugin-help').hidden, true); + const failed = await pluginPanel(() => ({ latest: '0.7.12', status: 'update_available' }), () => { throw Object.assign(new Error('Codex could not install the MainSequence update.'), { code: 'PROCESS_FAILED' }); }); + await button(failed, 'plugin-release', 'Update to 0.7.12').onclick(); await button(failed, 'confirmation', 'Confirm').onclick(); + assert.match(flatten(failed.el('plugin-help')), /Codex could not install the MainSequence update\..*codex plugin add mainsequence@mainsequence/s); + assert.ok(button(failed, 'plugin-release', 'Update to 0.7.12'), 'the published update is still offered'); +}); + +test('an unreachable catalog keeps the version with a manual check that reads again', async () => { + let reachable = false; + const ui = await pluginPanel(() => reachable ? { latest: 'test', status: 'current' } : { latest: null, status: 'unknown' }); + assert.equal(ui.el('plugin-version').textContent, 'vtest'); + reachable = true; await button(ui, 'plugin-release', 'Check for updates').onclick(); + assert.deepEqual(ui.pluginCalls.at(-1), { action: 'version', recheck: true }); + assert.match(flatten(ui.el('plugin-release')), /● Up to date/); +}); diff --git a/tests/plugin-update.test.cjs b/tests/plugin-update.test.cjs new file mode 100644 index 0000000..4785a85 --- /dev/null +++ b/tests/plugin-update.test.cjs @@ -0,0 +1,85 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs/promises'); +const path = require('node:path'); +const os = require('node:os'); +const { CODEX_CATALOG_URL, compareVersions, updateStatus, createPluginUpdate } = require('../build/hosts/pluginUpdate.js'); + +const signal = new AbortController().signal; +const catalog = version => ({ name: 'mainsequence', plugins: [{ name: 'mainsequence', source: { source: 'npm', package: '@dev-mainsequence/codex-plugin', version } }] }); +const listed = version => ({ installed: version ? [{ pluginId: 'mainsequence@mainsequence', name: 'mainsequence', marketplaceName: 'mainsequence', version, enabled: true }] + : [{ pluginId: 'mainsequence@mainsequence-local', name: 'mainsequence', marketplaceName: 'mainsequence-local', version: '0.7.11', enabled: true }], available: [] }); +async function executable(t, name = 'codex') { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-cli-')); + t.after(() => fs.rm(dir, { recursive: true, force: true })); + const file = path.join(dir, name); await fs.writeFile(file, ''); return file; +} +/** A fake Codex: `plugin list --json` answers from `installed`, and every command is recorded. */ +function harness(parent, { installed = '0.7.11', afterAdd, latest = '0.7.12', exit = () => 0 } = {}) { + const runs = [], fetched = []; + let clock = 0, current = installed; + const host = { openBrowser: async () => false, runProcess: async spec => { + runs.push(spec); + const code = exit(spec.args); + if (spec.args[1] === 'add' && code === 0 && afterAdd) current = afterAdd; + return { exitCode: code, stdout: spec.args[1] === 'list' ? JSON.stringify(listed(current)) : '', stderr: '' }; + } }; + const fetch = async url => { fetched.push(url); return latest instanceof Error ? Promise.reject(latest) : { ok: true, text: async () => JSON.stringify(catalog(latest)) }; }; + return { runs, fetched, advance: ms => { clock += ms; }, plugin: createPluginUpdate({ version: '0.7.11', host, fetch, now: () => clock, parentExecutable: async () => parent }) }; +} + +test('versions order by release, with a prerelease before its release', () => { + assert.equal(compareVersions('0.7.12', '0.7.11'), 1); + assert.equal(compareVersions('0.7.9', '0.7.10'), -1); + assert.equal(compareVersions('0.7.12-rc.1', '0.7.12'), -1); + assert.equal(updateStatus('0.7.11', '0.7.12', '0.7.12'), 'restart_required'); + assert.equal(updateStatus('0.7.11', '0.7.11', null), 'unknown'); +}); + +test("the version check reads Codex's installed copy and the published catalog, and one check serves a while", async t => { + const h = harness(await executable(t)); + const data = await h.plugin.call({ action: 'version' }, signal); + assert.deepEqual(data, { running: '0.7.11', installed: '0.7.11', latest: '0.7.12', status: 'update_available', updateSupported: true }); + assert.deepEqual(h.runs.map(run => run.args.join(' ')), ['plugin list --json']); assert.deepEqual(h.fetched, [CODEX_CATALOG_URL]); + await h.plugin.call({ action: 'version' }, signal); + assert.equal(h.fetched.length, 1, 'a panel reopened soon after reuses the check'); + await h.plugin.call({ action: 'version', recheck: true }, signal); + h.advance(31 * 60_000); await h.plugin.call({ action: 'version' }, signal); + assert.equal(h.fetched.length, 3); +}); + +test('a dev install, a missing Codex or an unreachable catalog still reports the running version', async t => { + const local = harness(await executable(t), { installed: null }); + const data = await local.plugin.call({ action: 'version' }, signal); + assert.deepEqual([data.status, data.installed, data.updateSupported], ['update_available', null, false]); + await assert.rejects(local.plugin.call({ action: 'update' }, signal), { code: 'UNSUPPORTED_ACTION' }); + assert.equal(local.runs.some(run => run.args[1] !== 'list'), false); + for (const parent of [await executable(t, 'node'), 'relative/codex', path.join(os.tmpdir(), 'missing-codex', 'codex')]) { + const h = harness(parent); + assert.equal((await h.plugin.call({ action: 'version' }, signal)).updateSupported, false, parent); + assert.equal(h.runs.length, 0, 'only the Codex that started the server is ever run'); + } + const offline = harness(await executable(t), { latest: new Error('offline') }); + const answer = await offline.plugin.call({ action: 'version' }, signal); + assert.equal(answer.status, 'unknown'); assert.match(answer.catalogError, /could not be reached/); + await offline.plugin.call({ action: 'version' }, signal); + assert.equal(offline.fetched.length, 2, 'a failed check is never reused'); +}); + +test("updating refreshes the marketplace, then reinstalls with Codex's own commands", async t => { + const h = harness(await executable(t), { afterAdd: '0.7.12' }); + await h.plugin.call({ action: 'version' }, signal); + const data = await h.plugin.call({ action: 'update' }, signal); + assert.deepEqual(data, { running: '0.7.11', previous: '0.7.11', installed: '0.7.12', status: 'restart_required' }); + assert.deepEqual(h.runs.map(run => run.args.join(' ')), ['plugin list --json', 'plugin list --json', 'plugin marketplace upgrade mainsequence', 'plugin add mainsequence@mainsequence', 'plugin list --json']); + assert.equal((await h.plugin.call({ action: 'version' }, signal)).status, 'restart_required', 'the update invalidates the cached check'); +}); + +test('a failed refresh or install is reported and nothing else runs', async t => { + const refresh = harness(await executable(t), { exit: args => args[1] === 'marketplace' ? 1 : 0 }); + await assert.rejects(refresh.plugin.call({ action: 'update' }, signal), { code: 'PROCESS_FAILED', message: /could not refresh the mainsequence marketplace/ }); + assert.equal(refresh.runs.some(run => run.args[1] === 'add'), false); + const install = harness(await executable(t), { exit: args => args[1] === 'add' ? 1 : 0 }); + await assert.rejects(install.plugin.call({ action: 'update' }, signal), { code: 'PROCESS_FAILED', message: 'Codex could not install the MainSequence update.' }); + await assert.rejects(install.plugin.call({ action: 'remove' }, signal), { code: 'INVALID_REQUEST' }); +});