Startpage layout reverted to the pre-0.3.131 shape. 0.3.131's full-page-
height sidebar was a mistake — slots in a short strip under a hero at
the top, cards and slots sharing one row of panels at the same height,
is what matches the design. .page/.main-col/.sidebar structure dropped,
hero back as a body sibling of .homepage, slots-list back to repeat(3,
72px) with minSlots=9. Two of the user's own tweaks baked into the
defaults: .section.chromeless on the left column so slot icons float
on the page background instead of sitting in a boxed panel, and
defaultState().iconSize = "large" (60 px tiles).
Game.X in the SM cards (replaces SilentMode.X in its slot, stub
description + a built-in gamepad brand mark until game.x goes live).
SilentMode.X moved to the quick-links strip as the 5th slot. moveItem
now strips the pinned-slot anchor when a q item moves to sm — a link
dragged onto the cards used to flip back on the next render because
its slot number was still set.
Nostr NIP-07 signer grows per-site identities (0.1.0 → 0.1.1). First
approval modal picks between the Hermes identity (one npub for DMs
and public notes, the one published as the np record on your BNS
name) and a site-specific HKDF-derived npub (unique per origin, so
Nostr clients can't cross-correlate your activity). The chooser shows
both npubs so the user sees what each button reveals. Grants without
a stored profileId (migrated from 0.3.131) default to the Hermes
identity so existing approvals keep behaving the same.
lib/hermes.js:nostrKeyFromRootForOrigin HKDFs with info
silentmode/nostr-origin/v1/<origin> over the existing 32-byte
messengerRoot. main.js _nostrAPI methods take {profileId, origin};
signer cache per profile.
Silent Mode Mail retired as an extension — now a Sidebar quicklink
(id: silentmail). Its 0.1.0 panel iframed mail.silentmode.st, which
sends X-Frame-Options: DENY; the browser rendered the block page and
STILL fired the iframe's load event, so the 5 s "did it embed?"
watchdog saw it as loaded and the panel stayed black. The whole
extension was just a landing page with an Open Mail button — exactly
what a sidebar quicklink does, without taking a panel slot or an
Extensions row. migrateAegisRename auto-retires any existing
extensions/mail/ to addons-backups/mail-migrated-<stamp>/ on first
boot of this Theseus; "mail" added to the first-party reserved-ids
list so a community add-on can't claim the id.
Claude connector 0.2.1 → 0.2.2: the bearer token no longer rotates on
every server stop. The old stop() generated a fresh token every time
Theseus quit, the panel toggled off, or a 0.3.X install replaced the
app — the Connect button wrote the new token to ~/.claude.json but
any already-running Claude Code or Claude Desktop process held the
previous token in memory and only re-reads config on launch, so /mcp
reconnect kept sending the stale bearer and the server rejected it
forever. Infinite loop: install Theseus upgrade, click Connect, MCP
still 401, close Claude, open Claude, install another upgrade a day
later, repeat. The explicit Revoke token button still rotates for
users who want fresh keys; everything else is persistent.
Sidebar panel apps blank to about:blank after 2 min of being closed to
free their render tree (iframes, media, listeners). Fast reopen within
the window still reuses the loaded page.
Light theme palette flip under body.light — the full set of
--ink / --mut / --card / --line tokens flip plus hero + card
translucent panels switch to light-glass, so hero titles, SM card
text, slot labels and the ".bch" tld read correctly on the light
background preset instead of as pale ghosts.
396 lines
19 KiB
JavaScript
396 lines
19 KiB
JavaScript
// Claude connector for Theseus. A local MCP server (Streamable HTTP on
|
||
// 127.0.0.1, bearer token) through which Claude Code or Claude Desktop on
|
||
// this computer works in the user's tabs: each tab through its own
|
||
// webContents.debugger, never a browser-wide debugging port.
|
||
//
|
||
// The extension ships turned off (Settings › Extensions). Once on, the panel
|
||
// shows the connection command, the sites the user approved, the upload
|
||
// folders and a live log of every action. The toolbar shows a chip while a
|
||
// client is connected; its ✕ stops everything at once.
|
||
//
|
||
// Design notes: DESIGN-claude-connector.md in the Theseus repo.
|
||
|
||
const path = require("node:path");
|
||
const fs = require("node:fs");
|
||
const crypto = require("node:crypto");
|
||
|
||
const { McpServer, pickPort } = require("./lib/mcp-server.cjs");
|
||
const { Controller } = require("./lib/tools.cjs");
|
||
const { SitePolicy } = require("./lib/policy.cjs");
|
||
const { ActionLog } = require("./lib/action-log.cjs");
|
||
const { makeClipboard } = require("./lib/clipboard.cjs");
|
||
const apps = require("./lib/clients.cjs");
|
||
|
||
let server = null;
|
||
let controller = null;
|
||
let statusTimer = null;
|
||
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.",
|
||
"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(" ");
|
||
|
||
module.exports = {
|
||
activate(api) {
|
||
const { clipboard, dialog, BrowserWindow, app } = require("electron");
|
||
const dataDir = path.join(api.dataDir || path.join(api.folder, "..", "..", "extensions-data"), "claude-connector");
|
||
fs.mkdirSync(dataDir, { recursive: true });
|
||
const endpointFile = path.join(dataDir, "endpoint.json");
|
||
|
||
// ---- settings (the add-on's own store) ---------------------------------
|
||
const S = {
|
||
get token() { return api.storage.get("token", null); },
|
||
get accept() { return api.storage.get("accept", true) !== false; },
|
||
get port() { return Number(api.storage.get("port", 0)) || 0; },
|
||
get allowEvaluate() { return api.storage.get("allowEvaluate", false) === true; },
|
||
get allowedOrigins() { const v = api.storage.get("allowedOrigins", []); return Array.isArray(v) ? v.map(String) : []; },
|
||
get uploadDirs() { const v = api.storage.get("uploadDirs", []); return Array.isArray(v) ? v.map(String) : []; },
|
||
// Simultaneous-session cap. 0 = unlimited (default). Values are clamped
|
||
// between 1 and 100 everywhere a UI writes them, so a saved 1 or 5 means
|
||
// the user picked that limit on purpose.
|
||
get sessionLimit() { const v = Number(api.storage.get("sessionLimit", 0)); return Number.isFinite(v) && v > 0 ? Math.floor(v) : 0; },
|
||
};
|
||
if (!S.token) api.storage.set("token", newToken());
|
||
|
||
const log = new ActionLog(dataDir, { onEntry: (e) => api.emit("log-entry", e) });
|
||
|
||
const policy = new SitePolicy({
|
||
getAllowed: () => S.allowedOrigins,
|
||
setAllowed: (list) => { api.storage.set("allowedOrigins", list); pushState(); },
|
||
ask: (origin, ctx) => {
|
||
const who = ctx && ctx.client ? ctx.client : "A Claude client";
|
||
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: isPanel ? `Let Claude control the ${panelName} panel?` : `Let Claude control ${host}?`,
|
||
origin,
|
||
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" },
|
||
{ id: "deny", label: "Don't allow" },
|
||
],
|
||
}).then((pick) => {
|
||
const p = String(pick || "").split("+")[0];
|
||
log.add({ tool: "approval", origin, client: ctx && ctx.client, args: p === "allow" ? "allowed" : p === "session" ? "allowed until restart" : "refused", ok: p === "allow" || p === "session" });
|
||
return pick;
|
||
});
|
||
},
|
||
});
|
||
|
||
controller = new Controller({
|
||
tabControl: api.tabControl,
|
||
policy,
|
||
log,
|
||
getSettings: () => ({ allowEvaluate: S.allowEvaluate, uploadDirs: S.uploadDirs }),
|
||
clipboard: makeClipboard(require("electron")),
|
||
onTabsChanged: () => pushStatus(),
|
||
onBusy: (on) => { busy = on; if (on) { clearTimeout(busyOff); pushStatus(); } else { clearTimeout(busyOff); busyOff = setTimeout(pushStatus, 600); } },
|
||
});
|
||
let busy = false;
|
||
let busyOff = null;
|
||
|
||
// ---- indicator + panel state ---------------------------------------
|
||
let lastConnected = false;
|
||
function pushStatus() {
|
||
const st = server ? server.status() : { listening: false, connected: false, clients: [] };
|
||
// No client left: let go of every tab (and its markers).
|
||
if (lastConnected && !st.connected && controller.drivers.size) controller.releaseAll();
|
||
lastConnected = st.connected;
|
||
try {
|
||
api.tabControl.setStatus({ connected: st.connected, busy: busy && st.connected, clients: st.clients, tabIds: st.connected ? controller.controlledTabIds() : [] });
|
||
} catch (e) { api.log("indicator update failed:", e.message); }
|
||
api.emit("status", publicState());
|
||
}
|
||
function pushState() { api.emit("status", publicState()); }
|
||
|
||
function publicState() {
|
||
const st = server ? server.status() : { listening: false, port: null, connected: false, clients: [], sessions: [] };
|
||
const url = server && server.url;
|
||
// Panel approvals (theseus-panel://<addonId>) and site approvals share
|
||
// the allowedOrigins list on disk. Split them for the UI so each gets
|
||
// its own section, and enrich panel rows with the add-on's own title.
|
||
const PANEL_PREFIX = "theseus-panel://";
|
||
const panelTitles = new Map();
|
||
try {
|
||
for (const t of api.tabControl.list()) {
|
||
if (t && t.kind === "panel" && t.addon) panelTitles.set(String(t.addon).toLowerCase(), t.title || t.addon);
|
||
}
|
||
} catch {}
|
||
const allowedPanels = [];
|
||
const allowedSites = [];
|
||
for (const o of S.allowedOrigins) {
|
||
if (typeof o === "string" && o.startsWith(PANEL_PREFIX)) {
|
||
const addon = o.slice(PANEL_PREFIX.length);
|
||
allowedPanels.push({ origin: o, addon, title: panelTitles.get(addon.toLowerCase()) || addon.replace(/^\w/, (c) => c.toUpperCase()) });
|
||
} else {
|
||
allowedSites.push(o);
|
||
}
|
||
}
|
||
return {
|
||
enabled: true,
|
||
accept: S.accept,
|
||
listening: st.listening,
|
||
url,
|
||
port: st.port || S.port || null,
|
||
connected: st.connected,
|
||
clients: st.clients,
|
||
sessions: st.sessions || [],
|
||
controlledTabs: controller.controlledTabIds(),
|
||
allowEvaluate: S.allowEvaluate,
|
||
allowedOrigins: S.allowedOrigins,
|
||
allowedSites,
|
||
allowedPanels,
|
||
uploadDirs: S.uploadDirs,
|
||
sessionLimit: S.sessionLimit,
|
||
token: S.token,
|
||
commands: url ? commandsFor(url, S.token) : null,
|
||
apps: appStatus,
|
||
};
|
||
}
|
||
|
||
// ---- Claude apps on this computer -------------------------------------
|
||
// "Connect" registers the connector with Claude Code / Claude Desktop in
|
||
// their own configs (lib/clients.cjs). The apps the user connected this
|
||
// way are remembered and kept current: the token changes on every stop
|
||
// and on Revoke, and the port can change, so after each start the
|
||
// entries are rewritten when they no longer match.
|
||
const APPS = { claudeCode: apps.claudeCode, claudeDesktop: apps.claudeDesktop };
|
||
const linked = () => ({ claudeCode: false, claudeDesktop: false, ...(api.storage.get("linkedApps", {}) || {}) });
|
||
let appStatus = null;
|
||
function appParams() {
|
||
return { url: server && server.url, token: S.token, exe: process.execPath, shim: path.join(api.folder, "stdio-shim.cjs") };
|
||
}
|
||
function refreshApps() {
|
||
const params = appParams();
|
||
const l = linked();
|
||
const out = {};
|
||
for (const [id, impl] of Object.entries(APPS)) {
|
||
try { out[id] = { ...impl.status(params), linked: !!l[id] }; }
|
||
catch (e) { out[id] = { installed: false, configured: false, current: false, linked: !!l[id], error: e.message }; }
|
||
}
|
||
appStatus = out;
|
||
return out;
|
||
}
|
||
async function connectApp(id) {
|
||
const impl = APPS[id];
|
||
if (!impl) throw new Error(`unknown app ${id}`);
|
||
if (!server) throw new Error("turn on Accept connections first");
|
||
const r = await impl.register(appParams());
|
||
api.storage.set("linkedApps", { ...linked(), [id]: true });
|
||
log.add({ tool: "connector", args: `registered with ${id === "claudeCode" ? "Claude Code" : "Claude Desktop"}`, ok: true });
|
||
return r;
|
||
}
|
||
async function syncApps() {
|
||
if (!server) return;
|
||
const params = appParams();
|
||
for (const [id, on] of Object.entries(linked())) {
|
||
if (!on || !APPS[id]) continue;
|
||
let st;
|
||
try { st = APPS[id].status(params); } catch { continue; }
|
||
if (!st.installed || st.current) continue;
|
||
try {
|
||
await APPS[id].register(params);
|
||
log.add({ tool: "connector", args: `${id === "claudeCode" ? "Claude Code" : "Claude Desktop"} updated to the current address and token`, ok: true });
|
||
} catch (e) {
|
||
log.add({ tool: "connector", args: `could not update ${id}`, ok: false, error: e.message });
|
||
}
|
||
}
|
||
refreshApps();
|
||
pushState();
|
||
}
|
||
|
||
function commandsFor(url, token) {
|
||
const exe = process.execPath;
|
||
const shim = path.join(api.folder, "stdio-shim.cjs");
|
||
return {
|
||
claudeCode: `claude mcp add --scope user --transport http theseus ${url} --header "Authorization: Bearer ${token}"`,
|
||
claudeDesktop: JSON.stringify({ mcpServers: { theseus: { command: exe, args: [shim], env: { ELECTRON_RUN_AS_NODE: "1", THESEUS_MCP_URL: url, THESEUS_MCP_TOKEN: token } } } }, null, 2),
|
||
};
|
||
}
|
||
|
||
// ---- server lifecycle ------------------------------------------------
|
||
async function start() {
|
||
if (server) return;
|
||
const port = await pickPort(S.port);
|
||
if (!port) throw new Error("no free local port");
|
||
const s = new McpServer({
|
||
getToken: () => S.token,
|
||
getSessionLimit: () => S.sessionLimit,
|
||
tools: { list: () => controller.definitions(), call: (name, args, session) => controller.call(name, args, session) },
|
||
serverInfo: { name: "theseus", title: "Theseus Navigator", version: readVersion(api.folder) },
|
||
instructions: INSTRUCTIONS,
|
||
onStatus: () => pushStatus(),
|
||
log: (...a) => api.log(...a),
|
||
});
|
||
await s.listen(port);
|
||
server = s;
|
||
if (port !== S.port) api.storage.set("port", port);
|
||
try { fs.writeFileSync(endpointFile, JSON.stringify({ url: s.url, port, pid: process.pid }, null, 2)); } catch {}
|
||
clearInterval(statusTimer);
|
||
statusTimer = setInterval(pushStatus, 15000);
|
||
statusTimer.unref?.();
|
||
api.log(`listening on ${s.url}`);
|
||
log.add({ tool: "connector", args: `listening on ${s.url}`, ok: true });
|
||
pushStatus();
|
||
syncApps().catch((e) => api.log("app sync failed:", e.message));
|
||
}
|
||
|
||
async function stop(reason) {
|
||
if (stopping) return stopping;
|
||
stopping = (async () => {
|
||
clearInterval(statusTimer);
|
||
statusTimer = null;
|
||
const s = server;
|
||
server = null;
|
||
if (s) await s.close().catch(() => {});
|
||
await controller.releaseAll();
|
||
policy.reset();
|
||
try { fs.rmSync(endpointFile, { force: true }); } catch {}
|
||
// Token is PERSISTED across stop/start — a Theseus restart or a
|
||
// panel toggle-off/on does NOT invalidate connected clients. The
|
||
// old behavior rotated here on every stop, which looked tidy but
|
||
// broke every registered Claude on every quit: the Connect button
|
||
// writes the new token to ~/.claude.json, but any already-running
|
||
// Claude Code or Claude Desktop process is holding the previous
|
||
// token in memory and only re-reads on launch, so /mcp
|
||
// reconnect kept sending the stale bearer. For explicit rotation
|
||
// the panel's "Revoke token" button still exists and still
|
||
// generates a fresh one.
|
||
if (s) {
|
||
log.add({ tool: "connector", args: reason || "stopped", ok: true });
|
||
}
|
||
pushStatus();
|
||
})().finally(() => { stopping = null; });
|
||
return stopping;
|
||
}
|
||
|
||
// Toolbar chip ✕: stop now, and stay stopped until the user turns it on.
|
||
api.tabControl.onStop(() => {
|
||
api.storage.set("accept", false);
|
||
stop("stopped from the toolbar").catch(() => {});
|
||
});
|
||
|
||
// ---- panel messages --------------------------------------------------
|
||
api.onMessage("state", () => publicState());
|
||
api.onMessage("log", ({ n } = {}) => log.recent(n || 200));
|
||
api.onMessage("clear-log", () => { log.clear(); return { ok: true }; });
|
||
api.onMessage("set-accept", async ({ on } = {}) => {
|
||
api.storage.set("accept", !!on);
|
||
if (on) await start(); else await stop("turned off in the panel");
|
||
return publicState();
|
||
});
|
||
// Simultaneous-session cap. 0 = unlimited; any positive value is a hard
|
||
// ceiling checked at initialize time. Lowering below the current live
|
||
// count does NOT retroactively kick anyone — the next connect will be
|
||
// refused until an existing session goes away.
|
||
api.onMessage("set-session-limit", async ({ limit } = {}) => {
|
||
let n = Number(limit);
|
||
if (!Number.isFinite(n) || n < 0) n = 0;
|
||
if (n > 100) n = 100;
|
||
api.storage.set("sessionLimit", Math.floor(n));
|
||
log.add({ tool: "connector", args: n > 0 ? `session cap set to ${Math.floor(n)}` : "session cap cleared (no limit)", ok: true });
|
||
pushStatus();
|
||
return publicState();
|
||
});
|
||
api.onMessage("disconnect", async () => {
|
||
if (server) server.dropAllSessions();
|
||
await controller.releaseAll();
|
||
log.add({ tool: "connector", args: "disconnected every client", ok: true });
|
||
pushStatus();
|
||
return publicState();
|
||
});
|
||
api.onMessage("revoke-token", async () => {
|
||
api.storage.set("token", newToken());
|
||
if (server) server.dropAllSessions();
|
||
await controller.releaseAll();
|
||
log.add({ tool: "connector", args: "token revoked; a new one was issued", ok: true });
|
||
pushStatus();
|
||
await syncApps();
|
||
return publicState();
|
||
});
|
||
api.onMessage("apps", () => { refreshApps(); return publicState(); });
|
||
api.onMessage("app-connect", async ({ app } = {}) => {
|
||
const r = await connectApp(String(app || ""));
|
||
refreshApps();
|
||
return { ...publicState(), restart: !!(r && r.restart) };
|
||
});
|
||
api.onMessage("app-remove", async ({ app } = {}) => {
|
||
const id = String(app || "");
|
||
if (!APPS[id]) throw new Error(`unknown app ${id}`);
|
||
const r = await APPS[id].unregister();
|
||
api.storage.set("linkedApps", { ...linked(), [id]: false });
|
||
log.add({ tool: "connector", args: `removed from ${id === "claudeCode" ? "Claude Code" : "Claude Desktop"}`, ok: true });
|
||
refreshApps();
|
||
return { ...publicState(), restart: !!(r && r.restart) };
|
||
});
|
||
api.onMessage("set-evaluate", ({ on } = {}) => {
|
||
api.storage.set("allowEvaluate", !!on);
|
||
log.add({ tool: "connector", args: on ? "evaluate allowed" : "evaluate turned off", ok: true });
|
||
if (server) server.notifyToolsChanged();
|
||
pushState();
|
||
return publicState();
|
||
});
|
||
api.onMessage("remove-origin", ({ origin } = {}) => { policy.forget(String(origin || "")); pushState(); return publicState(); });
|
||
api.onMessage("add-upload-dir", async () => {
|
||
const r = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow() || undefined, {
|
||
title: "Folder Claude may upload files from",
|
||
defaultPath: app.getPath("documents"),
|
||
properties: ["openDirectory", "createDirectory"],
|
||
});
|
||
if (r.canceled || !r.filePaths || !r.filePaths[0]) return publicState();
|
||
const dirs = new Set(S.uploadDirs);
|
||
dirs.add(r.filePaths[0]);
|
||
api.storage.set("uploadDirs", [...dirs]);
|
||
log.add({ tool: "connector", args: `upload folder added: ${r.filePaths[0]}`, ok: true });
|
||
return publicState();
|
||
});
|
||
api.onMessage("remove-upload-dir", ({ dir } = {}) => {
|
||
api.storage.set("uploadDirs", S.uploadDirs.filter((d) => d !== dir));
|
||
return publicState();
|
||
});
|
||
api.onMessage("copy", async ({ text } = {}) => { await clipboard.writeText(String(text || "")); return { ok: true }; });
|
||
api.onMessage("open-log-folder", () => { require("electron").shell.openPath(dataDir); return { ok: true }; });
|
||
|
||
api.registerSidebarPanel({ id: "main", title: "Claude connector", page: "panel.html" });
|
||
|
||
// The first time the extension is turned on, open its panel: the next
|
||
// step (Connect) is there, and nothing else points to it.
|
||
if (!api.storage.get("introShown", false)) {
|
||
api.storage.set("introShown", true);
|
||
Promise.resolve(api.whenUiReady ? api.whenUiReady() : null)
|
||
.then(() => api.revealSidebar && api.revealSidebar("main"))
|
||
.catch(() => {});
|
||
}
|
||
|
||
if (S.accept) {
|
||
Promise.resolve(api.whenUiReady ? api.whenUiReady() : null)
|
||
.then(() => start())
|
||
.catch((e) => { api.log("could not start:", e.message); log.add({ tool: "connector", args: "could not start", ok: false, error: e.message }); });
|
||
}
|
||
|
||
module.exports._stop = stop;
|
||
},
|
||
|
||
async deactivate() {
|
||
const stop = module.exports._stop;
|
||
module.exports._stop = null;
|
||
if (stop) await stop("extension turned off");
|
||
server = null;
|
||
controller = null;
|
||
},
|
||
};
|
||
|
||
function newToken() { return crypto.randomBytes(32).toString("base64url"); }
|
||
function readVersion(folder) {
|
||
try { return JSON.parse(fs.readFileSync(path.join(folder, "addon.json"), "utf8")).version || "0.0.0"; } catch { return "0.0.0"; }
|
||
}
|