From cb3d61a5683549188793f976dae37e461372ae62 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 00:31:42 -0500 Subject: [PATCH 01/63] =?UTF-8?q?plan:=20MCP=20tool=20doors=20=E2=80=94=20?= =?UTF-8?q?progressive=20disclosure=20for=20both=20tool=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full code-mapped plan: 57 ghost + 67 MCP tools -> core + doors (<=20 live), DoorState (LRU, cap, auto-open-on-call), ghost loop wiring, identity-keyed external MCP gating (flagged, compat default), 8k token budget, Sailfish answers Q1-Q5 with file:line evidence, 6 tested phases. Co-Authored-By: Claude Fable 5 --- plan/mcp-tool-doors.md | 241 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 plan/mcp-tool-doors.md 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` + `dynamic: Arc>>` (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` 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`; 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, // 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; // 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>>` 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) From 8c2e376e8d5f951b89de1be4e19eb072ad9384ba Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 00:52:43 -0500 Subject: [PATCH 02/63] doors: instrument tool-surface size Phase 0 baseline instrumentation, no behavior change. - ghost run_loop: log per-iteration effective tool count + serialized schema byte size (target=doors) just before the provider stream call. - mcp: hand-write ServerHandler call_tool/list_tools/get_tool verbatim from the #[tool_handler] macro so list_tools can log the full tool surface count + serialized bytes. Behavior is byte-identical to the macro; gating arrives in Phase 4. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/agent.rs | 15 ++++++++++++++ sidecar/src/mcp.rs | 42 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index ac9177a81b6..474def8c219 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -433,6 +433,21 @@ After cleanup, reply to the human and end the turn." 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. + let tool_schema_bytes = serde_json::to_vec(&effective_tool_defs) + .map(|v| v.len()) + .unwrap_or(0); + 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) diff --git a/sidecar/src/mcp.rs b/sidecar/src/mcp.rs index 0cf0872a8cd..da2ed587bde 100644 --- a/sidecar/src/mcp.rs +++ b/sidecar/src/mcp.rs @@ -4,7 +4,7 @@ use rmcp::{ model::*, schemars, service::{RequestContext, RoleServer}, - tool, tool_handler, tool_router, + tool, tool_router, }; use crate::ghost::compressor::{ContextCompressor, FOCUS_MIN_CHARS}; @@ -2901,8 +2901,46 @@ impl HyperiaMcp { // -- 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, + ) -> Result { + let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context); + self.tool_router.call(tcc).await + } + + async fn list_tools( + &self, + _request: Option, + _context: rmcp::service::RequestContext, + ) -> Result { + let tools = self.tool_router.list_all(); + // Phase 0 instrumentation (no behavior change): measure the full MCP + // tool surface returned on every tools/list — count + serialized bytes. + 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, + "mcp tools/list surface" + ); + Ok(rmcp::model::ListToolsResult { + tools, + meta: None, + next_cursor: None, + }) + } + + fn get_tool(&self, name: &str) -> Option { + self.tool_router.get(name).cloned() + } + fn get_info(&self) -> ServerInfo { ServerInfo { instructions: Some( From d9c21bc05579e821f34c0cb2a6266415e5b539d5 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 01:00:46 -0500 Subject: [PATCH 03/63] doors: taxonomy + DoorState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: new pure, unit-tested sidecar/src/doors.rs shared by both tool surfaces (registered as `mod doors;` in main.rs). No wiring yet. - Door taxonomy (plan §3): a single unified DOORS catalog where each Door carries a ghost_tools and an mcp_tools slice; surface-specific doors leave the other slice empty. GHOST_CORE (11) + 8 ghost doors partition all 57 registry tools exactly; MCP_CORE (12) + 9 MCP doors cover the router. - DoorState: LRU-ordered open Vec (front=oldest), live-tool cap (default 20, env HYPERIA_TOOL_CAP), enabled flag. open_door -> evicted list (never evicts the door being opened), touch (tool -> MRU), close_door, door_of lookup. - 17 unit tests: cap enforcement, LRU order + eviction, single-oversized-door, touch/reopen MRU, per-surface partition, every door <= 10 tools, and a registry cross-check asserting every ghost tool_defs name is in exactly one door or core. Co-Authored-By: Claude Fable 5 --- sidecar/src/doors.rs | 744 +++++++++++++++++++++++++++++++++++++++++++ sidecar/src/main.rs | 1 + 2 files changed, 745 insertions(+) create mode 100644 sidecar/src/doors.rs diff --git a/sidecar/src/doors.rs b/sidecar/src/doors.rs new file mode 100644 index 00000000000..b6c0063b404 --- /dev/null +++ b/sidecar/src/doors.rs @@ -0,0 +1,744 @@ +//! 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", + ghost_tools: &[ + "terminal_keys", + "terminal_cd", + "terminal_split", + "terminal_focus", + "terminal_close", + "terminal_new_tab", + "terminal_new_window", + "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 { + 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. Overridable per-process via `HYPERIA_TOOL_CAP`. +pub const DEFAULT_TOOL_CAP: usize = 20; + +fn cap_from_env() -> usize { + std::env::var("HYPERIA_TOOL_CAP") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .filter(|&c| c > 0) + .unwrap_or(DEFAULT_TOOL_CAP) +} + +// --------------------------------------------------------------------------- +// 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, + 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 + } + 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 { + // 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 = reg + .tool_defs(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() { + // ghost core = 11, terminal door = 9 → 20, exactly the default cap. + let mut s = DoorState::with_cap(Surface::Ghost, 20); + let evicted = s.open_door("terminal"); + assert!(evicted.is_empty(), "no eviction expected"); + assert_eq!(s.live_tool_count(), 20); + 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); + } + + #[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/main.rs b/sidecar/src/main.rs index 041522b2e3b..fdb22602f70 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -3,6 +3,7 @@ mod audit; mod bridge; mod dashboard; +mod doors; mod identity; mod fsnav; mod ghost; From 0b20240fce20a8861f2ca9c4caf52d9befac08d4 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 01:13:28 -0500 Subject: [PATCH 04/63] doors: ghost loop progressive disclosure (flagged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire DoorState into the ghost registry + agent loop behind HYPERIA_TOOL_DOORS (default off). Flag off = byte-identical tool surface. - registry: tool_defs(provider, model, doors) — Some+enabled emits core + open_tools/close_tools/tool_search meta + only open doors' schemas; None/disabled returns the full catalog unchanged. open_tools/close_tools ToolDefs added. handle_tool_search is door-aware (open/closed hints + unlock instruction) when a DoorState is passed. - agent: base tool_defs moved inside run_loop per-iteration; throttle tiers filter on top. open_tools/close_tools intercepted in the executor like tool_mount (gather-on-entry text). Closed-door call guard auto-opens + annotates. Bare door names accepted as open_tools aliases. touch() on every executed tool. DoorState threaded run->run_loop->session, reset in reset(). Doors-mode system prompt swaps the honesty paragraph. - settings/api.rs passes None (own DoorState is a later phase). Tests: 22 doors tests pass (cargo test doors); cargo build --release passes. Flag-off count/order asserted identical to the full catalog. Co-Authored-By: Claude Fable 5 --- sidecar/src/doors.rs | 2 +- sidecar/src/ghost/agent.rs | 253 ++++++++++++++++++++++++++++++++-- sidecar/src/ghost/registry.rs | 253 +++++++++++++++++++++++++++++++++- sidecar/src/settings/api.rs | 5 +- 4 files changed, 490 insertions(+), 23 deletions(-) diff --git a/sidecar/src/doors.rs b/sidecar/src/doors.rs index b6c0063b404..d08420b6b6b 100644 --- a/sidecar/src/doors.rs +++ b/sidecar/src/doors.rs @@ -561,7 +561,7 @@ mod tests { use crate::ghost::registry::ToolRegistry; let reg = ToolRegistry::new(9800, "test-token".into()); let catalog: HashSet = reg - .tool_defs(None, None) + .tool_defs(None, None, None) .into_iter() .map(|t| t.name) .collect(); diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index 474def8c219..1e0532c79e3 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -104,6 +104,34 @@ 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..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..token\", ). 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: +- If a capability seems missing, DON'T assume it doesn't exist — search first, then open the right door. +- 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. +- 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 +149,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, pub window_closed: Arc, /// Messages the user typed while the agent was running. Drained by the @@ -138,6 +171,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())), @@ -220,6 +254,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 +271,15 @@ impl GhostSession { pending_injects, initial_tool_call_count, initial_recent_calls, + initial_door_state, ).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,12 +309,19 @@ 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); } @@ -297,8 +341,19 @@ async fn run_loop( pending_injects: Arc>>, initial_tool_call_count: usize, initial_recent_calls: Vec<(String, String)>, -) -> anyhow::Result<(Vec, 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, +) -> anyhow::Result<(Vec, String, usize, Vec<(String, String)>, crate::doors::DoorState)> { + // Doors mode is gated by env in Phase 2 (config.agent.tool_doors auto-mode + // is Phase 3). The open-door set persists across messages via the session; + // `enabled` is re-derived here every run. + let doors_enabled = std::env::var("HYPERIA_TOOL_DOORS") + .map(|v| { + let v = v.trim(); + v == "1" || v.eq_ignore_ascii_case("true") + }) + .unwrap_or(false); + let mut door_state = initial_door_state; + door_state.set_enabled(doors_enabled); // Progressive throttle counters — seeded from session so they persist across messages. // Reset only when the user explicitly resets the conversation. @@ -310,6 +365,12 @@ async fn run_loop( // 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); + } let recalled = ferricula.recall(user_message).await; if !recalled.is_empty() { system.push_str(&recalled); @@ -357,9 +418,19 @@ 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 } @@ -516,7 +587,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)); } } } @@ -571,14 +642,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() { @@ -642,6 +731,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 = 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 @@ -659,7 +766,25 @@ After cleanup, reply to the human and end the turn." } } - output = registry.execute(&tool.name, &input).await; + // tool_search is door-aware in doors mode (adds open/closed + // hints); otherwise it flows through execute() unchanged. + let raw_output = if door_state.enabled() && tool.name == "tool_search" { + registry.handle_tool_search(&input, Some(&door_state)) + } else { + 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, + }; + } + + // 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: check if we've seen this exact call+output before @@ -820,7 +945,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 @@ -857,6 +982,104 @@ 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)); + } +} + +/// 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(), + }; + + // 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(); + return format!( + "Unknown door '{}'. Available doors: {}", + door, + names.join(", ") + ); + } + + let evicted = door_state.open_door(&door); + let door_def = door_by_name(&door).unwrap(); + + // One-line-per-tool listing pulled from the full catalog descriptions. + let catalog = registry.tool_defs(None, None, None); + let lines: Vec = 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/registry.rs b/sidecar/src/ghost/registry.rs index 1c306883387..f75c626a013 100644 --- a/sidecar/src/ghost/registry.rs +++ b/sidecar/src/ghost/registry.rs @@ -123,7 +123,20 @@ impl ToolRegistry { } /// All tool definitions for sending to the Anthropic API. - pub fn tool_defs(&self, provider: Option<&str>, model: Option<&str>) -> Vec { + /// + /// `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 (the legacy `is_small_ollama` + /// allowlist still applies) — so a flag-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 { let mut defs = self.builtins.clone(); defs.push(tool_search_def()); defs.push(tool_create_def()); @@ -156,6 +169,38 @@ impl ToolRegistry { for dt in dynamic.iter() { defs.push(dt.def.clone()); } + 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 = 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; + } + } let is_small_ollama = provider.map_or(false, |p| p.to_lowercase() == "ollama") && model.map_or(false, |m| { @@ -199,7 +244,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 +436,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 } } @@ -1297,6 +1373,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::>() + .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(), @@ -2745,3 +2868,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/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::(128); From fdfc75600de23e5e1437ef5ad64a1ac31d8b1275 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 01:25:29 -0500 Subject: [PATCH 05/63] fix(ghost): max_completion_tokens for api.openai.com (o-series/gpt-5.x 400) Compat endpoints (llama.cpp/Sailfish, vLLM, Ollama) keep max_tokens. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/provider.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/sidecar/src/ghost/provider.rs b/sidecar/src/ghost/provider.rs index 170cac7e41b..4d0a38357b9 100644 --- a/sidecar/src/ghost/provider.rs +++ b/sidecar/src/ghost/provider.rs @@ -1004,7 +1004,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 self.endpoint.contains("api.openai.com") { + body["max_completion_tokens"] = serde_json::json!(max_tokens); + } else { + body["max_tokens"] = serde_json::json!(max_tokens); + } } if !tools.is_empty() { From effaec3939ebaeb57529b602d64dc31e8f56136f Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 01:43:07 -0500 Subject: [PATCH 06/63] doors: auto mode for small models, 8k budget Phase 3 of MCP tool-doors. Delete the is_small_ollama allowlist (doors replaces it), add config.agent.tool_doors=on|off|auto (default auto), slim the doors-mode system prompt for small/local models, send temperature:0 on OpenAI-compatible endpoints, and add a hard context-token budget guard in the compressor. Co-Authored-By: Claude Fable 5 --- sidecar/src/doors.rs | 210 +++++++++++++++++++++++++++++++- sidecar/src/ghost/agent.rs | 114 ++++++++++++++--- sidecar/src/ghost/api.rs | 4 +- sidecar/src/ghost/compressor.rs | 94 +++++++++++++- sidecar/src/ghost/mod.rs | 17 ++- sidecar/src/ghost/provider.rs | 14 +++ sidecar/src/ghost/registry.rs | 41 ++----- sidecar/src/ghost/types.rs | 8 ++ 8 files changed, 446 insertions(+), 56 deletions(-) diff --git a/sidecar/src/doors.rs b/sidecar/src/doors.rs index d08420b6b6b..f3575d09c00 100644 --- a/sidecar/src/doors.rs +++ b/sidecar/src/doors.rs @@ -324,15 +324,128 @@ pub fn door_of(surface: Surface, tool: &str) -> Option<&'static str> { .map(|d| d.name) } -/// Default live-tool cap. Overridable per-process via `HYPERIA_TOOL_CAP`. +/// Default live-tool cap (small / local models). Overridable per-process via +/// `HYPERIA_TOOL_CAP`. pub const DEFAULT_TOOL_CAP: usize = 20; -fn cap_from_env() -> usize { +/// 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 { std::env::var("HYPERIA_TOOL_CAP") .ok() .and_then(|v| v.trim().parse::().ok()) .filter(|&c| c > 0) - .unwrap_or(DEFAULT_TOOL_CAP) +} + +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 { + let p = provider.trim().to_lowercase(); + if p == "ollama" { + 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)) +} + +/// 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 { + 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, + cap_override: Option, +) -> 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 } } // --------------------------------------------------------------------------- @@ -383,6 +496,11 @@ impl DoorState { 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 } @@ -728,6 +846,92 @@ mod tests { 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 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); + } + #[test] fn live_tools_lists_core_then_open_doors() { let mut s = DoorState::with_cap(Surface::Ghost, 30); diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index 1e0532c79e3..f78b0195bd8 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -231,6 +231,8 @@ impl GhostSession { provider: Arc, session_mutex: Arc>, ferricula: Arc, + door_config: crate::doors::DoorConfig, + context_tokens: usize, ) -> mpsc::Receiver { let (tx, rx) = mpsc::channel(128); @@ -272,6 +274,8 @@ impl GhostSession { 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, final_door_state)) => { @@ -342,18 +346,18 @@ async fn run_loop( initial_tool_call_count: usize, initial_recent_calls: Vec<(String, String)>, initial_door_state: crate::doors::DoorState, + door_config: crate::doors::DoorConfig, + context_tokens: usize, ) -> anyhow::Result<(Vec, String, usize, Vec<(String, String)>, crate::doors::DoorState)> { - // Doors mode is gated by env in Phase 2 (config.agent.tool_doors auto-mode - // is Phase 3). The open-door set persists across messages via the session; - // `enabled` is re-derived here every run. - let doors_enabled = std::env::var("HYPERIA_TOOL_DOORS") - .map(|v| { - let v = v.trim(); - v == "1" || v.eq_ignore_ascii_case("true") - }) - .unwrap_or(false); + // 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. @@ -375,7 +379,11 @@ async fn run_loop( 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 { @@ -390,8 +398,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)); } } @@ -495,11 +515,23 @@ 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); + // 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 hard + // context budget is configured (config.agent.context_tokens), the guard + // trims verbatim recent history so system + tools + history fits. let send_messages = if compress { - compressor.compress_messages(&messages).await + let overhead_chars = effective_system.len() + tool_schema_bytes; + compressor + .compress_messages_budgeted(&messages, context_tokens, overhead_chars) + .await } else { messages.clone() }; @@ -507,9 +539,6 @@ After cleanup, reply to the human and end the turn." // 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. - let tool_schema_bytes = serde_json::to_vec(&effective_tool_defs) - .map(|v| v.len()) - .unwrap_or(0); tracing::info!( target: "doors", turn = turns, @@ -986,6 +1015,59 @@ After cleanup, reply to the human and end the turn." } } +/// 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 = 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. diff --git a/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index c3cb43e31b6..6c87c928c23 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! { diff --git a/sidecar/src/ghost/compressor.rs b/sidecar/src/ghost/compressor.rs index e6b61f5fba6..e2562213638 100644 --- a/sidecar/src/ghost/compressor.rs +++ b/sidecar/src/ghost/compressor.rs @@ -219,11 +219,45 @@ impl ContextCompressor { // -- Message history compression (unchanged) -- pub async fn compress_messages(&self, messages: &[Value]) -> Vec { + 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 { + // 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..]; @@ -676,6 +710,23 @@ 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). +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 { @@ -987,6 +1038,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/mod.rs b/sidecar/src/ghost/mod.rs index a3a08f274da..b711c505108 100644 --- a/sidecar/src/ghost/mod.rs +++ b/sidecar/src/ghost/mod.rs @@ -166,12 +166,21 @@ pub fn load_config() -> Option { 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, @@ -192,12 +201,18 @@ fn default_model(provider: &str) -> &'static str { } 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 4d0a38357b9..c07388532d8 100644 --- a/sidecar/src/ghost/provider.rs +++ b/sidecar/src/ghost/provider.rs @@ -961,6 +961,11 @@ 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, } impl OpenAIProvider { @@ -975,11 +980,13 @@ impl OpenAIProvider { } else { config.endpoint.trim_end_matches('/').to_string() }; + let send_zero_temp = config.doors.enabled && !endpoint.contains("api.openai.com"); Self { client: reqwest::Client::new(), api_key: config.api_key.clone(), model, endpoint, + send_zero_temp, } } @@ -1031,6 +1038,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 diff --git a/sidecar/src/ghost/registry.rs b/sidecar/src/ghost/registry.rs index f75c626a013..a6234712781 100644 --- a/sidecar/src/ghost/registry.rs +++ b/sidecar/src/ghost/registry.rs @@ -128,9 +128,8 @@ impl ToolRegistry { /// 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 (the legacy `is_small_ollama` - /// allowlist still applies) — so a flag-off build is byte-identical to - /// before doors existed. + /// 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>, @@ -202,37 +201,11 @@ impl ToolRegistry { } } - 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())); - } - + // 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 } 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, /// Maximus Ollama URL configuration (optional, overrides environment/defaults) From 9f7e155c525fb19f946e1432f840d7973fd9eb1d Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 01:44:34 -0500 Subject: [PATCH 07/63] =?UTF-8?q?fix(web-pane):=20survive=20React=20remoun?= =?UTF-8?q?ts=20=E2=80=94=20delayed=20destroy=20+=20reuse=20on=20create?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a sibling pane (BSP reparent) or any layout reshuffle remounts the WebPane component, which fired destroy+create and rebuilt the native view — full page reload, agent chat log wiped. Renderer-driven destroys are now delayed 400ms (view hidden meanwhile); a create for the same uid cancels the teardown and reuses the live view, skipping loadURL when the URL is unchanged. Window teardown stays immediate. Co-Authored-By: Claude Fable 5 --- app/web-pane-manager.ts | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/app/web-pane-manager.ts b/app/web-pane-manager.ts index e86f591bd0c..ba3966663ed 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}; @@ -214,10 +216,21 @@ 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; } const view = new WebContentsView({ @@ -326,9 +339,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 +381,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); } } From eb2214485a3882eac8edb2d838902b52bb310525 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 01:53:07 -0500 Subject: [PATCH 08/63] fix(web-pane): adopt live view across group re-keying; ferricula off for now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat still cleared on pane open/close: layout collapses can re-key the term GROUP, so the remount arrives as destroy(oldUid)+create(newUid) and the same-uid reuse never fired. create() now ADOPTS a pending-destroy view in the same window with the same URL (re-keyed to the new uid) — page state survives. Known follow-up: webContents handlers still capture the old uid, so pushed title/loading state can go stale after adoption until next navigation. Ferricula recall/remember disabled (empty url no-op) behind HYPERIA_FERRICULA=1 until the service returns. Co-Authored-By: Claude Fable 5 --- app/web-pane-manager.ts | 13 +++++++++++++ sidecar/src/ghost/ferricula.rs | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/app/web-pane-manager.ts b/app/web-pane-manager.ts index ba3966663ed..be2035d6435 100644 --- a/app/web-pane-manager.ts +++ b/app/web-pane-manager.ts @@ -233,6 +233,19 @@ function createPane(win: BrowserWindow, uid: string, url: string) { 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(), 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() { From 16802856908537ed0b3399f500ea9c829fffaf82 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 02:00:50 -0500 Subject: [PATCH 09/63] doors: sailfish provider + config pane wiring (Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire Sailfish (local OpenAI-compatible llama.cpp CUDA endpoint) as a first-class ghost provider per nuts.services/sailfish/HYPERIA_INTEGRATION.md. - provider.rs: "sailfish" arm in AnyProvider::from_config → OpenAIProvider; provider_name() reports "sailfish" via a provider_label field carried from config.provider. Documented the no-timeout client for ~120s warmup. - ghost/mod.rs load_config: sailfish is LOCAL (no api_key required, never falls back to ollama on empty key); default endpoint built from config.agent.services.sailfish.port (default 22343); default_model fallback "gemma4-e4b" (guide's id; /v1/models is authoritative). - doors.rs: is_small_model classifies provider=="sailfish" as small (cap 20); added unit tests. - agent-config.html: enabled the Sailfish provider button (cat:'sailfish'), CHEAP_PICK matches first model, warmup/down model note. - ghost/api.rs get_agent_models: fresh (uncached) probe of sailfish /v1/models (2s timeout) injected as providers.sailfish {ok,default,models}. Co-Authored-By: Claude Fable 5 --- sidecar/src/doors.rs | 27 +++++++++++- sidecar/src/ghost/api.rs | 73 ++++++++++++++++++++++++++++---- sidecar/src/ghost/mod.rs | 26 ++++++++++-- sidecar/src/ghost/provider.rs | 23 +++++++++- sidecar/static/agent-config.html | 9 +++- 5 files changed, 142 insertions(+), 16 deletions(-) diff --git a/sidecar/src/doors.rs b/sidecar/src/doors.rs index f3575d09c00..6a8b31cef64 100644 --- a/sidecar/src/doors.rs +++ b/sidecar/src/doors.rs @@ -357,7 +357,9 @@ fn cap_from_env() -> usize { /// parameter tag (`e4b`, `2b`, `3b`, `4b`, `mini-local`, …). pub fn is_small_model(provider: &str, model: &str, endpoint: &str) -> bool { let p = provider.trim().to_lowercase(); - if p == "ollama" { + // Local appliances: Ollama and Sailfish (llama.cpp CUDA, gemma4-e4b) are + // always small/local — tight cap, slim prompt, temperature 0. + if p == "ollama" || p == "sailfish" { return true; } if p == "openai" && !endpoint.contains("api.openai.com") { @@ -901,6 +903,29 @@ mod tests { 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); diff --git a/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index 6c87c928c23..e556b7eb2b3 100644 --- a/sidecar/src/ghost/api.rs +++ b/sidecar/src/ghost/api.rs @@ -978,10 +978,65 @@ pub async fn get_agent_models() -> Json { use std::time::{Duration, Instant}; static CACHE: OnceLock>> = 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::().await.ok() } + .await; + let models: Vec = 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, + }); + 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() @@ -993,10 +1048,10 @@ pub async fn get_agent_models() -> Json { resp.json::().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. @@ -1034,6 +1089,8 @@ pub async fn get_agent_models() -> Json { 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/mod.rs b/sidecar/src/ghost/mod.rs index b711c505108..23002ab2ea8 100644 --- a/sidecar/src/ghost/mod.rs +++ b/sidecar/src/ghost/mod.rs @@ -141,16 +141,28 @@ pub fn load_config() -> Option { } }; + // 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 @@ -196,6 +208,12 @@ fn default_model(provider: &str) -> &'static str { "openai" => "gpt-4o", "gemini" => "gemini-2.0-flash", "ollama" => "gemma2:9b", + // Sailfish: the integration guide's reference client uses "gemma4-e4b" + // (gemma-4-E4B-it Q4_K_M). This is only a fallback — the served id can + // swap (stock vs fine-tuned), so the authoritative source is GET + // /v1/models, which the config pane probes live (ghost/api.rs + // get_agent_models) and the user can pin via config.agent.model. + "sailfish" => "gemma4-e4b", _ => "gemma2:9b", } } diff --git a/sidecar/src/ghost/provider.rs b/sidecar/src/ghost/provider.rs index c07388532d8..45237ac0d59 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, } } @@ -966,6 +973,10 @@ pub struct OpenAIProvider { /// 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 { @@ -981,12 +992,22 @@ impl OpenAIProvider { 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, } } diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index 5d1917e8acf..f0f45d48e79 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -92,10 +92,11 @@

