toolroll 0.8.1 → 0.9.0

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.
Files changed (59) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/agent-onboard.d.ts +7 -0
  3. package/dist/agent-onboard.js +23 -5
  4. package/dist/browser/workspace.css +1 -1
  5. package/dist/browser/workspace.js +21 -20
  6. package/dist/browser-workspace.d.ts +10 -0
  7. package/dist/chat-delivery.js +51 -3
  8. package/dist/chat-quiet.d.ts +28 -0
  9. package/dist/chat-quiet.js +161 -0
  10. package/dist/checkout-cleanup.d.ts +84 -0
  11. package/dist/checkout-cleanup.js +245 -0
  12. package/dist/cli.d.ts +1 -1
  13. package/dist/cli.js +3 -0
  14. package/dist/digest-times.d.ts +3 -0
  15. package/dist/digest-times.js +7 -0
  16. package/dist/dispose.js +2 -0
  17. package/dist/export.js +1 -1
  18. package/dist/first-run.d.ts +4 -1
  19. package/dist/first-run.js +2 -1
  20. package/dist/integrations-ui.d.ts +13 -0
  21. package/dist/integrations-ui.js +65 -0
  22. package/dist/integrations.d.ts +113 -0
  23. package/dist/integrations.js +526 -0
  24. package/dist/mcp.js +26 -0
  25. package/dist/operate.d.ts +5 -6
  26. package/dist/operate.js +254 -25
  27. package/dist/project-delete.js +1 -0
  28. package/dist/prompt.d.ts +7 -2
  29. package/dist/prompt.js +36 -7
  30. package/dist/publish.d.ts +11 -0
  31. package/dist/publish.js +9 -2
  32. package/dist/pull-request-flow.d.ts +140 -0
  33. package/dist/pull-request-flow.js +440 -0
  34. package/dist/pull-request-schema.d.ts +5 -0
  35. package/dist/pull-request-schema.js +21 -0
  36. package/dist/pull-request-ui.d.ts +23 -0
  37. package/dist/pull-request-ui.js +55 -0
  38. package/dist/pulls.d.ts +5 -0
  39. package/dist/pulls.js +11 -0
  40. package/dist/serve.d.ts +6 -0
  41. package/dist/serve.js +323 -26
  42. package/dist/setup-guide.d.ts +2 -2
  43. package/dist/storage-ui.d.ts +10 -0
  44. package/dist/storage-ui.js +67 -0
  45. package/dist/storage.d.ts +15 -1
  46. package/dist/storage.js +62 -7
  47. package/dist/store.d.ts +66 -5
  48. package/dist/store.js +160 -19
  49. package/dist/surface.js +2 -0
  50. package/dist/telegram-progress.d.ts +1 -0
  51. package/dist/telegram-progress.js +1 -1
  52. package/dist/telegram.js +95 -15
  53. package/dist/toolroll-update-ui.d.ts +2 -0
  54. package/dist/toolroll-update-ui.js +1 -1
  55. package/dist/toolroll-update.d.ts +26 -6
  56. package/dist/toolroll-update.js +188 -50
  57. package/dist/worktree.d.ts +24 -10
  58. package/dist/worktree.js +84 -44
  59. package/package.json +1 -1
