Claude connector: reach add-on sidebar panels (Aegis, Hermes, Pithos, …)

The connector only drove web pages. Add-on sidebar panels — Aegis's
wallet, Hermes's messenger, Pithos's storage panel, anything with
a sidebar-panel capability — were walled off: list_tabs skipped them
and webContents() refused anything that was not kind:"web".

Panels are now first-class tabs with a negative id, kind:"panel", and
an addon/panelId pair. Each open panel is listed next to the real
tabs; a closed one is still listed so a client can see what exists.
screenshot / snapshot / read_page_text / click / type / press_key /
paste_html / paste_text / wait_for / upload_file / evaluate all work
on them with the same TabDriver a web page uses. navigate /
activate_tab / close_tab refuse panels — the user owns panel
visibility, not the connector.

Approval is per add-on under a synthetic theseus-panel://<addon>
origin, so Aegis is approved once and every Aegis panel is reachable
from then on. The approval modal titles itself "Let Claude control
the Aegis panel?" when the origin is a panel origin, and the body
explains the panel is read, not the whole browser profile. Origin
pinning is skipped (the panel is served from the add-on scheme and
never navigates under us); password-field refusal and the action log
still apply, so a PIN input in a wallet panel is never typed into
and every call is in the connector's log.
This commit is contained in:
Hermes Dev 2026-10-29 00:06:51 +02:00
parent 8a08879c49
commit baf86fc5a6
4 changed files with 154 additions and 10 deletions

View file

@ -8,7 +8,7 @@ what belongs to whom and coordinate. Naming convention:
`YYYY-MM-DD:short-slug` for date-bounded work, or `parallel:<theme>` for
work owned by a session that isn't the current one.
Last updated: 2026-10-04.
Last updated: 2026-10-28.
---
@ -16,6 +16,42 @@ Releases are no longer recorded here: the live version and hashes are in
`../site/releases-manifest.json` and `https://dl.silentmode.st/releases-manifest.json`,
and each release commit is `Theseus 0.3.x: ...` in git history.
## Staged for the next release (post-0.3.128)
Queue of asks for the next cut. Items marked LANDED are edited in the
working tree waiting for a bump + build. Items marked TODO still need the
code change.
- **TODO: Home — swap `SilentMode.X` out, `Game.X` in** on the Silent Mode
cards section ([home.html:366-375](home.html) `defaultState().sections.sm`).
Game.X should pick up its own description + favicon from the live site,
so the entry is a stub (`{ title: "Game.X", url: "https://game.x/",
brand: "game" }`) until game.x goes live. SilentMode.X moves to a short
link somewhere else — the user suggested either under the search field,
at the bottom of the home page, or in the quicklinks (sections.q). The
canonical remote list at `https://dl.silentmode.st/home-cards.json`
needs the same swap so existing profiles pick it up without a reset
(local `sections.sm` isn't reseeded by `migrateCards` — the remote cache
is the only way through to installed users). `game.x` isn't resolving
yet (no s3 pointer on-chain, no manifest); wait until the service is up
before baking its description/favicon.
- **LANDED: Claude connector reaches add-on sidebar panels** (Aegis, Hermes,
Pithos, and any other sidebar add-on). The tab-control hook now exposes
each open panel as a tab with a negative id, `kind: "panel"`, `addon`
and `panelId` fields ([main.js](main.js) `panelCtrlEntries` /
`tabControlHook.list`). The connector's `use()` branches to `_usePanel`,
approves control under a synthetic `theseus-panel://<addonId>` origin
(one approval covers every panel owned by that add-on), skips origin
pinning (panels load from the add-on scheme and never change under us)
and drives the panel's `webContents` with the same `TabDriver` as a web
tab. `navigate` / `activate_tab` / `close_tab` refuse panels — the user
owns panel visibility. The approval modal says "Let Claude control the
Aegis panel?" when the origin is a panel origin. list_tabs returns
closed panels too so a client can see what exists; `webContents(-N)`
throws "<addon> is not open; ask the user to open the panel from the
sidebar first" until the user docks it. Password-field refusal and the
action log still apply.
## session: `parallel:collision-policy`
Owner's focus: BCNR ↔ ICANN name collision handling. Not mine — leave for

View file

