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.
@@ -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
+ }
@@ -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"]);
@@ -12,7 +12,17 @@ export type OneClick = {
12
12
  about: string;
13
13
  reads?: readonly string[];
14
14
  };
15
- /** Services verified to connect this way (their servers speak streamable HTTP and register clients on the spot). */
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
+ */
16
26
  export declare const ONE_CLICK: readonly OneClick[];
17
27
  /**
18
28
  * The list, with one service standing in on this computer when the person
@@ -22,12 +32,19 @@ export declare const ONE_CLICK: readonly OneClick[];
22
32
  */
23
33
  export declare function oneClickServices(environment?: NodeJS.ProcessEnv): typeof ONE_CLICK;
24
34
  export declare const oneClickOf: (id: string) => OneClick | null;
25
- /** 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. */
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) => {
37
+ id: string;
38
+ label: string;
39
+ about: string;
40
+ } | null;
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. */
26
42
  export declare function connectionsOf(store: Store, repo: string): {
27
43
  id: string;
28
44
  label: string;
29
45
  about: string;
30
46
  state: "connected" | "open" | "taken";
47
+ local?: true;
31
48
  }[];
32
49
  /**
33
50
  * Whether an action only reads: its name has a reading verb, and every other
@@ -39,7 +56,8 @@ export declare function readsOnly(action: string, subjects: readonly string[]):
39
56
  * What a research step may use of a project's connected services: each
40
57
  * signed-in service's read-only actions, as its last test listed them —
41
58
  * by server (`reads`, for Codex's `enabled_tools`) and named the way Claude
42
- * allows them (`mcp__<service>__<action>`). A service not connected, without
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
43
61
  * read-only actions, or left out of this run (`launched`) gives nothing.
44
62
  */
45
63
  export type ResearchTools = {
@@ -49,6 +67,7 @@ export type ResearchTools = {
49
67
  }[];
50
68
  allowed: string[];
51
69
  reads: Record<string, string[]>;
70
+ local: string[];
52
71
  };
53
72
  export declare function researchToolsOf(tools: readonly ProjectTool[], launched?: ReadonlySet<string> | null): ResearchTools;
54
73
  /** Where a service sends the person back after they sign in. */
