toolroll 0.9.25 → 0.9.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scout.js CHANGED
@@ -68,9 +68,11 @@ export function scoutBrowser(url) {
68
68
  const PROXY_BYPASS_ENV = ["NO_PROXY", "no_proxy", "ALL_PROXY", "all_proxy"];
69
69
  /** The scout's environment while its proxy runs (review 827): web fetch and the provider's own traffic go through
70
70
  * the same public-web-only proxy as its browser, so only public addresses (and the project's demo) are reachable.
71
- * The one direct address is its own browser's port, when it has one (review 828): the proxy refuses loopback. */
72
- export function scoutProxyEnv(proxy, browserUrl = null) {
73
- const direct = browserUrl === null ? {} : { NO_PROXY: new URL(browserUrl).host, no_proxy: new URL(browserUrl).host };
71
+ * The direct addresses are its own browser's port, when it has one (review 828), and an app on this computer it
72
+ * reads (`local`: Figma's desktop server): the proxy refuses loopback. */
73
+ export function scoutProxyEnv(proxy, browserUrl = null, local = []) {
74
+ const hosts = [...(browserUrl === null ? [] : [new URL(browserUrl).host]), ...local].join(",");
75
+ const direct = hosts === "" ? {} : { NO_PROXY: hosts, no_proxy: hosts };
74
76
  return { HTTP_PROXY: proxy, HTTPS_PROXY: proxy, http_proxy: proxy, https_proxy: proxy, ...direct };
75
77
  }
76
78
  /** One line of untrusted text made inert for the brief (audit IV-4). */
@@ -172,7 +174,7 @@ function scoutBrief(title, goal, outOfScope, mailbox, reportFile, answers, struc
172
174
  "its goal as the contract a builder would be held to.",
173
175
  ].join("\n");
174
176
  }
175
- const NO_RESEARCH = { services: [], allowed: [], reads: {} };
177
+ const NO_RESEARCH = { services: [], allowed: [], reads: {}, local: [] };
176
178
  /** What of the project's connected services this run may read (none when they can't be read). */
177
179
  function connectedResearch(store, taskRef, runId) {
178
180
  try {
@@ -305,11 +307,11 @@ async function scoutWith(store, request, outputDir) {
305
307
  browserServer = started.server;
306
308
  browserProblem = started !== null && !started.ok ? started.reason : null;
307
309
  const browser = browserServer === null ? null : scoutBrowser(browserServer.url);
308
- const proxyEnv = proxy === null ? {} : scoutProxyEnv(proxy.url, browserServer?.url ?? null);
309
- const briefBrowser = browserProblem !== null ? { problem: browserProblem } : browser === null ? null : { folder: imageFolder, demoUrl };
310
- // The project's signed-in services, read-only actions only: Claude allows them by name, so `dontAsk` refuses every
311
- // other one; Codex launches only those servers, each with only those actions enabled.
310
+ // The project's signed-in services (and apps on this computer), read-only actions only: Claude allows them by name,
311
+ // so `dontAsk` refuses every other one; Codex launches only those servers, each with only those actions enabled.
312
312
  const research = proxy === null ? NO_RESEARCH : connectedResearch(store, request.taskRef, request.runId);
313
+ const proxyEnv = proxy === null ? {} : scoutProxyEnv(proxy.url, browserServer?.url ?? null, research.local);
314
+ const briefBrowser = browserProblem !== null ? { problem: browserProblem } : browser === null ? null : { folder: imageFolder, demoUrl };
313
315
  invoked = await invokeAgent(store, request.runId, { provider: request.provider ?? "claude", model: request.model ?? null }, {
314
316
  phase: "plan",
315
317
  brief: projectSkillContext + scoutBrief(request.taskTitle, request.goal, request.outOfScope, mailbox, reportFile, request.answers ?? [], structured, briefBrowser, proxy !== null, research.services),
package/dist/serve.js CHANGED
@@ -24,13 +24,13 @@ import { SlackState } from "./slack-state.js";
24
24
  import { CHAT_ACTIONS, OWNER_ACTIONS, sharedActionPayload, sharedActionNeedsReview, sharedActionReviewPath, mintSharedActionReview } from './chat-actions.js';
25
25
  import { changeSkills, githubSkill, importSkill, readSkillsSnapshot, reviseSkillTest, skillTestResult, skillsView, testSkill } from "./project-skills.js";
26
26
  import { skillsHtml, skillsScript, skillsSnapshotHtml, skillTestFeedbackHtml, SKILLS_CSS } from "./skills-ui.js";
27
- import { toolsHtml, TOOLS_CSS } from "./tools-ui.js";
27
+ import { toolsHtml, toolsProjectPicker, TOOLS_CSS, TOOLS_PROJECT_SCRIPT } from "./tools-ui.js";
28
28
  import { flowFallbackHtml, flowImportHtml, flowsListHtml, flowView, FLOW_IMPORT_SCRIPT, FLOWS_CSS } from "./flows-ui.js";
29
29
  import { exportFlow, fetchFlowFile, FlowFileError, importFlow, parseFlowFile, planFlowImport } from "./flow-share.js";
30
30
  import { BLANK_SOUL, TEAMMATE_CSS, teammatePageHtml, teammatesListHtml } from "./teammates-ui.js";
31
31
  import { grantTool, revokeTool, rulesFromForm, setToolRules } from "./teammate-tools.js";
32
32
  import { addKitGithubTrigger, addKitSample, kitInstalled, kitOf, setUpKit } from "./kits.js";
33
- import { CONNECT_CALLBACK, connectionsOf, finishConnect, oneClickOf, startConnect } from "./mcp-connect.js";
33
+ import { CONNECT_CALLBACK, connectionsOf, finishConnect, localConnectOf, oneClickOf, startConnect } from "./mcp-connect.js";
34
34
  import { KITS_CSS, kitPageHtml, kitsGalleryHtml } from "./kits-ui.js";
35
35
  import { STARTERS_CSS, startersHtml } from "./flow-starters-ui.js";
36
36
  import { GALLERY_CSS, galleryHtml, galleryUseHtml } from "./flow-gallery-ui.js";
@@ -53,7 +53,7 @@ import { addFlowTriggerTo, checkFlowTriggerNow, HOOK_PATH, pressFlowButton, rece
53
53
  import { addCardToFlow, advanceFlows, cancelFlowCard, crossProjectProblem, decideFlowCard, FLOW_HREF, flowDefinitionOf, moveCardInFlow } from "./flow-engine.js";
54
54
  import { chooseFlowCard } from "./flow-send.js";
55
55
  import { FLOW_TEMPLATES, validateFlowDefinition } from "./flows.js";
56
- import { TOOL_CATALOG, addToolTo, catalogTool, discoverTools, projectToolsOf, removeToolFrom, secretsSetFor, setToolSecret, splitCommandLine, testToolOf } from "./project-tools.js";
56
+ import { TOOL_CATALOG, addToolTo, catalogTool, discoverTools, localAppOf, projectToolsOf, removeToolFrom, secretsSetFor, setToolSecret, splitCommandLine, testToolOf } from "./project-tools.js";
57
57
  import { changeLearning, learningView } from "./project-learning.js";
58
58
  import { changeKnowledge, knowledgeView, knowledgeVersion, readKnowledgeSnapshot } from "./project-knowledge.js";
59
59
  import { knowledgeHtml, knowledgeContextHtml, KNOWLEDGE_CSS, decisionsHtml, memorySearchHtml, MEMORY_INTRO, proposalsHtml } from "./knowledge-ui.js";
@@ -695,8 +695,8 @@ export function createDecisionServer(options) {
695
695
  return {
696
696
  repo, project: repo.split(/[\\/]/).filter(Boolean).at(-1) ?? repo,
697
697
  tools: tools.map(tool => ({ tool, secretsSet: secretsSetFor(repo, tool.spec, toolHome) })),
698
- // A service that connects by signing in is offered only that way.
699
- catalog: TOOL_CATALOG.filter(one => !names.has(one.name) && oneClickOf(one.name) === null),
698
+ // A service that connects by signing in (or an app's own Connect) is offered only that way.
699
+ catalog: TOOL_CATALOG.filter(one => !names.has(one.name) && oneClickOf(one.name) === null && localConnectOf(one.name) === null),
700
700
  connections: connectionsOf(store, repo), kit, wanted,
701
701
  found: discoverTools(repo, codex, toolHome).filter(one => !names.has(one.spec.name)),
702
702
  };
@@ -1113,8 +1113,9 @@ export function createDecisionServer(options) {
1113
1113
  const service = oneClickOf(visit.service);
1114
1114
  const kit = visit.kit === null ? null : kitOf(visit.kit);
1115
1115
  const back = kit === null ? `/settings/tools?repo=${encodeURIComponent(visit.repo)}` : `/kits/${kit.id}?repo=${encodeURIComponent(visit.repo)}`;
1116
+ const to = projectName(visit.repo);
1116
1117
  if (url.searchParams.has("error"))
1117
- return done(back, "problem", url.searchParams.get("error") === "access_denied" ? `${service.label} wasn't connected: access was declined.` : `${service.label} wasn't connected.`);
1118
+ return done(back, "problem", url.searchParams.get("error") === "access_denied" ? `${service.label} wasn't connected to ${to}: access was declined.` : `${service.label} wasn't connected to ${to}.`);
1118
1119
  const code = url.searchParams.get("code") ?? "";
1119
1120
  if (!/^[\x21-\x7e]{4,2048}$/.test(code))
1120
1121
  return done(back, "problem", `${service.label} didn't send a sign-in code. Connect again.`);
@@ -1127,7 +1128,7 @@ export function createDecisionServer(options) {
1127
1128
  if (set !== null && store.teammateGrant(set.mate.id, service.id) === null) {
1128
1129
  const granted = await grantTool(store, set.mate, service.id, visit.by, now, { toolHome });
1129
1130
  if (granted.ok)
1130
- return done(back, "said", `${service.label} is connected, and ${nameOf(set.mate)} can use it: reading freely, the rest after you approve each call.`);
1131
+ return done(back, "said", `${service.label} is connected to ${to}, and ${nameOf(set.mate)} can use it: reading freely, the rest after you approve each call.`);
1131
1132
  }
1132
1133
  return done(back, "said", finished.said);
1133
1134
  }
@@ -3893,18 +3894,23 @@ export function createDecisionServer(options) {
3893
3894
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
3894
3895
  if (chosen && !projects.includes(chosen))
3895
3896
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
3896
- const selector = projects.length > 1 ? `<form class="tools" method="get" action="/settings/tools"><label>Project<select name="repo">${projects.map(p => `<option value="${escape(p)}"${p === chosen ? " selected" : ""}>${escape(p.split("/").at(-1) ?? p)}</option>`).join("")}</select></label><button>Show project</button></form>` : "";
3897
+ const kit = kitOf(url.searchParams.get("kit") ?? "")?.id ?? null, wanted = (oneClickOf(url.searchParams.get("connect") ?? "") ?? localConnectOf(url.searchParams.get("connect") ?? ""))?.id ?? null;
3898
+ // Choosing a project opens it at once (keeping a kit's Connect), so every form below acts on the project shown.
3899
+ const selector = toolsProjectPicker(projects, chosen, { ...(kit === null ? {} : { kit }), ...(wanted === null ? {} : { connect: wanted }) });
3897
3900
  let content = "<p>Add a project to give its builds tools.</p>";
3898
3901
  if (chosen) {
3899
3902
  try {
3900
3903
  const approver = who.role === "approver" && who.via === "cookie";
3901
- content = toolsHtml(toolsViewOf(chosen, approver ? await codexServers(chosen) : null, kitOf(url.searchParams.get("kit") ?? "")?.id ?? null, oneClickOf(url.searchParams.get("connect") ?? "")?.id ?? null), who.via === "cookie" ? who.session.csrf : "", approver, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
3904
+ const view = toolsViewOf(chosen, approver ? await codexServers(chosen) : null, kit, wanted);
3905
+ if (approver)
3906
+ view.others = projects.filter(one => one !== chosen).map(one => ({ repo: one, project: projectName(one), open: connectionsOf(store, one).filter(c => c.state === "open").map(c => c.id) }));
3907
+ content = toolsHtml(view, who.via === "cookie" ? who.session.csrf : "", approver, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") });
3902
3908
  }
3903
3909
  catch {
3904
3910
  content = '<p class="problem" role="alert">Tools are unavailable. Reload to retry.</p>';
3905
3911
  }
3906
3912
  }
3907
- return sendScreen(response, 200, screen("Tools", `<p><a href="/settings">Settings</a></p><h1>Tools</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings") }));
3913
+ return sendScreen(response, 200, screen("Tools", `<p><a href="/settings">Settings</a></p><h1>Tools</h1>${selector}${content}`, { chrome: chromeFor(chosen || project, "settings"), functional: { script: SETTINGS_AUTOSAVE_SCRIPT + TOOLS_PROJECT_SCRIPT } }));
3908
3914
  }
3909
3915
  // Settings → Project: what Toolroll holds for a project; an instance operator deletes it here.
3910
3916
  if (url.pathname === "/settings/project") {
@@ -7714,19 +7720,61 @@ export function createDecisionServer(options) {
7714
7720
  if (who.via !== "cookie" || who.role !== "approver")
7715
7721
  return refuse(response, who, 403, "Sign in as an approver to connect tools.", "/settings/tools");
7716
7722
  const repo = body.get("repo") ?? "";
7717
- if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
7723
+ const reachable = (one) => visible(one) && [...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(one);
7724
+ if (!reachable(repo))
7718
7725
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
7719
- const service = oneClickOf(body.get("service") ?? "");
7726
+ // "Also connect to …": a service connected on the shown project, signed in again for another project.
7727
+ const also = body.get("also");
7728
+ const split = also === null ? -1 : also.indexOf(":");
7729
+ const service = oneClickOf(also === null ? body.get("service") ?? "" : split > 0 ? also.slice(0, split) : "");
7730
+ // An app on this computer (Figma's desktop app) connects here, never "also" elsewhere.
7731
+ const local = service === null && also === null ? localConnectOf(body.get("service") ?? "") : null;
7732
+ const chosen = service ?? local;
7733
+ const target = also === null ? repo : split > 0 ? also.slice(split + 1) : "";
7720
7734
  const kit = kitOf(body.get("kit") ?? "")?.id ?? null;
7721
- const back = (words) => redirect(response, `/settings/tools?repo=${encodeURIComponent(repo)}${kit === null ? "" : `&kit=${kit}`}${service === null ? "" : `&connect=${service.id}`}&problem=${encodeURIComponent(words)}#connect`);
7722
- if (service === null)
7735
+ // A Connect that fails comes back to its own button, where the reason is said (no #fragment: one stops the alert's
7736
+ // autofocus); an "Also connect to" refusal goes back to the Connect card.
7737
+ const back = (words, page = repo) => redirect(response, `/settings/tools?repo=${encodeURIComponent(page)}${kit === null ? "" : `&kit=${kit}`}${chosen === null || also !== null ? "" : `&connect=${chosen.id}`}&problem=${encodeURIComponent(words)}${also === null ? "" : "#connect"}`);
7738
+ // The page posts the project it showed; a post from a page showing another project is stale.
7739
+ const shown = body.get("shown") ?? "";
7740
+ if (shown !== repo)
7741
+ return back("The project changed; connect again.", reachable(shown) ? shown : repo);
7742
+ if (chosen === null)
7723
7743
  return back("Choose a service to connect.");
7744
+ if (also !== null && service !== null) {
7745
+ if (!reachable(target))
7746
+ return refuse(response, who, 403, "That project is outside your access.", "/projects");
7747
+ if (connectionsOf(store, repo).find(one => one.id === service.id)?.state !== "connected")
7748
+ return back(`${service.label} isn't connected to ${projectName(repo)}.`);
7749
+ if (connectionsOf(store, target).find(one => one.id === service.id)?.state !== "open")
7750
+ return back(`${projectName(target)} already has ${service.label}.`);
7751
+ }
7724
7752
  if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7725
7753
  return back("Enter your Toolroll password to connect a tool.");
7754
+ // An app on this computer: added like a common tool and tested; one that can't be reached isn't kept.
7755
+ if (local !== null) {
7756
+ const had = projectToolsOf(store, repo).find(one => one.name === local.id);
7757
+ if (had !== undefined && localAppOf(had.spec) === null)
7758
+ return back(`This project already has a tool called ${local.id}, set up another way. Remove it on this page to connect ${local.label}.`);
7759
+ if (had === undefined) {
7760
+ const { label: _label, ...spec } = catalogTool(local.id);
7761
+ const added = addToolTo(store, repo, spec, `${local.label}, connected on this computer`, who.name, clock(), { home: toolHome });
7762
+ if (!added.ok)
7763
+ return back(added.message);
7764
+ }
7765
+ const tested = await testToolOf(store, repo, local.id, clock(), { home: toolHome, omitEnv: ALL_CREDENTIAL_ENV });
7766
+ if (tested?.ok)
7767
+ return redirect(response, `/settings/tools?repo=${encodeURIComponent(repo)}${kit === null ? "" : `&kit=${kit}`}&said=${encodeURIComponent(`${local.label} is connected: ${tested.tools.length} action${tested.tools.length === 1 ? "" : "s"}.`)}#tool-${local.id}`);
7768
+ if (had === undefined)
7769
+ removeToolFrom(store, repo, local.id, who.name, clock(), toolHome);
7770
+ return back(tested?.problem ?? `${local.label} didn't answer.`);
7771
+ }
7772
+ if (service === null)
7773
+ return back("Choose a service to connect.");
7726
7774
  const origin = consoleOrigin(request.headers.host);
7727
7775
  if (origin === null)
7728
7776
  return back("Connect tools from this computer (localhost) or from your https address.");
7729
- const started = await startConnect({ service: service.id, repo, by: who.name, origin, kit }, options.connectFetch ?? fetch);
7777
+ const started = await startConnect({ service: service.id, repo: target, by: who.name, origin, kit: also === null ? kit : null }, options.connectFetch ?? fetch);
7730
7778
  if (!started.ok)
7731
7779
  return back(started.said);
7732
7780
  for (const [key, visit] of connectVisits)
@@ -7742,6 +7790,12 @@ export function createDecisionServer(options) {
7742
7790
  if (!visible(repo) || ![...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(repo))
7743
7791
  return refuse(response, who, 403, "That project is outside your access.", "/projects");
7744
7792
  const back = (key, words, anchor = "") => redirect(response, `/settings/tools?repo=${encodeURIComponent(repo)}&${key}=${encodeURIComponent(words)}${anchor}`);
7793
+ // As for Connect: a post from a page that showed another project is refused, never applied here.
7794
+ const shown = body.get("shown") ?? "";
7795
+ if (shown !== repo) {
7796
+ const page = visible(shown) && [...(admissionList() ?? []), ...managedRepos(), ...store.knownRepos()].includes(shown) ? shown : repo;
7797
+ return redirect(response, `/settings/tools?repo=${encodeURIComponent(page)}&problem=${encodeURIComponent("The project changed; try again.")}`);
7798
+ }
7745
7799
  const action = body.get("action") ?? "";
7746
7800
  const name = body.get("name") ?? "";
7747
7801
  // Anything that adds what a build can run or reach, or a secret, needs the password.
@@ -1,7 +1,7 @@
1
1
  /** Settings → Tools: one project's MCP servers, in plain words. Secrets are
2
2
  * entered here and nowhere else, and never shown again. */
3
3
  import { type FoundTool, type ProjectTool, type ToolSpec } from "./project-tools.js";
4
- export declare const TOOLS_CSS = ".tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready=\"false\"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:\"\";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:32px 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile i{grid-row:span 2;width:32px;height:32px;border-radius:8px;display:grid;place-items:center;font-style:normal;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile span{font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state=\"connected\"] span{color:var(--so-success)}.tools .connect-tile:disabled{cursor:default;opacity:.6}@media(max-width:600px){.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile span{display:none}.tools .connect-tile{grid-template-columns:28px 1fr;min-height:52px;padding:8px 10px}.tools .connect-tile i{grid-row:auto;width:28px;height:28px}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}.tools .tool-heading{align-items:flex-start}}";
4
+ export declare const TOOLS_CSS = ".tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready=\"false\"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:\"\";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:32px 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile i{grid-row:span 2;width:32px;height:32px;border-radius:8px;display:grid;place-items:center;font-style:normal;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile span{font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state=\"connected\"] span{color:var(--so-success)}.tools .connect-tile:disabled{cursor:default;opacity:.6}.tools .connect-local{grid-column:1/-1}.tools .connect-local span{-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}@media(max-width:600px){.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local) span{display:none}.tools .connect-local span{grid-column:2}.tools .connect-tile{grid-template-columns:28px 1fr;min-height:52px;padding:8px 10px}.tools .connect-tile i{grid-row:auto;width:28px;height:28px}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}.tools .tool-heading{align-items:flex-start}}";
5
5
  export type ToolsView = {
6
6
  repo: string;
7
7
  project: string;
@@ -11,6 +11,7 @@ export type ToolsView = {
11
11
  label: string;
12
12
  about: string;
13
13
  state: "connected" | "open" | "taken";
14
+ local?: true;
14
15
  }[];
15
16
  kit: string | null;
16
17
  /** The service a link asked for (a kit's checklist, the lead): its own button under the password. */
@@ -23,7 +24,17 @@ export type ToolsView = {
23
24
  label: string;
24
25
  })[];
25
26
  found: FoundTool[];
27
+ /** The person's other projects and the one-click services still open there: where a connection here can also go. */
28
+ others?: {
29
+ repo: string;
30
+ project: string;
31
+ open: string[];
32
+ }[];
26
33
  };
34
+ /** The project chooser: it opens the chosen project at once; without scripts, its button does. */
35
+ export declare function toolsProjectPicker(projects: readonly string[], chosen: string, keep?: Record<string, string>): string;
36
+ /** Choosing another project marks every form on the page as stale before it reloads, so a tap racing the reload is refused. */
37
+ export declare const TOOLS_PROJECT_SCRIPT = "(function(){var s=document.querySelector('select[data-tools-project]');if(!s)return;s.addEventListener('change',function(){document.querySelectorAll('input[name=shown]').forEach(function(i){i.value=s.value;});});})();";
27
38
  export declare function toolsHtml(view: ToolsView, csrf: string, canManage: boolean, message?: {
28
39
  said?: string | null;
29
40
  problem?: string | null;
package/dist/tools-ui.js CHANGED
@@ -3,9 +3,18 @@
3
3
  import { toolCommandLine, toolStanding } from "./project-tools.js";
4
4
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
5
5
  const hidden = (data) => Object.entries(data).map(([k, v]) => `<input type="hidden" name="${e(k)}" value="${e(v)}">`).join("");
6
- export const TOOLS_CSS = `.tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready="false"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:"";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:32px 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile i{grid-row:span 2;width:32px;height:32px;border-radius:8px;display:grid;place-items:center;font-style:normal;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile span{font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state="connected"] span{color:var(--so-success)}.tools .connect-tile:disabled{cursor:default;opacity:.6}@media(max-width:600px){.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile span{display:none}.tools .connect-tile{grid-template-columns:28px 1fr;min-height:52px;padding:8px 10px}.tools .connect-tile i{grid-row:auto;width:28px;height:28px}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}.tools .tool-heading{align-items:flex-start}}`;
6
+ export const TOOLS_CSS = `.tools{max-width:780px;min-width:0;overflow-wrap:anywhere}.tools .card{padding:16px 18px;margin:12px 0;min-width:0}.tools .card h2{font-size:1.05rem;margin:0;color:var(--so-ink);font-weight:600}.tools .tool-heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px}.tools .tool-state{font-size:.85rem;flex-shrink:0;color:var(--so-muted)}.tools .tool-state[data-ready="false"]{color:var(--so-warning)}.tools .card>p{margin:6px 0 0}.tools code{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:.85rem}.tools form{display:grid;gap:10px;margin:0}.tools label{display:grid;gap:6px}.tools input,.tools select{box-sizing:border-box;width:100%;max-width:100%;min-width:0}.tools button{justify-self:start;white-space:nowrap;min-height:44px;max-width:100%;overflow:hidden;text-overflow:ellipsis}.tools details:not(.card){margin:6px 0 0;padding:0;border:0;border-radius:0;background:none;box-shadow:none}.tools details:not(.card)>summary{min-height:36px;display:flex;align-items:center;font-size:.9rem;color:var(--so-muted);cursor:pointer}.tools details:not(.card)[open]>summary{color:var(--so-ink)}.tools details.card>summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.tools summary{list-style:none;gap:10px}.tools summary::-webkit-details-marker{display:none}.tools summary::before{content:"";flex-shrink:0;width:6px;height:6px;margin:0 2px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .15s}.tools details[open]>summary::before{transform:rotate(45deg)}.tools details:not(.card) form{padding:4px 0 10px}.tools .tool-facts{margin:0 0 8px;display:grid;gap:4px;font-size:.9rem}.tools .tool-facts p{margin:0}.tools .tool-controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}.tools .tool-found{display:flex;gap:10px;align-items:flex-start}.tools .tool-found input{flex:0 0 auto;width:18px;height:18px;margin-top:3px}.tools .tool-found span{min-width:0}.tools .problem{padding:10px 12px;margin:8px 0 0}.tools .tool-connect{scroll-margin-top:72px}.tools .connect-wanted{background:var(--so-accent);color:var(--so-on-accent);border-color:var(--so-accent)}.tools .connect-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:8px}.tools .connect-tile{display:grid;grid-template-columns:32px 1fr;column-gap:10px;align-items:center;justify-self:stretch;text-align:left;white-space:normal;padding:10px 12px;border:1px solid var(--so-line);border-radius:10px;background:var(--so-paper);color:var(--so-ink);min-height:60px;font:inherit;cursor:pointer;scroll-margin-top:96px}.tools .connect-tile:hover{border-color:var(--so-input-line);background:var(--so-raised)}.tools .connect-tile:target,.tools .connect-tile:focus-visible{outline:2px solid var(--so-accent);outline-offset:2px}.tools .connect-tile i{grid-row:span 2;width:32px;height:32px;border-radius:8px;display:grid;place-items:center;font-style:normal;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.tools .connect-tile strong{font-weight:600;line-height:1.3}.tools .connect-tile span{font-size:.8rem;color:var(--so-muted);line-height:1.35;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.tools .connect-tile[data-state="connected"] span{color:var(--so-success)}.tools .connect-tile:disabled{cursor:default;opacity:.6}.tools .connect-local{grid-column:1/-1}.tools .connect-local span{-webkit-line-clamp:unset}.tools .connect-problem{margin:0}.tools .connect-problem:focus{outline:2px solid var(--so-accent);outline-offset:2px}@media(max-width:600px){.tools .connect-grid{grid-template-columns:1fr 1fr}.tools .connect-tile:not(.connect-local) span{display:none}.tools .connect-local span{grid-column:2}.tools .connect-tile{grid-template-columns:28px 1fr;min-height:52px;padding:8px 10px}.tools .connect-tile i{grid-row:auto;width:28px;height:28px}.tools input,.tools select{font-size:16px}.tools .card{padding:14px}.tools .tool-heading{align-items:flex-start}}`;
7
+ /** The project chooser: it opens the chosen project at once; without scripts, its button does. */
8
+ export function toolsProjectPicker(projects, chosen, keep = {}) {
9
+ if (projects.length < 2)
10
+ return "";
11
+ const options = projects.map(p => `<option value="${e(p)}"${p === chosen ? " selected" : ""}>${e(p.split(/[\\/]/).filter(Boolean).at(-1) ?? p)}</option>`).join("");
12
+ return `<form class="tools" method="get" action="/settings/tools" data-autosave>${hidden(keep)}<label>Project<select name="repo" data-tools-project>${options}</select></label><button type="submit">Show project</button></form>`;
13
+ }
14
+ /** Choosing another project marks every form on the page as stale before it reloads, so a tap racing the reload is refused. */
15
+ export const TOOLS_PROJECT_SCRIPT = `(function(){var s=document.querySelector('select[data-tools-project]');if(!s)return;s.addEventListener('change',function(){document.querySelectorAll('input[name=shown]').forEach(function(i){i.value=s.value;});});})();`;
7
16
  const password = '<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>';
8
- function toolCard(one, base, manage) {
17
+ function toolCard(one, base, manage, project) {
9
18
  const { tool, secretsSet } = one;
10
19
  const standing = toolStanding(tool, secretsSet);
11
20
  // One line per secret: the disclosure is both where it stands and where it is set.
@@ -16,25 +25,38 @@ function toolCard(one, base, manage) {
16
25
  const tested = tool.lastTest?.ok ? `<p>Tested ${e(tool.lastTest.at.slice(0, 16).replace("T", " "))} UTC: ${e(tool.lastTest.tools.join(", ") || "it lists no tools")}</p>` : "";
17
26
  const details = `<details><summary>Details</summary><div class="tool-facts"><p>Starts: <code>${e(toolCommandLine(tool.spec))}</code></p><p>From ${e(tool.source)} · added by ${e(tool.createdBy)}</p>${tested}</div></details>`;
18
27
  const failed = tool.lastTest !== null && !tool.lastTest.ok ? `<p class="problem" role="status">${e(tool.lastTest.problem ?? "The last test failed.")}</p>` : "";
19
- const controls = manage ? `<div class="tool-controls"><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "test", name: tool.name })}<button>Test</button></form><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "remove", name: tool.name })}<button class="secondary">Remove</button></form></div>` : "";
28
+ const controls = manage ? `<div class="tool-controls"><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "test", name: tool.name })}<button aria-label="Test ${e(tool.name)} in ${e(project)}">Test</button></form><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "remove", name: tool.name })}<button class="secondary">Remove from ${e(project)}</button></form></div>` : "";
20
29
  return `<article class="card" id="tool-${e(tool.name)}"><div class="tool-heading"><h2>${e(tool.name)}</h2><span class="tool-state" data-ready="${standing.ready}">${e(standing.words)}</span></div><p>${e(tool.spec.about)}</p>${failed}${secrets}${details}${controls}</article>`;
21
30
  }
31
+ /** A service connected here, offered to the person's other projects: each button signs in again for that project (tokens are never copied). */
32
+ function elsewhere(view, base) {
33
+ const offers = view.connections.filter(one => one.state === "connected").flatMap(one => (view.others ?? []).filter(other => other.open.includes(one.id)).map(other => `<button name="also" value="${e(`${one.id}:${other.repo}`)}" class="secondary">Also connect ${e(one.label)} to ${e(other.project)}</button>`));
34
+ if (offers.length === 0)
35
+ return "";
36
+ return `<details class="tool-elsewhere"><summary>Also connect to another project</summary><form method="post" action="/settings/tools/connect">${hidden({ ...base, ...(view.kit === null ? {} : { kit: view.kit }) })}${password}<div class="tool-controls">${offers.join("")}</div></form></details>`;
37
+ }
22
38
  export function toolsHtml(view, csrf, canManage, message = {}) {
23
39
  const manage = canManage && csrf !== "";
24
- const base = { csrf, repo: view.repo };
40
+ // `shown` is the project this page shows; the server refuses a post whose repo differs from it.
41
+ const base = { csrf, repo: view.repo, shown: view.repo };
42
+ const to = ` to ${e(view.project)}`;
25
43
  const intro = `<p>Builds in ${e(view.project)} use exactly these tools. MCP servers set up elsewhere on this computer aren't used.</p><details><summary>How tools work</summary><ul><li>A tool you add reaches work you approve from now on. Approve a task again to give it a new tool.</li><li>Removing a tool takes it away from every build right away.</li><li>Secrets stay on this computer, are never shown again, and go only to the tool that needs them.</li><li>Reviewers and the lead chat never get tools. Gemini builds don't use tools yet.</li></ul></details>`;
26
- const cards = view.tools.length === 0 ? '<p class="meta">No tools yet. Builds in this project get none.</p>' : view.tools.map(one => toolCard(one, base, manage)).join("");
27
- const fromList = view.catalog.length === 0 ? "" : `<form method="post" action="/settings/tools/change">${hidden({ ...base, action: "add-catalog" })}<label>Common tools<select name="catalog">${view.catalog.map(one => `<option value="${e(one.name)}">${e(one.label)}: ${e(one.about)}</option>`).join("")}</select></label>${password}<button>Add</button></form>`;
28
- const custom = `<details><summary>Add your own</summary><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "add-custom" })}<label>Name<input name="name" pattern="[a-z0-9][a-z0-9_\\-]{0,39}" maxlength="40" required placeholder="for example: postgres"></label><label>It runs as<select name="transport"><option value="stdio">A program on this computer</option><option value="http">A web address</option></select></label><label>Command or address<input name="target" required placeholder="npx -y some-mcp-server@1.2.3, or https://…"></label><label>Secrets it needs (names, comma separated)<input name="secrets" placeholder="for example: DATABASE_URL"></label><p class="meta">For a web address, the first secret is sent as its sign-in token. Set each secret's value after adding.</p>${password}<button>Add</button></form></details>`;
44
+ const cards = view.tools.length === 0 ? '<p class="meta">No tools yet. Builds in this project get none.</p>' : view.tools.map(one => toolCard(one, base, manage, view.project)).join("");
45
+ const fromList = view.catalog.length === 0 ? "" : `<form method="post" action="/settings/tools/change">${hidden({ ...base, action: "add-catalog" })}<label>Common tools<select name="catalog">${view.catalog.map(one => `<option value="${e(one.name)}">${e(one.label)}: ${e(one.about)}</option>`).join("")}</select></label>${password}<button>Add${to}</button></form>`;
46
+ const custom = `<details><summary>Add your own</summary><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "add-custom" })}<label>Name<input name="name" pattern="[a-z0-9][a-z0-9_\\-]{0,39}" maxlength="40" required placeholder="for example: postgres"></label><label>It runs as<select name="transport"><option value="stdio">A program on this computer</option><option value="http">A web address</option></select></label><label>Command or address<input name="target" required placeholder="npx -y some-mcp-server@1.2.3, or https://…"></label><label>Secrets it needs (names, comma separated)<input name="secrets" placeholder="for example: DATABASE_URL"></label><p class="meta">For a web address, the first secret is sent as its sign-in token. Set each secret's value after adding.</p>${password}<button>Add${to}</button></form></details>`;
29
47
  // One click: sign in on the service's own page. The password is the same as for adding any tool.
30
48
  const tiles = view.connections.map(one => one.state === "taken"
31
49
  ? `<button type="button" class="connect-tile" id="connect-${e(one.id)}" data-state="taken" disabled><i aria-hidden="true">${e(one.label.charAt(0))}</i><strong>${e(one.label)}</strong><span>Added another way</span></button>`
32
- : `<button name="service" value="${e(one.id)}" class="connect-tile" id="connect-${e(one.id)}" data-state="${one.state}"${one.state === "connected" ? ` aria-label="${e(one.label)}, connected. Sign in again"` : ""}><i aria-hidden="true">${e(one.label.charAt(0))}</i><strong>${e(one.label)}</strong><span>${one.state === "connected" ? "Connected" : e(one.about)}</span></button>`).join("");
50
+ : `<button name="service" value="${e(one.id)}" class="connect-tile${one.local ? " connect-local" : ""}" id="connect-${e(one.id)}" data-state="${one.state}" aria-label="${one.state === "connected" ? `${e(one.label)}, connected${to}. ${one.local ? "Connect again" : "Sign in again"}` : `Connect ${e(one.label)}${to}`}"><i aria-hidden="true">${e(one.label.charAt(0))}</i><strong>${e(one.label)}</strong><span>${one.state === "connected" ? "Connected" : e(one.about)}</span></button>`).join("");
33
51
  const wanted = view.connections.find(one => one.id === view.wanted && one.state !== "taken");
34
- const first = wanted === undefined ? "" : `<button name="service" value="${e(wanted.id)}" class="connect-wanted">${wanted.state === "connected" ? `Sign in to ${e(wanted.label)} again` : `Connect ${e(wanted.label)}`}</button>`;
35
- const connect = manage && view.connections.length > 0 ? `<section class="card tool-connect" id="connect" aria-labelledby="connect-heading"><h2 id="connect-heading">Connect with one click</h2><form method="post" action="/settings/tools/connect">${hidden({ ...base, ...(view.kit === null ? {} : { kit: view.kit }) })}${password}${first}<div class="connect-grid">${tiles}</div></form></section>` : "";
52
+ const first = wanted === undefined ? "" : `<button name="service" value="${e(wanted.id)}" class="connect-wanted">${wanted.state === "connected" && !wanted.local ? `Sign in to ${e(wanted.label)} again for ${e(view.project)}` : `Connect ${e(wanted.label)}${to}`}</button>`;
53
+ const connecting = manage && view.connections.length > 0;
54
+ // A Connect that failed says why at its button, focused so it is heard and seen there (not only at the top).
55
+ const atButton = connecting && view.wanted !== null && view.connections.some(one => one.id === view.wanted) && Boolean(message.problem);
56
+ const failed = atButton ? `<p class="problem connect-problem" id="connect-problem" role="alert" tabindex="-1" autofocus>${e(message.problem)}</p>` : "";
57
+ const connect = connecting ? `<section class="card tool-connect" id="connect" aria-labelledby="connect-heading"><h2 id="connect-heading">Connect${to}</h2><form method="post" action="/settings/tools/connect">${hidden({ ...base, ...(view.kit === null ? {} : { kit: view.kit }) })}${password}${first}${failed}<div class="connect-grid">${tiles}</div></form>${elsewhere(view, base)}</section>` : "";
36
58
  const add = manage ? `<details class="card"${view.tools.length === 0 ? " open" : ""}><summary>Add a tool</summary>${fromList}${custom}</details>` : "";
37
- const found = manage && view.found.length > 0 ? `<details class="card"><summary>Found on this computer (${view.found.length})</summary><p class="meta">MCP servers your other apps use. Adding one copies its settings, and any secrets it has, into this project.</p><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "import" })}${view.found.map(one => `<label class="tool-found"><input type="checkbox" name="import" value="${e(one.spec.name)}"><span><strong>${e(one.spec.name)}</strong> · ${e(one.source)}<br><code>${e(toolCommandLine(one.spec))}</code>${one.spec.secrets.length === 0 ? "" : `<br><span class="meta">Brings ${e(one.spec.secrets.map(s => s.name).join(", "))}</span>`}</span></label>`).join("")}${password}<button>Add selected</button></form></details>` : "";
38
- const note = message.problem ? `<p class="problem" role="alert">${e(message.problem)}</p>` : message.said ? `<p role="status">${e(message.said)}</p>` : "";
59
+ const found = manage && view.found.length > 0 ? `<details class="card"><summary>Found on this computer (${view.found.length})</summary><p class="meta">MCP servers your other apps use. Adding one copies its settings, and any secrets it has, into this project.</p><form method="post" action="/settings/tools/change">${hidden({ ...base, action: "import" })}${view.found.map(one => `<label class="tool-found"><input type="checkbox" name="import" value="${e(one.spec.name)}"><span><strong>${e(one.spec.name)}</strong> · ${e(one.source)}<br><code>${e(toolCommandLine(one.spec))}</code>${one.spec.secrets.length === 0 ? "" : `<br><span class="meta">Brings ${e(one.spec.secrets.map(s => s.name).join(", "))}</span>`}</span></label>`).join("")}${password}<button>Add selected${to}</button></form></details>` : "";
60
+ const note = atButton ? "" : message.problem ? `<p class="problem" role="alert">${e(message.problem)}</p>` : message.said ? `<p role="status">${e(message.said)}</p>` : "";
39
61
  return `<section class="tools">${note}${intro}${cards}${connect}${add}${found}</section>`;
40
62
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.25",
3
+ "version": "0.9.26",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"