From 0a29c2fd7f829c9f156c8434a7a2921b0bf48149 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 19:52:07 +0100 Subject: [PATCH 1/2] fix(prime-agent): re-publish the indicator after prime-agent clears extension widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified with a probe extension rather than by reading the bundle: session_start fires with hasUI=true, ctx.ui.setWidget exists and the call returns without throwing — and the widget still never appeared. Cause is prime-agent wiping it immediately afterwards. resetExtensionUI() -> clearExtensionWidgets() runs from onBeforeSessionInvalidate and from the connection-state-snapshot handler, both of which land after session_start, so the indicator was set and cleared before it could be seen. Nothing re-set it until a turn, which is why a fresh session with no messages showed nothing. Re-publishes at 1s/3s/6s after session_start to land past that reset. setWidget is idempotent, so a redundant retry costs one re-render. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/prime-agent-extension.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index 26c9ce7..bbeb9e6 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -7,4 +7,4 @@ * by the CLI is always the one that runs. */ export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; -export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: {\n setStatus(key: string, text: string | undefined): void;\n setWidget(key: string, content: string[] | undefined, options?: { placement?: 'aboveEditor' | 'belowEditor' }): void;\n };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Uses a *widget*, not just `setStatus`. prime-agent stores extension statuses\n * (`FooterDataProvider.setExtensionStatus`) but its footer never reads them\n * back — there is no `getExtensionStatuses()` call site in the app — so\n * `setStatus` alone renders nothing there. Widgets are rendered\n * (`extensionWidgetsAbove/Below` → `renderWidgets()`). `setStatus` is still\n * called because pi's footer *does* render statuses, and the same extension\n * source is installed for both.\n */\nlet lastPublished: string | undefined;\n\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n const label = active !== null ? `mcpctl: ${active}` : 'mcpctl: no project';\n if (label === lastPublished) return; // cheap enough to call on every turn\n lastPublished = label;\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n try {\n ctx.ui.setWidget(STATUS_KEY, [label], { placement: 'belowEditor' });\n } catch {\n // Host without widget support — the status call above is the fallback.\n }\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: {\n setStatus(key: string, text: string | undefined): void;\n setWidget(key: string, content: string[] | undefined, options?: { placement?: 'aboveEditor' | 'belowEditor' }): void;\n };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Uses a *widget*, not just `setStatus`. prime-agent stores extension statuses\n * (`FooterDataProvider.setExtensionStatus`) but its footer never reads them\n * back — there is no `getExtensionStatuses()` call site in the app — so\n * `setStatus` alone renders nothing there. Widgets are rendered\n * (`extensionWidgetsAbove/Below` → `renderWidgets()`). `setStatus` is still\n * called because pi's footer *does* render statuses, and the same extension\n * source is installed for both.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n const label = active !== null ? `mcpctl: ${active}` : 'mcpctl: no project';\n // Deliberately NOT skipped when the label is unchanged. prime-agent's\n // renderWidgets() bails out early while the TUI containers do not exist yet\n // if (!this.widgetContainerAbove || !this.widgetContainerBelow) return;\n // and session_start fires before they are built, so that first call stores\n // the widget without ever drawing it. Only a later setWidget call re-renders,\n // so caching the label here means the indicator never appears at all.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n try {\n ctx.ui.setWidget(STATUS_KEY, [label], { placement: 'belowEditor' });\n } catch {\n // Host without widget support — the status call above is the fallback.\n }\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension widgets shortly after startup:\n // resetExtensionUI() -> clearExtensionWidgets() runs from\n // onBeforeSessionInvalidate and from the connection-state-snapshot\n // handler, both of which land *after* session_start. The indicator set\n // above is therefore cleared before it is ever seen. Re-publish a couple\n // of times to land after that reset; setWidget is idempotent, so the only\n // cost of an unnecessary retry is one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; From 90c49bcb22080c8fac092ce591820336c960ead1 Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 8 Aug 2026 19:59:24 +0100 Subject: [PATCH 2/2] refactor(prime-agent): drop the widget fallback, keep the tray status With prime-agent-extension-status.patch in place the tray renders ctx.ui.setStatus() next to the model name, which is what a status line should be. The widget was a workaround for its absence and was never a substitute: widgetContainerBelow sits in the fullscreen *scroll* list, not the dock, so it scrolled away with the transcript, and with both set the project name appeared twice. The startup retries stay: resetExtensionUI() clears extension statuses just as it cleared widgets, so the value set during session_start is still wiped before it can be seen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB --- src/cli/src/config/prime-agent-extension.ts | 2 +- src/cli/tests/commands/prime-agent.test.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/cli/src/config/prime-agent-extension.ts b/src/cli/src/config/prime-agent-extension.ts index bbeb9e6..39f7966 100644 --- a/src/cli/src/config/prime-agent-extension.ts +++ b/src/cli/src/config/prime-agent-extension.ts @@ -7,4 +7,4 @@ * by the CLI is always the one that runs. */ export const MCPCTL_SWITCH_EXTENSION_FILENAME = 'mcpctl-switch.ts'; -export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: {\n setStatus(key: string, text: string | undefined): void;\n setWidget(key: string, content: string[] | undefined, options?: { placement?: 'aboveEditor' | 'belowEditor' }): void;\n };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Uses a *widget*, not just `setStatus`. prime-agent stores extension statuses\n * (`FooterDataProvider.setExtensionStatus`) but its footer never reads them\n * back — there is no `getExtensionStatuses()` call site in the app — so\n * `setStatus` alone renders nothing there. Widgets are rendered\n * (`extensionWidgetsAbove/Below` → `renderWidgets()`). `setStatus` is still\n * called because pi's footer *does* render statuses, and the same extension\n * source is installed for both.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n const label = active !== null ? `mcpctl: ${active}` : 'mcpctl: no project';\n // Deliberately NOT skipped when the label is unchanged. prime-agent's\n // renderWidgets() bails out early while the TUI containers do not exist yet\n // if (!this.widgetContainerAbove || !this.widgetContainerBelow) return;\n // and session_start fires before they are built, so that first call stores\n // the widget without ever drawing it. Only a later setWidget call re-renders,\n // so caching the label here means the indicator never appears at all.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n try {\n ctx.ui.setWidget(STATUS_KEY, [label], { placement: 'belowEditor' });\n } catch {\n // Host without widget support — the status call above is the fallback.\n }\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension widgets shortly after startup:\n // resetExtensionUI() -> clearExtensionWidgets() runs from\n // onBeforeSessionInvalidate and from the connection-state-snapshot\n // handler, both of which land *after* session_start. The indicator set\n // above is therefore cleared before it is ever seen. Re-publish a couple\n // of times to land after that reset; setWidget is idempotent, so the only\n // cost of an unnecessary retry is one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; +export const MCPCTL_SWITCH_EXTENSION = "/**\n * Installed by `mcpctl config prime-agent` into ~/.prime/agent/extensions/.\n * Adds a `/mcpctl` slash command to switch the active mcpctl project (proxy\n * MCP + skills) from inside prime-agent, then reloads the session.\n *\n * It shells out to the `mcpctl` CLI (same binary that wrote the config) to\n * list projects and apply the switch, then asks the running TUI to reload so\n * the new project's MCP servers, credentials and skills take effect without an\n * app restart. Keeping the logic in the CLI means this UI shell stays in\n * lock-step with the machinery in the mcpctl repo.\n */\nimport { exec } from 'node:child_process';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\n\nconst AGENT_DIR = join(homedir(), '.prime', 'agent');\n\ninterface ProjectInfo {\n name: string;\n description?: string;\n}\n\nfunction mcpctl(...args: string[]): Promise {\n const quoted = args.map((a) => `'${String(a).replace(/'/g, \"'\\\\''\")}'`).join(' ');\n return new Promise((resolve, reject) => {\n exec(`mcpctl ${quoted}`, { timeout: 90_000, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {\n if (err) reject(new Error((stderr || String(err)).trim() || String(err)));\n else resolve(stdout || '');\n });\n });\n}\n\nasync function listProjects(): Promise {\n const out = await mcpctl('get', 'projects', '-o', 'json');\n const parsed = JSON.parse(out || '[]') as Array<{ name?: string; description?: string }>;\n return parsed.filter((p) => p && typeof p.name === 'string').map((p) => ({\n name: p.name as string,\n description: p.description,\n }));\n}\n\n/** Projects auth.json holds an mcpctl PAT for (`mcp:`). */\nasync function credentialedProjects(): Promise> {\n const out = new Set();\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'auth.json'), 'utf-8');\n const parsed = JSON.parse(raw) as Record;\n for (const [k, v] of Object.entries(parsed)) {\n if (!k.startsWith('mcp:')) continue;\n const key = v?.key;\n if (typeof key === 'string' && key.startsWith('mcpctl_pat_')) out.add(k.slice(4));\n }\n } catch {\n // no auth.json (or unreadable) — nothing to adopt\n }\n return out;\n}\n\n/**\n * The single *active* mcpctl project. Entries this CLI wrote carry an\n * `mcpctlManaged: true` tag; entries written by an older CLI do not, so an\n * untagged entry also counts when its URL is the canonical\n * `/projects//mcp` proxy URL *and* auth.json holds an `mcp:` mcpctl\n * PAT. A hand-configured server has no such credential and is never mistaken\n * for the active project.\n */\nasync function activeProject(): Promise {\n try {\n const { readFile } = await import('node:fs/promises');\n const raw = await readFile(join(AGENT_DIR, 'settings.json'), 'utf-8');\n const settings = JSON.parse(raw) as { mcpServers?: Record> };\n if (!settings.mcpServers) return null;\n const names = Object.keys(settings.mcpServers);\n for (const name of names) {\n const entry = settings.mcpServers[name];\n if (entry && typeof entry === 'object' && entry['mcpctlManaged'] === true) return name;\n }\n const credentialed = await credentialedProjects();\n for (const name of names) {\n const entry = settings.mcpServers[name];\n const url = entry && typeof entry === 'object' ? entry['url'] : undefined;\n if (typeof url !== 'string' || !credentialed.has(name)) continue;\n if (url.replace(/\\/+$/, '').endsWith(`/projects/${encodeURIComponent(name)}/mcp`)) return name;\n }\n return null;\n } catch {\n return null;\n }\n}\n\n/** Key our indicator is stored under (both the widget and the footer status). */\nconst STATUS_KEY = 'mcpctl';\n\ninterface StatusCapableContext {\n hasUI?: boolean;\n ui: { setStatus(key: string, text: string | undefined): void };\n}\n\n/**\n * Show the active project in the UI, so it is visible at a glance instead of\n * something you run a command to discover.\n *\n * Published via `setStatus`, which both hosts render next to the model name:\n * pi in its footer, prime-agent in the tray line built by\n * `getTrayLocationLabel()`.\n *\n * NOTE: prime-agent only grew that rendering in\n * `prime-agent-extension-status.patch` (upstream PR pending) — before it,\n * `FooterDataProvider.getExtensionStatuses()` had no call site at all and this\n * call silently did nothing. An unpatched build shows no indicator; a widget\n * would render there but scrolls away with the transcript, so it is not a\n * substitute for a status line.\n */\nasync function publishStatus(ctx: StatusCapableContext): Promise {\n // Before the TUI binds its UI context the runtime hands extensions a no-op\n // one, where every setter silently discards. Publishing then would cache a\n // label that never rendered.\n if (ctx.hasUI === false) return;\n let active: string | null = null;\n try {\n active = await activeProject();\n } catch {\n active = null;\n }\n // Deliberately not skipped when the value is unchanged: prime-agent clears\n // extension statuses on reset (see the retries in session_start), so a cached\n // \"nothing changed\" short-circuit would leave the indicator permanently blank.\n ctx.ui.setStatus(STATUS_KEY, active !== null ? `mcpctl:${active}` : undefined);\n}\n\n/** Above this many projects, offer a filter before opening the list. */\nconst FILTER_THRESHOLD = 20;\n\n/**\n * Order and filter the project list for the picker.\n *\n * Active project first (most likely pick), then alphabetical. Terms are\n * space-separated and ALL must match as case-insensitive substrings against\n * the name or description, so `home auto` finds `homeautomation`. A blank\n * query keeps everything.\n */\nexport function filterProjects(projects: ProjectInfo[], query: string, active: string | null): ProjectInfo[] {\n const ordered = [...projects].sort((a, b) => {\n if (a.name === active) return -1;\n if (b.name === active) return 1;\n return a.name.localeCompare(b.name);\n });\n const terms = query.toLowerCase().split(/\\s+/).filter((t) => t.length > 0);\n if (terms.length === 0) return ordered;\n return ordered.filter((p) => {\n const haystack = `${p.name} ${p.description ?? ''}`.toLowerCase();\n return terms.every((t) => haystack.includes(t));\n });\n}\n\n/**\n * Choose a project, asking for a filter first when the list is long.\n *\n * The host's selector is a plain arrow-key list with no search, so filtering\n * has to happen before the list is handed over. Real installs run to hundreds\n * of projects (smoke-test leftovers included), where scrolling is hopeless.\n */\nasync function pickProject(\n ctx: { ui: { select(title: string, options: string[]): Promise; input(title: string, placeholder?: string): Promise; notify(msg: string, type?: 'info' | 'warning' | 'error'): void } },\n projects: ProjectInfo[],\n active: string | null,\n): Promise {\n let candidates = filterProjects(projects, '', active);\n if (candidates.length > FILTER_THRESHOLD) {\n const query = await ctx.ui.input(\n `Filter ${String(candidates.length)} projects (blank = all, Esc = cancel)`,\n 'e.g. home auto',\n );\n if (query === undefined) return undefined; // cancelled\n candidates = filterProjects(projects, query, active);\n if (candidates.length === 0) {\n ctx.ui.notify(`No project matches '${query}'`, 'warning');\n return undefined;\n }\n }\n\n // No client-side cap: prime-agent's selector windows long lists itself and\n // shows a true \"(20/356)\" counter, so truncating here would only replace an\n // accurate total with a misleading one.\n const items = candidates.map((p) => (p.description ? `${p.name} — ${p.description}` : p.name));\n const picked = await ctx.ui.select(\n (active !== null ? `Switch mcpctl project (current: ${active})` : 'Switch mcpctl project')\n + ` (${String(candidates.length)})`,\n items,\n );\n if (picked === undefined) return undefined;\n return picked.split(' — ')[0]?.trim();\n}\n\nexport default function mcpctlSwitch(pi: import('@earendil-works/pi-coding-agent').ExtensionAPI) {\n // prime-agent emits `session_start` ONLY from reload() — never at startup —\n // so this alone would leave the indicator blank until the first switch.\n // `turn_start` fires on every user turn with a real UI context bound, which\n // is the earliest reliable moment; publishStatus is a no-op when the label\n // has not changed, so calling it per turn costs nothing.\n pi.on('session_start', async (_event, ctx) => {\n await publishStatus(ctx);\n // prime-agent wipes extension state shortly after startup:\n // resetExtensionUI() calls clearExtensionStatuses() (and\n // clearExtensionWidgets()) from onBeforeSessionInvalidate and from the\n // connection-state-snapshot handler, both of which land *after*\n // session_start. The indicator set above is therefore cleared before it is\n // ever seen. Re-publish a few times to land after that reset; setStatus is\n // idempotent, so an unnecessary retry costs one re-render.\n for (const delay of [1_000, 3_000, 6_000]) {\n setTimeout(() => { void publishStatus(ctx); }, delay);\n }\n });\n pi.on('turn_start', async (_event, ctx) => {\n await publishStatus(ctx);\n });\n\n pi.registerCommand('mcpctl', {\n description: 'Switch the active mcpctl project (proxy MCP + skills) and reload',\n handler: async (_args, ctx) => {\n if (!ctx.hasUI) {\n ctx.ui.notify('/mcpctl needs an interactive session', 'error');\n return;\n }\n // Running the command is itself proof of a real UI, and the \"already on\n // X\" path below returns without reloading — so publish here too.\n await publishStatus(ctx);\n let projects: ProjectInfo[];\n try {\n projects = await listProjects();\n } catch (err) {\n ctx.ui.notify(`mcpctl: could not list projects — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n if (projects.length === 0) {\n ctx.ui.notify('mcpctl: no projects found (is mcpctl logged in?)', 'info');\n return;\n }\n\n const active = await activeProject();\n const picked = await pickProject(ctx, projects, active);\n if (!picked) return;\n\n const name = picked;\n if (name === active) {\n ctx.ui.notify(`Already on mcpctl project '${name}'`, 'info');\n return;\n }\n\n ctx.ui.notify(`Switching mcpctl project to '${name}'…`, 'info');\n try {\n // Mint the project token (if needed), write settings.json + auth.json,\n // and sync skills. --skip-extension stops re-installing this very file;\n // --skip-marker stops us writing a .mcpctl-project into whatever\n // directory prime-agent was launched from, which would silently\n // re-scope that repo for Claude Code's own skills sync.\n await mcpctl('config', 'prime-agent', '--project', name, '--skip-extension', '--skip-marker');\n } catch (err) {\n ctx.ui.notify(`mcpctl: switch to '${name}' failed — ${err instanceof Error ? err.message : String(err)}`, 'error');\n return;\n }\n\n // reload() re-reads settings.json, re-reads auth.json and rebuilds the MCP\n // integration map from scratch, so the old project's gateway is dropped\n // and the new one mounted without restarting the app.\n await ctx.reload();\n // reload re-emits session_start, which refreshes the footer — but this\n // command's context outlives that, so set it here too rather than relying\n // on ordering.\n await publishStatus(ctx);\n ctx.ui.notify(`Switched to mcpctl project '${name}'.`, 'info');\n },\n });\n}\n"; diff --git a/src/cli/tests/commands/prime-agent.test.ts b/src/cli/tests/commands/prime-agent.test.ts index 1969e24..4507a83 100644 --- a/src/cli/tests/commands/prime-agent.test.ts +++ b/src/cli/tests/commands/prime-agent.test.ts @@ -537,12 +537,12 @@ describe('config prime-agent', () => { await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' }); const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8'); - // prime-agent stores extension statuses but never renders them, so a - // setStatus-only indicator is invisible there. Widgets are rendered. - expect(ext).toContain('ctx.ui.setWidget(STATUS_KEY'); - expect(ext).toContain("placement: 'belowEditor'"); - // Still set the status: pi's footer does render it. + // Both hosts render statuses next to the model name (pi in its footer, + // prime-agent in the tray — the latter via prime-agent-extension-status.patch). expect(ext).toContain('ctx.ui.setStatus(STATUS_KEY'); + // The widget was a workaround for the unpatched tray; it scrolled away with + // the transcript, so it is not a status line and must not come back. + expect(ext).not.toContain('setWidget'); // prime-agent emits session_start only from reload(), never at startup, so // a session_start-only indicator stays blank until the first switch. expect(ext).toContain("pi.on('turn_start'");