toolroll 0.9.24 → 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.
@@ -125,6 +125,7 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
125
125
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
126
126
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
127
127
  export declare function desktopUpdateStatus(stateDir: string): {
128
+ version?: never;
128
129
  active: boolean;
129
130
  phase: string;
130
131
  detail: string;
@@ -138,7 +139,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
138
139
  wasRunning?: never;
139
140
  backupPath?: never;
140
141
  updateId?: never;
141
- version?: never;
142
142
  buildId?: never;
143
143
  workDir?: never;
144
144
  } | {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Flow passes that don't wait for builds. The worker's pass used to move
3
+ * cards only between builds, so a card whose research finished sat still
4
+ * while another build ran (Oct 4: five minutes). Now the worker runs these
5
+ * passes on their own cadence beside its builds, and a finished build,
6
+ * research or check moves its card at once.
7
+ *
8
+ * Model-free and cheap: nothing here starts a build. Cards entering a build
9
+ * zone file their task, which waits for a free slot like any other.
10
+ */
11
+ import { type FlowAdvance } from "./flow-engine.js";
12
+ import { type TriggerIo, type TriggerPass } from "./flow-triggers.js";
13
+ import { type ReplyWatch, type ReplyWatchIo } from "./flow-replies.js";
14
+ import { type StepIo, type StepPass } from "./flow-steps.js";
15
+ import type { Store } from "./store.js";
16
+ /** How often the worker moves cards, checks triggers, reads replies and runs steps, builds or not. */
17
+ export declare const FLOW_EVERY_MS = 15000;
18
+ /** One pass of a kind for a project at a time in this process: a second one asked for while it runs is skipped
19
+ * (`idle`), and the next cadence takes up whatever it would have done. Cards are also claimed in the database,
20
+ * so passes in other processes can't act twice either. */
21
+ export declare function alone<T>(kind: string, project: string, idle: T, run: () => Promise<T>): Promise<T>;
22
+ export type FlowIo = {
23
+ triggers: TriggerIo;
24
+ replies: ReplyWatchIo;
25
+ steps: StepIo;
26
+ evidenceRoot?: string;
27
+ };
28
+ export type FlowHousekeeping = {
29
+ triggers: TriggerPass;
30
+ replies: ReplyWatch;
31
+ steps: StepPass;
32
+ flows: FlowAdvance;
33
+ };
34
+ /** One project's flow pass: triggers (which may start cards), replies (which move waiting cards), steps, then moves.
35
+ * `halted` (a stop or paused admission) skips everything that reaches outside; moves still run. */
36
+ export declare function flowHousekeeping(store: Store, repo: string, clock: () => Date, io: FlowIo, halted: () => boolean): Promise<FlowHousekeeping>;
37
+ /** Move one project's cards. Synchronous, so two can't overlap in a process; each move is checked against the
38
+ * card's visit in its own transaction, so a pass in another process can't move a card twice. */
39
+ export declare function moveCards(store: Store, repo: string, now: Date, evidenceRoot: string | undefined): FlowAdvance;
40
+ /** A build, research or check just finished: move the cards in this project, and in the project of any flow whose
41
+ * card that task belongs to (a build zone may build in another project), now rather than on the next pass. */
42
+ export declare function moveCardsAfter(store: Store, repo: string, tasks: readonly string[], now: Date, evidenceRoot: string | undefined): FlowAdvance;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Flow passes that don't wait for builds. The worker's pass used to move
3
+ * cards only between builds, so a card whose research finished sat still
4
+ * while another build ran (Oct 4: five minutes). Now the worker runs these
5
+ * passes on their own cadence beside its builds, and a finished build,
6
+ * research or check moves its card at once.
7
+ *
8
+ * Model-free and cheap: nothing here starts a build. Cards entering a build
9
+ * zone file their task, which waits for a free slot like any other.
10
+ */
11
+ import { advanceFlows } from "./flow-engine.js";
12
+ import { runFlowTriggers } from "./flow-triggers.js";
13
+ import { watchFlowReplies } from "./flow-replies.js";
14
+ import { runFlowSteps } from "./flow-steps.js";
15
+ /** How often the worker moves cards, checks triggers, reads replies and runs steps, builds or not. */
16
+ export const FLOW_EVERY_MS = 15_000;
17
+ const running = new Set();
18
+ /** One pass of a kind for a project at a time in this process: a second one asked for while it runs is skipped
19
+ * (`idle`), and the next cadence takes up whatever it would have done. Cards are also claimed in the database,
20
+ * so passes in other processes can't act twice either. */
21
+ export async function alone(kind, project, idle, run) {
22
+ const key = `${kind}\u0000${project}`;
23
+ if (running.has(key))
24
+ return idle;
25
+ running.add(key);
26
+ try {
27
+ return await run();
28
+ }
29
+ finally {
30
+ running.delete(key);
31
+ }
32
+ }
33
+ /** One project's flow pass: triggers (which may start cards), replies (which move waiting cards), steps, then moves.
34
+ * `halted` (a stop or paused admission) skips everything that reaches outside; moves still run. */
35
+ export async function flowHousekeeping(store, repo, clock, io, halted) {
36
+ const triggers = halted() ? { added: 0, checked: 0, problems: [] }
37
+ : await alone("triggers", repo, { added: 0, checked: 0, problems: [] }, () => runFlowTriggers(store, repo, clock(), io.triggers));
38
+ // One mailbox serves every project, so its reads are one at a time across them all.
39
+ const replies = halted() ? { read: 0, taken: 0, problem: null }
40
+ : await alone("replies", "*", { read: 0, taken: 0, problem: null }, () => watchFlowReplies(store, clock(), io.replies));
41
+ const steps = halted() ? { ran: 0, problems: [] }
42
+ : await alone("steps", repo, { ran: 0, problems: [] }, () => runFlowSteps(store, repo, clock(), io.steps));
43
+ // Last, so a step that just finished, or a card a trigger just added, moves now.
44
+ const flows = moveCards(store, repo, clock(), io.evidenceRoot);
45
+ return { triggers, replies, steps, flows };
46
+ }
47
+ /** Move one project's cards. Synchronous, so two can't overlap in a process; each move is checked against the
48
+ * card's visit in its own transaction, so a pass in another process can't move a card twice. */
49
+ export function moveCards(store, repo, now, evidenceRoot) {
50
+ try {
51
+ return advanceFlows(store, repo, now, evidenceRoot === undefined ? {} : { evidenceRoot });
52
+ }
53
+ catch (error) {
54
+ return { moved: 0, filed: [], problems: [`flows: ${error instanceof Error ? error.message : "could not move cards"}`] };
55
+ }
56
+ }
57
+ /** A build, research or check just finished: move the cards in this project, and in the project of any flow whose
58
+ * card that task belongs to (a build zone may build in another project), now rather than on the next pass. */
59
+ export function moveCardsAfter(store, repo, tasks, now, evidenceRoot) {
60
+ const projects = new Set([repo]);
61
+ for (const task of tasks) {
62
+ try {
63
+ const card = store.flowCardByTask(task) ?? store.flowCardByTask(store.taskFamilyOf(task, null, true)?.root.id ?? task);
64
+ const flow = card === null ? null : store.getFlow(card.flow);
65
+ if (flow !== null)
66
+ projects.add(flow.repo);
67
+ }
68
+ catch { /* not a task with a card */ }
69
+ }
70
+ const outcome = { moved: 0, filed: [], problems: [] };
71
+ for (const project of projects) {
72
+ const moved = moveCards(store, project, now, evidenceRoot);
73
+ outcome.moved += moved.moved;
74
+ outcome.filed.push(...moved.filed);
75
+ outcome.problems.push(...moved.problems);
76
+ }
77
+ return outcome;
78
+ }
package/dist/invoke.d.ts CHANGED
@@ -103,6 +103,9 @@ export declare function invokeAgent(store: Store, runId: number, spec: AgentSpec
103
103
  * hold belongs to the coordinator, never to a promise chain that would
104
104
  * stall the watch.
105
105
  */
106
+ /** What a held session may use of the project's tools: a research run, which may only read and isn't told here which
107
+ * actions are read-only, gets none at all; any other run gets them as launched. */
108
+ export declare function heldReadOnly(store: Store, runId: number): Readonly<Record<string, readonly string[]>> | undefined;
106
109
  export declare function invokeHeldAgent(store: Store, runId: number, spec: AgentSpec, argv: readonly string[], options: import("./exec.js").RunOptions & {
107
110
  socketPath: string;
108
111
  cookie: string;
package/dist/invoke.js CHANGED
@@ -292,7 +292,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
292
292
  const isolatedDb = isolatedAgentDatabase(runId);
293
293
  // The project's tools (v80): exactly its MCP servers, and none of the
294
294
  // operator's global or a repository's own. A review keeps its isolation.
295
- const tools = invocation.phase === "review" ? null : runTools(store, runId, spec, options.keyHome, clock);
295
+ const tools = invocation.phase === "review" ? null : runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools);
296
296
  // The agent fence: Toolroll's own secrets, database and other runs'
297
297
  // evidence stay out of reach, whatever the permission mode. A review is
298
298
  // already confined to its sealed files.
@@ -583,6 +583,11 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
583
583
  * hold belongs to the coordinator, never to a promise chain that would
584
584
  * stall the watch.
585
585
  */
586
+ /** What a held session may use of the project's tools: a research run, which may only read and isn't told here which
587
+ * actions are read-only, gets none at all; any other run gets them as launched. */
588
+ export function heldReadOnly(store, runId) {
589
+ return store.getRun(runId)?.role === "scout" ? {} : undefined;
590
+ }
586
591
  export async function invokeHeldAgent(store, runId, spec, argv, options) {
587
592
  const clock = options.clock ?? (() => new Date());
588
593
  const run = store.getRun(runId);
@@ -624,7 +629,7 @@ export async function invokeHeldAgent(store, runId, spec, argv, options) {
624
629
  const isolatedDb = isolatedAgentDatabase(runId);
625
630
  // The project's tools (v80), as for every build: exactly its MCP servers;
626
631
  // and the agent fence around Toolroll's own secrets.
627
- const heldTools = runTools(store, runId, spec, keyHome, clock);
632
+ const heldTools = runTools(store, runId, spec, keyHome, clock, heldReadOnly(store, runId));
628
633
  const heldFence = runFence(store, runId, keyHome);
629
634
  argv = [...argv, ...heldTools.argv, ...(heldFence.length > 0 ? ["--settings", claudeFenceSettings(heldFence)] : [])];
630
635
  const heldLaunch = fenceLaunch("claude", heldFence);
@@ -786,9 +791,9 @@ function fenceLaunch(provider, fence) {
786
791
  return { wrap: [], method: provider === "claude" ? "claude-rules" : "none" };
787
792
  }
788
793
  /** One launch's tools, or — when they cannot be prepared — the same isolation with none, never the operator's global servers. */
789
- function runTools(store, runId, spec, keyHome, clock) {
794
+ function runTools(store, runId, spec, keyHome, clock, readOnly) {
790
795
  try {
791
- return prepareRunTools(store, runId, spec.provider, { ...(keyHome === undefined ? {} : { home: keyHome }), now: clock(), includeModel: spec.model === null });
796
+ return prepareRunTools(store, runId, spec.provider, { ...(keyHome === undefined ? {} : { home: keyHome }), now: clock(), includeModel: spec.model === null, ...(readOnly === undefined ? {} : { readOnly }) });
792
797
  }
793
798
  catch {
794
799
  return noToolsArgs(spec.provider, spec.model === null);
@@ -761,7 +761,7 @@ export const MATE_TOOLS = [
761
761
  },
762
762
  {
763
763
  name: "get_project_tools",
764
- description: "Read a project's tools (the MCP servers its builds get, and only those), the services that connect by signing in, the common tools list and servers found on this computer. A service in connectBySigningIn (Stripe, Notion, Linear, Sentry, Jira…) connects in one click: the person signs in on the service's own page, so give them its connect link and never propose tool_add for it. Add/remove others with propose_action tool_add/tool_remove; secrets are set only on the Tools page.",
764
+ description: "Read a project's tools (the MCP servers its builds get, and only those), the services that connect by signing in, the common tools list and servers found on this computer. A service in connectBySigningIn (Stripe, Notion, Linear, Sentry, Jira…) connects in one click: the person signs in on the service's own page, so give them its connect link (for this repo; another project has its own) and never propose tool_add for it. When saying a service is connected, name the project it is connected to (its list_repos name). Add/remove others with propose_action tool_add/tool_remove; secrets are set only on the Tools page.",
765
765
  inputSchema: schema({ repo: REPO_ARG }, ["repo"]),
766
766
  handle: (ctx, args) => {
767
767
  const repo = repoPathOf(ctx.who, args["repo"]);
@@ -1,12 +1,29 @@
1
- import { type ToolSpec } from "./project-tools.js";
1
+ import { type ProjectTool, type ToolSpec } from "./project-tools.js";
2
2
  import type { Store } from "./store.js";
3
- /** Services verified to connect this way (their servers speak streamable HTTP and register clients on the spot). */
4
- export declare const ONE_CLICK: readonly {
3
+ /**
4
+ * A one-click service. `reads` names what a research step may read there:
5
+ * an action is read-only only when it is named by a reading verb (READING)
6
+ * and every other word in its name is one of these.
7
+ */
8
+ export type OneClick = {
5
9
  id: string;
6
10
  label: string;
7
11
  url: string;
8
12
  about: string;
9
- }[];
13
+ reads?: readonly string[];
14
+ };
15
+ /**
16
+ * Services that connect this way: their servers speak streamable HTTP and
17
+ * advertise registering clients on the spot. Advertising isn't accepting:
18
+ * Figma's server advertises it, but its registration answers 403 to any app
19
+ * Figma hasn't approved, so Figma connects through its desktop app instead
20
+ * (LOCAL_APPS). A service is listed only when its registration accepts
21
+ * Toolroll; mcp-connect.test.ts replays each one's answer (no live calls).
22
+ *
23
+ * Signed in end to end, live: Mobbin. The rest register in replayed checks
24
+ * only, until someone signs in to each for real.
25
+ */
26
+ export declare const ONE_CLICK: readonly OneClick[];
10
27
  /**
11
28
  * The list, with one service standing in on this computer when the person
12
29
  * running Toolroll says so ("stripe|Stripe|http://127.0.0.1:5123/mcp"
@@ -14,19 +31,45 @@ export declare const ONE_CLICK: readonly {
14
31
  * real without a real account. Only a loopback address is taken.
15
32
  */
16
33
  export declare function oneClickServices(environment?: NodeJS.ProcessEnv): typeof ONE_CLICK;
17
- export declare const oneClickOf: (id: string) => {
34
+ export declare const oneClickOf: (id: string) => OneClick | null;
35
+ /** An app on this computer that Connect adds without a sign-in (LOCAL_APPS): its tool, name and one line of how. */
36
+ export declare const localConnectOf: (id: string) => {
18
37
  id: string;
19
38
  label: string;
20
- url: string;
21
39
  about: string;
22
40
  } | null;
23
- /** Where each service stands in a project: connected by signing in, open to connect, or its name taken by a tool set up another way. */
41
+ /** Where each service stands in a project: connected, open to connect, or its name taken by a tool set up another way. `local` connects with no sign-in. */
24
42
  export declare function connectionsOf(store: Store, repo: string): {
25
43
  id: string;
26
44
  label: string;
27
45
  about: string;
28
46
  state: "connected" | "open" | "taken";
47
+ local?: true;
29
48
  }[];
49
+ /**
50
+ * Whether an action only reads: its name has a reading verb, and every other
51
+ * word is a reading verb, a qualifier or something this service lets
52
+ * research read. An allow-list: a word nobody listed withholds the action.
53
+ */
54
+ export declare function readsOnly(action: string, subjects: readonly string[]): boolean;
55
+ /**
56
+ * What a research step may use of a project's connected services: each
57
+ * signed-in service's read-only actions, as its last test listed them —
58
+ * by server (`reads`, for Codex's `enabled_tools`) and named the way Claude
59
+ * allows them (`mcp__<service>__<action>`). `local` is the loopback host of
60
+ * each app on this computer it reads (its proxy lets only those through). A service not connected, without
61
+ * read-only actions, or left out of this run (`launched`) gives nothing.
62
+ */
63
+ export type ResearchTools = {
64
+ services: {
65
+ id: string;
66
+ label: string;
67
+ }[];
68
+ allowed: string[];
69
+ reads: Record<string, string[]>;
70
+ local: string[];
71
+ };
72
+ export declare function researchToolsOf(tools: readonly ProjectTool[], launched?: ReadonlySet<string> | null): ResearchTools;
30
73
  /** Where a service sends the person back after they sign in. */
31
74
  export declare const CONNECT_CALLBACK = "/settings/tools/connected";
32
75
  type Fetch = typeof fetch;
@@ -13,9 +13,20 @@
13
13
  */
14
14
  import { createHash, randomBytes } from "node:crypto";
15
15
  import { homedir } from "node:os";
16
- import { addToolTo, projectToolsOf, readToolSecrets, setToolSecrets, testToolOf } from "./project-tools.js";
16
+ import { basename } from "node:path";
17
+ import { LOCAL_APPS, addToolTo, catalogTool, localAppOf, projectToolsOf, readToolSecrets, setToolSecrets, testToolOf } from "./project-tools.js";
17
18
  import { envValue } from "./names.js";
18
- /** Services verified to connect this way (their servers speak streamable HTTP and register clients on the spot). */
19
+ /**
20
+ * Services that connect this way: their servers speak streamable HTTP and
21
+ * advertise registering clients on the spot. Advertising isn't accepting:
22
+ * Figma's server advertises it, but its registration answers 403 to any app
23
+ * Figma hasn't approved, so Figma connects through its desktop app instead
24
+ * (LOCAL_APPS). A service is listed only when its registration accepts
25
+ * Toolroll; mcp-connect.test.ts replays each one's answer (no live calls).
26
+ *
27
+ * Signed in end to end, live: Mobbin. The rest register in replayed checks
28
+ * only, until someone signs in to each for real.
29
+ */
19
30
  export const ONE_CLICK = [
20
31
  { id: "stripe", label: "Stripe", url: "https://mcp.stripe.com/", about: "Payments, customers, refunds and invoices." },
21
32
  { id: "notion", label: "Notion", url: "https://mcp.notion.com/mcp", about: "Pages and databases: search, read and write." },
@@ -33,6 +44,13 @@ export const ONE_CLICK = [
33
44
  { id: "canva", label: "Canva", url: "https://mcp.canva.com/mcp", about: "Designs: search, create and export." },
34
45
  { id: "vercel", label: "Vercel", url: "https://mcp.vercel.com/", about: "Projects, deployments and logs." },
35
46
  { id: "cloudflare", label: "Cloudflare", url: "https://mcp.cloudflare.com/mcp", about: "Workers, DNS and your Cloudflare account." },
47
+ // Mobbin's sign-in is its Supabase auth server, on another origin: its protected-resource metadata names it.
48
+ { id: "mobbin", label: "Mobbin", url: "https://api.mobbin.com/mcp", about: "Real app screens and flows to learn from.",
49
+ reads: ["screen", "flow", "app", "site", "element", "pattern", "ui", "ios", "android", "web"] },
50
+ { id: "posthog", label: "PostHog", url: "https://mcp.posthog.com/mcp", about: "Product analytics, funnels and events.",
51
+ reads: ["insight", "run", "event", "definition", "property", "properties"] },
52
+ { id: "betterstack", label: "Better Stack", url: "https://mcp.betterstack.com", about: "Uptime checks and incidents.",
53
+ reads: ["uptime", "monitor", "incident", "availability", "response", "time"] },
36
54
  ];
37
55
  const loopback = (value) => { try {
38
56
  const url = new URL(value);
@@ -51,17 +69,72 @@ export function oneClickServices(environment = process.env) {
51
69
  const [id = "", label = "", url = ""] = (envValue(environment, "TEST_CONNECT") ?? "").split("|");
52
70
  if (!/^[a-z0-9-]{1,40}$/.test(id) || label === "" || !loopback(url))
53
71
  return ONE_CLICK;
54
- const standIn = { id, label, url, about: ONE_CLICK.find(one => one.id === id)?.about ?? "A service on this computer." };
72
+ const known = ONE_CLICK.find(one => one.id === id);
73
+ const standIn = { id, label, url, about: known?.about ?? "A service on this computer.", ...(known?.reads === undefined ? {} : { reads: known.reads }) };
55
74
  return ONE_CLICK.some(one => one.id === id) ? ONE_CLICK.map(one => one.id === id ? standIn : one) : [...ONE_CLICK, standIn];
56
75
  }
57
76
  export const oneClickOf = (id) => oneClickServices().find(one => one.id === id) ?? null;
58
- /** Where each service stands in a project: connected by signing in, open to connect, or its name taken by a tool set up another way. */
77
+ /** What to use when a service won't let Toolroll sign in by itself. */
78
+ const INSTEAD = { figma: "Connect Figma (desktop app) instead." };
79
+ /** An app on this computer that Connect adds without a sign-in (LOCAL_APPS): its tool, name and one line of how. */
80
+ export const localConnectOf = (id) => {
81
+ const app = LOCAL_APPS.find(one => one.tool === id), tool = catalogTool(id);
82
+ return app === undefined || tool === null ? null : { id, label: tool.label, about: app.connect };
83
+ };
84
+ /** Where each service stands in a project: connected, open to connect, or its name taken by a tool set up another way. `local` connects with no sign-in. */
59
85
  export function connectionsOf(store, repo) {
60
86
  const tools = projectToolsOf(store, repo);
61
- return oneClickServices().map(service => {
87
+ const signIn = oneClickServices().map(service => {
62
88
  const had = tools.find(one => one.name === service.id);
63
89
  return { id: service.id, label: service.label, about: service.about, state: had === undefined ? "open" : had.spec.url === service.url && had.spec.bearer === ACCESS ? "connected" : "taken" };
64
90
  });
91
+ const local = LOCAL_APPS.flatMap(app => {
92
+ const one = localConnectOf(app.tool);
93
+ if (one === null)
94
+ return [];
95
+ const had = tools.find(tool => tool.name === one.id);
96
+ return [{ ...one, state: had === undefined ? "open" : localAppOf(had.spec) !== null ? "connected" : "taken", local: true }];
97
+ });
98
+ return [...signIn, ...local];
99
+ }
100
+ /** The verbs that only read. An action named by anything else is withheld from research. */
101
+ const READING = new Set(["list", "get", "search", "find", "read", "query", "describe", "fetch", "view", "export-image"]);
102
+ /** Words that only qualify what is read ("insights-get-all", "get_by_id"). */
103
+ const QUALIFIERS = new Set(["all", "by", "id", "ids"]);
104
+ /**
105
+ * Whether an action only reads: its name has a reading verb, and every other
106
+ * word is a reading verb, a qualifier or something this service lets
107
+ * research read. An allow-list: a word nobody listed withholds the action.
108
+ */
109
+ export function readsOnly(action, subjects) {
110
+ if (!/^[A-Za-z0-9_.-]{1,64}$/.test(action))
111
+ return false;
112
+ const words = action.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(word => word !== "")
113
+ .join(" ").replace(/\bexport images?\b/g, "export-image").split(" ");
114
+ const subject = (word) => subjects.includes(word) || subjects.includes(word.replace(/s$/, ""));
115
+ return words.some(word => READING.has(word)) && words.every(word => READING.has(word) || QUALIFIERS.has(word) || subject(word));
116
+ }
117
+ export function researchToolsOf(tools, launched = null) {
118
+ const research = { services: [], allowed: [], reads: {}, local: [] };
119
+ // Signed-in services, then apps on this computer (each exactly as listed: its own address, no key).
120
+ const readable = [
121
+ ...oneClickServices().map(service => ({ id: service.id, label: service.label, reads: service.reads, is: (spec) => spec.url === service.url && spec.bearer === ACCESS })),
122
+ ...LOCAL_APPS.map(app => ({ id: app.tool, label: catalogTool(app.tool)?.label ?? app.tool, reads: app.reads, is: (spec) => localAppOf(spec) === app })),
123
+ ];
124
+ for (const service of readable) {
125
+ const tool = tools.find(one => one.name === service.id);
126
+ if (service.reads === undefined || tool === undefined || !service.is(tool.spec) || (launched !== null && !launched.has(tool.name)))
127
+ continue;
128
+ const reads = (tool.lastTest?.ok ? tool.lastTest.tools : []).filter(action => readsOnly(action, service.reads));
129
+ if (reads.length === 0)
130
+ continue;
131
+ research.services.push({ id: service.id, label: service.label });
132
+ research.allowed.push(...reads.map(action => `mcp__${service.id}__${action}`));
133
+ research.reads[service.id] = reads;
134
+ if (localAppOf(tool.spec) !== null)
135
+ research.local.push(new URL(tool.spec.url).host);
136
+ }
137
+ return research;
65
138
  }
66
139
  /** The secrets a connected tool keeps: the bearer the MCP server takes, and what refreshing it needs. */
67
140
  const ACCESS = "OAUTH_ACCESS_TOKEN", REFRESH = "OAUTH_REFRESH_TOKEN", CLIENT = "OAUTH_CLIENT_ID", CLIENT_SECRET = "OAUTH_CLIENT_SECRET", TOKEN_URL = "OAUTH_TOKEN_URL", EXPIRES = "OAUTH_EXPIRES_AT", RESOURCE = "OAUTH_RESOURCE";
@@ -110,6 +183,7 @@ export async function discoverSignIn(mcpUrl, fetcher = fetch) {
110
183
  const path = issuer.pathname === "/" ? "" : issuer.pathname.replace(/\/$/, "");
111
184
  const meta = await json(fetcher, `${issuer.origin}/.well-known/oauth-authorization-server${path}`)
112
185
  ?? await json(fetcher, `${issuer.origin}/.well-known/oauth-authorization-server`)
186
+ ?? (path === "" ? null : await json(fetcher, `${issuer.origin}${path}/.well-known/openid-configuration`))
113
187
  ?? await json(fetcher, `${issuer.origin}/.well-known/openid-configuration`);
114
188
  if (meta === null || !https(meta["authorization_endpoint"]) || !https(meta["token_endpoint"]) || !https(meta["registration_endpoint"]))
115
189
  return null;
@@ -138,6 +212,9 @@ export async function startConnect(input, fetcher = fetch, now = Date.now()) {
138
212
  try {
139
213
  const registered = await fetcher(server.register, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, signal: AbortSignal.timeout(10_000),
140
214
  body: JSON.stringify({ client_name: "Toolroll", redirect_uris: [redirect], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"], token_endpoint_auth_method: "none" }) });
215
+ // Figma's answer (Oct 2026): it registers only apps it has approved. Said before reading a body that may not be JSON.
216
+ if (registered.status === 401 || registered.status === 403)
217
+ return { ok: false, said: `${service.label} doesn't let other apps sign in this way yet.${INSTEAD[service.id] === undefined ? "" : ` ${INSTEAD[service.id]}`}` };
141
218
  const body = await registered.json();
142
219
  if (!registered.ok || typeof body.client_id !== "string")
143
220
  return { ok: false, said: `${service.label} didn't let Toolroll register (HTTP ${registered.status}).` };
@@ -208,11 +285,13 @@ export async function finishConnect(store, visit, code, now, options = {}) {
208
285
  }
209
286
  setToolSecrets(visit.repo, spec.name, { [ACCESS]: tokens.access, [CLIENT]: visit.clientId, [TOKEN_URL]: visit.token, [RESOURCE]: visit.resource,
210
287
  ...(tokens.refresh === null ? {} : { [REFRESH]: tokens.refresh }), ...(tokens.expires === null ? {} : { [EXPIRES]: tokens.expires }), ...(visit.clientSecret === null ? {} : { [CLIENT_SECRET]: visit.clientSecret }) }, home);
288
+ // The success line names the project it went to: the page and chat may be looking at another.
289
+ const to = basename(visit.repo);
211
290
  if (options.test === false)
212
- return { ok: true, said: `${service.label} is connected.` };
291
+ return { ok: true, said: `${service.label} is connected to ${to}.` };
213
292
  const tested = await testToolOf(store, visit.repo, spec.name, now, { home, ...(options.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) });
214
- return tested?.ok ? { ok: true, said: `${service.label} is connected: ${tested.tools.length} action${tested.tools.length === 1 ? "" : "s"}. Let a teammate use it from its page.` }
215
- : { ok: true, said: `${service.label} is signed in, but its test didn't pass yet: ${tested?.problem ?? "no answer"}.` };
293
+ return tested?.ok ? { ok: true, said: `${service.label} is connected to ${to}: ${tested.tools.length} action${tested.tools.length === 1 ? "" : "s"}. Let a teammate use it from its page.` }
294
+ : { ok: true, said: `${service.label} is signed in for ${to}, but its test didn't pass yet: ${tested?.problem ?? "no answer"}.` };
216
295
  }
217
296
  /** Keep connected services signed in: any token expiring within ten minutes is refreshed (the worker's pass, and before a teammate's call). */
218
297
  export async function refreshConnections(store, repos, now, options = {}) {
package/dist/operate.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type IntegrationIo } from "./integrations.js";
2
2
  import { type OnboardIo } from "./agent-onboard.js";
3
3
  import { type TriggerIo } from "./flow-triggers.js";
4
- import { type StepIo } from "./flow-steps.js";
4
+ import type { StepIo } from "./flow-steps.js";
5
5
  import type { FetchLike } from "./flow-share.js";
6
6
  import { type Store } from "./store.js";
7
7
  import { type DispatchAdapter } from "./sync.js";
@@ -61,6 +61,8 @@ export type OperateOptions = {
61
61
  flowTriggerIo?: Partial<TriggerIo>;
62
62
  /** Injected by tests: how flow check and update steps run commands and reach GitHub and Linear. */
63
63
  flowStepIo?: Partial<StepIo>;
64
+ /** Injected by tests: how often a worker's flow passes run beside its builds (default FLOW_EVERY_MS). */
65
+ flowEveryMs?: number;
64
66
  /** Injected by tests: how `flows import` fetches a flow file's address. */
65
67
  flowFetch?: FetchLike;
66
68
  /** Injected by tests: how `integrations` checks reach services (fetch, gh, sign-in checks, mail servers). */