diff --git a/PENDING.md b/PENDING.md index fe7fbce8..522e114b 100644 --- a/PENDING.md +++ b/PENDING.md @@ -8,7 +8,7 @@ what belongs to whom and coordinate. Naming convention: `YYYY-MM-DD:short-slug` for date-bounded work, or `parallel:` 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://` 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 " 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 diff --git a/bundled-addons/claude-connector/index.js b/bundled-addons/claude-connector/index.js index 4f7a316e..85f33cfe 100644 --- a/bundled-addons/claude-connector/index.js +++ b/bundled-addons/claude-connector/index.js @@ -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" }, diff --git a/bundled-addons/claude-connector/lib/tools.cjs b/bundled-addons/claude-connector/lib/tools.cjs index d9e3d33c..bd5febb8 100644 --- a/bundled-addons/claude-connector/lib/tools.cjs +++ b/bundled-addons/claude-connector/lib/tools.cjs @@ -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); diff --git a/main.js b/main.js index c3a09f03..27803a19 100644 --- a/main.js +++ b/main.js @@ -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) => {