diff --git a/.claude/agent-memory/rust-sidecar-engineer/MEMORY.md b/.claude/agent-memory/rust-sidecar-engineer/MEMORY.md new file mode 100644 index 00000000000..ac213f45fac --- /dev/null +++ b/.claude/agent-memory/rust-sidecar-engineer/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [Build exe-lock workaround](build_exe_lock_workaround.md) — rename the running sidecar exe (don't kill Electron) so `cargo build --release` can write the binary while `yarn start` holds it diff --git a/.claude/agent-memory/rust-sidecar-engineer/build_exe_lock_workaround.md b/.claude/agent-memory/rust-sidecar-engineer/build_exe_lock_workaround.md new file mode 100644 index 00000000000..c5a8448a0a2 --- /dev/null +++ b/.claude/agent-memory/rust-sidecar-engineer/build_exe_lock_workaround.md @@ -0,0 +1,13 @@ +--- +name: build-exe-lock-workaround +description: How to complete `cargo build --release` when a live `yarn start` Electron holds the sidecar exe +metadata: + type: project +--- + +`cargo build --release --manifest-path sidecar/Cargo.toml` compiles fine but fails at the final link/copy step with `error: failed to remove file ... hyperia-sidecar.exe / Access is denied (os error 5)` when a Hyperia dev instance is running. The Electron dev process (`yarn start`) auto-respawns the sidecar, so `Stop-Process` on the sidecar alone loses the race — a new PID re-locks the exe within a second. + +**Why:** the output binary IS the running process image; Windows won't let cargo delete-in-place a file backing a live process, and the bridge respawns it. + +**How to apply:** Windows allows *renaming* a running exe even while locked. Before building, rename it out of the way so cargo writes a fresh one; the live process keeps using the renamed file: +`Rename-Item sidecar/target/release/hyperia-sidecar.exe hyperia-sidecar.exe.old` then build. Clean up the `.old` afterward (may still be locked until the old process exits — best-effort `rm -f`). Do NOT kill the whole Electron/`yarn start` session to build — it's the human's live workspace. This matches the user's auto-memory note about the sidecar port-9800 clash / closing the installed copy, but the rename trick avoids disrupting the dev session entirely. diff --git a/app/bridge.ts b/app/bridge.ts index 632aa58ac2c..bdbfafc8153 100644 --- a/app/bridge.ts +++ b/app/bridge.ts @@ -110,6 +110,14 @@ function connect() { for (const [uid, tracked] of trackedSessions) { sendSessionRegister(uid, tracked); } + // Replay window bounds now that the socket is live. The per-window + // create/focus/resize sends can fire before the sidecar WS connects + // (connect happens last at launch), so send() no-ops silently — this + // guarantees the sidecar learns every window's pixel size on connect. + try { + const winList: BrowserWindow[] = Array.from((app as any).getWindows?.() || []); + for (const w of winList) updateWindowBounds(w); + } catch { /* best-effort */ } startHeartbeat(); }); @@ -398,12 +406,21 @@ function handleCommand(msg: Record) { shell: p.config?.shell as string | undefined, isDefault: p.name === (getConfig() as any).defaultProfile })); + // Per-window OS pixel size so the agent can answer "how big is the window" + // and resize relative to it (terminal_set_window_size). + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const winList: BrowserWindow[] = Array.from((app as any).getWindows?.() || []); + const windowSizes = winList.map((w) => { + const b = w.getBounds(); + return {id: w.id, width: b.width, height: b.height, x: b.x, y: b.y, focused: w.isFocused()}; + }); sendResult( seq, JSON.stringify({ panes, platform: process.platform, - profiles + profiles, + windowSizes }) ); break; @@ -1159,6 +1176,15 @@ export function updateWindowFocus(windowId: number) { send({type: 'WindowFocus', windowId}); } +/** Report a window's OS pixel bounds to the sidecar so terminal_status can + * answer "how big is the window" and resize relative to it. Sent on window + * create + resize/move. */ +export function updateWindowBounds(win: BrowserWindow) { + if (!win || win.isDestroyed()) return; + const b = win.getBounds(); + send({type: 'WindowBounds', windowId: win.id, width: b.width, height: b.height, x: b.x, y: b.y}); +} + /** Update the description for a session. */ export function updateSessionDescription(uid: string, description: string) { let tracked = trackedSessions.get(uid); diff --git a/app/index.ts b/app/index.ts index f6f6145d111..cc8d2d8083e 100644 --- a/app/index.ts +++ b/app/index.ts @@ -469,10 +469,16 @@ async function installDevExtensions(isDev_: boolean) { ); } -// Single instance lock — prevent duplicate tray icons and sidecar spawns +// Single instance lock — prevent duplicate tray icons and sidecar spawns. +// HARD exit for the loser: app.quit() is async and the losing instance kept +// EXECUTING its init — including spawnSidecar's taskkill of the WINNER's +// sidecar — before actually quitting. That mutual sidecar-squash is what +// made duplicate launches flash/restart/rotate panes. app.exit() is +// synchronous and runs nothing further. const gotLock = app.requestSingleInstanceLock(); if (!gotLock) { - app.quit(); + app.exit(0); + process.exit(0); // belt-and-suspenders: nothing below may run in the loser } app.on('second-instance', () => { // eslint-disable-next-line @typescript-eslint/no-unsafe-call diff --git a/app/package.json b/app/package.json index c73fe3cc204..fba1efbda0d 100644 --- a/app/package.json +++ b/app/package.json @@ -2,7 +2,7 @@ "name": "hyperia", "productName": "Hyperia", "description": "A modern agentic terminal.", - "version": "0.15.11", + "version": "0.15.15", "license": "BSD-2-Clause", "author": { "name": "Deep Blue Dynamics", diff --git a/app/sticky.ts b/app/sticky.ts index c3b29c3ce12..9f9062d047b 100644 --- a/app/sticky.ts +++ b/app/sticky.ts @@ -717,13 +717,12 @@ function createStickyNote( savedNote.source.path = translateContainerPath(savedNote.source.path); } - // Resolve default size (explicit > persisted manual size > default). A brand-new - // sticky opens at 25% of the default size (quick small notes); explicit sizes - // and restored notes keep their exact dimensions. - const NEW_STICKY_SCALE = 0.25; + // Resolve default size (explicit > persisted manual size > default). New + // stickys open at the user's default size — the old 25% "quick note" scale + // made them ridiculously small (~117×104) and unusable. const defaultSize = getStickyDefaultSize(); - let width = options.width || savedNote?.width || Math.round(defaultSize.width * NEW_STICKY_SCALE); - let height = options.height || savedNote?.height || Math.round(defaultSize.height * NEW_STICKY_SCALE); + let width = options.width || savedNote?.width || defaultSize.width; + let height = options.height || savedNote?.height || defaultSize.height; // Candidate position: explicit > persisted > centered on the cursor. const hasPlacedPos = options.x != null || options.y != null || savedNote?.x != null || savedNote?.y != null; @@ -738,6 +737,10 @@ function createStickyNote( const wa = targetDisplay.workArea; width = Math.min(width, wa.width); height = Math.min(height, wa.height); + // Hard floor: never open a sticky too small to read/use, whatever the + // source (corrupt persisted geometry, tiny explicit sizes). + width = Math.max(width, 220); + height = Math.max(height, 170); x = Math.max(wa.x, Math.min(x, wa.x + wa.width - width)); y = Math.max(wa.y, Math.min(y, wa.y + wa.height - height)); @@ -862,8 +865,10 @@ function createStickyNote( const bounds = win.getBounds(); const note = getNote(noteId); if (note) { + // Persist THIS note's geometry only. Resizing a note must NOT rewrite + // the global default — one huge note poisoned every future sticky + // ("how many times do I have to ask for a normal sized sticky?"). upsertNote({...note, x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height}); - saveStickyDefaultSize(bounds.width, bounds.height); } }; win.on('moved', saveGeom); diff --git a/app/ui/contextmenu.ts b/app/ui/contextmenu.ts index f265937a970..da922c124c3 100644 --- a/app/ui/contextmenu.ts +++ b/app/ui/contextmenu.ts @@ -51,6 +51,7 @@ const contextMenuTemplate = ( menu.push(separator); menu.push({label: 'New Tab', accelerator: commandKeys['tab:new'], click: cmd('tab:new')}); menu.push({label: 'New Window', click: () => createWindow()}); + menu.push({label: 'Hyperia Agent', click: cmd('pane:openShellPane')}); menu.push(separator); menu.push({label: 'New Stickys', click: () => ipcMain.emit('new-sticky', {})}); diff --git a/app/ui/window.ts b/app/ui/window.ts index 382804db32f..72ca36b60c0 100644 --- a/app/ui/window.ts +++ b/app/ui/window.ts @@ -2,7 +2,7 @@ import {existsSync, readFileSync, writeFileSync} from 'fs'; import {dirname, isAbsolute, join, normalize, sep} from 'path'; import {URL, fileURLToPath} from 'url'; -import {app, BrowserWindow, shell, Menu, dialog, ipcMain, nativeImage} from 'electron'; +import {app, BrowserWindow, shell, Menu, dialog, nativeImage} from 'electron'; import type {BrowserWindowConstructorOptions} from 'electron'; import {enable as remoteEnable} from '@electron/remote/main'; @@ -27,6 +27,7 @@ import { updateSessionLayout, updateSessionActive, updateWindowFocus, + updateWindowBounds, getSessionRootTab, forceRemoveSession, executeSessionCd @@ -51,12 +52,12 @@ import contextMenuTemplate from './contextmenu'; // Tab names are assigned by the renderer and synced via 'session set tab name' RPC. // The main process no longer generates names. -// Web panes spoof a Chrome User-Agent (see BROWSER_UA), but the -// `useragent` attribute only changes the UA *string* — Chromium still emits -// `sec-ch-ua` client hints that carry the "Electron" brand. Cloudflare's managed -// challenge cross-checks the UA against those hints, so a Chrome UA paired with -// Electron client hints loops on "Just a moment…" forever. Rewrite the outgoing -// headers so UA AND client hints both say Google Chrome, consistently. +// Web panes spoof a Chrome User-Agent, but setting the UA *string* alone isn't +// enough — Chromium still emits `sec-ch-ua` client hints that carry the +// "Electron" brand. Cloudflare's managed challenge cross-checks the UA against +// those hints, so a Chrome UA paired with Electron client hints loops on +// "Just a moment…" forever. Rewrite the outgoing headers so UA AND client hints +// both say Google Chrome, consistently. const configuredWebPaneSessions = new WeakSet(); function chromeHeaderSet() { const full = process.versions.chrome || '146.0.0.0'; @@ -91,7 +92,6 @@ function configureWebPaneSession(sess: Electron.Session) { // inside the page. The header rewrite below only covers HTTP; without this a // WebContentsView page sees the raw Electron UA in JS, and the JS-vs-header // mismatch trips login bot detection (LinkedIn boots the session, e.g.). - // (The old got this via its useragent= attribute.) try { sess.setUserAgent(ua); } catch (err) { @@ -160,8 +160,8 @@ export function newWindow( fn?: (win: BrowserWindow) => void, profileName: string = getDefaultProfile() ): BrowserWindow { - // Register the WebContentsView IPC surface once (idempotent guard). Reuses the - // exact same session config the legacy path uses. + // Register the WebContentsView IPC surface once (idempotent guard). Hands it + // configureWebPaneSession so web-pane sessions fingerprint as Chrome. if (!webPaneManagerInited) { webPaneManagerInited = true; initWebPaneManager({configureSession: configureWebPaneSession}); @@ -193,8 +193,7 @@ export function newWindow( webPreferences: { nodeIntegration: true, navigateOnDragDrop: true, - contextIsolation: false, - webviewTag: true + contextIsolation: false }, ...options_ }; @@ -615,10 +614,21 @@ export function newWindow( // Same deal as above, grabbing the window titlebar when the window // is maximized on Windows results in unmaximize, without hitting any // app buttons - const onGeometryChange = () => rpc.emit('windowGeometry change', {isMaximized: window.isMaximized()}); + const onGeometryChange = () => { + rpc.emit('windowGeometry change', {isMaximized: window.isMaximized()}); + updateWindowBounds(window); + }; window.on('maximize', onGeometryChange); window.on('unmaximize', onGeometryChange); window.on('minimize', onGeometryChange); + // Report OS pixel size to the sidecar on resize (debounced) + once at ready, + // so terminal_status carries the live window dimensions. + let _boundsT: ReturnType | null = null; + window.on('resize', () => { + if (_boundsT) clearTimeout(_boundsT); + _boundsT = setTimeout(() => updateWindowBounds(window), 200); + }); + window.once('ready-to-show', () => updateWindowBounds(window)); window.on('restore', onGeometryChange); // Tear down any native web-pane views this window owns — their webContents are @@ -772,175 +782,9 @@ export function newWindow( return {action: 'deny'}; }); - // When a is attached (e.g. a web pane), prevent it from opening - // popup windows (OAuth flows, target="_blank" links) as new BrowserWindows. - // Route them to the system browser instead. - window.webContents.on('did-attach-webview', (_event, webviewContents) => { - // Make this web pane's outgoing requests fingerprint as Chrome (UA + matching - // sec-ch-ua client hints) so Cloudflare's challenge clears instead of looping. - try { - configureWebPaneSession(webviewContents.session); - } catch (err) { - console.error('[web-pane] header config failed:', err); - } - // Let the renderer reach this guest via @electron/remote too (zoom keys etc.). - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('@electron/remote/main').enable(webviewContents); - } catch (err) { - console.error('[ctxmenu] remote.enable(guest) failed:', err); - } - // Right-click menu for web panes, built in MAIN with the guest webContents - // directly. (The renderer tried to reach it via @electron/remote's - // webContents.fromId, which silently returned nothing — so there was no - // in-page menu at all.) inspectElement docks DevTools at the bottom. - webviewContents.on('context-menu', (_e: any, params: Electron.ContextMenuParams) => { - const guestWc: Electron.WebContents = webviewContents as any; - const items: Electron.MenuItemConstructorOptions[] = []; - if (params.misspelledWord) { - if (params.dictionarySuggestions && params.dictionarySuggestions.length > 0) { - for (const suggestion of params.dictionarySuggestions) { - items.push({ - label: suggestion, - click: () => { - // Electron 41 dropped WebContents.replaceMisspelledWord. Right- - // clicking a misspelled word selects it, so insertText replaces - // that selection with the chosen suggestion. - void guestWc.insertText(suggestion); - } - }); - } - } else { - items.push({ - label: 'No spelling suggestions', - enabled: false - }); - } - items.push( - { - label: 'Add to Dictionary', - click: () => { - try { - guestWc.session.addWordToSpellCheckerDictionary(params.misspelledWord); - } catch (err) { - console.error('[web-pane] failed to add word to dictionary:', err); - } - } - }, - {type: 'separator'} - ); - } - items.push( - { - label: 'Back', - enabled: guestWc.canGoBack(), - click: () => { - guestWc.goBack(); - } - }, - { - label: 'Forward', - enabled: guestWc.canGoForward(), - click: () => { - guestWc.goForward(); - } - }, - { - label: 'Reload', - click: () => { - guestWc.reload(); - } - }, - {type: 'separator'} - ); - if (params.linkURL) { - items.push( - {label: 'Copy Link', click: () => require('electron').clipboard.writeText(params.linkURL)}, - {label: 'Open Link in Browser', click: () => void shell.openExternal(params.linkURL)}, - {type: 'separator'} - ); - } - items.push( - {label: 'Copy', role: 'copy', enabled: !!params.editFlags?.canCopy}, - {label: 'Paste', role: 'paste', enabled: !!params.editFlags?.canPaste}, - {label: 'Select All', role: 'selectAll'}, - {type: 'separator'}, - { - label: 'Find in page', - accelerator: 'CmdOrCtrl+F', - click: () => { - // The guest webContents id matches what the renderer's - // .getWebContentsId() returns, so the right web pane can - // open its find bar. - try { - window.webContents.send('web-pane-find', (webviewContents as any).id); - } catch (err) { - console.error('web-pane-find send failed:', err); - } - } - }, - {type: 'separator'}, - { - label: 'Inspect', - click: () => { - try { - // A guest has no window chrome to dock into, so - // {mode:'bottom'} silently no-ops. Detach opens a real DevTools - // window for the guest, which works. Open first, then inspect the - // clicked element once DevTools is up. - if (!guestWc.isDevToolsOpened()) { - guestWc.openDevTools({mode: 'detach'}); - guestWc.once('devtools-opened', () => { - try { - guestWc.inspectElement(params.x, params.y); - } catch (err) { - console.error('inspectElement failed:', err); - } - }); - } else { - guestWc.inspectElement(params.x, params.y); - } - } catch (err) { - console.error('Inspect failed:', err); - } - } - }, - {type: 'separator'}, - { - label: 'New Stickys', - click: () => void ipcMain.emit('new-sticky', {}) - }, - { - label: 'Search Stickys', - click: () => void ipcMain.emit('search-stickies') - } - ); - Menu.buildFromTemplate(items).popup({window}); - }); - webviewContents.setWindowOpenHandler(({url}) => { - // OAuth / login popups can't run inside an embedded browser — hand those - // to the system browser. - const isOAuth = - /^https?:\/\/(accounts\.google\.|login\.microsoftonline\.|appleid\.apple\.|github\.com\/login|login\.yahoo\.|(www\.)?facebook\.com\/(login|dialog)|api\.twitter\.com\/oauth)/i.test( - url - ); - if (isOAuth) { - void shell.openExternal(url); - return {action: 'deny'}; - } - // target="_blank" / window.open wants a new "tab" — but Hyperia has split - // panes, not browser tabs. So tell the renderer to split DOWN and open the - // link in a fresh web pane below the current one. (The guest webContents id - // routes it to the right pane.) - try { - window.webContents.send('web-pane-open-split', (webviewContents as any).id, url); - } catch (err) { - console.error('web-pane-open-split send failed:', err); - void shell.openExternal(url); - } - return {action: 'deny'}; - }); - }); + // Web panes render via native WebContentsViews (app/web-pane-manager.ts), which + // owns their context menu, OAuth punting, target="_blank" splits, and session + // config. The legacy guest wiring (did-attach-webview) is gone. // expose internals to extension authors window.rpc = rpc; @@ -967,12 +811,20 @@ export function newWindow( const updateFocusTime = () => { window.focusTime = process.uptime(); updateWindowFocus(window.id); + // Piggyback pixel bounds — focus fires at launch, after the sidecar WS is + // up, so terminal_status gets the window size even with no resize/maximize. + updateWindowBounds(window); }; window.on('focus', () => { updateFocusTime(); }); + // Safety net: a fresh window may be focused before the sidecar WS connects, + // so ready-to-show / focus bounds can be dropped. Resend a couple of times. + setTimeout(() => updateWindowBounds(window), 1500); + setTimeout(() => updateWindowBounds(window), 4000); + // the window can be closed by the browser process itself window.clean = () => { app.config.winRecord(window); diff --git a/app/web-pane-manager.ts b/app/web-pane-manager.ts index e86f591bd0c..ac6c2b8a2fc 100644 --- a/app/web-pane-manager.ts +++ b/app/web-pane-manager.ts @@ -41,6 +41,8 @@ interface WebPaneEntry { win: BrowserWindow; url: string; visible: boolean; + // Pending delayed teardown (see destroyPane) — cancelled if the pane remounts. + destroyTimer?: ReturnType; // Last pixel rect pushed from the renderer — used to re-split when the docked // inspector opens/closes without waiting for the next bounds tick. lastBounds?: {x: number; y: number; width: number; height: number}; @@ -73,21 +75,34 @@ function navState(wc: WebContents) { return {url: wc.getURL(), canGoBack: wc.navigationHistory.canGoBack(), canGoForward: wc.navigationHistory.canGoForward()}; } -function wireWebContents(uid: string, wc: WebContents) { - wc.on('did-start-loading', () => pushState(uid, {loading: true, error: null})); - wc.on('did-stop-loading', () => pushState(uid, {loading: false, ...navState(wc)})); - wc.on('did-navigate', () => pushState(uid, {...navState(wc)})); +// Panes get ADOPTED across React remounts (delayed-destroy + create re-keys the +// map), so a uid captured by wireWebContents closures can go STALE — lookups by +// it silently no-op (the invisible-context-menu bug). Resolve the CURRENT uid +// from the webContents every time. +function liveUidOf(wc: WebContents, fallback: string): string { + for (const [id, e] of panes) { + if (!e.view.webContents.isDestroyed() && e.view.webContents === wc) return id; + } + return fallback; +} + +function wireWebContents(initialUid: string, wc: WebContents) { + const u = () => liveUidOf(wc, initialUid); + wc.on('did-start-loading', () => pushState(u(), {loading: true, error: null})); + wc.on('did-stop-loading', () => pushState(u(), {loading: false, ...navState(wc)})); + wc.on('did-navigate', () => pushState(u(), {...navState(wc)})); wc.on('did-navigate-in-page', (_e, _url, isMainFrame) => { - if (isMainFrame) pushState(uid, {...navState(wc)}); + if (isMainFrame) pushState(u(), {...navState(wc)}); }); - wc.on('page-title-updated', (_e, title) => pushState(uid, {title})); + wc.on('page-title-updated', (_e, title) => pushState(u(), {title})); wc.on('did-fail-load', (_e, errorCode, errorDescription, validatedURL, isMainFrame) => { // -3 = ERR_ABORTED (user/redirect navigation), not a real failure. if (isMainFrame && errorCode !== -3) { - pushState(uid, {loading: false, error: {code: errorCode, description: errorDescription, url: validatedURL}}); + pushState(u(), {loading: false, error: {code: errorCode, description: errorDescription, url: validatedURL}}); } }); wc.on('found-in-page', (_e, result) => { + const uid = u(); entrySend(uid, 'web-pane:found-in-page', { uid, active: result.activeMatchOrdinal, @@ -96,10 +111,10 @@ function wireWebContents(uid: string, wc: WebContents) { }); // Let the renderer run its dom-ready work (scrollbar CSS inject, bg-color probe) // via web-pane:execute-js — it can't observe the guest's dom-ready directly. - wc.on('dom-ready', () => entrySend(uid, 'web-pane:dom-ready', {uid})); + wc.on('dom-ready', () => { const uid = u(); entrySend(uid, 'web-pane:dom-ready', {uid}); }); // Clicking into the page focuses its webContents — tell the renderer so it can // activate the pane (and dismiss the URL navigator). - wc.on('focus', () => entrySend(uid, 'web-pane:focus', {uid})); + wc.on('focus', () => { const uid = u(); entrySend(uid, 'web-pane:focus', {uid}); }); // Zoom shortcuts pressed while the PAGE has focus never reach the renderer's // window (the native view captures them), so intercept Ctrl/Cmd +/-/0 here and // route to the renderer's zoom handlers (which own the zoom-factor state). @@ -112,6 +127,7 @@ function wireWebContents(uid: string, wc: WebContents) { else if (k === '0') dir = 'reset'; if (dir) { event.preventDefault(); + const uid = u(); entrySend(uid, 'web-pane:zoom-key', {uid, dir}); } }); @@ -119,6 +135,7 @@ function wireWebContents(uid: string, wc: WebContents) { // guest handler in window.ts no longer fires). Reload / screenshot / copy / // paste / back-forward / copy-link / find / inspect-in-split / stickys. wc.on('context-menu', (_e: Electron.Event, params: Electron.ContextMenuParams) => { + const uid = u(); const entry = panes.get(uid); if (!entry) return; const items: Electron.MenuItemConstructorOptions[] = []; @@ -170,6 +187,16 @@ function wireWebContents(uid: string, wc: WebContents) { ? {label: 'Close Inspector', click: () => closeInspector(uid)} : {label: 'Inspect (split)', click: () => openInspector(uid, params.x, params.y)}, {type: 'separator'}, + // Not offered when this pane IS the agent shell (renderer dedupes to a + // single Hyperia Agent tab anyway — the item would just focus itself). + ...(/\/shell\b/.test(wc.getURL()) ? [] : [{ + label: 'Hyperia Agent', + click: () => { + const port = process.env.HYPERIA_PORT || '9800'; + (entry.win as any).rpc?.emit('open web pane req', {url: `http://localhost:${port}/shell`}); + } + } as Electron.MenuItemConstructorOptions]), + {type: 'separator'}, {label: 'New Stickys', click: () => void ipcMain.emit('new-sticky', {})}, {label: 'Search Stickys', click: () => void ipcMain.emit('search-stickies')} ); @@ -192,7 +219,8 @@ function wireWebContents(uid: string, wc: WebContents) { void shell.openExternal(url); return {action: 'deny'}; } - entrySend(uid, 'web-pane:open-split', {uid, url}); + const liveUid = u(); + entrySend(liveUid, 'web-pane:open-split', {uid: liveUid, url}); return {action: 'deny'}; }); } @@ -214,12 +242,36 @@ function roundRect(b: {x: number; y: number; width: number; height: number}) { function createPane(win: BrowserWindow, uid: string, url: string) { if (panes.has(uid)) { - // Re-navigate an existing view instead of leaking a second one. const entry = panes.get(uid)!; - entry.url = url; - if (url) void entry.view.webContents.loadURL(url).catch(() => {}); + // A React remount (sibling pane closed → BSP reparent, or any layout + // reshuffle) fires destroy+create for the SAME pane. Cancel the pending + // teardown and REUSE the live view — reloading would wipe page state + // (e.g. the agent chat log). + if (entry.destroyTimer) { + clearTimeout(entry.destroyTimer); + entry.destroyTimer = undefined; + } + const current = entry.view.webContents.getURL(); + if (url && url !== entry.url && url !== current) { + entry.url = url; + void entry.view.webContents.loadURL(url).catch(() => {}); + } + entry.view.setVisible(entry.visible); return; } + // Reparent with a NEW group uid (some layout collapses re-key the group): + // adopt a pending-destroy view in the same window with the same URL instead + // of building a fresh one — this is what preserves page state (chat logs). + for (const [oldUid, entry] of panes) { + if (entry.win === win && entry.destroyTimer && entry.url === url) { + clearTimeout(entry.destroyTimer); + entry.destroyTimer = undefined; + panes.delete(oldUid); + panes.set(uid, entry); + entry.view.setVisible(true); + return; + } + } const view = new WebContentsView({ webPreferences: { session: getSharedSession(), @@ -326,9 +378,19 @@ function closeInspector(uid: string) { positionViews(entry); } -function destroyPane(uid: string) { +// Renderer-driven destroy is DELAYED: an unmount is often half of a remount +// (layout reparent). Hide immediately; kill for real only if no create() lands +// within the grace window. Window teardown uses immediate=true. +function destroyPane(uid: string, immediate = false) { const entry = panes.get(uid); if (!entry) return; + if (!immediate) { + entry.view.setVisible(false); + if (entry.destroyTimer) clearTimeout(entry.destroyTimer); + entry.destroyTimer = setTimeout(() => destroyPane(uid, true), 400); + return; + } + if (entry.destroyTimer) clearTimeout(entry.destroyTimer); panes.delete(uid); if (entry.devtools) { try { @@ -358,7 +420,7 @@ function destroyPane(uid: string) { // Tear down every pane belonging to a window (call on window close). export function destroyPanesForWindow(win: BrowserWindow) { for (const [uid, entry] of panes) { - if (entry.win === win) destroyPane(uid); + if (entry.win === win) destroyPane(uid, true); } } diff --git a/docs/doors.md b/docs/doors.md new file mode 100644 index 00000000000..531493d2448 --- /dev/null +++ b/docs/doors.md @@ -0,0 +1,174 @@ +# Tool Doors — progressive disclosure for Hyperia's tool catalog + +Hyperia exposes 100+ agent tools. Showing all of them to a model on every turn is +expensive: a 4B local model (Sailfish, ~8k context) drowns in the schema, and a +token-billed cloud model pays for a giant `tools` array it mostly ignores. **Doors** +are the fix — a progressive-disclosure model where a model only ever sees a small +**core** plus a bounded set of **open doors**. + +A **door** is a named category of tools (`web`, `stickys`, `terminal_layout`, …). +The menu of *closed* doors costs one line each (name + one-line description). A model +opens a door to reveal its tools, and the door's full schemas land in the next +`tools` list. + +> **Doors are NOT a security boundary.** The door menu is purely a UX / token-budget +> concern. Consent (`request_access`, the 202/403 soft-walls) and identity (the +> `hyp_agent_` token) are enforced at the HTTP API layer, never by doors. That is why +> auto-opening a door on a direct tool call (below) is safe by construction — it +> grants nothing the menu was merely hiding. + +The pure data layer (the taxonomy + `DoorState` bookkeeping) lives in +[`sidecar/src/doors.rs`](../sidecar/src/doors.rs) and is exhaustively unit-tested +(`cargo test -- doors::`). + +## Two surfaces, one taxonomy + +Doors are applied independently on the two tool surfaces Hyperia ships: + +| Surface | Where | Consumer | Default | +| --- | --- | --- | --- | +| **Ghost** | `sidecar/src/ghost/*` | Hyperia's built-in agent loop | `auto` (on) | +| **MCP** | `sidecar/src/mcp.rs` | External MCP clients (Claude Code, other CLIs) over stdio / streamable-HTTP | **off** (full catalog) | + +Both share the *concept* of doors but expose different tool sets and, in places, +different door names (ghost `terminal` vs. MCP `terminal_layout`). Each [`Door`] +carries both a `ghost_tools` and an `mcp_tools` slice; a door absent from a surface +leaves that slice empty. The taxonomy is a **partition** per surface — every tool is +in exactly one door *or* the always-on core, never both, never two doors (enforced by +unit tests). + +### Core (always on) + +- **Ghost core (11):** `terminal_status`, `terminal_run`, `terminal_screen`, + `file_read`, `file_write`, `watercooler`, `memory_recall`, `memory_remember`, plus + meta `tool_search` / `open_tools` / `close_tools`. +- **MCP core (12):** `terminal_status`, `terminal_run`, `terminal_screen`, + `terminal_keys`, `terminal_split`, `tab_snapshot`, `request_access`, + `request_token`, `hyperia_version`, plus meta `open_tools` / `close_tools` / + `search_tools`. + +## Configuration + +### Ghost — `config.agent.tool_doors` + +`"off" | "on" | "auto"` (default `auto`). `auto` turns doors **on** for every +provider: small/local models get the tight cap ([`DEFAULT_TOOL_CAP`] = 20), cloud +models get [`CLOUD_TOOL_CAP`] = 24. Resolved by `doors::resolve_door_config`. + +### MCP — `config.agent.mcp_tool_doors` + +`"on" | "off"` (default **off**). This is deliberately **opt-in**: external agents +were built against the full 67-tool MCP catalog, so they keep it unless the user +explicitly turns doors on. Only an explicit `on` / `true` / `1` enables — `off`, +`auto`, `""`, and any unknown value all stay off. When on, doors apply with +[`CLOUD_TOOL_CAP`] = 24 headroom (external MCP clients are typically cloud/large +models). Resolved by `doors::resolve_mcp_door_config`. + +### Environment overrides (both surfaces) + +| Var | Effect | +| --- | --- | +| `HYPERIA_TOOL_DOORS=1` / `0` | Force doors on / off, overriding the config mode entirely. | +| `HYPERIA_TOOL_CAP=` | Override the resolved live-tool cap (core + open doors). | + +## The meta-tools + +Three tools drive the doors. On the ghost surface the search meta-tool is +`tool_search`; on the MCP surface it is `search_tools` (same behavior — the MCP +taxonomy names it `search_tools`). + +| Tool | Effect | +| --- | --- | +| `open_tools(door)` | Open a door. Its tools appear in your list on the next `tools/list`. Over-cap opens evict the least-recently-used door(s) first (never the door just opened). | +| `close_tools(door)` | Close a door, freeing its slice of the budget. | +| `search_tools(query)` / `tool_search(query)` | Keyword-search the **full** catalog (open *and* closed). Each hit is tagged `[door: X — open\|closed]` so a model can discover a tool, then `open_tools` its door. | + +### Live-tool budget & LRU eviction + +`DoorState` maintains the invariant `core + Σ open-door tools ≤ cap`. Doors are held +in LRU order; opening one that would breach the cap evicts the oldest door(s) first. +A single door larger than the whole cap is still openable (it's allowed to exceed +rather than be un-openable). Executing any tool `touch`es its door → moves it to +most-recently-used so it survives eviction longest. + +## Auto-open semantics + +A model may call a tool that sits behind a **closed** door directly (it saw the name +in `search_tools`, in compressed history, or from an earlier turn). Rather than +rejecting the call, the surface **auto-opens** the door, runs the tool, and annotates +the result: + +``` +[door 'web' auto-opened by this call] + +``` + +- **Ghost:** [`sidecar/src/ghost/agent.rs`](../sidecar/src/ghost/agent.rs) (~L931) — + the closed-door call guard mutates the loop-local `DoorState` before dispatch. +- **MCP:** `call_tool` in [`sidecar/src/mcp.rs`](../sidecar/src/mcp.rs) does the same + against the process-global MCP `DoorState`, then fires a `tools/list_changed` + (below). + +This is safe because doors are a menu, not a permission gate. + +## MCP door state is process-global + +rmcp's stateless streamable-HTTP transport (`stateful_mode: false`, chosen so sidecar +restarts don't 404 the client) builds a **fresh `HyperiaMcp` per request**, so a +per-connection door field would be wiped between calls — rmcp gives no per-connection +scratch space in stateless mode. The MCP surface therefore keeps **one process-global +`DoorState`** behind a `std::sync::Mutex` (`mcp_door_state()` in `mcp.rs`). Every +external client shares it. This is acceptable precisely because doors aren't a +security boundary. The lock is only ever held for short synchronous mutations, never +across an `.await`. + +Because the `#[tool]` router can't dynamically filter itself, the MCP surface applies +doors at the `ServerHandler` interception layer: + +- **`list_tools`** returns `core + open-door` router tools (via `DoorState::live_tools`), + then appends the three synthetic meta-tools (they aren't `#[tool]`s — they mutate + door state, which lives outside the router). +- **`call_tool`** intercepts the three meta-tool names, and auto-opens closed doors + for real tools, before delegating to `self.tool_router.call(...)`. + +With `mcp_tool_doors` **off**, both paths are byte-identical to the pre-doors server. + +## `tools/list_changed` behavior (MCP) + +When the MCP door set changes at runtime — `open_tools`, `close_tools`, or an +auto-open — the server sends the MCP `notifications/tools/list_changed` to the client +via the request's server peer: + +```rust +let _ = context.peer.notify_tool_list_changed().await; +``` + +The `tools/list_changed` capability is advertised in `get_info` **only when +`mcp_tool_doors` is on** (that's the only mode where the set mutates). + +### Delivery on the stateless HTTP transport — it works, with one nuance + +We use the **stateless** streamable-HTTP transport. A natural worry is that a +stateless server has no persistent channel to push a server-initiated notification. +In practice it works, because of how rmcp handles a stateless POST: + +1. Each incoming JSON-RPC request (e.g. a `tools/call`) is served over a + `OneshotTransport` whose outbound side becomes the **SSE stream of that POST's + response** (`transport/streamable_http_server/tower.rs`). +2. `OneshotTransport::send` (`transport.rs`) only *terminates* the stream on a + `Response`/`Error`. **Notifications pass straight through** the same mpsc channel. +3. So a `notify_tool_list_changed()` sent *during* `call_tool` — before the result is + returned — is emitted on that same POST's SSE stream, ahead of the tool result. + A client that reads its POST response as SSE (Claude Code does) receives it. + +**The nuance / limitation:** a stateless server has **no out-of-band push channel**. +The notification only rides along the SSE response of the very call that changed the +door state. There is no standalone server→client stream a client could subscribe to +for door changes triggered by *other* clients (they share the global state, but a +change made by client A does not spontaneously notify client B — B only learns on its +next `tools/list` or its own door-changing call). For our use case this is exactly +right: MCP door state only ever changes *as a result of a tool call*, so the +notification naturally piggybacks on that call's response. + +On the **stdio** transport (a persistent bidirectional pipe) there is no such nuance — +notifications flow normally. diff --git a/lib/actions/term-groups.ts b/lib/actions/term-groups.ts index d46e8fd2ade..0ac61f1733c 100644 --- a/lib/actions/term-groups.ts +++ b/lib/actions/term-groups.ts @@ -296,9 +296,9 @@ export function clearWebPane(groupUid: string) { }; } -export function openWebPaneInNewTab(url: string) { +export function openWebPaneInNewTab(url: string, name?: string) { return (dispatch: HyperDispatch) => { - dispatch({type: TERM_GROUP_ADD_WEB_TAB, url} as any); + dispatch({type: TERM_GROUP_ADD_WEB_TAB, url, name} as any); }; } diff --git a/lib/components/find-bar.tsx b/lib/components/find-bar.tsx index 71782363bfe..36819417262 100644 --- a/lib/components/find-bar.tsx +++ b/lib/components/find-bar.tsx @@ -47,7 +47,7 @@ export default function FindBar(props: FindBarProps) { ref={inputRef} type="text" // The bar mounts when Ctrl+F opens it; autoFocus reliably grabs the - // cursor (the manual ref.focus in openFind races with the + // cursor (the manual ref.focus in openFind races with the native view // stealing focus back). Select any existing query so you can retype. autoFocus onFocus={(e) => e.currentTarget.select()} diff --git a/lib/components/new-pane-picker.tsx b/lib/components/new-pane-picker.tsx index b45bcf9b482..d81a15198c3 100644 --- a/lib/components/new-pane-picker.tsx +++ b/lib/components/new-pane-picker.tsx @@ -87,6 +87,35 @@ const INSTALL_CATALOG: InstallEntry[] = [ } ]; +// Persisted picker defaults — what the S / A hotkeys launch. Written on every +// explicit shell/agent selection, read once when the picker mounts, so the +// quick keys keep working across sessions. +const LS_DEFAULT_SHELL = 'hyperia.picker.defaultShell'; +const LS_DEFAULT_AGENT = 'hyperia.picker.defaultAgent'; +const readStoredDefault = (key: string): string | undefined => { + try { + return window.localStorage.getItem(key) || undefined; + } catch { + return undefined; + } +}; +const writeStoredDefault = (key: string, value: string) => { + try { + window.localStorage.setItem(key, value); + } catch { + /* storage unavailable */ + } +}; + +// Self-update command shown in the picker footer. Like the install catalog, +// it never auto-runs: [run] opens a shell with it typed but NOT submitted. +const UPDATE_COMMAND = isWindows + ? 'powershell -c "irm https://hyperia.nuts.services/install.ps1 | iex"' + : 'curl -fsSL https://hyperia.nuts.services/install.sh | sh'; + +// Version strings compare with or without a leading "v" ("0.15.11" == "v0.15.11"). +const normalizeVersion = (v?: string): string => (v || '').trim().replace(/^v/i, ''); + // Icon per agent harness. const agentIconClass = (name: string): {icon: string; style?: React.CSSProperties} => { const n = name.toLowerCase().replace(/^install /, ''); @@ -151,6 +180,10 @@ interface ComboboxProps { createLabel: string; onAdd: () => void; isGlimmerActive?: boolean; + // Hotkey chip (e.g. "S") shown next to the label; `keyHintTitle` explains + // what pressing the key launches. + keyHint?: string; + keyHintTitle?: string; } interface ComboboxState { @@ -177,8 +210,9 @@ const comboRowStyle = (focused: boolean): React.CSSProperties => ({ }); // Shared layout so New Webpane / New Shell / New Agent are the SAME width and -// look: a fixed-width left label, then a flex box capped at the same maxWidth. -const PICKER_ROW_MAX = '430px'; +// look: a fixed-width left label (wide enough for the W/S/A hotkey chip), then +// a flex box capped at the same maxWidth. +const PICKER_ROW_MAX = '446px'; const PICKER_BOX_MAX = '340px'; const pickerRowStyle: React.CSSProperties = { display: 'flex', @@ -188,7 +222,7 @@ const pickerRowStyle: React.CSSProperties = { maxWidth: PICKER_ROW_MAX }; const pickerLabelStyle: React.CSSProperties = { - width: '80px', + width: '96px', flexShrink: 0, fontSize: '11px', color: 'var(--text-secondary)', @@ -235,6 +269,20 @@ const pickerEnterBadgeStyle: React.CSSProperties = { cursor: 'pointer', flexShrink: 0 }; +// Small hotkey chip ("W" / "S" / "A") next to each section label — same look +// as the inline enter badge, sized down to fit the label column. +const pickerKeyHintStyle: React.CSSProperties = { + fontFamily: 'var(--font-mono)', + fontSize: '9px', + fontWeight: 600, + padding: '1px var(--space-4)', + border: '0.5px solid var(--border-neutral)', + borderRadius: 'var(--radius-3)', + color: 'var(--text-tertiary)', + userSelect: 'none', + lineHeight: '1.2', + flexShrink: 0 +}; const pickerDropdownStyle: React.CSSProperties = { position: 'absolute', top: 'calc(100% + var(--space-4))', @@ -327,13 +375,15 @@ class InlineCombobox extends React.Component { }; render() { - const {label, leadingIcon, addLabel, createLabel, placeholder} = this.props; + const {label, leadingIcon, addLabel, createLabel, placeholder, keyHint, keyHintTitle} = this.props; const rows = this.rows(); const {text, open, focusedIndex} = this.state; return (
-
{label}
+
+ {label} +
@@ -391,7 +441,7 @@ class InlineCombobox extends React.Component { onClick={() => this.commit()} style={pickerEnterBadgeStyle} > - enter + {keyHint ? keyHint.toLowerCase() : 'enter'}
@@ -520,6 +570,10 @@ export interface NewPanePickerProps { pickerZoom: number; isGlimmerActive?: boolean; + // Gate for the W/S/A pane hotkeys — true only while this pane is the active + // session and nothing (e.g. the custom-profile modal) covers the picker. + hotkeysEnabled?: boolean; + // Callbacks into Term. onUrlChange: (value: string) => void; onSubmitUrl: (url?: string) => void; @@ -528,10 +582,15 @@ export interface NewPanePickerProps { } interface NewPanePickerState { - // Session-local "last used" so the combobox pre-fills the last choice. Not - // persisted across sessions. + // Remembered defaults (what the S / A hotkeys launch, and what the combo- + // boxes pre-fill). Seeded from localStorage and re-persisted on every + // explicit selection, so they survive across sessions. lastUsedShell?: string; lastUsedAgent?: string; + // Running Hyperia version (sidecar /api/status) and the latest published + // one (hyperia.nuts.services/version) — drives the footer's update row. + currentVersion?: string; + latestVersion?: string; // 'install' swaps the picker content for the agent install-instructions view. view?: 'main' | 'install'; // Which install command was just copied (flash feedback). @@ -545,7 +604,10 @@ interface NewPanePickerState { // profile. Top→bottom: title, URL entry, a New Shell combobox, a New Agent // combobox. No dividers between sections. export class NewPanePicker extends React.Component { - state: NewPanePickerState = {}; + state: NewPanePickerState = { + lastUsedShell: readStoredDefault(LS_DEFAULT_SHELL), + lastUsedAgent: readStoredDefault(LS_DEFAULT_AGENT) + }; componentDidMount() { // Is the Hyperia agent configured? (provider+model+key in config.agent.*) @@ -554,8 +616,93 @@ export class NewPanePicker extends React.Component r.json()) .then((j) => this.setState({hyperiaConfigured: !!j?.configured})) .catch(() => {}); + + // Footer: the running version (sidecar) and the latest published one. + fetch(`http://localhost:${port}/api/status`) + .then((r) => r.json()) + .then((j) => { + if (j?.version) this.setState({currentVersion: String(j.version)}); + }) + .catch(() => {}); + // The endpoint may return plain text ("0.15.11") or JSON ({version: …}). + fetch('https://hyperia.nuts.services/version') + .then((r) => r.text()) + .then((t) => { + let v = t.trim(); + try { + const j = JSON.parse(v); + if (j && typeof j === 'object' && j.version) v = String(j.version).trim(); + } catch { + /* plain text */ + } + // Accept ONLY a strict version string — the endpoint may not exist + // yet and can return an HTML error page / arbitrary file content, + // which previously rendered as garbage in the footer. + if (/^v?\d+\.\d+(\.\d+)?$/.test(v)) this.setState({latestVersion: v}); + }) + .catch(() => {}); + + // W/S/A quick-launch hotkeys — window-level because the picker has no + // focusable surface of its own; gated on this pane being the active + // session (hotkeysEnabled) and on focus not being in a text field. + window.addEventListener('keydown', this.handleHotkey); + } + + componentWillUnmount() { + window.removeEventListener('keydown', this.handleHotkey); } + // W/S/A quick paths. Only in the main view (the hints live on its section + // labels), never while typing in an input, and never with modifiers held. + private handleHotkey = (e: KeyboardEvent) => { + if (!this.props.hotkeysEnabled) return; + if ((this.state.view || 'main') !== 'main') return; + if (e.ctrlKey || e.metaKey || e.altKey || e.repeat) return; + const target = e.target as HTMLElement | null; + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) return; + + const key = e.key.toLowerCase(); + if (key === 'w') { + e.preventDefault(); + e.stopPropagation(); + this.openGuidePane(); + } else if (key === 's') { + e.preventDefault(); + e.stopPropagation(); + this.launchDefaultShell(); + } else if (key === 'a') { + e.preventDefault(); + e.stopPropagation(); + this.launchDefaultAgent(); + } + }; + + // W: swap THIS picker pane into a web pane on the sidecar-served guide — + // the same exit + setWebPaneUrl pattern the URL box and agent config use. + private openGuidePane = () => { + const {groupUid, uid, setWebPaneUrl} = this.props; + if (!setWebPaneUrl || !groupUid) return; + const port = process.env.HYPERIA_PORT || '9800'; + rpc.emit('exit', {uid}); + setWebPaneUrl(groupUid, `http://localhost:${port}/guide`); + }; + + // S: the remembered default shell; falls back to the same resolution the + // combobox displays (configured defaultProfile, then the first shell). + private launchDefaultShell = () => { + const item = this.resolveDefaultShell(this.buildShellItems()); + item?.onSelect(); + }; + + // A: the remembered default agent; with nothing remembered (or the agent + // gone), the Hyperia Agent path is the fallback. + private launchDefaultAgent = () => { + const items = this.buildAgentItems(); + const remembered = this.state.lastUsedAgent && items.find((i) => i.key === this.state.lastUsedAgent); + if (remembered) remembered.onSelect(); + else this.launchHyperiaShell(); + }; + // Open the Hyperia Agent configuration pane (sidecar-served) in this pane. private openAgentConfig = () => { const {groupUid, uid, setWebPaneUrl} = this.props; @@ -578,24 +725,28 @@ export class NewPanePicker extends React.Component { this.setState({lastUsedShell: name}); + writeStoredDefault(LS_DEFAULT_SHELL, name); this.newWithProfile(name); }; private launchAgent = (name: string) => { this.setState({lastUsedAgent: name}); + writeStoredDefault(LS_DEFAULT_AGENT, name); this.newWithProfile(name); }; - // Hyperia Shell isn't a profile launch — it swaps the pane for a web pane - // pointed at the local shell UI. Preserved verbatim from the old button. + // Hyperia Agent always gets its OWN tab (labeled "Hyperia Agent"; focuses + // the existing one if open) — routed through the shared 'open web pane req' + // handler in lib/index.tsx. The picker pane closes itself. Remembered under + // the agent item's key ('Hyperia') so the A hotkey resolves back to it. private launchHyperiaShell = () => { - const {groupUid, uid, setWebPaneUrl} = this.props; - if (!setWebPaneUrl || !groupUid) return; + const {uid} = this.props; const port = process.env.HYPERIA_PORT || '9800'; const shellUrl = `http://localhost:${port}/shell`; - this.setState({lastUsedAgent: 'Hyperia Shell'}); + this.setState({lastUsedAgent: 'Hyperia'}); + writeStoredDefault(LS_DEFAULT_AGENT, 'Hyperia'); + rpc.emitter.emit('open web pane req', {url: shellUrl}); rpc.emit('exit', {uid}); - setWebPaneUrl(groupUid, shellUrl); }; private confirmDelete = (type: 'shell' | 'agent', name: string, displayName: string) => { @@ -781,11 +932,10 @@ export class NewPanePicker extends React.Component { const n = p.name.toLowerCase(); @@ -795,7 +945,7 @@ export class NewPanePicker extends React.Component (a.kind ? 1 : 0) - (b.kind ? 1 : 0)); - const shellItems: ComboItem[] = shellProfiles.map((p: any) => { + return shellProfiles.map((p: any) => { const displayName = capitalize(p.name); return { key: p.name, @@ -805,20 +955,26 @@ export class NewPanePicker extends React.Component this.confirmDelete('shell', p.name, displayName) : undefined }; }); + } - // Default text: last used → configured defaultProfile (if it's a shell) → - // first shell. - const defaultShellItem = + // Default shell: remembered default → configured defaultProfile (if it's a + // shell) → first shell. + private resolveDefaultShell(shellItems: ComboItem[]): ComboItem | undefined { + const {defaultProfile} = this.props; + return ( (this.state.lastUsedShell && shellItems.find((i) => i.key === this.state.lastUsedShell)) || (defaultProfile && shellItems.find((i) => i.key === defaultProfile)) || - shellItems[0]; - const shellDefaultText = defaultShellItem ? defaultShellItem.label : ''; + shellItems[0] + ); + } - // --- Agent items — detection-driven (app/config/detect.ts catalog) --- - // INSTALLED harnesses arrive as profiles named exactly per AGENT_NAMES - // (Nemesis8 installed also brings "Nemesis8 Danger" = `nemesis8 --danger`). - // Missing ones are NOT listed — the "install an agent" row opens the - // instruction view instead. + // --- Agent items — detection-driven (app/config/detect.ts catalog) --- + // INSTALLED harnesses arrive as profiles named exactly per AGENT_NAMES + // (Nemesis8 installed also brings "Nemesis8 Danger" = `nemesis8 --danger`). + // Missing ones are NOT listed — the "install an agent" row opens the + // instruction view instead. Shared by render() and the A hotkey. + private buildAgentItems(): ComboItem[] { + const profileList: any[] = this.props.profiles || []; const agentItems: ComboItem[] = []; for (const p of profileList) { const n = p.name.toLowerCase(); @@ -867,11 +1023,29 @@ export class NewPanePicker extends React.Component this.confirmDelete('agent', p.name, p.name) }); } + return agentItems; + } + + render() { + const {urlInput, urlError, pickerZoom, isGlimmerActive} = this.props; + + const shellItems = this.buildShellItems(); + const defaultShellItem = this.resolveDefaultShell(shellItems); + const shellDefaultText = defaultShellItem ? defaultShellItem.label : ''; - const defaultAgentItem = - (this.state.lastUsedAgent && agentItems.find((i) => i.key === this.state.lastUsedAgent)) || agentItems[0]; + const agentItems = this.buildAgentItems(); + const rememberedAgentItem = + this.state.lastUsedAgent && agentItems.find((i) => i.key === this.state.lastUsedAgent); + const defaultAgentItem = rememberedAgentItem || agentItems[0]; const agentDefaultText = defaultAgentItem ? defaultAgentItem.label : ''; + // Footer version status. "Up to date" only when BOTH versions resolved and + // match — an unreachable check leaves the run button enabled. + const {currentVersion, latestVersion} = this.state; + const upToDate = + !!currentVersion && !!latestVersion && normalizeVersion(currentVersion) === normalizeVersion(latestVersion); + const updateCopied = this.state.copiedInstall === UPDATE_COMMAND; + if (this.state.view === 'install') { return this.renderInstallView(); } @@ -915,7 +1089,9 @@ export class NewPanePicker extends React.Component -
New Webpane
+
+ New Webpane +
this.props.onOpenCustomModal('shell')} isGlimmerActive={isGlimmerActive} + keyHint="S" + keyHintTitle={`Press S — launch ${shellDefaultText || 'the default shell'}`} /> {/* New Agent combobox. "install an agent" opens the instruction view @@ -964,7 +1142,81 @@ export class NewPanePicker extends React.Component this.setState({view: 'install'})} isGlimmerActive={isGlimmerActive} + keyHint="A" + keyHintTitle={`Press A — launch ${ + rememberedAgentItem ? rememberedAgentItem.label : 'the Hyperia Agent' + }`} /> + + {/* Footer — running version + self-update command. Like the install + view, [run] opens a shell with the command TYPED but not + submitted; nothing here auto-runs. */} +
+
+ + {currentVersion ? `Hyperia v${normalizeVersion(currentVersion)}` : 'Hyperia'} + + {upToDate ? ( + + ✓ up to date + + ) : latestVersion ? ( + + v{normalizeVersion(latestVersion)} available + + ) : null} + + this.copyInstall(UPDATE_COMMAND)} + title="Copy the update command" + style={{...pickerEnterBadgeStyle, cursor: 'pointer'}} + > + {updateCopied ? 'copied ✓' : 'copy'} + + {upToDate ? ( + + run + + ) : ( + this.openInstallShell(UPDATE_COMMAND)} + title="Open a shell with the update command typed — press Enter yourself" + style={{...pickerEnterBadgeStyle, cursor: 'pointer', color: 'var(--info-text)'}} + > + run + + )} +
+
+ {UPDATE_COMMAND} +
+
); diff --git a/lib/components/term.tsx b/lib/components/term.tsx index 220a9f3cb65..40d32551540 100644 --- a/lib/components/term.tsx +++ b/lib/components/term.tsx @@ -374,6 +374,18 @@ export default class Term extends React.PureComponent< menu.append(new MenuItem({type: 'separator'})); + menu.append( + new MenuItem({ + label: 'Hyperia Agent', + click: () => { + const port = process.env.HYPERIA_PORT || '9800'; + openUrl(`http://localhost:${port}/shell`); + } + }) + ); + + menu.append(new MenuItem({type: 'separator'})); + menu.append( new MenuItem({ label: 'Rename Pane', @@ -389,7 +401,7 @@ export default class Term extends React.PureComponent< menu.append( new MenuItem({ - label: 'Switch Shell…', + label: 'Picker', enabled: !isPicker, click: () => { // Unload the current shell and show the picker in this pane — the @@ -2856,6 +2868,9 @@ export default class Term extends React.PureComponent< urlError={this.state.urlError} pickerZoom={this.state.pickerZoom} isGlimmerActive={this.state.isGlimmerActive} + // W/S/A quick keys only for the active pane, and not while the + // custom-profile modal covers the picker. + hotkeysEnabled={this.props.isTermActive && !this.state.isCustomModalOpen} onUrlChange={(v) => this.setState({urlInput: v, urlError: ''})} onSubmitUrl={(url) => this.submitUrl(url)} onTriggerGlimmer={() => this.triggerPickerGlimmer()} diff --git a/lib/components/terms.tsx b/lib/components/terms.tsx index 8d931228b39..03c0374ebb0 100644 --- a/lib/components/terms.tsx +++ b/lib/components/terms.tsx @@ -112,8 +112,7 @@ export default class Terms extends React.Component { cursor: 'pointer' }} > - enter + w
diff --git a/lib/components/web-pane.tsx b/lib/components/web-pane.tsx index 4481b69094c..24771e69766 100644 --- a/lib/components/web-pane.tsx +++ b/lib/components/web-pane.tsx @@ -5,6 +5,7 @@ import {connect} from 'react-redux'; import type {HyperDispatch} from '../../typings/hyper'; import {clearWebPane, userExitTermGroup, splitWebPane, popOutPane} from '../actions/term-groups'; +import {markTabBell, clearTabBell} from '../actions/ui'; import rpc from '../rpc'; import {toNavigableUrl} from '../utils/navigable-url'; import {countPathHorizontalStacks} from '../utils/term-groups'; @@ -21,6 +22,7 @@ import {clickFnStr, ghostMouseFnStr} from '../utils/webview-scripts'; import {AskAiView} from './ask-ai-view'; import FindBar from './find-bar'; import {PaneBand} from './pane-band'; +import {activeTerminals} from './term'; import {UrlNavigator} from './url-navigator'; // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -40,6 +42,11 @@ interface WebPaneProps { webName?: string; onActive?: () => void; onSplitWebPane?: (url: string, direction: 'HORIZONTAL' | 'VERTICAL') => void; + // True when this pane's ROOT term group is the active tab (mapped from redux). + isTabActive?: boolean; + // Standard tab-bell plumbing (ui.bellMarkers — the same store terminal BELs use). + onTabBell?: (uid: string) => void; + onTabBellClear?: (uid: string) => void; } export interface WebHistoryEntry { @@ -104,9 +111,12 @@ interface WebPaneState { // Freeze-swap still shown in place of the (hidden) native view while an // occluding DOM overlay is open. null = live view visible. frozenShot?: string | null; - // True while the cursor is over the pane header — hides the native view so the - // header's hover tooltips (DOM, which a native view would otherwise occlude) - // are visible over a frozen frame. + // True only after the cursor has DWELLED on the pane header (see + // HEADER_HOVER_DWELL_MS) — hides the native view so the header's hover + // tooltips (DOM, which a native view would otherwise occlude) are visible + // over a frozen frame. Deliberately NOT set on transient mouse passes: a + // freeze-swap on every header crossing made the pane flash constantly while + // simply mousing in/out of it. headerHover?: boolean; // Whether this pane is actually in the viewport (IntersectionObserver). An // inactive tab is parked off-screen, so its native view must be hidden. @@ -126,6 +136,12 @@ interface WebPaneState { // ERR_NAME_NOT_RESOLVED, ERR_NAME_RESOLUTION_FAILED, ERR_ADDRESS_UNREACHABLE, ERR_INVALID_URL. const DDG_RESOLVE_FAIL = new Set([-105, -137, -109, -300]); +// How long the cursor must REST on the pane header before the native view is +// frozen+hidden to reveal the header's DOM tooltips. Transient passes (mousing +// out of the pane across the header, moving to another pane/tab) never trigger +// the swap — hiding on every crossing made web panes flash constantly. +const HEADER_HOVER_DWELL_MS = 350; + class WebPane_ extends React.PureComponent { // Geometry anchor for the native WebContentsView (main-process owned). The // native view paints OVER this div; we only read its rect to position it. @@ -155,9 +171,15 @@ class WebPane_ extends React.PureComponent { _stateHandler: ((e: any, payload: any) => void) | null = null; _foundHandler: ((e: any, payload: any) => void) | null = null; _domReadyHandler: ((e: any, payload: any) => void) | null = null; - // OAuth hand-off de-bounce: an MS/Google login bounces through many redirects, - // each of which would otherwise open its own system-browser tab. Open once. - _lastOAuthOpenAt = 0; + // Pending header-hover dwell (freeze-swap only fires after the cursor rests). + _headerHoverTimer: ReturnType | null = null; + // ── Background-tab notify for the agent shell pane ─────────────────────── + // Last the shell page reported (null until the first push) and a + // burst guard so rapid-fire title ticks ring at most once per window. + _lastShellTitle: string | null = null; + _lastShellBellAt = 0; + // True while OUR group-uid bell marker is set (cleared on tab activation). + _bellPending = false; searchAbortCtrl: AbortController | null = null; constructor(props: WebPaneProps) { @@ -949,8 +971,8 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { } // Click anywhere outside the dropdown and the URL bar → close it. (Clicks - // INSIDE the guest <webview> don't reach this document, so those are handled - // separately by the guest 'focus' listener in dom-ready.) + // INSIDE the native view don't reach this document — those are handled by + // the web-pane:focus push from the manager.) if ( this.state.isUrlNavigatorOpen && this.urlNavigatorRef.current && @@ -1048,6 +1070,12 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { ) { this.reportBounds(); } + + // The human switched TO this pane's tab → the shell-update bell has served + // its purpose (mirror of SESSION_SET_ACTIVE clearing terminal bells). + if (!prevProps.isTabActive && this.props.isTabActive) { + this.clearPendingBell(); + } } componentDidMount() { @@ -1149,8 +1177,10 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { if ('canGoBack' in payload) patch.canGoBack = !!payload.canGoBack; if ('canGoForward' in payload) patch.canGoForward = !!payload.canGoForward; - if ('title' in payload && payload.title && this.props.onSetTitle) { - this.props.onSetTitle(payload.title); + if ('title' in payload && payload.title) { + this.props.onSetTitle?.(payload.title); + // Agent shell pane finished a turn in a background tab → tab bell. + this.maybeBellOnShellUpdate(String(payload.title)); } let navigatedUrl: string | null = null; @@ -1232,14 +1262,9 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { 'var h=document.head||document.documentElement;h.insertBefore(s,h.firstChild);}catch(e){}})()' }).catch(() => {}); this.probePageBgColor(); - // TODO(webcontentsview Phase 2/3): these all used to attach here via - // @electron/remote on the guest webContents and are main-process concerns - // for a WebContentsView. Temporarily NOT wired: - // - before-input-event: in-page Ctrl+F / zoom / split-&-tab shortcuts - // - will-navigate / will-redirect: OAuth redirect bail-out to system browser - // - 'focus': clicking INTO the page no longer activates this pane - // (onActive) or closes the URL navigator, since native-view input - // doesn't reach this document. + // In-page input concerns (zoom shortcuts, OAuth redirect bail-out, focus → + // pane activation) are wired on the native webContents in + // app/web-pane-manager.ts and relayed here over web-pane:* IPC. }; ipcRenderer.on('web-pane:dom-ready', this._domReadyHandler); @@ -1505,6 +1530,13 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { clearInterval(this._boundsInterval); this._boundsInterval = null; } + if (this._headerHoverTimer != null) { + clearTimeout(this._headerHoverTimer); + this._headerHoverTimer = null; + } + // Don't leave an orphaned group-uid bell marker behind (sessions get the + // same cleanup via SESSION_PTY_EXIT). + this.clearPendingBell(); if (this._io) { this._io.disconnect(); this._io = null; @@ -1530,6 +1562,65 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { this.reportBounds(); }; + // ── Header hover (dwell-gated freeze-swap) ────────────────────────────── + // The header's DOM tooltips drop over the page area, which the native view + // would occlude — so a dwell on the header freeze-swaps the view out. The + // dwell gate is the anti-flash: crossing the header (mousing out of the pane, + // reaching for the tab bar) must NOT capture/hide/show the native view. + onHeaderMouseEnter = () => { + if (this._headerHoverTimer) clearTimeout(this._headerHoverTimer); + this._headerHoverTimer = setTimeout(() => { + this._headerHoverTimer = null; + this.setState({headerHover: true}); + }, HEADER_HOVER_DWELL_MS); + }; + + onHeaderMouseLeave = () => { + if (this._headerHoverTimer) { + clearTimeout(this._headerHoverTimer); + this._headerHoverTimer = null; + } + if (this.state.headerHover) this.setState({headerHover: false}); + }; + + // ── Background-tab notify for the agent shell pane ────────────────────── + // The Hyperia Agent page (sidecar /shell) signals a completed turn by + // changing its <title> (relayed here via the manager's page-title-updated → + // web-pane:state push). If that lands while this pane's TAB is inactive, + // raise the STANDARD tab notify — the same ui.bellMarkers 🔔/flash a + // terminal BEL sets, plus the same bell sound rung through a live Term + // (which respects the user's bell config: silent when bell=false). Never + // touches focus — the human's view is theirs. + isAgentShellUrl = (u: string): boolean => /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?\/shell([/?#]|$)/i.test(u); + + maybeBellOnShellUpdate = (title: string): void => { + const url = this.state.activeUrl || this.props.url || ''; + if (!this.isAgentShellUrl(url)) return; + const prev = this._lastShellTitle; + this._lastShellTitle = title; + if (prev === null || title === prev) return; // initial title / no change + if (this.props.isTabActive !== false) return; // tab is (or may be) active — no notify + const now = Date.now(); + if (now - this._lastShellBellAt < 3000) return; // burst guard + this._lastShellBellAt = now; + // Keyed by GROUP uid (not sessionUid) so we never clobber a bell the + // underlying terminal session rang; header.ts counts leaf-group markers. + this.props.onTabBell?.(this.props.groupUid); + this._bellPending = true; + // Reuse the exact terminal bell sound: any live Term's ringBell() plays + // the shared configured Audio (they're all built from the same config). + for (const t of activeTerminals.values()) { + t.ringBell(); + break; + } + }; + + clearPendingBell = (): void => { + if (!this._bellPending) return; + this._bellPending = false; + this.props.onTabBellClear?.(this.props.groupUid); + }; + // Zoom is applied to the native view in main; we track the factor here to do // the +/- clamp math (0.5–3.0, 0.1 step) and reflect it back. setZoom = (factor: number) => { @@ -1560,16 +1651,6 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { _evalHandler: ((data: {uid: string; js: string}) => void) | null = null; _mouseHandler: ((data: {uid: string; x: number; y: number; action?: string}) => void) | null = null; - // Hand an OAuth URL to the system browser, but only ONCE per flow. A single - // sign-in bounces through many redirects (login → authorize → consent → …), - // each matching isOAuthUrl; without this guard every hop opened a new tab. - openOAuthExternal = (url: string): void => { - const now = Date.now(); - if (now - this._lastOAuthOpenAt < 8000) return; - this._lastOAuthOpenAt = now; - void shell.openExternal(url); - }; - // ── Find-in-page (Ctrl+F) ──────────────────────────────────────────────── openFind = (): void => { this.setState({findOpen: true}, () => { @@ -1699,7 +1780,9 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { .catch(() => send(`# ${fallbackTitle}\n${fallbackUrl}`)); }; - handleContextMenu = (e: React.MouseEvent | any, params?: any, wc?: any) => { + // Right-click on the pane's DOM chrome (toolbar, error screen — NOT the page + // itself; the native view's in-page menu is built in app/web-pane-manager.ts). + handleContextMenu = (e: React.MouseEvent) => { if (e && typeof e.preventDefault === 'function') e.preventDefault(); /* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-call */ const remote = require('@electron/remote'); @@ -1711,24 +1794,6 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { click: () => this.reloadWebview(false) }) ); - // Inspect: dock DevTools at the bottom of THIS pane (splits down) and, when - // we have the right-click coordinates, jump straight to that element. - if (wc) { - menu.append(new MenuItem({type: 'separator'})); - menu.append( - new MenuItem({ - label: 'Inspect', - click: () => { - try { - if (!wc.isDevToolsOpened()) wc.openDevTools({mode: 'bottom'}); - if (params && typeof params.x === 'number') wc.inspectElement(params.x, params.y); - } catch (err) { - console.error('Inspect failed:', err); - } - } - }) - ); - } menu.append(new MenuItem({type: 'separator'})); menu.append(new MenuItem({label: 'New Stickys', click: () => void ipcMain.emit('new-sticky', {})})); menu.append(new MenuItem({label: 'Search Stickys', click: () => void ipcMain.emit('search-stickies')})); @@ -1757,6 +1822,24 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { const {Menu, MenuItem} = remote; const menu = new Menu(); + menu.append( + new MenuItem({ + label: 'Picker', + click: () => { + // Swap THIS web pane back to the picker (same in-group replacement + // the terminal's Picker item uses). + rpc.emit('new', { + isNewGroup: false, + activeUid: this.props.sessionUid || this.props.groupUid, + profile: 'picker', + groupUid: this.props.groupUid + }); + } + }) + ); + + menu.append(new MenuItem({type: 'separator'})); + menu.append( new MenuItem({ label: 'Split Right', @@ -1952,13 +2035,15 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { onClick={(e) => e.stopPropagation()} > {showStrip && ( - // Hovering the header hides the native view (freeze-swap) so the + // DWELLING on the header hides the native view (freeze-swap) so the // header's DOM tooltips — which a native WebContentsView would paint - // over — are visible. Restored the moment the cursor leaves. + // over — are visible. Gated behind HEADER_HOVER_DWELL_MS so transient + // mouse passes never flash the view; restored the moment the cursor + // leaves. <div style={{flexShrink: 0, display: 'flex', flexDirection: 'column'}} - onMouseEnter={() => this.setState({headerHover: true})} - onMouseLeave={() => this.setState({headerHover: false})} + onMouseEnter={this.onHeaderMouseEnter} + onMouseLeave={this.onHeaderMouseLeave} > <PaneBand ref={this.labelRef} @@ -2347,7 +2432,7 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { )} {/* Click-off backdrop: a click anywhere outside the dropdown (including on - the page area, whose <webview> clicks never reach this document and + the page area, whose native-view clicks never reach this document and whose dimmed wrapper is pointer-events:none) closes the navigator. Sits just under the dropdown (z 9999 < navigator's 10000) so the dropdown itself stays interactive. */} @@ -3078,13 +3163,6 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { user-select: none; } - webview { - border: none !important; - outline: none !important; - width: 100%; - height: 100%; - } - .web_fit { position: absolute; top: 0; @@ -3121,7 +3199,14 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { } const mapStateToProps = (state: any, ownProps: WebPaneProps) => { - const termGroup = state.termGroups.termGroups[ownProps.groupUid]; + const termGroups = state.termGroups.termGroups; + const termGroup = termGroups[ownProps.groupUid]; + // Walk up to this pane's ROOT group — its tab is active iff that root is the + // active root group (used to gate the background-tab shell bell). + let rootUid = ownProps.groupUid; + while (termGroups[rootUid]?.parentUid) { + rootUid = termGroups[rootUid].parentUid; + } return { defaultProfile: state.ui.defaultProfile, profiles: state.ui.profiles @@ -3130,7 +3215,8 @@ const mapStateToProps = (state: any, ownProps: WebPaneProps) => { state.ui.profiles.asMutable({deep: true}) : state.ui.profiles : [], - webName: termGroup ? termGroup.webName : undefined + webName: termGroup ? termGroup.webName : undefined, + isTabActive: rootUid === state.termGroups.activeRootGroup }; }; @@ -3155,6 +3241,12 @@ const mapDispatchToProps = (dispatch: HyperDispatch, ownProps: WebPaneProps) => }, onSplitWebPane(url: string, direction: 'HORIZONTAL' | 'VERTICAL') { dispatch(splitWebPane(ownProps.groupUid, url, direction) as any); + }, + onTabBell(uid: string) { + dispatch(markTabBell(uid) as any); + }, + onTabBellClear(uid: string) { + dispatch(clearTabBell(uid) as any); } }); diff --git a/lib/containers/header.ts b/lib/containers/header.ts index e7c8819c5b7..69e00a05870 100644 --- a/lib/containers/header.ts +++ b/lib/containers/header.ts @@ -110,9 +110,14 @@ const getTabs = createSelector( }; const paneColors = leaves.map((leaf, idx) => mapLeafToColor(leaf, idx)); - // Check overall activity and bell markers across all sessions in this tab + // Check overall activity and bell markers across all sessions in this tab. + // Bell markers are keyed by SESSION uid for terminal BELs; web panes (e.g. + // the Hyperia Agent shell notifying a background tab) mark their term-group + // uid instead — count both so either rings the tab. const hasActivity = leaves.some((leaf) => leaf.sessionUid && activityMarkers[leaf.sessionUid]); - const hasBell = leaves.some((leaf) => leaf.sessionUid && bellMarkers[leaf.sessionUid]); + const hasBell = leaves.some( + (leaf) => (leaf.sessionUid && bellMarkers[leaf.sessionUid]) || bellMarkers[leaf.uid as string] + ); // Agent status from active session or first session const activeSessionUid = activeSessions[t.uid]; diff --git a/lib/index.tsx b/lib/index.tsx index a472274c085..f5096a82f63 100644 --- a/lib/index.tsx +++ b/lib/index.tsx @@ -29,6 +29,7 @@ import {getRootGroups} from './selectors'; import configureStore from './store/configure-store'; import * as config from './utils/config'; import {getBase64FileData} from './utils/file'; +import {toNavigableUrl} from './utils/navigable-url'; import * as plugins from './utils/plugins'; // On Linux, the default zoom was somehow changed with Electron 3 (or maybe 2). @@ -594,15 +595,43 @@ rpc.on( 'split web pane req', ({activeUid, url, direction, isAgentInitiated}: {activeUid?: string | null; url?: string; direction?: 'HORIZONTAL' | 'VERTICAL'; isAgentInitiated?: boolean}) => { if (url) { - const full = /^[a-z]+:\/\//i.test(url) ? url : 'https://' + url; + const full = toNavigableUrl(url); store_.dispatch(termGroupActions.splitWebPane(activeUid ?? undefined, full, direction ?? 'HORIZONTAL', isAgentInitiated) as any); } } ); +// The Hyperia Agent shell (sidecar /shell page) always gets its OWN tab, +// labeled "Hyperia Agent" — and only one: re-opening focuses the existing tab. +const isHyperiaShellUrl = (u: string): boolean => { + try { + const parsed = new URL(u); + const localHost = + parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === 'hyperia.local'; + return localHost && parsed.pathname.startsWith('/shell'); + } catch { + return false; + } +}; + rpc.on('open web pane req', ({url}: {url?: string}) => { if (url) { - const full = /^https?:\/\//i.test(url) ? url : 'https://' + url; + const full = toNavigableUrl(url); + if (isHyperiaShellUrl(full)) { + // One dedicated tab: focus it if it already exists anywhere. + const {termGroups} = store_.getState(); + const existing = Object.values(termGroups.termGroups).find( + (g: any) => !g.parentUid && g.webUrl && isHyperiaShellUrl(g.webUrl) + ); + if (existing) { + store_.dispatch({type: 'TERM_GROUP_ACTIVATE_WEB_TAB', uid: (existing as any).uid} as any); + return; + } + store_.dispatch(termGroupActions.openWebPaneInNewTab(full, 'Hyperia Agent') as any); + return; + } store_.dispatch(termGroupActions.openWebPaneInNewTab(full) as any); } else { showWebPaneDialog((full) => { diff --git a/lib/reducers/term-groups.ts b/lib/reducers/term-groups.ts index f1be68a5d10..77365b52f8c 100644 --- a/lib/reducers/term-groups.ts +++ b/lib/reducers/term-groups.ts @@ -403,15 +403,20 @@ const reducer: ITermGroupReducer = (state = initialState, action) => { return state.setIn(['termGroups', uid, 'webUrl'], url); } case TERM_GROUP_ADD_WEB_TAB: { - const {url} = act; + const {url, name} = act as any; const uid = uuidv4(); const termGroup = TermGroup({uid}); - return state + let nextState = state .setIn(['termGroups', uid], termGroup) .setIn(['termGroups', uid, 'webUrl'], url) .setIn(['activeSessions', uid], null as any) .set('activeRootGroup', uid) .set('activeTermGroup', uid); + // Optional fixed tab label (e.g. "Hyperia Agent" for the shell tab). + if (name) { + nextState = nextState.setIn(['termGroups', uid, 'tabName'], name); + } + return nextState; } case 'TERM_GROUP_SPLIT_WEB' as any: { return splitWebGroup(state, act); diff --git a/lib/utils/navigable-url.ts b/lib/utils/navigable-url.ts index b13f1f3587c..4800c75cfb3 100644 --- a/lib/utils/navigable-url.ts +++ b/lib/utils/navigable-url.ts @@ -24,13 +24,14 @@ export function toNavigableUrl(input: string): string { return 'http://' + t; } - // 3. No whitespace AND every character is URL-legal → treat as a host/URL and - // default to https://. (The dotted-host case "example.com" is the common - // member of this set; host:port and single-label hosts also qualify.) - if (/^[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+$/.test(t)) { + // 3. Host-LIKE tokens only: a dotted host (example.com, sub.host.io/path) or + // an explicit host:port (myhost:8080). A bare word ("hello") is NOT a + // host — it won't resolve, so it falls through to search instead of + // dead-ending at https://hello. + if (/^[\w-]+(\.[\w-]+)+([:/?#].*)?$/.test(t) || /^[\w-]+:\d+([/?#].*)?$/.test(t)) { return 'https://' + t; } - // 4. Free text (spaces or non-URL characters) → search. + // 4. Everything else (bare words, free text, spaces) → search. return 'https://duckduckgo.com/?q=' + encodeURIComponent(t); } diff --git a/package.json b/package.json index a5ed72c11c7..3b9eaf2c08f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hyperia", - "version": "0.15.11", + "version": "0.15.15", "repository": "deepbluedynamics/hyperia", "scripts": { "start": "concurrently -n \"Webpack,TypeScript,App\" -c \"cyan.bold,blue.bold,green.bold\" \"webpack -w\" \"tsc --build -v --pretty --watch --preserveWatchOutput\" \"cross-env ELECTRONMON_LOGLEVEL=error electronmon target\" -k", @@ -144,7 +144,19 @@ "!dist/**", "!sidecar/**", "!build/**", - "!.github/**" + "!.github/**", + "!target/renderer/**", + "!target/static/**", + "!target/assets/**", + "!target/keymaps/**", + "!target/config/**", + "!target/node_modules/**", + "!target/**/*.html", + "!target/**/*.map", + "!target/**/*.tsbuildinfo", + "!target/yarn.lock", + "!target/package.json", + "!target/cli.js" ] } } diff --git a/plan/agent-to-agent-shell.md b/plan/agent-to-agent-shell.md new file mode 100644 index 00000000000..d35b99bd9a1 --- /dev/null +++ b/plan/agent-to-agent-shell.md @@ -0,0 +1,40 @@ +# Plan: external-agent turns visible + attributed in the Hyperia Shell + +## Problem +External agents (Claude Code, Codex, n8) can already talk to the ghost via +POST /api/ghost/chat — but each POST gets its OWN SSE stream. The user's open +shell pane never sees those turns: no entry line, no attribution, no reply +rendering. From the human's seat it's invisible (or looks like "you"). + +## Feature (user spec) +When an agent messages the ghost while a shell pane is open: +- a SECOND entry line appears at the bottom, labeled with the sender + (e.g. `claude-code~>` from its identity token label, `codex~>`, `n8~>`) +- the message "types out" into that line (streamed char animation), then + submits — visually identical to a human turn, but attributed +- the ghost's reply renders in the same transcript as usual + +## Design sketch +1. Session event bus: GhostSession gains a broadcast (tokio::sync::broadcast) + that mirrors every GhostEvent AND a new TurnStart{speaker, text} event. + /api/ghost/chat resolves caller identity from the Authorization header + (resolve_caller) -> speaker label ("you" for the human shell, agent label + otherwise) and publishes TurnStart before streaming. +2. Shell subscribes to GET /api/ghost/events (persistent SSE fan-out of the + bus) in addition to its own POST streams. On TurnStart from a non-human + speaker: render the second promptline (label + typing animation from the + text), then render the streamed reply events that follow. +3. Identity: external agents MUST send Authorization: Bearer hyp_agent_...; + anonymous callers render as `agent?~>`. +4. Concurrency: one turn at a time stays enforced by the existing session + lock; a second caller's TurnStart queues visibly ("codex~> waiting…"). + +## Files +- sidecar/src/ghost/agent.rs (broadcast on GhostSession) +- sidecar/src/ghost/api.rs (ghost_chat identity + publish; new ghost_events) +- sidecar/src/main.rs (route /api/ghost/events) +- sidecar/static/shell.html (subscribe; second promptline component) + +## Status +Planned only — no code yet (per instruction). Pairs with the doors-contract +full-send prompt fixes committed alongside this plan. diff --git a/plan/mcp-tool-doors.md b/plan/mcp-tool-doors.md new file mode 100644 index 00000000000..0393dfd8695 --- /dev/null +++ b/plan/mcp-tool-doors.md @@ -0,0 +1,241 @@ +# MCP Tool Doors — progressive disclosure for Hyperia's tool surface + +**Branch:** `mcp-tool-doors` · **Status:** plan · **Date:** 2026-07-02 + +**Mission:** Refactor both of Hyperia's tool surfaces — the built-in ghost agent loop and the external MCP server — to a "doors" progressive-disclosure model so that a 4B local model (Sailfish `gemma4-e4b`, 8k context) and token-billed cloud models never see 100+ tool schemas at once. Live tool set stays ≤ ~20 per turn. + +Spec source: `C:\Users\kordl\Code\DeepBlueDynamics\nuts.services\sailfish\HYPERIA_TOOLCALL_GUIDE.md` ("Recommended: gate tools behind doors"), `HYPERIA_INTEGRATION.md` (endpoints/ladder/auth), `harness\PROFILE_RESULTS.md` (6/6 tool selection on a tight menu, 5–12x decode speedup on agentic output). + +--- + +## 1. Code map (what exists today, with evidence) + +All paths relative to `C:\Users\kordl\Code\DeepBlueDynamics\hyperia\`. + +### 1.1 Ghost agent tool surface — `sidecar/src/ghost/registry.rs` (2747 lines) + +- `ToolRegistry` (registry.rs:36–54) holds `builtins: Vec<ToolDef>` + `dynamic: Arc<Mutex<Vec<DynamicTool>>>` (runtime `tool_create` tools). +- `builtin_tool_defs()` (registry.rs:1818–2236) defines **34 builtins**: `terminal_keys, terminal_cd, terminal_run, terminal_split, terminal_focus, terminal_rename, terminal_where_pane, terminal_new_window, terminal_new_tab, terminal_close, terminal_status, terminal_screen, file_read, file_write, sticky_note_create, sticky_note_create_code, sticky_note_list, sticky_note_close, sticky_note_update, sticky_note_delete, web_fetch, session_report, tab_snapshot, shell_state, shell_confirm, terminal_ui_key, auto_describe, terminal_web_reload, terminal_web_click, web_pane_content, web_pane_eval, web_pane_mouse, open_web_pane, open_settings`. +- `tool_defs(provider, model)` (registry.rs:126–192) appends **23 more**: `tool_search, tool_create, watercooler`, 10× `memory_*`, 4× `show_*` (input/button/picker/form), `doctor, model_catalog, docker_run, help, maximus_explain, tool_mount`, plus all dynamic tools. **Total ≈ 57 + dynamic.** +- A crude precedent for doors already exists: the `is_small_ollama` hard allowlist (registry.rs:160–189) shrinks to 19 tools for `e2b/1b/2b/3b/small` Ollama models. This is the thing doors replaces. +- `execute()` (registry.rs:199–253) dispatches by name: internal tools matched first, then `execute_builtin` (HTTP calls to the sidecar API on `http_port`) or `execute_dynamic`. Unknown names return `"Unknown tool: {name}"` (registry.rs:1273). +- `tool_search` (registry.rs:394–411) already does keyword search over name+description — a ready-made "search door". + +### 1.2 The agent loop — `sidecar/src/ghost/agent.rs` + +- `run_loop()` computes `tool_defs` **once, before the loop** (agent.rs:301). Per iteration it derives `effective_tool_defs` by throttle tier filtering (agent.rs:363–383: tier 1 drops `terminal_screen`, tier 2 drops `terminal_*`, tier 3 keeps only `watercooler`+`memory_*`), then calls `provider.stream(&effective_system, &send_messages, &effective_tool_defs, 4096)` (agent.rs:437–439). **The tools array is already rebuilt per provider call — doors only needs to move the base `tool_defs` computation inside the loop and make it door-state-aware.** +- Tool execution: iterates `pending_tools` (**all** of them — N tool calls per turn are supported, agent.rs:544–704), one `tool_result` block per `tool.id` (agent.rs:699–704). `tool_mount` is intercepted in the loop itself before `registry.execute()` (agent.rs:566–628) — a clean precedent for intercepting door tools with mutable loop state. +- Session state persistence across user messages: `tool_call_count` + `recent_calls` are threaded through `run()` → `run_loop()` → returned → written back (agent.rs:221–222, 241–246, 270–273). **Open-door state should ride the same path.** +- One global `GhostSession` per sidecar (`ghost/api.rs:35`), `Arc<ToolRegistry>` built at api.rs:38 and **shared with the settings agent** (`settings/api.rs:104`, which calls the same `registry.tool_defs(provider, model)` at settings/api.rs:141). +- System prompt (agent.rs:11–105) is ~1.5–2k tokens and grows with recalled memories + the full `/api/status` terminal-state JSON (agent.rs:312–335). On an 8k model this plus 57 schemas is fatal — doors alone is not enough; see Phase 3. + +### 1.3 Provider serialization — `sidecar/src/ghost/provider.rs` + +- **Anthropic** (provider.rs:121–138): `ToolDef` → `{name, description, input_schema}` verbatim. +- **OpenAI-compatible** (provider.rs:1010–1025): `ToolDef` → `{"type":"function","function":{name, description, parameters: t.input_schema}}` — `input_schema` passed **verbatim** as `parameters`. This is exactly the mechanical map the Sailfish guide's `toOpenAI()` describes. +- **OpenAI streaming parser** (provider.rs:1133–1157): tracks N parallel `tool_calls` per turn via `active_tool_calls: HashMap<index,(id,name)>`; ids echoed verbatim into history by `build_openai_messages` (provider.rs:1239–1263); `reasoning_content` handled (provider.rs:1119–1130); `finish_reason:"tool_calls"` → `"tool_use"` (provider.rs:1161–1166). All three Sailfish gotchas are already handled on this path. +- **Ollama** (provider.rs:555–953): does NOT use native tool calling. Builds a structured-output `format` JSON Schema whose `tool_name` field is an **enum of the live tool names** (provider.rs:749–781) and runs 3 parallel temperature candidates. Single tool call per turn by construction. Doors directly shrinks this enum — a large accuracy win for small models. +- **Sailfish is not a provider** — there is no `"sailfish"` arm in `AnyProvider::from_config` (provider.rs:24–31). It rides `OpenAIProvider` with `config.endpoint = http://localhost:22343` and model fetched from `/v1/models`. No code change strictly required to talk to it; a `"sailfish"` alias arm + detection ladder is a nice-to-have (Phase 6). + +### 1.4 External MCP server — `sidecar/src/mcp.rs` (3020 lines) + +- rmcp 0.15 (`sidecar/Cargo.toml:37`), `#[tool_router]` on `impl HyperiaMcp` (mcp.rs:616), `#[tool_handler]` generates `list_tools`/`call_tool` from the router (mcp.rs:2904). +- **67 `#[tool]` methods** (mcp.rs:789 `terminal_keys` … mcp.rs:2242 `auto_describe`). `tools/list` returns all 67 schemas, every time. Combined with ghost: 57 + 67 = **124 tool definitions in the codebase** — the "100+" is real. +- Capabilities: `ServerCapabilities::builder().enable_tools().build()` (mcp.rs:2976–2978) — **`list_changed` is NOT advertised** today. rmcp 0.15 supports it: `enable_tool_list_changed()` exists (`rmcp-0.15.0/src/model/capabilities.rs:431`) and `peer.notify_tool_list_changed()` exists (`rmcp-0.15.0/src/service/server.rs:448`). +- **Transport constraint:** streamable HTTP is deliberately `stateful_mode: false` (mcp.rs:3006–3019) with a **factory that constructs a fresh `HyperiaMcp` per request** (mcp.rs:3013). Consequences: (a) server→client notifications like `listChanged` have no live stream to ride; (b) per-connection state on the handler does not persist. Door state for external clients must live in a **process-global map keyed by identity** (the `Authorization` bearer is already extracted per request by `forwarded_auth`, mcp.rs:18–24). +- Discovery precursor: the `skills` tool (mcp.rs:1518–1571) already defines a 9-area taxonomy (`terminal, web, stickies, snapshots, settings, editing, styles, telemetry, diagnostics`) with tool lists — informational only, it does not gate anything. **This taxonomy becomes the door registry.** +- No deferred/lazy listing exists anywhere on the wire today. + +### 1.5 Context management — `sidecar/src/ghost/compressor.rs` + +- `ContextCompressor::compress_messages` (compressor.rs:221–246): keeps the last `keep_recent = 6` messages verbatim (compressor.rs:12), summarizes everything older into one `[Earlier context — compressed]` block via local Ollama. Applied per model call (agent.rs:430–434) **only when Ollama is reachable** (agent.rs:339–345); otherwise full history ships. +- There is **no token-count budget**, no per-model context-window awareness, and tool results are only compressed via the optional `focus=`/Maximus path (registry.rs:236–252). An 8k Sailfish window can still overflow from 6 recent messages + system prompt + tools. + +--- + +## 2. Answers to the Sailfish agent's questions (Q1–Q5) + +**Q1 — How big is the tool surface actually, and how is the menu built per turn?** +57 ghost tools (34 builtins at registry.rs:1818–2236 + 23 appended at registry.rs:126–158, + dynamic `tool_create` tools) and 67 external MCP tools (mcp.rs:789–2248). The ghost sends the full 57 every turn unless the `is_small_ollama` allowlist (registry.rs:160–189) or a throttle tier (agent.rs:363–383) kicks in; the MCP server returns all 67 on every `tools/list`. Base list computed once per user message at agent.rs:301, provider request built per iteration at agent.rs:437–439. + +**Q2 — MCP `inputSchema` vs OpenAI `parameters` mapping?** +Confirmed mechanical. `ToolDef.input_schema` (ghost/types.rs) is raw JSON Schema; the OpenAI provider passes it verbatim as `function.parameters` (provider.rs:1010–1025); Anthropic passes it verbatim as `input_schema` (provider.rs:121–130); rmcp derives the MCP `inputSchema` from the same shapes via `schemars`. The guide's `toOpenAI()` is exactly what Hyperia already does. No transformation layer needed for doors — a door just changes *which* defs are included. + +**Q3 — Parallel `tool_calls` handling?** +Yes, N-per-turn is fully handled on the Anthropic and OpenAI paths: the OpenAI stream parser demuxes concurrent tool-call deltas by `index` (provider.rs:1133–1157), the executor loops over every `pending_tools` entry and emits one `tool_result` per `tool_use_id` (agent.rs:544–704, result push at 699–704), and ids are echoed verbatim on replay (provider.rs:1239–1263). Exception: the **Ollama structured-output path emits at most one tool call per turn by construction** (single `tool_name` field in the format schema, provider.rs:758–781). Sailfish will be driven through the OpenAI path, so parallel calls work. + +**Q4 — Does the ghost truncate/trim history for small windows?** +Partially. `ContextCompressor` keeps the last 6 messages verbatim and Ollama-summarizes older ones (compressor.rs:12, 221–246; wired at agent.rs:430–434), but it is disabled when Ollama is down, has **no token budget**, and the system prompt balloons with the full terminal-state JSON (agent.rs:318–335). For an 8k model this is not sufficient — Phase 3 adds a hard token budget and a slim system prompt for doors mode. + +**Q5 — Does deferred/lazy tool listing exist today?** +Not on the wire. External `tools/list` is the full router (mcp.rs:616 + 2904) and `list_changed` isn't advertised (mcp.rs:2976–2978); the stateless HTTP config (mcp.rs:3016) currently precludes delivering the notification anyway. The in-process analogs are `tool_search` (ghost, registry.rs:394–411) and `skills` (external, mcp.rs:1518–1571) — both return *text about* tools, neither changes what is callable/advertised. Doors makes these the front door. + +--- + +## 3. Door taxonomy (grounded in actual tool names) + +A single shared module `sidecar/src/doors.rs` defines the taxonomy once; both surfaces consume it. + +```rust +pub struct Door { + pub name: &'static str, + pub description: &'static str, // one line — this is all a closed door costs + pub ghost_tools: &'static [&'static str], + pub mcp_tools: &'static [&'static str], +} +``` + +### 3.1 Ghost agent (57 tools → core 11 + 7 doors) + +**Core (always on, 11 defs incl. meta-tools):** +`terminal_status, terminal_run, terminal_screen, file_read, file_write, watercooler, memory_recall, memory_remember` + meta: `tool_search` (door-aware), `open_tools`, `close_tools`. +Rationale: run/read/screen/status matches the guide's level-0; watercooler is the yield primitive the throttle system depends on (agent.rs:379, 787); recall/remember are called constantly by the system prompt's memory rules (agent.rs:56–60). + +| Door | Ghost tools | n | core+door | +|---|---|---|---| +| `terminal` | terminal_keys, terminal_cd, terminal_split, terminal_focus, terminal_close, terminal_new_tab, terminal_new_window, terminal_rename, terminal_where_pane | 9 | 20 ✓ (terminal_ui_key moved to `inspect`) | +| `inspect` | tab_snapshot, shell_state, shell_confirm, auto_describe, session_report, maximus_explain, terminal_ui_key | 7 | 18 ✓ | +| `web` | open_web_pane, web_pane_content, web_pane_eval, web_pane_mouse, terminal_web_click, terminal_web_reload, web_fetch | 7 | 18 ✓ | +| `stickys` | sticky_note_create, sticky_note_create_code, sticky_note_list, sticky_note_update, sticky_note_close, sticky_note_delete | 6 | 17 ✓ | +| `memory_deep` | memory_dream, memory_connect, memory_status, memory_sql, memory_inspect, memory_keystone, memory_neighbors, memory_embody | 8 | 19 ✓ | +| `ui` | show_input, show_button, show_picker, show_form, tool_mount | 5 | 16 ✓ | +| `settings` | doctor, model_catalog, docker_run, help, open_settings + (settings_get/settings_set where exposed) | 5–7 | ≤18 ✓ | +| `create` | tool_create + all dynamic tools | 1+N | capped | + +Every door body ≤ 10, so core + any single door ≤ 21 → with the ≤20 cap, opening door B evicts door A (LRU). Two small doors (e.g. `web` 7 + `ui` 5 = 23) still evict; that is the intended breadth bound. + +**Settings agent** (shares the registry, settings/api.rs:141): gets its own `DoorState` with a settings-biased core (`doctor, model_catalog, help, show_input, show_button, show_picker, show_form, docker_run` + meta) and the same door catalog — today it receives all 57 tools, which is worse than what doors gives it. + +### 3.2 External MCP (67 tools → core 12 + 9 doors) + +Reuse and extend the existing `skills()` taxonomy (mcp.rs:1522–1568): + +**Core:** `terminal_status, terminal_run, terminal_screen, terminal_keys, terminal_split, tab_snapshot, request_access, request_token, hyperia_version` + meta `open_tools, close_tools, search_tools` (the `skills` tool is subsumed by `open_tools` with no args = list doors; keep `skills` as an alias during transition). + +| Door | MCP tools | n | +|---|---|---| +| `terminal_layout` | terminal_new_tab, terminal_new_window, terminal_close, terminal_focus, terminal_rename, terminal_where_pane, terminal_cd, terminal_set_window_size, terminal_flush_state | 9 | +| `inspect` | terminal_scrollback, shell_log_search, shell_state, shell_confirm, tab_image, auto_describe, terminal_ui_key | 7 | +| `web` | open_web_pane, web_pane_content, web_pane_eval, web_pane_mouse, terminal_web_click, terminal_web_reload | 6 | +| `stickys` | sticky_note_create, sticky_note_create_code, sticky_note_list, sticky_note_search, sticky_note_read, sticky_note_update, sticky_note_open, sticky_note_close, sticky_note_delete, sticky_note_schedule | 10 | +| `pulse` | pane_busy, pane_idle, pane_on_idle, pane_pulse_set, pane_pulse_clear, pane_pulse_pause, pane_pulse_status | 7 | +| `settings` | settings_get, settings_set, settings_list_profiles, settings_add_profile, settings_delete_profile, doctor | 6 | +| `styles` | style_list, style_create, style_delete, dashboard_widgets | 4 | +| `telemetry_diag` | telemetry_toggle, telemetry_snapshot, telemetry_record, telemetry_reset, sidecar_logs, audit_search, agent_status | 7 | +| `editing` | apply_text_edits | 1 | + +--- + +## 4. Mechanism A — the ghost loop (registry-side) + +### 4.1 `DoorState` (new, in `sidecar/src/doors.rs`) + +```rust +pub struct DoorState { + open: Vec<String>, // insertion-ordered = LRU (front = oldest) + cap: usize, // default 20, env HYPERIA_TOOL_CAP + enabled: bool, // doors mode on/off +} +impl DoorState { + pub fn open_door(&mut self, name) -> Vec<String /*evicted*/>; // enforce cap, LRU-evict + pub fn touch(&mut self, tool_name); // any call to a door's tool moves it to MRU + pub fn close_door(&mut self, name); +} +``` + +Lives in `GhostSession` next to `tool_call_count`/`recent_calls` (agent.rs:120–123), threaded through `run()` → `run_loop()` exactly like the throttle state (agent.rs:221–222, 237–238), returned in the result tuple and written back via `set_throttle_state`'s sibling `set_door_state` (agent.rs:270–273). Reset in `GhostSession::reset()` (agent.rs:275–283). + +### 4.2 Registry changes (`registry.rs`) + +- `tool_defs(provider, model)` → `tool_defs(provider, model, doors: Option<&DoorState>)`. When `doors` is `Some(enabled)`: emit core defs + `open_tools`/`close_tools`/`tool_search` meta defs + full schemas for tools of open doors + **nothing else**. When `None`/disabled: current behavior (full list). Update the two other call sites: registry.rs:396 (`handle_tool_search` — searches the FULL catalog always, that is the point) and settings/api.rs:141. +- **Delete the `is_small_ollama` allowlist** (registry.rs:160–189) — replaced by doors `auto` mode. +- `open_tools` def: `{door: string enum of door names}` — description lists each door name + its one-liner (this is the entire cost of the closed catalog: ~9 lines). `close_tools`: same param. +- `tool_search` result lines gain a door hint: `"- web_pane_eval [door: web — closed]: Run JS in a web pane…"` + trailing `"Call open_tools(door=\"web\") to make these callable next turn."` + +### 4.3 Loop changes (`agent.rs`) + +1. Move `registry.tool_defs(...)` from before the loop (agent.rs:301) to the top of each iteration, passing the current `DoorState`. Throttle-tier filtering (agent.rs:363–383) then applies **on top of** the door-assembled list (tiers still win — they are a stricter emergency brake). +2. Intercept `open_tools`/`close_tools` in the executor exactly like `tool_mount` (agent.rs:566–628): mutate the loop-local `DoorState`, and synthesize the result the guide prescribes ("gather on entry, expand next turn"): + ``` + Door 'web' opened. Available on your NEXT turn (7 tools): + - open_web_pane: Open a URL in a new web pane tab… + - web_pane_content: Read the current page as markdown… + … + [doors open: terminal(idle 3), web] [live tools next turn: 18/20] + [evicted: stickys — reopen with open_tools if needed] + ``` + Name+one-line only — the schemas land in the next request's `tools` array, not in the transcript. +3. **Closed-door call guard:** if the model calls a tool that exists in the catalog but is behind a closed door (it saw the name in `tool_search`, in compressed history, or hallucinated it from an earlier turn), **auto-open the door, execute the tool, and prepend a note** to the result: `"[door 'web' auto-opened by this call]"`. This is deterministic, saves a full round-trip for the 4B model, and never grants anything doors was withholding — consent/identity gating happens at the HTTP API layer, not in the menu (registry.rs:57–73 Ghost token; mcp.rs consent notes). Truly unknown names keep the existing `"Unknown tool: {name}"` (registry.rs:1273). +4. `touch()` on every executed tool; doors idle for ≥ 6 consecutive tool rounds are candidates for LRU eviction first. v1 collapse policy = **explicit `close_tools` + LRU eviction on cap overflow only** (no timer-based auto-close — keep it deterministic and testable). +5. System prompt: in doors mode, replace the "Honesty about tools" paragraph (agent.rs:17–24) with a doors contract: *"Your tool list shows a small core plus doors. A door is a category opener: call `open_tools(door=…)` (or `tool_search`) and the tools behind it become callable on your next turn. Never invent tool names; if a capability seems missing, search first."* +6. Config: `config.agent.tool_doors = "on" | "off" | "auto"` (read in `ghost::load_config`), default `auto` = on for `ollama`/openai-endpoint-override (Sailfish)/small models, on-with-larger-cap for cloud providers (doors also cuts token billing; Anthropic/OpenAI get `cap=24`). Env `HYPERIA_TOOL_DOORS=0|1` overrides. History replay is safe: neither Anthropic nor OpenAI requires past `tool_use`/`tool_calls` names to still be present in `tools`, and the Ollama path re-encodes history as plain JSON text (provider.rs:597–735). + +### 4.4 Ollama/Sailfish specifics + +- Ollama structured path: the `tool_name` enum (provider.rs:749–756) now contains ~18 names instead of ~57 — direct selection-accuracy win; no code change needed beyond receiving a shorter `tools` slice. +- Sailfish rides `OpenAIProvider` (endpoint `http://localhost:22343`). Send `temperature: 0` for tool turns per the guide — note: OpenAIProvider currently sends **no** temperature (provider.rs:997–1008); add `"temperature": 0` when doors mode is active for a small-model provider (or make it config). + +--- + +## 5. Mechanism B — the external MCP surface (`mcp.rs`) + +What "progressive disclosure" means over MCP: `tools/list` returns core + meta-tools; calling `open_tools` mutates server-side door state; the client learns about new tools either via a `notifications/tools/list_changed` (when a stateful session exists) or by re-listing because the `open_tools` result text tells it to. rmcp supports both halves (`enable_tool_list_changed()` — capabilities.rs:431; `notify_tool_list_changed()` — service/server.rs:448), **but Hyperia's transport is stateless** (mcp.rs:3016, per-request handler factory at mcp.rs:3013), so: + +1. **Door state store:** process-global `OnceLock<Mutex<HashMap<String /*identity*/, DoorState>>>` keyed by the bearer token from `forwarded_auth` (mcp.rs:18–24); anonymous callers share one `"anon"` bucket. Entries expire after ~30 min idle. +2. **Hand-written `list_tools`/`call_tool`:** drop the `#[tool_handler]` macro (mcp.rs:2904) and implement `ServerHandler::list_tools` manually: `self.tool_router.list_all()` filtered to core + open doors for this identity, plus synthesized `open_tools`/`close_tools`/`search_tools` meta-tools (the existing `skills` method becomes the data source for `open_tools` with no args). `call_tool`: meta-tools handled inline; router tools delegate to `self.tool_router.call(...)` with the same auto-open-on-closed-door behavior as the ghost (never a hard error for a real tool — MCP clients cache lists and will legitimately call "closed" tools). +3. **Compat mode is the default.** `HYPERIA_MCP_DOORS=1` (env) or `config.mcp.doors=true` enables gating; otherwise `list_tools` returns all 67 as today. Rationale: Claude Code and other long-lived MCP clients cache `tools/list` and rely on the full set (the Claude Code harness already defers `mcp__hyperia__*` schemas client-side, so the external win is smaller than the ghost win). Additionally honor a per-request opt-in so one client can get doors while others don't: `Mcp-Doors: 1` header (visible via the injected `axum::http::request::Parts`, same mechanism as `forwarded_auth`). +4. **`listChanged` (best-effort, Phase 5):** advertise `enable_tool_list_changed()` only when doors mode is on; when a stateful session exists (stdio transport `run_mcp_stdio` mcp.rs:2985–2990 — this one IS stateful), fire `notify_tool_list_changed` after `open_tools`/`close_tools`. On stateless HTTP, rely on the result-text contract: `open_tools` result ends with `"Re-run tools/list to fetch the new schemas."` Sailfish's own harness rebuilds the `tools` array every turn anyway (guide §"The loop"), so it needs no notification. +5. `skills` stays as a read-only alias forever (cheap, existing clients call it). + +--- + +## 6. Phased build order (small commits on `mcp-tool-doors`, each testable against live MCP at `localhost:9800`) + +**Phase 0 — measurement baseline (no behavior change).** +Add a `tracing::info!` line in `run_loop` logging per-iteration tool count + serialized-schema byte size, and the same in MCP `list_tools`. +*Test:* JSON-RPC `tools/list` against `http://localhost:9800/mcp` (curl or an MCP inspector); confirm 67 tools and record the byte/token size (expect ~15–25k tokens). Commit: `doors: instrument tool-surface size`. + +**Phase 1 — `doors.rs` + DoorState (pure, unit-tested).** +New module with the taxonomy tables (§3), `DoorState` with cap/LRU/eviction, and exhaustive unit tests: cap enforcement, LRU order, auto-open eviction, every catalog tool belongs to exactly one door or core, every door ≤ 10 tools (compile-time-ish assert in a test). +*Test:* `cargo test doors`. Commit: `doors: taxonomy + DoorState`. + +**Phase 2 — ghost registry + loop wiring, default OFF.** +`tool_defs(provider, model, doors)`, meta-tool defs, per-iteration rebuild in `run_loop`, `open_tools`/`close_tools` intercept (tool_mount pattern), auto-open guard, door-aware `tool_search`, `set_door_state` session plumbing, settings/api.rs call-site update. Flag `HYPERIA_TOOL_DOORS` default off. +*Test:* run sidecar with `HYPERIA_TOOL_DOORS=1` + Ollama/Sailfish configured; in the ghost chat ask "open google and read the page" — verify turn 1 offers no `web_pane_*`, the model calls `open_tools(web)` (or `web_fetch` path), turn 2 tools array contains the web door (visible in the Phase-0 log line), task completes. Regression: flag off → byte-identical tool list to main. Commit: `doors: ghost loop progressive disclosure (flagged)`. + +**Phase 3 — small-model hardening.** +Delete `is_small_ollama` allowlist; `config.agent.tool_doors=auto` logic; slim doors-mode system prompt; add `temperature: 0` for tool turns on the OpenAI provider when configured; add a hard token-budget guard in `compress_messages` (estimate 4 chars/token; if system+tools+history > budget from a `config.agent.context_tokens`, drop `keep_recent` from 6 toward 2 before shipping). +*Test:* Sailfish live at `localhost:22343` (detection: `GET /api/status`): run the guide's ls-and-count task through the ghost with doors on; verify prompt_tokens in Sailfish's response stays under ~2k on turn 1 (guide's example was 129 with 2 tools; we should land ~1–2k with core+doors), and 6/6-style selection on a 3-task smoke script. Commit: `doors: auto mode for small models, 8k budget`. + +**Phase 4 — external MCP surface, default OFF.** +Global identity-keyed door store; replace `#[tool_handler]` with hand-written `list_tools`/`call_tool`; meta-tools; `HYPERIA_MCP_DOORS` env + `Mcp-Doors` header; `skills` alias kept. +*Test:* against live `localhost:9800/mcp` — (a) flag off: `tools/list` returns 67 (Claude Code session keeps working, run one `terminal_status` from a real Claude Code session); (b) `Mcp-Doors: 1`: list returns ~15, `open_tools(stickys)` then re-list returns +10, calling `sticky_note_list` while door closed auto-opens and succeeds. Commit: `doors: MCP tools/list gating (flagged, identity-keyed)`. + +**Phase 5 — notifications + polish.** +`enable_tool_list_changed()` when doors on; `notify_tool_list_changed` on the stdio transport; door-state surfaced in `agent_status`; docs in BUILDING.md/README; consider `stateful_mode: true` opt-in path for clients that want real notifications (revisit the restart-404 tradeoff documented at mcp.rs:2996–3005 before flipping). +*Test:* stdio MCP client sees `listChanged` after `open_tools`. Commit: `doors: listChanged + docs`. + +**Phase 6 (optional, adjacent) — Sailfish provider alias + detection ladder.** +`"sailfish"` arm in `AnyProvider::from_config` → `OpenAIProvider` with endpoint default `http://localhost:22343`, model id fetched from `/v1/models`, `/api/status` health probe, 120s first-call warmup handling per HYPERIA_INTEGRATION.md. Separate commit(s); not required for doors. + +--- + +## 7. Risks & mitigations + +- **Breaking external agents (Claude Code caches full `tools/list`).** Doors on the MCP surface is opt-in (`HYPERIA_MCP_DOORS` / `Mcp-Doors` header), default off forever until clients prove out. Ghost-side doors never affects external clients. +- **Doors ≠ security.** The menu is a UX/token concern; consent (`request_access`, soft-wall 202/403) and identity (Ghost's own `hyp_agent_` token, registry.rs:57–73) remain enforced at the HTTP API. Auto-open-on-call is therefore safe by construction — document this invariant in `doors.rs`. +- **Widgets/`tool_mount`:** if the `ui` door is closed when the settings flow needs `show_picker`, the system-prompt flows (agent.rs:97–105) would name unavailable tools. Mitigation: settings agent's DoorState pre-opens `ui`+`settings`; ghost auto-open guard covers stragglers; audit SYSTEM_PROMPT for tool names and gate those paragraphs on door membership (Phase 2 checklist). +- **Settings agent shares the registry** (settings/api.rs:104,141): signature change must update it in the same commit; give it its own DoorState (§3.1) — never share door state between the two sessions. +- **Throttle-tier interaction:** tiers filter after door assembly; tier 3's `memory_*`-only list must still include `watercooler` (it does) — add a test that tiered filtering of a doored list is non-empty. +- **Model calls a door name as if it were the tool** (`web` instead of `open_tools(door=web)`): accept `web`/`open_web` as aliases in the executor intercept; cheap and 4B-friendly. +- **History references to closed tools:** benign on Anthropic/OpenAI (past tool_use ids don't need live defs) and the Ollama path re-encodes history as text; covered by Phase-2 regression run. +- **Compressor summarizing away door announcements:** the "what's now available" text is transcript-only; ground truth is the DoorState, which is never derived from the transcript — announcements can be lossy without breaking anything. +- **rmcp macro removal risk:** hand-written `list_tools` must stay in sync with the router — build it from `self.tool_router.list_all()` (never a hand-maintained list) and filter by name; add a test asserting router names ⊆ doors catalog ∪ core. + +### Critical files +- `sidecar/src/ghost/registry.rs` (tool catalog, `tool_defs`, `execute`, tool_search; the is_small_ollama allowlist to delete) +- `sidecar/src/ghost/agent.rs` (run_loop per-turn tools assembly at :301/:363–439, tool_mount-style intercept at :566, session state plumbing) +- `sidecar/src/mcp.rs` (67 #[tool] methods, `skills` taxonomy at :1518, ServerHandler/get_info at :2904, stateless transport at :3006) +- `sidecar/src/ghost/provider.rs` (per-provider tool serialization :121/:1010, Ollama tool_name enum :749, OpenAI parallel tool_calls :1133) +- `sidecar/src/ghost/compressor.rs` (keep_recent/compress_messages :221 — 8k token budget hook) +- NEW: `sidecar/src/doors.rs` (shared Door taxonomy + DoorState) diff --git a/plan/pane-editor-spec.md b/plan/pane-editor-spec.md new file mode 100644 index 00000000000..4a48497017a --- /dev/null +++ b/plan/pane-editor-spec.md @@ -0,0 +1,121 @@ +# Spec: Per-Pane Multi-Agent Code Editor + +**Status:** input for a planner — this document fixes scope, inventory, and +constraints; the planner designs the implementation. + +## Goal + +A code editor that lives in a Hyperia **pane** (peer of terminal panes and web +panes), one instance per pane, where **multiple agents and the human edit files +in a shared workspace**. The workspace is managed by **nemesis8 (n8)** and +mounted into each agent's container; the editor shows multiple open files and +live status (dirty/saved/who-touched-it). Agents keep editing through the +existing tool; the pane is where the human sees, reviews, and edits the same +files. + +## Current state — the editor we already have (inventory) + +The "multi-line editor tool" today is **Aegis-Edit**, three layers deep: + +| Layer | Where | What | +|---|---|---| +| Engine | **`./aegis-edit`** (crate at repo root; path dep in `sidecar/Cargo.toml:25`) | LOPT Document + `TextEdit`. Grapheme-cluster (line,col) coordinates — can never split a codepoint/emoji. Multiple DISJOINT edits validated up front (overlap → reject, file untouched), applied back-to-front, written atomically (temp + rename). `preview=true` returns result without writing. | +| HTTP | `POST /api/edit/apply` — `sidecar/src/main.rs` `post_edit_apply` (~:2534) | Body `{path, edits:[{start_line,start_col,end_line,end_col,text}], preview?}`. Gated on the **"files" capability** (`enforce_capability`). | +| MCP tool | `apply_text_edits` — `sidecar/src/mcp.rs:2134` | External-agent surface only, behind the **`editing` door** (`doors.rs:306`). NOT currently on the ghost surface. | + +Adjacent prior art to reuse, not reinvent: +- **Code-mode stickies** (`app/sticky.ts`): `filePath` = read-only syntax-highlighted + viewer (highlight.min.js); `source:{kind:'file',path}` = editable note bound to a + file (load on open, debounced write-back). Proof that file-bound editing UI + already works in this app. +- **Web panes** (WebContentsView) render sidecar-served pages (`/shell`, `/guide`, + `/agent/config`) — a served `/editor` page is a viable UI vehicle. +- **Container path translation**: `translateContainerPath` in `app/sticky.ts` + already maps n8 container paths → host paths. The editor needs the same + mapping, centralized (see Requirements 6). + +## Permanent home (migration plan for what exists) + +1. **Engine stays a standalone crate** — promote `./aegis-edit` from an ad-hoc + path dep to a declared member of a root `[workspace]` (create the workspace + manifest; sidecar + aegis-edit as members). It is the single write path for + ALL programmatic edits — editor UI included — so it must not get absorbed + into sidecar internals. Add its own README + tests as the contract. +2. **API grows beside the engine**: `/api/edit/*` becomes a small family + (`apply` today; planner adds `read`, `open`, `status` — see below) in a new + `sidecar/src/edit_api.rs` module rather than accreting in `main.rs`. +3. **Tool surface**: `apply_text_edits` stays the agent verb, added to the + **ghost surface** as well (new/existing `editing` door on Surface::Ghost) so + the built-in agent can edit too. +4. **UI**: new pane type `editor` (planner chooses served-page vs native React — + see Open Questions), launched from the picker, pane context menus, and an + `editor_open` tool. + +## Functional requirements + +1. **Per-pane instances.** Each editor pane is independent (own open-file set, + own scroll/cursor). Multiple editor panes may view the same file. +2. **Multiple open files** per pane — a file-tab strip inside the pane; a + simple workspace file tree or quick-open (planner picks minimal v1). +3. **Status, per file and per pane:** + - dirty / saved / external-change (file changed on disk under us) + - last writer attribution: which AGENT (by identity label) or the human + last modified the file — sourced from `/api/edit/apply` callers + - a lightweight activity feed/badge when an agent edits a file that is open + in the pane (flash the tab; never steal focus — hard rule). +4. **Human edits and agent edits converge on one write path.** The pane's + saves go through the same Aegis-Edit transaction API (whole-buffer replace + is expressible as one edit), with **optimistic concurrency**: every read + returns a revision token (content hash); every write carries the token and + is rejected on mismatch → pane shows a conflict state (reload/overwrite + diff). No CRDTs in v1 — rev tokens + disjoint-edit validation is the model. +5. **Live refresh:** when `/api/edit/apply` mutates a file open in any editor + pane, the pane refreshes (SSE or the existing rpc push pattern). File + watcher on the workspace root is the fallback for out-of-band writes. +6. **Workspace = n8-managed.** The editor operates on a workspace ROOT + registered by nemesis8. n8 owns the mount map: host path ↔ per-container + path. Requirements: + - a single sidecar-side path-translation module (move/absorb + `translateContainerPath` logic; sticky.ts becomes a consumer) + - agents inside containers pass container paths to `apply_text_edits`; + the sidecar normalizes to host paths before Aegis-Edit + - the editor pane displays workspace-relative paths. +7. **Syntax highlighting** (highlight.min.js is already vendored) + line + numbers. No LSP, no autocomplete in v1. +8. **Security:** every mutating call keeps the "files" capability gate; + editor-pane saves ride an authenticated/system-side path like other UI + surfaces (cf. `/api/agent/config/edit` precedent for anonymous-page actions). + +## Non-goals (v1) + +- No CRDT/simultaneous character-level co-editing (rev-token conflicts only) +- No LSP/diagnostics/format-on-save +- No git integration beyond showing dirty state (git lives in the terminal) +- No remote (non-mounted) file systems + +## Phase skeleton for the planner to elaborate + +- **P1 — engine/API:** workspace manifest for the crate; `edit_api.rs`; + `GET /api/edit/read` (content + rev), `POST /api/edit/apply` gains `rev` + precondition; path-translation module; change events (SSE or rpc). +- **P2 — editor pane, single file:** pane type + picker/menu entry + + `editor_open` tool; open/edit/save with rev conflict UX; highlighting. +- **P3 — multi-file:** tab strip, quick-open, per-file status chips. +- **P4 — multi-agent status:** last-writer attribution, open-file flash on + agent edits, per-pane activity line. +- **P5 — n8 workspace integration:** workspace registration handshake with + nemesis8, mount-map sync, container-path normalization end-to-end test with + two agents + human editing the same workspace. + +## Open questions (planner must answer or escalate) + +1. UI vehicle: sidecar-served page in a web pane (fast, matches /shell; weaker + keyboard/focus integration) vs native React pane (first-class focus/keys; + more renderer work). Recommendation to evaluate first: served page v1. +2. Editor widget: hand-rolled textarea+overlay (like stickies) vs vendoring + CodeMirror 6 (proper editing UX; ~300KB). Evaluate CM6 first — multi-file + + status wants real editor infrastructure. +3. Does n8 need per-agent write scoping (agent A read-only on dir X) in v1, + or is the "files" capability boundary enough? +4. Rev token granularity: per-file hash vs per-file monotonic counter held by + the sidecar (survives external writes worse). Default: content hash. diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 00000000000..8afdefea38f --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,45 @@ +# dev.ps1 - the ONLY sanctioned way to (re)start the Hyperia dev loop. +# +# Guarantees exactly one stack: sweeps every hyperia-scoped watcher/electron/ +# sidecar process, VERIFIES zero remain, launches one `yarn start`, then the +# app-level single-instance lock hard-exits any accidental duplicate. Never +# blanket-kills electron.exe/node.exe - always matched by command line, so +# other Electron apps and unrelated node processes are untouched. +# ASCII ONLY in this file: Windows PowerShell 5.1 misparses UTF-8 without BOM. +$ErrorActionPreference = 'SilentlyContinue' +$repo = Split-Path -Parent $PSScriptRoot + +function Get-HypNode { + Get-CimInstance Win32_Process -Filter "Name='node.exe'" | + Where-Object { $_.CommandLine -match 'DeepBlueDynamics.hyperia' } +} +function Get-HypElectron { + Get-CimInstance Win32_Process -Filter "Name='electron.exe'" | + Where-Object { $_.CommandLine -like '*DeepBlueDynamics\hyperia*' } +} + +# 1) Sweep +Get-HypNode | ForEach-Object { Stop-Process -Id $_.ProcessId -Force } +Get-HypElectron | ForEach-Object { Stop-Process -Id $_.ProcessId -Force } +try { taskkill /f /im hyperia-sidecar.exe 2>$null | Out-Null } catch {} +Start-Sleep -Seconds 2 + +# 2) Verify zero - refuse to launch into a dirty state +$n = @(Get-HypNode).Count +$e = @(Get-HypElectron).Count +if ($n -gt 0 -or $e -gt 0) { + Get-HypNode | ForEach-Object { Stop-Process -Id $_.ProcessId -Force } + Get-HypElectron | ForEach-Object { Stop-Process -Id $_.ProcessId -Force } + Start-Sleep -Seconds 2 + $n = @(Get-HypNode).Count + $e = @(Get-HypElectron).Count + if ($n -gt 0 -or $e -gt 0) { + Write-Error "dev.ps1: could not reach zero (node=$n electron=$e) - NOT launching." + exit 1 + } +} +Write-Host "dev.ps1: clean slate (node=0 electron=0) - launching one stack" + +# 3) Launch ONE stack (foreground of this shell; backgrounding is the caller's job) +Set-Location $repo +yarn start diff --git a/scripts/hyperia-local.ps1 b/scripts/hyperia-local.ps1 new file mode 100644 index 00000000000..8b16c5e9afc --- /dev/null +++ b/scripts/hyperia-local.ps1 @@ -0,0 +1,42 @@ +# hyperia-local.ps1 - map hyperia.local to 127.0.0.1 in the Windows hosts file +# so http://hyperia.local:9800/shell works in ANY browser on this machine +# (Chrome, Edge, and inside Hyperia web panes). Idempotent; self-elevates. +# ASCII ONLY in this file: Windows PowerShell 5.1 misparses UTF-8 without BOM. +# +# Usage: +# powershell -ExecutionPolicy Bypass -File scripts\hyperia-local.ps1 # install +# powershell -ExecutionPolicy Bypass -File scripts\hyperia-local.ps1 -Remove # uninstall +param([switch]$Remove) + +$hostsPath = "$env:SystemRoot\System32\drivers\etc\hosts" +$entry = "127.0.0.1`thyperia.local" +$marker = 'hyperia.local' + +# Self-elevate: hosts edits need admin. +$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + $args = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath) + if ($Remove) { $args += '-Remove' } + Start-Process powershell -Verb RunAs -ArgumentList $args -Wait + exit $LASTEXITCODE +} + +$lines = Get-Content $hostsPath -ErrorAction Stop +$has = $lines | Where-Object { $_ -match $marker -and $_ -notmatch '^\s*#' } + +if ($Remove) { + if ($has) { + $lines | Where-Object { $_ -notmatch $marker } | Set-Content $hostsPath -Encoding ASCII + Write-Host "removed hyperia.local from hosts" + } else { + Write-Host "hyperia.local not present - nothing to do" + } +} else { + if ($has) { + Write-Host "hyperia.local already mapped - nothing to do" + } else { + Add-Content $hostsPath -Value $entry -Encoding ASCII + Write-Host "mapped hyperia.local -> 127.0.0.1" + } + Write-Host "try it: http://hyperia.local:9800/shell" +} diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock index a0ba76631c7..0bb37a642f5 100644 --- a/sidecar/Cargo.lock +++ b/sidecar/Cargo.lock @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "hyperia-sidecar" -version = "0.15.11" +version = "0.15.15" dependencies = [ "aegis-edit", "anyhow", diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index aab9afa8a04..5b2a15ef54f 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyperia-sidecar" -version = "0.15.11" +version = "0.15.15" edition = "2021" description = "Rust sidecar for Hyperia: agent engine, MCP, signaling" diff --git a/sidecar/src/bridge.rs b/sidecar/src/bridge.rs index 8db1f37b4e3..befeb4c49c3 100644 --- a/sidecar/src/bridge.rs +++ b/sidecar/src/bridge.rs @@ -211,6 +211,15 @@ struct BridgeInner { perms: crate::perms::PermStore, /// Persistent external-agent identities (file-backed, survive restarts). identity: crate::identity::IdentityStore, + /// OS pixel bounds per window id (from the renderer): {width,height,x,y}. + /// Lets terminal_status report real window size + resize relative to it. + window_bounds: Mutex<HashMap<u32, serde_json::Value>>, + /// Agent TOKENS that bypass consent (Hyperia's OWN built-in agent — the + /// ghost). Trust is by token, never by name (names are spoofable). The + /// user configured this agent deliberately; prompting them to approve its + /// every pane action is asking permission for the thing they just asked + /// it to do. Populated at startup with the freshly-minted ghost token. + trusted_agent_tokens: std::sync::Mutex<std::collections::HashSet<String>>, } impl Bridge { @@ -231,10 +240,35 @@ impl Bridge { lume: crate::lume_store::LumeStore::new(), perms: crate::perms::PermStore::default(), identity: crate::identity::IdentityStore::new(), + window_bounds: Mutex::new(HashMap::new()), + trusted_agent_tokens: std::sync::Mutex::new(std::collections::HashSet::new()), }), } } + /// Mark an agent TOKEN as consent-exempt (Hyperia's own built-in agent). + pub fn trust_agent_token(&self, token: &str) { + if token.is_empty() { + return; + } + if let Ok(mut set) = self.inner.trusted_agent_tokens.lock() { + set.insert(token.to_string()); + } + } + + /// Is this caller a consent-exempt trusted agent (by token, never by name)? + fn is_trusted_agent(&self, id: &crate::identity::CallerIdentity) -> bool { + if let crate::identity::CallerIdentity::Agent { token, .. } = id { + return self + .inner + .trusted_agent_tokens + .lock() + .map(|s| s.contains(token)) + .unwrap_or(false); + } + false + } + /// Access the lume store (per-shell log search, sticky search, persistence). pub fn lume(&self) -> crate::lume_store::LumeStore { self.inner.lume.clone() @@ -327,6 +361,11 @@ impl Bridge { if !self.inner.perms.enforced() { return AuthDecision::Allow; } + // Hyperia's own agent (trusted token) drives panes without consent — + // the user configured it; its actions ARE the user's ask. + if self.is_trusted_agent(id) { + return AuthDecision::Allow; + } match id { CallerIdentity::System => AuthDecision::Allow, CallerIdentity::Pane { pane, .. } if pane == target_pane => AuthDecision::RefuseHome, @@ -360,6 +399,9 @@ impl Bridge { if !self.inner.perms.enforced() { return AuthDecision::Allow; } + if self.is_trusted_agent(id) { + return AuthDecision::Allow; + } match id { CallerIdentity::System => AuthDecision::Allow, CallerIdentity::Anonymous => AuthDecision::SoftWall, @@ -390,6 +432,9 @@ impl Bridge { if !self.inner.perms.enforced() { return AuthDecision::Allow; } + if self.is_trusted_agent(id) { + return AuthDecision::Allow; + } match id { CallerIdentity::System => AuthDecision::Allow, CallerIdentity::Anonymous => AuthDecision::SoftWall, @@ -1177,6 +1222,37 @@ impl Bridge { self.resolve_pane_uid(window, tab, None).await } + /// Resolve a target window id (#119). An explicit `window` resolves only if + /// it currently hosts sessions; an omitted window resolves to the focused + /// window, else the lowest-id window. Returns `None` only when the explicit + /// window doesn't exist, or when there are no windows at all. + /// + /// Used by new-tab/new-window style operations that target a WINDOW rather + /// than a specific pane, so they land in the focused window by default + /// instead of relying on the renderer's OS focus (which can diverge from the + /// sidecar's tracked focus). + pub async fn resolve_window_id(&self, window: Option<u32>) -> Option<u32> { + let focused_window_id = *self.inner.focused_window_id.lock().await; + let sessions = self.inner.sessions.lock().await; + + let mut ids: Vec<u32> = sessions.values().map(|i| i.window_id).collect(); + ids.sort_unstable(); + ids.dedup(); + + if let Some(w) = window { + // Explicit window: honor it only if it actually hosts sessions. + return ids.into_iter().find(|id| *id == w); + } + // Omitted: prefer the focused window when it hosts sessions, else the + // lowest-id window (BTreeMap/get_status order). + if let Some(focused) = focused_window_id { + if ids.contains(&focused) { + return Some(focused); + } + } + ids.into_iter().next() + } + /// Get status of all registered sessions, grouped by window and tab. pub async fn get_status(&self) -> serde_json::Value { use sysinfo::{Pid, ProcessesToUpdate, System}; @@ -1185,6 +1261,7 @@ impl Bridge { sys.refresh_processes(ProcessesToUpdate::All, true); let focused_window_id = *self.inner.focused_window_id.lock().await; + let win_bounds = self.inner.window_bounds.lock().await.clone(); let sessions = self.inner.sessions.lock().await; // Group sessions by window_id @@ -1318,11 +1395,31 @@ impl Bridge { } let focused = focused_window_id.map(|id| id == *win_id).unwrap_or_else(|| windows.is_empty()); - windows.push(serde_json::json!({ + let mut win_obj = serde_json::json!({ "id": win_id, "focused": focused, "tabs": tabs, - })); + }); + // Real OS pixel size (from the renderer's WindowBounds reports), so + // the agent can answer "how big is the window" and resize by it. + // Keep the raw fields for resize math, but ALSO ship a pre-rendered + // unambiguous `size` string — a small model reading raw width/height/ + // x/y tends to confuse the y-offset for the height. The string leaves + // no room for that: it can just parrot it. + if let Some(b) = win_bounds.get(win_id) { + let w = b["width"].as_i64().unwrap_or(0); + let h = b["height"].as_i64().unwrap_or(0); + let x = b["x"].as_i64().unwrap_or(0); + let y = b["y"].as_i64().unwrap_or(0); + win_obj["width"] = b["width"].clone(); + win_obj["height"] = b["height"].clone(); + win_obj["x"] = b["x"].clone(); + win_obj["y"] = b["y"].clone(); + win_obj["size"] = serde_json::json!(format!( + "{w} px wide × {h} px tall, positioned at x={x} y={y}" + )); + } + windows.push(win_obj); } serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "windows": windows }) @@ -1514,6 +1611,19 @@ impl Bridge { } } + "WindowBounds" => { + let window_id = msg["windowId"].as_u64().unwrap_or(0) as u32; + if window_id != 0 { + self.inner.window_bounds.lock().await.insert( + window_id, + serde_json::json!({ + "width": msg["width"], "height": msg["height"], + "x": msg["x"], "y": msg["y"], + }), + ); + } + } + "WindowFocus" => { let window_id = msg["windowId"].as_u64().unwrap_or(0) as u32; *self.inner.focused_window_id.lock().await = Some(window_id); @@ -1770,6 +1880,45 @@ mod tests { }); } + #[test] + fn resolve_window_id_defaults_to_focused_then_lowest() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let bridge = Bridge::new(); + { + let mut sessions = bridge.inner.sessions.lock().await; + sessions.insert("win0-a".into(), session_info(10, "tab-0", "a", true, true)); + sessions.insert("win1-a".into(), session_info(20, "tab-1", "a", true, true)); + } + + // No sessions yet, no focus → None (renderer bootstraps first window). + // (checked on a fresh bridge below) + + // Focused window hosts sessions → return it. + *bridge.inner.focused_window_id.lock().await = Some(20); + assert_eq!(bridge.resolve_window_id(None).await, Some(20)); + + // Stale focus (window with no sessions) → fall back to lowest id. + *bridge.inner.focused_window_id.lock().await = Some(999); + assert_eq!(bridge.resolve_window_id(None).await, Some(10)); + + // Explicit window that exists → honored. + assert_eq!(bridge.resolve_window_id(Some(20)).await, Some(20)); + // Explicit window that doesn't exist → None (handler turns this into 404). + assert_eq!(bridge.resolve_window_id(Some(99)).await, None); + }); + } + + #[test] + fn resolve_window_id_none_when_no_sessions() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + let bridge = Bridge::new(); + assert_eq!(bridge.resolve_window_id(None).await, None); + assert_eq!(bridge.resolve_window_id(Some(1)).await, None); + }); + } + #[test] fn status_marks_real_focused_window_and_active_pane() { let rt = tokio::runtime::Runtime::new().unwrap(); diff --git a/sidecar/src/doors.rs b/sidecar/src/doors.rs new file mode 100644 index 00000000000..78b57ab4776 --- /dev/null +++ b/sidecar/src/doors.rs @@ -0,0 +1,1033 @@ +//! Door taxonomy + `DoorState` — the shared, pure data layer for the +//! MCP-tool-doors progressive-disclosure model (plan/mcp-tool-doors.md §3–§4). +//! +//! A **door** is a named category of tools. A small always-on **core** plus a +//! bounded set of *open* doors is all a model ever sees at once, so a 4B local +//! model (Sailfish, 8k context) or a token-billed cloud model never faces the +//! full 100+ tool catalog. +//! +//! ## Two surfaces, one catalog +//! Hyperia has two independent tool surfaces — the built-in ghost agent loop +//! (`ghost/registry.rs`) and the external MCP server (`mcp.rs`). They share the +//! *concept* of doors but expose different tool sets and, in places, different +//! door names (ghost `terminal` vs MCP `terminal_layout`). A single [`Door`] +//! entry therefore carries both a `ghost_tools` and an `mcp_tools` slice; a door +//! that only exists on one surface leaves the other slice empty. Pick the slice +//! for a surface with [`Door::tools`]. +//! +//! ## Doors are NOT security +//! The door menu is a UX / token-budget concern only. Consent (`request_access`, +//! 202/403 soft-walls) and identity (Ghost's `hyp_agent_` token) are enforced at +//! the HTTP API layer, never here. Auto-opening a door on a direct tool call is +//! therefore safe by construction — it grants nothing the menu was withholding. +//! +//! This module is intentionally pure (no I/O, no async): it is exhaustively +//! unit-tested and consumed by both surfaces in later phases. + +/// Which tool surface a [`DoorState`] / lookup applies to. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum Surface { + Ghost, + Mcp, +} + +/// A category of tools. Populated per surface: a door absent from a surface has +/// an empty slice there (see module docs). +#[derive(Clone, Copy, Debug)] +pub struct Door { + pub name: &'static str, + /// One-line summary — this is the *entire* cost of a closed door in the + /// menu, so keep it terse. + pub description: &'static str, + pub ghost_tools: &'static [&'static str], + pub mcp_tools: &'static [&'static str], +} + +impl Door { + /// The tool names this door exposes on `surface` (empty if the door does + /// not exist there). + pub fn tools(&self, surface: Surface) -> &'static [&'static str] { + match surface { + Surface::Ghost => self.ghost_tools, + Surface::Mcp => self.mcp_tools, + } + } +} + +// --------------------------------------------------------------------------- +// Core tool sets (always on). Includes the meta-tools that drive doors. +// --------------------------------------------------------------------------- + +/// Ghost agent core — 11 defs (plan §3.1). `open_tools`/`close_tools` are new +/// meta-tools (added in Phase 2); `tool_search` is an existing registry tool. +pub const GHOST_CORE: &[&str] = &[ + "terminal_status", + "terminal_run", + "terminal_screen", + "file_read", + "file_write", + "watercooler", + "memory_recall", + "memory_remember", + // meta: + "tool_search", + "open_tools", + "close_tools", +]; + +/// External MCP core — 12 defs (plan §3.2). `open_tools`/`close_tools`/ +/// `search_tools` are new meta-tools (added in Phase 4). +pub const MCP_CORE: &[&str] = &[ + "terminal_status", + "terminal_run", + "terminal_screen", + "terminal_keys", + "terminal_split", + "tab_snapshot", + "request_access", + "request_token", + "hyperia_version", + // meta: + "open_tools", + "close_tools", + "search_tools", +]; + +/// Meta-tool names that live in the core but are NOT backed by a real +/// tool-def / router entry yet (added in Phase 2/4). Excluded when +/// cross-checking a core list against a live tool catalog. +pub const GHOST_META: &[&str] = &["open_tools", "close_tools"]; +pub const MCP_META: &[&str] = &["open_tools", "close_tools", "search_tools"]; + +/// Core tools for a surface. +pub fn core_tools(surface: Surface) -> &'static [&'static str] { + match surface { + Surface::Ghost => GHOST_CORE, + Surface::Mcp => MCP_CORE, + } +} + +// --------------------------------------------------------------------------- +// Door catalog (plan §3.1 ghost table + §3.2 MCP table). +// +// Doors shared across surfaces (same name, both slices populated): inspect, +// web, stickys, settings. Ghost-only: terminal, memory_deep, ui, create. +// MCP-only: terminal_layout, pulse, styles, telemetry_diag, editing. +// --------------------------------------------------------------------------- + +pub const DOORS: &[Door] = &[ + // ---- shared-name doors ------------------------------------------------- + Door { + name: "inspect", + description: "Snapshots, shell state, confirmation & description of what's on screen", + ghost_tools: &[ + "tab_snapshot", + "shell_state", + "shell_confirm", + "auto_describe", + "session_report", + "maximus_explain", + "terminal_ui_key", + ], + mcp_tools: &[ + "terminal_scrollback", + "shell_log_search", + "shell_state", + "shell_confirm", + "tab_image", + "auto_describe", + "terminal_ui_key", + ], + }, + Door { + name: "web", + description: "Open web panes, read/eval/click pages, fetch URLs", + ghost_tools: &[ + "open_web_pane", + "web_pane_content", + "web_pane_eval", + "web_pane_mouse", + "terminal_web_click", + "terminal_web_reload", + "web_fetch", + ], + mcp_tools: &[ + "open_web_pane", + "web_pane_content", + "web_pane_eval", + "web_pane_mouse", + "terminal_web_click", + "terminal_web_reload", + ], + }, + Door { + name: "stickys", + description: "Create, list, update & close sticky notes", + ghost_tools: &[ + "sticky_note_create", + "sticky_note_create_code", + "sticky_note_list", + "sticky_note_update", + "sticky_note_close", + "sticky_note_delete", + ], + mcp_tools: &[ + "sticky_note_create", + "sticky_note_create_code", + "sticky_note_list", + "sticky_note_search", + "sticky_note_read", + "sticky_note_update", + "sticky_note_open", + "sticky_note_close", + "sticky_note_delete", + "sticky_note_schedule", + ], + }, + Door { + name: "settings", + description: "Diagnostics, model catalog, docker, settings profiles", + ghost_tools: &[ + "doctor", + "model_catalog", + "docker_run", + "help", + "open_settings", + ], + mcp_tools: &[ + "settings_get", + "settings_set", + "settings_list_profiles", + "settings_add_profile", + "settings_delete_profile", + "doctor", + ], + }, + // ---- ghost-only doors -------------------------------------------------- + Door { + name: "terminal", + description: "Terminal layout: keys, cd, split, focus, tabs, windows & resize", + ghost_tools: &[ + "terminal_keys", + "terminal_cd", + "terminal_split", + "terminal_focus", + "terminal_close", + "terminal_new_tab", + "terminal_new_window", + "terminal_set_window_size", + "terminal_rename", + "terminal_where_pane", + ], + mcp_tools: &[], + }, + Door { + name: "memory_deep", + description: "Deep memory: dream, connect, SQL, inspect & embody", + ghost_tools: &[ + "memory_dream", + "memory_connect", + "memory_status", + "memory_sql", + "memory_inspect", + "memory_keystone", + "memory_neighbors", + "memory_embody", + ], + mcp_tools: &[], + }, + Door { + name: "ui", + description: "Inline widgets: inputs, buttons, pickers, forms & tool mounts", + ghost_tools: &["show_input", "show_button", "show_picker", "show_form", "tool_mount"], + mcp_tools: &[], + }, + Door { + name: "create", + description: "Author new tools at runtime", + ghost_tools: &["tool_create"], + mcp_tools: &[], + }, + // ---- MCP-only doors ---------------------------------------------------- + Door { + name: "terminal_layout", + description: "Terminal layout: tabs, windows, focus, rename, cd, sizing", + ghost_tools: &[], + mcp_tools: &[ + "terminal_new_tab", + "terminal_new_window", + "terminal_close", + "terminal_focus", + "terminal_rename", + "terminal_where_pane", + "terminal_cd", + "terminal_set_window_size", + "terminal_flush_state", + ], + }, + Door { + name: "pulse", + description: "Pane busy/idle status and pulse indicators", + ghost_tools: &[], + mcp_tools: &[ + "pane_busy", + "pane_idle", + "pane_on_idle", + "pane_pulse_set", + "pane_pulse_clear", + "pane_pulse_pause", + "pane_pulse_status", + ], + }, + Door { + name: "styles", + description: "Manage terminal styles and dashboard widgets", + ghost_tools: &[], + mcp_tools: &["style_list", "style_create", "style_delete", "dashboard_widgets"], + }, + Door { + name: "telemetry_diag", + description: "Telemetry, sidecar logs, audit search & agent status", + ghost_tools: &[], + mcp_tools: &[ + "telemetry_toggle", + "telemetry_snapshot", + "telemetry_record", + "telemetry_reset", + "sidecar_logs", + "audit_search", + "agent_status", + ], + }, + Door { + name: "editing", + description: "Apply text edits to files", + ghost_tools: &[], + mcp_tools: &["apply_text_edits"], + }, +]; + +/// Look up a door entry by name (surface-agnostic). +pub fn door_by_name(name: &str) -> Option<&'static Door> { + DOORS.iter().find(|d| d.name == name) +} + +/// Iterator over the doors that exist on `surface` (non-empty tool slice). +pub fn doors_for(surface: Surface) -> impl Iterator<Item = &'static Door> { + DOORS.iter().filter(move |d| !d.tools(surface).is_empty()) +} + +/// Which door a tool belongs to on `surface`, or `None` if it is a core tool +/// or not part of the taxonomy at all. +pub fn door_of(surface: Surface, tool: &str) -> Option<&'static str> { + doors_for(surface) + .find(|d| d.tools(surface).contains(&tool)) + .map(|d| d.name) +} + +/// Default live-tool cap (small / local models). Overridable per-process via +/// `HYPERIA_TOOL_CAP`. +pub const DEFAULT_TOOL_CAP: usize = 20; + +/// Live-tool cap for cloud (token-billed) providers in `auto`/`on` mode. Doors +/// still cut token billing there, so they stay on — just with more headroom +/// than a small local model needs. +pub const CLOUD_TOOL_CAP: usize = 24; + +fn env_cap_override() -> Option<usize> { + std::env::var("HYPERIA_TOOL_CAP") + .ok() + .and_then(|v| v.trim().parse::<usize>().ok()) + .filter(|&c| c > 0) +} + +fn cap_from_env() -> usize { + env_cap_override().unwrap_or(DEFAULT_TOOL_CAP) +} + +// --------------------------------------------------------------------------- +// Doors auto-mode resolution (plan §4.3.6). Shared by GhostConfig load, the run +// loop's `DoorState`, the OpenAI provider (temperature), and the compressor. +// --------------------------------------------------------------------------- + +/// Heuristic: is this a small / local model that benefits most from a tight +/// tool menu, a slim system prompt, and `temperature: 0`? +/// +/// True for Ollama, any OpenAI-compatible endpoint that is NOT `api.openai.com` +/// (Sailfish, llama.cpp, vLLM, …), or a model whose name carries a small- +/// parameter tag (`e4b`, `2b`, `3b`, `4b`, `mini-local`, …). +pub fn is_small_model(provider: &str, model: &str, endpoint: &str) -> bool { + // Single source of truth: crate::models (kept as a shim so existing + // callers/tests don't churn). + crate::models::is_small_model(provider, model, endpoint) +} + +/// Resolved doors settings for one ghost run. Rides on `GhostConfig` so the +/// provider (temperature), the loop (`DoorState`), and the compressor all agree +/// on a single derivation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DoorConfig { + /// Doors mode active this run. + pub enabled: bool, + /// Live-tool cap (core + open doors). + pub cap: usize, + /// Small / local model — drives the slim system prompt and `temperature: 0` + /// on OpenAI-compatible (non-`api.openai.com`) endpoints. + pub small: bool, +} + +impl Default for DoorConfig { + fn default() -> Self { + Self { enabled: false, cap: DEFAULT_TOOL_CAP, small: false } + } +} + +fn env_doors_override() -> Option<bool> { + std::env::var("HYPERIA_TOOL_DOORS").ok().and_then(|v| { + let v = v.trim(); + if v == "1" || v.eq_ignore_ascii_case("true") { + Some(true) + } else if v == "0" || v.eq_ignore_ascii_case("false") { + Some(false) + } else { + None + } + }) +} + +/// Resolve doors settings for a run from the `config.agent.tool_doors` mode +/// ("on" | "off" | "auto"), the active provider/model/endpoint, and env. +/// +/// - `"off"` → disabled (legacy full-catalog path). +/// - `"on"` → enabled. +/// - `"auto"` (default, and any unrecognized value) → enabled for every +/// provider; small/local models get [`DEFAULT_TOOL_CAP`], cloud models get +/// [`CLOUD_TOOL_CAP`]. +/// +/// `HYPERIA_TOOL_DOORS=0|1` overrides the mode entirely; `HYPERIA_TOOL_CAP` +/// overrides the resolved cap. +pub fn resolve_door_config(mode: &str, provider: &str, model: &str, endpoint: &str) -> DoorConfig { + resolve_door_config_inner( + mode, + provider, + model, + endpoint, + env_doors_override(), + env_cap_override(), + ) +} + +/// Pure core of [`resolve_door_config`] — env reads are hoisted out so this is +/// deterministically unit-testable. +fn resolve_door_config_inner( + mode: &str, + provider: &str, + model: &str, + endpoint: &str, + doors_override: Option<bool>, + cap_override: Option<usize>, +) -> DoorConfig { + let small = is_small_model(provider, model, endpoint); + + let mode_enabled = match mode.trim().to_lowercase().as_str() { + "off" => false, + // "on", "auto", "" and anything unrecognized → doors on. Auto differs + // from on only by the cap, which is derived from `small` below. + _ => true, + }; + let enabled = doors_override.unwrap_or(mode_enabled); + + let cap = cap_override.unwrap_or(if small { DEFAULT_TOOL_CAP } else { CLOUD_TOOL_CAP }); + + DoorConfig { enabled, cap, small } +} + +/// Resolve the **MCP surface's** opt-in doors config from +/// `config.agent.mcp_tool_doors` ("on" | "off"; default **off**). +/// +/// Unlike the ghost surface (whose `tool_doors` defaults to `auto` = on), the +/// external MCP tool catalog stays *fully visible* unless the user explicitly +/// opts in — external agents built against the full catalog keep every tool by +/// default. When opted in, doors apply with [`CLOUD_TOOL_CAP`] headroom +/// (external MCP clients are typically cloud/large models), overridable via +/// `HYPERIA_TOOL_CAP`. +/// +/// `HYPERIA_TOOL_DOORS=0|1` overrides the mode entirely; `HYPERIA_TOOL_CAP` +/// overrides the resolved cap. `small` is always `false` here — the MCP surface +/// is provider-agnostic (it doesn't know which model is on the other end). +pub fn resolve_mcp_door_config(mode: &str) -> DoorConfig { + resolve_mcp_door_config_inner(mode, env_doors_override(), env_cap_override()) +} + +/// Pure core of [`resolve_mcp_door_config`] — env reads hoisted out for tests. +fn resolve_mcp_door_config_inner( + mode: &str, + doors_override: Option<bool>, + cap_override: Option<usize>, +) -> DoorConfig { + // Opt-in: ONLY an explicit on/true/1 enables. Everything else — "off", + // "auto", "", or any unknown value — stays OFF so the full catalog ships by + // default (this is the deliberate difference from the ghost's `auto`). + let mode_enabled = matches!(mode.trim().to_lowercase().as_str(), "on" | "true" | "1"); + let enabled = doors_override.unwrap_or(mode_enabled); + let cap = cap_override.unwrap_or(CLOUD_TOOL_CAP); + DoorConfig { enabled, cap, small: false } +} + +// --------------------------------------------------------------------------- +// DoorState — per-session (ghost) / per-identity (MCP) open-door bookkeeping. +// --------------------------------------------------------------------------- + +/// Tracks which doors are currently open for one session/identity. +/// +/// `open` is LRU-ordered: **front = oldest (least-recently-used), back = MRU**. +/// The invariant is a live-tool budget: `core + Σ open-door tools ≤ cap`. +/// Opening a door that would breach the cap evicts the oldest door(s) first +/// (never the door being opened — a single oversized door is allowed to exceed +/// the cap rather than be un-openable). +#[derive(Clone, Debug)] +pub struct DoorState { + surface: Surface, + open: Vec<String>, + cap: usize, + enabled: bool, +} + +impl DoorState { + /// New state for `surface`, cap read from `HYPERIA_TOOL_CAP` (default 20), + /// doors mode disabled by default (enabled by the Phase 2/4 flags). + pub fn new(surface: Surface) -> Self { + Self { + surface, + open: Vec::new(), + cap: cap_from_env(), + enabled: false, + } + } + + /// Explicit-cap constructor — deterministic, for tests and config-driven + /// caps (cloud providers get a larger cap; see plan §4.3). + pub fn with_cap(surface: Surface, cap: usize) -> Self { + Self { + surface, + open: Vec::new(), + cap: cap.max(1), + enabled: false, + } + } + + pub fn surface(&self) -> Surface { + self.surface + } + pub fn cap(&self) -> usize { + self.cap + } + /// Override the live-tool cap (e.g. from the resolved [`DoorConfig`] at the + /// top of a run). Clamped to ≥ 1. + pub fn set_cap(&mut self, cap: usize) { + self.cap = cap.max(1); + } + pub fn enabled(&self) -> bool { + self.enabled + } + pub fn set_enabled(&mut self, on: bool) { + self.enabled = on; + } + pub fn with_enabled(mut self, on: bool) -> Self { + self.enabled = on; + self + } + + /// Open doors, oldest → newest (front → back). + pub fn open_doors(&self) -> &[String] { + &self.open + } + + pub fn is_door_open(&self, name: &str) -> bool { + self.open.iter().any(|d| d == name) + } + + /// Count of the tools currently live: core + every open door's tools. + pub fn live_tool_count(&self) -> usize { + let mut n = core_tools(self.surface).len(); + for name in &self.open { + if let Some(door) = door_by_name(name) { + n += door.tools(self.surface).len(); + } + } + n + } + + /// The full set of live tool names this turn: core first, then open doors + /// in LRU order. Order is stable and deduped is unnecessary (the taxonomy + /// is a partition — see unit tests). + pub fn live_tools(&self) -> Vec<&'static str> { + let mut out: Vec<&'static str> = core_tools(self.surface).to_vec(); + for name in &self.open { + if let Some(door) = door_by_name(name) { + out.extend_from_slice(door.tools(self.surface)); + } + } + out + } + + /// Which door a tool belongs to on this surface (or `None` for core / + /// unknown). + pub fn door_of(&self, tool: &str) -> Option<&'static str> { + door_of(self.surface, tool) + } + + /// Open a door. Returns the list of doors evicted to stay within the cap + /// (LRU order, oldest first). No-op (empty vec) if the door does not exist + /// on this surface. If the door is already open it is moved to MRU. + pub fn open_door(&mut self, name: &str) -> Vec<String> { + // Reject doors that don't exist on this surface. + match door_by_name(name) { + Some(d) if !d.tools(self.surface).is_empty() => {} + _ => return Vec::new(), + } + + // Already open → move to MRU, nothing evicted. + if let Some(pos) = self.open.iter().position(|d| d == name) { + let d = self.open.remove(pos); + self.open.push(d); + return Vec::new(); + } + + // New door goes to the MRU end. + self.open.push(name.to_string()); + + // Evict oldest doors (front) until within cap — but never evict the + // door we just opened (stop when it is the only one left). + let mut evicted = Vec::new(); + while self.live_tool_count() > self.cap && self.open.len() > 1 { + evicted.push(self.open.remove(0)); + } + evicted + } + + /// Mark a tool as used: move its door to MRU so it survives the next + /// eviction longest. No-op for core tools and tools of closed doors. + pub fn touch(&mut self, tool: &str) { + if let Some(door_name) = self.door_of(tool) { + if let Some(pos) = self.open.iter().position(|d| d == door_name) { + let d = self.open.remove(pos); + self.open.push(d); + } + } + } + + /// Close a door (no-op if not open). + pub fn close_door(&mut self, name: &str) { + self.open.retain(|d| d != name); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + // ---- taxonomy structure ------------------------------------------------- + + #[test] + fn every_door_has_at_most_ten_tools() { + for d in DOORS { + assert!( + d.ghost_tools.len() <= 10, + "ghost door '{}' has {} tools (>10)", + d.name, + d.ghost_tools.len() + ); + assert!( + d.mcp_tools.len() <= 10, + "mcp door '{}' has {} tools (>10)", + d.name, + d.mcp_tools.len() + ); + } + } + + #[test] + fn door_names_are_unique() { + let mut seen = HashSet::new(); + for d in DOORS { + assert!(seen.insert(d.name), "duplicate door name '{}'", d.name); + } + } + + /// No tool appears in two doors, nor in both a door and the core, on a + /// given surface (the taxonomy must be a partition). + fn assert_partition(surface: Surface) { + let core: HashSet<&str> = core_tools(surface).iter().copied().collect(); + let mut seen: HashSet<&str> = HashSet::new(); + for d in doors_for(surface) { + for &t in d.tools(surface) { + assert!( + !core.contains(t), + "[{surface:?}] tool '{t}' is in both core and door '{}'", + d.name + ); + assert!( + seen.insert(t), + "[{surface:?}] tool '{t}' appears in more than one door (door '{}')", + d.name + ); + } + } + } + + #[test] + fn ghost_taxonomy_is_a_partition() { + assert_partition(Surface::Ghost); + } + + #[test] + fn mcp_taxonomy_is_a_partition() { + assert_partition(Surface::Mcp); + } + + #[test] + fn expected_door_counts_per_surface() { + // Plan §3.1 table: 8 ghost doors (header says "7", table lists 8 — + // the table is authoritative). Plan §3.2: 9 MCP doors. + assert_eq!(doors_for(Surface::Ghost).count(), 8, "ghost door count"); + assert_eq!(doors_for(Surface::Mcp).count(), 9, "mcp door count"); + assert_eq!(GHOST_CORE.len(), 11, "ghost core count"); + assert_eq!(MCP_CORE.len(), 12, "mcp core count"); + } + + /// Every ghost catalog tool (registry `tool_defs`) belongs to exactly one + /// door OR the core — and every ghost taxonomy tool (minus meta) is a real + /// registry tool. Validates the table against `registry.rs` directly. + #[test] + fn ghost_catalog_matches_registry_exactly() { + use crate::ghost::registry::ToolRegistry; + let reg = ToolRegistry::new(9800, "test-token".into()); + let catalog: HashSet<String> = reg + .tool_defs(None, None, None) + .into_iter() + .map(|t| t.name) + .collect(); + + let core: HashSet<&str> = GHOST_CORE.iter().copied().collect(); + let meta: HashSet<&str> = GHOST_META.iter().copied().collect(); + + // (a) Each registry tool is in exactly one place (core xor one door). + for name in &catalog { + let in_core = core.contains(name.as_str()); + let in_door = door_of(Surface::Ghost, name).is_some(); + assert!( + in_core ^ in_door, + "registry tool '{name}' must be in exactly one of core/door \ + (core={in_core}, door={in_door})" + ); + } + + // (b) Every non-meta core tool exists in the registry catalog. + for &c in GHOST_CORE { + if meta.contains(c) { + continue; + } + assert!( + catalog.contains(c), + "core tool '{c}' is not a real registry tool" + ); + } + + // (c) Every ghost door tool exists in the registry catalog. + for d in doors_for(Surface::Ghost) { + for &t in d.ghost_tools { + assert!( + catalog.contains(t), + "ghost door '{}' tool '{t}' is not a real registry tool", + d.name + ); + } + } + + // (d) Nothing in the registry is left uncovered. + for name in &catalog { + let covered = + core.contains(name.as_str()) || door_of(Surface::Ghost, name).is_some(); + assert!(covered, "registry tool '{name}' is not covered by any door or core"); + } + } + + // ---- DoorState: cap / LRU / eviction ----------------------------------- + + #[test] + fn cap_not_breached_by_a_single_fitting_door() { + // Cap sized to exactly core + terminal so the door fits with zero + // headroom — computed from the live taxonomy so adding a tool to the + // terminal door (e.g. terminal_set_window_size) doesn't break this. + let core_n = core_tools(Surface::Ghost).len(); + let term_n = door_by_name("terminal").unwrap().ghost_tools.len(); + let mut s = DoorState::with_cap(Surface::Ghost, core_n + term_n); + let evicted = s.open_door("terminal"); + assert!(evicted.is_empty(), "no eviction expected"); + assert_eq!(s.live_tool_count(), core_n + term_n); + assert_eq!(s.open_doors(), &["terminal".to_string()]); + } + + #[test] + fn opening_second_door_evicts_oldest_when_over_cap() { + // core 11 + terminal 9 = 20; adding web (7) → 27 > 20 → evict terminal. + let mut s = DoorState::with_cap(Surface::Ghost, 20); + s.open_door("terminal"); + let evicted = s.open_door("web"); + assert_eq!(evicted, vec!["terminal".to_string()]); + assert_eq!(s.open_doors(), &["web".to_string()]); + assert_eq!(s.live_tool_count(), 18); // 11 + 7 + } + + #[test] + fn lru_order_and_eviction() { + // Larger cap so two doors coexist, then a third forces one eviction. + let mut s = DoorState::with_cap(Surface::Ghost, 30); + assert!(s.open_door("terminal").is_empty()); // 11+9 = 20 + assert!(s.open_door("web").is_empty()); // +7 = 27 ≤ 30 + assert_eq!(s.open_doors(), &["terminal".to_string(), "web".to_string()]); + + // stickys (6) → 33 > 30 → evict oldest (terminal). + let evicted = s.open_door("stickys"); + assert_eq!(evicted, vec!["terminal".to_string()]); + assert_eq!(s.open_doors(), &["web".to_string(), "stickys".to_string()]); + } + + #[test] + fn touch_moves_door_to_mru() { + let mut s = DoorState::with_cap(Surface::Ghost, 30); + s.open_door("terminal"); + s.open_door("web"); + // web is MRU; touch a terminal tool → terminal becomes MRU. + s.touch("terminal_split"); + assert_eq!(s.open_doors(), &["web".to_string(), "terminal".to_string()]); + + // Now opening stickys over cap should evict web (the new oldest). + let evicted = s.open_door("stickys"); + assert_eq!(evicted, vec!["web".to_string()]); + } + + #[test] + fn touch_is_noop_for_core_and_closed_tools() { + let mut s = DoorState::with_cap(Surface::Ghost, 30); + s.open_door("terminal"); + s.open_door("web"); + let before = s.open_doors().to_vec(); + s.touch("terminal_run"); // core tool → no door + s.touch("sticky_note_list"); // closed door → no-op + assert_eq!(s.open_doors(), &before[..]); + } + + #[test] + fn reopening_open_door_moves_to_mru_without_eviction() { + let mut s = DoorState::with_cap(Surface::Ghost, 30); + s.open_door("terminal"); + s.open_door("web"); + let evicted = s.open_door("terminal"); // already open + assert!(evicted.is_empty()); + assert_eq!(s.open_doors(), &["web".to_string(), "terminal".to_string()]); + } + + #[test] + fn single_oversized_door_is_allowed_to_exceed_cap() { + // Tiny cap: even one door exceeds it, but we still open it (can't evict + // the door being opened). + let mut s = DoorState::with_cap(Surface::Ghost, 5); + let evicted = s.open_door("terminal"); + assert!(evicted.is_empty()); + assert!(s.is_door_open("terminal")); + assert!(s.live_tool_count() > s.cap()); + } + + #[test] + fn close_door_removes_it() { + let mut s = DoorState::with_cap(Surface::Ghost, 30); + s.open_door("terminal"); + s.open_door("web"); + s.close_door("terminal"); + assert!(!s.is_door_open("terminal")); + assert_eq!(s.open_doors(), &["web".to_string()]); + s.close_door("terminal"); // idempotent + s.close_door("nonexistent"); // no-op + assert_eq!(s.open_doors(), &["web".to_string()]); + } + + #[test] + fn opening_unknown_or_wrong_surface_door_is_noop() { + let mut s = DoorState::with_cap(Surface::Ghost, 30); + assert!(s.open_door("does_not_exist").is_empty()); + // 'pulse' is an MCP-only door — invalid on the ghost surface. + assert!(s.open_door("pulse").is_empty()); + assert!(s.open_doors().is_empty()); + } + + #[test] + fn door_of_lookup() { + assert_eq!(door_of(Surface::Ghost, "web_pane_eval"), Some("web")); + assert_eq!(door_of(Surface::Ghost, "terminal_split"), Some("terminal")); + assert_eq!(door_of(Surface::Ghost, "terminal_run"), None); // core + assert_eq!(door_of(Surface::Ghost, "not_a_tool"), None); + // MCP surface: same concept, surface-specific door name. + assert_eq!(door_of(Surface::Mcp, "terminal_new_tab"), Some("terminal_layout")); + assert_eq!(door_of(Surface::Mcp, "apply_text_edits"), Some("editing")); + // A ghost-only tool is unknown on the MCP surface. + assert_eq!(door_of(Surface::Mcp, "tool_create"), None); + } + + // ---- set_cap ----------------------------------------------------------- + + #[test] + fn set_cap_clamps_to_one() { + let mut s = DoorState::with_cap(Surface::Ghost, 20); + s.set_cap(0); + assert_eq!(s.cap(), 1); + s.set_cap(15); + assert_eq!(s.cap(), 15); + } + + // ---- small-model heuristic + auto resolution --------------------------- + + #[test] + fn is_small_model_matches_local_and_compat() { + // Ollama is always small/local. + assert!(is_small_model("ollama", "gemma2:9b", "http://localhost:11434")); + // OpenAI-compatible endpoint that isn't api.openai.com → Sailfish/compat. + assert!(is_small_model("openai", "gemma4-e4b", "http://localhost:22343")); + // Small-parameter tags in the model name. + assert!(is_small_model("anthropic", "some-2b-model", "https://api.anthropic.com")); + assert!(is_small_model("openai", "phi-4b", "https://api.openai.com")); + } + + #[test] + fn is_small_model_rejects_cloud_flagships() { + assert!(!is_small_model("anthropic", "claude-sonnet-4-6", "https://api.anthropic.com")); + assert!(!is_small_model("openai", "gpt-4o", "https://api.openai.com")); + assert!(!is_small_model("gemini", "gemini-2.0-flash", "https://generativelanguage.googleapis.com")); + } + + #[test] + fn resolve_off_disables() { + let dc = resolve_door_config_inner("off", "ollama", "gemma2:9b", "http://localhost:11434", None, None); + assert!(!dc.enabled); + // small is still reported (used elsewhere) but doors are off. + assert!(dc.small); + } + + #[test] + fn resolve_auto_small_uses_default_cap() { + let dc = resolve_door_config_inner("auto", "ollama", "gemma2:9b", "http://localhost:11434", None, None); + assert!(dc.enabled); + assert!(dc.small); + assert_eq!(dc.cap, DEFAULT_TOOL_CAP); + } + + #[test] + fn resolve_auto_sailfish_endpoint_is_small() { + let dc = resolve_door_config_inner("auto", "openai", "gemma4-e4b", "http://localhost:22343", None, None); + assert!(dc.enabled); + assert!(dc.small); + assert_eq!(dc.cap, DEFAULT_TOOL_CAP); + } + + #[test] + fn is_small_model_matches_sailfish_provider() { + // The "sailfish" provider id is always local/small regardless of model. + assert!(is_small_model("sailfish", "gemma4-e4b", "http://localhost:22343")); + assert!(is_small_model("sailfish", "anything", "http://localhost:22343")); + } + + #[test] + fn resolve_auto_sailfish_provider_is_small_default_cap() { + // Phase 6: provider == "sailfish" classifies as small → tight cap 20. + let dc = resolve_door_config_inner( + "auto", + "sailfish", + "gemma4-e4b", + "http://localhost:22343", + None, + None, + ); + assert!(dc.enabled); + assert!(dc.small); + assert_eq!(dc.cap, DEFAULT_TOOL_CAP); + } + + #[test] + fn resolve_auto_cloud_uses_larger_cap() { + let dc = resolve_door_config_inner("auto", "anthropic", "claude-sonnet-4-6", "https://api.anthropic.com", None, None); + assert!(dc.enabled, "auto mode enables doors on cloud too (token billing)"); + assert!(!dc.small); + assert_eq!(dc.cap, CLOUD_TOOL_CAP); + } + + #[test] + fn resolve_on_enables_regardless_of_size() { + let dc = resolve_door_config_inner("on", "anthropic", "claude-sonnet-4-6", "https://api.anthropic.com", None, None); + assert!(dc.enabled); + assert_eq!(dc.cap, CLOUD_TOOL_CAP); + } + + #[test] + fn resolve_env_override_wins() { + // env=0 forces off even when mode says on. + let off = resolve_door_config_inner("on", "ollama", "gemma2:9b", "http://localhost:11434", Some(false), None); + assert!(!off.enabled); + // env=1 forces on even when mode says off. + let on = resolve_door_config_inner("off", "anthropic", "claude-sonnet-4-6", "https://api.anthropic.com", Some(true), None); + assert!(on.enabled); + } + + #[test] + fn resolve_cap_override_wins() { + let dc = resolve_door_config_inner("auto", "ollama", "gemma2:9b", "http://localhost:11434", None, Some(12)); + assert_eq!(dc.cap, 12); + } + + // ---- MCP surface: opt-in resolution -------------------------------------- + + #[test] + fn resolve_mcp_off_by_default() { + // "off", "auto", "", and unknown values ALL resolve to disabled — the + // MCP surface keeps its full catalog unless explicitly opted in. + assert!(!resolve_mcp_door_config_inner("off", None, None).enabled); + assert!(!resolve_mcp_door_config_inner("auto", None, None).enabled); + assert!(!resolve_mcp_door_config_inner("", None, None).enabled); + assert!(!resolve_mcp_door_config_inner("garbage", None, None).enabled); + } + + #[test] + fn resolve_mcp_on_enables_with_cloud_cap() { + for mode in ["on", "true", "1", "ON", " On "] { + let dc = resolve_mcp_door_config_inner(mode, None, None); + assert!(dc.enabled, "mode {mode:?} should enable"); + assert_eq!(dc.cap, CLOUD_TOOL_CAP); + assert!(!dc.small, "MCP surface never claims small-model"); + } + } + + #[test] + fn resolve_mcp_env_overrides_win() { + // env=1 forces on even when the mode says off… + assert!(resolve_mcp_door_config_inner("off", Some(true), None).enabled); + // …and env=0 forces off even when the mode says on. + assert!(!resolve_mcp_door_config_inner("on", Some(false), None).enabled); + // cap override wins over the CLOUD default. + assert_eq!(resolve_mcp_door_config_inner("on", None, Some(15)).cap, 15); + } + + #[test] + fn live_tools_lists_core_then_open_doors() { + let mut s = DoorState::with_cap(Surface::Ghost, 30); + s.open_door("web"); + let live = s.live_tools(); + // core present + assert!(live.contains(&"terminal_run")); + // web door present + assert!(live.contains(&"web_pane_eval")); + // closed door absent + assert!(!live.contains(&"sticky_note_list")); + assert_eq!(live.len(), s.live_tool_count()); + } +} diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index ac9177a81b6..b3c63e57fe7 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -33,9 +33,14 @@ Never call a tool name that wasn't in your tool definitions for this turn. ## Rules - Be concise. Act, don't narrate. Never say what you're about to do — just do it. +- FULL SEND is the default: do NOT ask permission for routine actions (splits, tabs, windows, reading screens, typing into panes, opening pages). Hyperia's consent layer will prompt the human itself when an action needs approval — asking first is double-asking. Execute, then report: done / failed / next fix. +- The ONLY things you confirm first: deleting files, force-push, credential changes, or anything irreversible outside the workspace. +- Finish the WHOLE request before replying. If it says open+split+list+close, do all of it; never report done with steps remaining. +- NEVER claim an action happened unless YOU called the tool and saw its result in THIS conversation. Saying \"Done\" without the tool call is fabrication — the user sees their screen and will catch it. If the tool you need isn't in your live list, your next action MUST be open_tools/tool_search, not a reply. - Never repeat a tool call you already made this turn. If you have the result, use it. - Read tool results before calling more tools. terminal_run already returns the screen. -- For destructive operations, confirm with the user first. +- To SHOW a web page, use open_web_pane (web door) — NEVER `start <url>`/xdg-open/system browser; those leave Hyperia. +- Typing into an AI/TUI pane (claude, codex, vim): the message goes with terminal_keys, then submit is a separate Enter — send a carriage return (\\r). A trailing newline (\\n) often does NOT submit in TUIs. - STRUCTURED WORKFLOWS: - Web Content: open_web_pane -> terminal_status -> Parse tabId -> web_pane_content. - Terminal Execution: terminal_status -> Parse active paneId -> terminal_run -> terminal_screen. @@ -104,6 +109,37 @@ When the user says \"change my model\", \"switch to OpenAI\", \"use Claude\", or 4. If the chosen provider has no token configured (settings_get(\"config.providers.<provider>.token\") returns null/empty) and the provider isn't ollama, follow up with show_input(id=\"token\", kind=\"password\") to collect it, then settings_set(\"config.providers.<provider>.token\", <value>). Do not invent models. Only present what model_catalog returns."; +/// The exact "## Honesty about tools" paragraph inside [`SYSTEM_PROMPT`]. In +/// doors mode it is string-replaced with [`DOORS_CONTRACT`] (plan §4.3.5). +/// MUST stay byte-for-byte identical to the block in SYSTEM_PROMPT or the +/// replace silently no-ops. +const HONESTY_ABOUT_TOOLS: &str = "\ +## Honesty about tools +The tool list above is the COMPLETE set of tools you have. Do not invent tool names — `google:search`, `web_search`, generic shell access, image generation, etc. don't exist unless they're in the list. If the user asks for something you can't do: +- check `tool_search` for what exists by keyword +- use `web_fetch` if you can compose a specific URL +- offer to build a new tool with `tool_create` +- or tell the user plainly that the capability isn't wired + +Never call a tool name that wasn't in your tool definitions for this turn."; + +/// Doors-mode replacement for [`HONESTY_ABOUT_TOOLS`]. Explains that the live +/// tool list is a small core plus opened doors, and how to reveal more. +const DOORS_CONTRACT: &str = "\ +## Tools behind doors +Your tool list is NOT the whole catalog — it is a small always-on core plus any doors you have opened. A door is a named category of tools. Two meta-tools drive it: +- open_tools(door=\"NAME\") makes that door's tools callable on your NEXT turn. Its description lists every door and what each contains. +- close_tools(door=\"NAME\") puts a door away to free room (there is a live-tool cap; opening a door may evict the least-recently-used one). +- tool_search(query) searches the FULL catalog (open or closed) and tells you which door each tool is behind. + +Rules: +- Doors are YOURS to open, free and instant. NEVER ask the human to open a door or for permission to open one — \"open the terminal door for me\" is never a valid request to the user. Consent prompts (when needed) happen at the action layer automatically; doors are just your menu. +- If a capability seems missing, DON'T assume it doesn't exist and DON'T stop to ask — tool_search first, then open_tools the right door, then ACT, all in the same turn. Only report a missing capability after BOTH searching and opening have failed. +- Requests like split/resize/focus/tab/window layout → open the terminal door and do it. Requests about pages/URLs → the web door. Notes → stickys. Match the request to the door and proceed. +- You may call a tool that is behind a closed door directly; the door auto-opens and the call runs. Prefer open_tools when you know you'll need a whole category. +- Act first, then explain. Complete the user's request before any commentary about tools or doors. +- Never invent tool names that are not in the catalog (tool_search will confirm what's real)."; + #[derive(Debug, Clone)] pub enum SessionState { Idle, @@ -121,6 +157,11 @@ pub struct GhostSession { tool_call_count: usize, /// Recent (name+input, output) pairs for repeat detection — persists across messages. recent_calls: Vec<(String, String)>, + /// Progressive-disclosure door state (open doors + cap), persisted across + /// messages exactly like `tool_call_count`/`recent_calls`. `enabled` is + /// (re)derived per run from `HYPERIA_TOOL_DOORS`; the open-door list rides + /// through so a door opened on one message stays open on the next. + door_state: crate::doors::DoorState, pub stop_requested: Arc<AtomicBool>, pub window_closed: Arc<AtomicBool>, /// Messages the user typed while the agent was running. Drained by the @@ -138,6 +179,7 @@ impl GhostSession { state: SessionState::Idle, tool_call_count: 0, recent_calls: Vec::new(), + door_state: crate::doors::DoorState::new(crate::doors::Surface::Ghost), stop_requested: Arc::new(AtomicBool::new(false)), window_closed: Arc::new(AtomicBool::new(false)), pending_injects: Arc::new(std::sync::Mutex::new(Vec::new())), @@ -197,6 +239,8 @@ impl GhostSession { provider: Arc<AnyProvider>, session_mutex: Arc<tokio::sync::Mutex<GhostSession>>, ferricula: Arc<FerriculaBackend>, + door_config: crate::doors::DoorConfig, + context_tokens: usize, ) -> mpsc::Receiver<GhostEvent> { let (tx, rx) = mpsc::channel(128); @@ -220,6 +264,7 @@ impl GhostSession { let turn_start = self.turn; let initial_tool_call_count = self.tool_call_count; let initial_recent_calls = self.recent_calls.clone(); + let initial_door_state = self.door_state.clone(); tokio::spawn(async move { let result = run_loop( @@ -236,13 +281,17 @@ impl GhostSession { pending_injects, initial_tool_call_count, initial_recent_calls, + initial_door_state, + door_config, + context_tokens, ).await; match result { - Ok((final_messages, stop_reason, final_tool_call_count, final_recent_calls)) => { + Ok((final_messages, stop_reason, final_tool_call_count, final_recent_calls, final_door_state)) => { // Write the full conversation history and throttle state back to the session let mut session = session_mutex.lock().await; session.set_messages(final_messages); session.set_throttle_state(final_tool_call_count, final_recent_calls); + session.set_door_state(final_door_state); session.set_state(SessionState::Completed(stop_reason)); } Err(e) => { @@ -272,17 +321,87 @@ impl GhostSession { self.recent_calls = recent_calls; } + /// Persist the open-door set back to the session after a run (mirrors + /// `set_throttle_state`). + pub fn set_door_state(&mut self, door_state: crate::doors::DoorState) { + self.door_state = door_state; + } + pub fn reset(&mut self) { self.messages.clear(); self.turn = 0; self.state = SessionState::Idle; self.tool_call_count = 0; self.recent_calls.clear(); + self.door_state = crate::doors::DoorState::new(crate::doors::Surface::Ghost); self.stop_requested.store(false, Ordering::Relaxed); self.window_closed.store(false, Ordering::Relaxed); } } +/// Shrink serialized tool payloads for small/8k-window models: cap each tool +/// description at its first line (~selection still works from name + one +/// line) and strip per-property `description` strings from input schemas, +/// keeping structure (type/enum/required/default). Measured ~15-40% schema +/// savings. `open_tools` is exempt — its description carries the door menu. +fn slim_tool_defs(defs: &mut [ToolDef]) { + fn strip_schema_descriptions(v: &mut serde_json::Value) { + if let Some(obj) = v.as_object_mut() { + if let Some(props) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) { + for (_k, prop) in props.iter_mut() { + if let Some(po) = prop.as_object_mut() { + po.remove("description"); + } + // Nested objects/arrays keep their structure but lose prose too. + strip_schema_descriptions(prop); + } + } + if let Some(items) = obj.get_mut("items") { + strip_schema_descriptions(items); + } + } + } + for def in defs.iter_mut() { + if def.name == "open_tools" { + continue; + } + if let Some(first) = def.description.lines().next() { + let mut line = first.trim().to_string(); + if line.len() > 200 { + line.truncate(200); + } + if !line.is_empty() && line.len() < def.description.len() { + def.description = line; + } + } + strip_schema_descriptions(&mut def.input_schema); + } +} + +/// Ring buffer of per-turn ghost telemetry, exposed at GET /api/ghost/debug. +/// Lets a dev/agent troubleshoot the run loop (which model, token counts, +/// decode speed, context budget, tools) without a human relaying the shell. +static GHOST_DEBUG: std::sync::OnceLock<std::sync::Mutex<Vec<serde_json::Value>>> = + std::sync::OnceLock::new(); + +pub fn push_ghost_debug(entry: serde_json::Value) { + let buf = GHOST_DEBUG.get_or_init(|| std::sync::Mutex::new(Vec::new())); + if let Ok(mut v) = buf.lock() { + v.push(entry); + let len = v.len(); + if len > 60 { + v.drain(0..len - 60); + } + } +} + +pub fn ghost_debug_snapshot() -> Vec<serde_json::Value> { + GHOST_DEBUG + .get() + .and_then(|m| m.lock().ok().map(|v| v.clone())) + .unwrap_or_default() +} + async fn run_loop( tx: mpsc::Sender<GhostEvent>, mut messages: Vec<serde_json::Value>, @@ -297,24 +416,60 @@ async fn run_loop( pending_injects: Arc<std::sync::Mutex<Vec<String>>>, initial_tool_call_count: usize, initial_recent_calls: Vec<(String, String)>, -) -> anyhow::Result<(Vec<serde_json::Value>, String, usize, Vec<(String, String)>)> { - let tool_defs = registry.tool_defs(Some(provider.provider_name()), Some(provider.model_name())); + initial_door_state: crate::doors::DoorState, + door_config: crate::doors::DoorConfig, + context_tokens: usize, +) -> anyhow::Result<(Vec<serde_json::Value>, String, usize, Vec<(String, String)>, crate::doors::DoorState)> { + // Doors mode + cap are resolved once at config load (config.agent.tool_doors + // "auto"/"on"/"off" + provider + env override) and passed in via + // `door_config`. The open-door set persists across messages via the session; + // `enabled`/`cap` are re-applied here every run so a config change takes + // effect on the next message without a restart. + let doors_enabled = door_config.enabled; + let mut door_state = initial_door_state; + door_state.set_enabled(doors_enabled); + door_state.set_cap(door_config.cap); // Progressive throttle counters — seeded from session so they persist across messages. // Reset only when the user explicitly resets the conversation. let mut tool_call_count: usize = initial_tool_call_count; let mut screen_poll_streak: usize = 0; // consecutive iterations where ONLY terminal_screen was called (resets per message) + let mut view_open_count: usize = 0; // panes/tabs/windows created this message — runaway-open guard (resets per message) // Track recent tool calls for repeat detection — seeded from session let mut recent_calls: Vec<(String, String)> = initial_recent_calls; // Recall memories from Ferricula before the first model call let mut system = SYSTEM_PROMPT.to_string(); + // Doors mode: swap the "complete tool list" honesty paragraph for the + // doors contract (plan §4.3.5) — in doors mode the tool list is a small + // core plus opened doors, not the whole catalog. + if doors_enabled { + system = system.replace(HONESTY_ABOUT_TOOLS, DOORS_CONTRACT); + } + // Anchor reality for small models: name the actual host platform so they + // don't confabulate one (ornith greeted a Windows user with a "2019 Apple + // Silicon MacBook Pro"). + let host_os = match std::env::consts::OS { + "windows" => "Windows", + "macos" => "macOS", + "linux" => "Linux", + other => other, + }; + system.push_str(&format!( + "\n\n## Host\nThis terminal is running on {} ({}). Never state or guess other host details (hardware model, OS version) unless a tool output told you.", + host_os, + std::env::consts::ARCH + )); let recalled = ferricula.recall(user_message).await; if !recalled.is_empty() { system.push_str(&recalled); } - // Inject current terminal state so the agent knows what's already running + // Inject current terminal state so the agent knows what's already running. + // In doors mode on a small/local model the full /api/status JSON is a big + // fixed cost against an 8k window — trim it to a compact one-line-per-pane + // summary (window/tab/pane counts + focused pane) instead (plan §4.3.3). + let slim_state = doors_enabled && door_config.small; let http_port = std::env::var("HYPERIA_PORT").unwrap_or_else(|_| "9800".into()); let state_client = reqwest::Client::new(); if let Ok(resp) = state_client.get(format!("http://localhost:{}/api/status", http_port)).send().await { @@ -329,8 +484,20 @@ async fn run_loop( _ => "## OS: Unknown platform", }; system.push_str(&format!("\n\n{}", os_note)); + + if slim_state { + system.push_str(&format!( + "\n\n## Current terminal state (summary)\n{}\n\ + Call terminal_status for full pane/tab detail when you need it.", + compact_terminal_state(&parsed) + )); + } else { + system.push_str(&format!("\n\n## Current terminal state\n```json\n{}\n```", status)); + } + } else if !slim_state { + // Unparseable but we still ship the raw text in full mode. + system.push_str(&format!("\n\n## Current terminal state\n```json\n{}\n```", status)); } - system.push_str(&format!("\n\n## Current terminal state\n```json\n{}\n```", status)); } } @@ -347,9 +514,12 @@ async fn run_loop( let mut turns = 0; let mut total_input_tokens: u64 = 0; let mut total_output_tokens: u64 = 0; + let mut prev_in: u64 = 0; + let mut prev_out: u64 = 0; loop { turns += 1; + let turn_start_at = std::time::Instant::now(); if turns > max_turns { let _ = tx .send(GhostEvent::Done { @@ -357,16 +527,26 @@ async fn run_loop( turns: turn_start + turns - 1, }) .await; - return Ok((messages, "max_turns".into(), tool_call_count, recent_calls)); + return Ok((messages, "max_turns".into(), tool_call_count, recent_calls, door_state)); } + // Assemble the base tool list for THIS iteration. In doors mode this is + // core + open doors' schemas (which change as the model opens/closes + // doors mid-turn); with doors off it is the full catalog. Throttle-tier + // filtering below then applies ON TOP as a stricter emergency brake. + let tool_defs = registry.tool_defs( + Some(provider.provider_name()), + Some(provider.model_name()), + Some(&door_state), + ); + // Compute throttle tier and filter tools accordingly let throttle_tier: u8 = if tool_call_count > 32 { 3 } else if tool_call_count > 24 { 2 } else if tool_call_count > 16 { 1 } else { 0 }; - let effective_tool_defs: Vec<ToolDef> = match throttle_tier { + let mut effective_tool_defs: Vec<ToolDef> = match throttle_tier { 1 => tool_defs.iter() .filter(|t| t.name != "terminal_screen") .cloned() @@ -382,6 +562,16 @@ async fn run_loop( _ => tool_defs.clone(), }; + // Small-model payload slim (plan: tool-harness optimization). On an 8k + // window every schema byte is context the model can't use — trim tool + // descriptions to their first line and drop per-property schema + // descriptions (types/enums/required stay). Frontier models keep the + // full prose. open_tools keeps its dynamic door listing — that IS the + // menu. + if door_config.small { + slim_tool_defs(&mut effective_tool_defs); + } + let mut effective_system = system.clone(); if throttle_tier == 1 { effective_system.push_str(&format!( @@ -424,18 +614,61 @@ After cleanup, reply to the human and end the turn." ); } + // Serialized tool-schema byte size — used both by the Phase 0 + // instrumentation below and as compressor overhead (system + tools) for + // the token-budget guard. + let tool_schema_bytes = serde_json::to_vec(&effective_tool_defs) + .map(|v| v.len()) + .unwrap_or(0); + + // Effective context window. Explicit config (config.agent.context_tokens) + // wins; otherwise assume the known small-model window (8k) for local/small + // providers (Sailfish/Ollama) so the budget guard actually engages — the + // config leaves this 0, which used to disable trimming entirely and let an + // 8k model 400 with "exceeds context size". Cloud models have huge windows + // and manage themselves, so they stay unbounded (0 = no trim). + let effective_ctx = if context_tokens > 0 { + context_tokens + } else { + crate::models::default_context_tokens(door_config.small) + }; + // Reserve headroom for the model's own output. On a small 8k window, the + // old fixed 4096 was half the budget — scale it down so the prompt has room. + let out_reserve: u32 = if effective_ctx > 0 && effective_ctx <= 16384 { 1024 } else { 4096 }; + let prompt_budget = effective_ctx.saturating_sub(out_reserve as usize); + // Compress older messages via local Ollama before sending to the primary model. // Recent messages are kept verbatim; `messages` itself is never modified so - // tool results continue accumulating against the full history. + // tool results continue accumulating against the full history. When a context + // budget is known, trim so system + tools + history fits; if the LLM + // compressor is down, still hard-trim mechanically (never send full history + // into a bounded window). + let overhead_chars = effective_system.len() + tool_schema_bytes; let send_messages = if compress { - compressor.compress_messages(&messages).await + compressor + .compress_messages_budgeted(&messages, prompt_budget, overhead_chars) + .await + } else if prompt_budget > 0 { + crate::ghost::compressor::hard_trim_to_budget(&messages, prompt_budget, overhead_chars) } else { messages.clone() }; + // Phase 0 instrumentation (no behavior change): measure the live tool + // surface shipped to the provider this iteration — count + serialized + // schema byte size. This is the baseline the doors work shrinks. + tracing::info!( + target: "doors", + turn = turns, + throttle_tier, + tool_count = effective_tool_defs.len(), + schema_bytes = tool_schema_bytes, + "ghost tool surface (per-iteration)" + ); + // Call the provider let mut event_rx = provider - .stream(&effective_system, &send_messages, &effective_tool_defs, 4096) + .stream(&effective_system, &send_messages, &effective_tool_defs, out_reserve) .await?; // Accumulate assistant content and tool calls @@ -445,6 +678,17 @@ After cleanup, reply to the human and end the turn." let mut stop_reason = String::new(); while let Some(event) = event_rx.recv().await { + // Fast stop: honor Escape MID-GENERATION. Previously the flag was + // only read between turns, so a ~60s local generation ignored the + // user for its whole duration. Dropping event_rx on return unwinds + // the provider task's sends; any in-flight HTTP finishes in the + // background and is discarded. + if stop_requested.load(Ordering::Relaxed) { + let _ = tx + .send(GhostEvent::Done { stop_reason: "stopped".into(), turns: turn_start + turns - 1 }) + .await; + return Ok((messages, "stopped".into(), tool_call_count, recent_calls, door_state)); + } match event { ProviderEvent::ThinkingStart { id } => { text_parts.push("[Thinking: ".to_string()); @@ -501,7 +745,7 @@ After cleanup, reply to the human and end the turn." } ProviderEvent::Error(msg) => { let _ = tx.send(GhostEvent::Error { message: msg }).await; - return Ok((messages, "error".into(), tool_call_count, recent_calls)); + return Ok((messages, "error".into(), tool_call_count, recent_calls, door_state)); } } } @@ -514,6 +758,38 @@ After cleanup, reply to the human and end the turn." turns, }).await; + // Per-turn debug telemetry (ring buffer at /api/ghost/debug) so a dev or + // agent can see WHICH model handled the turn, token counts, decode speed, + // context budget, and tools — without a human relaying the transcript. + { + let dt_in = total_input_tokens.saturating_sub(prev_in); + let dt_out = total_output_tokens.saturating_sub(prev_out); + prev_in = total_input_tokens; + prev_out = total_output_tokens; + let ms = turn_start_at.elapsed().as_millis() as u64; + let tps = if ms > 0 { (dt_out as f64) / (ms as f64 / 1000.0) } else { 0.0 }; + let tools: Vec<&str> = pending_tools.iter().map(|t| t.name.as_str()).collect(); + push_ghost_debug(serde_json::json!({ + "turn": turns, + "provider": provider.provider_name(), + "model": provider.model_name(), + "sent_messages": send_messages.len(), + "compressed": compress, + "effective_ctx": effective_ctx, + "prompt_budget": prompt_budget, + "out_reserve": out_reserve, + "in_tokens_total": total_input_tokens, + "out_tokens_total": total_output_tokens, + "in_tokens_turn": dt_in, + "out_tokens_turn": dt_out, + "duration_ms": ms, + "tps": (tps * 10.0).round() / 10.0, + "tools": tools, + "stop_reason": stop_reason, + "view_opens": view_open_count, + })); + } + if stop_reason == "tool_use" && !pending_tools.is_empty() { // Build assistant content blocks let mut content_blocks: Vec<serde_json::Value> = Vec::new(); @@ -556,14 +832,32 @@ After cleanup, reply to the human and end the turn." }) .await; - // tool_mount: non-blocking dynamic-widget mount. Stash the - // payload server-side keyed by a generated mount_id, emit - // the SSE event so the renderer can render it inline, and - // synthesize a confirmation string for the agent's history. - // Skip registry.execute() entirely — there's no blocking - // dispatch to run. + // Doors meta-tools (only when doors mode is active). A bare door + // name (e.g. calling "web") is accepted as an alias for + // open_tools(door="web") — cheap and 4B-friendly (plan §7). let output; - if tool.name == "tool_mount" { + let door_alias: Option<&'static str> = if door_state.enabled() { + crate::doors::door_by_name(&tool.name) + .filter(|d| !d.ghost_tools.is_empty()) + .map(|d| d.name) + } else { + None + }; + + if door_state.enabled() && (tool.name == "open_tools" || door_alias.is_some()) { + // Gather-on-entry: mutate loop-local DoorState, synthesize the + // "available next turn" text (plan §4.3.2). Schemas land in the + // next request's tools array, not in the transcript. + output = open_door_result(®istry, &mut door_state, door_alias, &input); + } else if door_state.enabled() && tool.name == "close_tools" { + output = close_door_result(&mut door_state, &input); + } else if tool.name == "tool_mount" { + // tool_mount: non-blocking dynamic-widget mount. Stash the + // payload server-side keyed by a generated mount_id, emit + // the SSE event so the renderer can render it inline, and + // synthesize a confirmation string for the agent's history. + // Skip registry.execute() entirely — there's no blocking + // dispatch to run. let widget_name = input["name"].as_str().unwrap_or("widget").to_string(); let srcdoc = input["srcdoc"].as_str().unwrap_or("").to_string(); if srcdoc.is_empty() { @@ -627,6 +921,24 @@ After cleanup, reply to the human and end the turn." ); } } else { + // Closed-door call guard (plan §4.3.3): the model called a + // real catalog tool that is behind a closed door (saw the + // name in tool_search / compressed history / an earlier + // turn). Auto-open the door, run the tool, and annotate the + // result. This is safe by construction — doors are a menu, + // not a permission boundary (consent/identity live at the + // HTTP API). Truly unknown names fall through to the normal + // "Unknown tool: X" from registry.execute. + let mut auto_opened: Option<String> = None; + if door_state.enabled() { + if let Some(dn) = door_state.door_of(&tool.name) { + if !door_state.is_door_open(dn) { + door_state.open_door(dn); + auto_opened = Some(dn.to_string()); + } + } + } + // For show_* tools, surface the widget to the renderer // *before* dispatching (because dispatch blocks until the // user submits via POST /api/ghost/ui-response). The @@ -644,14 +956,61 @@ After cleanup, reply to the human and end the turn." } } - output = registry.execute(&tool.name, &input).await; + // Runaway-pane guard: a small model on a fuzzy task can open + // panes/tabs forever — each open is a fresh side effect, so + // the repeat guard (which keys on identical output) never + // trips. Hard-cap view-creating tools per user message; past + // the cap, refuse the side effect and tell it to stop. + // Focus-never-steal makes runaway opens especially disruptive. + const VIEW_CREATORS: [&str; 4] = + ["open_web_pane", "terminal_new_tab", "terminal_new_window", "terminal_split"]; + const VIEW_CREATE_CAP: usize = 4; + let is_view_creator = VIEW_CREATORS.contains(&tool.name.as_str()); + + // tool_search is door-aware in doors mode (adds open/closed + // hints); otherwise it flows through execute() unchanged. + let raw_output = if is_view_creator && view_open_count >= VIEW_CREATE_CAP { + tracing::warn!(target: "doors", tool = %tool.name, count = view_open_count, "runaway view-creation blocked"); + format!( + "[BLOCKED: refusing to run {} — you've already opened {} panes/tabs/windows \ + for this request. Opening more disrupts the human's view. STOP creating panes. \ + Use the ones already open (terminal_status lists them), finish the task, or ask \ + the user what they want.]", + tool.name, view_open_count + ) + } else if door_state.enabled() && tool.name == "tool_search" { + registry.handle_tool_search(&input, Some(&door_state)) + } else { + if is_view_creator { + view_open_count += 1; + } + registry.execute(&tool.name, &input).await + }; + + output = match auto_opened { + Some(dn) => format!("[door '{}' auto-opened by this call]\n{}", dn, raw_output), + None => raw_output, + }; } - // Repeat detection: check if we've seen this exact call+output before + // Any executed tool touches its door → moves it to MRU so it + // survives the next cap eviction longest (no-op for core tools + // and closed doors). + if door_state.enabled() { + door_state.touch(&tool.name); + } + + // Repeat detection. Two ways a call counts as a loop: + // (a) same call+output as a previous one (deterministic re-fire), OR + // (b) same tool+input issued 3+ times recently even when the + // OUTPUT differs each time — e.g. terminal_split with no + // args spawns a NEW pane every call, so its output never + // repeats and (a) alone would let a small model split + // forever. Counting the signature catches that. let call_sig = format!("{}:{}", tool.name, input.to_string()); - let is_repeat = recent_calls.iter().any(|(sig, prev_out)| { - sig == &call_sig && prev_out == &output - }); + let same_sig_count = recent_calls.iter().filter(|(sig, _)| sig == &call_sig).count(); + let is_repeat = same_sig_count >= 2 + || recent_calls.iter().any(|(sig, prev_out)| sig == &call_sig && prev_out == &output); recent_calls.push((call_sig, output.clone())); // Keep only last 10 if recent_calls.len() > 10 { recent_calls.remove(0); } @@ -805,7 +1164,7 @@ After cleanup, reply to the human and end the turn." turns: turn_start + turns - 1, }) .await; - return Ok((messages, "watercooler".into(), tool_call_count, recent_calls)); + return Ok((messages, "watercooler".into(), tool_call_count, recent_calls, door_state)); } // Continue the loop @@ -842,6 +1201,168 @@ After cleanup, reply to the human and end the turn." turns: turn_start + turns - 1, }) .await; - return Ok((messages, final_stop_reason, tool_call_count, recent_calls)); + return Ok((messages, final_stop_reason, tool_call_count, recent_calls, door_state)); + } +} + +/// Compact one-liner summary of `/api/status` for the slim doors-mode prompt +/// (plan §4.3.3): total window/tab/pane counts plus the single focused pane. +/// Keeps a small/local model oriented without paying for the full nested JSON. +fn compact_terminal_state(status: &serde_json::Value) -> String { + let windows = status["windows"].as_array().cloned().unwrap_or_default(); + let win_count = windows.len(); + let mut tab_count = 0usize; + let mut pane_count = 0usize; + let mut focused: Option<String> = None; + + for win in &windows { + let win_id = win["id"].as_u64().unwrap_or(0); + let win_focused = win["focused"].as_bool().unwrap_or(false); + for tab in win["tabs"].as_array().into_iter().flatten() { + tab_count += 1; + let tab_name = tab["name"].as_str().unwrap_or("shell"); + let tab_active = tab["active"].as_bool().unwrap_or(false); + for pane in tab["panes"].as_array().into_iter().flatten() { + pane_count += 1; + let pane_active = pane["active"].as_bool().unwrap_or(false); + if focused.is_none() && win_focused && tab_active && pane_active { + let label = pane["label"].as_str().unwrap_or(""); + let shell = pane["shell"].as_str().unwrap_or(""); + let proc = pane["process"].as_str().unwrap_or(""); + let cwd = pane["cwd"].as_str().unwrap_or(""); + let proc_info = if proc.is_empty() { + shell.to_string() + } else { + format!("{} ({})", shell, proc) + }; + focused = Some(format!( + "window {} › tab '{}' › pane {}: {} in `{}`", + win_id, + tab_name, + if label.is_empty() { "-" } else { label }, + proc_info, + cwd + )); + } + } + } + } + + let counts = format!( + "{} window(s), {} tab(s), {} pane(s).", + win_count, tab_count, pane_count + ); + match focused { + Some(f) => format!("{} Focused: {}", counts, f), + None => format!("{} No focused pane reported.", counts), + } +} + +/// Open a door and synthesize the gather-on-entry result text (plan §4.3.2): +/// the door name, a one-line-per-tool listing of what it exposes NEXT turn, the +/// current open-door set, the live-tool budget, and any LRU-evicted doors. +/// Handles both `open_tools(door=…)` and the bare-door-name alias. +fn open_door_result( + registry: &ToolRegistry, + door_state: &mut crate::doors::DoorState, + alias: Option<&str>, + input: &serde_json::Value, +) -> String { + use crate::doors::{door_by_name, doors_for, Surface}; + + let door: String = match alias { + Some(d) => d.to_string(), + None => input["door"].as_str().unwrap_or("").trim().to_string(), + }; + tracing::info!(target: "doors", door = %door, alias = ?alias, raw_input = %input, "open_tools requested"); + + // Validate against the ghost surface. + let valid = door_by_name(&door).map_or(false, |d| !d.ghost_tools.is_empty()); + if !valid { + let names: Vec<&str> = doors_for(Surface::Ghost).map(|d| d.name).collect(); + tracing::warn!(target: "doors", door = %door, available = %names.join(", "), "open_tools failed: unknown door"); + return format!( + "Unknown door '{}'. Available doors: {}", + door, + names.join(", ") + ); + } + + let evicted = door_state.open_door(&door); + let door_def = door_by_name(&door).unwrap(); + tracing::info!( + target: "doors", + door = %door, + opened_tools = door_def.ghost_tools.len(), + live = door_state.live_tool_count(), + cap = door_state.cap(), + evicted = %evicted.join(","), + "open_tools ok" + ); + + // One-line-per-tool listing pulled from the full catalog descriptions. + let catalog = registry.tool_defs(None, None, None); + let lines: Vec<String> = door_def + .ghost_tools + .iter() + .map(|&t| { + let desc = catalog + .iter() + .find(|c| c.name == t) + .map(|c| c.description.as_str()) + .unwrap_or(""); + let first = desc.lines().next().unwrap_or("").trim(); + format!("- {}: {}", t, first) + }) + .collect(); + + let open_list = door_state.open_doors().join(", "); + let mut out = format!( + "Door '{}' opened. Available on your NEXT turn ({} tools):\n{}\n\n\ + [doors open: {}] [live tools next turn: {}/{}]", + door, + door_def.ghost_tools.len(), + lines.join("\n"), + open_list, + door_state.live_tool_count(), + door_state.cap() + ); + if !evicted.is_empty() { + out.push_str(&format!( + "\n[evicted: {} — reopen with open_tools if needed]", + evicted.join(", ") + )); + } + out +} + +/// Close a door and report the resulting live-tool budget (plan §4.3.2). +fn close_door_result( + door_state: &mut crate::doors::DoorState, + input: &serde_json::Value, +) -> String { + let door = input["door"].as_str().unwrap_or("").trim().to_string(); + if door.is_empty() { + return "close_tools requires a 'door' name.".to_string(); } + let was_open = door_state.is_door_open(&door); + door_state.close_door(&door); + let open_list = door_state.open_doors().join(", "); + let open_disp = if open_list.is_empty() { + "none".to_string() + } else { + open_list + }; + let prefix = if was_open { + format!("Door '{}' closed.", door) + } else { + format!("Door '{}' was not open.", door) + }; + format!( + "{} [doors open: {}] [live tools next turn: {}/{}]", + prefix, + open_disp, + door_state.live_tool_count(), + door_state.cap() + ) } diff --git a/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index c3cb43e31b6..cdc2d36a1f8 100644 --- a/sidecar/src/ghost/api.rs +++ b/sidecar/src/ghost/api.rs @@ -92,10 +92,12 @@ pub async fn ghost_chat( let registry = state.registry.clone(); let session_mutex = state.session.clone(); let ferricula = state.ferricula.clone(); + let door_config = config.doors; + let context_tokens = config.context_tokens; let rx = { let mut session = state.session.lock().await; - session.run(req.message, registry, provider, session_mutex.clone(), ferricula) + session.run(req.message, registry, provider, session_mutex.clone(), ferricula, door_config, context_tokens) }; let s = async_stream::stream! { @@ -128,6 +130,35 @@ pub async fn ghost_status(State(state): State<GhostState>) -> Json<serde_json::V })) } +/// GET /api/ghost/debug — per-turn run-loop telemetry so a dev or agent can +/// troubleshoot without the human relaying the shell: the configured +/// provider/model/endpoint, current state, and a ring buffer of the last +/// turns (which model answered, in/out tokens per turn + total, decode tps, +/// context budget, tools called, stop reason, panes opened). +pub async fn ghost_debug(State(state): State<GhostState>) -> Json<serde_json::Value> { + let cfg = super::load_config(); + let session = state.session.lock().await; + let state_str = match session.state() { + SessionState::Idle => "idle", + SessionState::Running => "running", + SessionState::Completed(_) => "completed", + SessionState::Error(_) => "error", + }; + Json(serde_json::json!({ + "state": state_str, + "turn": session.turn(), + "messages": session.message_count(), + "stop_requested": session.stop_requested(), + "provider": cfg.as_ref().map(|c| c.provider.clone()), + "model": cfg.as_ref().map(|c| c.model.clone()), + "endpoint": cfg.as_ref().map(|c| c.endpoint.clone()), + "context_tokens_cfg": cfg.as_ref().map(|c| c.context_tokens), + "doors_enabled": cfg.as_ref().map(|c| c.doors.enabled), + "doors_small": cfg.as_ref().map(|c| c.doors.small), + "turns": super::agent::ghost_debug_snapshot(), + })) +} + /// GET /api/ghost/history — return chat messages from Ferricula for restoring the UI. pub async fn ghost_history(State(state): State<GhostState>) -> Json<serde_json::Value> { let turns = state.ferricula.history(50).await; @@ -448,6 +479,9 @@ pub async fn ghost_capabilities(State(state): State<GhostState>) -> Json<serde_j Json(serde_json::json!({ "sidecar": env!("CARGO_PKG_VERSION"), "level": level, + // Per-provider default model ids for the UI pickers — served from the + // models registry so shell.html / agent-config.html never hardcode ids. + "model_defaults": crate::models::model_defaults_json(), "agent": { "provider": if needs_autopick { "ollama".to_string() } else { active_provider }, "model": active_model, @@ -581,7 +615,7 @@ pub async fn ghost_wipe_config( } } -fn config_raw_path() -> Option<std::path::PathBuf> { +pub fn config_raw_path() -> Option<std::path::PathBuf> { super::config_path() } @@ -714,6 +748,12 @@ pub async fn ghost_shell_page() -> impl IntoResponse { // the OS keystore with #130. /// GET /agent/config — the Hyperia Agent configuration page. +/// GET /guide — the web-pane start page: DuckDuckGo/URL search bar + the +/// Hyperia field guide (MCP add commands, basics, links to the agent). +pub async fn guide_page() -> impl IntoResponse { + Html(include_str!("../../static/guide.html")) +} + pub async fn agent_config_page() -> impl IntoResponse { Html(include_str!("../../static/agent-config.html")) } @@ -749,14 +789,23 @@ pub async fn get_agent_config() -> Json<serde_json::Value> { "gemini": has_key("gemini"), }, // Per-service settings (ports etc.) — config.agent.services.<name>. - "services": agent["services"].clone() + "services": agent["services"].clone(), + // Absolute path of the hand-editable config file — the page links it + // to a code-mode sticky for direct editing. + "config_path": config_raw_path().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(), + // Token Maximus (local-model compressor/extractor) settings. + "maximus": { + "model": json["config"]["maximus"]["model"].as_str().unwrap_or(""), + "url": json["config"]["maximus"]["url"].as_str().unwrap_or(""), + "disabled": json["config"]["maximus"]["disabled"].as_bool().unwrap_or(false), + } })) } /// POST /api/agent/config — write provider/model and any pasted keys into the /// shared config. Body: { provider?, model?, keys?: {anthropic?, ...} }. -/// Empty-string key = leave unchanged; "-" = clear. provider="" clears the -/// agent config (unconfigure — Hyperia drops out of the agent list). +/// Empty-string key = leave unchanged; "-" = clear. Empty provider is ignored +/// (Hyperia is always in the agent list; there is no unconfigure). pub async fn post_agent_config( Json(body): Json<serde_json::Value>, ) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> { @@ -770,12 +819,10 @@ pub async fn post_agent_config( if !json["config"]["agent"].is_object() { json["config"]["agent"] = serde_json::json!({}); } + // Hyperia is ALWAYS in the agent list and always configurable — there is + // no "unconfigure". An empty provider is simply ignored. if let Some(p) = body["provider"].as_str() { - if p.is_empty() { - // Unconfigure: drop provider/model, keep keys (they're credentials). - json["config"]["agent"]["provider"] = serde_json::json!(""); - json["config"]["agent"]["model"] = serde_json::json!(""); - } else { + if !p.is_empty() { json["config"]["agent"]["provider"] = serde_json::json!(p.to_lowercase()); } } @@ -806,6 +853,22 @@ pub async fn post_agent_config( json["config"]["agent"]["services"][name] = val.clone(); } } + // Token Maximus (the local-model compressor/extractor): model, url, + // disabled. Written under config.maximus.* — the compressor's getters + // already prefer this over env/defaults. + if let Some(mx) = body["maximus"].as_object() { + if !json["config"]["maximus"].is_object() { + json["config"]["maximus"] = serde_json::json!({}); + } + for key in ["model", "url"] { + if let Some(v) = mx.get(key).and_then(|v| v.as_str()) { + json["config"]["maximus"][key] = serde_json::json!(v); + } + } + if let Some(d) = mx.get("disabled").and_then(|v| v.as_bool()) { + json["config"]["maximus"]["disabled"] = serde_json::json!(d); + } + } match crate::util::write_json_file_atomic(&path, &json) { Ok(_) => Ok(Json(serde_json::json!({"ok": true}))), Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({"ok": false, "error": e.to_string()})))), @@ -964,10 +1027,8 @@ pub async fn get_agent_services() -> Json<serde_json::Value> { })) } -/// Curated Ollama allowlist — Gemma 4 ONLY: the fast local tags (e4b/12b) plus -/// the strong cloud tags. E2B-class excluded (poorly quantized). Hand-extend -/// via config.agent.ollama_allow in the shared config. -const OLLAMA_CURATED: &[&str] = &["gemma4:e4b", "gemma4:12b", "gemma4:cloud", "gemma4:31b-cloud"]; +/// Curated Ollama allowlist — single source of truth: crate::models. +const OLLAMA_CURATED: &[&str] = crate::models::OLLAMA_CURATED; /// GET /api/agent/models — the nemesis8.nuts.services/models catalog, cached /// for its TTL (1h), with the ollama list filtered to the curated set. @@ -976,10 +1037,105 @@ pub async fn get_agent_models() -> Json<serde_json::Value> { use std::time::{Duration, Instant}; static CACHE: OnceLock<Mutex<Option<(Instant, serde_json::Value)>>> = OnceLock::new(); let cache = CACHE.get_or_init(|| Mutex::new(None)); - if let Some((at, val)) = cache.lock().unwrap().clone() { - if at.elapsed() < Duration::from_secs(3600) { - return Json(val); + + // The nemesis8 catalog (frontier + curated ollama) is cached for 1h. Sailfish + // is NOT cached — its availability flips as the local container starts/stops + // and warms up, so we probe it fresh on every call and inject after the cache + // lookup. `inject_sailfish` funnels both the cache-hit and fresh paths. + async fn inject_sailfish(mut val: serde_json::Value) -> serde_json::Value { + let sf_port = read_shared_config()["config"]["agent"]["services"]["sailfish"]["port"] + .as_u64() + .unwrap_or(22343); + let probed = async { + let resp = reqwest::Client::new() + .get(format!("http://localhost:{}/v1/models", sf_port)) + .timeout(Duration::from_secs(2)) + .send() + .await + .ok()?; + if !resp.status().is_success() { + return None; + } + resp.json::<serde_json::Value>().await.ok() } + .await; + let models: Vec<serde_json::Value> = probed + .as_ref() + .and_then(|j| j["data"].as_array()) + .map(|arr| { + arr.iter() + .filter_map(|m| { + let id = m["id"].as_str()?; + Some(serde_json::json!({"id": id, "label": id})) + }) + .collect() + }) + .unwrap_or_default(); + let default = models + .first() + .and_then(|m| m["id"].as_str()) + .unwrap_or("") + .to_string(); + val["providers"]["sailfish"] = serde_json::json!({ + "ok": !models.is_empty(), + "default": default, + "models": models, + }); + // Ollama cloud tags: ANY installed model with a cloud tag (…:cloud or + // …-cloud, e.g. nemotron-3-super:cloud, deepseek-v4-pro:cloud) joins + // the curated list automatically. Probed fresh per request — like the + // sailfish block above — so a new `ollama pull x:cloud` shows up in + // the picker immediately, never stale behind the 1h catalog cache. + let ollama_ep = super::default_endpoint("ollama"); + let tags = async { + let resp = reqwest::Client::new() + .get(format!("{}/api/tags", ollama_ep)) + .timeout(std::time::Duration::from_secs(2)) + .send() + .await + .ok()?; + resp.json::<serde_json::Value>().await.ok() + } + .await; + if let Some(tags) = tags { + let installed_cloud: Vec<String> = tags["models"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|m| m["name"].as_str()) + .filter(|n| n.ends_with(":cloud") || n.contains("-cloud")) + .map(String::from) + .collect() + }) + .unwrap_or_default(); + if !installed_cloud.is_empty() { + let mut models = val["providers"]["ollama"]["models"] + .as_array() + .cloned() + .unwrap_or_default(); + for id in installed_cloud { + if !models.iter().any(|m| m["id"].as_str() == Some(id.as_str())) { + models.push(serde_json::json!({"id": id, "label": id})); + } + } + val["providers"]["ollama"]["models"] = serde_json::json!(models); + } + } + val + } + + // Snapshot the cached catalog and drop the MutexGuard BEFORE any await — + // holding a std Mutex guard across .await makes the future non-Send and + // breaks the axum Handler bound. + let cached = { + let guard = cache.lock().unwrap(); + match guard.clone() { + Some((at, val)) if at.elapsed() < Duration::from_secs(3600) => Some(val), + _ => None, + } + }; + if let Some(val) = cached { + return Json(inject_sailfish(val).await); } let fetched = async { let resp = reqwest::Client::new() @@ -991,10 +1147,10 @@ pub async fn get_agent_models() -> Json<serde_json::Value> { resp.json::<serde_json::Value>().await.ok() } .await; - let mut val = match fetched { - Some(v) => v, - None => return Json(serde_json::json!({"ok": false, "error": "models catalog unreachable"})), - }; + // When the nemesis8 catalog is unreachable we still return a usable payload + // (curated ollama + a fresh sailfish probe below) rather than a hard error, + // so the picker keeps working for the local providers. + let mut val = fetched.unwrap_or_else(|| serde_json::json!({"providers": {}})); // Ollama: the curated list IS the list (not an intersection with the // catalog — these tags are pulled locally via `ollama pull`). Extras come // from config.agent.ollama_allow. @@ -1032,6 +1188,8 @@ pub async fn get_agent_models() -> Json<serde_json::Value> { val["providers"]["codex"]["models"] = serde_json::json!(plain); } val["ok"] = serde_json::json!(true); + // Cache the nemesis8-derived catalog WITHOUT sailfish, then inject a fresh + // sailfish probe so its availability is never stale behind the 1h TTL. *cache.lock().unwrap() = Some((Instant::now(), val.clone())); - Json(val) + Json(inject_sailfish(val).await) } diff --git a/sidecar/src/ghost/bootstub.rs b/sidecar/src/ghost/bootstub.rs index 12f9834ad96..ba153001a70 100644 --- a/sidecar/src/ghost/bootstub.rs +++ b/sidecar/src/ghost/bootstub.rs @@ -305,10 +305,9 @@ fn write_token(provider: &str, token: &str) -> BootReply { } fn default_model_for(provider: &str) -> &'static str { + // Single source of truth: crate::models. match provider { - "anthropic" => "claude-sonnet-4-6", - "openai" => "gpt-4o", - "gemini" => "gemini-2.0-flash", + "anthropic" | "openai" | "gemini" => crate::models::default_model(provider), _ => "", } } diff --git a/sidecar/src/ghost/compressor.rs b/sidecar/src/ghost/compressor.rs index e6b61f5fba6..5c0cd806ca9 100644 --- a/sidecar/src/ghost/compressor.rs +++ b/sidecar/src/ghost/compressor.rs @@ -8,7 +8,7 @@ use tracing::{info, warn}; use super::ferricula::FerriculaBackend; const DEFAULT_OLLAMA_URL: &str = "http://localhost:11434"; -const DEFAULT_MODEL: &str = "gemma2:2b"; +const DEFAULT_MODEL: &str = crate::models::COMPRESSOR_DEFAULT_MODEL; const DEFAULT_KEEP_RECENT: usize = 6; const COMPRESS_THRESHOLD: usize = 10; pub const FOCUS_MIN_CHARS: usize = 400; @@ -219,11 +219,45 @@ impl ContextCompressor { // -- Message history compression (unchanged) -- pub async fn compress_messages(&self, messages: &[Value]) -> Vec<Value> { + self.compress_messages_budgeted(messages, 0, 0).await + } + + /// Like [`compress_messages`], but honors a hard token budget (plan §3 + /// Phase 3). `budget_tokens == 0` disables the guard (identical to + /// `compress_messages`). `overhead_chars` is the char length of everything + /// else in the request the budget must also cover (system prompt + tool + /// schemas). Tokens are estimated at 4 chars/token. When the estimate blows + /// the budget, the number of verbatim recent messages kept is stepped down + /// from the default (6) toward a floor of 2 so system + tools + history fit + /// (e.g. an 8k Sailfish window). + pub async fn compress_messages_budgeted( + &self, + messages: &[Value], + budget_tokens: usize, + overhead_chars: usize, + ) -> Vec<Value> { + // Decide how many recent messages to keep verbatim under the budget. + let mut keep_recent = self.keep_recent; + if budget_tokens > 0 { + let history_chars: usize = messages + .iter() + .map(|m| serde_json::to_string(m).map(|s| s.len()).unwrap_or(0)) + .sum(); + let est_tokens = (overhead_chars + history_chars) / 4; + keep_recent = budgeted_keep_recent(self.keep_recent, budget_tokens, est_tokens); + if keep_recent != self.keep_recent { + info!( + "maximus: context budget {} tok exceeded (~{} tok incl. {} overhead chars) — keep_recent {} → {}", + budget_tokens, est_tokens, overhead_chars, self.keep_recent, keep_recent + ); + } + } + if messages.len() <= COMPRESS_THRESHOLD { return messages.to_vec(); } - let split_at = messages.len().saturating_sub(self.keep_recent); + let split_at = messages.len().saturating_sub(keep_recent); let older = &messages[..split_at]; let recent = &messages[split_at..]; @@ -247,8 +281,11 @@ impl ContextCompressor { out } Err(e) => { - warn!("maximus: compression skipped ({}), using full history", e); - messages.to_vec() + // The LLM summarizer is down — do NOT fall back to full history + // (that's exactly what overflows an 8k window). Trim mechanically + // so the prompt still fits. + warn!("maximus: compression failed ({}), hard-trimming to fit budget", e); + hard_trim_to_budget(messages, budget_tokens, overhead_chars) } } } @@ -265,6 +302,15 @@ impl ContextCompressor { return MaximusResult { content: content.to_string(), meta }; } + // Disabled means NOT RUN AT ALL — full tokens pass through to the + // agent untouched. (is_available() already gates context compression; + // this gates the tool-result extraction path too.) + if self.is_disabled() { + let meta = MaximusMeta::passthrough(chars_in, "maximus disabled"); + self.store_last(&meta).await; + return MaximusResult { content: content.to_string(), meta }; + } + if content.len() < FOCUS_MIN_CHARS && focus.trim().is_empty() { let meta = MaximusMeta::passthrough(chars_in, "below threshold"); self.store_last(&meta).await; @@ -676,6 +722,79 @@ impl ContextCompressor { } } +// -- Token-budget guard (plan §3 Phase 3) -- + +/// How many recent messages to keep verbatim under a hard token budget. +/// +/// Returns `default_keep` when the budget is off (`0`) or the estimate fits. +/// Otherwise steps down one message per ~1/8 of the budget the estimate is +/// over, floored at 2 (never drops below the two most recent turns). +/// Mechanical, LLM-free trim: keep the newest messages that fit within the +/// token budget (chars/4 estimate, incl. `overhead_chars` for system+tools), +/// dropping the oldest. Always keeps at least the final message (the current +/// turn). Strips any leading orphaned `tool` result so the trimmed history +/// doesn't 400 the provider ("tool message must follow tool_calls"), and +/// prepends a short note when history was dropped. Used when the LLM +/// compressor is unavailable/failed — the last line of defense against a +/// context-window overflow. +pub fn hard_trim_to_budget(messages: &[Value], budget_tokens: usize, overhead_chars: usize) -> Vec<Value> { + if budget_tokens == 0 || messages.is_empty() { + return messages.to_vec(); + } + let est = |m: &Value| serde_json::to_string(m).map(|s| s.len()).unwrap_or(0) / 4; + let mut budget = budget_tokens.saturating_sub(overhead_chars / 4); + let mut kept: Vec<Value> = Vec::new(); + for m in messages.iter().rev() { + let t = est(m); + // Always keep the newest message even if it alone blows the budget — + // sending a too-big current turn is the provider's problem to report, + // whereas sending nothing is useless. + if kept.is_empty() || t <= budget { + budget = budget.saturating_sub(t); + kept.push(m.clone()); + } else { + break; + } + } + kept.reverse(); + // Drop a leading tool-result whose parent assistant/tool_calls got trimmed. + while kept + .first() + .and_then(|m| m["role"].as_str()) + .map_or(false, |r| r == "tool") + { + kept.remove(0); + } + let dropped = messages.len() - kept.len(); + if dropped == 0 { + return kept; + } + info!( + "maximus: hard-trimmed {} old message(s) to fit ~{} tok context budget", + dropped, budget_tokens + ); + let mut out = Vec::with_capacity(kept.len() + 1); + out.push(serde_json::json!({ + "role": "user", + "content": format!( + "[{} earlier message(s) were dropped to fit the model's context window. Ask the human if you need lost detail.]", + dropped + ) + })); + out.extend(kept); + out +} + +fn budgeted_keep_recent(default_keep: usize, budget_tokens: usize, est_tokens: usize) -> usize { + if budget_tokens == 0 || est_tokens <= budget_tokens { + return default_keep; + } + let over = est_tokens - budget_tokens; + let step = (budget_tokens / 8).max(1); + let drop = (over / step).min(default_keep.saturating_sub(2)); + default_keep.saturating_sub(drop).max(2) +} + // -- Heuristic content type detection (offline, no Ollama) -- fn detect_content_type(content: &str) -> Option<String> { @@ -987,6 +1106,47 @@ mod tests { assert!(ann.contains("raw=true")); } + // --- budgeted_keep_recent (token budget guard) --- + + #[test] + fn budget_off_keeps_default() { + assert_eq!(budgeted_keep_recent(6, 0, 999_999), 6); + } + + #[test] + fn budget_under_keeps_default() { + assert_eq!(budgeted_keep_recent(6, 8000, 5000), 6); + } + + #[test] + fn budget_slightly_over_drops_one() { + // 8000 budget, step = 1000. Over by 1200 → drop 1 → 5. + assert_eq!(budgeted_keep_recent(6, 8000, 9200), 5); + } + + #[test] + fn budget_way_over_floors_at_two() { + // Massively over budget → floored at 2, never lower. + assert_eq!(budgeted_keep_recent(6, 8000, 200_000), 2); + } + + #[test] + fn budget_never_below_two_even_at_boundary() { + // Exactly at budget → no reduction. + assert_eq!(budgeted_keep_recent(6, 8000, 8000), 6); + // Small default already at floor stays at floor. + assert_eq!(budgeted_keep_recent(2, 8000, 100_000), 2); + } + + #[tokio::test] + async fn compress_budgeted_zero_matches_plain() { + let c = ContextCompressor::new("http://127.0.0.1:1", "m"); + let input = msgs(COMPRESS_THRESHOLD + 5); + let plain = c.compress_messages(&input).await; + let budgeted = c.compress_messages_budgeted(&input, 0, 0).await; + assert_eq!(plain, budgeted); + } + // --- detect_content_type heuristic --- #[test] diff --git a/sidecar/src/ghost/ferricula.rs b/sidecar/src/ghost/ferricula.rs index 0b2ce15d3d8..005a14c0f0e 100644 --- a/sidecar/src/ghost/ferricula.rs +++ b/sidecar/src/ghost/ferricula.rs @@ -424,6 +424,13 @@ impl FerriculaBackend { /// 2. shared Hyperia config → config.ferricula.url /// 3. Default http://localhost:8765 pub fn load_ferricula_config() -> FerriculaConfig { + // DISABLED for now (user call, 2026-07-02): ferricula recall/remember adds + // latency/timeouts while the service is down and small-model work is the + // focus. Empty url = backend no-ops. Re-enable by removing this early + // return (env/config plumbing below is intact). + if std::env::var("HYPERIA_FERRICULA").map(|v| v == "1").unwrap_or(false) == false { + return FerriculaConfig { url: String::new() }; + } if let Ok(url) = std::env::var("FERRICULA_URL") { let url = url.trim().trim_end_matches('/').to_string(); if !url.is_empty() { diff --git a/sidecar/src/ghost/mod.rs b/sidecar/src/ghost/mod.rs index a3a08f274da..157a93f573a 100644 --- a/sidecar/src/ghost/mod.rs +++ b/sidecar/src/ghost/mod.rs @@ -141,16 +141,28 @@ pub fn load_config() -> Option<GhostConfig> { } }; + // Sailfish's default endpoint is derived from the shared config's service + // port (config.agent.services.sailfish.port, default 22343) — default_endpoint + // can't see the config, so resolve it here when no per-provider override is set. + if provider == "sailfish" && endpoint.is_empty() { + let port = cfg["agent"]["services"]["sailfish"]["port"] + .as_u64() + .unwrap_or(22343); + endpoint = format!("http://localhost:{}", port); + } + if provider == "ollama" && std::path::Path::new("/.dockerenv").exists() { if endpoint == "http://localhost:11434" || endpoint == "http://127.0.0.1:11434" { endpoint = "http://host.docker.internal:11434".to_string(); } } - // Ollama doesn't require a token. Cloud providers do — without one we - // can't honor the user's choice, so fall back to local Ollama and let - // the doctor probe surface what's missing. - if provider != "ollama" && api_key.is_empty() { + // Local providers (Ollama, Sailfish) don't require a token — they're + // localhost-only appliances. Cloud providers do — without one we can't + // honor the user's choice, so fall back to local Ollama and let the doctor + // probe surface what's missing. Sailfish must NOT fall back to Ollama on an + // empty key (it's a valid keyless local endpoint). + if provider != "ollama" && provider != "sailfish" && api_key.is_empty() { tracing::warn!( "agent.provider = '{}' but no token configured at config.providers.{}.token — falling back to local Ollama. Use the settings agent to paste a key.", provider, provider @@ -166,12 +178,21 @@ pub fn load_config() -> Option<GhostConfig> { let maximus_url = cfg["maximus"]["url"].as_str().map(|s| s.to_string()); let maximus_disabled = cfg["maximus"]["disabled"].as_bool().unwrap_or(false); + // MCP-tool-doors: mode + context budget. `auto` (default) turns doors on + // for every provider (small/local get the tight cap; cloud gets a larger + // one). Env HYPERIA_TOOL_DOORS overrides. See doors::resolve_door_config. + let tool_doors_mode = cfg["agent"]["tool_doors"].as_str().unwrap_or("auto"); + let doors = crate::doors::resolve_door_config(tool_doors_mode, &provider, &model, &endpoint); + let context_tokens = cfg["agent"]["context_tokens"].as_u64().unwrap_or(0) as usize; + Some(GhostConfig { provider, model, api_key, endpoint, max_turns: 25, + doors, + context_tokens, maximus_model, maximus_url, maximus_disabled, @@ -179,25 +200,24 @@ pub fn load_config() -> Option<GhostConfig> { } /// Pick a sensible default model when the user has set a provider but no -/// specific model yet. The settings agent will normally guide them to a -/// concrete one, but this keeps the chat reachable in the meantime. +/// specific model yet. Single source of truth: crate::models. fn default_model(provider: &str) -> &'static str { - match provider { - "anthropic" => "claude-sonnet-4-6", - "openai" => "gpt-4o", - "gemini" => "gemini-2.0-flash", - "ollama" => "gemma2:9b", - _ => "gemma2:9b", - } + crate::models::default_model(provider) } fn default_local_ollama() -> GhostConfig { + let endpoint = default_endpoint("ollama"); + // Local Ollama is always a small/local model → auto-doors on with the tight + // cap (env still overrides). Keep this consistent with load_config's path. + let doors = crate::doors::resolve_door_config("auto", "ollama", "gemma2:9b", &endpoint); GhostConfig { provider: "ollama".into(), model: "gemma2:9b".into(), api_key: String::new(), - endpoint: default_endpoint("ollama"), + endpoint, max_turns: 25, + doors, + context_tokens: 0, maximus_model: None, maximus_url: None, maximus_disabled: false, diff --git a/sidecar/src/ghost/provider.rs b/sidecar/src/ghost/provider.rs index 170cac7e41b..19e68ccdc04 100644 --- a/sidecar/src/ghost/provider.rs +++ b/sidecar/src/ghost/provider.rs @@ -26,6 +26,11 @@ impl AnyProvider { "anthropic" => AnyProvider::Anthropic(AnthropicProvider::new(config)), "ollama" => AnyProvider::Ollama(OllamaProvider::new(config)), "openai" => AnyProvider::OpenAI(OpenAIProvider::new(config)), + // Sailfish is a local OpenAI-compatible endpoint (llama.cpp CUDA, + // http://localhost:22343/v1). It rides OpenAIProvider verbatim — the + // only difference is provider_name() reports "sailfish" (via the + // provider_label carried on OpenAIProvider from config.provider). + "sailfish" => AnyProvider::OpenAI(OpenAIProvider::new(config)), "gemini" => AnyProvider::Unsupported("gemini".into()), other => AnyProvider::Unsupported(other.to_string()), } @@ -35,7 +40,9 @@ impl AnyProvider { match self { AnyProvider::Anthropic(_) => "anthropic", AnyProvider::Ollama(_) => "ollama", - AnyProvider::OpenAI(_) => "openai", + // "openai" or "sailfish" — both ride OpenAIProvider; the label is + // carried from config.provider so doors/telemetry can tell them apart. + AnyProvider::OpenAI(p) => &p.provider_label, AnyProvider::Unsupported(name) => name, } } @@ -91,7 +98,7 @@ impl AnthropicProvider { // model_catalog → show_picker → settings_set flow. If it's empty // load_config has already defaulted it. let model = if config.model.is_empty() { - "claude-sonnet-4-6".to_string() + crate::models::default_model("anthropic").to_string() } else { config.model.clone() }; @@ -386,13 +393,16 @@ fn extract_reply_fallback(raw: &str) -> String { break; } } - if let Some(e) = end_idx { - let extracted: String = chars[1..e].iter().collect(); - return extracted - .replace("\\\"", "\"") - .replace("\\n", "\n") - .replace("\\t", "\t"); - } + // Take up to the closing quote — or, when the generation was + // truncated mid-string (token cap) and there IS no closing + // quote, take everything to the end. A cut-off sentence beats + // showing the user a raw JSON fragment. + let e = end_idx.unwrap_or(chars.len()); + let extracted: String = chars[1..e].iter().collect(); + return extracted + .replace("\\\"", "\"") + .replace("\\n", "\n") + .replace("\\t", "\t"); } } } @@ -439,6 +449,173 @@ fn clean_and_parse_json(content: &str) -> anyhow::Result<serde_json::Value> { Ok(parsed) } +/// Live-streaming single-shot Ollama generation (temperature 0.1). Streams +/// native `message.thinking` deltas to the UI AS THE MODEL GENERATES — the +/// blocking candidate path sits silent for the whole generation (~60s on +/// ornith) and then fake-streams the finished thought. `message.content` +/// (the structured JSON) is accumulated and parsed at the end, with the same +/// validation as run_ollama_candidate. Returns the candidate tuple plus a +/// flag: was thinking already streamed live (so the caller doesn't replay it). +async fn run_ollama_streaming( + client: reqwest::Client, + endpoint: String, + api_key: String, + model: String, + ollama_messages: Vec<serde_json::Value>, + format_schema: Option<serde_json::Value>, + tools: Vec<ToolDef>, + tx: &mpsc::Sender<ProviderEvent>, +) -> anyhow::Result<(String, Option<serde_json::Value>, String, u64, u64, bool)> { + let mut body = serde_json::json!({ + "model": model, + "messages": ollama_messages, + "stream": true, + "options": { "temperature": 0.1 } + }); + if let Some(schema) = format_schema { + body["format"] = schema; + } + + let mut req = client + .post(format!("{}/api/chat", endpoint)) + .header("content-type", "application/json") + .body(body.to_string()); + if !api_key.is_empty() { + req = req.bearer_auth(&api_key); + } + let resp = req.send().await?; + if !resp.status().is_success() { + let status = resp.status(); + let raw = resp.text().await.unwrap_or_default(); + return Err(anyhow::Error::new(CandidateError::Http(format!( + "Ollama error {}: {}", + status, raw + )))); + } + + // Ollama streams NDJSON — one JSON object per line; message.thinking and + // message.content are per-chunk DELTAS. Final line has done:true + usage. + let mut stream = resp.bytes_stream(); + let mut buf = String::new(); + let mut content = String::new(); + let mut native_thinking = String::new(); + let mut input_tokens = 0u64; + let mut output_tokens = 0u64; + let mut think_id: Option<String> = None; + + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + buf.push_str(&String::from_utf8_lossy(&chunk)); + while let Some(pos) = buf.find('\n') { + let line = buf[..pos].trim().to_string(); + buf = buf[pos + 1..].to_string(); + if line.is_empty() { + continue; + } + let Ok(val) = serde_json::from_str::<serde_json::Value>(&line) else { + continue; + }; + if let Some(t) = val["message"]["thinking"].as_str() { + if !t.is_empty() { + let first = native_thinking.is_empty(); + native_thinking.push_str(t); + let id = think_id + .get_or_insert_with(|| { + format!( + "think_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + ) + }) + .clone(); + if first { + let _ = tx.send(ProviderEvent::ThinkingStart { id: id.clone() }).await; + } + let _ = tx + .send(ProviderEvent::ThinkingDelta { id, text: t.to_string() }) + .await; + } + } + if let Some(c) = val["message"]["content"].as_str() { + content.push_str(c); + } + if val["done"].as_bool() == Some(true) { + input_tokens = val["prompt_eval_count"].as_u64().unwrap_or(0); + output_tokens = val["eval_count"].as_u64().unwrap_or(0); + } + } + } + if let Some(id) = think_id.clone() { + let _ = tx.send(ProviderEvent::ThinkingEnd { id }).await; + } + let streamed_thinking = think_id.is_some(); + + // Debug dump, mirroring the blocking path's ollama_debug.log. + let _ = std::fs::write( + "ollama_debug.log", + format!( + "=== STREAMED REQUEST ===\n{}\n=== CONTENT ===\n{}\n=== NATIVE THINKING ===\n{}\n", + body, content, native_thinking + ), + ); + + // Parse + validate exactly like the blocking candidate path. + let parsed = clean_and_parse_json(&content).map_err(|e| { + anyhow::Error::new(CandidateError::JsonParse { + raw_content: content.clone(), + native_thinking: native_thinking.clone(), + input_tokens, + output_tokens, + error_msg: e.to_string(), + }) + })?; + + let mut thought = parsed["thought"].as_str().unwrap_or("").to_string(); + if thought.is_empty() && !native_thinking.is_empty() { + thought = native_thinking; + } + let reply = parsed["reply"].as_str().unwrap_or("").to_string(); + let tool_call = if let Some(name) = parsed["tool_name"].as_str() { + let args = parsed + .get("tool_arguments") + .cloned() + .unwrap_or(serde_json::json!({})); + Some(serde_json::json!({ "name": name, "arguments": args })) + } else { + parsed.get("tool_call").cloned() + }; + if let Some(ref tc) = tool_call { + if !tc.is_null() && tc.is_object() { + if let Some(name) = tc["name"].as_str() { + if name != "none" { + let arguments = &tc["arguments"]; + if let Some(tool_def) = tools.iter().find(|t| t.name == name) { + if !validate_arguments(&tool_def.input_schema, arguments) { + return Err(anyhow::Error::new(CandidateError::Validation(format!( + "Invalid arguments for tool {}. Args: {}", + name, arguments + )))); + } + } else { + return Err(anyhow::Error::new(CandidateError::Validation(format!( + "Model called unknown tool {}", + name + )))); + } + } + } else { + return Err(anyhow::Error::new(CandidateError::Validation( + "Missing tool name in tool_call".to_string(), + ))); + } + } + } + + Ok((thought, tool_call, reply, input_tokens, output_tokens, streamed_thinking)) +} + async fn run_ollama_candidate( client: reqwest::Client, endpoint: String, @@ -753,6 +930,20 @@ impl OllamaProvider { for t in tools { tool_names.push(t.name.clone()); } + // Door names are callable too — agent.rs treats a bare door-name + // call as open_tools(door=...). The enum IS a structured-output + // model's entire tool universe: without door names it literally + // cannot reach behind a closed door ("split the pane" dead-ended + // in "I don't have terminal_split" because terminal_split wasn't + // in the enum and neither was any way to open its door). + for d in crate::doors::doors_for(crate::doors::Surface::Ghost) { + if !d.ghost_tools.is_empty() { + let n = d.name.to_string(); + if !tool_names.contains(&n) { + tool_names.push(n); + } + } + } } let format_schema = if !tools.is_empty() { @@ -774,10 +965,13 @@ impl OllamaProvider { }, "reply": { "type": "string", - "description": "Your final response to the user. Use this if you are not calling a tool (i.e., tool_name is 'none')." + "description": "REQUIRED. Your final answer to the user's question — shown to them verbatim. MUST be \"\" when tool_name is not 'none'; never narrate tool calls here." } }, - "required": ["thought", "tool_name", "tool_arguments"] + // reply IS required — ornith emitted thought + tool_name:"none" + // and legally omitted reply (it wasn't listed), rendering as + // thinking followed by pure silence in the shell. + "required": ["thought", "tool_name", "tool_arguments", "reply"] })) } else { Some(serde_json::json!({ @@ -803,77 +997,109 @@ impl OllamaProvider { let model = self.model.clone(); tokio::spawn(async move { - let mut candidates = Vec::new(); - - // Spawn 3 candidate futures in parallel - let temp_list = vec![0.1, 0.4, 0.7]; - for temp in temp_list { - let candidate_fut = run_ollama_candidate( - client.clone(), - endpoint.clone(), - api_key.clone(), - model.clone(), - ollama_messages.clone(), - format_schema.clone(), - temp, - active_tools.clone(), - ); - candidates.push(Box::pin(candidate_fut) as std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<(String, Option<serde_json::Value>, String, u64, u64)>> + Send>>); - } - - // We await candidates using futures::future::select_all to grab the first one that succeeds. - // If one succeeds, we drop all other candidates (canceling their in-flight HTTP requests). - let mut matched_candidate = None; - let mut last_error: Option<anyhow::Error> = None; + // Attempt 1: live-streaming single generation (temp 0.1). Thinking + // reaches the shell AS IT GENERATES instead of after a ~60s silent + // wait, and a single generation avoids the 3× parallel-candidate + // VRAM contention that made 12B models flaky on a 12GB card. + let streamed = run_ollama_streaming( + client.clone(), + endpoint.clone(), + api_key.clone(), + model.clone(), + ollama_messages.clone(), + format_schema.clone(), + active_tools.clone(), + &tx, + ) + .await; + + let (thought, tool_call, reply, input_tokens, output_tokens, thinking_streamed_live) = match streamed { + Ok(v) => v, + Err(stream_err) => { + if let Some(CandidateError::JsonParse { raw_content, native_thinking, input_tokens, output_tokens, .. }) = stream_err.downcast_ref::<CandidateError>() { + // The generation itself succeeded but the structured JSON + // didn't parse — use the raw content as the reply directly + // instead of burning three more generations re-asking. + tracing::info!("streamed generation failed JSON parse — using raw content as reply"); + let mut reply = extract_reply_fallback(raw_content); + if reply.starts_with("```") { + let lines: Vec<&str> = reply.lines().collect(); + if lines.len() >= 2 && lines.first().unwrap().starts_with("```") && lines.last().unwrap().starts_with("```") { + reply = lines[1..lines.len()-1].join("\n").trim().to_string(); + } + } + // Any native thinking was already streamed live as it arrived. + (native_thinking.clone(), None, reply, *input_tokens, *output_tokens, true) + } else { + // Transport/validation failure — fall back to the blocking + // parallel candidates (3 temperatures, first success wins). + tracing::warn!("Ollama streaming attempt failed ({}), falling back to parallel candidates", stream_err); + let mut candidates = Vec::new(); + let temp_list = vec![0.1, 0.4, 0.7]; + for temp in temp_list { + let candidate_fut = run_ollama_candidate( + client.clone(), + endpoint.clone(), + api_key.clone(), + model.clone(), + ollama_messages.clone(), + format_schema.clone(), + temp, + active_tools.clone(), + ); + candidates.push(Box::pin(candidate_fut) as std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<(String, Option<serde_json::Value>, String, u64, u64)>> + Send>>); + } - while !candidates.is_empty() { - let (res, _index, remaining) = futures::future::select_all(candidates).await; - candidates = remaining; + let mut matched_candidate = None; + let mut last_error: Option<anyhow::Error> = None; + while !candidates.is_empty() { + let (res, _index, remaining) = futures::future::select_all(candidates).await; + candidates = remaining; + match res { + Ok(val) => { + matched_candidate = Some(val); + break; + } + Err(e) => { + tracing::warn!("Ollama candidate generation failed: {}", e); + last_error = Some(e); + } + } + } - match res { - Ok(val) => { - matched_candidate = Some(val); - break; - } - Err(e) => { - tracing::warn!("Ollama candidate generation failed: {}", e); - last_error = Some(e); - } - } - } + match matched_candidate { + Some((t, tc, r, i, o)) => (t, tc, r, i, o, false), + None => { + // Lazy-model fallback: raw text as the reply. + let mut fallback = None; + if let Some(ref err) = last_error { + if let Some(CandidateError::JsonParse { raw_content, native_thinking, input_tokens, output_tokens, .. }) = err.downcast_ref::<CandidateError>() { + tracing::info!("All candidates failed JSON parsing. Falling back to raw content as reply."); + let mut reply = extract_reply_fallback(raw_content); + if reply.starts_with("```") { + let lines: Vec<&str> = reply.lines().collect(); + if lines.len() >= 2 && lines.first().unwrap().starts_with("```") && lines.last().unwrap().starts_with("```") { + reply = lines[1..lines.len()-1].join("\n").trim().to_string(); + } + } + fallback = Some((native_thinking.clone(), None, reply, *input_tokens, *output_tokens, false)); + } + } - let (thought, tool_call, reply, input_tokens, output_tokens) = match matched_candidate { - Some(val) => val, - None => { - // Try to downcast last_error to CandidateError::JsonParse to perform robust fallback. - // This allows local models that get lazy and reply with raw text to still finish successfully. - let mut fallback = None; - if let Some(ref err) = last_error { - if let Some(CandidateError::JsonParse { raw_content, native_thinking, input_tokens, output_tokens, .. }) = err.downcast_ref::<CandidateError>() { - tracing::info!("All candidates failed JSON parsing. Falling back to raw content as reply."); - let mut reply = extract_reply_fallback(raw_content); - if reply.starts_with("```") { - // Strip code fences - let lines: Vec<&str> = reply.lines().collect(); - if lines.len() >= 2 && lines.first().unwrap().starts_with("```") && lines.last().unwrap().starts_with("```") { - reply = lines[1..lines.len()-1].join("\n").trim().to_string(); + if let Some(val) = fallback { + val + } else { + // All parallel candidates failed. Report the error. + let err_msg = format!( + "All parallel candidate generations failed. Last error: {}", + last_error.map(|e| e.to_string()).unwrap_or_else(|| "Unknown failure".into()) + ); + let _ = tx.send(ProviderEvent::Error(err_msg)).await; + return; } } - fallback = Some((native_thinking.clone(), None, reply, *input_tokens, *output_tokens)); } } - - if let Some(val) = fallback { - val - } else { - // All parallel candidates failed. Report the error. - let err_msg = format!( - "All parallel candidate generations failed. Last error: {}", - last_error.map(|e| e.to_string()).unwrap_or_else(|| "Unknown failure".into()) - ); - let _ = tx.send(ProviderEvent::Error(err_msg)).await; - return; - } } }; @@ -883,7 +1109,8 @@ impl OllamaProvider { } // Emit the thinking reasoning block via structured thinking events - if !thought.is_empty() { + // — unless it already streamed live during generation. + if !thinking_streamed_live && !thought.is_empty() { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) @@ -934,9 +1161,20 @@ impl OllamaProvider { } } - // Emit final direct reply - if !reply.is_empty() { - let _ = tx.send(ProviderEvent::TextDelta(reply)).await; + // Emit final direct reply — but ONLY on non-tool turns. Now that + // `reply` is required, small models fill it with self-narration on + // every tool call ("I'm checking the status…"); emitting that per + // turn renders as the agent talking to itself while it polls. On + // tool turns the thinking row already shows the reasoning — the + // user-visible reply belongs to the turn that ANSWERS. + // Belt-and-suspenders: a no-tool turn with an empty reply promotes + // the thought instead of saying nothing. + if !had_tool_call { + if !reply.is_empty() { + let _ = tx.send(ProviderEvent::TextDelta(reply)).await; + } else if !thought.is_empty() { + let _ = tx.send(ProviderEvent::TextDelta(thought.clone())).await; + } } // Emit completion stop reason @@ -961,12 +1199,21 @@ pub struct OpenAIProvider { api_key: String, pub model: String, endpoint: String, + /// Send `temperature: 0` on tool turns. True only when doors mode is active + /// AND the endpoint is an OpenAI-compatible server (NOT api.openai.com) — + /// the Sailfish guide asks for temperature 0 on tool calls, and cloud + /// OpenAI o-series models reject the `temperature` field outright. + send_zero_temp: bool, + /// The configured provider id ("openai" or "sailfish"). Reported by + /// `AnyProvider::provider_name()` so a Sailfish run is distinguishable from + /// a stock OpenAI run even though they share this provider implementation. + provider_label: String, } impl OpenAIProvider { pub fn new(config: &GhostConfig) -> Self { let model = if config.model.is_empty() { - "gpt-4o".to_string() + crate::models::default_model("openai").to_string() } else { config.model.clone() }; @@ -975,11 +1222,23 @@ impl OpenAIProvider { } else { config.endpoint.trim_end_matches('/').to_string() }; + let send_zero_temp = config.doors.enabled && !endpoint.contains("api.openai.com"); + let provider_label = if config.provider.is_empty() { + "openai".to_string() + } else { + config.provider.clone() + }; Self { + // No request-level timeout on the client: the Sailfish appliance can + // take ~120 s to answer the first call after idle (model/graph warm, + // per HYPERIA_INTEGRATION.md). A reqwest timeout here would abort the + // warmup; instead we let the stream run and the shell shows progress. client: reqwest::Client::new(), api_key: config.api_key.clone(), model, endpoint, + send_zero_temp, + provider_label, } } @@ -990,6 +1249,13 @@ impl OpenAIProvider { tools: &[ToolDef], max_tokens: u32, ) -> anyhow::Result<mpsc::Receiver<ProviderEvent>> { + // Newer OpenAI models (gpt-5-codex, *-pro, *-deep-research) are only + // served by the /v1/responses endpoint and 404 on /v1/chat/completions + // ("Use the v1/responses endpoint instead"). Route them there. + if needs_responses_api(&self.model) { + return self.stream_responses(system, messages, tools, max_tokens).await; + } + let (tx, rx) = mpsc::channel(128); let openai_messages = build_openai_messages(system, messages); @@ -1004,7 +1270,16 @@ impl OpenAIProvider { }); if max_tokens > 0 { - body["max_tokens"] = serde_json::json!(max_tokens); + // api.openai.com rejects `max_tokens` on reasoning/gpt-5.x models + // ("use max_completion_tokens"); it accepts max_completion_tokens on + // all current chat models. OpenAI-COMPATIBLE servers (llama.cpp / + // Sailfish, vLLM, Ollama) generally only know `max_tokens`, so key + // off the endpoint. + if crate::models::uses_max_completion_tokens(&self.endpoint) { + body["max_completion_tokens"] = serde_json::json!(max_tokens); + } else { + body["max_tokens"] = serde_json::json!(max_tokens); + } } if !tools.is_empty() { @@ -1022,6 +1297,13 @@ impl OpenAIProvider { }) .collect(); body["tools"] = serde_json::json!(tool_defs); + + // Sailfish/compat guide: deterministic tool selection wants + // temperature 0 on tool turns. Guarded to non-api.openai.com + // endpoints (cloud o-series rejects `temperature`); see new(). + if self.send_zero_temp { + body["temperature"] = serde_json::json!(0); + } } let resp = self @@ -1039,10 +1321,19 @@ impl OpenAIProvider { let api_message = serde_json::from_str::<serde_json::Value>(&raw) .ok() .and_then(|j| j["error"]["message"].as_str().map(|s| s.to_string())); + // Name the ACTUAL provider, not "OpenAI" — Sailfish/vLLM/etc. ride + // this same code path, and labeling every failure "OpenAI error" + // makes a local model look like a cloud one. Include the host so + // it's unambiguous which endpoint answered. + let host = self + .endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + let who = format!("{} ({})", self.provider_label, host); let label = if let Some(msg) = api_message { - format!("OpenAI error {} — {}\nFull response: {}", status, msg, raw) + format!("{} error {} — {}\nFull response: {}", who, status, msg, raw) } else { - format!("OpenAI error {} — {}", status, raw) + format!("{} error {} — {}", who, status, raw) }; let _ = tx.send(ProviderEvent::Error(label)).await; return Ok(rx); @@ -1183,6 +1474,264 @@ impl OpenAIProvider { Ok(rx) } + + /// Stream via OpenAI's /v1/responses endpoint (the newer unified API). + /// Required for gpt-5-codex / *-pro / *-deep-research; those 404 on + /// chat/completions. Different request shape (flat tools, `instructions`, + /// `input` items, `max_output_tokens`) and a typed-event SSE stream. + pub async fn stream_responses( + &self, + system: &str, + messages: &[serde_json::Value], + tools: &[ToolDef], + max_tokens: u32, + ) -> anyhow::Result<mpsc::Receiver<ProviderEvent>> { + let (tx, rx) = mpsc::channel(128); + + let mut body = serde_json::json!({ + "model": self.model, + "input": build_responses_input(messages), + "stream": true, + }); + if !system.is_empty() { + body["instructions"] = serde_json::json!(system); + } + if max_tokens > 0 { + body["max_output_tokens"] = serde_json::json!(max_tokens); + } + if !tools.is_empty() { + // Responses tools are FLAT (name/description/parameters at top level), + // unlike chat/completions which nests under `function`. + let tool_defs: Vec<serde_json::Value> = tools + .iter() + .map(|t| { + serde_json::json!({ + "type": "function", + "name": t.name, + "description": t.description, + "parameters": t.input_schema, + }) + }) + .collect(); + body["tools"] = serde_json::json!(tool_defs); + } + + let resp = self + .client + .post(format!("{}/v1/responses", self.endpoint)) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .body(body.to_string()) + .send() + .await?; + + if !resp.status().is_success() { + let status = resp.status(); + let raw = resp.text().await.unwrap_or_default(); + let api_message = serde_json::from_str::<serde_json::Value>(&raw) + .ok() + .and_then(|j| j["error"]["message"].as_str().map(|s| s.to_string())); + let host = self + .endpoint + .trim_start_matches("https://") + .trim_start_matches("http://"); + let who = format!("{} ({}, responses)", self.provider_label, host); + let label = if let Some(msg) = api_message { + format!("{} error {} — {}\nFull response: {}", who, status, msg, raw) + } else { + format!("{} error {} — {}", who, status, raw) + }; + let _ = tx.send(ProviderEvent::Error(label)).await; + return Ok(rx); + } + + let mut stream = resp.bytes_stream(); + + tokio::spawn(async move { + let mut buffer = String::new(); + // item_id → call_id, so streamed argument deltas (keyed by item_id) + // resolve to the call_id we must echo back as function_call_output. + let mut item_to_call: std::collections::HashMap<String, String> = std::collections::HashMap::new(); + let mut active_calls: Vec<String> = Vec::new(); + let mut saw_tool_call = false; + + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + let _ = tx.send(ProviderEvent::Error(e.to_string())).await; + break; + } + }; + buffer.push_str(&String::from_utf8_lossy(&chunk)); + + while let Some(pos) = buffer.find("\n\n") { + let block = buffer[..pos].to_string(); + buffer = buffer[pos + 2..].to_string(); + for line in block.lines() { + let lt = line.trim(); + if lt.is_empty() || lt == "data: [DONE]" { + continue; + } + // Responses SSE also emits `event:` lines; ignore them and + // key off the `type` field inside the JSON `data:` payload. + let Some(data) = lt.strip_prefix("data:") else { continue }; + let d = data.trim(); + if d.is_empty() { + continue; + } + let Ok(val) = serde_json::from_str::<serde_json::Value>(d) else { continue }; + match val["type"].as_str().unwrap_or("") { + "response.output_text.delta" => { + if let Some(delta) = val["delta"].as_str() { + if !delta.is_empty() { + let _ = tx.send(ProviderEvent::TextDelta(delta.to_string())).await; + } + } + } + "response.reasoning_summary_text.delta" => { + if let Some(delta) = val["delta"].as_str() { + if !delta.is_empty() { + let id = val["item_id"].as_str().unwrap_or("reason").to_string(); + let _ = tx.send(ProviderEvent::ThinkingDelta { id, text: delta.to_string() }).await; + } + } + } + "response.output_item.added" => { + let item = &val["item"]; + if item["type"] == "function_call" { + let item_id = item["id"].as_str().unwrap_or("").to_string(); + let call_id = item["call_id"].as_str().unwrap_or("").to_string(); + let name = item["name"].as_str().unwrap_or("").to_string(); + if !call_id.is_empty() { + item_to_call.insert(item_id, call_id.clone()); + active_calls.push(call_id.clone()); + saw_tool_call = true; + let _ = tx.send(ProviderEvent::ToolCallStart { id: call_id, name }).await; + } + } + } + "response.function_call_arguments.delta" => { + let item_id = val["item_id"].as_str().unwrap_or(""); + if let Some(call_id) = item_to_call.get(item_id) { + if let Some(delta) = val["delta"].as_str() { + let _ = tx.send(ProviderEvent::ToolCallDelta { + id: call_id.clone(), + json_fragment: delta.to_string(), + }).await; + } + } + } + "response.completed" => { + let usage = &val["response"]["usage"]; + let it = usage["input_tokens"].as_u64().unwrap_or(0); + let ot = usage["output_tokens"].as_u64().unwrap_or(0); + if it > 0 || ot > 0 { + let _ = tx.send(ProviderEvent::Usage { input_tokens: it, output_tokens: ot }).await; + } + } + "response.failed" => { + let msg = val["response"]["error"]["message"] + .as_str() + .unwrap_or("response failed") + .to_string(); + let _ = tx.send(ProviderEvent::Error(msg)).await; + } + "error" => { + let msg = val["message"].as_str().unwrap_or("stream error").to_string(); + let _ = tx.send(ProviderEvent::Error(msg)).await; + } + _ => {} + } + } + } + } + + for id in active_calls { + let _ = tx.send(ProviderEvent::ToolCallEnd { id }).await; + } + let stop = if saw_tool_call { "tool_use" } else { "end_turn" }; + let _ = tx.send(ProviderEvent::MessageStop { stop_reason: stop.to_string() }).await; + }); + + Ok(rx) + } +} + +/// Models served ONLY by /v1/responses (they 404 on /v1/chat/completions): +/// the codex, -pro, and deep-research variants. Base chat models and the +/// standard reasoning models (o1/o3/o4-mini) support both, so they stay on +/// chat/completions. +fn needs_responses_api(model: &str) -> bool { + // Single source of truth: crate::models. + crate::models::needs_responses_api(model) +} + +/// Translate internal Anthropic-style message blocks into /v1/responses +/// `input` items: user/assistant text stay as role messages; `tool_use` +/// becomes a `function_call` item and `tool_result` a `function_call_output`, +/// correlated by call_id (the same id we assigned at ToolCallStart). +fn build_responses_input(messages: &[serde_json::Value]) -> Vec<serde_json::Value> { + let mut out = Vec::new(); + for msg in messages { + let role = msg["role"].as_str().unwrap_or("user"); + if msg["content"].is_string() { + out.push(serde_json::json!({ "role": role, "content": msg["content"].as_str().unwrap_or("") })); + } else if let Some(arr) = msg["content"].as_array() { + if role == "user" { + for block in arr { + match block["type"].as_str() { + Some("tool_result") => { + let call_id = block["tool_use_id"].as_str().unwrap_or(""); + let content_val = &block["content"]; + let output = if content_val.is_string() { + content_val.as_str().unwrap_or("").to_string() + } else { + content_val.to_string() + }; + out.push(serde_json::json!({ + "type": "function_call_output", + "call_id": call_id, + "output": output, + })); + } + Some("text") => { + out.push(serde_json::json!({ "role": "user", "content": block["text"].as_str().unwrap_or("") })); + } + _ => {} + } + } + } else if role == "assistant" { + let mut text_content = String::new(); + let mut calls = Vec::new(); + for block in arr { + match block["type"].as_str() { + Some("text") => { + if let Some(t) = block["text"].as_str() { + text_content.push_str(t); + } + } + Some("tool_use") => { + let id = block["id"].as_str().unwrap_or(""); + let name = block["name"].as_str().unwrap_or(""); + calls.push(serde_json::json!({ + "type": "function_call", + "call_id": id, + "name": name, + "arguments": block["input"].to_string(), + })); + } + _ => {} + } + } + if !text_content.is_empty() { + out.push(serde_json::json!({ "role": "assistant", "content": text_content })); + } + out.extend(calls); + } + } + } + out } fn build_openai_messages(system: &str, messages: &[serde_json::Value]) -> Vec<serde_json::Value> { diff --git a/sidecar/src/ghost/registry.rs b/sidecar/src/ghost/registry.rs index 1c306883387..59cdba66c0f 100644 --- a/sidecar/src/ghost/registry.rs +++ b/sidecar/src/ghost/registry.rs @@ -123,7 +123,19 @@ impl ToolRegistry { } /// All tool definitions for sending to the Anthropic API. - pub fn tool_defs(&self, provider: Option<&str>, model: Option<&str>) -> Vec<ToolDef> { + /// + /// `doors`: when `Some(state)` **and** `state.enabled()`, the returned list + /// is the progressive-disclosure set — core tools + the `open_tools`/ + /// `close_tools`/`tool_search` meta-tools + the full schemas of *only* the + /// currently-open doors' tools (plan §4.2). When `None` or disabled, the + /// full catalog is returned unchanged — so a doors-off build is byte- + /// identical to before doors existed. + pub fn tool_defs( + &self, + provider: Option<&str>, + model: Option<&str>, + doors: Option<&crate::doors::DoorState>, + ) -> Vec<ToolDef> { let mut defs = self.builtins.clone(); defs.push(tool_search_def()); defs.push(tool_create_def()); @@ -156,38 +168,44 @@ impl ToolRegistry { for dt in dynamic.iter() { defs.push(dt.def.clone()); } - - let is_small_ollama = provider.map_or(false, |p| p.to_lowercase() == "ollama") - && model.map_or(false, |m| { - let m_lower = m.to_lowercase(); - m_lower.contains("e2b") || m_lower.contains("2b") || m_lower.contains("3b") || m_lower.contains("1b") || m_lower.contains("small") - }); - - if is_small_ollama { - let allowed_tools = [ - "terminal_run", - "terminal_cd", - "terminal_keys", - "terminal_screen", - "terminal_status", - "terminal_split", - "file_read", - "file_write", - "web_fetch", - "open_web_pane", - "web_pane_content", - "web_pane_eval", - "web_pane_mouse", - "tab_snapshot", - "shell_state", - "show_input", - "show_picker", - "tool_search", - "help", - ]; - defs.retain(|t| allowed_tools.contains(&t.name.as_str())); + drop(dynamic); + + // Doors mode: progressive disclosure overrides the full catalog. + // Emit core + meta-tools + only the open doors' tool schemas, in + // core-then-LRU order (see DoorState::live_tools). + if let Some(ds) = doors { + if ds.enabled() { + let open_meta = open_tools_def(); + let close_meta = close_tools_def(); + let mut out: Vec<ToolDef> = Vec::new(); + for name in ds.live_tools() { + if name == "open_tools" { + out.push(open_meta.clone()); + } else if name == "close_tools" { + out.push(close_meta.clone()); + } else if let Some(d) = defs.iter().find(|d| d.name == name) { + out.push(d.clone()); + } + // A live-tool name with no catalog def (should not happen — + // the doors taxonomy is unit-tested against the registry) is + // silently skipped. + } + // The `create` door also surfaces every runtime-authored tool. + if ds.is_door_open("create") { + let dynamic = self.dynamic.lock().unwrap(); + for dt in dynamic.iter() { + out.push(dt.def.clone()); + } + } + return out; + } } + // Legacy small-Ollama allowlist removed in Phase 3 — the doors `auto` + // mode (resolved in ghost::load_config) now shrinks the surface for + // small/local models via progressive disclosure instead of a fixed + // hard-coded subset. With doors off, the full catalog ships (byte-for- + // byte the pre-doors behavior). defs } @@ -199,7 +217,7 @@ impl ToolRegistry { pub async fn execute(&self, name: &str, input: &serde_json::Value) -> String { // Internal tools bypass Maximus entirely match name { - "tool_search" => return self.handle_tool_search(input), + "tool_search" => return self.handle_tool_search(input, None), "tool_create" => return self.handle_tool_create(input), "watercooler" => { let msg = input["message"].as_str().unwrap_or("Checking in"); @@ -391,22 +409,53 @@ impl ToolRegistry { self.builtins.iter().any(|t| t.name == name) } - fn handle_tool_search(&self, input: &serde_json::Value) -> String { + /// Keyword search over the **full** tool catalog (always — that is the + /// point of the search door: it sees everything, open or closed). + /// + /// When `doors` is `Some`, each result line gains a `[door: X — open|closed]` + /// hint and the result ends with an `open_tools` instruction so a model can + /// discover-then-unlock. When `None` (doors disabled / settings agent) the + /// output is byte-identical to the pre-doors format. + pub fn handle_tool_search( + &self, + input: &serde_json::Value, + doors: Option<&crate::doors::DoorState>, + ) -> String { let query = input["query"].as_str().unwrap_or("").to_lowercase(); - let all_defs = self.tool_defs(None, None); + let all_defs = self.tool_defs(None, None, None); let matches: Vec<_> = all_defs .iter() .filter(|t| { t.name.to_lowercase().contains(&query) || t.description.to_lowercase().contains(&query) }) - .map(|t| format!("- {}: {}", t.name, t.description)) + .map(|t| match doors { + Some(ds) => { + let hint = match ds.door_of(&t.name) { + Some(dn) => { + let state = if ds.is_door_open(dn) { "open" } else { "closed" }; + format!(" [door: {} — {}]", dn, state) + } + // Core tool (always live) or untaxonomied — no door hint. + None => String::new(), + }; + format!("- {}{}: {}", t.name, hint, t.description) + } + None => format!("- {}: {}", t.name, t.description), + }) .collect(); if matches.is_empty() { format!("No tools found matching '{}'", query) } else { - format!("Found {} tool(s):\n{}", matches.len(), matches.join("\n")) + let mut out = format!("Found {} tool(s):\n{}", matches.len(), matches.join("\n")); + if doors.is_some() { + out.push_str( + "\n\nTools marked [door: X — closed] are not callable yet. \ + Call open_tools(door=\"X\") to make them callable on your next turn.", + ); + } + out } } @@ -747,6 +796,18 @@ impl ToolRegistry { .send() .await } + "terminal_set_window_size" => { + let body = serde_json::json!({ + "window": input["window"], + "width": input["width"], + "height": input["height"], + }); + self.client + .post(format!("{}/api/window/size", base)) + .json(&body) + .send() + .await + } "terminal_new_tab" => { let body = serde_json::json!({ "profile": input["profile"], @@ -1297,6 +1358,53 @@ fn tool_search_def() -> ToolDef { } } +/// Meta-tool: open a door so its tools become callable next turn. +/// The description lists every ghost door + its one-liner — this listing is +/// the *entire* cost of the closed catalog in the menu. +fn open_tools_def() -> ToolDef { + use crate::doors::{doors_for, Surface}; + let names: Vec<&str> = doors_for(Surface::Ghost).map(|d| d.name).collect(); + let listing = doors_for(Surface::Ghost) + .map(|d| format!("- {}: {}", d.name, d.description)) + .collect::<Vec<_>>() + .join("\n"); + ToolDef { + name: "open_tools".into(), + description: format!( + "Open a door — a category of tools — so its tools become callable on your NEXT turn. \ + Your live tool list is a small core plus any doors you have opened; open a door to \ + reveal more. Available doors:\n{}", + listing + ), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "door": { "type": "string", "enum": names, "description": "Door to open" } + }, + "required": ["door"] + }), + } +} + +/// Meta-tool: close a door, freeing its slice of the live-tool budget. +fn close_tools_def() -> ToolDef { + use crate::doors::{doors_for, Surface}; + let names: Vec<&str> = doors_for(Surface::Ghost).map(|d| d.name).collect(); + ToolDef { + name: "close_tools".into(), + description: "Close a door you previously opened, removing its tools from your live list \ + to make room for others." + .into(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "door": { "type": "string", "enum": names, "description": "Door to close" } + }, + "required": ["door"] + }), + } +} + fn tool_create_def() -> ToolDef { ToolDef { name: "tool_create".into(), @@ -1917,6 +2025,19 @@ fn builtin_tool_defs() -> Vec<ToolDef> { "description": "Open a new Hyperia window (separate OS window). Use terminal_status after to get its window id for targeting. Use this when the user wants a separate window, not just a new tab.", "input_schema": { "type": "object", "properties": {} } }, + { + "name": "terminal_set_window_size", + "description": "Resize a Hyperia window to exact pixel dimensions (width/height). Use for 'make the window wider/taller/1200 tall' requests. Omit window to resize the focused window; get window ids from terminal_status. To double the width, read the current size from terminal_status first.", + "input_schema": { + "type": "object", + "properties": { + "window": { "type": "integer", "description": "Window id from terminal_status. Omit for the focused window." }, + "width": { "type": "integer", "description": "New window width in pixels" }, + "height": { "type": "integer", "description": "New window height in pixels" } + }, + "required": ["width", "height"] + } + }, { "name": "terminal_new_tab", "description": "Open a new tab. Use 'profile' to start a specific shell (e.g. 'WSL', 'PowerShell', 'Command Prompt') — get the exact name from terminal_status profiles[]. Omit profile to use the default shell. 'command' types a startup command into the new shell after it opens.", @@ -2593,16 +2714,16 @@ struct ModelEntry { // show_picker flow: pick provider → pick model. const MODEL_CATALOG: &[ModelEntry] = &[ // Anthropic - ModelEntry { id: "claude-opus-4-7", name: "Claude Opus 4.7", provider: "anthropic", context: 200_000, tier: "frontier", note: "Newest Opus — best reasoning, deepest tool use" }, - ModelEntry { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", provider: "anthropic", context: 200_000, tier: "balanced", note: "Strong daily driver — fast and capable" }, + ModelEntry { id: "claude-opus-4-8", name: "Claude Opus 4.8", provider: "anthropic", context: 200_000, tier: "frontier", note: "Newest Opus — best reasoning, deepest tool use" }, + ModelEntry { id: "claude-sonnet-5", name: "Claude Sonnet 5", provider: "anthropic", context: 200_000, tier: "balanced", note: "Strong daily driver — fast and capable" }, ModelEntry { id: "claude-haiku-4-5", name: "Claude Haiku 4.5", provider: "anthropic", context: 200_000, tier: "fast", note: "Cheapest + fastest Claude — good for routine work" }, - ModelEntry { id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: "anthropic", context: 200_000, tier: "balanced", note: "Previous-gen Sonnet — still capable" }, // OpenAI + ModelEntry { id: "gpt-5", name: "GPT-5", provider: "openai", context: 400_000, tier: "frontier", note: "Flagship reasoning + chat" }, + ModelEntry { id: "gpt-5-codex", name: "GPT-5 Codex", provider: "openai", context: 400_000, tier: "frontier", note: "Code-tuned GPT-5 (responses API)" }, ModelEntry { id: "gpt-4.1", name: "GPT-4.1", provider: "openai", context: 1_000_000, tier: "frontier", note: "Long-context flagship" }, ModelEntry { id: "gpt-4o", name: "GPT-4o", provider: "openai", context: 128_000, tier: "balanced", note: "Multimodal default — vision + tools" }, ModelEntry { id: "gpt-4o-mini", name: "GPT-4o mini", provider: "openai", context: 128_000, tier: "fast", note: "Cheap and quick" }, - ModelEntry { id: "o3-mini", name: "o3-mini", provider: "openai", context: 200_000, tier: "reasoning", note: "Smaller reasoning model" }, - ModelEntry { id: "o1", name: "o1", provider: "openai", context: 200_000, tier: "reasoning", note: "Deep reasoning, no streaming" }, + ModelEntry { id: "o4-mini", name: "o4-mini", provider: "openai", context: 200_000, tier: "reasoning", note: "Fast reasoning model" }, // Local Ollama ModelEntry { id: "ollama:gemma4:e2b", name: "Gemma 4 e2b", provider: "ollama", context: 8_192, tier: "tiny", note: "Maximus's compression model — small + fast" }, ModelEntry { id: "ollama:gemma4:12b", name: "Gemma 4 12B", provider: "ollama", context: 8_192, tier: "local", note: "Ollama gemma4 local model" }, @@ -2745,3 +2866,121 @@ async fn probe_ollama() -> serde_json::Value { _ => serde_json::json!({ "running": false, "url": url, "models": [], "gpu": null }), } } + +#[cfg(test)] +mod doors_tests { + use super::*; + use crate::doors::{DoorState, Surface}; + + fn reg() -> ToolRegistry { + ToolRegistry::new(9800, "test-token".into()) + } + + /// Flag OFF must be byte-for-byte identical to the pre-doors behavior: + /// `tool_defs(_, _, None)` and `tool_defs(_, _, Some(disabled))` both yield + /// the full catalog with the same count and ordering. + #[test] + fn doors_off_is_identical_to_full_catalog() { + let r = reg(); + let full = r.tool_defs(None, None, None); + let disabled = DoorState::new(Surface::Ghost); // enabled == false by default + let via_disabled = r.tool_defs(None, None, Some(&disabled)); + + assert_eq!( + full.len(), + via_disabled.len(), + "disabled DoorState must not change the tool count" + ); + let a: Vec<&str> = full.iter().map(|t| t.name.as_str()).collect(); + let b: Vec<&str> = via_disabled.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(a, b, "disabled DoorState must not change tool ordering"); + + // Sanity: the full catalog is the large legacy surface, not the doored one. + assert!(full.len() > 40, "full catalog should be the whole ~57-tool surface"); + } + + /// Enabled with no open doors → exactly the core (11 ghost core defs, + /// including the open_tools/close_tools meta-tools). + #[test] + fn doors_on_no_open_doors_is_core_only() { + let r = reg(); + let ds = DoorState::new(Surface::Ghost).with_enabled(true); + let defs = r.tool_defs(None, None, Some(&ds)); + + let names: std::collections::HashSet<&str> = + defs.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(defs.len(), crate::doors::GHOST_CORE.len(), "core-only count"); + for &c in crate::doors::GHOST_CORE { + assert!(names.contains(c), "core tool '{c}' missing from doored defs"); + } + // The meta-tools are present and closed-door tools are not. + assert!(names.contains("open_tools")); + assert!(names.contains("close_tools")); + assert!(!names.contains("web_pane_eval"), "closed door tool leaked"); + } + + /// Enabled with an open door → core + that door's tools, nothing else. + #[test] + fn doors_on_open_door_adds_only_that_door() { + let r = reg(); + let mut ds = DoorState::new(Surface::Ghost).with_enabled(true); + ds.open_door("web"); + let defs = r.tool_defs(None, None, Some(&ds)); + let names: std::collections::HashSet<&str> = + defs.iter().map(|t| t.name.as_str()).collect(); + + // core still present + assert!(names.contains("terminal_run")); + // web door now present + assert!(names.contains("web_pane_eval")); + assert!(names.contains("open_web_pane")); + // an unopened door's tool is absent + assert!(!names.contains("sticky_note_list")); + // count == core + web door tools + let web_n = crate::doors::door_by_name("web").unwrap().ghost_tools.len(); + assert_eq!(defs.len(), crate::doors::GHOST_CORE.len() + web_n); + } + + /// The ≤cap invariant survives into the emitted def list: opening a second + /// door that would breach the cap evicts the first, so the doored def count + /// never exceeds core + a single (fitting) door. + #[test] + fn doors_on_respects_cap_via_eviction() { + let r = reg(); + let mut ds = DoorState::with_cap(Surface::Ghost, 20).with_enabled(true); + ds.open_door("terminal"); // core 11 + 9 = 20 (at cap) + ds.open_door("web"); // would be 27 > 20 → evicts terminal + let defs = r.tool_defs(None, None, Some(&ds)); + let names: std::collections::HashSet<&str> = + defs.iter().map(|t| t.name.as_str()).collect(); + + assert!(names.contains("web_pane_eval"), "web (MRU) should be live"); + assert!(!names.contains("terminal_split"), "terminal should be evicted"); + assert!(defs.len() <= 20, "live tool count must stay within cap"); + } + + /// tool_search is door-aware when a DoorState is passed: closed doors get a + /// "closed" hint, open doors get "open", core tools get no hint, and the + /// result ends with an open_tools instruction. With `None` the output is the + /// legacy format (no hints, no trailer). + #[test] + fn tool_search_door_hints() { + let r = reg(); + let mut ds = DoorState::new(Surface::Ghost).with_enabled(true); + ds.open_door("web"); + + let input = serde_json::json!({ "query": "web_pane_eval" }); + let with_doors = r.handle_tool_search(&input, Some(&ds)); + assert!(with_doors.contains("[door: web — open]"), "got: {with_doors}"); + assert!(with_doors.contains("open_tools"), "should include the unlock hint"); + + let closed = serde_json::json!({ "query": "sticky_note_list" }); + let closed_res = r.handle_tool_search(&closed, Some(&ds)); + assert!(closed_res.contains("[door: stickys — closed]"), "got: {closed_res}"); + + // None → legacy format, no door hints or trailer. + let legacy = r.handle_tool_search(&input, None); + assert!(!legacy.contains("[door:"), "legacy output must not add hints"); + assert!(!legacy.contains("open_tools(door"), "legacy output must not add trailer"); + } +} diff --git a/sidecar/src/ghost/types.rs b/sidecar/src/ghost/types.rs index 920e03ad4bd..befd5301c53 100644 --- a/sidecar/src/ghost/types.rs +++ b/sidecar/src/ghost/types.rs @@ -108,6 +108,14 @@ pub struct GhostConfig { /// ollama → http://localhost:11434 pub endpoint: String, pub max_turns: usize, + /// Resolved MCP-tool-doors settings for this run (enabled/cap/small), + /// derived once at config load from `config.agent.tool_doors` + provider + + /// env so the provider, the loop, and the compressor all agree. + pub doors: crate::doors::DoorConfig, + /// Hard context-window budget in tokens (`config.agent.context_tokens`, + /// 0 = off). When >0 the compressor trims verbatim recent history so + /// system + tools + history fits (e.g. 8000 for Sailfish). + pub context_tokens: usize, /// Maximus model configuration (optional, overrides environment/defaults) pub maximus_model: Option<String>, /// Maximus Ollama URL configuration (optional, overrides environment/defaults) diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 041522b2e3b..c368278d22b 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -3,11 +3,13 @@ mod audit; mod bridge; mod dashboard; +mod doors; mod identity; mod fsnav; mod ghost; mod logs; mod mcp; +mod models; mod perms; mod process; mod lume_store; @@ -704,24 +706,32 @@ async fn post_split( let profile = parsed["profile"].as_str().unwrap_or("").to_string(); let command = parsed["command"].as_str().unwrap_or("").to_string(); - // Resolve an explicit split target (window/tab/pane) to a session uid so the - // split lands on the pane the caller named instead of whatever the UI happens - // to have focused. Omitting all three keeps the old focused-pane behavior. - let target_uid = if parsed["window"].is_u64() || parsed["tab"].is_string() || parsed["pane"].is_string() { - match state - .bridge - .resolve_pane_uid( - parsed["window"].as_u64().map(|v| v as u32), - parsed["tab"].as_str(), - parsed["pane"].as_str(), - ) - .await - { - Some(u) => Some(u), - None => return (StatusCode::NOT_FOUND, "No pane at that window/tab/pane address".into()), + // Resolve the split target to a session uid. #119: even when window/tab/pane + // are ALL omitted we now resolve to sane defaults — the focused window's + // active tab's active pane — instead of passing a null uid and letting the + // renderer pick from its own (possibly divergent) UI focus. An explicitly + // named address that matches nothing is a hard 404; an omitted address that + // resolves to nothing (no sessions yet) falls back to null so the renderer + // can bootstrap the very first pane. + let addressed = + parsed["window"].is_u64() || parsed["tab"].is_string() || parsed["pane"].is_string(); + let target_uid = match state + .bridge + .resolve_pane_uid( + parsed["window"].as_u64().map(|v| v as u32), + parsed["tab"].as_str(), + parsed["pane"].as_str(), + ) + .await + { + Some(u) => Some(u), + None if addressed => { + return ( + StatusCode::NOT_FOUND, + "No pane at that window/tab/pane address".into(), + ); } - } else { - None + None => None, }; let url = parsed["url"].as_str().unwrap_or("").to_string(); @@ -1847,7 +1857,24 @@ async fn post_new_tab( let parsed = serde_json::from_str::<serde_json::Value>(&body).unwrap_or_default(); let profile = parsed["profile"].as_str().unwrap_or("").to_string(); let command = parsed["command"].as_str().unwrap_or("").to_string(); - let cmd = serde_json::json!({"type": "NewTab", "profile": profile, "command": command}); + + // #119: resolve the target window. Omitted → the focused window (the + // sidecar's tracked focus, which the renderer honors via `windowId` and + // falls back to its own focused window if absent). An explicitly named + // window that doesn't exist is a hard 404 rather than silently opening the + // tab in the wrong window. + let requested_window = parsed["window"].as_u64().map(|v| v as u32); + let window_id = state.bridge.resolve_window_id(requested_window).await; + if requested_window.is_some() && window_id.is_none() { + return (StatusCode::NOT_FOUND, "No such window".into()); + } + + let cmd = serde_json::json!({ + "type": "NewTab", + "profile": profile, + "command": command, + "windowId": window_id, + }); match state.bridge.send_command(cmd).await { Ok(r) => { stamp_created_pane(&state, &headers, &r).await; @@ -2327,6 +2354,27 @@ async fn post_note_create( } } +/// POST /api/agent/config/edit — open THE Hyperia config file in a +/// syntax-highlighted code sticky. Fixed action for the config page's +/// "Edit config" button: the page's fetch is anonymous so the general +/// /api/notes create is identity-gated, but this endpoint can only ever +/// open the one local config file, user-initiated — System-side by design. +async fn post_open_config_sticky(State(state): State<AppState>) -> (StatusCode, String) { + let Some(path) = crate::ghost::api::config_raw_path() else { + return (StatusCode::INTERNAL_SERVER_ERROR, "no home dir".into()); + }; + let cmd = serde_json::json!({ + "type": "NoteCreate", + "color": "code:dark", + "filePath": path.to_string_lossy(), + "creator": "Hyperia", + }); + match state.bridge.send_command(cmd).await { + Ok(r) => (StatusCode::OK, r), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e), + } +} + async fn post_note_close( State(state): State<AppState>, headers: HeaderMap, @@ -2933,6 +2981,7 @@ async fn main() -> anyhow::Result<()> { .route("/api/ui/key", axum::routing::post(post_ui_key)) .route("/api/pane/describe", axum::routing::post(post_auto_describe)) .route("/api/notes", axum::routing::get(get_notes).post(post_note_create)) + .route("/api/agent/config/edit", axum::routing::post(post_open_config_sticky)) .route("/api/notes/highlight", axum::routing::post(post_notes_highlight)) .route("/api/notes/{id}", axum::routing::get(get_note).delete(delete_note).patch(patch_note)) .route("/api/notes/{id}/schedule", axum::routing::post(post_note_schedule)) @@ -2954,11 +3003,16 @@ async fn main() -> anyhow::Result<()> { // Mint Ghost a persistent identity so its sidecar API calls are attributed // (not anonymous) — same IdentityStore resolve_caller reads (#22). let ghost_token = bridge_for_ghost.identity().mint("Ghost 👻").await.token; + // Hyperia's OWN agent is consent-exempt (trusted by TOKEN, never name): + // the user configured it, so its pane actions are the user's ask — no + // approval prompts for the built-in agent (#131). + bridge_for_ghost.trust_agent_token(&ghost_token); let ghost_state = ghost::GhostState::new(args.port, ghost_token); let shared_registry = ghost_state.registry.clone(); let ghost_routes = axum::Router::new() .route("/api/ghost/chat", axum::routing::post(ghost::api::ghost_chat)) .route("/api/ghost/status", axum::routing::get(ghost::api::ghost_status)) + .route("/api/ghost/debug", axum::routing::get(ghost::api::ghost_debug)) .route("/api/ghost/history", axum::routing::get(ghost::api::ghost_history)) .route("/api/ghost/memory", axum::routing::get(ghost::api::ghost_memory)) .route("/api/ghost/session", axum::routing::get(ghost::api::ghost_session_dump)) @@ -2990,6 +3044,7 @@ async fn main() -> anyhow::Result<()> { .route("/shell", axum::routing::get(ghost::api::ghost_shell_page)) // Hyperia Agent configuration (epic #131). .route("/agent/config", axum::routing::get(ghost::api::agent_config_page)) + .route("/guide", axum::routing::get(ghost::api::guide_page)) .route( "/api/agent/config", axum::routing::get(ghost::api::get_agent_config).post(ghost::api::post_agent_config) diff --git a/sidecar/src/mcp.rs b/sidecar/src/mcp.rs index 0cf0872a8cd..705867c3fe6 100644 --- a/sidecar/src/mcp.rs +++ b/sidecar/src/mcp.rs @@ -4,11 +4,132 @@ use rmcp::{ model::*, schemars, service::{RequestContext, RoleServer}, - tool, tool_handler, tool_router, + tool, tool_router, }; use crate::ghost::compressor::{ContextCompressor, FOCUS_MIN_CHARS}; +use crate::doors::{self, DoorState, Surface}; +use std::sync::{Mutex, OnceLock}; + +// --------------------------------------------------------------------------- +// MCP tool-doors (Phase 4/5) — opt-in progressive disclosure for the EXTERNAL +// MCP tool surface. See docs/doors.md. +// +// STATE MODEL — why the door state is process-GLOBAL, not per-connection: +// rmcp's stateless streamable-HTTP transport (see `streamable_http_service`, +// `stateful_mode: false`) builds a FRESH `HyperiaMcp` for every request, so a +// per-connection door field on `HyperiaMcp` would be wiped between calls. There +// is no rmcp-provided per-connection scratch space in stateless mode. We +// therefore keep ONE `DoorState` for the whole MCP surface, guarded by a std +// `Mutex`. Every external client shares it — acceptable because doors are a +// token/UX concern, not a security boundary (consent + identity are enforced at +// the HTTP API layer). The lock is only ever held for short synchronous +// mutations — NEVER across an `.await`. +// --------------------------------------------------------------------------- + +/// Read `config.agent.mcp_tool_doors` from hyperia.json (sync, best-effort). +/// Missing / unreadable / unset → `"off"` (full catalog). +fn mcp_tool_doors_mode() -> String { + let path = crate::util::shared_config_path() + .unwrap_or_else(|| std::path::PathBuf::from(".").join("hyperia.json")); + std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok()) + .and_then(|json| { + json["config"]["agent"]["mcp_tool_doors"] + .as_str() + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "off".to_string()) +} + +/// Resolve the MCP doors config once per process (config file + env). Cached — +/// the config is read a single time at first use. +fn mcp_door_config() -> crate::doors::DoorConfig { + static CFG: OnceLock<crate::doors::DoorConfig> = OnceLock::new(); + *CFG.get_or_init(|| crate::doors::resolve_mcp_door_config(&mcp_tool_doors_mode())) +} + +/// Process-global MCP door state (see module note above). Initialised from the +/// resolved [`mcp_door_config`]. +fn mcp_door_state() -> &'static Mutex<DoorState> { + static STATE: OnceLock<Mutex<DoorState>> = OnceLock::new(); + STATE.get_or_init(|| { + let cfg = mcp_door_config(); + Mutex::new(DoorState::with_cap(Surface::Mcp, cfg.cap).with_enabled(cfg.enabled)) + }) +} + +/// Wrap a JSON value as an rmcp tool input-schema object. +fn mcp_schema(v: serde_json::Value) -> std::sync::Arc<serde_json::Map<String, serde_json::Value>> { + std::sync::Arc::new(v.as_object().cloned().unwrap_or_default()) +} + +/// Synthetic `open_tools` meta-tool def. These three meta-tools are NOT part of +/// the `#[tool]` router (they mutate door state, which lives outside the router), +/// so `list_tools` appends them by hand when doors are on. +fn mcp_open_tools_tool() -> Tool { + let names: Vec<&str> = doors::doors_for(Surface::Mcp).map(|d| d.name).collect(); + let listing = doors::doors_for(Surface::Mcp) + .map(|d| format!("- {}: {}", d.name, d.description)) + .collect::<Vec<_>>() + .join("\n"); + Tool::new( + "open_tools", + format!( + "Open a door — a named category of tools — so its tools appear in your tool list. Your \ + list is a small always-on core plus whatever doors you have opened; open a door to \ + reveal more. A notifications/tools/list_changed fires when the set changes. Available \ + doors:\n{}", + listing + ), + mcp_schema(serde_json::json!({ + "type": "object", + "properties": { + "door": { "type": "string", "enum": names, "description": "Door to open" } + }, + "required": ["door"] + })), + ) +} + +/// Synthetic `close_tools` meta-tool def. +fn mcp_close_tools_tool() -> Tool { + let names: Vec<&str> = doors::doors_for(Surface::Mcp).map(|d| d.name).collect(); + Tool::new( + "close_tools", + "Close a door you previously opened, removing its tools from your list to make room for \ + others. Fires notifications/tools/list_changed.", + mcp_schema(serde_json::json!({ + "type": "object", + "properties": { + "door": { "type": "string", "enum": names, "description": "Door to close" } + }, + "required": ["door"] + })), + ) +} + +/// Synthetic `search_tools` meta-tool def — the MCP analogue of the ghost's +/// `tool_search`. Searches the FULL catalog (open or closed) so a model can +/// discover a tool and then `open_tools` its door. +fn mcp_search_tools_tool() -> Tool { + Tool::new( + "search_tools", + "Search the FULL tool catalog by keyword (across open AND closed doors). Each hit shows \ + which door the tool lives behind and whether it is open. Call open_tools(door=\"X\") to \ + reveal a closed one.", + mcp_schema(serde_json::json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Search keywords" } + }, + "required": ["query"] + })), + ) +} + /// Pull the caller's `Authorization` header off the /mcp request so it can be /// forwarded on the internal proxy hop to the gated HTTP endpoints. Without /// this, every MCP mutation tool would reach the HTTP API anonymous and get @@ -243,6 +364,9 @@ pub struct NewTabRequest { pub command: Option<String>, /// Shell profile to use for the new tab. If omitted, uses default shell. pub profile: Option<String>, + /// Window ID to open the tab in — the `id` field from terminal_status. Omit + /// to open it in the focused window. + pub window: Option<u32>, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -1330,6 +1454,9 @@ impl HyperiaMcp { if let Some(prof) = &req.profile { body["profile"] = serde_json::json!(prof); } + if let Some(win) = req.window { + body["window"] = serde_json::json!(win); + } let resp = self.post_json_as("/api/pane/new", &body, forwarded_auth(&ctx).as_deref()).await?; Ok(CallToolResult::success(vec![Content::text(resp)])) } @@ -2899,10 +3026,273 @@ impl HyperiaMcp { } +// -- MCP tool-doors meta-tool handlers (Phase 4) -- +// +// These run OUTSIDE the `#[tool]` router (they mutate the global door state). +// `call_tool` intercepts the three meta-tool names and dispatches here. None of +// them `.await`, so holding the door-state `Mutex` inside is safe. +impl HyperiaMcp { + /// First line of an MCP tool's description, pulled from the live router. + fn mcp_tool_oneliner(&self, name: &str) -> String { + self.tool_router + .get(name) + .and_then(|t| { + t.description + .as_ref() + .map(|d| d.lines().next().unwrap_or("").trim().to_string()) + }) + .unwrap_or_default() + } + + /// `open_tools(door)` — open a door on the global MCP `DoorState` and report + /// the tools it reveals + the resulting live-tool budget. Auto-eviction (LRU) + /// keeps the surface within cap. + fn mcp_open_tools(&self, args: Option<&serde_json::Map<String, serde_json::Value>>) -> String { + let door = args + .and_then(|a| a.get("door")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + // Validate against the MCP surface (a ghost-only door name is invalid here). + let valid = doors::door_by_name(&door).map_or(false, |d| !d.mcp_tools.is_empty()); + if !valid { + let names: Vec<&str> = doors::doors_for(Surface::Mcp).map(|d| d.name).collect(); + return format!("Unknown door '{}'. Available doors: {}", door, names.join(", ")); + } + let door_def = doors::door_by_name(&door).unwrap(); + + // Build the per-tool listing from the router BEFORE taking the lock. + let lines: Vec<String> = door_def + .mcp_tools + .iter() + .map(|&t| format!("- {}: {}", t, self.mcp_tool_oneliner(t))) + .collect(); + + let mut ds = mcp_door_state().lock().unwrap(); + let evicted = ds.open_door(&door); + let open_list = ds.open_doors().join(", "); + let mut out = format!( + "Door '{}' opened ({} tools now callable):\n{}\n\n[doors open: {}] [live tools: {}/{}]", + door, + door_def.mcp_tools.len(), + lines.join("\n"), + open_list, + ds.live_tool_count(), + ds.cap(), + ); + if !evicted.is_empty() { + out.push_str(&format!( + "\n[evicted (over cap): {} — reopen with open_tools if needed]", + evicted.join(", ") + )); + } + out + } + + /// `close_tools(door)` — close a door, freeing its slice of the budget. + fn mcp_close_tools(&self, args: Option<&serde_json::Map<String, serde_json::Value>>) -> String { + let door = args + .and_then(|a| a.get("door")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if door.is_empty() { + return "close_tools requires a 'door' name.".to_string(); + } + let mut ds = mcp_door_state().lock().unwrap(); + let was_open = ds.is_door_open(&door); + ds.close_door(&door); + let open_list = ds.open_doors().join(", "); + let open_disp = if open_list.is_empty() { "none".to_string() } else { open_list }; + let prefix = if was_open { + format!("Door '{}' closed.", door) + } else { + format!("Door '{}' was not open.", door) + }; + format!( + "{} [doors open: {}] [live tools: {}/{}]", + prefix, + open_disp, + ds.live_tool_count(), + ds.cap(), + ) + } + + /// `search_tools(query)` — keyword search over the full MCP catalog with a + /// per-hit `[door: X — open|closed]` hint (mirrors the ghost's tool_search). + fn mcp_search_tools(&self, args: Option<&serde_json::Map<String, serde_json::Value>>) -> String { + let query = args + .and_then(|a| a.get("query")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_lowercase(); + let all = self.tool_router.list_all(); + let ds = mcp_door_state().lock().unwrap(); + let matches: Vec<String> = all + .iter() + .filter(|t| { + let n = t.name.to_lowercase(); + let d = t + .description + .as_ref() + .map(|c| c.to_lowercase()) + .unwrap_or_default(); + n.contains(&query) || d.contains(&query) + }) + .map(|t| { + let hint = match doors::door_of(Surface::Mcp, t.name.as_ref()) { + Some(dn) => { + let state = if ds.is_door_open(dn) { "open" } else { "closed" }; + format!(" [door: {} — {}]", dn, state) + } + None => String::new(), // core tool (always live) or untaxonomied + }; + let desc = t + .description + .as_ref() + .map(|c| c.lines().next().unwrap_or("").trim().to_string()) + .unwrap_or_default(); + format!("- {}{}: {}", t.name, hint, desc) + }) + .collect(); + + if matches.is_empty() { + format!("No tools found matching '{}'", query) + } else { + format!( + "Found {} tool(s):\n{}\n\nTools marked [door: X — closed] are not in your list \ + yet. Call open_tools(door=\"X\") to reveal them (a tools/list_changed \ + notification fires when they appear).", + matches.len(), + matches.join("\n") + ) + } + } +} + // -- ServerHandler impl -- -#[tool_handler] +// NOTE: the `#[tool_handler]` macro would auto-generate `call_tool`, `list_tools`, +// and `get_tool` from `self.tool_router`. Phase 0 hand-writes them verbatim (same +// behavior) so we can add a `tracing::info!` measuring the tool-surface size in +// `list_tools`. Behavior is byte-identical to the macro; gating comes in Phase 4. impl ServerHandler for HyperiaMcp { + async fn call_tool( + &self, + request: rmcp::model::CallToolRequestParams, + context: rmcp::service::RequestContext<rmcp::RoleServer>, + ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> { + // Doors OFF (default) → byte-identical to the pre-doors path. + if !mcp_door_config().enabled { + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + return self.tool_router.call(tcc).await; + } + + let name = request.name.to_string(); + + // Meta-tools live outside the router — intercept them here. open/close + // mutate door state, so they fire a tools/list_changed (Phase 5). + match name.as_str() { + "open_tools" => { + let text = self.mcp_open_tools(request.arguments.as_ref()); + let _ = context.peer.notify_tool_list_changed().await; + return Ok(CallToolResult::success(vec![Content::text(text)])); + } + "close_tools" => { + let text = self.mcp_close_tools(request.arguments.as_ref()); + let _ = context.peer.notify_tool_list_changed().await; + return Ok(CallToolResult::success(vec![Content::text(text)])); + } + "search_tools" => { + // Read-only — no door change, no notification. + let text = self.mcp_search_tools(request.arguments.as_ref()); + return Ok(CallToolResult::success(vec![Content::text(text)])); + } + _ => {} + } + + // Real catalog tool. If it sits behind a CLOSED door, auto-open that door + // (safe by construction — doors are a menu, not a permission boundary; + // consent + identity are enforced at the HTTP API layer) and touch it so + // it survives the next LRU eviction longest. + let auto_opened = { + let mut ds = mcp_door_state().lock().unwrap(); + let door = ds.door_of(&name); + let opened = match door { + Some(dn) if !ds.is_door_open(dn) => { + ds.open_door(dn); + Some(dn.to_string()) + } + _ => None, + }; + ds.touch(&name); + opened + }; + if auto_opened.is_some() { + // The live tool set just grew — tell the client to refetch (Phase 5). + let _ = context.peer.notify_tool_list_changed().await; + } + + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + let result = self.tool_router.call(tcc).await; + match auto_opened { + Some(dn) => result.map(|mut r| { + r.content.insert( + 0, + Content::text(format!("[door '{}' auto-opened by this call]", dn)), + ); + r + }), + None => result, + } + } + + async fn list_tools( + &self, + _request: Option<rmcp::model::PaginatedRequestParams>, + _context: rmcp::service::RequestContext<rmcp::RoleServer>, + ) -> Result<rmcp::model::ListToolsResult, rmcp::ErrorData> { + let mut tools = self.tool_router.list_all(); + let cfg = mcp_door_config(); + + // Doors ON → progressive disclosure: keep only the live (core + open-door) + // router tools, then append the three synthetic meta-tools. Doors OFF → + // the full catalog, unchanged. + if cfg.enabled { + let live: Vec<&'static str> = { + let ds = mcp_door_state().lock().unwrap(); + ds.live_tools() + }; + tools.retain(|t| live.iter().any(|n| *n == t.name.as_ref())); + tools.push(mcp_open_tools_tool()); + tools.push(mcp_close_tools_tool()); + tools.push(mcp_search_tools_tool()); + } + + // Instrumentation: measure the MCP tool surface returned on every + // tools/list — count + serialized bytes + whether doors gated it. + let schema_bytes = serde_json::to_vec(&tools).map(|v| v.len()).unwrap_or(0); + tracing::info!( + target: "doors", + tool_count = tools.len(), + schema_bytes, + doors_enabled = cfg.enabled, + "mcp tools/list surface" + ); + Ok(rmcp::model::ListToolsResult { + tools, + meta: None, + next_cursor: None, + }) + } + + fn get_tool(&self, name: &str) -> Option<rmcp::model::Tool> { + self.tool_router.get(name).cloned() + } + fn get_info(&self) -> ServerInfo { ServerInfo { instructions: Some( @@ -2973,9 +3363,16 @@ impl ServerHandler for HyperiaMcp { \n\nLogs: sidecar_logs." .into(), ), - capabilities: ServerCapabilities::builder() - .enable_tools() - .build(), + capabilities: { + // Advertise tools/list_changed only when doors are on — that's + // the only mode in which the tool set mutates at runtime, so + // clients only need to subscribe then. + let mut caps = ServerCapabilities::builder().enable_tools(); + if mcp_door_config().enabled { + caps = caps.enable_tool_list_changed(); + } + caps.build() + }, ..Default::default() } } diff --git a/sidecar/src/models.rs b/sidecar/src/models.rs new file mode 100644 index 00000000000..3e3fb159462 --- /dev/null +++ b/sidecar/src/models.rs @@ -0,0 +1,146 @@ +//! models.rs — the single source of truth for model knowledge. +//! +//! Every "which model / how does this model behave" fact lives HERE, not +//! scattered through providers, bootstub, doors, api handlers, or HTML. +//! Consumers: ghost/mod.rs + bootstub.rs (defaults), ghost/provider.rs +//! (endpoint routing + token param naming), doors.rs (small-model +//! classification), ghost/api.rs (curated ollama list + UI defaults served +//! via /api/ghost/capabilities so shell.html stops hardcoding model ids). +//! +//! History: a stale `claude-opus-4-7` sat in the picker catalog and +//! `claude-sonnet-4-6` was duplicated as the anthropic default in THREE +//! places (ghost/mod.rs, bootstub.rs, provider.rs) — nobody noticed because +//! there was no one place to look. That's the failure mode this module ends. + +// --------------------------------------------------------------------------- +// Defaults +// --------------------------------------------------------------------------- + +/// Default model when the user has set a provider but no model. UI pickers +/// receive this via /api/ghost/capabilities `model_defaults` — do not +/// duplicate these ids in HTML/JS. +/// Defaults are each provider's TOP CHEAP/FAST model (per user policy) — the +/// picker offers the full catalog for stepping up. +pub fn default_model(provider: &str) -> &'static str { + match provider { + "anthropic" => "claude-haiku-4-5", + "openai" => "gpt-5-mini", + "gemini" => "gemini-3-flash-preview", + "ollama" => "gemma2:9b", + // Sailfish: the integration guide's reference client uses "gemma4-e4b" + // (gemma-4-E4B-it Q4_K_M). Only a fallback — the served id can swap + // (stock vs fine-tuned); authoritative source is GET /v1/models, + // probed live by ghost/api.rs get_agent_models. + "sailfish" => "gemma4-e4b", + _ => "gemma2:9b", + } +} + +/// Per-provider defaults as JSON for the shell/config UI (served on +/// capabilities). Keys match the provider ids the UI uses. +pub fn model_defaults_json() -> serde_json::Value { + serde_json::json!({ + "anthropic": default_model("anthropic"), + "openai": default_model("openai"), + "gemini": default_model("gemini"), + "ollama": default_model("ollama"), + "sailfish": default_model("sailfish"), + }) +} + +/// Default model for the Maximus compressor/extractor (auxiliary local jobs). +/// Overridden by config.maximus_model / MAXIMUS_MODEL env. +pub const COMPRESSOR_DEFAULT_MODEL: &str = "gemma2:2b"; + +// --------------------------------------------------------------------------- +// Curation / allowlists +// --------------------------------------------------------------------------- + +/// Curated Ollama allowlist — the fast local Gemma 4 tags (e4b/12b) plus the +/// strong cloud tags, and `ornith:latest` for testing. E2B-class excluded +/// (poorly quantized). Hand-extend via config.agent.ollama_allow. +pub const OLLAMA_CURATED: &[&str] = &[ + "gemma4:e4b", + "gemma4:12b", + "gemma4:cloud", + "gemma4:31b-cloud", + "ornith:latest", +]; + +// --------------------------------------------------------------------------- +// Capability / behavior switches +// --------------------------------------------------------------------------- + +/// Models served ONLY by OpenAI's /v1/responses endpoint (they 404 on +/// /v1/chat/completions): the codex, -pro, and deep-research variants. Base +/// chat models and the standard reasoning models (o1/o3/o4-mini) support +/// both, so they stay on chat/completions. +pub fn needs_responses_api(model: &str) -> bool { + let m = model.to_ascii_lowercase(); + m.contains("codex") || m.contains("-pro") || m.contains("deep-research") +} + +/// api.openai.com rejects `max_tokens` on reasoning/gpt-5.x chat models +/// ("use max_completion_tokens") but accepts max_completion_tokens on all +/// current chat models. OpenAI-COMPATIBLE servers (llama.cpp / Sailfish, +/// vLLM, Ollama) generally only know `max_tokens` — key off the endpoint. +pub fn uses_max_completion_tokens(endpoint: &str) -> bool { + endpoint.contains("api.openai.com") +} + +/// Heuristic: is this a small / local model that benefits most from a tight +/// tool menu, a slim system prompt, slimmed tool schemas, and temperature 0? +/// +/// True for Ollama and Sailfish, any OpenAI-compatible endpoint that is NOT +/// api.openai.com (llama.cpp, vLLM, …), or a model name carrying a small- +/// parameter tag. +pub fn is_small_model(provider: &str, model: &str, endpoint: &str) -> bool { + let p = provider.trim().to_lowercase(); + if p == "ollama" || p == "sailfish" { + return true; + } + if p == "openai" && !endpoint.contains("api.openai.com") { + return true; + } + let m = model.to_lowercase(); + const SMALL_TAGS: &[&str] = &["e4b", "e2b", "1b", "2b", "3b", "4b", "mini-local"]; + SMALL_TAGS.iter().any(|t| m.contains(t)) +} + +/// Assumed context window when config.agent.context_tokens is unset (0). +/// Small/local models get the known 8k llama.cpp default so the budget +/// guard engages; cloud models manage themselves (0 = no trim). +pub fn default_context_tokens(small: bool) -> usize { + if small { 8192 } else { 0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn responses_api_routing() { + assert!(needs_responses_api("gpt-5-codex")); + assert!(needs_responses_api("o1-pro")); + assert!(needs_responses_api("o4-mini-deep-research")); + assert!(!needs_responses_api("gpt-4o")); + assert!(!needs_responses_api("o4-mini")); + assert!(!needs_responses_api("gpt-5")); + } + + #[test] + fn small_model_classification() { + assert!(is_small_model("sailfish", "gemma4-e4b", "http://localhost:22343")); + assert!(is_small_model("ollama", "anything", "http://localhost:11434")); + assert!(is_small_model("openai", "whatever", "http://localhost:8080")); + assert!(!is_small_model("openai", "gpt-4o", "https://api.openai.com")); + assert!(!is_small_model("anthropic", "claude-sonnet-5", "https://api.anthropic.com")); + } + + #[test] + fn defaults_exist_for_all_providers() { + for p in ["anthropic", "openai", "gemini", "ollama", "sailfish"] { + assert!(!default_model(p).is_empty(), "no default for {p}"); + } + } +} diff --git a/sidecar/src/settings/api.rs b/sidecar/src/settings/api.rs index 363c45be1bb..c58905de1e2 100644 --- a/sidecar/src/settings/api.rs +++ b/sidecar/src/settings/api.rs @@ -138,7 +138,10 @@ pub async fn settings_chat( "role": "user", "content": req.message, })); - (session.messages.clone(), registry.tool_defs(Some(provider.provider_name()), Some(provider.model_name()))) + // Doors: settings agent passes None for now — it gets its own + // DoorState in a later phase (plan §7); today it sees the full catalog + // exactly as before. + (session.messages.clone(), registry.tool_defs(Some(provider.provider_name()), Some(provider.model_name()), None)) }; let (tx, mut rx_inner) = mpsc::channel::<GhostEvent>(128); diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index 5d1917e8acf..93e68ca2aba 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -20,6 +20,11 @@ } /* Standard dropdown chevron — the native arrow renders wonky against the custom dark background, so draw our own. */ + /* While the dropdown list is showing, square the select's bottom corners so + the box visually connects with the (unstylable, square) native list. */ + select.open { + border-bottom-left-radius:0; border-bottom-right-radius:0; + } select { appearance:none; -webkit-appearance:none; background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239a9aa2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>"); @@ -52,7 +57,7 @@ </span> </div> <h1>👻 Hyperia Agent</h1> - <div class="sub">This page configures the <b>model powering Hyperia's built-in agent</b> — the intelligence behind its chat, tools, and automation. <b>Note:</b> the Hyperia agent bills against the provider API keys you add here, per token. External agents — Claude Code, Nemesis8, and friends — run on their own subscriptions and logins; the two are entirely separate. Local models run on your hardware for free. Everything here keeps a hand-editable copy at <code>config.agent</code>.</div> + <div class="sub">This page configures the <b>model powering Hyperia's built-in agent</b> — the intelligence behind its chat, tools, and automation. <b>Note:</b> the Hyperia agent bills against the provider API keys you add here, per token. External agents — Claude Code, Nemesis8, and friends — run on their own subscriptions and logins; the two are entirely separate. Local models run on your hardware for free. Everything here keeps a hand-editable copy at <a href="#" onclick="openConfigSticky(); return false" title="open the config file in a code sticky (syntax-highlighted, editable)"><code>config.agent</code></a>.</div> <h2>Provider</h2> <div class="providers" id="providers"></div> @@ -61,19 +66,40 @@ <h2>Provider</h2> <h2>Model <span id="modelSpin" class="spin" style="display:none"></span></h2> <select id="model"></select> <div class="note" id="modelNote"></div> + <div id="ollamaPortRow" style="display:none;align-items:center;gap:8px;margin-top:6px"> + Port <input type="text" id="olPort" style="margin-top:0;width:90px"> + <span class="note" style="margin:0">Ollama daemon port (default 11434) — saved with Save.</span> + </div> <div id="keyBlock"> - <h2>API key</h2> - <input type="password" id="apiKey" placeholder="paste your API key (stored locally)"> - <div class="note">Stored in the Hyperia config file for now — <b>plaintext until the OS-keystore work (#130) lands</b>. Leave blank to keep the saved key. Enter “-” to clear it.</div> + <h2>API key <span id="keySet" style="display:none;color:#3fb950;font-size:11px;text-transform:none">[set]</span></h2> + <div style="display:flex;align-items:center;background:#17171c;border:1px solid #2c2c34;border-radius:8px;margin-top:6px;padding-right:6px"> + <input type="password" id="apiKey" placeholder="paste your API key (stored locally)" style="border:none;background:transparent;margin-top:0;flex:1;min-width:0"> + <button id="keyRemove" title="remove the stored key for this provider" style="display:none;margin:0;background:#12121c;color:#f85149;border:1px solid #2c2c34;border-radius:4px;padding:2px 8px;font-family:inherit;font-size:10px;cursor:pointer;flex:0 0 auto;white-space:nowrap;align-self:center">remove</button> + </div> + <div class="note">Stored in the Hyperia config file for now — <b>plaintext until the OS-keystore work (#130) lands</b>. Paste to replace; leave blank to keep the saved key.</div> + </div> + + <h2>Token Maximus</h2> + <div class="note" style="margin-bottom:8px"> + Token Maximus <b>downsizes what your agent has to read</b>: a small <b>local</b> model compresses + older conversation history and extracts just the relevant part of big tool outputs (terminal + screens, files) <i>before</i> they reach the agent above — fewer tokens, lower cost, faster turns. + When <b>disabled it does not run at all</b>: full, untouched tokens go straight to your agent. + Runs free on your hardware (Ollama / Sailfish); your primary agent choice above is unaffected. </div> + <label style="display:flex;align-items:center;gap:8px;margin:6px 0 8px;cursor:pointer"> + <input type="checkbox" id="mxEnabled" checked> <span>enabled</span> + </label> + <select id="mxModel"></select> + <div class="note">the local Ollama model that does the downsizing — small + fast wins (gemma2:2b, qwen2.5:7b). Sailfish-as-Maximus lands next. saved with the button below.</div> <h2>Services</h2> <div id="services"></div> - <button id="save">Save configuration</button> - <button id="unconfigure" class="ghostbtn" title="Clears provider/model — Hyperia drops out of the agent list. Keys are kept.">Unconfigure</button> <button class="ghostbtn" onclick="goBack()">← Back</button> + <button id="save">Save</button> + <button class="ghostbtn" onclick="openConfigSticky()" title="open the raw config file in a syntax-highlighted code sticky">Edit config</button> <span id="status"></span> </div> @@ -92,10 +118,11 @@ <h2>Services</h2> {id:'grok', label:'Grok', cat:'grok', key:true}, {id:'gemini', label:'Gemini', cat:'gemini', key:true}, {id:'ollama', label:'Ollama (local)', cat:'ollama', key:false}, - {id:'sailfish', label:'Sailfish (local)', cat:null, key:false, disabled:true} + {id:'sailfish', label:'Sailfish (local)', cat:'sailfish', key:false} ]; // Best-in-class cheapest agentic runner per provider (user-changeable). -const CHEAP_PICK = {claude:/haiku/i, codex:/mini/i, gemini:/flash/i, grok:/build/i, ollama:/e4b|ornith/i}; +// Sailfish serves one model at a time (gemma4-e4b) — match anything → first id. +const CHEAP_PICK = {claude:/haiku/i, codex:/mini/i, gemini:/flash/i, grok:/build/i, ollama:/e4b|ornith/i, sailfish:/.*/}; let cfg = {provider:'', model:'', keys:{}}; let catalog = null; let sel = ''; @@ -103,6 +130,7 @@ <h2>Services</h2> const $ = (id) => document.getElementById(id); let keyStatus = {}; +let localStatus = {}; async function loadKeycheck() { try { keyStatus = (await (await fetch('/api/agent/keycheck')).json()).providers || {}; @@ -124,6 +152,13 @@ <h2>Services</h2> s.style.color = DOT[keyStatus[p.id]] || '#9a9aa2'; d.title = DOT_TIP[keyStatus[p.id]] || ''; d.appendChild(s); + } else if (!p.key && localStatus[p.id] !== undefined) { + // Local providers (Ollama / Sailfish): live endpoint reachability. + const s = document.createElement('span'); + s.textContent = ' ●'; + s.style.color = localStatus[p.id] ? '#3fb950' : '#f85149'; + d.title = localStatus[p.id] ? 'endpoint answering' : 'endpoint not reachable'; + d.appendChild(s); } if (!p.disabled) d.onclick = () => { sel = p.id; renderProviders(); renderModels(); }; $('providers').appendChild(d); @@ -131,6 +166,16 @@ <h2>Services</h2> const p = PROVIDERS.find(x => x.id === sel); $('burnWarn').style.display = p && p.key ? '' : 'none'; $('keyBlock').style.display = p && p.key ? '' : 'none'; + renderKeyState(); + // Ollama daemon port lives under the model select (not in Services). + const opr = $('ollamaPortRow'); + if (opr) { + opr.style.display = sel === 'ollama' ? 'flex' : 'none'; + if (sel === 'ollama' && !$('olPort').value) { + const saved = cfg && cfg.services && cfg.services.ollama && cfg.services.ollama.port; + $('olPort').value = saved || 11434; + } + } } function renderModels() { @@ -158,6 +203,10 @@ <h2>Services</h2> m.value = ids.includes(cfg.model) ? cfg.model : (cheap || prov.default || ids[0] || ''); $('modelNote').textContent = p.id === 'ollama' ? 'Curated: small, fast, tool-capable local models (E4B-class + Ornith). Extend via config.agent.ollama_allow.' + : p.id === 'sailfish' + ? (models.length + ? 'Local OpenAI-compatible endpoint (llama.cpp CUDA). First call after idle can take ~120s to warm up.' + : 'Sailfish not reachable on :22343 — start the appliance (Services → Sailfish) then reopen.') : (cheap && m.value === cheap ? 'Defaulted to the cheapest capable runner — change freely.' : ''); } @@ -166,11 +215,10 @@ <h2>Services</h2> const SVC_DEFS = [ {id:'nuts', label:'nuts.services', login:true}, {id:'nemesis8', label:'Nemesis8', desc:'agent runner + MCP', port:9801, install:'nemesis8'}, - {id:'shivvr', label:'Shivvr', desc:'embeddings', port:0, install:'shivvr'}, - {id:'grub', label:'Grub crawler', desc:'', port:0, install:'grub'}, - {id:'transcription', label:'Transcription', desc:'', port:0, install:'transcription'}, + {id:'shivvr', label:'Shivvr', desc:'embeddings', port:8085, install:'shivvr'}, + {id:'grub', label:'Grub crawler', desc:'', port:6792, install:'grub'}, + {id:'transcription', label:'Transcription', desc:'', port:8765, install:'transcription'}, {id:'sailfish', label:'Sailfish', desc:'optimized local Gemma (Docker, ≥16 GB GPU)', port:22343, install:'sailfish'}, - {id:'ollama', label:'Ollama', desc:'local model daemon', port:11434, install:'ollama'} ]; const installLine = (name) => name === 'ollama' ? (IS_WIN ? 'winget install Ollama.Ollama' : 'curl -fsSL https://ollama.com/install.sh | sh') @@ -208,9 +256,11 @@ <h2>Services</h2> statusHtml = `<span class="tag">not detected</span><span class="tag" style="color:#6ea8fe;cursor:pointer" data-add="${d.id}">+ add</span>`; } } + // The WHOLE header line toggles (data-tog on each piece), not just the + // chevron — buttons/links inside the status area stop propagation. row.innerHTML = `<span data-tog="${d.id}" style="cursor:pointer;color:#9a9aa2;width:12px">${open?'▾':'▸'}</span>` + - `<b>${d.label}</b>${d.desc?' <span style="color:#9a9aa2;font-size:11px">'+d.desc+'</span>':''}` + + `<span data-tog="${d.id}" style="cursor:pointer;display:flex;gap:6px;align-items:baseline;flex:1"><b>${d.label}</b>${d.desc?' <span style="color:#9a9aa2;font-size:11px">'+d.desc+'</span>':''}</span>` + `<span style="margin-left:auto;display:flex;gap:6px;align-items:center">${statusHtml}</span>`; if (open) { const panel = document.createElement('div'); @@ -219,7 +269,7 @@ <h2>Services</h2> panel.innerHTML = `<div style="display:flex;gap:8px"><input type="text" id="nutsEmail" placeholder="you@example.com" style="margin-top:0;flex:1">` + `<button style="margin-top:0;padding:7px 16px" data-nutslogin="1">Login</button></div>` + - `<div class="note">Opens the nuts.services login in your system browser (magic link). Google/GitHub sign-in hidden when opened from Hyperia.</div>`; + `<div class="note">WIP — allows use of nuts.services serverless services. Coming soon.</div>`; } else { panel.innerHTML = `<div style="display:flex;gap:8px;align-items:center">Port <input type="text" data-port="${d.id}" value="${svcPort(d)}" style="margin-top:0;width:90px">` + @@ -261,6 +311,10 @@ <h2>Services</h2> try { const s = await (await fetch('/api/agent/services')).json(); svcState = s.services || {}; + // Provider status lights: ollama daemon + sailfish OpenAI-compat API. + localStatus.ollama = !!(svcState.ollama || {}).running; + localStatus.sailfish = !!svcState.sailfish_api; + renderProviders(); renderServices(); } catch { /* keep last state */ } } @@ -281,23 +335,92 @@ <h2>Services</h2> catch { catalog = false; } $('modelSpin').style.display = 'none'; renderModels(); + renderMaximus(); +} + +// Stored-key state: "[set]" tag on the heading + a remove button INSIDE the +// entry box (left). Removing sends the "-" clear sentinel for this provider. +function renderKeyState() { + const has = !!(cfg && cfg.keys && cfg.keys[sel]); + const tag = $('keySet'), btn = $('keyRemove'), inp = $('apiKey'); + if (!tag || !btn || !inp) return; + tag.style.display = has ? '' : 'none'; + btn.style.display = has ? '' : 'none'; + inp.placeholder = has ? 'key is set — paste to replace' : 'paste your API key (stored locally)'; +} +$('keyRemove').onclick = async () => { + await fetch('/api/agent/config', {method:'POST', headers:{'content-type':'application/json'}, + body: JSON.stringify({keys: {[sel]: '-'}})}); + if (cfg && cfg.keys) cfg.keys[sel] = false; + $('apiKey').value = ''; + renderKeyState(); + loadKeycheck(); +}; + +// Open the raw config file in a CODE-mode sticky (syntax-highlighted, +// editable, bound to the file) so the user can hand-edit config.agent. +async function openConfigSticky() { + // Dedicated endpoint — the page's fetch is anonymous, so the general + // /api/notes create is identity-gated and silently refused. This one can + // only ever open THE config file. + await fetch('/api/agent/config/edit', {method:'POST'}); +} + +// ── Token Maximus: local downsizer model + enable toggle ────────────────── +function renderMaximus() { + const mx = (cfg && cfg.maximus) || {}; + $('mxEnabled').checked = !mx.disabled; + const sel2 = $('mxModel'); + sel2.innerHTML = ''; + const opts = []; + // Ollama tags only for now — the compressor speaks the Ollama API; wiring + // the Sailfish appliance (OpenAI-compat) as a Maximus engine is next. + const ol = catalog && catalog.providers && catalog.providers.ollama; + if (ol) for (const m of (ol.models || [])) opts.push({v: m.id, label: 'ollama · ' + m.id}); + // Always offer the known-small defaults even if the catalog is down. + for (const d of ['gemma2:2b', 'qwen2.5:7b']) { + if (!opts.some(o => o.v === d)) opts.push({v: d, label: 'ollama · ' + d}); + } + for (const o of opts) { + const el = document.createElement('option'); + el.value = o.v; el.textContent = o.label; + sel2.appendChild(el); + } + if (mx.model && !opts.some(o => o.v === mx.model)) { + const el = document.createElement('option'); + el.value = mx.model; el.textContent = mx.model + ' (saved)'; + sel2.appendChild(el); + } + if (mx.model) sel2.value = mx.model; } $('save').onclick = async () => { const p = PROVIDERS.find(x => x.id === sel); const body = {provider: sel, model: $('model').value, keys: {}}; if (p && p.key && $('apiKey').value) body.keys[sel] = $('apiKey').value; + // Token Maximus settings ride the same save. + body.maximus = {disabled: !$('mxEnabled').checked, model: $('mxModel').value || ''}; + // Ollama port (shown under the model when ollama is selected). + if (sel === 'ollama') { + const port = parseInt($('olPort').value, 10); + if (port > 0) body.services = {ollama: {port}}; + } const r = await (await fetch('/api/agent/config', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body)})).json(); - $('status').textContent = r.ok ? 'saved ✓ — Hyperia is in your agent list' : ('error: ' + r.error); + $('status').textContent = r.ok ? 'saved ✓' : ('error: ' + r.error); $('apiKey').value = ''; load(); }; -$('unconfigure').onclick = async () => { - await fetch('/api/agent/config', {method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({provider:''})}); - $('status').textContent = 'unconfigured — removed from the agent list'; - load(); -}; load(); + +// Square the select's bottom corners while its native dropdown list is open +// (the list itself is unstylable and square — this unifies the two shapes). +for (const s of document.querySelectorAll('select')) { + const close = () => s.classList.remove('open'); + s.addEventListener('mousedown', () => s.classList.add('open')); + s.addEventListener('change', close); + s.addEventListener('blur', close); + s.addEventListener('keydown', (e) => { if (e.key === 'Escape' || e.key === 'Enter') close(); }); +} </script> </body> </html> diff --git a/sidecar/static/guide.html b/sidecar/static/guide.html new file mode 100644 index 00000000000..2291844d6c5 --- /dev/null +++ b/sidecar/static/guide.html @@ -0,0 +1,101 @@ +<!doctype html> +<html> +<head> +<meta charset="utf-8"> +<title>Hyperia + + + +
+

hyperia

+ +
a URL navigates · anything else searches
+ +

field guide

+

Hyperia is a terminal where agents are first-class: every pane is addressable, every agent + gets an identity, and the human approves anything that changes state. Split panes, run agents + side by side, and let them drive the terminal through MCP — with your consent.

+
    +
  • Panes & tabs — right-click any pane for splits, clones, and Open Hyperia. Ctrl+Shift+| splits right, Ctrl+Shift+_ splits down.
  • +
  • Built-in agent — the Hyperia Agent tab talks to your configured model and can drive the terminal with tools.
  • +
  • Stickys — floating notes with schedules; right-click → New Stickys.
  • +
+ +

connect an external agent (MCP)

+

Any MCP-capable agent outside Hyperia (Claude Code, etc.) can drive this terminal:

+
claude mcp add --transport http hyperia http://localhost:9800/mcp
+

Inside a Hyperia pane, agents find their identity in HYPERIA_AGENT_TOKEN automatically.

+ +

antigravity

+

Run Antigravity in a pane and it gets the same MCP powers:

+
agy mcp add --transport http hyperia http://localhost:9800/mcp
+ + +
+ + + diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index 9e68bb34af5..9207e168740 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -66,6 +66,17 @@ display: flex; gap: 5px; align-items: baseline; + border: 1px solid var(--line); + border-radius: 10px; + padding: 1px 8px; + background: var(--bg-soft); + transition: border-color 0.5s ease, box-shadow 0.5s ease; + } + /* Metric badge just updated → subtle yellow flash (throttled in JS so a + streaming turn doesn't strobe the whole HUD). */ + .titlebar .hud .stat.flash { + border-color: rgba(232, 194, 104, 0.55); + transition: none; } .titlebar .hud .v { color: var(--text-dim); @@ -75,6 +86,42 @@ background: var(--line) !important; color: var(--text-dim) !important; } + /* Enabled-model lights — hybrid mode runs more than one; a blinking dot + next to each tells you what's live, and the ACTIVE agent model blinks + brighter/faster so you can see which one is actually answering. */ + .titlebar .models { + display: flex; + gap: 12px; + margin-right: 16px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1px; + } + .titlebar .models .m { + display: flex; + align-items: center; + gap: 4px; + color: var(--text-faint); + white-space: nowrap; + } + .titlebar .models .m::before { + content: "●"; + font-size: 9px; + color: var(--ok); + animation: blink-soft 2.4s ease-in-out infinite; + } + /* Lights are GREEN only — steady green when enabled, pulsing green when + it's the active (answering) model. */ + .titlebar .models .m::before { + animation: none; + } + .titlebar .models .m.active { + color: var(--text-dim); + } + .titlebar .models .m.active::before { + color: var(--ok); + animation: blink-soft 1.2s ease-in-out infinite; + } .usage-badge { color: var(--text-faint); font-size: 10px; @@ -233,7 +280,7 @@ align-items: baseline; min-width: 0; cursor: pointer; - user-select: none; + user-select: text; /* selectable — the click-toggle skips when a selection exists */ white-space: nowrap; } .row.tool .label { @@ -560,13 +607,17 @@
-
hyperia · shell
+
hyperia shell
+
+
TPS max0
+
TPS avg0
in0
out0
tools0
turns0
-
reset
+ +
@@ -603,14 +654,95 @@ const hint = $('hint'); const promptLbl = $('prompt-label'); +// Click anywhere in the transcript → focus the input, so you can just click and +// type. BUT stay out of the way of real interactions: don't steal focus if the +// user is selecting text, or clicked a link / button / input / expandable tool +// row / widget. Guarding on a live selection also handles drag-selects (a drag +// ends in a click, but leaves a selection, so we skip). +document.addEventListener('mouseup', (e) => { + const sel = window.getSelection(); + if (sel && sel.toString().length > 0) return; // selecting text + if (e.target.closest('a, button, input, textarea, select, [contenteditable], .summary, .tool, iframe, .widget-mount')) return; + // Only within the transcript area, not the status bar / model picker chrome. + if (!e.target.closest('#screen')) return; + input.focus(); +}); + const STATS = { in: 0, out: 0, tools: 0, turns: 0 }; let turn_in = 0; let turn_out = 0; +// Decode speed: output tokens over generation time for the CURRENT stream. +let genStartMs = 0; // set when the agent starts producing this turn +let genTokens = 0; // output tokens produced since genStartMs +let lastDeltaMs = 0; // when the LAST token delta arrived — avg counts only + // active generation, not tool-wait/idle wall-clock +let statsLastMs = 0; // last stats event — usage-delta TPS for cloud tool turns +let lastTps = 0; // last computed tok/s (held between turns) +let maxTps = 0; // peak decode speed observed (the ceiling) +let cumTokens = 0; // completed-turn tokens, for the running average +let cumSecs = 0; // completed-turn generation seconds +const flashPrev = {}; // last rendered value per badge +const flashLast = {}; // last flash time per badge — throttle so streaming + // (a value change per token) doesn't strobe the HUD +function setStat(id, text) { + const el = $(id); + if (!el) return; + if (flashPrev[id] !== undefined && flashPrev[id] !== text) { + const now = performance.now(); + if (!flashLast[id] || now - flashLast[id] > 1500) { + flashLast[id] = now; + const badge = el.parentElement; + badge.classList.add('flash'); + clearTimeout(badge._flashT); + badge._flashT = setTimeout(() => badge.classList.remove('flash'), 400); + } + } + flashPrev[id] = text; + el.textContent = text; +} function updateHud() { - $('hud-in').textContent = STATS.in.toLocaleString(); - $('hud-out').textContent = STATS.out.toLocaleString(); - $('hud-tools').textContent = STATS.tools; - $('hud-turns').textContent = STATS.turns; + // Average decode speed = tokens / ACTIVE generation time. The live window + // ends at the last delta, not at now() — otherwise the average decays + // toward zero while the agent waits on tools or sits idle. + let liveTok = 0, liveSecs = 0; + if (genStartMs && lastDeltaMs > genStartMs) { + liveTok = genTokens; + liveSecs = (lastDeltaMs - genStartMs) / 1000; + } + const totSecs = cumSecs + liveSecs; + const avg = totSecs > 0.25 ? (cumTokens + liveTok) / totSecs : 0; + setStat('hud-tps-max', maxTps ? maxTps.toFixed(1) : '0'); + setStat('hud-tps-avg', avg ? avg.toFixed(1) : '0'); + setStat('hud-in', STATS.in.toLocaleString()); + setStat('hud-out', STATS.out.toLocaleString()); + setStat('hud-tools', String(STATS.tools)); + setStat('hud-turns', String(STATS.turns)); +} + +// Accrue generated tokens toward the decode-speed meter. Called for BOTH +// thinking and final-text deltas — they're all tokens the model decoded, so +// counting only final text made tps read ~0 on thinking-heavy local turns. +// The clock starts on the first delta of a turn (excludes first-byte latency). +function accrueDecode(chunk) { + const est = Math.max(1, ((chunk || '').length / 4) | 0); + const now = performance.now(); + // A long gap since the last delta = a tool call / new turn boundary — fold + // the finished burst into the running average and start a fresh window, so + // idle time never counts against the average. + if (genStartMs && lastDeltaMs && now - lastDeltaMs > 3000) { + const secs = (lastDeltaMs - genStartMs) / 1000; + if (secs > 0.05 && genTokens > 0) { cumTokens += genTokens; cumSecs += secs; } + genStartMs = 0; + } + if (!genStartMs) { genStartMs = now; genTokens = 0; } + genTokens += est; + lastDeltaMs = now; + const secs = (now - genStartMs) / 1000; + if (secs > 0.25) { + lastTps = genTokens / secs; + if (lastTps > maxTps) maxTps = lastTps; + } + updateHud(); } const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ @@ -706,6 +838,8 @@ r.id = 'thinking-' + id; r.innerHTML = `
💭thinking
`; r.querySelector('.summary').addEventListener('click', () => { + const sel = window.getSelection(); + if (sel && sel.toString().length > 0) return; r.classList.toggle('expanded'); const out = r.querySelector('.output'); out.style.display = r.classList.contains('expanded') ? 'block' : 'none'; @@ -722,6 +856,7 @@ if (outEl) { outEl.innerHTML += escapeHtml(text).replace(/\n/g, '
'); } + accrueDecode(text); // thinking tokens count toward decode speed too autoscroll(); } @@ -740,9 +875,15 @@ r.className = 'row tool'; r.id = 'tool-' + id; // Summary line is the only click target — the output region below - // doesn't toggle when you click into it (so you can select text). + // doesn't toggle when you click into it (so you can select text). The + // summary itself is selectable too: a click that ends with a live text + // selection (i.e. a drag-select) does NOT toggle. r.innerHTML = `
${getEmoji(name)}${escapeHtml(name)}running…
`; - r.querySelector('.summary').addEventListener('click', () => r.classList.toggle('expanded')); + r.querySelector('.summary').addEventListener('click', () => { + const sel = window.getSelection(); + if (sel && sel.toString().length > 0) return; + r.classList.toggle('expanded'); + }); screen.appendChild(r); STATS.tools++; updateHud(); autoscroll(); @@ -765,19 +906,28 @@ : ''; const outEl = r.querySelector('.output'); if (outEl) { - let formattedOutput = output || '(no output)'; - if (output) { - const trimmed = output.trim(); - if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { - try { - const parsed = JSON.parse(trimmed); - formattedOutput = JSON.stringify(parsed, null, 2); - } catch (e) { - // Fallback to raw output - } + // Pretty-print helper: parse-and-reindent when the value looks like JSON, + // else return the raw string. + const pretty = (v) => { + if (v == null) return ''; + if (typeof v === 'object') { try { return JSON.stringify(v, null, 2); } catch (e) { return String(v); } } + const t = String(v).trim(); + if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) { + try { return JSON.stringify(JSON.parse(t), null, 2); } catch (e) { /* raw */ } } - } - outEl.textContent = formattedOutput; + // Terminal screens of a near-empty pane arrive as dozens of blank + // (space-padded) lines — strip trailing spaces per line, collapse 3+ + // consecutive blank lines, and drop trailing whitespace entirely. + return String(v) + .replace(/[ \t]+$/gm, '') + .replace(/\n{3,}/g, '\n\n') + .replace(/\s+$/, ''); + }; + // Expanded row shows the FULL call: pretty input args, then output. + const hasInput = input && (typeof input !== 'object' || Object.keys(input).length > 0); + const inBlock = hasInput ? ('Input:\n' + pretty(input) + '\n\n') : ''; + const outBlock = 'Output:\n' + (pretty(output) || '(no output)'); + outEl.textContent = inBlock + outBlock; } console.debug('[tool] finished', id, name, 'output bytes:', (output || '').length); } @@ -788,6 +938,7 @@ endAgentStream(); streamingRow = addRow('agent', 'hyperia~>', '', { streaming: true }); streamSeenContent = false; + genStartMs = 0; // restart the tok/s clock for this turn } function streamText(chunk) { if (!streamingRow) startAgentStream(); @@ -802,13 +953,20 @@ const body = streamingRow.querySelector('.body'); body.innerHTML += escapeHtml(chunk).replace(/\n/g, '
'); const est = Math.max(1, (chunk.length / 4) | 0); - STATS.out += est; + STATS.out += est; // output-content tokens (billing-ish) — text only turn_out += est; - updateHud(); + accrueDecode(chunk); // decode-speed meter — text + thinking autoscroll(); } function endAgentStream() { if (streamingRow) { + // Fold this turn's decode into the running average before the clock + // stops — measured to the LAST delta, not now() (activity time only). + if (genStartMs && lastDeltaMs > genStartMs) { + const secs = (lastDeltaMs - genStartMs) / 1000; + if (secs > 0.05 && genTokens > 0) { cumTokens += genTokens; cumSecs += secs; } + genStartMs = 0; + } streamingRow.classList.remove('streaming'); if (turn_in > 0 || turn_out > 0) { const usageEl = document.createElement('span'); @@ -1373,6 +1531,21 @@ if (delta_out > 0) { STATS.out += delta_out; turn_out = ev.output_tokens; + // TPS from API-reported usage. Cloud tool-call turns stream NO + // visible text (arguments aren't text_deltas), so the char-count + // meter reads 0 for e.g. gpt-5-codex — usage deltas over wall time + // between stats events is provider-truth for every model. + const now = performance.now(); + if (statsLastMs && now - statsLastMs < 300000) { + const secs = (now - statsLastMs) / 1000; + if (secs > 0.25) { + const tps = delta_out / secs; + if (tps > maxTps) maxTps = tps; + cumTokens += delta_out; + cumSecs += secs; + } + } + statsLastMs = now; } STATS.tools = ev.tool_calls != null ? ev.tool_calls : STATS.tools; STATS.turns = ev.turns || STATS.turns; @@ -1381,6 +1554,11 @@ } case 'done': endAgentStream(); + // Tick the title on each completed turn — the renderer watches + // page-title CHANGES on /shell panes and fires the standard tab + // bell/flash when this tab is backgrounded. + window.__ttick = (window.__ttick || 0) + 1; + document.title = '👻 Hyperia · ' + window.__ttick; break; case 'error': addRow('error', 'err!', escapeHtml(ev.message || 'unknown error')); @@ -1834,6 +2012,7 @@ } // Cache capabilities for the picker click handler. window.__caps__ = caps; + void renderModelLights(caps); // Auto-prompt model picker on first capabilities load if configured model // isn't installed and we have alternatives. if (!modelInstalled && providerNm === 'ollama' && ollamaModels.length && !window.__model_prompted__) { @@ -1845,27 +2024,71 @@ addRow('error', 'err!', 'could not reach sidecar at /api/ghost/capabilities'); return null; } + return caps; +} - // Boot lines +// Enabled-model lights in the titlebar. Hybrid mode has more than one brain +// wired at once (a frontier planner + a local model), and it's otherwise hard +// to tell which is answering — so paint a blinking dot per enabled model, and +// mark the ACTIVE agent provider so it's obvious what you're talking to. +// Local liveness: sailfish from the services probe, ollama from caps. Frontier +// liveness: keycheck (ok = usable key), the same authoritative signal the +// config page dots use. +async function renderModelLights(caps) { + const host = $('hud-models'); + if (!host || !caps) return; + const active = (caps.agent && caps.agent.provider) ? caps.agent.provider : ''; + const chips = []; + const ollama = caps.providers && caps.providers.ollama; + let sailfishUp = false, keys = {}; + try { sailfishUp = !!((await (await fetch('/api/agent/services')).json()).services || {}).sailfish_api; } catch (_) {} + try { keys = (await (await fetch('/api/agent/keycheck')).json()).providers || {}; } catch (_) {} + if (sailfishUp) chips.push('sailfish'); + if (ollama && ollama.reachable && !ollama.disabled) chips.push('ollama'); + for (const p of ['anthropic', 'openai', 'gemini', 'grok']) { + if (keys[p] === 'ok' || keys[p] === 'payment') chips.push(p); + } + // The active provider always shows, even if its probe didn't flag it. + if (active && !chips.includes(active)) chips.unshift(active); + host.innerHTML = chips.map(k => + `${escapeHtml(k)}` + ).join(''); +} + +// The boot banner (sidecar/providers/level) prints ONCE per shell load — from +// boot() only. Re-probes (set-model, wipe) refresh the status strip silently +// instead of re-spamming these rows. +let bannerPrinted = false; +async function printBootBanner(caps) { + if (!caps || bannerPrinted) return; + bannerPrinted = true; addRow('system', 'system=>', `hyperia sidecar v${escapeHtml(caps.sidecar)}`); const provider = caps.agent && caps.agent.provider ? caps.agent.provider : '(none)'; const model = caps.agent && caps.agent.model ? caps.agent.model : '(none)'; + + // Local-provider status: ollama from caps, sailfish from the services probe. const ollama = caps.providers && caps.providers.ollama; - if (ollama && ollama.reachable) { - addRow('system', 'system=>', `ollama: reachable at ${escapeHtml(ollama.endpoint || '')}`); - } else if (ollama && ollama.disabled) { - addRow('system', 'system=>', `ollama: disabled`); - } else { - addRow('system', 'system=>', `ollama: unreachable`); - } - if (caps.ferricula && caps.ferricula.reachable) { - addRow('system', 'system=>', `ferricula: reachable`); + let sailfishUp = false; + try { + const svc = (await (await fetch('/api/agent/services')).json()).services || {}; + sailfishUp = !!svc.sailfish_api; + } catch (_) {} + + // Show the ACTIVE local provider's line first; only show the other if it's up. + const line = (name, up, extra) => addRow('system', 'system=>', + `${name}: ${up ? 'reachable' : 'unreachable'}${up && extra ? ' at ' + escapeHtml(extra) + '' : ''}`); + if (provider === 'sailfish') { + line('sailfish', sailfishUp, 'localhost:22343'); + if (ollama && ollama.reachable) line('ollama', true, ollama.endpoint || ''); } else { - addRow('system', 'system=>', `ferricula: unreachable`); + if (ollama && ollama.reachable) line('ollama', true, ollama.endpoint || ''); + else if (ollama && ollama.disabled) addRow('system', 'system=>', 'ollama: disabled'); + else line('ollama', false); + if (sailfishUp) line('sailfish', true, 'localhost:22343'); } + addRow('system', 'system=>', `agent: provider=${escapeHtml(provider)} model=${escapeHtml(model)}`); addRow('system', 'system=>', `level: ${escapeHtml(caps.level)}`); - return caps; } // Post any uncaught JS error from the shell pane up to the sidecar's @@ -1909,6 +2132,7 @@ async function boot() { await maybeAutoResetIfStale(); const caps = await bootCapabilities(); + await printBootBanner(caps); await seedHistory(); await seedAssets(); const greeting = (() => { @@ -1919,7 +2143,12 @@ switch (caps.level) { case 'hybrid': - return "ready. frontier + local both online. ask anything."; + // Truthful hybrid: ONE provider answers (config.agent.provider); local + // models are reachable and handle aux jobs (Maximus compression). The + // old "frontier + local both online" implied parallel brains. + return "ready. hybrid — " + + (caps.agent && caps.agent.provider || '?') + "/" + (caps.agent && caps.agent.model || '?') + + " is answering; local models are online for compression + aux jobs. ask anything."; case 'frontier': return ollamaDisabled ? "ready on frontier." @@ -1960,12 +2189,12 @@ } // For frontier providers we don't have a model list (model_catalog tool // owns that), so just offer a default per provider that has a token. - const frontierDefaults = { - anthropic: 'claude-sonnet-4-6', - openai: 'gpt-4o', - gemini: 'gemini-2.0-flash', - }; + // Default model ids come from the sidecar's models registry (capabilities + // model_defaults) — no model ids hardcoded in the UI. Empty fallback only + // if the sidecar predates the registry. + const frontierDefaults = (caps.model_defaults) || {}; for (const p of ['anthropic', 'openai', 'gemini']) { + if (!frontierDefaults[p]) continue; // registry didn't supply one — skip const pInfo = caps.providers && caps.providers[p]; const hasToken = !!(pInfo && pInfo.token); const suffix = hasToken ? '' : ' (no token)'; @@ -2042,6 +2271,13 @@ STATS.turns = 0; turn_in = 0; turn_out = 0; + maxTps = 0; + cumTokens = 0; + cumSecs = 0; + lastTps = 0; + genStartMs = 0; + lastDeltaMs = 0; + statsLastMs = 0; updateHud(); });