@ -29,7 +29,8 @@ let stopping = null;
const INSTRUCTIONS = [
"This server controls tabs in Theseus Navigator, the user's own signed-in browser.",
"Start with list_tabs, or open_tab for a new page. Use snapshot to get element refs, then click / type / paste_html / upload_file with those refs.",
"The user approves each site the first time it is controlled; if a call fails because a site was not approved, tell the user rather than retrying.",
"list_tabs also includes any add-on panel the user has opened in the sidebar (Aegis, Hermes, Pithos, …) with kind \"panel\" and an addon field; the same snapshot / click / type tools drive them. navigate / activate_tab / close_tab are not available on panels — the user controls what is open.",
"The user approves each site the first time it is controlled, and each add-on panel is approved per add-on; if a call fails because a site or panel was not approved, tell the user rather than retrying.",
"type sends real input that rich editors (Medium, Substack, Google Docs-style editors) register; for formatted content prefer paste_html.",
"Screenshot pixels are CSS pixels, the same coordinates click(x, y) takes. Password fields are always refused. Files can only be uploaded from folders the user allowed.",
].join(" ");
@ -63,12 +64,16 @@ module.exports = {
setAllowed: (list) => { api.storage.set("allowedOrigins", list); pushState(); },
ask: (origin, ctx) => {
const who = ctx && ctx.client ? ctx.client : "A Claude client";
const host = origin.replace(/^https?:\/\//, "");
log.add({ tool: "approval", origin, client: ctx && ctx.client, args: `asked to ${ctx && ctx.action === "open" ? "open" : "control"} ${host}`, ok: true });
const isPanel = origin.startsWith("theseus-panel://");
const panelName = isPanel ? (ctx && ctx.title ? ctx.title : origin.slice("theseus-panel://".length).replace(/^\w/, (c) => c.toUpperCase())) : "";
const host = isPanel ? panelName : origin.replace(/^https?:\/\//, "");
log.add({ tool: "approval", origin, client: ctx && ctx.client, args: `asked to ${ctx && ctx.action === "open" ? "open" : isPanel ? "drive panel" : "control"} ${host}`, ok: true });
return api.approvalModal({
title: `Let Claude control ${host}?`,
title: isPanel ? `Let Claude control the ${panelName} panel?` : `Let Claude control ${host}?`,
origin,
body: `${who}, connected to Theseus through the Claude connector, wants to open, read and act on pages from ${host} in this profile, where you are signed in: read text, click, type and paste. It never types into password fields and every action is logged in the connector's panel. You can remove the site there at any time.`,
body: isPanel
? `${who}, connected to Theseus through the Claude connector, wants to read and act on the ${panelName} add-on panel inside Theseus: screenshot and read the panel's own page, click, type and paste. It never types into password fields and every action is logged in the connector's panel. You can remove this approval there at any time.`
: `${who}, connected to Theseus through the Claude connector, wants to open, read and act on pages from ${host} in this profile, where you are signed in: read text, click, type and paste. It never types into password fields and every action is logged in the connector's panel. You can remove the site there at any time.`,
actions: [
{ id: "allow", label: "Allow", primary: true },
{ id: "session", label: "Until Theseus restarts" },

View file

@ -10,7 +10,7 @@ const tabIdProp = { tab_id: { type: "integer", description: "Tab id from list_ta
const refProp = { ref: { type: "string", description: "Element ref from the latest snapshot, e.g. \"e12\"." } };
const DEFS = [
{ name: "list_tabs", description: "List the open Theseus tabs (id, title, URL, which one is active). Tabs on sites the user has not approved show only their host.", inputSchema: { type: "object", properties: {} } },
{ name: "list_tabs", description: "List the open Theseus tabs and add-on panels. Each row has id, title and which one is active. Web tabs on sites the user has not approved show only their host. Add-on panels (Aegis, Hermes, Pithos, …) have kind \"panel\", carry addon and panelId, and are driven as a tab once the user opens them from the sidebar and approves the panel in the connector.", inputSchema: { type: "object", properties: {} } },
{ name: "open_tab", description: "Open a URL in a new tab. The user approves each new site the first time.", inputSchema: { type: "object", properties: { url: { type: "string" }, background: { type: "boolean", description: "Open without switching to it (default false)." } }, required: ["url"] } },
{ name: "navigate", description: "Load a URL in a tab, or go back / forward / reload.", inputSchema: { type: "object", properties: { ...tabIdProp, url: { type: "string" }, action: { type: "string", enum: ["back", "forward", "reload"] } } } },
{ name: "activate_tab", description: "Bring a tab to the front.", inputSchema: { type: "object", properties: tabIdProp, required: ["tab_id"] } },
@ -90,9 +90,16 @@ class Controller {
return t;
}
// Approval key for an add-on panel. Each add-on's panels share one key
// (`theseus-panel://aegis`) — approve Aegis once, drive every Aegis panel.
_panelOrigin(tab) {
return `theseus-panel://${String(tab.addon || tab.panelId || "panel").toLowerCase()}`;
}
// The tab, approved and attached. Returns { tab, wc, driver, origin }.
async use(tabId, session) {
const tab = this._tabRecord(tabId);
if (tab.kind === "panel") return this._usePanel(tab, session);
if (tab.kind !== "web") throw new Error(`tab ${tab.id} is not a web page (${tab.kind}); the connector only controls web pages`);
const wc = this.tabControl.webContents(tab.id);
// A dormant tab just started loading: let it reach a URL first.
@ -115,6 +122,28 @@ class Controller {
return { tab, wc, driver, origin };
}
// Add-on panels (Aegis, Hermes, Pithos, …) share two WebContentsViews on
// the host side, one for currently-open left panels and one for the right
// sidebar. The connector drives them with the same CDP toolbox a web tab
// uses, under a synthetic origin so the policy approval prompt names the
// add-on. navigation / origin pinning do not apply — the page is served
// from the add-on scheme and never changes under us.
async _usePanel(tab, session) {
if (!tab.open) throw new Error(`${tab.title || tab.panelId} is not open; ask the user to open the panel from the sidebar first`);
const wc = this.tabControl.webContents(tab.id);
const origin = this._panelOrigin(tab);
await this.policy.ensure(origin, { client: session && session.client, action: "control-panel", addon: tab.addon, panelId: tab.panelId, title: tab.title });
let driver = this.drivers.get(tab.id);
if (!driver || driver.wc !== wc) {
if (driver) await driver.release().catch(() => {});
driver = new TabDriver(tab.id, wc, { onGone: (id) => { this.drivers.delete(id); this.onTabsChanged(); } });
this.drivers.set(tab.id, driver);
this.onTabsChanged();
}
await driver.ready();
return { tab, wc, driver, origin };
}
async waitLoad(wc, timeoutMs = 20000) {
const until = Date.now() + timeoutMs;
await sleep(150);
@ -149,10 +178,14 @@ class Controller {
switch (name) {
case "list_tabs": {
const rows = this.tabControl.list().map((t) => {
const base = { id: t.id, active: t.active, window: t.window, kind: t.kind, controlled: this.drivers.has(t.id), ...(t.sidebar ? { sidebar: t.sidebar } : {}) };
if (t.kind === "panel") {
const approved = this.policy.isAllowed(this._panelOrigin(t));
return { ...base, title: t.title, addon: t.addon, panelId: t.panelId, side: t.side, open: !!t.open, ...(approved ? {} : { approved: false }) };
}
if (t.kind !== "web") return { ...base, title: t.kind === "settings" ? "Settings" : t.title };
const o = originOf(t.url);
const approved = o && this.policy.isAllowed(o);
const base = { id: t.id, active: t.active, window: t.window, kind: t.kind, controlled: this.drivers.has(t.id), ...(t.sidebar ? { sidebar: t.sidebar } : {}) };
if (t.kind !== "web") return { ...base, title: t.kind === "settings" ? "Settings" : t.title };
return approved ? { ...base, title: t.title, url: t.url } : { ...base, host: hostOf(t.url), approved: false };
});
return text(JSON.stringify(rows, null, 1));
@ -169,6 +202,8 @@ class Controller {
return text(JSON.stringify({ tab_id: id, url: wc.getURL(), title: wc.getTitle() }));
}
case "navigate": {
const peek = this._tabRecord(a.tab_id);
if (peek.kind === "panel") throw new Error("navigate is not supported on add-on panels; drive the panel's own controls instead");
const { tab, wc, driver, origin } = await this.use(a.tab_id, session);
note(origin, tab.id);
if (a.action) {
@ -188,12 +223,16 @@ class Controller {
return text(JSON.stringify({ tab_id: tab.id, url: wc.getURL(), title: wc.getTitle() }));
}
case "activate_tab": {
const peek = this._tabRecord(a.tab_id);
if (peek.kind === "panel") throw new Error("add-on panels are not activated by the connector; the user controls what is open in the sidebar");
const { tab, origin } = await this.use(a.tab_id, session);
note(origin, tab.id);
this.tabControl.activate(tab.id);
return text(`tab ${tab.id} is in front`);
}
case "close_tab": {
const peek = this._tabRecord(a.tab_id);
if (peek.kind === "panel") throw new Error("add-on panels are closed by the user, not by the connector");
const { tab, origin } = await this.use(a.tab_id, session);
note(origin, tab.id);
await this.release(tab.id);

66
main.js
View file

@ -3234,6 +3234,59 @@ function sidebarCtrl(id) {
if (!e) throw new Error("the sidebar has no web app open");
return e;
}
// Add-on sidebar panels (Aegis, Hermes, Pithos, …) share two WebContentsViews
// — leftPanel for left-docked panels and `sidebar` for right-docked ones —
// each loaded with whichever panelId is currently active. The connector sees
// each OPEN panel as a tab with a negative id so a client can snapshot /
// screenshot / click inside the panel's own page without touching Settings
// or the add-on scheme. A panel that is not currently loaded is listed with
// id but no url so the client can tell what exists; webContents() throws
// until the user opens it from the sidebar.
const panelCtrlIds = new Map(); // panelId -> negative integer
let panelCtrlNext = -1;
function panelCtrlId(panelId) {
let id = panelCtrlIds.get(panelId);
if (id == null) { id = panelCtrlNext--; panelCtrlIds.set(panelId, id); }
return id;
}
function panelCtrlAddonOf(panelId) { const i = String(panelId).indexOf(":"); return i > 0 ? String(panelId).slice(0, i) : String(panelId); }
function panelCtrlView(panel) {
if (!panel) return null;
if (isLeftPanel(panel)) return (leftPanelId === panel.panelId && leftPanel) ? leftPanel : null;
return (sidebarVisible && sidebarActivePanelId === panel.panelId && sidebar) ? sidebar : null;
}
function panelCtrlEntries() {
if (!addonHost) return [];
const panels = addonHost.getSidebarPanels();
return panels.map((p) => {
const view = panelCtrlView(p);
const open = !!view;
let url = "", title = p.title || p.addonName || p.panelId;
if (open) { try { url = view.webContents.getURL(); title = view.webContents.getTitle() || title; } catch {} }
return {
id: panelCtrlId(p.panelId),
title,
url,
kind: "panel",
panelId: p.panelId,
addon: p.addonId || panelCtrlAddonOf(p.panelId),
side: isLeftPanel(p) ? "left" : "right",
open,
active: open,
pending: false,
window: 0,
};
});
}
function panelCtrl(id) {
const n = Number(id);
if (!Number.isFinite(n) || n >= 0) return null;
if (!addonHost) throw new Error("add-on panels are not available");
const panels = addonHost.getSidebarPanels();
for (const p of panels) if (panelCtrlId(p.panelId) === n) return p;
throw new Error(`no add-on panel ${id}`);
}
function tabControlTab(id) {
const t = tabById(Number(id));
if (!t) throw new Error(`no tab ${id}`);
@ -3248,13 +3301,21 @@ const tabControlHook = {
active: isShownTabId(t.id),
pending: !!t.pending,
window: Math.max(0, windows.indexOf(t.w)),
})), sidebarCtrlEntry()].filter(Boolean),
})), sidebarCtrlEntry(), ...panelCtrlEntries()].filter(Boolean),
webContents: (id) => {
if (sidebarCtrl(id)) {
const wc = quickPanel.webContents;
if (!wc || wc.isDestroyed()) throw new Error("the sidebar is closed");
return wc;
}
const panel = Number(id) < 0 ? panelCtrl(id) : null;
if (panel) {
const view = panelCtrlView(panel);
if (!view) throw new Error(`${panel.addonName || panel.title || panel.panelId} is not open; ask the user to open the panel from the sidebar first`);
const wc = view.webContents;
if (!wc || wc.isDestroyed()) throw new Error(`${panel.panelId} panel view is closed`);
return wc;
}
const t = tabControlTab(id);
if (tabControlKind(t) !== "web") throw new Error("only web pages can be controlled (not Settings, extension or home tabs)");
if (t.pending) withWin(t.w, () => materializePending(t));
@ -3276,6 +3337,7 @@ const tabControlHook = {
quickPanel.webContents.loadURL(url).catch(() => {});
return;
}
if (Number(id) < 0) throw new Error("add-on panels cannot be navigated; drive the open panel's own controls instead");
const t = tabControlTab(id);
if (tabControlKind(t) !== "web") throw new Error("only web pages can be navigated");
if (!/^(?:https?|bns):\/\//i.test(url)) throw new Error("navigate needs an http(s) URL");
@ -3284,6 +3346,7 @@ const tabControlHook = {
},
close: (id, addonId) => {
if (sidebarCtrl(id)) { console.log(`[addons] [${addonId}] close the sidebar app`); closeQuickPanel(); return; }
if (Number(id) < 0) throw new Error("add-on panels cannot be closed by the connector; the user controls panel visibility");
const t = tabControlTab(id);
if (tabControlKind(t) !== "web") throw new Error("only web tabs can be closed");
console.log(`[addons] [${addonId}] close tab ${t.id}`);
@ -3292,6 +3355,7 @@ const tabControlHook = {
activate: (id) => {
const e = sidebarCtrl(id);
if (e) { const q = activeQuickLinkId || lastQuickLinkId || quickLinkFor(e.url)?.id; if (q && !e.active) openQuickPanel(q); return; }
if (Number(id) < 0) throw new Error("add-on panels are not raised by the connector; ask the user to open the panel from the sidebar");
const t = tabControlTab(id); setActive(t.id);
},
setStatus: (st) => {