@@ -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." },
@@ -36,8 +47,6 @@ export const ONE_CLICK = [
36
47
  // Mobbin's sign-in is its Supabase auth server, on another origin: its protected-resource metadata names it.
37
48
  { id: "mobbin", label: "Mobbin", url: "https://api.mobbin.com/mcp", about: "Real app screens and flows to learn from.",
38
49
  reads: ["screen", "flow", "app", "site", "element", "pattern", "ui", "ios", "android", "web"] },
39
- { id: "figma", label: "Figma", url: "https://mcp.figma.com/mcp", about: "Design files and frames.",
40
- reads: ["file", "frame", "node", "image", "screenshot", "design", "context", "metadata", "variable", "defs", "component", "style", "code", "connect", "map", "figjam"] },
41
50
  { id: "posthog", label: "PostHog", url: "https://mcp.posthog.com/mcp", about: "Product analytics, funnels and events.",
42
51
  reads: ["insight", "run", "event", "definition", "property", "properties"] },
43
52
  { id: "betterstack", label: "Better Stack", url: "https://mcp.betterstack.com", about: "Uptime checks and incidents.",
@@ -65,13 +74,28 @@ export function oneClickServices(environment = process.env) {
65
74
  return ONE_CLICK.some(one => one.id === id) ? ONE_CLICK.map(one => one.id === id ? standIn : one) : [...ONE_CLICK, standIn];
66
75
  }
67
76
  export const oneClickOf = (id) => oneClickServices().find(one => one.id === id) ?? null;
68
- /** 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. */
69
85
  export function connectionsOf(store, repo) {
70
86
  const tools = projectToolsOf(store, repo);
71
- return oneClickServices().map(service => {
87
+ const signIn = oneClickServices().map(service => {
72
88
  const had = tools.find(one => one.name === service.id);
73
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" };
74
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];
75
99
  }
76
100
  /** The verbs that only read. An action named by anything else is withheld from research. */
77
101
  const READING = new Set(["list", "get", "search", "find", "read", "query", "describe", "fetch", "view", "export-image"]);
@@ -91,10 +115,15 @@ export function readsOnly(action, subjects) {
91
115
  return words.some(word => READING.has(word)) && words.every(word => READING.has(word) || QUALIFIERS.has(word) || subject(word));
92
116
  }
93
117
  export function researchToolsOf(tools, launched = null) {
94
- const research = { services: [], allowed: [], reads: {} };
95
- for (const service of oneClickServices()) {
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) {
96
125
  const tool = tools.find(one => one.name === service.id);
97
- if (service.reads === undefined || tool === undefined || tool.spec.url !== service.url || tool.spec.bearer !== ACCESS || (launched !== null && !launched.has(tool.name)))
126
+ if (service.reads === undefined || tool === undefined || !service.is(tool.spec) || (launched !== null && !launched.has(tool.name)))
98
127
  continue;
99
128
  const reads = (tool.lastTest?.ok ? tool.lastTest.tools : []).filter(action => readsOnly(action, service.reads));
100
129
  if (reads.length === 0)
@@ -102,6 +131,8 @@ export function researchToolsOf(tools, launched = null) {
102
131
  research.services.push({ id: service.id, label: service.label });
103
132
  research.allowed.push(...reads.map(action => `mcp__${service.id}__${action}`));
104
133
  research.reads[service.id] = reads;
134
+ if (localAppOf(tool.spec) !== null)
135
+ research.local.push(new URL(tool.spec.url).host);
105
136
  }
106
137
  return research;
107
138
  }
@@ -181,6 +212,9 @@ export async function startConnect(input, fetcher = fetch, now = Date.now()) {
181
212
  try {
182
213
  const registered = await fetcher(server.register, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, signal: AbortSignal.timeout(10_000),
183
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]}`}` };
184
218
  const body = await registered.json();
185
219
  if (!registered.ok || typeof body.client_id !== "string")
186
220
  return { ok: false, said: `${service.label} didn't let Toolroll register (HTTP ${registered.status}).` };
@@ -251,11 +285,13 @@ export async function finishConnect(store, visit, code, now, options = {}) {
251
285
  }
252
286
  setToolSecrets(visit.repo, spec.name, { [ACCESS]: tokens.access, [CLIENT]: visit.clientId, [TOKEN_URL]: visit.token, [RESOURCE]: visit.resource,
253
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);
254
290
  if (options.test === false)
255
- return { ok: true, said: `${service.label} is connected.` };
291
+ return { ok: true, said: `${service.label} is connected to ${to}.` };
256
292
  const tested = await testToolOf(store, visit.repo, spec.name, now, { home, ...(options.omitEnv === undefined ? {} : { omitEnv: options.omitEnv }) });
257
- 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.` }
258
- : { 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"}.` };
259
295
  }
260
296
  /** Keep connected services signed in: any token expiring within ten minutes is refreshed (the worker's pass, and before a teammate's call). */
261
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). */
package/dist/operate.js CHANGED
@@ -31,13 +31,11 @@ import { startCodexLimits } from "./codex-limits.js";
31
31
  import { packageVersion, startMonitoring, targetOf } from "./monitoring.js";
32
32
  import { origin, readMonitoring } from "./monitoring-settings.js";
33
33
  import { logEvent } from "./log.js";
34
- import { advanceFlows } from "./flow-engine.js";
35
- import { readHooksBase, runFlowTriggers } from "./flow-triggers.js";
36
- import { watchFlowReplies } from "./flow-replies.js";
34
+ import { FLOW_EVERY_MS, flowHousekeeping, moveCards, moveCardsAfter } from "./flow-cadence.js";
35
+ import { readHooksBase } from "./flow-triggers.js";
37
36
  import { sendTeammateSummaries } from "./teammate-admin.js";
38
37
  import { runRequestedUndos, sendTeammateWeeklies } from "./teammate-week.js";
39
38
  import { refreshConnections } from "./mcp-connect.js";
40
- import { runFlowSteps } from "./flow-steps.js";
41
39
  import { followDiscord } from "./discord.js";
42
40
  import { followTeams } from "./teams.js";
43
41
  import { loadDiscordCredentials } from "./discord-api.js";
@@ -835,6 +833,7 @@ export async function runOperate(command, argv, write, options = {}) {
835
833
  ...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
836
834
  ...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
837
835
  ...(options.flowStepIo === undefined ? {} : { flowStepIo: options.flowStepIo }),
836
+ ...(options.flowEveryMs === undefined ? {} : { flowEveryMs: options.flowEveryMs }),
838
837
  ...(options.flowFetch === undefined ? {} : { flowFetch: options.flowFetch }),
839
838
  ...(options.integrationIo === undefined ? {} : { integrationIo: options.integrationIo }),
840
839
  ...(options.shouldStop === undefined ? {} : { shouldStop: options.shouldStop }),
@@ -2007,6 +2006,24 @@ async function buildCommand(positional, flags, context) {
2007
2006
  * run opens and no attempt is spent, reported as its own skip reason. */
2008
2007
  class StaleAuthorization extends Error {
2009
2008
  }
2009
+ /** How a worker's flow pass reaches outside: the tests' seams, or the real commands, network and folders. */
2010
+ function flowIoOf(context, pool, git, base) {
2011
+ const dir = dirname(context.databaseFile);
2012
+ return {
2013
+ triggers: { gh: context.flowTriggerIo?.gh ?? run, fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
2014
+ // A schedule's script (v90) runs like a code step: the same runner, in a clean folder beside the step copies.
2015
+ shell: context.flowTriggerIo?.shell ?? context.flowStepIo?.shell ?? run, scratch: context.flowTriggerIo?.scratch ?? context.flowStepIo?.scratch ?? join(pool, "flow-checks"),
2016
+ ...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) },
2017
+ // Replies to cards' emails (v91): read about once a minute, and only while a card is in an email conversation.
2018
+ replies: { fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dir,
2019
+ ...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) },
2020
+ // Check and update steps run outside a model: an approved command in a fresh copy of the card's work, or a comment on the issue it came from.
2021
+ steps: { gh: context.flowStepIo?.gh ?? run, git: context.flowStepIo?.git ?? git, shell: context.flowStepIo?.shell ?? run, fetch: context.flowStepIo?.fetch ?? fetch,
2022
+ dir: context.flowStepIo?.dir ?? dir, scratch: context.flowStepIo?.scratch ?? join(pool, "flow-checks"), base,
2023
+ ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }) },
2024
+ ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
2025
+ };
2026
+ }
2010
2027
  /**
2011
2028
  * One scheduling pass: the M1 loop, without the human typing each step.
2012
2029
  *
@@ -2130,26 +2147,13 @@ async function tickCommand(flags, context) {
2130
2147
  // Triggers first: a schedule, GitHub, Linear or another flow may start
2131
2148
  // cards, which then move in the same pass. Checking an outside service is
2132
2149
  // `gh` or one HTTPS request, never a model, and only when it is due.
2133
- const triggerPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { added: 0, checked: 0, problems: [] }
2134
- : await runFlowTriggers(store, repo, clock(), { gh: context.flowTriggerIo?.gh ?? run, fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dirname(context.databaseFile),
2135
- // A schedule's script (v90) runs like a code step: the same runner, in a clean folder beside the step copies.
2136
- shell: context.flowTriggerIo?.shell ?? context.flowStepIo?.shell ?? run, scratch: context.flowTriggerIo?.scratch ?? context.flowStepIo?.scratch ?? join(pool, "flow-checks"),
2137
- ...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) });
2138
- // Replies to cards' emails (v91): read about once a minute, and only while
2139
- // a card is in an email conversation. A reply moves a waiting card on here,
2140
- // before its next step runs.
2141
- const replyPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { read: 0, taken: 0, problem: null }
2142
- : await watchFlowReplies(store, clock(), { fetch: context.flowTriggerIo?.fetch ?? fetch, dir: context.flowTriggerIo?.dir ?? dirname(context.databaseFile),
2143
- ...(context.flowTriggerIo?.mail === undefined ? {} : { mail: context.flowTriggerIo.mail }) });
2144
- // Check and update steps run outside a model: an approved command in a
2145
- // fresh copy of the card's work, or a comment on the issue it came from.
2146
- const stepPass = buildsOnly || context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true ? { ran: 0, problems: [] }
2147
- : await runFlowSteps(store, repo, clock(), {
2148
- gh: context.flowStepIo?.gh ?? run, git: context.flowStepIo?.git ?? git, shell: context.flowStepIo?.shell ?? run, fetch: context.flowStepIo?.fetch ?? fetch,
2149
- dir: context.flowStepIo?.dir ?? dirname(context.databaseFile), scratch: context.flowStepIo?.scratch ?? join(pool, "flow-checks"), base,
2150
- ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
2151
- });
2152
- const flowPass = buildsOnly ? { moved: 0, filed: [], problems: [] } : advanceFlows(store, repo, clock(), context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot });
2150
+ // Each part is one at a time per project, so the worker's own flow cadence (runWatchLoop) and this pass never
2151
+ // run the same part twice at once.
2152
+ const housekeeping = buildsOnly ? null : await flowHousekeeping(store, repo, clock, flowIoOf(context, pool, git, base), () => context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true);
2153
+ const triggerPass = housekeeping?.triggers ?? { added: 0, checked: 0, problems: [] };
2154
+ const replyPass = housekeeping?.replies ?? { read: 0, taken: 0, problem: null };
2155
+ const stepPass = housekeeping?.steps ?? { ran: 0, problems: [] };
2156
+ const flowPass = housekeeping?.flows ?? { moved: 0, filed: [], problems: [] };
2153
2157
  // Each AI teammate's daily summary to its manager (v92), once, after 5 pm; its weekly report (v97), Monday mornings.
2154
2158
  if (!buildsOnly)
2155
2159
  try {
@@ -2177,9 +2181,19 @@ async function tickCommand(flags, context) {
2177
2181
  flowPass.problems.push(`tool sign-ins: ${error instanceof Error ? error.message : "could not renew"}`);
2178
2182
  }
2179
2183
  }
2180
- const replied = replyPass.taken > 0 || replyPass.problem !== null;
2181
- const flows = flowPass.moved + flowPass.filed.length + flowPass.problems.length + triggerPass.added + triggerPass.problems.length + stepPass.ran + stepPass.problems.length === 0 && !replied ? {} : { flows: { ...flowPass,
2182
- ...(triggerPass.added + triggerPass.problems.length === 0 ? {} : { triggers: triggerPass }), ...(stepPass.ran + stepPass.problems.length === 0 ? {} : { steps: stepPass }), ...(replied ? { replies: replyPass } : {}) } };
2184
+ // A build, research or check finishing below moves its card at once (moveCardsAfter), not a pass later: by then
2185
+ // other builds may hold the worker for minutes.
2186
+ let settled = 0;
2187
+ const settleFinished = () => {
2188
+ const finished = dispatched.slice(settled).filter(one => one.outcome !== "skipped" && one.outcome !== "contest").map(one => one.id);
2189
+ settled = dispatched.length;
2190
+ if (finished.length === 0)
2191
+ return;
2192
+ const after = moveCardsAfter(store, repo, finished, clock(), context.evidenceRoot);
2193
+ flowPass.moved += after.moved;
2194
+ flowPass.filed.push(...after.filed);
2195
+ flowPass.problems.push(...after.problems);
2196
+ };
2183
2197
  // Tournament housekeeping before the ordinary pass (stage 4): interrupted
2184
2198
  // races recover by CAS, and an ANSWERED question re-admits its parked
2185
2199
  // agent — fresh claim, fresh slot, remaining budget only, the SAME
@@ -2544,6 +2558,7 @@ async function tickCommand(flags, context) {
2544
2558
  const attestedThisPass = new Map();
2545
2559
  let untakenTrial = null;
2546
2560
  for (const ref of ready) {
2561
+ settleFinished();
2547
2562
  untakenTrial?.giveBack();
2548
2563
  untakenTrial = null;
2549
2564
  // The build budget governs UNATTENDED admissions (round-1 finding 4):
@@ -3913,6 +3928,7 @@ async function tickCommand(flags, context) {
3913
3928
  // only claim, worktree, and rail, and its run then re-proves the
3914
3929
  // chain-entry dispatch proof inside build() before any money moves.
3915
3930
  for (const pending of buildsOnly ? [] : store.pendingChainAdmissions(repo)) {
3931
+ settleFinished();
3916
3932
  if (context.shouldStop?.() === true || context.shouldPauseAdmission?.() === true)
3917
3933
  break;
3918
3934
  if (built >= max)
@@ -4323,6 +4339,10 @@ async function tickCommand(flags, context) {
4323
4339
  // Existing worker pass owns durable lead handoffs; reads never create events.
4324
4340
  if (!buildsOnly)
4325
4341
  syncAssignmentHandoffs(store, clock(), auth.runner.repos, context.evidenceRoot);
4342
+ settleFinished();
4343
+ const replied = replyPass.taken > 0 || replyPass.problem !== null;
4344
+ const flows = flowPass.moved + flowPass.filed.length + flowPass.problems.length + triggerPass.added + triggerPass.problems.length + stepPass.ran + stepPass.problems.length === 0 && !replied ? {} : { flows: { ...flowPass,
4345
+ ...(triggerPass.added + triggerPass.problems.length === 0 ? {} : { triggers: triggerPass }), ...(stepPass.ran + stepPass.problems.length === 0 ? {} : { steps: stepPass }), ...(replied ? { replies: replyPass } : {}) } };
4326
4346
  const summary = () => {
4327
4347
  const lines = [`Considered ${considered}, built ${built}, parked ${parked}, broke ${broke}.`];
4328
4348
  for (const entry of routines) {
@@ -7544,6 +7564,52 @@ async function runWatchLoop(args) {
7544
7564
  const ran = await runWaitingChecks(store, context.evidenceRoot, { now: context.clock, shouldStop: stopping });
7545
7565
  if (ran > 0)
7546
7566
  progress(`watch: ran ${ran} follow-up check(s)`);
7567
+ // A finished check may send a build's card down its failure path: now, not on the next beat.
7568
+ if (ran > 0)
7569
+ moveCards(store, repo, context.clock(), context.evidenceRoot);
7570
+ },
7571
+ });
7572
+ // Flows on their own cadence (flow-cadence.ts), not between builds: while builds hold every slot of this
7573
+ // project, a card whose step finished still moves within one beat. Moves run apart from triggers, replies
7574
+ // and steps, so a long check script doesn't hold them either. Neither starts a build: a card entering a
7575
+ // build zone files its task, which waits for a free slot like any other.
7576
+ const flowEveryMs = context.flowEveryMs ?? FLOW_EVERY_MS;
7577
+ const flowPool = text(flags, "pool") ?? join(dirname(databasePath(process.env, homedir())), "worktrees");
7578
+ const flowGit = context.gitRunner ?? run;
7579
+ const flowsDid = (moved, filed, did) => {
7580
+ if (moved + filed === 0)
7581
+ return;
7582
+ store.bumpWake();
7583
+ progress(`watch: ${did}`);
7584
+ };
7585
+ const flowMoves = startMaintenance({
7586
+ intervalMs: flowEveryMs,
7587
+ shouldStop: stopping,
7588
+ onError: error => progress(`watch: flow cards couldn't move — ${describe(error)}; they try again shortly`),
7589
+ run: async () => {
7590
+ if (paused())
7591
+ return;
7592
+ const moved = moveCards(store, repo, context.clock(), context.evidenceRoot);
7593
+ flowsDid(moved.moved, moved.filed.length, `moved ${moved.moved} flow card(s), filed ${moved.filed.length} task(s)`);
7594
+ },
7595
+ });
7596
+ const flowSteps = startMaintenance({
7597
+ intervalMs: flowEveryMs,
7598
+ shouldStop: stopping,
7599
+ onError: error => progress(`watch: a flow pass failed — ${describe(error)}; it runs again shortly`),
7600
+ run: async () => {
7601
+ if (paused())
7602
+ return;
7603
+ let base = text(flags, "base");
7604
+ if (base === undefined) {
7605
+ const head = await flowGit("git", ["symbolic-ref", "--short", "-q", "HEAD"], { cwd: repo });
7606
+ // A detached HEAD: the tick says so; steps wait for a branch rather than guessing one.
7607
+ if (head.code !== 0 || head.stdout.trim() === "")
7608
+ return;
7609
+ base = head.stdout.trim();
7610
+ }
7611
+ const pass = await flowHousekeeping(store, repo, context.clock, flowIoOf(context, flowPool, flowGit, base), admissionStopped);
7612
+ flowsDid(pass.flows.moved + pass.triggers.added + pass.steps.ran + pass.replies.taken, pass.flows.filed.length, `flow pass — ${pass.triggers.added} card(s) added, ${pass.replies.taken} reply(ies) taken, ${pass.steps.ran} step(s) run, ${pass.flows.moved} moved`);
7547
7613
  },
7548
7614
  });
7549
7615
  const modelWatch = startMaintenance({
@@ -7780,6 +7846,8 @@ async function runWatchLoop(args) {
7780
7846
  await Promise.all(lanes);
7781
7847
  await maintenance.stop();
7782
7848
  await followUpChecks.stop();
7849
+ await flowMoves.stop();
7850
+ await flowSteps.stop();
7783
7851
  await modelWatch.stop();
7784
7852
  clearInterval(heartbeat);
7785
7853
  followController.abort();
@@ -48,6 +48,21 @@ export type ResolvedTool = {
48
48
  export declare const TOOL_CATALOG: readonly (ToolSpec & {
49
49
  label: string;
50
50
  })[];
51
+ /**
52
+ * Apps that serve their own MCP on this computer: Connect adds them like a
53
+ * common tool (`connect` is its one line of how), a test that can't reach one
54
+ * says why (`offline`), and research may read what `reads` names (see
55
+ * readsOnly in mcp-connect.ts). The only loopback address research reaches.
56
+ */
57
+ export type LocalApp = {
58
+ tool: string;
59
+ connect: string;
60
+ offline: string;
61
+ reads: readonly string[];
62
+ };
63
+ export declare const LOCAL_APPS: readonly LocalApp[];
64
+ /** The local app a project's tool is, when it is one exactly as listed (same name and address). */
65
+ export declare function localAppOf(spec: Pick<ToolSpec, "name" | "url">): LocalApp | null;
51
66
  export declare function catalogTool(id: string): (ToolSpec & {
52
67
  label: string;
53
68
  }) | null;
@@ -51,7 +51,20 @@ export const TOOL_CATALOG = [
51
51
  about: "Reads Sentry issues and errors." },
52
52
  { label: "Supabase (read-only)", name: "supabase", transport: "stdio", command: "npx", args: ["-y", "@supabase/mcp-server-supabase@0.13.0", "--read-only"], url: null, secrets: [{ name: "SUPABASE_ACCESS_TOKEN", optional: false }], bearer: null, headerSecrets: {},
53
53
  about: "Reads Supabase tables and schema, without changing anything." },
54
+ // Figma's Dev Mode server, run by its desktop app on this computer and signed in as the app is: no key.
55
+ { label: "Figma (desktop app)", name: "figma-desktop", transport: "http", command: null, args: [], url: "http://127.0.0.1:3845/mcp", secrets: [], bearer: null, headerSecrets: {},
56
+ about: "Code, images, variables and metadata from what you select in the Figma desktop app." },
54
57
  ];
58
+ export const LOCAL_APPS = [
59
+ { tool: "figma-desktop", connect: "Open the Figma desktop app, turn on the Dev Mode MCP server in Preferences, then Connect.",
60
+ offline: "The Figma desktop app isn't running, or its Dev Mode MCP server is off. Open Figma and turn the server on in Preferences.",
61
+ reads: ["code", "image", "screenshot", "variable", "defs", "metadata", "design", "context", "connect", "map", "figjam"] },
62
+ ];
63
+ /** The local app a project's tool is, when it is one exactly as listed (same name and address). */
64
+ export function localAppOf(spec) {
65
+ const app = LOCAL_APPS.find(one => one.tool === spec.name);
66
+ return app !== undefined && spec.url !== null && spec.url === catalogTool(app.tool)?.url ? app : null;
67
+ }
55
68
  export function catalogTool(id) {
56
69
  return TOOL_CATALOG.find(one => one.name === id) ?? null;
57
70
  }
@@ -513,7 +526,7 @@ async function exchangeHttp(spec, values, timeoutMs, request) {
513
526
  return { ok: true, result: answered.message.result };
514
527
  }
515
528
  catch (error) {
516
- return { ok: false, problem: error instanceof Error && error.name === "TimeoutError" ? `It did not answer within ${Math.round(timeoutMs / 1000)} seconds.` : "It could not be reached." };
529
+ return { ok: false, problem: error instanceof Error && error.name === "TimeoutError" ? `It did not answer within ${Math.round(timeoutMs / 1000)} seconds.` : localAppOf(spec)?.offline ?? "It could not be reached." };
517
530
  }
518
531
  }
519
532
  /** One line of a server's own words, never a secret. */
package/dist/scout.d.ts CHANGED
@@ -25,8 +25,9 @@ export declare function scoutBrowserLaunch(tool: Pick<ToolSpec, "command" | "arg
25
25
  export declare function scoutBrowser(url: string): Record<string, unknown>;
26
26
  /** The scout's environment while its proxy runs (review 827): web fetch and the provider's own traffic go through
27
27
  * the same public-web-only proxy as its browser, so only public addresses (and the project's demo) are reachable.
28
- * The one direct address is its own browser's port, when it has one (review 828): the proxy refuses loopback. */
29
- export declare function scoutProxyEnv(proxy: string, browserUrl?: string | null): Record<string, string>;
28
+ * The direct addresses are its own browser's port, when it has one (review 828), and an app on this computer it
29
+ * reads (`local`: Figma's desktop server): the proxy refuses loopback. */
30
+ export declare function scoutProxyEnv(proxy: string, browserUrl?: string | null, local?: readonly string[]): Record<string, string>;
30
31
  export type ScoutRequest = {
31
32
  /** v105: what's left of a monthly budget this API-key work counts toward (the CLI's own cap), when one does. */
32
33
  maxBudgetUsd?: number;