@@ -0,0 +1,526 @@
1
+ /**
2
+ * Settings → Integrations and `toolroll integrations`: one place that says
3
+ * which integrations work. Each is Connected, Not set up or Broken, with its
4
+ * last success and last error, what uses it, and one action (Set up, Send
5
+ * test or Fix).
6
+ *
7
+ * Two halves. `integrationsNow` is cheap and synchronous: configuration files,
8
+ * delivery history already in SQLite, and the last check's answer from
9
+ * `integration_check`. It never touches the network, so a page can call it on
10
+ * every render. `checkIntegrations` runs the checks themselves: a harmless
11
+ * read-only call per integration (never a message to anyone, never a write to
12
+ * a repository) with a short timeout, and records each answer. The console
13
+ * runs them in the background through `createIntegrationMonitor`; the CLI
14
+ * runs them inline. Only account and workspace names leave this module:
15
+ * tokens, keys and passwords are never in a row, and every problem text is
16
+ * scrubbed before it is stored.
17
+ */
18
+ import { connect as tcpConnect } from "node:net";
19
+ import { connect as tlsConnect } from "node:tls";
20
+ import { homedir } from "node:os";
21
+ import { basename } from "node:path";
22
+ import { ALL_CREDENTIAL_ENV } from "./provider.js";
23
+ import { providerName, signInCommand } from "./provider-auth.js";
24
+ import { loadBotToken } from "./telegram.js";
25
+ import { loadSlackCredentials } from "./slack-api.js";
26
+ import { loadDiscordCredentials } from "./discord-api.js";
27
+ import { loadTeamsCredentials, teamsAccessToken } from "./teams-api.js";
28
+ import { effectivePrimary } from "./webhooks.js";
29
+ import { readEmailSettings } from "./email-settings.js";
30
+ import { googleConnected } from "./google-mail.js";
31
+ import { LINEAR_URL, readHooksBase, readLinearKey } from "./flow-triggers.js";
32
+ import { projectToolsOf, readToolSecrets, missingSecrets, testToolOf } from "./project-tools.js";
33
+ import { origin, readMonitoring } from "./monitoring-settings.js";
34
+ import { targetOf } from "./monitoring.js";
35
+ export const STATE_WORDS = { connected: "Connected", "not-set-up": "Not set up", broken: "Broken" };
36
+ const NO_ACTIVITY = { okAt: null, error: null, errorAt: null, failing: false };
37
+ const PROBE_MS = 8_000;
38
+ const AGENTS = ["claude", "codex"];
39
+ // ---------------------------------------------------------------- secrets
40
+ /** Token shapes that must never reach a page, a log line or --json, whatever an upstream error message says. */
41
+ const SECRET_SHAPES = [
42
+ /\b\d{5,}:[A-Za-z0-9_-]{30,}\b/g, // Telegram bot token
43
+ /\bx(?:ox[abposr]|app)-[A-Za-z0-9-]{8,}\b/g, // Slack tokens
44
+ /\blin_(?:api|oauth)_[A-Za-z0-9]{12,}\b/g, // Linear keys
45
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, // GitHub tokens
46
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
47
+ /\bsk-[A-Za-z0-9_-]{16,}\b/g, // provider API keys
48
+ /\bBearer\s+[A-Za-z0-9._~+/-]{8,}=*/gi,
49
+ /\b[A-Za-z0-9_-]{24,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{20,}\b/g, // Discord bot token, JWTs
50
+ /\/\/[^/\s:@]+:[^/\s@]+@/g, // user:password@ in an address
51
+ ];
52
+ /** Remove every known secret value and anything shaped like a token; keep it to one short line. */
53
+ export function scrubIntegrationText(text, secrets = []) {
54
+ let out = text;
55
+ for (const secret of secrets)
56
+ if (secret !== null && secret !== undefined && secret.length >= 6)
57
+ out = out.split(secret).join("[hidden]");
58
+ for (const shape of SECRET_SHAPES)
59
+ out = out.replace(shape, match => (match.startsWith("//") ? "//[hidden]@" : "[hidden]"));
60
+ out = out.replace(/[\p{Cc}\s]+/gu, " ").trim();
61
+ return out.length > 240 ? `${out.slice(0, 239).trimEnd()}…` : out;
62
+ }
63
+ const str = (value) => (value === null || value === undefined ? null : String(value));
64
+ export function readIntegrationChecks(store) {
65
+ if (!tableExists(store, "integration_check"))
66
+ return new Map();
67
+ const rows = store.handle.prepare("SELECT * FROM integration_check").all();
68
+ return new Map(rows.map(row => [String(row["key"]), {
69
+ key: String(row["key"]), outcome: String(row["outcome"]), account: str(row["account"]), detail: str(row["detail"]), problem: str(row["problem"]),
70
+ checkedAt: String(row["checked_at"]), okAt: str(row["ok_at"]), error: str(row["error"]), errorAt: str(row["error_at"]),
71
+ }]));
72
+ }
73
+ export function recordIntegrationCheck(store, key, result, now) {
74
+ const at = now.toISOString();
75
+ const account = result.outcome === "absent" ? null : result.account ?? null;
76
+ const detail = result.outcome === "absent" ? null : result.detail ?? null;
77
+ const problem = result.outcome === "ok" ? null : scrubIntegrationText(result.problem);
78
+ store.handle.prepare(`INSERT INTO integration_check (key, outcome, account, detail, problem, checked_at, ok_at, error, error_at)
79
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
80
+ ON CONFLICT(key) DO UPDATE SET outcome = excluded.outcome, account = excluded.account, detail = excluded.detail, problem = excluded.problem,
81
+ checked_at = excluded.checked_at, ok_at = COALESCE(excluded.ok_at, integration_check.ok_at),
82
+ error = COALESCE(excluded.error, integration_check.error), error_at = COALESCE(excluded.error_at, integration_check.error_at)`)
83
+ .run(key, result.outcome, account, detail, problem, at, result.outcome === "ok" ? at : null, result.outcome === "failed" ? problem : null, result.outcome === "failed" ? at : null);
84
+ }
85
+ const later = (a, b) => (a === null ? b : b === null ? a : a > b ? a : b);
86
+ function tableExists(store, table) {
87
+ return store.handle.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
88
+ }
89
+ function telegramActivity(store) {
90
+ const ok = store.handle.prepare("SELECT MAX(delivered_at) AS at FROM notification_delivery WHERE destination LIKE 'telegram:%' AND delivered_at IS NOT NULL").get();
91
+ const bad = store.handle.prepare("SELECT last_error, last_attempt_at FROM notification_delivery WHERE destination LIKE 'telegram:%' AND last_error IS NOT NULL AND delivered_at IS NULL ORDER BY last_attempt_at DESC LIMIT 1").get();
92
+ const okAt = str(ok?.["at"]), errorAt = str(bad?.["last_attempt_at"]);
93
+ return { okAt, error: str(bad?.["last_error"]), errorAt, failing: errorAt !== null && (okAt === null || errorAt > okAt) };
94
+ }
95
+ function chatActivity(store, channel, installation) {
96
+ const runtime = `${channel}_runtime`, part = `${channel}_part`, event = `${channel}_event`;
97
+ if (!tableExists(store, runtime))
98
+ return NO_ACTIVITY;
99
+ const row = store.handle.prepare(`SELECT connected, problem, retry_at FROM ${runtime} WHERE installation = ?`).get(installation);
100
+ const sent = store.handle.prepare(`SELECT MAX(p.created) AS at FROM ${part} p JOIN ${event} e ON e.id = p.event WHERE e.installation = ? AND p.state = 'sent'`).get(installation);
101
+ const dropped = store.handle.prepare(`SELECT p.problem, p.created FROM ${part} p JOIN ${event} e ON e.id = p.event WHERE e.installation = ? AND p.state = 'dropped' AND p.problem IS NOT NULL ORDER BY p.id DESC LIMIT 1`).get(installation);
102
+ const okAt = later(str(row?.["connected"]), str(sent?.["at"]));
103
+ const runtimeProblem = str(row?.["problem"]);
104
+ const error = runtimeProblem ?? str(dropped?.["problem"]);
105
+ const errorAt = runtimeProblem !== null ? str(row?.["retry_at"]) ?? okAt : str(dropped?.["created"]);
106
+ return { okAt, error, errorAt, failing: runtimeProblem !== null };
107
+ }
108
+ function triggerUses(store) {
109
+ return store.handle.prepare(`SELECT t.kind, t.config_json, t.last_at, t.last_outcome, t.failures, f.name FROM flow_trigger t JOIN flow f ON f.id = t.flow
110
+ WHERE t.state = 'active' AND f.state = 'active' ORDER BY t.id`).all().map(row => {
111
+ let config = {};
112
+ try {
113
+ config = JSON.parse(String(row["config_json"]));
114
+ }
115
+ catch {
116
+ config = {};
117
+ }
118
+ return { kind: String(row["kind"]), app: typeof config["app"] === "string" ? config["app"] : null, flow: String(row["name"]), delivery: typeof config["delivery"] === "string" ? config["delivery"] : null,
119
+ lastAt: str(row["last_at"]), lastOutcome: str(row["last_outcome"]), failures: Number(row["failures"] ?? 0) };
120
+ });
121
+ }
122
+ function triggerActivity(uses) {
123
+ let activity = NO_ACTIVITY;
124
+ for (const one of uses) {
125
+ if (one.lastAt === null)
126
+ continue;
127
+ if (one.failures > 0) {
128
+ if (activity.errorAt === null || one.lastAt > activity.errorAt)
129
+ activity = { ...activity, error: one.lastOutcome ?? "The last check failed", errorAt: one.lastAt, failing: true };
130
+ }
131
+ else {
132
+ activity = { ...activity, okAt: later(activity.okAt, one.lastAt) };
133
+ }
134
+ }
135
+ return activity;
136
+ }
137
+ function merge(a, b) {
138
+ const aNewer = (a.errorAt ?? "") >= (b.errorAt ?? "");
139
+ return { okAt: later(a.okAt, b.okAt), error: aNewer ? a.error : b.error, errorAt: later(a.errorAt, b.errorAt), failing: a.failing || b.failing };
140
+ }
141
+ const plural = (count, one) => `${count} ${one}${count === 1 ? "" : "s"}`;
142
+ function flowWords(uses) {
143
+ const flows = [...new Set(uses.map(one => one.flow))];
144
+ return flows.length === 0 ? [] : [flows.length <= 2 ? `Flow${flows.length === 1 ? "" : "s"} ${flows.join(", ")}` : plural(flows.length, "flow")];
145
+ }
146
+ // ---------------------------------------------------------------- probes
147
+ const place = (href, command = null) => ({ href, command });
148
+ async function getJson(io, url, init = {}) {
149
+ const response = await (io.fetch ?? fetch)(url, { redirect: "error", signal: AbortSignal.timeout(PROBE_MS), ...init });
150
+ let body = {};
151
+ try {
152
+ const parsed = await response.json();
153
+ if (parsed !== null && typeof parsed === "object")
154
+ body = parsed;
155
+ }
156
+ catch {
157
+ body = {};
158
+ }
159
+ return { status: response.status, body };
160
+ }
161
+ const unreachable = (service) => `Couldn't reach ${service}. Check this computer's internet connection, then send a test again.`;
162
+ async function probeTelegram(io, token) {
163
+ try {
164
+ const { status, body } = await getJson(io, `https://api.telegram.org/bot${token}/getMe`);
165
+ const result = body["result"];
166
+ if (body["ok"] === true && typeof result?.["username"] === "string")
167
+ return { outcome: "ok", account: `@${result["username"]}` };
168
+ if (status === 401 || status === 404)
169
+ return { outcome: "failed", problem: "Telegram doesn't accept the saved bot token. Paste a new one from @BotFather." };
170
+ return { outcome: "failed", problem: `Telegram answered with an error (${status}). Try again in a minute.` };
171
+ }
172
+ catch {
173
+ return { outcome: "failed", problem: unreachable("Telegram") };
174
+ }
175
+ }
176
+ async function probeSlack(io, botToken) {
177
+ try {
178
+ const { body } = await getJson(io, "https://slack.com/api/auth.test", { method: "POST", headers: { Authorization: `Bearer ${botToken}`, "Content-Type": "application/json; charset=utf-8" }, body: "{}" });
179
+ if (body["ok"] === true)
180
+ return { outcome: "ok", account: typeof body["team"] === "string" ? body["team"] : null };
181
+ const code = typeof body["error"] === "string" ? body["error"] : "unknown";
182
+ if (["invalid_auth", "not_authed", "account_inactive", "token_revoked", "token_expired"].includes(code))
183
+ return { outcome: "failed", problem: "Slack no longer accepts the app's bot token. Reinstall the app in Slack and connect it again." };
184
+ return { outcome: "failed", problem: `Slack said "${code}". Connect the app again if this keeps happening.` };
185
+ }
186
+ catch {
187
+ return { outcome: "failed", problem: unreachable("Slack") };
188
+ }
189
+ }
190
+ async function probeDiscord(io, botToken) {
191
+ try {
192
+ const { status, body } = await getJson(io, "https://discord.com/api/v10/users/@me", { headers: { Authorization: `Bot ${botToken}` } });
193
+ if (status === 200 && typeof body["id"] === "string")
194
+ return { outcome: "ok" };
195
+ if (status === 401)
196
+ return { outcome: "failed", problem: "Discord no longer accepts the bot token. Reset it in the Discord developer portal and connect again." };
197
+ return { outcome: "failed", problem: `Discord answered with an error (${status}). Try again in a minute.` };
198
+ }
199
+ catch {
200
+ return { outcome: "failed", problem: unreachable("Discord") };
201
+ }
202
+ }
203
+ async function probeTeams(io, credentials) {
204
+ try {
205
+ await teamsAccessToken(credentials, io.fetch ?? fetch);
206
+ return { outcome: "ok" };
207
+ }
208
+ catch (error) {
209
+ const said = error instanceof Error ? error.message : "";
210
+ return { outcome: "failed", problem: /secret|credential|unauthori[sz]ed|invalid/i.test(said)
211
+ ? "Microsoft doesn't accept the app's client secret. Make a new secret in Azure and connect Teams again."
212
+ : unreachable("Microsoft Teams") };
213
+ }
214
+ }
215
+ async function probeGithub(io, needed) {
216
+ const gh = io.gh;
217
+ if (gh === undefined)
218
+ return { outcome: "failed", problem: "GitHub can't be checked here." };
219
+ const user = await gh("gh", ["api", "user", "--jq", ".login"], { timeoutMs: PROBE_MS, maxBuffer: 16 * 1024 }).catch(() => null);
220
+ if (user === null || user.notFound) {
221
+ const problem = "The GitHub CLI (gh) isn't installed on this computer.";
222
+ return needed ? { outcome: "failed", problem: `${problem} Install it, then run gh auth login.`, fix: { command: "gh auth login" } } : { outcome: "absent", problem, setup: { command: "gh auth login" } };
223
+ }
224
+ if (user.timedOut)
225
+ return { outcome: "failed", problem: unreachable("GitHub") };
226
+ const login = user.stdout.trim();
227
+ if (user.code !== 0 || !/^[A-Za-z0-9-]{1,39}$/.test(login)) {
228
+ const signedOut = /auth login|not logged|authenticat/i.test(`${user.stderr}\n${user.stdout}`);
229
+ if (signedOut && !needed)
230
+ return { outcome: "absent", problem: "gh isn't signed in to GitHub.", setup: { command: "gh auth login" } };
231
+ return { outcome: "failed", problem: signedOut ? "gh isn't signed in to GitHub. Run gh auth login." : unreachable("GitHub"), fix: { command: "gh auth login" } };
232
+ }
233
+ // Push rights per project: a read of the repository's own permission for this account, never a write.
234
+ const push = [], noPush = [];
235
+ for (const repo of io.repos) {
236
+ const viewed = await gh("gh", ["repo", "view", "--json", "nameWithOwner,viewerPermission"], { cwd: repo, timeoutMs: PROBE_MS, maxBuffer: 16 * 1024 }).catch(() => null);
237
+ if (viewed === null || viewed.code !== 0)
238
+ continue;
239
+ try {
240
+ const value = JSON.parse(viewed.stdout);
241
+ const name = typeof value.nameWithOwner === "string" ? value.nameWithOwner : basename(repo);
242
+ (["ADMIN", "MAINTAIN", "WRITE"].includes(String(value.viewerPermission)) ? push : noPush).push(name);
243
+ }
244
+ catch {
245
+ continue;
246
+ }
247
+ }
248
+ const detail = [push.length === 0 ? null : `Can push to ${push.join(", ")}`, noPush.length === 0 ? null : `can't push to ${noPush.join(", ")}`].filter(Boolean).join("; ") || null;
249
+ if (noPush.length > 0)
250
+ return { outcome: "failed", account: login, detail, problem: `${login} can't push to ${noPush.join(", ")}. Ask for write access on GitHub, or sign gh in as an account that has it.`, fix: { command: "gh auth login" } };
251
+ return { outcome: "ok", account: login, detail };
252
+ }
253
+ async function probeLinear(io, key) {
254
+ try {
255
+ const { status, body } = await getJson(io, LINEAR_URL, { method: "POST", headers: { Authorization: key, "Content-Type": "application/json" }, body: JSON.stringify({ query: "{ viewer { name organization { name } } }" }) });
256
+ const viewer = body["data"]?.viewer;
257
+ if (status === 200 && viewer !== undefined && viewer !== null)
258
+ return { outcome: "ok", account: typeof viewer.organization?.name === "string" ? viewer.organization.name : null };
259
+ if (status === 400 || status === 401 || status === 403)
260
+ return { outcome: "failed", problem: "Linear doesn't accept the saved API key. Make a new one in Linear → Settings → Security & access, and save it on a flow's Linear trigger." };
261
+ return { outcome: "failed", problem: `Linear answered with an error (${status}). Try again in a minute.` };
262
+ }
263
+ catch {
264
+ return { outcome: "failed", problem: unreachable("Linear") };
265
+ }
266
+ }
267
+ /** Whether something answers at host:port: a connection opened and closed, no login and no message. */
268
+ export function reachServer(host, port, secure) {
269
+ return new Promise((resolve, reject) => {
270
+ const socket = secure ? tlsConnect({ host, port, servername: host, timeout: PROBE_MS }) : tcpConnect({ host, port, timeout: PROBE_MS });
271
+ const done = (error) => { socket.removeAllListeners(); socket.destroy(); if (error === undefined)
272
+ resolve();
273
+ else
274
+ reject(error); };
275
+ socket.once(secure ? "secureConnect" : "connect", () => done());
276
+ socket.once("timeout", () => done(new Error("timed out")));
277
+ socket.once("error", error => done(error));
278
+ });
279
+ }
280
+ async function probeEmail(io, smtp, imap) {
281
+ const reach = io.reach ?? reachServer;
282
+ const failed = [];
283
+ if (smtp !== null)
284
+ await reach(smtp.host, smtp.port, smtp.secure).catch(() => failed.push(`the outgoing mail server ${smtp.host}:${smtp.port}`));
285
+ if (imap !== null)
286
+ await reach(imap.host, imap.port, imap.secure).catch(() => failed.push(`the incoming mail server ${imap.host}:${imap.port}`));
287
+ const detail = [smtp === null ? null : "Sending", imap === null ? null : "reading"].filter(Boolean).join(" and ") || null;
288
+ if (failed.length > 0)
289
+ return { outcome: "failed", detail, problem: `Couldn't reach ${failed.join(" or ")}. Check the server name and port in Settings → Email.` };
290
+ return { outcome: "ok", detail: detail === null ? null : `${detail} reachable` };
291
+ }
292
+ async function probeAgent(io, provider) {
293
+ if (io.checkConnection === undefined)
294
+ return { outcome: "failed", problem: `${providerName(provider)} can't be checked here.` };
295
+ let connection;
296
+ try {
297
+ connection = await io.checkConnection(provider);
298
+ }
299
+ catch {
300
+ return { outcome: "failed", problem: `Couldn't check ${providerName(provider)}'s sign-in.` };
301
+ }
302
+ const name = providerName(provider);
303
+ const account = connection.email ?? connection.method ?? null;
304
+ const detail = connection.plan ?? null;
305
+ const command = signInCommand({ provider, authMode: connection.mode });
306
+ switch (connection.state) {
307
+ case "connected":
308
+ case "key-works":
309
+ case "key-present": return { outcome: "ok", account, detail };
310
+ case "not-installed": return { outcome: "absent", problem: `The ${name} CLI isn't installed on this computer.` };
311
+ case "signed-out": return { outcome: "failed", account, problem: `${name} isn't signed in. Run ${command}.`, fix: { command } };
312
+ case "missing-key": return { outcome: "failed", problem: `${name} is set to use an API key, and none is saved. Run ${command}.`, fix: { command } };
313
+ case "key-refused": return { outcome: "failed", problem: `${name} refused the saved API key. Save a new one with ${command}.`, fix: { command } };
314
+ default: return { outcome: "ok", account, detail: "Signed in; the account couldn't be read" };
315
+ }
316
+ }
317
+ // ---------------------------------------------------------------- targets
318
+ function targets(io) {
319
+ const { store, dir, env } = io;
320
+ const uses = triggerUses(store);
321
+ const telegram = io.telegramTokenFile === null ? null : loadBotToken(env, io.telegramTokenFile);
322
+ const slack = dir === null ? null : loadSlackCredentials(dir);
323
+ const discord = dir === null ? null : loadDiscordCredentials(dir);
324
+ const teams = dir === null ? null : loadTeamsCredentials(dir);
325
+ const primary = dir === null ? null : effectivePrimary(env, dir, telegram !== null).channel;
326
+ const chatUses = (app) => [...(primary === app ? ["Alerts"] : []), ...flowWords(uses.filter(one => one.kind === "chat" && one.app === app))];
327
+ const list = [];
328
+ list.push({
329
+ key: "telegram", group: "chat", name: "Telegram", configured: telegram !== null, account: null,
330
+ setup: place("/settings#telegram-token"), fix: place("/settings#telegram-token"), usedBy: chatUses("telegram"), activity: telegramActivity(store),
331
+ probe: telegram === null ? null : check => probeTelegram(check, telegram.token),
332
+ });
333
+ list.push({
334
+ key: "slack", group: "chat", name: "Slack", configured: slack !== null, account: slack?.workspace ?? null,
335
+ setup: place("/settings/slack"), fix: place("/settings/slack"), usedBy: chatUses("slack"), activity: slack === null ? NO_ACTIVITY : chatActivity(store, "slack", slack.installation),
336
+ probe: slack === null ? null : check => probeSlack(check, slack.botToken),
337
+ });
338
+ list.push({
339
+ key: "discord", group: "chat", name: "Discord", configured: discord !== null, account: discord?.workspace ?? null,
340
+ setup: place("/settings/discord"), fix: place("/settings/discord"), usedBy: chatUses("discord"), activity: discord === null ? NO_ACTIVITY : chatActivity(store, "discord", discord.installation),
341
+ probe: discord === null ? null : check => probeDiscord(check, discord.botToken),
342
+ });
343
+ list.push({
344
+ key: "teams", group: "chat", name: "Microsoft Teams", configured: teams !== null, account: teams === null ? null : "Microsoft Teams",
345
+ setup: place("/settings/teams"), fix: place("/settings/teams"), usedBy: chatUses("teams"), activity: teams === null ? NO_ACTIVITY : chatActivity(store, "teams", teams.installation),
346
+ probe: teams === null ? null : check => probeTeams(check, teams),
347
+ });
348
+ // GitHub: always worth checking (gh is how results become pull requests); webhook deliveries count when public.
349
+ const githubUses = uses.filter(one => one.kind === "github");
350
+ const hooksPublic = readHooksBase(dir) !== null;
351
+ const githubActivity = triggerActivity(githubUses.filter(one => one.delivery !== "webhook" || hooksPublic));
352
+ list.push({
353
+ key: "github", group: "code", name: "GitHub", configured: true, account: null,
354
+ setup: place(null, "gh auth login"), fix: place(null, "gh auth login"),
355
+ usedBy: ["Pull requests", ...flowWords(githubUses), ...(hooksPublic && githubUses.some(one => one.delivery === "webhook") ? ["Webhooks"] : [])], activity: githubActivity,
356
+ probe: check => probeGithub(check, githubUses.length > 0),
357
+ });
358
+ const linearKey = readLinearKey(dir);
359
+ const linearUses = uses.filter(one => one.kind === "linear");
360
+ list.push({
361
+ key: "linear", group: "code", name: "Linear", configured: linearKey !== null, account: null,
362
+ setup: place("/flows"), fix: place("/flows"), usedBy: flowWords(linearUses), activity: triggerActivity(linearUses),
363
+ probe: linearKey === null ? null : check => probeLinear(check, linearKey),
364
+ });
365
+ const email = readEmailSettings(dir);
366
+ const google = googleConnected(dir);
367
+ const emailUses = uses.filter(one => one.kind === "email");
368
+ const mailWatch = tableExists(store, "flow_mail_watch") ? store.handle.prepare("SELECT failures, last_outcome, updated_at FROM flow_mail_watch WHERE id = 1").get() : undefined;
369
+ const watchActivity = mailWatch === undefined ? NO_ACTIVITY : Number(mailWatch["failures"] ?? 0) > 0
370
+ ? { okAt: null, error: str(mailWatch["last_outcome"]), errorAt: str(mailWatch["updated_at"]), failing: true }
371
+ : { okAt: str(mailWatch["updated_at"]), error: null, errorAt: null, failing: false };
372
+ list.push({
373
+ key: "email", group: "mail", name: "Email", configured: email !== null || google !== null, account: email?.from ?? google?.address ?? null,
374
+ setup: place("/settings#email"), fix: place("/settings#email"), usedBy: [...flowWords(emailUses)], activity: merge(triggerActivity(emailUses), watchActivity),
375
+ probe: email === null ? null : check => probeEmail(check, { host: email.host, port: email.port, secure: email.secure }, email.imap),
376
+ });
377
+ // Each project's MCP tools: the server starts and lists its tools.
378
+ const toolHome = io.toolHome ?? homedir();
379
+ let anyTool = false;
380
+ for (const repo of io.repos) {
381
+ for (const tool of projectToolsOf(store, repo)) {
382
+ anyTool = true;
383
+ const href = `/settings/tools?repo=${encodeURIComponent(repo)}#tool-${encodeURIComponent(tool.name)}`;
384
+ const test = tool.lastTest;
385
+ list.push({
386
+ key: `mcp:${repo}:${tool.name}`, group: "tools", name: tool.name, configured: true, account: basename(repo),
387
+ setup: place(href), fix: place(href), usedBy: [`Tasks in ${basename(repo)}`],
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
+ probe: async (check) => {
390
+ const missing = missingSecrets(tool.spec, readToolSecrets(repo, tool.name, toolHome));
391
+ if (missing.length > 0)
392
+ return { outcome: "failed", problem: `${tool.name} needs ${missing.join(", ")}. Add ${missing.length === 1 ? "it" : "them"} in Settings → Tools.` };
393
+ const tested = await testToolOf(check.store, repo, tool.name, (check.clock ?? (() => new Date()))(), { home: toolHome, omitEnv: ALL_CREDENTIAL_ENV, timeoutMs: 15_000 });
394
+ if (tested === null)
395
+ return { outcome: "failed", problem: `${tool.name} is no longer on this project.` };
396
+ return tested.ok ? { outcome: "ok", account: basename(repo), detail: plural(tested.tools.length, "tool") } : { outcome: "failed", account: basename(repo), problem: `${tool.name} didn't start: ${scrubIntegrationText(tested.problem ?? "no answer")}` };
397
+ },
398
+ });
399
+ }
400
+ }
401
+ if (!anyTool)
402
+ list.push({ key: "mcp", group: "tools", name: "MCP tools", configured: false, account: null, setup: place("/settings/tools"), fix: place("/settings/tools"), usedBy: [], activity: NO_ACTIVITY, probe: null });
403
+ // Monitoring: the delivery loop is the check; its status row says how it's going.
404
+ const monitoring = readMonitoring(dir);
405
+ const destinations = [
406
+ ...(monitoring.webhook === null ? [] : [{ sink: "webhook", name: "Audit webhook", account: origin(monitoring.webhook.url), address: monitoring.webhook.url, uses: "Audit stream" }]),
407
+ ...(monitoring.folder === null ? [] : [{ sink: "folder", name: "Audit folder", account: monitoring.folder.path, address: monitoring.folder.path, uses: "Audit stream" }]),
408
+ ...(monitoring.traces === null ? [] : [{ sink: "traces", name: "Traces", account: origin(monitoring.traces.endpoint), address: monitoring.traces.endpoint, uses: "Run traces" }]),
409
+ ];
410
+ for (const one of destinations) {
411
+ const status = store.monitoringStatus(one.sink).find(row => row.target === targetOf(one.address)) ?? null;
412
+ const activity = status === null ? NO_ACTIVITY : { okAt: status.lastOkAt, error: status.lastError, errorAt: status.lastErrorAt, failing: status.failures > 0 };
413
+ list.push({
414
+ key: `monitoring:${one.sink}`, group: "monitoring", name: one.name, configured: true, account: one.account,
415
+ setup: place("/settings/monitoring"), fix: place("/settings/monitoring"), usedBy: [one.uses], activity,
416
+ probe: async () => status === null || (status.lastOkAt === null && status.lastError === null)
417
+ ? { outcome: "ok", account: one.account, detail: "Starting: the first delivery goes out within a few seconds" }
418
+ : status.failures > 0 ? { outcome: "failed", account: one.account, problem: `Deliveries are failing: ${status.lastError ?? "no answer"}` }
419
+ : { outcome: "ok", account: one.account },
420
+ });
421
+ }
422
+ if (destinations.length === 0)
423
+ list.push({ key: "monitoring", group: "monitoring", name: "Monitoring", configured: false, account: null, setup: place("/settings/monitoring"), fix: place("/settings/monitoring"), usedBy: [], activity: NO_ACTIVITY, probe: null });
424
+ for (const provider of AGENTS) {
425
+ list.push({
426
+ key: `agent:${provider}`, group: "agents", name: providerName(provider), configured: true, account: null,
427
+ setup: place("/settings#providers"), fix: place("/settings#providers"), usedBy: ["Tasks"], activity: NO_ACTIVITY,
428
+ probe: check => probeAgent(check, provider),
429
+ });
430
+ }
431
+ return list;
432
+ }
433
+ // ---------------------------------------------------------------- the list
434
+ function combine(target, row) {
435
+ const base = { key: target.key, group: target.group, name: target.name, usedBy: target.usedBy };
436
+ const checked = target.probe === null || row !== undefined;
437
+ const lastSuccessAt = later(row?.okAt ?? null, target.activity.okAt);
438
+ const checkError = row?.error ?? null, checkErrorAt = row?.errorAt ?? null;
439
+ const activityNewer = (target.activity.errorAt ?? "") > (checkErrorAt ?? "");
440
+ const lastError = target.activity.error !== null && (activityNewer || checkError === null) ? scrubIntegrationText(target.activity.error) : checkError;
441
+ const lastErrorAt = lastError === null ? null : later(checkErrorAt, target.activity.errorAt);
442
+ const common = { account: row?.account ?? target.account, detail: row?.detail ?? null, checked, checkedAt: row?.checkedAt ?? null, lastSuccessAt, lastError, lastErrorAt };
443
+ if (!target.configured || row?.outcome === "absent") {
444
+ return { ...base, ...common, account: null, detail: row?.outcome === "absent" ? row.problem : null, state: "not-set-up", action: { kind: "setup", label: "Set up", ...target.setup } };
445
+ }
446
+ // Broken: the last check failed, or deliveries are failing and no successful check has come since.
447
+ const failing = row?.outcome === "failed" ? row.problem
448
+ : target.activity.failing && (row === undefined || (target.activity.errorAt ?? "") >= row.checkedAt) ? target.activity.error ?? "Deliveries are failing."
449
+ : null;
450
+ if (failing !== null) {
451
+ return { ...base, ...common, state: "broken", action: { kind: "fix", label: "Fix", words: scrubIntegrationText(failing ?? "The last check failed."), ...target.fix } };
452
+ }
453
+ return { ...base, ...common, state: "connected", action: { kind: "test", label: "Send test" } };
454
+ }
455
+ /** Every integration as it stands now, from files and saved history only: no network, no process, never slow. */
456
+ export function integrationsNow(io) {
457
+ const rows = readIntegrationChecks(io.store);
458
+ return targets(io).map(target => combine(target, rows.get(target.key)));
459
+ }
460
+ /** Run the checks (all, or the named ones), record each answer, and return the list. */
461
+ export async function checkIntegrations(io, keys) {
462
+ const clock = io.clock ?? (() => new Date());
463
+ const wanted = targets(io).filter(one => one.probe !== null && (keys === undefined || keys.includes(one.key)));
464
+ await Promise.all(wanted.map(async (target) => {
465
+ let result;
466
+ try {
467
+ result = await target.probe(io);
468
+ }
469
+ catch {
470
+ result = { outcome: "failed", problem: "The check stopped unexpectedly. Try again." };
471
+ }
472
+ recordIntegrationCheck(io.store, target.key, result, clock());
473
+ }));
474
+ return integrationsNow(io);
475
+ }
476
+ /** The one line `status` and `onboard` add when something is Broken; null when nothing is. */
477
+ export function integrationsBrokenLine(list) {
478
+ const broken = list.filter(one => one.state === "broken").map(one => one.name);
479
+ if (broken.length === 0)
480
+ return null;
481
+ const names = broken.length <= 3 ? broken.join(", ") : `${broken.slice(0, 3).join(", ")} and ${broken.length - 3} more`;
482
+ return `Broken integration${broken.length === 1 ? "" : "s"}: ${names}. See \`toolroll integrations\` or Settings → Integrations.`;
483
+ }
484
+ const whenText = (at) => (at === null ? "never" : `${at.slice(0, 16).replace("T", " ")} UTC`);
485
+ /** The CLI's lines: one per integration, then the fix under anything Broken. */
486
+ export function renderIntegrations(list) {
487
+ const width = Math.min(24, Math.max(...list.map(one => one.name.length)));
488
+ return list.flatMap(one => {
489
+ const facts = [one.account, one.detail].filter(Boolean).join(" · ");
490
+ const lines = [`${one.name.padEnd(width)} ${STATE_WORDS[one.state].padEnd(10)}${facts === "" ? "" : ` ${facts}`}`];
491
+ if (one.state !== "not-set-up")
492
+ lines.push(`${"".padEnd(width)} last success ${whenText(one.lastSuccessAt)}${one.lastError === null ? "" : ` · last error ${whenText(one.lastErrorAt)}: ${one.lastError}`}`);
493
+ if (one.action.kind === "fix")
494
+ lines.push(`${"".padEnd(width)} Fix: ${one.action.words}${one.action.command === null || one.action.words.includes(one.action.command) ? "" : ` (run ${one.action.command})`}`);
495
+ if (one.action.kind === "setup")
496
+ lines.push(`${"".padEnd(width)} Set up: ${one.action.command !== null ? `run ${one.action.command}` : `Settings (${one.action.href ?? "/settings"})`}`);
497
+ return lines;
498
+ });
499
+ }
500
+ /** Checks in the background with a short cache, as the first-run suggestions are: a render never waits on a check. */
501
+ export function createIntegrationMonitor(io, options = {}) {
502
+ const cacheMs = options.cacheMs ?? 60_000;
503
+ const now = options.now ?? Date.now;
504
+ let lastStarted = 0;
505
+ let running = null;
506
+ const start = (keys) => {
507
+ const current = io();
508
+ const run = checkIntegrations(current, keys).catch(() => []);
509
+ if (keys === undefined) {
510
+ lastStarted = now();
511
+ running = run.finally(() => { running = null; });
512
+ }
513
+ return run;
514
+ };
515
+ return {
516
+ list() {
517
+ const current = io();
518
+ const list = integrationsNow(current);
519
+ if (running === null && now() - lastStarted >= cacheMs)
520
+ void start();
521
+ return list;
522
+ },
523
+ check: keys => start(keys),
524
+ get checking() { return running !== null; },
525
+ };
526
+ }
package/dist/mcp.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { TASK_SCOPE_TEXT_SCHEMA } from "./task-text.js";
2
2
  import { dirname, join } from 'node:path';
3
+ import { statSync } from 'node:fs';
3
4
  import { repositoryContextRead } from './repository-context.js';
4
5
  import { ASSIGNMENT_TOOLS, assignmentForCoordinator } from "./assignment-adapters.js";
5
6
  /**
@@ -367,12 +368,30 @@ function proposeTools() {
367
368
  make("answer", "Propose an answer to an open decision you read with get_decision, with a rationale. The approver confirms where every consequence and the builder's recommendation are shown; an irreversible option needs their explicit confirmation.", { decision: { type: "integer", minimum: 1 }, option: { type: "string", minLength: 1, maxLength: 64 }, rationale: { type: "string", maxLength: 400 } }, ["decision", "option", "rationale"]),
368
369
  ];
369
370
  }
371
+ function fileIdentity(path) {
372
+ if (path === null)
373
+ return null;
374
+ try {
375
+ const at = statSync(path);
376
+ return { path, dev: at.dev, ino: at.ino };
377
+ }
378
+ catch {
379
+ return null;
380
+ }
381
+ }
382
+ /** Still the same file at the same path: not moved aside, deleted, or replaced by a rename. */
383
+ function sameFile(was) {
384
+ const now = fileIdentity(was.path);
385
+ return now !== null && now.dev === was.dev && now.ino === was.ino;
386
+ }
370
387
  /** Serve until EOF. The store is already open through the non-migrating
371
388
  * door; the token was startup-verified by the caller (and dies here again
372
389
  * if it does not hold). */
373
390
  export function serveMcp(store, token, io, clock = () => new Date(), enrolled = null, evidenceRoot) {
374
391
  /** Per connection: which decisions this credential read in full (v3). */
375
392
  const readDecisions = new Set();
393
+ /** The database file this server opened, by identity (null in memory). */
394
+ const database = fileIdentity(store.databaseFile());
376
395
  const auth = authenticateCoordinator(store, token);
377
396
  if (!auth.ok) {
378
397
  return {
@@ -414,6 +433,13 @@ export function serveMcp(store, token, io, clock = () => new Date(), enrolled =
414
433
  tools: TOOLS.map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema })),
415
434
  });
