theseus/bundled-addons/claude-connector/panel.html
Local Dev f3a8d883e2 Theseus 0.3.125: Claude connector gets a simultaneous-session cap
New row in the connector panel: Simultaneous sessions. Three radios —
1 only / Multi / No limit — and a number input next to Multi for the
exact cap (clamped 1-100). Default stays at no limit, so existing users
see no behaviour change until they pick a value.

On the server side, initialize counts live sessions first (a GC pass over
expired ones runs so an abandoned tab doesn't permanently hold a slot)
and refuses any that would push past the cap with 429 and a message the
client surfaces to the user ("Theseus is at its simultaneous-session
cap (<N>). Disconnect a client, or raise the limit..."). Lowering the
cap below the live count is non-retroactive: no one gets kicked, but the
next connect fails until one goes away.

Settings surface widened: publicState() carries sessionLimit, and a new
set-session-limit message writes it (persisted in the add-on's storage
alongside token / port / allowEvaluate).
2026-10-27 22:30:23 +02:00

278 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Claude connector</title>
<style>
:root { color-scheme: light dark;
--bg:#0e131c; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --clay:#d97757; --bad:#ff9081;
--btn:#1c2432; --btn-h:#242e40; }
@media (prefers-color-scheme: light) {
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#f0f3fa; --line:rgba(0,0,0,.10);
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --btn:#f0f3fa; --btn-h:#e3e8f2; --bad:#c43c2c; }
}
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: var(--bg); color: var(--ink); font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
header { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 2; }
header .t { font-weight: 600; flex: 1; }
.pill { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--mut); white-space: nowrap; }
.pill.on { color: var(--clay); border-color: rgb(217 119 87 / .6); background: rgb(217 119 87 / .12); }
.pill.idle { color: var(--acid); border-color: rgb(from var(--acid) r g b / .35); }
section { padding: 12px 14px; border-bottom: 1px solid var(--line); }
h2 { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); margin: 0 0 8px; font-weight: 600; }
p { margin: 0 0 8px; color: var(--mut); }
.row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
.row .grow { flex: 1; min-width: 0; }
.sw { position: relative; width: 34px; height: 20px; flex: none; }
.sw input { opacity: 0; width: 0; height: 0; position: absolute; }
.sw span { position: absolute; inset: 0; background: var(--btn); border: 1px solid var(--line); border-radius: 999px; cursor: pointer; transition: .15s; }
.sw span::after { content: ""; position: absolute; width: 14px; height: 14px; left: 2px; top: 2px; background: var(--mut); border-radius: 50%; transition: .15s; }
.sw input:checked + span { background: rgb(217 119 87 / .35); border-color: var(--clay); }
.sw input:checked + span::after { transform: translateX(14px); background: var(--clay); }
button.b { font: inherit; font-size: 12px; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: var(--btn); color: var(--ink); cursor: pointer; white-space: nowrap; }
button.b:hover { background: var(--btn-h); }
button.b.warn { color: var(--bad); }
button.x { border: none; background: none; color: var(--dim); cursor: pointer; font-size: 13px; padding: 2px 6px; }
button.x:hover { color: var(--bad); }
pre { margin: 6px 0; padding: 8px; background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; font: 11.5px/1.45 ui-monospace, Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 180px; overflow: auto; }
.mono { font-family: ui-monospace, Consolas, monospace; font-size: 12px; overflow-wrap: anywhere; }
.list { margin: 4px 0; padding: 0; list-style: none; }
.list li { display: flex; align-items: center; gap: 6px; padding: 3px 0; border-bottom: 1px dashed var(--line); }
.list li:last-child { border-bottom: none; }
.list li .grow { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.empty { color: var(--dim); font-style: italic; }
.note { font-size: 12px; color: var(--mut); }
.warnbox { font-size: 12px; color: var(--bad); margin-top: 4px; }
.log { font: 11.5px/1.4 ui-monospace, Consolas, monospace; }
.log .e { padding: 4px 0; border-bottom: 1px solid var(--line); }
.log .e .h { display: flex; gap: 6px; flex-wrap: wrap; }
.log .ts { color: var(--dim); }
.log .tool { color: var(--ink); font-weight: 600; }
.log .org { color: var(--mut); }
.log .ok { color: var(--acid); }
.log .bad { color: var(--bad); }
.log .a { color: var(--mut); overflow-wrap: anywhere; }
.app { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px dashed var(--line); }
.app:last-of-type { border-bottom: none; }
.app .grow { flex: 1; min-width: 0; }
.app .st { font-size: 12px; color: var(--mut); }
.app .st.ok { color: var(--acid); }
.app .st.warn { color: var(--clay); }
button.b.pri { background: rgb(217 119 87 / .2); border-color: var(--clay); color: var(--ink); }
button.b:disabled { opacity: .5; cursor: default; }
details.manual { margin-top: 8px; }
details.manual summary { cursor: pointer; color: var(--mut); font-size: 12px; }
.msg { font-size: 12px; margin-top: 6px; color: var(--mut); }
.msg.bad { color: var(--bad); }
.tabs { display: flex; gap: 4px; margin-bottom: 6px; }
.tabs button { font: inherit; font-size: 12px; padding: 3px 9px; border: 1px solid var(--line); background: var(--btn); color: var(--mut); border-radius: 6px; cursor: pointer; }
.tabs button.sel { color: var(--ink); border-color: var(--clay); }
</style>
</head>
<body>
<header><span class="t">Claude connector</span><span class="pill" id="pill">…</span></header>
<section>
<div class="row"><div class="grow"><b>Accept connections</b><div class="note" id="where">Off</div></div><label class="sw"><input type="checkbox" id="accept"><span></span></label></div>
<div class="row" style="margin-top:8px;align-items:flex-start"><div class="grow"><b>Simultaneous sessions</b><div class="note">A new client's <code>initialize</code> past this limit is refused with 429. Live sessions only — abandoned ones time out.</div></div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<label><input type="radio" name="smode" value="1"> 1 only</label>
<label><input type="radio" name="smode" value="multi"> Multi</label>
<label><input type="radio" name="smode" value="0"> No limit</label>
<input type="number" id="sNum" min="1" max="100" step="1" style="width:60px" title="Multi: enter the cap (1-100)" hidden>
</div>
</div>
<div id="clients" class="note"></div>
<div class="row" style="margin-top:8px"><button class="b" id="disconnect">Disconnect clients</button><button class="b warn" id="revoke" title="Issue a new token. Every client must be set up again with the new command.">Revoke token</button></div>
</section>
<section id="setup">
<h2>Connect Claude</h2>
<p>One click adds Theseus to a Claude app on this computer. Theseus keeps the entry current when its token or address changes.</p>
<div class="app"><div class="grow"><b>Claude Code</b><div class="st" id="stCode">…</div></div><button class="b" id="rmCode" hidden>Remove</button><button class="b pri" id="goCode">Connect</button></div>
<div class="app"><div class="grow"><b>Claude Desktop</b><div class="st" id="stDesk">…</div></div><button class="b" id="rmDesk" hidden>Remove</button><button class="b pri" id="goDesk">Connect</button></div>
<div class="msg" id="appMsg"></div>
<details class="manual"><summary>Set up by hand</summary>
<div class="tabs"><button id="tCode" class="sel">Claude Code</button><button id="tDesk">Claude Desktop</button></div>
<div id="pCode">
<p>Run once in a terminal. <span class="mono">--scope user</span> makes it available in every project.</p>
<pre id="cmdCode"></pre>
<div class="row"><button class="b" id="copyCode">Copy command</button></div>
</div>
<div id="pDesk" hidden>
<p>Add to <span class="mono">claude_desktop_config.json</span> (Settings › Developer › Edit config), then restart Claude Desktop. It runs through Theseus itself, nothing else to install.</p>
<pre id="cmdDesk"></pre>
<div class="row"><button class="b" id="copyDesk">Copy config</button></div>
</div>
<p class="note">The token in this command is the key to this browser. Keep it out of shared files; Revoke token makes a new one.</p>
</details>
</section>
<section>
<h2>Sites Claude may control</h2>
<p>Theseus asks the first time Claude acts on a site.</p>
<ul class="list" id="origins"></ul>
</section>
<section>
<h2>Upload folders</h2>
<p>Claude can attach files only from these folders.</p>
<ul class="list" id="dirs"></ul>
<div class="row"><button class="b" id="addDir">Add folder…</button></div>
</section>
<section>
<h2>Advanced</h2>
<div class="row"><div class="grow"><b>Allow evaluate</b><div class="note">Lets Claude run its own JavaScript in approved sites' pages.</div></div><label class="sw"><input type="checkbox" id="evaluate"><span></span></label></div>
<div class="warnbox" id="evalWarn" hidden>Evaluate runs any script as the page itself, including reading what you see there. Turn it off when you do not need it.</div>
</section>
<section>
<h2>Activity</h2>
<div class="row"><span class="note grow">Every action, newest first.</span><button class="b" id="openLog">Log file</button><button class="b" id="clearLog">Clear</button></div>
<div class="log" id="log"></div>
</section>
<script>
const $ = (id) => document.getElementById(id);
const S = window.silentmode;
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
let state = null;
function render(st) {
state = st;
$("accept").checked = !!st.accept;
const pill = $("pill");
if (st.connected) { pill.textContent = "Connected"; pill.className = "pill on"; }
else if (st.listening) { pill.textContent = "Waiting for Claude"; pill.className = "pill idle"; }
else { pill.textContent = "Off"; pill.className = "pill"; }
$("where").textContent = st.listening ? `Listening on ${st.url} (this computer only)` : st.accept ? "Starting…" : "Not listening";
const sess = st.sessions || [];
$("clients").innerHTML = sess.length
? sess.map((s) => `${esc(s.client)} — connected since ${esc(new Date(s.since).toLocaleTimeString())}${s.streaming ? "" : ", idle"}`).join("<br>") + (st.controlledTabs.length ? `<br>Controlling tab${st.controlledTabs.length === 1 ? "" : "s"} ${st.controlledTabs.join(", ")}` : "")
: (st.listening ? "No client connected." : "");
$("setup").hidden = !st.commands;
renderApps(st.apps);
if (st.commands) { $("cmdCode").textContent = st.commands.claudeCode; $("cmdDesk").textContent = st.commands.claudeDesktop; }
$("origins").innerHTML = st.allowedOrigins.length
? st.allowedOrigins.map((o) => `<li><span class="grow mono">${esc(o)}</span><button class="x" data-origin="${esc(o)}" title="Remove">✕</button></li>`).join("")
: '<li class="empty">None yet.</li>';
$("dirs").innerHTML = st.uploadDirs.length
? st.uploadDirs.map((d) => `<li><span class="grow mono">${esc(d)}</span><button class="x" data-dir="${esc(d)}" title="Remove">✕</button></li>`).join("")
: '<li class="empty">None. Uploads are refused.</li>';
$("evaluate").checked = !!st.allowEvaluate;
$("evalWarn").hidden = !st.allowEvaluate;
// Session-cap radios: 1-only / Multi / No-limit. Multi reveals a number
// input for the exact cap. 0 (unlimited) shows "No limit" selected.
const lim = Number(st.sessionLimit) || 0;
const smode = lim === 1 ? "1" : lim === 0 ? "0" : "multi";
for (const r of document.querySelectorAll('input[name="smode"]')) r.checked = (r.value === smode);
const num = $("sNum");
num.hidden = (smode !== "multi");
num.value = smode === "multi" ? String(lim) : (smode === "1" ? "" : "");
}
function entryHtml(e) {
const t = new Date(e.ts);
const ts = `${t.toLocaleDateString()} ${t.toLocaleTimeString()}`;
return `<div class="e"><div class="h"><span class="ts">${esc(ts)}</span><span class="tool">${esc(e.tool)}</span>${e.tab != null ? `<span class="org">tab ${esc(e.tab)}</span>` : ""}${e.origin ? `<span class="org">${esc(e.origin)}</span>` : ""}<span class="${e.ok ? "ok" : "bad"}">${e.ok ? "ok" : "failed"}</span>${e.ms != null ? `<span class="ts">${esc(e.ms)} ms</span>` : ""}</div>${e.args ? `<div class="a">${esc(e.args)}</div>` : ""}${e.error ? `<div class="bad">${esc(e.error)}</div>` : ""}</div>`;
}
async function loadLog() {
const list = await S.invoke("log", { n: 200 });
$("log").innerHTML = list.slice().reverse().map(entryHtml).join("") || '<div class="empty">Nothing yet.</div>';
}
$("accept").onchange = async (e) => render(await S.invoke("set-accept", { on: e.target.checked }));
// Session cap: the three radios + the number input share one setter.
for (const r of document.querySelectorAll('input[name="smode"]')) {
r.addEventListener("change", async () => {
const v = r.value;
$("sNum").hidden = (v !== "multi");
if (v === "0") render(await S.invoke("set-session-limit", { limit: 0 }));
else if (v === "1") render(await S.invoke("set-session-limit", { limit: 1 }));
else { // multi
const n = Math.max(2, Math.min(100, Number($("sNum").value) || 5));
$("sNum").value = String(n);
render(await S.invoke("set-session-limit", { limit: n }));
}
});
}
$("sNum").addEventListener("change", async () => {
const n = Math.max(2, Math.min(100, Number($("sNum").value) || 5));
$("sNum").value = String(n);
render(await S.invoke("set-session-limit", { limit: n }));
});
$("evaluate").onchange = async (e) => render(await S.invoke("set-evaluate", { on: e.target.checked }));
$("disconnect").onclick = async () => render(await S.invoke("disconnect"));
// Two clicks: every client has to be set up again afterwards.
let revokeArmed = null;
$("revoke").onclick = async () => {
const b = $("revoke");
if (!revokeArmed) { b.textContent = "Click again to revoke"; revokeArmed = setTimeout(() => { revokeArmed = null; b.textContent = "Revoke token"; }, 4000); return; }
clearTimeout(revokeArmed); revokeArmed = null; b.textContent = "Revoke token";
render(await S.invoke("revoke-token"));
};
$("addDir").onclick = async () => render(await S.invoke("add-upload-dir"));
$("copyCode").onclick = () => state && S.invoke("copy", { text: state.commands.claudeCode }).then(() => flash("copyCode"));
$("copyDesk").onclick = () => state && S.invoke("copy", { text: state.commands.claudeDesktop }).then(() => flash("copyDesk"));
$("clearLog").onclick = async () => { await S.invoke("clear-log"); loadLog(); };
$("openLog").onclick = () => S.invoke("open-log-folder");
$("tCode").onclick = () => { $("pCode").hidden = false; $("pDesk").hidden = true; $("tCode").classList.add("sel"); $("tDesk").classList.remove("sel"); };
$("tDesk").onclick = () => { $("pCode").hidden = true; $("pDesk").hidden = false; $("tDesk").classList.add("sel"); $("tCode").classList.remove("sel"); };
document.body.addEventListener("click", async (e) => {
const o = e.target.closest("[data-origin]");
if (o) render(await S.invoke("remove-origin", { origin: o.dataset.origin }));
const d = e.target.closest("[data-dir]");
if (d) render(await S.invoke("remove-upload-dir", { dir: d.dataset.dir }));
});
function flash(id) { const b = $(id), t = b.textContent; b.textContent = "Copied"; setTimeout(() => { b.textContent = t; }, 1200); }
S.on("status", render);
S.on("log-entry", (e) => {
const box = $("log");
if (box.querySelector(".empty")) box.innerHTML = "";
box.insertAdjacentHTML("afterbegin", entryHtml(e));
while (box.children.length > 300) box.lastChild.remove();
});
// ---- Claude apps ----
const APP_UI = { claudeCode: ["stCode", "goCode", "rmCode"], claudeDesktop: ["stDesk", "goDesk", "rmDesk"] };
function renderApps(apps) {
if (!apps) return;
for (const [id, [stId, goId, rmId]] of Object.entries(APP_UI)) {
const a = apps[id] || {};
const st = $(stId), go = $(goId), rm = $(rmId);
if (!a.installed && !a.configured) { st.textContent = id === "claudeCode" ? "Not found on this computer (the claude command)." : "Not installed on this computer."; st.className = "st"; go.hidden = true; rm.hidden = true; continue; }
go.hidden = false;
if (a.current) { st.textContent = "Connected. Theseus keeps it up to date."; st.className = "st ok"; go.hidden = true; }
else if (a.configured) { st.textContent = "Set up with an old address or token."; st.className = "st warn"; go.textContent = "Update"; }
else { st.textContent = "Not connected."; st.className = "st"; go.textContent = "Connect"; }
go.disabled = !(state && state.listening) || !a.installed;
if (!(state && state.listening)) st.textContent += " Turn on Accept connections first.";
rm.hidden = !a.configured;
}
}
async function appAction(msg, app, btn) {
const m = $("appMsg");
btn.disabled = true; m.className = "msg"; m.textContent = "Working…";
try {
const st = await S.invoke(msg, { app });
render(st);
const name = app === "claudeCode" ? "Claude Code" : "Claude Desktop";
m.textContent = msg === "app-connect"
? (st.restart ? `Added. Restart ${name} to load it.` : `Added to ${name}. New Claude Code sessions see the Theseus tools; in a running one, use /mcp.`)
: (st.restart ? `Removed. Restart ${name} to unload it.` : `Removed from ${name}.`);
} catch (e) { m.className = "msg bad"; m.textContent = String(e && e.message || e).replace(/^Error invoking remote method '[^']*': (Error: )?/, ""); }
finally { btn.disabled = false; }
}
$("goCode").onclick = (e) => appAction("app-connect", "claudeCode", e.currentTarget);
$("goDesk").onclick = (e) => appAction("app-connect", "claudeDesktop", e.currentTarget);
$("rmCode").onclick = (e) => appAction("app-remove", "claudeCode", e.currentTarget);
$("rmDesk").onclick = (e) => appAction("app-remove", "claudeDesktop", e.currentTarget);
S.invoke("apps").then(render);
loadLog();
</script>
</body>
</html>