toolroll 0.9.25 → 0.9.27

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.
@@ -6,12 +6,13 @@
6
6
  */
7
7
  import { STATE_WORDS } from "./integrations.js";
8
8
  import { whenUtc } from "./when-html.js";
9
+ import { brandMarkHtml } from "./brand-mark.js";
9
10
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
10
11
  const when = (at) => whenUtc(at);
11
12
  export const INTEGRATIONS_CSS = `.integrations{max-width:760px;min-width:0}.integrations h2{margin:24px 0 6px;font-size:.9375rem}` +
12
13
  `.integrations .integration{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 16px;align-items:start;padding:12px 0;border-top:1px solid var(--so-line);min-width:0}` +
13
14
  `.integrations .integration>:not(.integration-action),.integrations .integration-body>*{grid-column:1}.integrations .integration-body{display:contents}.integrations .integration:first-of-type{border-top:0}.integrations .integration-head{margin:0;display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}` +
14
- `.integrations .integration-head strong{overflow-wrap:anywhere}.integrations .integration p{margin:2px 0 0;min-width:0;overflow-wrap:anywhere}` +
15
+ `.integrations .integration .integration-body>*{margin-left:calc(var(--brand-mark) + 10px)}.integrations .integration-head strong{overflow-wrap:anywhere}.integrations .integration p{margin:2px 0 0;min-width:0;overflow-wrap:anywhere}` +
15
16
  `.integrations .integration-action{grid-column:2;grid-row:1 / span 3;align-self:center;margin:0}.integrations .integration-action button,.integrations .integration-action .button-link{min-height:44px;white-space:nowrap}` +
16
17
  `.integrations .integration .integration-action button{width:auto}.integrations .integration .integration-action a.integration-quiet{background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line)}` +
17
18
  `.integrations .integration-fix{color:var(--so-danger)}.integrations code{overflow-wrap:anywhere}.integrations details{margin-top:4px;font-size:.8125rem}.integrations summary{cursor:pointer;padding:4px 0}` +
@@ -21,7 +22,7 @@ export const INTEGRATIONS_CSS = `.integrations{max-width:760px;min-width:0}.inte
21
22
  `.integration-state--not-set-up,.integration-state--checking{color:var(--so-muted);background:var(--so-neutral-soft)}` +
22
23
  // A phone: the action sits at the right of the name line, and the facts under it run on as one line.
23
24
  `@media (max-width:760px){.integrations h2{margin:16px 0 0}.integrations .integration{gap:0 12px;padding:6px 0 8px}.integrations .integration-head{min-height:44px}` +
24
- `.integrations .integration-action{grid-row:1;align-self:center}.integrations .integration-body{display:block;grid-column:1 / -1;line-height:1.35}` +
25
+ `.integrations .integration-action{grid-row:1;align-self:center}.integrations .integration-body{display:block;grid-column:1 / -1;line-height:1.35;padding-left:calc(var(--brand-mark) + 10px)}.integrations .integration .integration-body>*{margin-left:0}` +
25
26
  `.integrations .integration-body>p{margin:0}.integrations .integration-body>p.meta{display:inline}.integrations .integration-body>p.meta+p.meta::before{content:" · "}` +
26
27
  `.integrations .integration-body>:not(.meta){margin-top:4px}.integrations .integration-body>:not(.meta)+p.meta{display:block;margin-top:2px}.integrations details{margin-top:2px}}`;
27
28
  const GROUPS = [
@@ -39,6 +40,12 @@ function action(one, csrf) {
39
40
  return `<p class="integration-action"><a class="button-link${a.kind === "setup" ? " integration-quiet" : ""}" href="${e(a.href)}">${a.label}</a></p>`;
40
41
  return "";
41
42
  }
43
+ /** Which mark a row shows: email is Gmail's only when the account is a Gmail address; a custom tool is a letter. */
44
+ function markFor(one) {
45
+ if (one.custom)
46
+ return null;
47
+ return one.key === "email" && /@(gmail|googlemail)\.com$/i.test(one.account ?? "") ? "gmail" : one.key;
48
+ }
42
49
  function row(one, csrf) {
43
50
  const badge = !one.checked && one.state === "connected"
44
51
  ? `<span class="integration-state integration-state--checking"><i aria-hidden="true"></i>Checking</span>`
@@ -53,7 +60,7 @@ function row(one, csrf) {
53
60
  ? `<p class="meta">Failed ${when(one.lastErrorAt ?? "")}</p>`
54
61
  : `<details><summary>Last error ${one.lastErrorAt === null ? "" : when(one.lastErrorAt)}</summary><p class="meta">${e(one.lastError)}</p></details>`;
55
62
  return `<div class="integration" data-integration="${e(one.key)}" data-state="${one.state}">` +
56
- `<p class="integration-head"><strong>${e(one.name)}</strong> ${badge}</p>` +
63
+ `<p class="integration-head">${brandMarkHtml(markFor(one), one.name, one.state === "connected")}<strong>${e(one.name)}</strong> ${badge}</p>` +
57
64
  action(one, csrf) +
58
65
  `<div class="integration-body">${facts === "" ? "" : `<p class="meta">${facts}</p>`}${fix}${run}${history}${lastError}</div></div>`;
59
66
  }
@@ -36,6 +36,8 @@ export type Integration = {
36
36
  lastErrorAt: string | null;
37
37
  usedBy: string[];
38
38
  action: IntegrationAction;
39
+ /** A project's own MCP tool: it shows a letter, never a logo matched by name. */
40
+ custom?: true;
39
41
  };
40
42
  export declare const STATE_WORDS: Record<IntegrationState, string>;
41
43
  export type ProbeResult = {
@@ -383,7 +383,7 @@ function targets(io) {
383
383
  const href = `/settings/tools?repo=${encodeURIComponent(repo)}#tool-${encodeURIComponent(tool.name)}`;