416
435
  const callTool = (id, era, params) => {
436
+ // A restore or rollback puts a different file at the database's path: this server's connection still reads and
437
+ // writes the one it replaced, so it answers nothing more and exits (the agent starts it again on the new one).
438
+ if (database !== null && !sameFile(database)) {
439
+ error(id, -32000, "the database was replaced underneath this server (a Toolroll restore or rollback) — restart it");
440
+ io.exit(0);
441
+ return;
442
+ }
417
443
  // ONE snapshot per call (review finding 3): the version check, the
418
444
  // credential re-read, and every data read share a transaction, so a
419
445
  // concurrent migration cannot slip between them; filing's own
package/dist/operate.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type IntegrationIo } from "./integrations.js";
1
2
  import { type OnboardIo } from "./agent-onboard.js";
2
3
  import { type TriggerIo } from "./flow-triggers.js";
3
4
  import { type StepIo } from "./flow-steps.js";
@@ -22,10 +23,6 @@ export declare const EXIT: {
22
23
  readonly usage: 2;
23
24
  readonly refused: 3;
24
25
  };
25
- /** Storage retention: how long a finished task's clean checkout is kept after it was let go, and a result marked
26
- * complete's or a release candidate's after it was (its checkout holds the build a deploy installs). */
27
- export declare const CHECKOUT_KEEP_MS: number;
28
- export declare const RESULT_KEEP_MS: number;
29
26
  export type OperateOptions = {
30
27
  /** Native-shell proof key, passed in memory rather than command-line arguments. */
31
28
  desktopIdentity?: string;
@@ -59,6 +56,8 @@ export type OperateOptions = {
59
56
  flowTriggerIo?: Partial<TriggerIo>;
60
57
  /** Injected by tests: how flow check and update steps run commands and reach GitHub and Linear. */
61
58
  flowStepIo?: Partial<StepIo>;
59
+ /** Injected by tests: how `integrations` checks reach services (fetch, gh, sign-in checks, mail servers). */
60
+ integrationIo?: Partial<IntegrationIo>;
62
61
  /** Injected by tests: the mate's provider fetch, key environment, and stdin lines. */
63
62
  mateSeams?: MateCliSeams;
64
63
  /** Injected by tests: a held-session coordinator, so a `tick` exercises
@@ -76,7 +75,7 @@ export type OperateOptions = {
76
75
  openBrowser?: (url: string) => void;
77
76
  };
78
77
  };
79
- export type OnboardSeams = Partial<Pick<OnboardIo, "home" | "env" | "cwd" | "interactive" | "confirm" | "findRepo" | "checkConnection">>;
78
+ export type OnboardSeams = Partial<Pick<OnboardIo, "home" | "env" | "cwd" | "interactive" | "confirm" | "findRepo" | "checkConnection" | "pullRequests">>;
80
79
  export declare const OPERATE_HELP: string;
81
80
  /** Parsed flags, with the positionals left over. */
82
81
  type Args = {
@@ -93,7 +92,7 @@ type Args = {
93
92
  * (surface.ts) is tested for exact equality against them.
94
93
  */
95
94
  export declare const TASK_ACTIONS: readonly ["add", "list", "show", "wait", "state", "block", "unblock", "next", "steer", "assign", "reopen", "scope", "approve", "hold", "unhold", "require", "requeue", "regate", "plan", "review", "accept", "repair", "route", "stop", "resume", "complete", "revise"];
96
- export declare const PUBLISH_ACTIONS: readonly ["grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
95
+ export declare const PUBLISH_ACTIONS: readonly ["setup", "grant", "revoke", "status", "unblock", "rearm", "merge", "refire"];
97
96
  export declare const CONFIG_ACTIONS: readonly ["show", "set", "clear"];
98
97
  export declare const APPROVER_ACTIONS: readonly ["list", "add"];
99
98
  export declare const ROUTINE_ACTIONS: readonly ["list", "add", "show", "approve", "refresh", "pause", "resume", "run-now"];