Services

{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 = ''; @@ -158,6 +159,10 @@

Services

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.' : ''); } From df058ce4b35d93f48cd3d3dc5dfe1615640d1269 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 02:11:31 -0500 Subject: [PATCH 10/63] fix(ghost): consent-exempt own agent by token; add terminal_set_window_size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The built-in agent rode a plain hyp_agent_ token and hit NeedConsent on every pane action — prompting the user to approve the thing they just asked it to do. Bridge now holds a trusted-token set (populated with the minted ghost token at startup; trust is by TOKEN, never spoofable name) and the three authorize_* short-circuit to Allow for it. - Ghost catalog gains terminal_set_window_size (POST /api/window/size) in the terminal door — "resize to double width / height 1200" now has a real tool (tool_search correctly found nothing before; the tool did not exist). Co-Authored-By: Claude Fable 5 --- sidecar/src/bridge.rs | 41 +++++++++++++++++++++++++++++++++++ sidecar/src/doors.rs | 3 ++- sidecar/src/ghost/registry.rs | 25 +++++++++++++++++++++ sidecar/src/main.rs | 4 ++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/sidecar/src/bridge.rs b/sidecar/src/bridge.rs index 8db1f37b4e3..33890eaac7b 100644 --- a/sidecar/src/bridge.rs +++ b/sidecar/src/bridge.rs @@ -211,6 +211,12 @@ struct BridgeInner { perms: crate::perms::PermStore, /// Persistent external-agent identities (file-backed, survive restarts). identity: crate::identity::IdentityStore, + /// 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>, } impl Bridge { @@ -231,10 +237,34 @@ impl Bridge { lume: crate::lume_store::LumeStore::new(), perms: crate::perms::PermStore::default(), identity: crate::identity::IdentityStore::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 +357,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 +395,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 +428,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, diff --git a/sidecar/src/doors.rs b/sidecar/src/doors.rs index 6a8b31cef64..df65c8f04c1 100644 --- a/sidecar/src/doors.rs +++ b/sidecar/src/doors.rs @@ -206,7 +206,7 @@ pub const DOORS: &[Door] = &[ // ---- ghost-only doors -------------------------------------------------- Door { name: "terminal", - description: "Terminal layout: keys, cd, split, focus, tabs & windows", + description: "Terminal layout: keys, cd, split, focus, tabs, windows & resize", ghost_tools: &[ "terminal_keys", "terminal_cd", @@ -215,6 +215,7 @@ pub const DOORS: &[Door] = &[ "terminal_close", "terminal_new_tab", "terminal_new_window", + "terminal_set_window_size", "terminal_rename", "terminal_where_pane", ], diff --git a/sidecar/src/ghost/registry.rs b/sidecar/src/ghost/registry.rs index a6234712781..d3f6ec4c7c7 100644 --- a/sidecar/src/ghost/registry.rs +++ b/sidecar/src/ghost/registry.rs @@ -796,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"], @@ -2013,6 +2025,19 @@ fn builtin_tool_defs() -> Vec { "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.", diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index fdb22602f70..91b958f5dde 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -2955,6 +2955,10 @@ 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() From 71f40ce1e7e785b751073bb9110d2045ccef6c7a Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 02:16:20 -0500 Subject: [PATCH 11/63] fix(ghost): trusted own-agent token (no consent prompts); resize tool; local status lights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bridge trusted-token set: the minted ghost token bypasses authorize_drive/ create/capability — the built-in agent stops asking the user to approve the actions they just requested. Trust by TOKEN, never name. - terminal_set_window_size added to the ghost catalog (terminal door) — the "resize" ask now has a real tool. - Config pane: live status lights on Ollama (daemon probe) and Sailfish (OpenAI-compat /v1/models probe) provider buttons — green answering, red not. Refreshed on the 5s services poll. Co-Authored-By: Claude Fable 5 --- sidecar/static/agent-config.html | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index f0f45d48e79..bd867a6c36b 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -104,6 +104,7 @@

Services

const $ = (id) => document.getElementById(id); let keyStatus = {}; +let localStatus = {}; async function loadKeycheck() { try { keyStatus = (await (await fetch('/api/agent/keycheck')).json()).providers || {}; @@ -125,6 +126,13 @@

Services

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); @@ -266,6 +274,10 @@

Services

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 */ } } From 7486e10ec09a494ceb7f11c5466cb86d724867d2 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 02:25:49 -0500 Subject: [PATCH 12/63] fix(ghost): stop tool loops (split spam) + pretty full-JSON tool rows + local status lights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Repeat guard now also trips when the SAME tool+input fires 3x recently even if the output differs — terminal_split with no args spawns a new pane each call, so output never repeated and a small model (gemma4) split forever. The 3rd identical call now gets the "you are repeating, stop" system note. - Expanded tool rows in the shell show the FULL call: pretty-printed input args + output (was output-only, input just a compact label). - Config pane: green/red status lights on Ollama + Sailfish provider buttons from the live endpoint probes. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/agent.rs | 14 ++++++++++---- sidecar/static/shell.html | 27 +++++++++++++++------------ 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index f78b0195bd8..19bedc6e22e 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -816,11 +816,17 @@ After cleanup, reply to the human and end the turn." door_state.touch(&tool.name); } - // Repeat detection: check if we've seen this exact call+output before + // 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); } diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index 9e68bb34af5..439377f5793 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -765,19 +765,22 @@ : ''; 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; + return String(v); + }; + // 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); } From 31b338d8ed6df92d2c424d6cc2445f3120db1e63 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 02:39:41 -0500 Subject: [PATCH 13/63] fix(shell): boot banner prints once + detects sailfish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The sidecar/providers/level system=> lines moved into printBootBanner(), called only from boot() and guarded by bannerPrinted — re-probes (set-model, wipe) refresh the status strip silently instead of re-spamming the banner every config change. - Local-provider detection now covers Sailfish: active provider shown first; sailfish reachability from the /api/agent/services probe (not just ollama). Co-Authored-By: Claude Fable 5 --- sidecar/static/shell.html | 41 +++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index 439377f5793..a4b3f413ea3 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -1848,27 +1848,43 @@ addRow('error', 'err!', 'could not reach sidecar at /api/ghost/capabilities'); return null; } + return caps; +} - // Boot lines +// 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 @@ -1912,6 +1928,7 @@ async function boot() { await maybeAutoResetIfStale(); const caps = await bootCapabilities(); + await printBootBanner(caps); await seedHistory(); await seedAssets(); const greeting = (() => { From 041cad39c0c69c6f4d021ed583cc02966eb5b2d1 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 02:50:02 -0500 Subject: [PATCH 14/63] feat: window pixel size in terminal_status; tok/s HUD; click-to-focus shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renderer reports WindowBounds (OS pixel w/h/x/y) to the sidecar on window create + resize; cached per window id and attached to each window node in terminal_status. The agent can now answer "how big is the window" and resize relative to it (it already knew its own window/tab/pane). - Shell HUD gains a live tok/s readout (output tokens over decode time, clock starts on first token so warmup doesn't drag it down). - Click anywhere in the transcript focuses the input — but not when selecting text (drag-selects leave a selection → skipped) or clicking a link / button / input / expandable tool row / widget. Co-Authored-By: Claude Fable 5 --- app/bridge.ts | 20 +++++++++++++++++++- app/ui/window.ts | 14 +++++++++++++- sidecar/src/bridge.rs | 31 +++++++++++++++++++++++++++++-- sidecar/static/shell.html | 28 ++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/app/bridge.ts b/app/bridge.ts index 632aa58ac2c..a86150ce231 100644 --- a/app/bridge.ts +++ b/app/bridge.ts @@ -398,12 +398,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 +1168,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/ui/window.ts b/app/ui/window.ts index 382804db32f..ca81e16f507 100644 --- a/app/ui/window.ts +++ b/app/ui/window.ts @@ -27,6 +27,7 @@ import { updateSessionLayout, updateSessionActive, updateWindowFocus, + updateWindowBounds, getSessionRootTab, forceRemoveSession, executeSessionCd @@ -615,10 +616,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 diff --git a/sidecar/src/bridge.rs b/sidecar/src/bridge.rs index 33890eaac7b..0f175249eb8 100644 --- a/sidecar/src/bridge.rs +++ b/sidecar/src/bridge.rs @@ -211,6 +211,9 @@ 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>, /// 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 @@ -237,6 +240,7 @@ 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()), }), } @@ -1226,6 +1230,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 @@ -1359,11 +1364,20 @@ 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. + if let Some(b) = win_bounds.get(win_id) { + win_obj["width"] = b["width"].clone(); + win_obj["height"] = b["height"].clone(); + win_obj["x"] = b["x"].clone(); + win_obj["y"] = b["y"].clone(); + } + windows.push(win_obj); } serde_json::json!({ "version": env!("CARGO_PKG_VERSION"), "windows": windows }) @@ -1555,6 +1569,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); diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index a4b3f413ea3..45514f80276 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -562,6 +562,7 @@
hyperia · shell
+
tok/s0
in0
out0
tools0
@@ -603,10 +604,29 @@ 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 lastTps = 0; // last computed tok/s (held between turns) function updateHud() { + $('hud-tps').textContent = lastTps ? lastTps.toFixed(1) : '0'; $('hud-in').textContent = STATS.in.toLocaleString(); $('hud-out').textContent = STATS.out.toLocaleString(); $('hud-tools').textContent = STATS.tools; @@ -791,6 +811,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(); @@ -807,6 +828,13 @@ const est = Math.max(1, (chunk.length / 4) | 0); STATS.out += est; turn_out += est; + // Live tok/s over this generation. Start the clock on the first real token + // (excludes the model's think/first-byte latency, so it reflects decode + // speed — ~74 on Sailfish, not dragged down by warmup). + if (!genStartMs) { genStartMs = performance.now(); genTokens = 0; } + genTokens += est; + const secs = (performance.now() - genStartMs) / 1000; + if (secs > 0.25) lastTps = genTokens / secs; updateHud(); autoscroll(); } From 4de188a62f2c1e64fc80ef535aedc2e1ff72d185 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 02:51:10 -0500 Subject: [PATCH 15/63] fix: send WindowBounds on focus + delayed retries (WS-connect race) ready-to-show fired before the sidecar WS was up, so terminal_status had no window size. Now bounds also ride the focus handler (fires at launch) plus two short delayed sends as a safety net. Co-Authored-By: Claude Fable 5 --- app/ui/window.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/ui/window.ts b/app/ui/window.ts index ca81e16f507..033131540d5 100644 --- a/app/ui/window.ts +++ b/app/ui/window.ts @@ -979,12 +979,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); From e0efbb12ce490f399025b05bfaedc28b457d7445 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 02:58:17 -0500 Subject: [PATCH 16/63] fix(status): broadcast window bounds on sidecar WS connect Window create/focus/resize bounds sends fired before the sidecar WS finished connecting (connect is the last step at launch), so send() no-oped and terminal_status never learned window pixel size. Replay bounds for all windows in the ws 'open' handler. Co-Authored-By: Claude Fable 5 --- app/bridge.ts | 8 ++++++++ sidecar/src/bridge.rs | 1 + 2 files changed, 9 insertions(+) diff --git a/app/bridge.ts b/app/bridge.ts index a86150ce231..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(); }); diff --git a/sidecar/src/bridge.rs b/sidecar/src/bridge.rs index 0f175249eb8..ed176060109 100644 --- a/sidecar/src/bridge.rs +++ b/sidecar/src/bridge.rs @@ -1571,6 +1571,7 @@ impl Bridge { "WindowBounds" => { let window_id = msg["windowId"].as_u64().unwrap_or(0) as u32; + tracing::info!(target: "doors", "WindowBounds recv win={} {}x{}", window_id, msg["width"], msg["height"]); if window_id != 0 { self.inner.window_bounds.lock().await.insert( window_id, From 557f733659edc575a3e8e260d799bc4a7af4f5f9 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 03:00:55 -0500 Subject: [PATCH 17/63] chore(status): drop WindowBounds debug log Diagnostic tracing served its purpose; window pixel size is verified in /api/status. Removed to keep the log clean (it fired on every move/resize). Co-Authored-By: Claude Fable 5 --- sidecar/src/bridge.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/sidecar/src/bridge.rs b/sidecar/src/bridge.rs index ed176060109..0f175249eb8 100644 --- a/sidecar/src/bridge.rs +++ b/sidecar/src/bridge.rs @@ -1571,7 +1571,6 @@ impl Bridge { "WindowBounds" => { let window_id = msg["windowId"].as_u64().unwrap_or(0) as u32; - tracing::info!(target: "doors", "WindowBounds recv win={} {}x{}", window_id, msg["width"], msg["height"]); if window_id != 0 { self.inner.window_bounds.lock().await.insert( window_id, From 14bad6eae3c9a86d12c91e777320edcefbf520cb Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 03:11:07 -0500 Subject: [PATCH 18/63] feat(context-menu): add 'Open Hyperia' to web-pane + terminal right-click Opens the Hyperia shell (/shell agentic surface) in a new web-pane tab. Web pane: native context menu emits 'open web pane req' on the window rpc. Terminal: pane-band menu reuses the module-level openUrl() localhost path. Co-Authored-By: Claude Fable 5 --- app/web-pane-manager.ts | 8 ++++++++ lib/components/term.tsx | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/app/web-pane-manager.ts b/app/web-pane-manager.ts index be2035d6435..de60e8050b4 100644 --- a/app/web-pane-manager.ts +++ b/app/web-pane-manager.ts @@ -172,6 +172,14 @@ function wireWebContents(uid: string, wc: WebContents) { ? {label: 'Close Inspector', click: () => closeInspector(uid)} : {label: 'Inspect (split)', click: () => openInspector(uid, params.x, params.y)}, {type: 'separator'}, + { + label: 'Open Hyperia', + click: () => { + const port = process.env.HYPERIA_PORT || '9800'; + (entry.win as any).rpc?.emit('open web pane req', {url: `http://localhost:${port}/shell`}); + } + }, + {type: 'separator'}, {label: 'New Stickys', click: () => void ipcMain.emit('new-sticky', {})}, {label: 'Search Stickys', click: () => void ipcMain.emit('search-stickies')} ); diff --git a/lib/components/term.tsx b/lib/components/term.tsx index 220a9f3cb65..8d25ef9575e 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: 'Open Hyperia', + 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', From 757a508e3b34baa3fc531cc55321b6c4b5e33752 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 03:14:57 -0500 Subject: [PATCH 19/63] fix(context-menu): 'Open Hyperia' on the terminal-body right-click too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The terminal body pops the main-window contextmenu.ts menu, not the pane-band menu — add 'Open Hyperia' there via the existing pane:openShellPane command so it appears when right-clicking a terminal. Co-Authored-By: Claude Fable 5 --- app/ui/contextmenu.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/app/ui/contextmenu.ts b/app/ui/contextmenu.ts index f265937a970..54edf8d62ab 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: 'Open Hyperia', click: cmd('pane:openShellPane')}); menu.push(separator); menu.push({label: 'New Stickys', click: () => ipcMain.emit('new-sticky', {})}); From 34d91525b816e0e5f00900c6143b1e5223415335 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 03:27:10 -0500 Subject: [PATCH 20/63] feat(shell): model lights + max/avg TPS; fix provider mislabel + context overflow + open_tools logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four related fixes for the "which model am I talking to / it 400'd" confusion: - Titlebar model lights: a blinking dot per enabled model (sailfish/ollama from probes, frontier from keycheck), the active agent provider marked + brighter. Hybrid mode runs more than one brain; now you can see which. - HUD tok/s split into t/s max (peak) and t/s avg (running, folds the live turn in so it settles as it streams). Reset clears both. - Provider errors name the ACTUAL provider + host, not "OpenAI" — Sailfish rides the OpenAI code path, so an 8k-ctx llama.cpp 400 read as "OpenAI error", making a local model look like a cloud one. - Dynamic context handling: local/small models (Sailfish/Ollama) default to an 8k window when config.agent.context_tokens is unset (was 0 = no trim → "exceeds context size" 400). Output reservation scales down on small windows. New hard_trim_to_budget() guarantees a mechanical fit even when the Ollama compressor is down or its summary fails (no more full-history fallback into a bounded window); strips orphaned tool results so the trim can't 400 the provider. - open_tools/open_door_result now logs (target=doors): the requested door, unknown-door failures, and success with live/cap counts. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/agent.rs | 44 +++++++++++++--- sidecar/src/ghost/compressor.rs | 63 ++++++++++++++++++++++- sidecar/src/ghost/provider.rs | 13 ++++- sidecar/static/shell.html | 91 +++++++++++++++++++++++++++++++-- 4 files changed, 198 insertions(+), 13 deletions(-) diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index 19bedc6e22e..86d03cc3ca0 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -522,16 +522,37 @@ After cleanup, reply to the human and end the turn." .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 if door_config.small { + 8192 + } else { + 0 + }; + // 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. When a hard - // context budget is configured (config.agent.context_tokens), the guard - // trims verbatim recent history so system + tools + history fits. + // 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 { - let overhead_chars = effective_system.len() + tool_schema_bytes; compressor - .compress_messages_budgeted(&messages, context_tokens, overhead_chars) + .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() }; @@ -550,7 +571,7 @@ After cleanup, reply to the human and end the turn." // 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 @@ -1090,11 +1111,13 @@ fn open_door_result( 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, @@ -1104,6 +1127,15 @@ fn open_door_result( 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); diff --git a/sidecar/src/ghost/compressor.rs b/sidecar/src/ghost/compressor.rs index e2562213638..2a66ce89798 100644 --- a/sidecar/src/ghost/compressor.rs +++ b/sidecar/src/ghost/compressor.rs @@ -281,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) } } } @@ -717,6 +720,62 @@ impl ContextCompressor { /// 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 { + 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 = 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; diff --git a/sidecar/src/ghost/provider.rs b/sidecar/src/ghost/provider.rs index 45237ac0d59..1150cdc4d0a 100644 --- a/sidecar/src/ghost/provider.rs +++ b/sidecar/src/ghost/provider.rs @@ -1083,10 +1083,19 @@ impl OpenAIProvider { let api_message = serde_json::from_str::(&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); diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index 45514f80276..8e649988af7 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -75,6 +75,37 @@ 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; + } + .titlebar .models .m.active { + color: var(--text-dim); + } + .titlebar .models .m.active::before { + color: var(--accent); + animation: blink-soft 1s ease-in-out infinite; + } .usage-badge { color: var(--text-faint); font-size: 10px; @@ -561,8 +592,10 @@
hyperia · shell
+
-
tok/s0
+
t/s max0
+
t/s avg0
in0
out0
tools0
@@ -625,8 +658,18 @@ let genStartMs = 0; // set when the agent starts producing this turn let genTokens = 0; // output tokens produced since genStartMs 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 function updateHud() { - $('hud-tps').textContent = lastTps ? lastTps.toFixed(1) : '0'; + // Average decode speed = all generated tokens / all generation time, + // folding the in-flight turn in live so it visibly settles as it streams. + let liveTok = 0, liveSecs = 0; + if (genStartMs) { liveTok = genTokens; liveSecs = (performance.now() - genStartMs) / 1000; } + const totSecs = cumSecs + liveSecs; + const avg = totSecs > 0.25 ? (cumTokens + liveTok) / totSecs : 0; + $('hud-tps-max').textContent = maxTps ? maxTps.toFixed(1) : '0'; + $('hud-tps-avg').textContent = avg ? avg.toFixed(1) : '0'; $('hud-in').textContent = STATS.in.toLocaleString(); $('hud-out').textContent = STATS.out.toLocaleString(); $('hud-tools').textContent = STATS.tools; @@ -834,12 +877,21 @@ if (!genStartMs) { genStartMs = performance.now(); genTokens = 0; } genTokens += est; const secs = (performance.now() - genStartMs) / 1000; - if (secs > 0.25) lastTps = genTokens / secs; + if (secs > 0.25) { + lastTps = genTokens / secs; + if (lastTps > maxTps) maxTps = lastTps; + } updateHud(); autoscroll(); } function endAgentStream() { if (streamingRow) { + // Fold this turn's decode into the running average before the clock stops. + if (genStartMs) { + const secs = (performance.now() - 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'); @@ -1865,6 +1917,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__) { @@ -1879,6 +1932,34 @@ return caps; } +// 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. @@ -2090,6 +2171,10 @@ STATS.turns = 0; turn_in = 0; turn_out = 0; + maxTps = 0; + cumTokens = 0; + cumSecs = 0; + lastTps = 0; updateHud(); }); From 4fb23a7f0687ccaceb5ed8ddcfa9e03d6c8ed787 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 03:42:38 -0500 Subject: [PATCH 21/63] feat(ghost): runaway-pane guard + t/s counts thinking + /api/ghost/debug telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Runaway-pane guard: hard-cap view-creating tools (open_web_pane, terminal_new_tab/new_window/split) at 4 per user message. Each open is a fresh side effect so the identical-output repeat guard never tripped — a small model could open panes forever. Past the cap, refuse the side effect and nudge it to use existing panes / finish / ask. - t/s HUD: decode meter now counts thinking-delta tokens too, not just final text. Thinking-heavy local turns produced sparse text_deltas so max/avg read ~0; shared accrueDecode() fixes both stats. - /api/ghost/debug: per-turn telemetry ring buffer (provider, model, endpoint, in/out tokens per turn + total, decode tps, context budget, tools, stop reason, panes opened) + configured provider/model/doors. Lets a dev/agent troubleshoot the run loop without the human relaying the shell — pairs with the existing SSE /api/ghost/chat to drive + observe the agent headless. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/agent.rs | 85 +++++++++++++++++++++++++++++++++++++- sidecar/src/ghost/api.rs | 29 +++++++++++++ sidecar/src/main.rs | 1 + sidecar/static/shell.html | 31 ++++++++------ 4 files changed, 133 insertions(+), 13 deletions(-) diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index 86d03cc3ca0..aaa2275f9f2 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -331,6 +331,30 @@ impl GhostSession { } } +/// 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::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 { + GHOST_DEBUG + .get() + .and_then(|m| m.lock().ok().map(|v| v.clone())) + .unwrap_or_default() +} + async fn run_loop( tx: mpsc::Sender, mut messages: Vec, @@ -363,6 +387,7 @@ async fn run_loop( // 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; @@ -428,9 +453,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 { @@ -650,6 +678,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 = Vec::new(); @@ -816,11 +876,34 @@ After cleanup, reply to the human and end the turn." } } + // 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 door_state.enabled() && tool.name == "tool_search" { + 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 }; diff --git a/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index e556b7eb2b3..1285668fa70 100644 --- a/sidecar/src/ghost/api.rs +++ b/sidecar/src/ghost/api.rs @@ -130,6 +130,35 @@ pub async fn ghost_status(State(state): State) -> Json) -> Json { + 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) -> Json { let turns = state.ferricula.history(50).await; diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 91b958f5dde..71018d9dc8a 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -2964,6 +2964,7 @@ async fn main() -> anyhow::Result<()> { 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)) diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index 8e649988af7..b559264cb2e 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -676,6 +676,22 @@ $('hud-turns').textContent = 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); + if (!genStartMs) { genStartMs = performance.now(); genTokens = 0; } + genTokens += est; + const secs = (performance.now() - genStartMs) / 1000; + if (secs > 0.25) { + lastTps = genTokens / secs; + if (lastTps > maxTps) maxTps = lastTps; + } + updateHud(); +} + const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); @@ -785,6 +801,7 @@ if (outEl) { outEl.innerHTML += escapeHtml(text).replace(/\n/g, '
'); } + accrueDecode(text); // thinking tokens count toward decode speed too autoscroll(); } @@ -869,19 +886,9 @@ 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; - // Live tok/s over this generation. Start the clock on the first real token - // (excludes the model's think/first-byte latency, so it reflects decode - // speed — ~74 on Sailfish, not dragged down by warmup). - if (!genStartMs) { genStartMs = performance.now(); genTokens = 0; } - genTokens += est; - const secs = (performance.now() - genStartMs) / 1000; - if (secs > 0.25) { - lastTps = genTokens / secs; - if (lastTps > maxTps) maxTps = lastTps; - } - updateHud(); + accrueDecode(chunk); // decode-speed meter — text + thinking autoscroll(); } function endAgentStream() { From f2b5a1e87dab09672b67839a72e2757037d6deb0 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 04:06:39 -0500 Subject: [PATCH 22/63] feat(status): pre-rendered window `size` string in terminal_status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small model reading raw width/height/x/y confused the y-offset for the height ("1267 wide and 238 from the top" — 238 was y, height dropped). Add an unambiguous `size` string ("1267 px wide × 783 px tall, positioned at x=.. y=..") it can parrot; raw fields stay for resize math. Co-Authored-By: Claude Fable 5 --- sidecar/src/bridge.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sidecar/src/bridge.rs b/sidecar/src/bridge.rs index 0f175249eb8..1c4dc219ec1 100644 --- a/sidecar/src/bridge.rs +++ b/sidecar/src/bridge.rs @@ -1371,11 +1371,22 @@ impl Bridge { }); // 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); } From 8d17d3f1a8266194f3b9f7178cf787efa86bd3a4 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 04:20:19 -0500 Subject: [PATCH 23/63] feat(provider): OpenAI /v1/responses support for gpt-5-codex / *-pro / *-deep-research MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Those models 404 on /v1/chat/completions ("Use the v1/responses endpoint instead"). Route responses-only models to /v1/responses with the correct request shape (flat tools, top-level `instructions`, `input` items with function_call/function_call_output correlated by call_id, `max_output_tokens`) and a typed-event SSE parser (response.output_text.delta, function_call_arguments.delta keyed via item_id→call_id, response.completed usage, response.failed/error). Base chat + standard reasoning models (o1/o3/o4-mini) keep chat/completions. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/provider.rs | 265 ++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/sidecar/src/ghost/provider.rs b/sidecar/src/ghost/provider.rs index 1150cdc4d0a..c6108cc4848 100644 --- a/sidecar/src/ghost/provider.rs +++ b/sidecar/src/ghost/provider.rs @@ -1018,6 +1018,13 @@ impl OpenAIProvider { tools: &[ToolDef], max_tokens: u32, ) -> anyhow::Result> { + // 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); @@ -1236,6 +1243,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> { + 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 = 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::(&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 = std::collections::HashMap::new(); + let mut active_calls: Vec = 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::(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 { + let m = model.to_ascii_lowercase(); + m.contains("codex") || m.contains("-pro") || m.contains("deep-research") +} + +/// 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 { + 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 { From fc78e1b148cd94d21f1de70df19c50c1a75fa098 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 04:28:14 -0500 Subject: [PATCH 24/63] chore(agent): add ornith:latest to the curated Ollama model list for testing Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/api.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index 1285668fa70..1c92e455df7 100644 --- a/sidecar/src/ghost/api.rs +++ b/sidecar/src/ghost/api.rs @@ -995,10 +995,11 @@ pub async fn get_agent_services() -> Json { })) } -/// 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 — 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 in the shared +/// config. +const OLLAMA_CURATED: &[&str] = &["gemma4:e4b", "gemma4:12b", "gemma4:cloud", "gemma4:31b-cloud", "ornith:latest"]; /// GET /api/agent/models — the nemesis8.nuts.services/models catalog, cached /// for its TTL (1h), with the ollama list filtered to the curated set. From e7eb1b3d78fa928174b09a7f5082906fcc3ab0c7 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 04:40:27 -0500 Subject: [PATCH 25/63] feat(models): central model registry + small-model payload slim + truthful hybrid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit models.rs is now the single source of truth for model knowledge. It ends the drift that let a stale claude-opus-4-7 sit in the picker and claude-sonnet-4-6 be duplicated as the anthropic default in THREE places (ghost/mod.rs, bootstub.rs, provider.rs): - defaults per provider (anthropic bumped to claude-sonnet-5), curated ollama allowlist, responses-API routing (codex/-pro/deep-research), max_tokens vs max_completion_tokens keying, small-model classification (doors.rs now a shim), 8k default context for small models, compressor default model. - capabilities now serves `model_defaults` — shell.html picker reads it instead of hardcoding model ids (skips providers without a default). - catalog refresh: claude-opus-4-8 / claude-sonnet-5 / haiku-4-5 (drop 3-7), add gpt-5 + gpt-5-codex, o3-mini/o1 -> o4-mini. - tool-harness slim for small models (slim_tool_defs): descriptions capped to first line, per-property schema descriptions stripped (structure/enums/ required kept), open_tools door menu exempt. Frontier keeps full prose. - hybrid greeting now truthful: names the ONE answering provider/model; local models are aux (Maximus compression), not a parallel brain. Co-Authored-By: Claude Fable 5 --- sidecar/src/doors.rs | 15 +--- sidecar/src/ghost/agent.rs | 55 +++++++++++- sidecar/src/ghost/api.rs | 10 +-- sidecar/src/ghost/bootstub.rs | 5 +- sidecar/src/ghost/compressor.rs | 2 +- sidecar/src/ghost/mod.rs | 17 +--- sidecar/src/ghost/provider.rs | 10 +-- sidecar/src/ghost/registry.rs | 10 +-- sidecar/src/main.rs | 1 + sidecar/src/models.rs | 144 ++++++++++++++++++++++++++++++++ sidecar/static/shell.html | 17 ++-- 11 files changed, 230 insertions(+), 56 deletions(-) create mode 100644 sidecar/src/models.rs diff --git a/sidecar/src/doors.rs b/sidecar/src/doors.rs index df65c8f04c1..0a4d8ff476f 100644 --- a/sidecar/src/doors.rs +++ b/sidecar/src/doors.rs @@ -357,18 +357,9 @@ fn cap_from_env() -> usize { /// (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 { - let p = provider.trim().to_lowercase(); - // Local appliances: Ollama and Sailfish (llama.cpp CUDA, gemma4-e4b) are - // always small/local — tight cap, slim prompt, temperature 0. - 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)) + // 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 diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index aaa2275f9f2..769d748a346 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -331,6 +331,45 @@ impl GhostSession { } } +/// 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. @@ -485,7 +524,7 @@ async fn run_loop( else if tool_call_count > 16 { 1 } else { 0 }; - let effective_tool_defs: Vec = match throttle_tier { + let mut effective_tool_defs: Vec = match throttle_tier { 1 => tool_defs.iter() .filter(|t| t.name != "terminal_screen") .cloned() @@ -501,6 +540,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!( @@ -558,10 +607,8 @@ After cleanup, reply to the human and end the turn." // and manage themselves, so they stay unbounded (0 = no trim). let effective_ctx = if context_tokens > 0 { context_tokens - } else if door_config.small { - 8192 } else { - 0 + 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. diff --git a/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index 1c92e455df7..28e25e8e1ce 100644 --- a/sidecar/src/ghost/api.rs +++ b/sidecar/src/ghost/api.rs @@ -479,6 +479,9 @@ pub async fn ghost_capabilities(State(state): State) -> Json Json { })) } -/// 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 in the shared -/// config. -const OLLAMA_CURATED: &[&str] = &["gemma4:e4b", "gemma4:12b", "gemma4:cloud", "gemma4:31b-cloud", "ornith:latest"]; +/// 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. 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 2a66ce89798..e83007b996b 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; diff --git a/sidecar/src/ghost/mod.rs b/sidecar/src/ghost/mod.rs index 23002ab2ea8..157a93f573a 100644 --- a/sidecar/src/ghost/mod.rs +++ b/sidecar/src/ghost/mod.rs @@ -200,22 +200,9 @@ pub fn load_config() -> Option { } /// 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", - // Sailfish: the integration guide's reference client uses "gemma4-e4b" - // (gemma-4-E4B-it Q4_K_M). This is only a fallback — the served id can - // swap (stock vs fine-tuned), so the authoritative source is GET - // /v1/models, which the config pane probes live (ghost/api.rs - // get_agent_models) and the user can pin via config.agent.model. - "sailfish" => "gemma4-e4b", - _ => "gemma2:9b", - } + crate::models::default_model(provider) } fn default_local_ollama() -> GhostConfig { diff --git a/sidecar/src/ghost/provider.rs b/sidecar/src/ghost/provider.rs index c6108cc4848..82b739307a6 100644 --- a/sidecar/src/ghost/provider.rs +++ b/sidecar/src/ghost/provider.rs @@ -98,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() }; @@ -982,7 +982,7 @@ pub struct OpenAIProvider { 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() }; @@ -1044,7 +1044,7 @@ impl OpenAIProvider { // all current chat models. OpenAI-COMPATIBLE servers (llama.cpp / // Sailfish, vLLM, Ollama) generally only know `max_tokens`, so key // off the endpoint. - if self.endpoint.contains("api.openai.com") { + 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); @@ -1432,8 +1432,8 @@ impl OpenAIProvider { /// standard reasoning models (o1/o3/o4-mini) support both, so they stay on /// chat/completions. fn needs_responses_api(model: &str) -> bool { - let m = model.to_ascii_lowercase(); - m.contains("codex") || m.contains("-pro") || m.contains("deep-research") + // Single source of truth: crate::models. + crate::models::needs_responses_api(model) } /// Translate internal Anthropic-style message blocks into /v1/responses diff --git a/sidecar/src/ghost/registry.rs b/sidecar/src/ghost/registry.rs index d3f6ec4c7c7..59cdba66c0f 100644 --- a/sidecar/src/ghost/registry.rs +++ b/sidecar/src/ghost/registry.rs @@ -2714,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" }, diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 71018d9dc8a..8b54e0828ff 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -9,6 +9,7 @@ mod fsnav; mod ghost; mod logs; mod mcp; +mod models; mod perms; mod process; mod lume_store; diff --git a/sidecar/src/models.rs b/sidecar/src/models.rs new file mode 100644 index 00000000000..f9e040ba91d --- /dev/null +++ b/sidecar/src/models.rs @@ -0,0 +1,144 @@ +//! 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. +pub fn default_model(provider: &str) -> &'static str { + match provider { + "anthropic" => "claude-sonnet-5", + "openai" => "gpt-4o", + "gemini" => "gemini-2.0-flash", + "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/static/shell.html b/sidecar/static/shell.html index b559264cb2e..a1e108c31b1 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -2055,7 +2055,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." @@ -2096,12 +2101,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)'; From 4cc12995d8ca5de209daa30fa8a533aae858c8e9 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 04:43:54 -0500 Subject: [PATCH 26/63] test(doors): compute cap test from live taxonomy cap_not_breached_by_a_single_fitting_door hardcoded core=11 + terminal=9; terminal_set_window_size made the terminal door 10. Size the cap from the actual door definition so taxonomy growth can not silently break it. Co-Authored-By: Claude Fable 5 --- sidecar/src/doors.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/sidecar/src/doors.rs b/sidecar/src/doors.rs index 0a4d8ff476f..19156505dd0 100644 --- a/sidecar/src/doors.rs +++ b/sidecar/src/doors.rs @@ -726,11 +726,15 @@ mod tests { #[test] fn cap_not_breached_by_a_single_fitting_door() { - // ghost core = 11, terminal door = 9 → 20, exactly the default cap. - let mut s = DoorState::with_cap(Surface::Ghost, 20); + // 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(), 20); + assert_eq!(s.live_tool_count(), core_n + term_n); assert_eq!(s.open_doors(), &["terminal".to_string()]); } From d9c299474d99791d46d4372b01543b2605d9f4d3 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 10:04:48 -0500 Subject: [PATCH 27/63] fix(ollama): require `reply` in structured output + thought fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ornith:latest emitted valid schema JSON (thought + tool_name:"none") but legally OMITTED reply — it was not in the required list — so the shell showed thinking followed by pure silence. Require reply in the tools-mode format schema (empty string allowed for tool turns), and belt-and-suspenders: a no-tool turn that still arrives with an empty reply promotes its thought to the visible reply instead of saying nothing. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/provider.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/sidecar/src/ghost/provider.rs b/sidecar/src/ghost/provider.rs index 82b739307a6..56a2083e6ad 100644 --- a/sidecar/src/ghost/provider.rs +++ b/sidecar/src/ghost/provider.rs @@ -781,10 +781,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 response to the user. Set to \"\" when calling a tool; otherwise this is what the user sees." } }, - "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!({ @@ -941,9 +944,14 @@ impl OllamaProvider { } } - // Emit final direct reply + // Emit final direct reply. Belt-and-suspenders: a no-tool turn + // with an empty reply would render as thinking → silence (models + // can still under-fill even with reply required), so promote the + // thought to the visible reply rather than saying nothing. if !reply.is_empty() { let _ = tx.send(ProviderEvent::TextDelta(reply)).await; + } else if !had_tool_call && !thought.is_empty() { + let _ = tx.send(ProviderEvent::TextDelta(thought.clone())).await; } // Emit completion stop reason From a94606b2a8b397ce273bd2917021907668574b90 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 10:32:35 -0500 Subject: [PATCH 28/63] =?UTF-8?q?fix(ollama):=20silence=20tool-turn=20narr?= =?UTF-8?q?ation=20=E2=80=94=20reply=20only=20speaks=20on=20the=20answerin?= =?UTF-8?q?g=20turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making `reply` required (the ornith-silence fix) over-corrected: small models now fill it with self-narration on every tool turn ("I am checking the status..."), and the provider emitted any non-empty reply as visible text per turn — a 4-turn status poll rendered as the agent talking to itself. Repro also caught it parroting the compressor's synthetic "Context noted." message. - Visible reply (and the thought-promotion fallback) now emit ONLY on non-tool turns; reasoning stays in the thinking row. Narration also stops accumulating in history, which starves the mimicry loop. - Schema description hardened: reply MUST be "" when calling a tool, never tool narration, shown to the user verbatim. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/provider.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/sidecar/src/ghost/provider.rs b/sidecar/src/ghost/provider.rs index 56a2083e6ad..030ade03d25 100644 --- a/sidecar/src/ghost/provider.rs +++ b/sidecar/src/ghost/provider.rs @@ -781,7 +781,7 @@ impl OllamaProvider { }, "reply": { "type": "string", - "description": "REQUIRED. Your final response to the user. Set to \"\" when calling a tool; otherwise this is what the user sees." + "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." } }, // reply IS required — ornith emitted thought + tool_name:"none" @@ -944,14 +944,20 @@ impl OllamaProvider { } } - // Emit final direct reply. Belt-and-suspenders: a no-tool turn - // with an empty reply would render as thinking → silence (models - // can still under-fill even with reply required), so promote the - // thought to the visible reply rather than saying nothing. - if !reply.is_empty() { - let _ = tx.send(ProviderEvent::TextDelta(reply)).await; - } else if !had_tool_call && !thought.is_empty() { - let _ = tx.send(ProviderEvent::TextDelta(thought.clone())).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 From 49532209d690230c83c0797aadc6012b71ac1cad Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 11:18:35 -0500 Subject: [PATCH 29/63] fix(stickys): open at default size, not 25% of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'quick small notes' scale opened brand-new stickys at ~117x104 px — ridiculously small. New stickys now open at the user's default size (defaults.json, currently 466x417), and a hard floor (220x170) guards against tiny opens from corrupt persisted geometry or tiny explicit sizes. Restored notes keep their exact saved dimensions as before. Co-Authored-By: Claude Fable 5 --- app/sticky.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/sticky.ts b/app/sticky.ts index c3b29c3ce12..e6ab2b3dc12 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)); From 874cefad3635450c8a878a56ad75342f11b9071c Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 12:27:37 -0500 Subject: [PATCH 30/63] feat(ollama): live thinking streaming + fast Escape + door-callable enum + display fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Live streaming: single-generation stream:true path streams native message.thinking deltas to the shell AS THE MODEL GENERATES (was: ~60s of silence, then a fake word-by-word replay). Structured JSON content is accumulated and parsed at the end with the same validation. One generation instead of 3 parallel candidates also ends the VRAM contention on 12GB cards; the parallel-candidate path remains as transport-failure fallback, and a parse failure reuses the raw content instead of re-generating. - Fast stop: stop_requested is honored per provider event mid-generation — Escape lands in seconds, not at the next turn boundary. - Doors on the enum: door names are callable entries in the structured-output enum (agent.rs already aliases a bare door-name call to open_tools). The enum IS a structured-output model'\''s tool universe — without doors in it, "split the pane" dead-ended in "I don'\''t have terminal_split". - Host anchor: system prompt names the real host OS/arch (ornith greeted a Windows user with a "2019 Apple Silicon MacBook Pro"). - Shell display: tool-row summaries are mouse-selectable (click-toggle skips when a selection exists), terminal-screen outputs collapse blank-line floods, and a truncated structured reply extracts the reply text to end-of-string instead of leaking a raw JSON fragment. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/agent.rs | 25 +++ sidecar/src/ghost/provider.rs | 361 +++++++++++++++++++++++++++------- sidecar/static/shell.html | 22 ++- 3 files changed, 332 insertions(+), 76 deletions(-) diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index 769d748a346..c7a94a2ffae 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -439,6 +439,20 @@ async fn run_loop( 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); @@ -656,6 +670,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()); diff --git a/sidecar/src/ghost/provider.rs b/sidecar/src/ghost/provider.rs index 030ade03d25..19e68ccdc04 100644 --- a/sidecar/src/ghost/provider.rs +++ b/sidecar/src/ghost/provider.rs @@ -393,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"); } } } @@ -446,6 +449,173 @@ fn clean_and_parse_json(content: &str) -> anyhow::Result { 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, + format_schema: Option, + tools: Vec, + tx: &mpsc::Sender, +) -> anyhow::Result<(String, Option, 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 = 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::(&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, @@ -760,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() { @@ -813,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, 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 = 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::() { + // 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, 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 = 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::() { + 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::() { - 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; - } } }; @@ -893,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()) diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index a1e108c31b1..fc6bb9726fc 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -264,7 +264,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 { @@ -785,6 +785,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'; @@ -820,9 +822,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(); @@ -854,7 +862,13 @@ if ((t.startsWith('{') && t.endsWith('}')) || (t.startsWith('[') && t.endsWith(']'))) { try { return JSON.stringify(JSON.parse(t), null, 2); } catch (e) { /* raw */ } } - return String(v); + // 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); From e30238c506172894a01aae9cada858bb04f9ff4f Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 12:35:17 -0500 Subject: [PATCH 31/63] fix(dev): end the launch restart-storm + duplicate-instance squash for good MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three structural fixes for "it keeps flashing/restarting/rotating panes": 1. electronmon now IGNORES renderer-only outputs (target/renderer, static, assets, html, maps, copied configs). Every cold `yarn start` had webpack's initial build writing ~20 files into target/, and electronmon restarted the app for EVERY batch — 25+ renderer boots per launch, each rebuilding panes (the "restart thing"). Renderer edits use Ctrl+R as always; only real main-process emits restart the app. 2. Single-instance loser now HARD-exits (app.exit + process.exit). app.quit() is async and the losing instance kept executing init — including spawnSidecar's taskkill of the WINNER's sidecar — before quitting. That mutual sidecar-squash is why two stacks fought instead of one winning. 3. scripts/dev.ps1 — the only sanctioned dev (re)start: sweep hyperia-scoped processes (by command line, never blanket), VERIFY ZERO, refuse to launch dirty, then start exactly one stack. Co-Authored-By: Claude Fable 5 --- app/index.ts | 10 ++++++++-- package.json | 14 +++++++++++++- scripts/dev.ps1 | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 scripts/dev.ps1 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/package.json b/package.json index a5ed72c11c7..450b2ed3251 100644 --- a/package.json +++ b/package.json @@ -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/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 00000000000..e6d665a518c --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,43 @@ +# 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 +# post-verifies no duplicate watchers appeared. Never blanket-kills +# electron.exe/node.exe — always matched by command line, so other Electron +# apps and unrelated node processes are untouched. +$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) { + # one more pass, then hard-fail + 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 From 861ee7039a5b064d80595f40896cd5849840ed5e Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 12:36:08 -0500 Subject: [PATCH 32/63] fix(dev): dev.ps1 pure ASCII - PS5.1 misparses UTF-8 em-dashes without BOM Co-Authored-By: Claude Fable 5 --- scripts/dev.ps1 | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 index e6d665a518c..8afdefea38f 100644 --- a/scripts/dev.ps1 +++ b/scripts/dev.ps1 @@ -1,10 +1,11 @@ -# dev.ps1 — the ONLY sanctioned way to (re)start the Hyperia dev loop. +# 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 -# post-verifies no duplicate watchers appeared. Never blanket-kills -# electron.exe/node.exe — always matched by command line, so other Electron -# apps and unrelated node processes are untouched. +# 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 @@ -23,20 +24,21 @@ 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 +# 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) { - # one more pass, then hard-fail 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 + $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." + 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" +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 From 282cf389ad0eac198af8d1a0bf71f2e813d34c72 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 13:15:45 -0500 Subject: [PATCH 33/63] feat(shell): Hyperia Agent gets its own labeled tab + hyperia.local installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The agent shell always opens in its OWN tab labeled "Hyperia Agent" — every entry point (context menus, pane picker, pane:openShellPane) routes through the shared open-web-pane-req handler, which now detects the shell URL (localhost/127.0.0.1/hyperia.local + /shell), labels the tab via a new optional name on TERM_GROUP_ADD_WEB_TAB, and DEDUPES: reopening focuses the existing Hyperia Agent tab instead of stacking copies. The pane picker no longer swaps the current pane; it opens the tab and closes itself. - scripts/hyperia-local.ps1: idempotent, self-elevating hosts-file mapping hyperia.local -> 127.0.0.1, so http://hyperia.local:9800/shell works in Chrome/Edge and Hyperia alike (sidecar binds loopback; no LAN exposure). Co-Authored-By: Claude Fable 5 --- lib/actions/term-groups.ts | 4 +-- lib/components/new-pane-picker.tsx | 10 +++---- lib/index.tsx | 28 ++++++++++++++++++++ lib/reducers/term-groups.ts | 9 +++++-- scripts/hyperia-local.ps1 | 42 ++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 scripts/hyperia-local.ps1 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/new-pane-picker.tsx b/lib/components/new-pane-picker.tsx index b45bcf9b482..618ad1f49a7 100644 --- a/lib/components/new-pane-picker.tsx +++ b/lib/components/new-pane-picker.tsx @@ -586,16 +586,16 @@ export class NewPanePicker extends React.Component { - 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'}); + 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) => { diff --git a/lib/index.tsx b/lib/index.tsx index a472274c085..505cd25d15c 100644 --- a/lib/index.tsx +++ b/lib/index.tsx @@ -600,9 +600,37 @@ rpc.on( } ); +// 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; + 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/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" +} From 375afd71ddab567fa183ec57d70f1d548e60b28f Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 13:46:23 -0500 Subject: [PATCH 34/63] feat(agent): auto-offer all installed ollama cloud-tag models in the picker Any locally installed model whose tag is :cloud or contains -cloud (nemotron-3-super:cloud, deepseek-v4-pro:cloud, ...) now joins the curated list automatically, probed fresh per request like the sailfish block so a new `ollama pull x:cloud` appears immediately (never stale behind the 1h catalog cache). Curated list + config.agent.ollama_allow still apply. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/api.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index 28e25e8e1ce..9590b139a43 100644 --- a/sidecar/src/ghost/api.rs +++ b/sidecar/src/ghost/api.rs @@ -1052,6 +1052,46 @@ pub async fn get_agent_models() -> Json { "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::().await.ok() + } + .await; + if let Some(tags) = tags { + let installed_cloud: Vec = 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 } From 86bcea3bda961d2aeaf321e609477337cc10f030 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 14:05:01 -0500 Subject: [PATCH 35/63] feat: Token Maximus config + /guide start page + W/S/A picker with version/update row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Token Maximus section on the agent config page: enable toggle + local ollama model pick, saved to config.maximus.*; disabled now means NOT RUN AT ALL (extraction path gained the missing is_disabled gate) — full untouched tokens flow to the selected agent. - /guide: web-pane start page — DuckDuckGo/URL search bar + Hyperia field guide (mcp add commands with copy, basics, agent links). - Picker: W/S/A hotkeys (W = guide web pane, S/A = remembered default shell/agent via localStorage) + version footer with latest-check and the platform-aware hyperia.nuts.services install command (copy + run; run disabled when up to date, opens shell with command typed, never submitted). Co-Authored-By: Claude Fable 5 --- lib/components/new-pane-picker.tsx | 315 ++++++++++++++++++++++++++--- lib/components/term.tsx | 3 + sidecar/src/ghost/api.rs | 30 ++- sidecar/src/ghost/compressor.rs | 9 + sidecar/src/main.rs | 1 + sidecar/static/agent-config.html | 45 +++++ sidecar/static/guide.html | 101 +++++++++ 7 files changed, 474 insertions(+), 30 deletions(-) create mode 100644 sidecar/static/guide.html diff --git a/lib/components/new-pane-picker.tsx b/lib/components/new-pane-picker.tsx index 618ad1f49a7..7b112a416d1 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,20 @@ 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} + {keyHint && ( + + {keyHint} + + )} +
@@ -520,6 +575,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 +587,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 +609,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 +621,90 @@ 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 */ + } + if (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,22 +727,26 @@ 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 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. + // 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 {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}); }; @@ -781,11 +934,10 @@ export class NewPanePicker extends React.Component { const n = p.name.toLowerCase(); @@ -795,7 +947,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 +957,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 +1025,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 +1091,12 @@ export class NewPanePicker extends React.Component -
New Webpane
+
+ New Webpane + + W + +
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 +1147,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 8d25ef9575e..136e7d8bad3 100644 --- a/lib/components/term.tsx +++ b/lib/components/term.tsx @@ -2868,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/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index 9590b139a43..b7fee29d6e9 100644 --- a/sidecar/src/ghost/api.rs +++ b/sidecar/src/ghost/api.rs @@ -748,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")) } @@ -783,7 +789,13 @@ pub async fn get_agent_config() -> Json { "gemini": has_key("gemini"), }, // Per-service settings (ports etc.) — config.agent.services.. - "services": agent["services"].clone() + "services": agent["services"].clone(), + // 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), + } })) } @@ -840,6 +852,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()})))), diff --git a/sidecar/src/ghost/compressor.rs b/sidecar/src/ghost/compressor.rs index e83007b996b..5c0cd806ca9 100644 --- a/sidecar/src/ghost/compressor.rs +++ b/sidecar/src/ghost/compressor.rs @@ -302,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; diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 8b54e0828ff..dd418ab43f2 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -2997,6 +2997,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/static/agent-config.html b/sidecar/static/agent-config.html index bd867a6c36b..236e874c231 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -68,6 +68,20 @@

API key

Stored in the Hyperia config file for now — plaintext until the OS-keystore work (#130) lands. Leave blank to keep the saved key. Enter “-” to clear it.
+

Token Maximus

+
+ Token Maximus downsizes what your agent has to read: a small local model compresses + older conversation history and extracts just the relevant part of big tool outputs (terminal + screens, files) before they reach the agent above — fewer tokens, lower cost, faster turns. + When disabled it does not run at all: full, untouched tokens go straight to your agent. + Runs free on your hardware (Ollama / Sailfish); your primary agent choice above is unaffected. +
+ + +
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.
+

Services

@@ -298,12 +312,43 @@

Services

catch { catalog = false; } $('modelSpin').style.display = 'none'; renderModels(); + renderMaximus(); +} + +// ── 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 || ''}; 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); $('apiKey').value = ''; 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 @@ + + + + +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
+ + +
+ + + From 05a18a5f8c072a9cc98ec37594f575ef8e3934e3 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 15:37:12 -0500 Subject: [PATCH 36/63] fix(picker+shell): W/S/A replace enter badges, sanitize version, shell config link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The in-box "enter" badges are now the hotkey letters: URL box shows "w", shell/agent boxes show "s"/"a" (falls back to "enter" when no hotkey). - Latest-version fetch accepts ONLY a strict version string — the endpoint can return an error page/file content today, which rendered as raw garbage (a stray Rust line) in the picker footer. - Hyperia shell status strip gains a "config" item linking to /agent/config. Co-Authored-By: Claude Fable 5 --- lib/components/new-pane-picker.tsx | 7 +++++-- lib/components/url-picker.tsx | 2 +- sidecar/static/shell.html | 1 + 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/components/new-pane-picker.tsx b/lib/components/new-pane-picker.tsx index 7b112a416d1..a6e80f62f06 100644 --- a/lib/components/new-pane-picker.tsx +++ b/lib/components/new-pane-picker.tsx @@ -446,7 +446,7 @@ class InlineCombobox extends React.Component { onClick={() => this.commit()} style={pickerEnterBadgeStyle} > - enter + {keyHint ? keyHint.toLowerCase() : 'enter'}
@@ -640,7 +640,10 @@ export class NewPanePicker extends React.Component {}); diff --git a/lib/components/url-picker.tsx b/lib/components/url-picker.tsx index a894e070e70..66f10d6c2e2 100644 --- a/lib/components/url-picker.tsx +++ b/lib/components/url-picker.tsx @@ -282,7 +282,7 @@ export default class UrlPicker extends React.Component { cursor: 'pointer' }} > - enter + w
diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index fc6bb9726fc..df6c68f419d 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -608,6 +608,7 @@
sidecar
level
model
+
config
ferricula
From e3e16f8e8ee4c669ae60590b2343ccb1df002434 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 15:59:06 -0500 Subject: [PATCH 37/63] =?UTF-8?q?fix(picker):=20drop=20the=20W/S/A=20chips?= =?UTF-8?q?=20after=20labels=20=E2=80=94=20the=20in-box=20letter=20badges?= =?UTF-8?q?=20carry=20the=20hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- lib/components/new-pane-picker.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/components/new-pane-picker.tsx b/lib/components/new-pane-picker.tsx index a6e80f62f06..d81a15198c3 100644 --- a/lib/components/new-pane-picker.tsx +++ b/lib/components/new-pane-picker.tsx @@ -381,13 +381,8 @@ class InlineCombobox extends React.Component { return (
-
+
{label} - {keyHint && ( - - {keyHint} - - )}
@@ -1094,11 +1089,8 @@ export class NewPanePicker extends React.Component -
+
New Webpane - - W -
Date: Thu, 2 Jul 2026 16:00:03 -0500 Subject: [PATCH 38/63] chore(menus): rename 'Open Hyperia' to 'Hyperia Agent' in all right-click menus Co-Authored-By: Claude Fable 5 --- app/ui/contextmenu.ts | 2 +- app/web-pane-manager.ts | 2 +- lib/components/term.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/ui/contextmenu.ts b/app/ui/contextmenu.ts index 54edf8d62ab..da922c124c3 100644 --- a/app/ui/contextmenu.ts +++ b/app/ui/contextmenu.ts @@ -51,7 +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: 'Open Hyperia', click: cmd('pane:openShellPane')}); + 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/web-pane-manager.ts b/app/web-pane-manager.ts index de60e8050b4..c31b2f97b13 100644 --- a/app/web-pane-manager.ts +++ b/app/web-pane-manager.ts @@ -173,7 +173,7 @@ function wireWebContents(uid: string, wc: WebContents) { : {label: 'Inspect (split)', click: () => openInspector(uid, params.x, params.y)}, {type: 'separator'}, { - label: 'Open Hyperia', + 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`}); diff --git a/lib/components/term.tsx b/lib/components/term.tsx index 136e7d8bad3..08011dbcf7d 100644 --- a/lib/components/term.tsx +++ b/lib/components/term.tsx @@ -376,7 +376,7 @@ export default class Term extends React.PureComponent< menu.append( new MenuItem({ - label: 'Open Hyperia', + label: 'Hyperia Agent', click: () => { const port = process.env.HYPERIA_PORT || '9800'; openUrl(`http://localhost:${port}/shell`); From bcb92bf7ddf3ccec685f52f4c63b96bb635c3c9e Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 16:06:19 -0500 Subject: [PATCH 39/63] feat(shell): HYPERIA SHELL title, gear by reset, badge metrics with blue flash, green lights, activity-based t/s avg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Titlebar reads "hyperia shell" (rendered HYPERIA SHELL), no separator. - Gear button next to reset opens /agent/config (status-strip config item removed in its favor). - HUD metrics are bordered badges; a value change flashes the badge blue. - Model lights are green only: steady green = enabled, pulsing green = the active answering model (no accent-colored blink). - t/s avg measures ACTIVITY time only: the live window ends at the last token delta (not now()), and a >3s delta gap folds the burst and starts a fresh window — tool waits and idle no longer decay the average. Co-Authored-By: Claude Fable 5 --- sidecar/static/shell.html | 84 ++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 18 deletions(-) diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index df6c68f419d..df1e47ee403 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 → brief blue flash. */ + .titlebar .hud .stat.flash { + border-color: #8ab4f8; + box-shadow: 0 0 5px rgba(138, 180, 248, 0.45); + transition: none; } .titlebar .hud .v { color: var(--text-dim); @@ -99,12 +110,17 @@ 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(--accent); - animation: blink-soft 1s ease-in-out infinite; + color: var(--ok); + animation: blink-soft 1.2s ease-in-out infinite; } .usage-badge { color: var(--text-faint); @@ -591,7 +607,7 @@
-
hyperia · shell
+
hyperia shell
t/s max0
@@ -601,6 +617,7 @@
tools0
turns0
reset
+
@@ -608,7 +625,6 @@
sidecar
level
model
-
config
ferricula
@@ -658,23 +674,42 @@ // 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 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, for the blue flash +function setStat(id, text) { + const el = $(id); + if (!el) return; + if (flashPrev[id] !== undefined && flashPrev[id] !== text) { + const badge = el.parentElement; + badge.classList.add('flash'); + clearTimeout(badge._flashT); + badge._flashT = setTimeout(() => badge.classList.remove('flash'), 450); + } + flashPrev[id] = text; + el.textContent = text; +} function updateHud() { - // Average decode speed = all generated tokens / all generation time, - // folding the in-flight turn in live so it visibly settles as it streams. + // 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) { liveTok = genTokens; liveSecs = (performance.now() - genStartMs) / 1000; } + if (genStartMs && lastDeltaMs > genStartMs) { + liveTok = genTokens; + liveSecs = (lastDeltaMs - genStartMs) / 1000; + } const totSecs = cumSecs + liveSecs; const avg = totSecs > 0.25 ? (cumTokens + liveTok) / totSecs : 0; - $('hud-tps-max').textContent = maxTps ? maxTps.toFixed(1) : '0'; - $('hud-tps-avg').textContent = avg ? avg.toFixed(1) : '0'; - $('hud-in').textContent = STATS.in.toLocaleString(); - $('hud-out').textContent = STATS.out.toLocaleString(); - $('hud-tools').textContent = STATS.tools; - $('hud-turns').textContent = STATS.turns; + 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 @@ -683,9 +718,19 @@ // 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); - if (!genStartMs) { genStartMs = performance.now(); genTokens = 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; - const secs = (performance.now() - genStartMs) / 1000; + lastDeltaMs = now; + const secs = (now - genStartMs) / 1000; if (secs > 0.25) { lastTps = genTokens / secs; if (lastTps > maxTps) maxTps = lastTps; @@ -908,9 +953,10 @@ } function endAgentStream() { if (streamingRow) { - // Fold this turn's decode into the running average before the clock stops. - if (genStartMs) { - const secs = (performance.now() - genStartMs) / 1000; + // 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; } @@ -2202,6 +2248,8 @@ cumTokens = 0; cumSecs = 0; lastTps = 0; + genStartMs = 0; + lastDeltaMs = 0; updateHud(); }); From 92e533c3047ac483114cc0a285d23c1ed1624cd0 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 16:22:24 -0500 Subject: [PATCH 40/63] feat: shell HUD rework + cheap/fast provider defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shell: HYPERIA SHELL title, gear-to-config by reset, badge metrics with blue update-flash, green-only model lights, activity-based t/s avg (live window ends at the last token delta; >3s gaps fold the burst — tool waits no longer decay the average). Defaults (models.rs): each provider now defaults to its top cheap/fast model per user policy — claude-haiku-4-5, gpt-5-mini, gemini-3-flash-preview (pulled from the live catalog). Picker still offers the full lists. Co-Authored-By: Claude Fable 5 --- sidecar/src/models.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/sidecar/src/models.rs b/sidecar/src/models.rs index f9e040ba91d..3e3fb159462 100644 --- a/sidecar/src/models.rs +++ b/sidecar/src/models.rs @@ -19,11 +19,13 @@ /// 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-sonnet-5", - "openai" => "gpt-4o", - "gemini" => "gemini-2.0-flash", + "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 From 5eff5b33c0d8c9b6a841a033f7e22ad383d19c97 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 16:26:04 -0500 Subject: [PATCH 41/63] feat: shell HUD rework, cheap/fast defaults, clickable config.agent -> code sticky - Shell: HYPERIA SHELL title, gear-to-config by reset, badge metrics with blue update-flash, green-only lights, activity-based t/s avg. - Defaults: top cheap/fast per provider - claude-haiku-4-5, gpt-5-mini, gemini-3-flash-preview (from the live catalog). - Config page: "config.agent" is now a link that opens the raw config file in a code-mode sticky (syntax-highlighted, editable, bound to the file); get_agent_config serves config_path for it. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/api.rs | 3 +++ sidecar/static/agent-config.html | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index b7fee29d6e9..b437be435f8 100644 --- a/sidecar/src/ghost/api.rs +++ b/sidecar/src/ghost/api.rs @@ -790,6 +790,9 @@ pub async fn get_agent_config() -> Json { }, // Per-service settings (ports etc.) — config.agent.services.. "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(""), diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index 236e874c231..f834aaac074 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -52,7 +52,7 @@

👻 Hyperia Agent

-
This page configures the model powering Hyperia's built-in agent — the intelligence behind its chat, tools, and automation. Note: 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 config.agent.
+
This page configures the model powering Hyperia's built-in agent — the intelligence behind its chat, tools, and automation. Note: 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 config.agent.

Provider

@@ -315,6 +315,15 @@

Services

renderMaximus(); } +// 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() { + const p = cfg && cfg.config_path; + if (!p) return; + await fetch('/api/notes', {method:'POST', headers:{'content-type':'application/json'}, + body: JSON.stringify({file_path: p, color: 'code:dark', width: 800, height: 600})}); +} + // ── Token Maximus: local downsizer model + enable toggle ────────────────── function renderMaximus() { const mx = (cfg && cfg.maximus) || {}; From 6d7db78e9b00825d98002c3549119b9566daf8cd Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Thu, 2 Jul 2026 23:39:33 -0500 Subject: [PATCH 42/63] fix(stickys): resizing a note no longer rewrites the global default size One huge resize poisoned defaults.json (1313x1133) so every new sticky opened huge. Resize persists per-note geometry only; the default stays stable (reset to 480x420 on disk). saveStickyDefaultSize kept for future explicit 'set as default' UI. Co-Authored-By: Claude Fable 5 --- app/sticky.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/sticky.ts b/app/sticky.ts index e6ab2b3dc12..9f9062d047b 100644 --- a/app/sticky.ts +++ b/app/sticky.ts @@ -865,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); From 160ae7d9fc2f86a04e6263173e8b82413a34d899 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 02:51:38 -0500 Subject: [PATCH 43/63] feat(config): API key [set] tag + in-box remove button + Open config button - When a provider has a stored key: heading shows "API key [set]" and a remove button sits INSIDE the entry box on the left (sends the "-" clear sentinel, refreshes the keycheck dot). Placeholder flips to "key is set - paste to replace". - Bottom button row gains "Open config" - opens the raw config file in a syntax-highlighted, editable code sticky (same as the config.agent link). Co-Authored-By: Claude Fable 5 --- sidecar/static/agent-config.html | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index f834aaac074..63168532541 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -63,9 +63,12 @@

Model

-

API key

- -
Stored in the Hyperia config file for now — plaintext until the OS-keystore work (#130) lands. Leave blank to keep the saved key. Enter “-” to clear it.
+

API key

+
+ + +
+
Stored in the Hyperia config file for now — plaintext until the OS-keystore work (#130) lands. Paste to replace; leave blank to keep the saved key.

Token Maximus

@@ -87,6 +90,7 @@

Services

+
@@ -154,6 +158,7 @@

Services

const p = PROVIDERS.find(x => x.id === sel); $('burnWarn').style.display = p && p.key ? '' : 'none'; $('keyBlock').style.display = p && p.key ? '' : 'none'; + renderKeyState(); } function renderModels() { @@ -315,6 +320,26 @@

Services

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.style.paddingLeft = has ? '74px' : ''; + 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() { From 8b1bd0b9c5ce7d9525c17cc2cf9c7d67a0786528 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 02:57:18 -0500 Subject: [PATCH 44/63] feat(config): key [set] + in-box remove, Open config button, terse save, no unconfigure - Stored key: "API key [set]" heading, remove button INSIDE the entry box (flex-row box: wrapper carries border/bg, button + borderless input are children - cannot drift outside). - "Open config" button opens the raw config in an editable code sticky. - Save status is just "saved". - Unconfigure removed everywhere: Hyperia is ALWAYS in the agent list and always configurable. Empty provider in POST /api/agent/config is ignored. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/api.rs | 12 +++++------- sidecar/static/agent-config.html | 15 ++++----------- 2 files changed, 9 insertions(+), 18 deletions(-) diff --git a/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index b437be435f8..23517cce078 100644 --- a/sidecar/src/ghost/api.rs +++ b/sidecar/src/ghost/api.rs @@ -804,8 +804,8 @@ pub async fn get_agent_config() -> Json { /// 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, ) -> Result, (StatusCode, Json)> { @@ -819,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()); } } diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index 63168532541..913399553ce 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -64,9 +64,9 @@

Model

API key

-
- - +
+ +
Stored in the Hyperia config file for now — plaintext until the OS-keystore work (#130) lands. Paste to replace; leave blank to keep the saved key.
@@ -89,7 +89,6 @@

Services

- @@ -328,7 +327,6 @@

Services

if (!tag || !btn || !inp) return; tag.style.display = has ? '' : 'none'; btn.style.display = has ? '' : 'none'; - inp.style.paddingLeft = has ? '74px' : ''; inp.placeholder = has ? 'key is set — paste to replace' : 'paste your API key (stored locally)'; } $('keyRemove').onclick = async () => { @@ -384,15 +382,10 @@

Services

// Token Maximus settings ride the same save. body.maximus = {disabled: !$('mxEnabled').checked, model: $('mxModel').value || ''}; 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(); From a05fe642579c3512521035e9cd01e4726f802b8c Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 04:06:41 -0500 Subject: [PATCH 45/63] feat(config): edit-config sticky endpoint, services cleanup, ollama port under model; v0.15.12 - "Edit config" opens the config file in a syntax-highlighted code sticky via a dedicated system-side endpoint (POST /api/agent/config/edit) - the page fetch is anonymous so the general /api/notes create was silently identity-blocked. Fixed action: can only open THE config file. - Button row: Back (left), Save, Edit config. - Services: ollama row removed (its port now lives under the Model select when ollama is the provider, saved with Save); real default ports for shivvr (8085), grub (6792), transcription (8765); nuts.services note is now "WIP - allows use of nuts.services serverless services. Coming soon." - Version bump 0.15.11 -> 0.15.12 for the local installer build. Co-Authored-By: Claude Fable 5 --- .../rust-sidecar-engineer/MEMORY.md | 3 ++ .../build_exe_lock_workaround.md | 13 +++++++ app/package.json | 2 +- package.json | 2 +- sidecar/Cargo.lock | 2 +- sidecar/Cargo.toml | 2 +- sidecar/src/ghost/api.rs | 2 +- sidecar/src/main.rs | 22 +++++++++++ sidecar/static/agent-config.html | 39 +++++++++++++------ 9 files changed, 71 insertions(+), 16 deletions(-) create mode 100644 .claude/agent-memory/rust-sidecar-engineer/MEMORY.md create mode 100644 .claude/agent-memory/rust-sidecar-engineer/build_exe_lock_workaround.md 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/package.json b/app/package.json index c73fe3cc204..dee97e37ba0 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.12", "license": "BSD-2-Clause", "author": { "name": "Deep Blue Dynamics", diff --git a/package.json b/package.json index 450b2ed3251..2808e7b2e13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hyperia", - "version": "0.15.11", + "version": "0.15.12", "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", diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock index a0ba76631c7..122bc6987df 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.12" dependencies = [ "aegis-edit", "anyhow", diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index aab9afa8a04..35b210ed09a 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyperia-sidecar" -version = "0.15.11" +version = "0.15.12" edition = "2021" description = "Rust sidecar for Hyperia: agent engine, MCP, signaling" diff --git a/sidecar/src/ghost/api.rs b/sidecar/src/ghost/api.rs index 23517cce078..cdc2d36a1f8 100644 --- a/sidecar/src/ghost/api.rs +++ b/sidecar/src/ghost/api.rs @@ -615,7 +615,7 @@ pub async fn ghost_wipe_config( } } -fn config_raw_path() -> Option { +pub fn config_raw_path() -> Option { super::config_path() } diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index dd418ab43f2..212b23fb036 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -2329,6 +2329,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) -> (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, headers: HeaderMap, @@ -3002,6 +3023,7 @@ async fn main() -> anyhow::Result<()> { "/api/agent/config", axum::routing::get(ghost::api::get_agent_config).post(ghost::api::post_agent_config) ) + .route("/api/agent/config/edit", axum::routing::post(post_open_config_sticky)) .route("/api/agent/models", axum::routing::get(ghost::api::get_agent_models)) .route("/api/agent/services", axum::routing::get(ghost::api::get_agent_services)) .route("/api/agent/keycheck", axum::routing::get(ghost::api::get_agent_keycheck)) diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index 913399553ce..f9292551e5f 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -61,6 +61,10 @@

Provider

Model

+

API key

@@ -88,9 +92,9 @@

Token Maximus

Services

- - + +
@@ -158,6 +162,15 @@

Services

$('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() { @@ -197,11 +210,10 @@

Services

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') @@ -250,7 +262,7 @@

Services

panel.innerHTML = `
` + `
` + - `
Opens the nuts.services login in your system browser (magic link). Google/GitHub sign-in hidden when opened from Hyperia.
`; + `
WIP — allows use of nuts.services serverless services. Coming soon.
`; } else { panel.innerHTML = `
Port ` + @@ -341,10 +353,10 @@

Services

// 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() { - const p = cfg && cfg.config_path; - if (!p) return; - await fetch('/api/notes', {method:'POST', headers:{'content-type':'application/json'}, - body: JSON.stringify({file_path: p, color: 'code:dark', width: 800, height: 600})}); + // 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 ────────────────── @@ -381,6 +393,11 @@

Services

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 ✓' : ('error: ' + r.error); $('apiKey').value = ''; From 77bdee7178bc24b56b0fad411e90a3517f1254e8 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 04:13:18 -0500 Subject: [PATCH 46/63] fix(main): register /api/agent/config/edit on the AppState router; v0.15.13 The route was registered in the GhostState section (E0308) so the 0.15.12 installer shipped WITHOUT the new sidecar - Edit config would 404 there. Moved next to /api/notes (AppState). Bumped to 0.15.13; 0.15.12 exe on disk is orphaned/do-not-ship. Co-Authored-By: Claude Fable 5 --- app/package.json | 2 +- package.json | 2 +- sidecar/Cargo.lock | 2 +- sidecar/Cargo.toml | 2 +- sidecar/src/main.rs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/package.json b/app/package.json index dee97e37ba0..658fbc5b688 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.12", + "version": "0.15.13", "license": "BSD-2-Clause", "author": { "name": "Deep Blue Dynamics", diff --git a/package.json b/package.json index 2808e7b2e13..adb91433f8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hyperia", - "version": "0.15.12", + "version": "0.15.13", "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", diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock index 122bc6987df..6c8db231ebd 100644 --- a/sidecar/Cargo.lock +++ b/sidecar/Cargo.lock @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "hyperia-sidecar" -version = "0.15.12" +version = "0.15.13" dependencies = [ "aegis-edit", "anyhow", diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index 35b210ed09a..2e200446fcb 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyperia-sidecar" -version = "0.15.12" +version = "0.15.13" edition = "2021" description = "Rust sidecar for Hyperia: agent engine, MCP, signaling" diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 212b23fb036..707aacb7ba8 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -2956,6 +2956,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)) @@ -3023,7 +3024,6 @@ async fn main() -> anyhow::Result<()> { "/api/agent/config", axum::routing::get(ghost::api::get_agent_config).post(ghost::api::post_agent_config) ) - .route("/api/agent/config/edit", axum::routing::post(post_open_config_sticky)) .route("/api/agent/models", axum::routing::get(ghost::api::get_agent_models)) .route("/api/agent/services", axum::routing::get(ghost::api::get_agent_services)) .route("/api/agent/keycheck", axum::routing::get(ghost::api::get_agent_keycheck)) From e4de95d93f6fa91c3ff87d7562650acbbbab643c Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 04:15:29 -0500 Subject: [PATCH 47/63] fix(omnibox): bare words search DuckDuckGo instead of https:// toNavigableUrl treated ANY whitespace-free token as a host, so 'hello' navigated to https://hello. Host rule now requires a dotted host or an explicit host:port; bare words fall through to search. The open/split web-pane rpc handlers also route through toNavigableUrl instead of naively prefixing https:// (they bypassed the omnibox logic entirely). Unresolvable dotted hosts still hit the web pane's did-fail-load -> DDG fallback. Co-Authored-By: Claude Fable 5 --- lib/index.tsx | 5 +++-- lib/utils/navigable-url.ts | 11 ++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/lib/index.tsx b/lib/index.tsx index 505cd25d15c..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,7 +595,7 @@ 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); } } @@ -617,7 +618,7 @@ const isHyperiaShellUrl = (u: string): boolean => { 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(); 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); } From 9d72f8b5a2abcfe5511507f08787e8714a6bdb29 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 04:17:09 -0500 Subject: [PATCH 48/63] feat(menus): 'Picker' on the web-pane band menu; 'Switch Shell' renamed to 'Picker' Co-Authored-By: Claude Fable 5 --- lib/components/term.tsx | 2 +- lib/components/web-pane.tsx | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/components/term.tsx b/lib/components/term.tsx index 08011dbcf7d..40d32551540 100644 --- a/lib/components/term.tsx +++ b/lib/components/term.tsx @@ -401,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 diff --git a/lib/components/web-pane.tsx b/lib/components/web-pane.tsx index 4481b69094c..1897f393e7a 100644 --- a/lib/components/web-pane.tsx +++ b/lib/components/web-pane.tsx @@ -1757,6 +1757,24 @@ class WebPane_ extends React.PureComponent { 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', From 6f0f6552b5577fdd242a196eb05a64fda7b826e3 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 04:23:52 -0500 Subject: [PATCH 49/63] chore: v0.15.14 installer (omnibox DDG fallback + Picker menus included) Co-Authored-By: Claude Fable 5 --- app/package.json | 2 +- package.json | 2 +- sidecar/Cargo.lock | 2 +- sidecar/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/package.json b/app/package.json index 658fbc5b688..34f0a8517cf 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.13", + "version": "0.15.14", "license": "BSD-2-Clause", "author": { "name": "Deep Blue Dynamics", diff --git a/package.json b/package.json index adb91433f8e..ebdabb9f594 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hyperia", - "version": "0.15.13", + "version": "0.15.14", "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", diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock index 6c8db231ebd..0912d05fd0a 100644 --- a/sidecar/Cargo.lock +++ b/sidecar/Cargo.lock @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "hyperia-sidecar" -version = "0.15.13" +version = "0.15.14" dependencies = [ "aegis-edit", "anyhow", diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index 2e200446fcb..8027f263bee 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyperia-sidecar" -version = "0.15.13" +version = "0.15.14" edition = "2021" description = "Rust sidecar for Hyperia: agent engine, MCP, signaling" From d4ad2ff6ee14138ac4490ce1bbaac8f0fd08f3ee Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 11:17:45 -0500 Subject: [PATCH 50/63] fix(config): remove button right-justified inside the key box, no wrap Input first (flex:1, min-width:0 so it shrinks instead of pushing), button last with white-space:nowrap and flex:0 0 auto - pinned to the right edge, never wraps. Co-Authored-By: Claude Fable 5 --- sidecar/static/agent-config.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index f9292551e5f..9d142b7524d 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -68,9 +68,9 @@

Model

API key

-
- - +
+ +
Stored in the Hyperia config file for now — plaintext until the OS-keystore work (#130) lands. Paste to replace; leave blank to keep the saved key.
From e04767149750007c10e372c548faf12173a643cd Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 11:19:30 -0500 Subject: [PATCH 51/63] fix(config): square the select's bottom corners while its dropdown is open The native dropdown list is unstylable and square; the select box kept its 8px rounding while open, so the two shapes clashed. A tiny open/close class toggle (mousedown/change/blur/Esc/Enter) zeroes the bottom radii only while the list is showing. Co-Authored-By: Claude Fable 5 --- sidecar/static/agent-config.html | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index 9d142b7524d..e67628c816d 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,"); @@ -404,6 +409,16 @@

Services

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(); }); +} From b372149b832f1d438d5b8a67bbb427f2f9994d76 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 11:48:06 -0500 Subject: [PATCH 52/63] docs(plan): per-pane multi-agent code editor spec (planner input) Co-Authored-By: Claude Fable 5 --- plan/pane-editor-spec.md | 121 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 plan/pane-editor-spec.md 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. From 9eeabac512b122ab26b5de7ba0962783be2899dd Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 11:57:17 -0500 Subject: [PATCH 53/63] =?UTF-8?q?feat(doors):=20contract=20forbids=20askin?= =?UTF-8?q?g=20the=20human=20to=20open=20doors=20=E2=80=94=20act=20in-turn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live transcript: the agent said 'Open the terminal tool door for me and I'll split it' — treating doors as human-gated, then self-diagnosed exactly the right fix. Contract now states: doors are the AGENT'S to open (never ask, never request permission — consent lives at the action layer); capability- seeming-missing = tool_search -> open_tools -> ACT in the same turn, report missing only after both fail; request-to-door routing hints (layout->terminal, pages->web, notes->stickys); act first, commentary after. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/agent.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index c7a94a2ffae..2b6d4c3c093 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -128,8 +128,11 @@ Your tool list is NOT the whole catalog — it is a small always-on core plus an - tool_search(query) searches the FULL catalog (open or closed) and tells you which door each tool is behind. Rules: -- If a capability seems missing, DON'T assume it doesn't exist — search first, then open the right door. +- 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)] From 3579a9c676d15e0f6bb0f0df82706f386561f66e Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 11:59:52 -0500 Subject: [PATCH 54/63] =?UTF-8?q?fix(shell):=20HUD=20badge=20flash=20?= =?UTF-8?q?=E2=80=94=20subtle=20yellow,=20throttled=20(no=20more=20strobin?= =?UTF-8?q?g)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During a streaming turn every token changed out/t-s, and each change re-triggered the 450ms blue glow — the whole HUD strobed. Flash is now a faint yellow border only (no box-shadow), throttled to at most one flash per badge per 1.5s. Co-Authored-By: Claude Fable 5 --- sidecar/static/shell.html | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index df1e47ee403..3e20a9dc341 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -72,10 +72,10 @@ background: var(--bg-soft); transition: border-color 0.5s ease, box-shadow 0.5s ease; } - /* Metric badge just updated → brief blue flash. */ + /* 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: #8ab4f8; - box-shadow: 0 0 5px rgba(138, 180, 248, 0.45); + border-color: rgba(232, 194, 104, 0.55); transition: none; } .titlebar .hud .v { @@ -680,15 +680,21 @@ 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, for the blue flash +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 badge = el.parentElement; - badge.classList.add('flash'); - clearTimeout(badge._flashT); - badge._flashT = setTimeout(() => badge.classList.remove('flash'), 450); + 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; From 0a678b086317a3e66922c252ca7131f6ea6a93cc Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 12:12:07 -0500 Subject: [PATCH 55/63] feat(ghost): full-send rules + web-pane routing + CR submit; plan agent-attributed shell turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt fixes from live transcript failures (gpt-5-codex balking): - FULL SEND default: never ask permission for routine actions (the consent layer prompts the human itself — asking first is double-asking); confirm only destructive/irreversible ops. Finish the WHOLE request before replying. - Show pages with open_web_pane, never start/system browser (HN went to Chrome instead of a web pane). - Typing into TUI panes: submit is a separate \r — trailing \n does not submit in Ink (agent typed into claude pane and never pressed Enter). plan/agent-to-agent-shell.md: design for a second, agent-attributed entry line in the shell when an external agent talks to the ghost (broadcast bus + /api/ghost/events SSE fan-out + typing animation). Plan only. Co-Authored-By: Claude Fable 5 --- plan/agent-to-agent-shell.md | 40 ++++++++++++++++++++++++++++++++++++ sidecar/src/ghost/agent.rs | 6 +++++- 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 plan/agent-to-agent-shell.md 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/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index 2b6d4c3c093..c80a4e63c73 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -33,9 +33,13 @@ 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 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 `/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 "\r" (carriage return). A trailing "\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. From 9490d75d7388fc29288fb7678a8da3feec9726de Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 12:40:09 -0500 Subject: [PATCH 56/63] fix(ghost): escape CR/LF mentions in SYSTEM_PROMPT (unescaped quotes broke the literal) Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/agent.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index c80a4e63c73..a4b1957d1f5 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -39,7 +39,7 @@ Never call a tool name that wasn't in your tool definitions for this turn. - 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. - To SHOW a web page, use open_web_pane (web door) — NEVER `start `/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 "\r" (carriage return). A trailing "\n" often does NOT submit in TUIs. +- 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. From d7073810409b54f39af2f9ca52cc78a5796da614 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 12:42:39 -0500 Subject: [PATCH 57/63] chore: v0.15.15 version bump (installer shipped) Co-Authored-By: Claude Fable 5 --- app/package.json | 2 +- package.json | 2 +- sidecar/Cargo.lock | 2 +- sidecar/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/package.json b/app/package.json index 34f0a8517cf..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.14", + "version": "0.15.15", "license": "BSD-2-Clause", "author": { "name": "Deep Blue Dynamics", diff --git a/package.json b/package.json index ebdabb9f594..3b9eaf2c08f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hyperia", - "version": "0.15.14", + "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", diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock index 0912d05fd0a..0bb37a642f5 100644 --- a/sidecar/Cargo.lock +++ b/sidecar/Cargo.lock @@ -896,7 +896,7 @@ dependencies = [ [[package]] name = "hyperia-sidecar" -version = "0.15.14" +version = "0.15.15" dependencies = [ "aegis-edit", "anyhow", diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index 8027f263bee..5b2a15ef54f 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyperia-sidecar" -version = "0.15.14" +version = "0.15.15" edition = "2021" description = "Rust sidecar for Hyperia: agent engine, MCP, signaling" From 87db76b9589edf45297f9fda5c1cb57c874dd892 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 13:21:57 -0500 Subject: [PATCH 58/63] fix(shell+config): anti-fabrication rule, TPS labels + OpenAI TPS, HUD icons, remove-button vertical fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prompt: NEVER claim done without the tool result this conversation — repro showed gpt-5.3-codex calling terminal_status then replying 'Done.' with zero split; if the tool isn't live, next action MUST be open_tools/search. - HUD: t/s -> TPS labels; TPS now also measured from API usage deltas per stats event (cloud tool-call turns stream no visible text, so char-count TPS read 0 on codex). statsLastMs declared + reset. - Gear is a bare emoji (no button chrome); reset is a red X with 'reset' hover tooltip. - Config: remove button vertical mis-align root-caused — global button{margin-top:20px} margin box shoved it to the bottom of the flex row; margin:0 + align-self:center inline. Co-Authored-By: Claude Fable 5 --- sidecar/src/ghost/agent.rs | 1 + sidecar/static/agent-config.html | 2 +- sidecar/static/shell.html | 25 +++++++++++++++++++++---- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/sidecar/src/ghost/agent.rs b/sidecar/src/ghost/agent.rs index a4b1957d1f5..b3c63e57fe7 100644 --- a/sidecar/src/ghost/agent.rs +++ b/sidecar/src/ghost/agent.rs @@ -36,6 +36,7 @@ Never call a tool name that wasn't in your tool definitions for this turn. - 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. - To SHOW a web page, use open_web_pane (web door) — NEVER `start `/xdg-open/system browser; those leave Hyperia. diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index e67628c816d..8e09d6390cc 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -75,7 +75,7 @@

Model

API key

- +
Stored in the Hyperia config file for now — plaintext until the OS-keystore work (#130) lands. Paste to replace; leave blank to keep the saved key.
diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index 3e20a9dc341..87cb7687b08 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -610,14 +610,14 @@
hyperia shell
-
t/s max0
-
t/s avg0
+
TPS max0
+
TPS avg0
in0
out0
tools0
turns0
-
reset
-
+ +
@@ -676,6 +676,7 @@ 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 @@ -1530,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; @@ -2256,6 +2272,7 @@ lastTps = 0; genStartMs = 0; lastDeltaMs = 0; + statsLastMs = 0; updateHud(); }); From cb84fc0f67c61128ed508d2a348064c66848b7d4 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 13:25:51 -0500 Subject: [PATCH 59/63] fix(web-pane+config): live-uid resolution (invisible context menu), shell-pane menu trim, full-row service expand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - web-pane-manager: pane adoption re-keys the map but wireWebContents closures captured the ORIGINAL uid — every lookup went stale after a React remount, so the context menu (and zoom keys, find results, nav state, window-open splits) silently died in adopted panes. All handlers now resolve the live uid from the webContents (liveUidOf). This is why the Hyperia Agent pane had NO right-click menu. - The agent-shell pane's menu omits the 'Hyperia Agent' item (it would only focus itself — the renderer already dedupes to one agent tab). - Config services: the whole row header toggles expansion, not just the chevron. Co-Authored-By: Claude Fable 5 --- app/web-pane-manager.ts | 42 +++++++++++++++++++++++--------- sidecar/static/agent-config.html | 4 ++- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/app/web-pane-manager.ts b/app/web-pane-manager.ts index c31b2f97b13..ac6c2b8a2fc 100644 --- a/app/web-pane-manager.ts +++ b/app/web-pane-manager.ts @@ -75,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, @@ -98,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). @@ -114,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}); } }); @@ -121,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[] = []; @@ -172,13 +187,15 @@ 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')} @@ -202,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'}; }); } diff --git a/sidecar/static/agent-config.html b/sidecar/static/agent-config.html index 8e09d6390cc..93e68ca2aba 100644 --- a/sidecar/static/agent-config.html +++ b/sidecar/static/agent-config.html @@ -256,9 +256,11 @@

Services

statusHtml = `not detected+ add`; } } + // The WHOLE header line toggles (data-tog on each piece), not just the + // chevron — buttons/links inside the status area stop propagation. row.innerHTML = `${open?'▾':'▸'}` + - `${d.label}${d.desc?' '+d.desc+'':''}` + + `${d.label}${d.desc?' '+d.desc+'':''}` + `${statusHtml}`; if (open) { const panel = document.createElement('div'); From 31af0eba51b2ea3ca0633cb3bf14e38b4db49003 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 13:25:57 -0500 Subject: [PATCH 60/63] =?UTF-8?q?refactor(web-pane):=20Phase=206=20?= =?UTF-8?q?=E2=80=94=20remove=20the=20legacy=20=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web panes are fully on native WebContentsView (app/web-pane-manager.ts), so the -tag plumbing is dead code: - window.ts: drop the entire did-attach-webview guest handler (session config, remote.enable(guest), guest context menu, guest setWindowOpenHandler with its web-pane-find / web-pane-open-split channels — all reimplemented in the manager and keyed by pane uid, not guest webContents id, which no longer exists) - window.ts: drop webviewTag:true from BrowserWindow webPreferences and the now-unused ipcMain import; remoteEnable(window.webContents) stays (renderer pane/tab menus still use @electron/remote) - web-pane.tsx: remove dead openOAuthExternal/_lastOAuthOpenAt (manager owns OAuth punting via will-navigate/will-redirect + window-open), the never-passed params/wc args + Inspect branch of handleContextMenu (in-page menu incl. Inspect-in-split lives in the manager), and the orphaned webview{} CSS rule; retire the stale "temporarily NOT wired" TODO — zoom keys / OAuth bail / focus ARE wired in main - terms.tsx: drop the dead target.closest('webview') guard - find-bar.tsx: comment accuracy (native view, not ) Live WebContentsView behavior is unchanged: navigation, find, zoom, screenshot, context menu, OAuth hand-off, _blank splits. Co-Authored-By: Claude Fable 5 --- app/ui/window.ts | 194 +++--------------------------------- lib/components/find-bar.tsx | 2 +- lib/components/terms.tsx | 3 +- lib/components/web-pane.tsx | 59 ++--------- 4 files changed, 24 insertions(+), 234 deletions(-) diff --git a/app/ui/window.ts b/app/ui/window.ts index 033131540d5..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'; @@ -52,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'; @@ -92,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) { @@ -161,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}); @@ -194,8 +193,7 @@ export function newWindow( webPreferences: { nodeIntegration: true, navigateOnDragDrop: true, - contextIsolation: false, - webviewTag: true + contextIsolation: false }, ...options_ }; @@ -784,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; 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/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 { _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; searchAbortCtrl: AbortController | null = null; constructor(props: WebPaneProps) { @@ -949,8 +946,8 @@ class WebPane_ extends React.PureComponent { } // Click anywhere outside the dropdown and the URL bar → close it. (Clicks - // INSIDE the guest 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 && @@ -1232,14 +1229,9 @@ class WebPane_ extends React.PureComponent { '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); @@ -1560,16 +1552,6 @@ class WebPane_ extends React.PureComponent { _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 +1681,9 @@ class WebPane_ extends React.PureComponent { .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 +1695,6 @@ class WebPane_ extends React.PureComponent { 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')})); @@ -2365,7 +2331,7 @@ class WebPane_ extends React.PureComponent { )} {/* Click-off backdrop: a click anywhere outside the dropdown (including on - the page area, whose 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. */} @@ -3096,13 +3062,6 @@ class WebPane_ extends React.PureComponent { user-select: none; } - webview { - border: none !important; - outline: none !important; - width: 100%; - height: 100%; - } - .web_fit { position: absolute; top: 0; From 6f04398e713d0ac9237323bcd15ced2e96751930 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 13:35:56 -0500 Subject: [PATCH 61/63] feat(doors): gate external MCP tool surface behind opt-in doors (#129) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4/5 of MCP tool doors, plus the #119 default-targeting fix. Doors Phase 4 — opt-in progressive disclosure for the external MCP surface: - config.agent.mcp_tool_doors ("on"|"off", default OFF) via new doors::resolve_mcp_door_config; HYPERIA_TOOL_DOORS / HYPERIA_TOOL_CAP override. - MCP door state is process-global (rmcp stateless streamable-HTTP rebuilds the handler per request, so per-connection state is impractical — documented). - list_tools filters to core + open doors and appends open_tools/close_tools/ search_tools meta-tools; call_tool intercepts the meta-tools and auto-opens a closed door on a direct tool call (mirrors ghost/agent.rs). Doors off is byte-identical to the pre-doors server. Doors Phase 5 — tools/listChanged: - open/close/auto-open fire peer.notify_tool_list_changed(); capability is advertised in get_info only when doors are on. Verified it is delivered on the stateless HTTP transport (rides the tool call's own SSE response via OneshotTransport). docs/doors.md documents the model, config, meta-tools, auto-open, and the listChanged delivery nuance. Fix #119 — post_split/post_new_tab default targeting: - post_split now resolves the focused window's active tab's active pane even when window/tab/pane are all omitted (was passing a null uid, letting the renderer pick from divergent UI focus). Explicit-but-missing address -> 404; omitted with no sessions -> null (renderer bootstraps first pane). - post_new_tab resolves a target window (focused if omitted) via new Bridge::resolve_window_id and passes windowId; terminal_new_tab gains a window arg. - Unit tests for resolve_window_id and the MCP resolver. Co-Authored-By: Claude Fable 5 --- docs/doors.md | 174 ++++++++++++++++++++ sidecar/src/bridge.rs | 70 ++++++++ sidecar/src/doors.rs | 64 ++++++++ sidecar/src/main.rs | 61 +++++-- sidecar/src/mcp.rs | 373 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 717 insertions(+), 25 deletions(-) create mode 100644 docs/doors.md 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/sidecar/src/bridge.rs b/sidecar/src/bridge.rs index 1c4dc219ec1..befeb4c49c3 100644 --- a/sidecar/src/bridge.rs +++ b/sidecar/src/bridge.rs @@ -1222,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) -> Option { + let focused_window_id = *self.inner.focused_window_id.lock().await; + let sessions = self.inner.sessions.lock().await; + + let mut ids: Vec = 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}; @@ -1849,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 index 19156505dd0..78b57ab4776 100644 --- a/sidecar/src/doors.rs +++ b/sidecar/src/doors.rs @@ -442,6 +442,38 @@ fn resolve_door_config_inner( 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, + cap_override: Option, +) -> 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. // --------------------------------------------------------------------------- @@ -953,6 +985,38 @@ mod tests { 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); diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index 707aacb7ba8..c368278d22b 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -706,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(); @@ -1849,7 +1857,24 @@ async fn post_new_tab( let parsed = serde_json::from_str::(&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; diff --git a/sidecar/src/mcp.rs b/sidecar/src/mcp.rs index da2ed587bde..705867c3fe6 100644 --- a/sidecar/src/mcp.rs +++ b/sidecar/src/mcp.rs @@ -9,6 +9,127 @@ use rmcp::{ 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::(&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 = 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 { + static STATE: OnceLock> = 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> { + 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::>() + .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, /// Shell profile to use for the new tab. If omitted, uses default shell. pub profile: Option, + /// Window ID to open the tab in — the `id` field from terminal_status. Omit + /// to open it in the focused window. + pub window: Option, } #[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,6 +3026,153 @@ 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 { + 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 = 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 { + 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 { + 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 = 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 -- // NOTE: the `#[tool_handler]` macro would auto-generate `call_tool`, `list_tools`, @@ -2911,8 +3185,69 @@ impl ServerHandler for HyperiaMcp { request: rmcp::model::CallToolRequestParams, context: rmcp::service::RequestContext, ) -> Result { + // 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); - self.tool_router.call(tcc).await + 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( @@ -2920,14 +3255,31 @@ impl ServerHandler for HyperiaMcp { _request: Option, _context: rmcp::service::RequestContext, ) -> Result { - let tools = self.tool_router.list_all(); - // Phase 0 instrumentation (no behavior change): measure the full MCP - // tool surface returned on every tools/list — count + serialized bytes. + 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 { @@ -3011,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() } } From 45093a3109e10835b08a08450a502c9c36cc1af9 Mon Sep 17 00:00:00 2001 From: Kord Campbell Date: Fri, 3 Jul 2026 13:39:01 -0500 Subject: [PATCH 62/63] fix(web-pane): dwell-gate the header freeze-swap; bell background tab on agent-shell updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two user-facing fixes to web panes: 1. De-flash: the pane header's onMouseEnter/Leave hid+screenshotted the native WebContentsView on EVERY crossing (freeze-swap), so simply mousing out of a pane — or across it to the tab bar — flashed the page constantly. The swap now fires only after the cursor DWELLS on the header for 350ms (HEADER_HOVER_DWELL_MS), i.e. when the header's DOM tooltips actually need to paint over the page. Transient passes, mouse-out, and focus changes never touch native-view visibility. Real overlays (URL navigator, find bar, tab switch) are unchanged. 2. Background-tab notify for the Hyperia Agent pane: when a /shell web pane's page changes (manager page-title-updated -> web-pane:state) while its tab is NOT active, raise the STANDARD tab bell — the same ui.bellMarkers 🔔/flash a terminal BEL sets (markTabBell), plus the same bell sound rung via a live Term's ringBell() (respects the bell=false config). Marker is keyed by the pane's term-group uid so it can never clobber a session bell; header.ts now counts group-uid markers for web leaves. Cleared when the human activates the tab (mirror of SESSION_SET_ACTIVE) or the pane unmounts. Never steals focus. NOTE: shell.html must tick document.title on completed turns for the signal to fire — reported upstream, page not edited here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- lib/components/web-pane.tsx | 137 +++++++++++++++++++++++++++++++++--- lib/containers/header.ts | 9 ++- 2 files changed, 133 insertions(+), 13 deletions(-) diff --git a/lib/components/web-pane.tsx b/lib/components/web-pane.tsx index f0b0e1e294c..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<WebPaneProps, WebPaneState> { // 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,6 +171,15 @@ class WebPane_ extends React.PureComponent<WebPaneProps, WebPaneState> { _stateHandler: ((e: any, payload: any) => void) | null = null; _foundHandler: ((e: any, payload: any) => void) | null = null; _domReadyHandler: ((e: any, payload: any) => void) | null = null; + // Pending header-hover dwell (freeze-swap only fires after the cursor rests). + _headerHoverTimer: ReturnType<typeof setTimeout> | null = null; + // ── Background-tab notify for the agent shell pane ─────────────────────── + // Last <title> 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) { @@ -1045,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() { @@ -1146,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; @@ -1497,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; @@ -1522,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) => { @@ -1936,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} @@ -3098,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 @@ -3107,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 }; }; @@ -3132,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]; From 7e2f8977edf41ce3df6d6bf6417951bbd0d17a3c Mon Sep 17 00:00:00 2001 From: Kord Campbell <kord@deepbluedynamics.com> Date: Fri, 3 Jul 2026 13:40:49 -0500 Subject: [PATCH 63/63] =?UTF-8?q?feat(shell):=20title=20tick=20per=20compl?= =?UTF-8?q?eted=20turn=20=E2=80=94=20drives=20the=20standard=20tab=20bell/?= =?UTF-8?q?flash=20for=20a=20backgrounded=20agent=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- sidecar/static/shell.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sidecar/static/shell.html b/sidecar/static/shell.html index 87cb7687b08..9207e168740 100644 --- a/sidecar/static/shell.html +++ b/sidecar/static/shell.html @@ -1554,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'));