384
384
  const test = tool.lastTest;
385
385
  list.push({
386
- key: `mcp:${repo}:${tool.name}`, group: "tools", name: tool.name, configured: true, account: basename(repo),
386
+ key: `mcp:${repo}:${tool.name}`, group: "tools", name: tool.name, configured: true, account: basename(repo), ...(tool.source === "custom" ? { custom: true } : {}),
387
387
  setup: place(href), fix: place(href), usedBy: [`Tasks in ${basename(repo)}`],
388
388
  activity: test === null ? NO_ACTIVITY : test.ok ? { okAt: test.at, error: null, errorAt: null, failing: false } : { okAt: null, error: test.problem, errorAt: test.at, failing: false },
389
389
  probe: async (check) => {
@@ -432,7 +432,7 @@ function targets(io) {
432
432
  }
433
433
  // ---------------------------------------------------------------- the list
434
434
  function combine(target, row) {
435
- const base = { key: target.key, group: target.group, name: target.name, usedBy: target.usedBy };
435
+ const base = { key: target.key, group: target.group, name: target.name, usedBy: target.usedBy, ...(target.custom ? { custom: true } : {}) };
436
436
  const checked = target.probe === null || row !== undefined;
437
437
  const lastSuccessAt = later(row?.okAt ?? null, target.activity.okAt);
438
438
  const checkError = row?.error ?? null, checkErrorAt = row?.errorAt ?? 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"]);
@@ -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. */
@@ -15,6 +15,8 @@ export type ResultShot = {
15
15
  caption: string;
16
16
  /** Within Telegram's photo limits, so it shows an inline preview. */
17
17
  photo: boolean;
18
+ /** Captioned with its item's number (flow-items.ts): an album shows it on this photo, not only on the first. */
19
+ numbered?: true;
18
20
  };
19
21
  export type ResultShotsPlan =
20
22
  /** Nothing to send and nothing to say: off, pruned by retention, none saved, or all already sent. */
@@ -38,7 +40,9 @@ export type ResultShotsPlan =
38
40
  * Every byte returned was verified this instant; a channel uploads them with
39
41
  * no wait in between, or asks again.
40
42
  */
41
- export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run">, sent?: ReadonlySet<number>): ResultShotsPlan;
43
+ export declare function resultShotsFor(store: Store, evidenceRoot: string | undefined, repos: readonly string[], row: Pick<Notification, "kind" | "recipient" | "taskId" | "taskRef" | "run"> & {
44
+ dedupeKey?: string;
45
+ }, sent?: ReadonlySet<number>): ResultShotsPlan;
42
46
  /** A row that carries a result's screenshots: one a person asked for with results, or one a flow sends them. */
43
47
  export declare function isShotsKind(kind: string): boolean;
44
48
  /** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
@@ -19,6 +19,7 @@ import { imageDimensions, readVerifiedProofForRun, readVerifiedReport, RETENTION
19
19
  import { resultImageFileName, verifyResultImage } from "./chat-evidence.js";
20
20
  import { publicChatText } from "./chat-display.js";
21
21
  import { chatTitle } from "./chat-voice.js";
22
+ import { itemCaption, readFlowItems } from "./flow-items.js";
22
23
  import { FLOW_SHOTS_KIND, RESULT_SHOTS_KIND } from "./store.js";
23
24
  /** "Up to 4": the most screenshots one result ever sends. */
24
25
  export const RESULT_SHOTS_MAX = 4;
@@ -58,6 +59,28 @@ function reportCaptions(store, root, taskRef, run) {
58
59
  }
59
60
  return captions;
60
61
  }
62
+ /** A flow visit's item screenshots from this run, in item order, each with its numbered caption (flow-items.ts). */
63
+ function itemCaptions(store, dedupeKey, run) {
64
+ const captions = new Map();
65
+ const visit = /^flow-shots:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/.exec(dedupeKey ?? "");
66
+ const kept = visit === null ? null : store.flowSend(Number(visit[1]), Number(visit[2]));
67
+ if (kept === null)
68
+ return captions;
69
+ let content;
70
+ try {
71
+ content = JSON.parse(kept.contentJson);
72
+ }
73
+ catch {
74
+ return captions;
75
+ }
76
+ if (content.shots?.run !== run)
77
+ return captions;
78
+ readFlowItems(content.items).forEach((item, index) => {
79
+ if (item.shot !== null && !captions.has(item.shot))
80
+ captions.set(item.shot, publicChatText(itemCaption(index + 1, item), 200).trim());
81
+ });
82
+ return captions;
83
+ }
61
84
  function isPhoto(bytes, format) {
62
85
  const size = imageDimensions(bytes, format);
63
86
  if (size === null || size.width === 0 || size.height === 0)
@@ -92,7 +115,13 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
92
115
  const latest = store.runsFor(row.taskRef).find(one => one.finishedAt !== null && ["builder", "repair", "scout"].includes(one.role));
93
116
  if (family?.current.id !== row.taskId || latest?.id !== row.run)
94
117
  return line("a newer result replaced this one");
95
- const chosen = store.artifactsFor(row.run).filter(one => one.kind === "screenshot").slice(0, limit);
118
+ // After research, a flow's message numbers the report's items (flow-items.ts): their screenshots go first, in that
119
+ // order, each captioned with its item's number, so "2." here is item 2 in the text.
120
+ const items = row.kind === FLOW_SHOTS_KIND ? itemCaptions(store, row.dedupeKey, row.run) : new Map();
121
+ const order = [...items.keys()];
122
+ const rank = (id) => { const at = order.indexOf(id); return at < 0 ? order.length : at; };
123
+ const chosen = store.artifactsFor(row.run).filter(one => one.kind === "screenshot")
124
+ .map((one, at) => ({ one, at })).sort((a, b) => rank(a.one.id) - rank(b.one.id) || a.at - b.at).map(({ one }) => one).slice(0, limit);
96
125
  if (chosen.length === 0)
97
126
  return { kind: "none", why: "none" };
98
127
  const captions = ownCaptions(store, evidenceRoot, row.run);
@@ -107,11 +136,12 @@ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()
107
136
  return line(verified.problem);
108
137
  }
109
138
  const own = captions.get(SCREENSHOT_CAPTURE.exec(artifact.capture)?.[1] ?? "") ?? reported.get(artifact.id) ?? null;
139
+ const item = items.get(artifact.id);
110
140
  shots.push({
111
141
  artifact: artifact.id, sha256: artifact.sha256, bytes: verified.bytes, format: verified.format,
112
142
  fileName: resultImageFileName(row.taskId, row.run, artifact.id, verified.format),
113
- caption: index === 0 ? (own === null ? title : `${title} · ${own}`) : own ?? `${title} · screenshot ${index + 1}`,
114
- photo: isPhoto(verified.bytes, verified.format),
143
+ caption: item !== undefined ? item : index === 0 ? (own === null ? title : `${title} · ${own}`) : own ?? `${title} · screenshot ${index + 1}`,
144
+ photo: isPhoto(verified.bytes, verified.format), ...(item === undefined ? {} : { numbered: true }),
115
145
  });
116
146
  }
117
147
  const remaining = shots.filter(one => !sent.has(one.artifact));