toolroll 0.9.17 → 0.9.19

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.
@@ -12,6 +12,7 @@ import { asksToFinish, batchLine, chatText, chatTitle, factLinkLabel, mentions,
12
12
  import { phoneText, projectLabel } from "./telegram-status.js";
13
13
  import { isLifecycleNotification } from "./store.js";
14
14
  import { leadSubjectOf } from "./lead-voice.js";
15
+ import { decideFallbackLink, decideTargetOf } from "./chat-decide.js";
15
16
  /** What always makes a new message, even in quiet mode: a question or approval waiting, a failure or other
16
17
  * attention fact, a result Ready for review, a security alert or release, and anything addressed to one person
17
18
  * (a sign-in or plan-limit pause, an evening digest). */
@@ -132,7 +133,8 @@ function lineOf(store, item, now, root, viewer) {
132
133
  const subject = leadSubjectOf(store, row, viewer);
133
134
  const words = chatText(phoneText(row.body === "" ? subject : `${subject}\n\n${row.body}`, 2500), [{ id: ref.externalId, title: summary }]);
134
135
  const { phrase, headline } = updatePhrase(row);
135
- return { also: [], link: row.link === null ? { label: "Open task", path: chatControlHref("task", ref.externalId) } : { label: factLinkLabel(row.link), path: row.link },
136
+ const target = decideTargetOf(row);
137
+ return { also: [], target, link: decideFallbackLink(target) ?? (row.link === null ? { label: "Open task", path: chatControlHref("task", ref.externalId) } : { label: factLinkLabel(row.link), path: row.link }),
136
138
  fact: { summary, headline, checks: null, report: false, completedBy: null, update: { words: mentions(words, summary) ? words : `${summary} · ${words}`, phrase } } };
137
139
  }
138
140
  const run = item.run === null ? null : store.getRun(item.run);
@@ -156,6 +158,7 @@ function lineOf(store, item, now, root, viewer) {
156
158
  // A result ready for a person offers its real next step, then a look first.
157
159
  if (asksToFinish(fact))
158
160
  return { fact, also: [{ label: READY_ACTIONS.look, path: chatResultHref(ref.externalId, run.id, "changes") }],
161
+ target: pullRequest ? { kind: "merge", taskId: ref.externalId, run: run.id } : { kind: "result", taskId: ref.externalId, run: run.id },
159
162
  link: pullRequest ? { label: READY_ACTIONS.merge, path: `/t/${encodeURIComponent(ref.externalId)}#merge` } : { label: READY_ACTIONS.complete, path: chatResultHref(ref.externalId, run.id) } };
160
163
  return { fact, link: card.link, also: [] };
161
164
  }
@@ -168,7 +171,8 @@ export function finishedView(store, batch, now, root, viewer) {
168
171
  const text = batchLine(lines.map(one => one.fact));
169
172
  if (lines.length > 1)
170
173
  return { text, entities: [], link: { label: "Open", path: "/tasks" } };
171
- return { text, entities: [], link: lines[0].link, ...(lines[0].also.length === 0 ? {} : { also: lines[0].also }) };
174
+ const target = lines[0].target ?? null;
175
+ return { text, entities: [], link: lines[0].link, ...(lines[0].also.length === 0 ? {} : { also: lines[0].also }), ...(target === null ? {} : { target }) };
172
176
  }
173
177
  // ---- the evening digest ----------------------------------------------------------
174
178
  const FAILURE = /fail|exhausted|fenced/;
@@ -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
  } | {
@@ -11,6 +11,8 @@ export type DiscordApi = (method: "GET" | "POST" | "PATCH" | "PUT", path: string
11
11
  }) => Promise<Record<string, unknown>>;
12
12
  export declare class DiscordError extends ChatDeliveryError {
13
13
  }
14
+ /** Discord's 403: the bot may not do this here (for a file, no Attach Files permission). */
15
+ export declare const DISCORD_REFUSED = "Discord refused access. Check the app and direct-message permissions.";
14
16
  export declare function discordApi(token: string, fetcher?: typeof fetch): DiscordApi;
15
17
  export declare const discordCredentialFile: (dir: string) => string;
16
18
  export declare function loadDiscordCredentials(dir: string): DiscordCredentials | null;
@@ -8,6 +8,8 @@ export { object };
8
8
  export const discordId = (v) => typeof v === "string" && /^[1-9][0-9]{16,19}$/.test(v);
9
9
  export class DiscordError extends ChatDeliveryError {
10
10
  }
11
+ /** Discord's 403: the bot may not do this here (for a file, no Attach Files permission). */
12
+ export const DISCORD_REFUSED = "Discord refused access. Check the app and direct-message permissions.";
11
13
  export function discordApi(token, fetcher = fetch) {
12
14
  return async (method, path, body, file) => {
13
15
  // Only relative API routes; never send the bot credential to supplied URLs.
@@ -56,7 +58,7 @@ export function discordApi(token, fetcher = fetch) {
56
58
  const message = response.status === 401
57
59
  ? "Discord access expired. Disconnect and reconnect with the current bot token."
58
60
  : response.status === 403
59
- ? "Discord refused access. Check the app and direct-message permissions."
61
+ ? DISCORD_REFUSED
60
62
  : response.status === 404
61
63
  ? "The Discord message or conversation is unavailable. Open the saved chat."
62
64
  : response.status === 413
@@ -2,6 +2,7 @@
2
2
  import { chatQuestionButtons } from "./teammate-question.js";
3
3
  import { chatAskButtons } from "./chat-ask.js";
4
4
  import { chatFlowButtons } from "./chat-flow.js";
5
+ import { refuseResultShots, resultShotsPruned, shotWaitsUntil } from "./result-shots.js";
5
6
  import { channelInbox } from "./chat-inbox.js";
6
7
  import { roomCommand } from "./chat-rooms.js";
7
8
  import { ChatState, ChatDeliveryError, chatHash, } from "./chat-delivery-state.js";
@@ -12,7 +13,7 @@ import { discordPlain, renderReply } from "./reply-shape.js";
12
13
  import { WARM_EMOJI } from "./chat-warmth.js";
13
14
  import { proposalPreview, proposalLink, proposalOutcomeText, armedCardText, armedYesLabel, } from "./chat-channel.js";
14
15
  import { resultImageFileName, safeResultImageCaption, verifyResultImage, } from "./chat-evidence.js";
15
- import { discordId, discordMember, DiscordError, } from "./discord-api.js";
16
+ import { discordId, discordMember, DiscordError, DISCORD_REFUSED, } from "./discord-api.js";
16
17
  export const discordDelivery = (options) => ({
17
18
  ...options,
18
19
  state: new ChatState(options.store, "discord"),
@@ -205,6 +206,18 @@ export async function deliverDiscordPart(options) {
205
206
  !repos.includes(store.lookupRef(content.task)?.repo ?? ""))
206
207
  throw new DiscordError("Connected projects changed");
207
208
  let text = content.text, buttons = [], file;
209
+ if (content.image && content.shot && resultShotsPruned(options.evidenceRoot, content.image.run)) {
210
+ state.prepare("UPDATE chat_part SET state='dropped',problem='Removed by retention' WHERE id=?").run(row.id);
211
+ return true;
212
+ }
213
+ // A screenshot sent with a result replies to that result's message once it is placed, and waits until then.
214
+ const result = content.shot?.follows == null ? undefined : state.prepare("SELECT message,state,next_at FROM chat_part WHERE id=?").get(content.shot.follows);
215
+ if (result !== undefined && !discordId(result["message"]) && result["state"] === "pending") {
216
+ state.prepare("UPDATE chat_part SET next_at=? WHERE id=?").run(shotWaitsUntil(result["next_at"], now), row.id);
217
+ return true;
218
+ }
219
+ const follows = result?.["message"];
220
+ const thread = discordId(follows) ? String(follows) : event.thread;
208
221
  if (content.image) {
209
222
  const image = verifyResultImage(store, options.evidenceRoot, repos, content.image);
210
223
  if (!image.ok)
@@ -359,9 +372,9 @@ export async function deliverDiscordPart(options) {
359
372
  if (!target) {
360
373
  args.nonce = nonce;
361
374
  args.enforce_nonce = true;
362
- if (discordId(event.thread))
375
+ if (discordId(thread))
363
376
  args.message_reference = {
364
- message_id: event.thread,
377
+ message_id: thread,
365
378
  channel_id: destination,
366
379
  fail_if_not_exists: false,
367
380
  };
@@ -385,6 +398,12 @@ export async function deliverDiscordPart(options) {
385
398
  return true;
386
399
  }
387
400
  catch (error) {
401
+ // No permission to attach files here: one plain line instead of the result's screenshots.
402
+ const content = JSON.parse(row.payload);
403
+ if (content.image && content.shot && error instanceof DiscordError && error.code === DISCORD_REFUSED) {
404
+ refuseResultShots(state, store, row, content, "Discord", options.clock?.() ?? new Date());
405
+ return true;
406
+ }
388
407
  const problem = error instanceof ChatDeliveryError
389
408
  ? error
390
409
  : new DiscordError("Discord delivery is waiting to retry", 15_000, true);
package/dist/modes.d.ts CHANGED
@@ -65,6 +65,12 @@ export type ModeTerms = {
65
65
  * `repairAuto` is also true — a mode may sign a cap without signing the
66
66
  * authority, but never the reverse. */
67
67
  repairMaxAttempts: number;
68
+ /** Explicit opt-in: the signer's paired Telegram chat may approve this
69
+ * repository's plans and merge its ready pull requests with two taps,
70
+ * for the mode's lifetime. A plan that widens permissions, exceeds the
71
+ * per-attempt budget or touches protected paths still opens Toolroll.
72
+ * Legacy modes read FALSE — never inherited, only freshly signed. */
73
+ chatApprove: boolean;
68
74
  absoluteExpiry: string;
69
75
  };
70
76
  export declare const MODE_MAX_DAYS = 7;
package/dist/modes.js CHANGED
@@ -34,6 +34,7 @@ export function presetTerms(name, absoluteExpiry) {
34
34
  allowPaidFallback: false,
35
35
  repairAuto: false,
36
36
  repairMaxAttempts: 0,
37
+ chatApprove: false,
37
38
  absoluteExpiry,
38
39
  }
39
40
  : {
@@ -51,6 +52,7 @@ export function presetTerms(name, absoluteExpiry) {
51
52
  allowPaidFallback: false,
52
53
  repairAuto: false,
53
54
  repairMaxAttempts: 0,
55
+ chatApprove: false,
54
56
  absoluteExpiry,
55
57
  };
56
58
  }
@@ -119,6 +121,7 @@ export function modeTermsFromJson(json) {
119
121
  (t["repairAuto"] === undefined || typeof t["repairAuto"] === "boolean") &&
120
122
  (t["repairMaxAttempts"] === undefined ||
121
123
  (typeof t["repairMaxAttempts"] === "number" && Number.isInteger(t["repairMaxAttempts"]) && t["repairMaxAttempts"] >= 0 && t["repairMaxAttempts"] <= 3)) &&
124
+ (t["chatApprove"] === undefined || typeof t["chatApprove"] === "boolean") &&
122
125
  typeof t["absoluteExpiry"] === "string" &&
123
126
  !Number.isNaN(Date.parse(t["absoluteExpiry"]))) {
124
127
  return {
@@ -136,6 +139,7 @@ export function modeTermsFromJson(json) {
136
139
  allowPaidFallback: t["allowPaidFallback"] === true,
137
140
  repairAuto: t["repairAuto"] === true,
138
141
  repairMaxAttempts: typeof t["repairMaxAttempts"] === "number" ? t["repairMaxAttempts"] : 0,
142
+ chatApprove: t["chatApprove"] === true,
139
143
  absoluteExpiry: t["absoluteExpiry"],
140
144
  };
141
145
  }
@@ -179,6 +183,8 @@ export function modeWords(terms) {
179
183
  : "automatic fallback never switches to a paid API key on its own; a subscription that runs out stops and waits for you",
180
184
  ...(terms.repairAuto
181
185
  ? ["historical automatic repair grants are retained on record but no longer schedule work"] : []),
186
+ ...(terms.chatApprove
187
+ ? ["your paired Telegram chat may approve this repository's plans and merge its ready pull requests, two taps each, without your password; a plan that widens permissions, exceeds the per-attempt cap above or touches protected paths still opens Toolroll"] : []),
182
188
  `everything above ends at ${terms.absoluteExpiry.slice(0, 16).replace("T", " ")} — revoking it earlier is one click, and every act it covered falls back to its own ceremony`,
183
189
  ];
184
190
  }
package/dist/operate.js CHANGED
@@ -79,7 +79,7 @@ import { pullRequestLines, runTaskMergeCommand, runTaskOutcomeCommand } from "./
79
79
  import { homedir, hostname, tmpdir } from "node:os";
80
80
  import { basename, dirname, join, resolve } from "node:path";
81
81
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
82
- import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, isDigestTime, } from "./store.js";
82
+ import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, DEFAULT_ACTOR, parseCapabilityKey, verifiedAuthor, contestantProfileOf, isDigestTime, RESULT_SCREENSHOTS, } from "./store.js";
83
83
  import { randomBytes, randomInt, randomUUID } from "node:crypto";
84
84
  import { authorizePlanUnderMode } from "./plan-auto.js";
85
85
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
@@ -247,6 +247,7 @@ export const OPERATE_HELP = `toolroll — operating the queue
247
247
  toolroll notifications [quiet|all] how chats reach you: only when you're needed (the default), or every step
248
248
  toolroll notifications digest <HH:MM>|off one evening message: what finished, what waits, what failed
249
249
  toolroll notifications mute|unmute --repo <p> no pings for a project; the console and digest keep it
250
+ toolroll notifications screenshots off|first|all a result's saved screenshots with its chat message: none, the first one, or up to 4
250
251
  toolroll retention show|preview how long evidence, checkout records, chat and notifications are kept; what the daily sweep would remove
251
252
  toolroll retention set <kind> <period> evidence|checkouts|chat|notifications, 1d|30d|1y|forever (instance operator)
252
253
  toolroll backup now|list back the database up now; list backups and how the last ones went
@@ -647,7 +648,7 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
647
648
  "json", "yes", "all", "local", "history", "latest-watch", "dry-run", "file", "allow-paid-fallback",
648
649
  "clear", "follow", "ready", "all-tasks", "inbound-only", "help", "undo", "anyone", "allow-dispatch", "allow-merge", "merge-delete-branch",
649
650
  "no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
650
- "self-heal", "plan-auto", "repair-auto", "review-retry-auto", "no-local",
651
+ "self-heal", "plan-auto", "chat-approve", "repair-auto", "review-retry-auto", "no-local",
651
652
  "html", "csv", "alerts-only",
652
653
  // v105: the full export.
653
654
  "zip",
@@ -5716,6 +5717,9 @@ async function modeCommand(positional, flags, context) {
5716
5717
  // explicit flag lets an exhausted subscription switch to another account.
5717
5718
  if (flag(flags, "allow-paid-fallback"))
5718
5719
  terms.allowPaidFallback = true;
5720
+ // Approving plans and merges from the signer's paired chat: never a preset default, only this flag.
5721
+ if (flag(flags, "chat-approve"))
5722
+ terms.chatApprove = true;
5719
5723
  // Automerge requires a live merge-capable grant on the repo (D1).
5720
5724
  if (terms.publication === "automerge" && !store.hasMergeCapableGrant(repo, now)) {
5721
5725
  return fail(write, json, "mode set", "no-grant", "automerge needs a merge-capable publication grant on this repo — file one first", EXIT.refused);
@@ -12238,8 +12242,9 @@ async function budgetCommand(positional, flags, context) {
12238
12242
  const saved = store.setBudget({ scope: target.scope, key: target.key, limitMicrousd: Math.round(dollars) * 1_000_000, hardStop: !flags.has("alerts-only") }, acting.name, context.clock());
12239
12243
  return succeed(context.write, context.json, command, { budget: saved }, () => [`${budgetLabel(saved).replace(/'s$/, "")}: ${spendUsd(saved.limitMicrousd)} a month${saved.hardStop ? ", API work stops at 100%" : ", alerts only"}.`]);
12240
12244
  }
12241
- /** `notifications [quiet | all | digest <HH:MM> | digest off]`: how chats reach this person — only when they are
12242
- * needed (the default), every step, and an optional evening digest. Each person sets their own. */
12245
+ /** `notifications [quiet | all | digest <HH:MM> | digest off | screenshots off|first|all]`: how chats reach this
12246
+ * person — only when they are needed (the default), every step, an optional evening digest, and whether a result's
12247
+ * saved screenshots follow its message. Each person sets their own. */
12243
12248
  async function notificationsCommand(positional, flags, context) {
12244
12249
  const [action, value, ...extra] = positional;
12245
12250
  const command = `notifications${action === undefined ? "" : ` ${action}`}`;
@@ -12248,10 +12253,13 @@ async function notificationsCommand(positional, flags, context) {
12248
12253
  for (const name of flags.keys())
12249
12254
  if (!["as", "token", "token-file", "token-env", "db", "json", ...(muting ? ["repo"] : [])].includes(name))
12250
12255
  return fail(context.write, context.json, command, "usage", `--${name} is not a notifications option.`, EXIT.usage);
12251
- const usage = "Use notifications quiet, notifications all, notifications digest <HH:MM>|off, or notifications mute|unmute --repo <project>.";
12252
- if (extra.length > 0 || (action !== undefined && !["quiet", "all", "digest", "mute", "unmute"].includes(action)) || (action === "digest") !== (value !== undefined) || (muting && (value !== undefined || text(flags, "repo") === undefined))) {
12256
+ const usage = "Use notifications quiet, notifications all, notifications digest <HH:MM>|off, notifications screenshots off|first|all, or notifications mute|unmute --repo <project>.";
12257
+ const valued = action === "digest" || action === "screenshots";
12258
+ if (extra.length > 0 || (action !== undefined && !["quiet", "all", "digest", "screenshots", "mute", "unmute"].includes(action)) || valued !== (value !== undefined) || (muting && (value !== undefined || text(flags, "repo") === undefined))) {
12253
12259
  return fail(context.write, context.json, command, "usage", usage, EXIT.usage);
12254
12260
  }
12261
+ if (action === "screenshots" && !RESULT_SCREENSHOTS.includes(value))
12262
+ return fail(context.write, context.json, command, "usage", "Screenshots with results is off, first (the first one) or all (up to 4).", EXIT.usage);
12255
12263
  if (action === "digest" && value !== "off" && !isDigestTime(value))
12256
12264
  return fail(context.write, context.json, command, "usage", "The digest time is HH:MM on a 24-hour clock, like 18:30, or off.", EXIT.usage);
12257
12265
  const acting = await askCredentials(flags, context);
@@ -12274,11 +12282,13 @@ async function notificationsCommand(positional, flags, context) {
12274
12282
  }
12275
12283
  const preference = action === "quiet" || action === "all" ? store.setNotificationPreference(acting.name, { mode: action }, acting.name, now)
12276
12284
  : action === "digest" ? store.setNotificationPreference(acting.name, { digestAt: value === "off" ? null : value }, acting.name, now)
12277
- : store.notificationPreference(acting.name);
12285
+ : action === "screenshots" ? store.setNotificationPreference(acting.name, { screenshots: value }, acting.name, now)
12286
+ : store.notificationPreference(acting.name);
12278
12287
  const muted = store.mutedProjects(acting.name);
12279
- return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt, mutedProjects: muted }, () => [
12288
+ return succeed(context.write, context.json, command, { mode: preference.mode, digestAt: preference.digestAt, screenshots: preference.screenshots, mutedProjects: muted }, () => [
12280
12289
  preference.mode === "quiet" ? "Only when you're needed: one message per task, updated as it moves, and a new one when something needs you." : "Every step: a message for each update.",
12281
12290
  preference.digestAt === null ? "No evening digest." : `Evening digest at ${preference.digestAt}: what finished, what waits and what failed.`,
12291
+ preference.screenshots === "off" ? "Screenshots with results: off." : `Screenshots with results: ${preference.screenshots === "first" ? "the first one" : "up to 4"}.`,
12282
12292
  ...(muted.length === 0 ? [] : [`Muted: ${muted.map(one => basename(one)).join(", ")}.`]),
12283
12293
  ]);
12284
12294
  }
@@ -228,12 +228,15 @@ function deleteRows(store, repo, d, now) {
228
228
  del("telegram_flow_prompt", IN("card"), C);
229
229
  del("telegram_question_action", IN("question"), questions);
230
230
  del("telegram_question_prompt", IN("question"), questions);
231
+ del("chat_decide_action", IN("task_id"), K);
232
+ del("chat_decide_prompt", IN("task_id"), K);
231
233
  del("telegram_task_message", `${IN("task_id")} OR ${IN("source_run")}`, K, R);
232
234
  del("telegram_conversation_part", `${IN("conversation")} OR ${IN("source_run")} OR ${IN("task_id")}`, conversations, R, K);
233
235
  del("telegram_conversation", IN("id"), conversations);
234
236
  del("telegram_outbound_message", `project = ? OR ${IN("notification")} OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, notifications, T, R);
235
237
  del("chat_card_task", IN("task_ref"), T);
236
238
  del("chat_batch_item", IN("task_ref"), T);
239
+ del("result_shot_sent", IN("run"), R);
237
240
  del("push_delivery", IN("notification"), notifications);
238
241
  del("notification_actor", IN("notification"), notifications);
239
242
  del("notification_delivery", IN("notification"), notifications);
@@ -0,0 +1,53 @@
1
+ import { type Notification, type ResultScreenshots, type Store } from "./store.js";
2
+ import type { ChatContent, ChatState } from "./chat-delivery-state.js";
3
+ /** "Up to 4": the most screenshots one result ever sends. */
4
+ export declare const RESULT_SHOTS_MAX = 4;
5
+ /** The choices in the order Settings and the CLI show them. */
6
+ export declare const RESULT_SHOT_CHOICES: readonly (readonly [ResultScreenshots, string])[];
7
+ export declare function resultShotLimit(choice: ResultScreenshots): number;
8
+ export type ResultShot = {
9
+ artifact: number;
10
+ sha256: string;
11
+ bytes: Buffer;
12
+ format: "png" | "jpeg";
13
+ fileName: string;
14
+ /** Plain words: the first screenshot's carry the task ("Is the site current? · the home page on a phone"). */
15
+ caption: string;
16
+ /** Within Telegram's photo limits, so it shows an inline preview. */
17
+ photo: boolean;
18
+ };
19
+ export type ResultShotsPlan =
20
+ /** Nothing to send and nothing to say: off, pruned by retention, none saved, or all already sent. */
21
+ {
22
+ kind: "none";
23
+ why: "off" | "pruned" | "none" | "sent";
24
+ }
25
+ /** One plain line instead of any screenshot. */
26
+ | {
27
+ kind: "line";
28
+ text: string;
29
+ } | {
30
+ kind: "send";
31
+ taskId: string;
32
+ run: number;
33
+ shots: ResultShot[];
34
+ };
35
+ /**
36
+ * What one person's screenshots row may send now, to a chat that reaches
37
+ * `repos`. `sent`: screenshots this destination already has, never sent again.
38
+ * Every byte returned was verified this instant; a channel uploads them with
39
+ * no wait in between, or asks again.
40
+ */
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;
42
+ /** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
43
+ export declare function shotWaitsUntil(resultNextAt: unknown, now: Date): string;
44
+ /** Retention removed this result's files: its screenshots go quietly, never as a complaint. */
45
+ export declare function resultShotsPruned(evidenceRoot: string, run: number): boolean;
46
+ /**
47
+ * The chat app refused the upload (no file permission there): this part becomes one plain line and the result's
48
+ * other screenshots in the same message are dropped, so the person reads one line, not one per screenshot.
49
+ */
50
+ export declare function refuseResultShots(state: Pick<ChatState, "prepare">, store: Store, part: {
51
+ id: number;
52
+ event: string;
53
+ }, content: ChatContent, app: string, now: Date): void;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Screenshots with results: a person can choose to get a result's saved
3
+ * screenshots in their chat app right after its "ready" (or failed) message —
4
+ * Off (the default), the first one, or up to four.
5
+ *
6
+ * The store records one row per result and person (`RESULT_SHOTS_KIND`) when
7
+ * the result message is made; every transport asks here, at send time, what
8
+ * that row may carry. The checks are the acceptance-evidence sender's: the
9
+ * task is in the projects this chat may reach now, the result is still the
10
+ * task's current one, and each file verifies against its recorded sha256
11
+ * under the evidence root. A failure is one plain line and nothing else; a
12
+ * result whose files retention already removed sends nothing and says
13
+ * nothing. The choice is read again here, so turning it off stops what has
14
+ * not gone yet, and never more than it allows is sent.
15
+ */
16
+ import { existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { imageDimensions, readVerifiedProofForRun, RETENTION_NOTE } from "./evidence.js";
19
+ import { resultImageFileName, verifyResultImage } from "./chat-evidence.js";
20
+ import { publicChatText } from "./chat-display.js";
21
+ import { chatTitle } from "./chat-voice.js";
22
+ import { RESULT_SHOTS_KIND } from "./store.js";
23
+ /** "Up to 4": the most screenshots one result ever sends. */
24
+ export const RESULT_SHOTS_MAX = 4;
25
+ /** The choices in the order Settings and the CLI show them. */
26
+ export const RESULT_SHOT_CHOICES = [["off", "Off"], ["first", "First one"], ["all", "Up to 4"]];
27
+ export function resultShotLimit(choice) {
28
+ return choice === "first" ? 1 : choice === "all" ? RESULT_SHOTS_MAX : 0;
29
+ }
30
+ /** Telegram's own photo limits (Bot API sendPhoto, checked 2026-10-03): over them, a screenshot goes as a document. */
31
+ const PHOTO_MAX_BYTES = 10 * 1024 * 1024;
32
+ const PHOTO_MAX_SIDES = 10_000;
33
+ const PHOTO_MAX_RATIO = 20;
34
+ const SCREENSHOT_CAPTURE = /^agent-claimed screenshot at (.+) \(validated (?:png|jpeg)\)/;
35
+ /** A screenshot's own caption from the result's proof, in plain words; null when it has none fit to show. */
36
+ function ownCaptions(store, root, run) {
37
+ const proof = readVerifiedProofForRun(store, root, run);
38
+ const captions = new Map();
39
+ if (proof === null || !proof.ok)
40
+ return captions;
41
+ for (const shot of proof.proof.screenshots) {
42
+ const words = publicChatText(shot.caption, 200).trim();
43
+ if (words !== "" && !words.includes("[sensitive text hidden]"))
44
+ captions.set(shot.path, words);
45
+ }
46
+ return captions;
47
+ }
48
+ function isPhoto(bytes, format) {
49
+ const size = imageDimensions(bytes, format);
50
+ if (size === null || size.width === 0 || size.height === 0)
51
+ return false;
52
+ return bytes.length <= PHOTO_MAX_BYTES && size.width + size.height <= PHOTO_MAX_SIDES
53
+ && Math.max(size.width, size.height) / Math.min(size.width, size.height) <= PHOTO_MAX_RATIO;
54
+ }
55
+ /**
56
+ * What one person's screenshots row may send now, to a chat that reaches
57
+ * `repos`. `sent`: screenshots this destination already has, never sent again.
58
+ * Every byte returned was verified this instant; a channel uploads them with
59
+ * no wait in between, or asks again.
60
+ */
61
+ export function resultShotsFor(store, evidenceRoot, repos, row, sent = new Set()) {
62
+ if (row.kind !== RESULT_SHOTS_KIND || row.recipient === null || row.taskId === null || row.taskRef === null || row.run === null)
63
+ return { kind: "none", why: "none" };
64
+ const limit = resultShotLimit(store.notificationPreference(row.recipient).screenshots);
65
+ if (limit === 0)
66
+ return { kind: "none", why: "off" };
67
+ const title = chatTitle(store, row.taskId);
68
+ const line = (why) => ({ kind: "line", text: `Screenshots for ${title} weren't sent: ${why}.` });
69
+ if (evidenceRoot === undefined)
70
+ return line("saved files can't be read from here");
71
+ // Retention removed this result's files: nothing to show, and nothing worth saying.
72
+ if (existsSync(join(evidenceRoot, String(row.run), RETENTION_NOTE)))
73
+ return { kind: "none", why: "pruned" };
74
+ const ref = store.lookupRef(row.taskId);
75
+ if (ref?.repo == null || !repos.includes(ref.repo))
76
+ return line("the result is outside your connected projects now");
77
+ const family = store.taskFamilyOf(row.taskId, repos, false);
78
+ const latest = store.runsFor(row.taskRef).find(one => one.finishedAt !== null && ["builder", "repair", "scout"].includes(one.role));
79
+ if (family?.current.id !== row.taskId || latest?.id !== row.run)
80
+ return line("a newer result replaced this one");
81
+ const chosen = store.artifactsFor(row.run).filter(one => one.kind === "screenshot").slice(0, limit);
82
+ if (chosen.length === 0)
83
+ return { kind: "none", why: "none" };
84
+ const captions = ownCaptions(store, evidenceRoot, row.run);
85
+ const shots = [];
86
+ for (const [index, artifact] of chosen.entries()) {
87
+ const verified = verifyResultImage(store, evidenceRoot, repos, { taskId: row.taskId, run: row.run, artifact: artifact.id, sha256: artifact.sha256 });
88
+ if (!verified.ok) {
89
+ // Removed by retention between the check above and now: still nothing to say.
90
+ if (existsSync(join(evidenceRoot, String(row.run), RETENTION_NOTE)))
91
+ return { kind: "none", why: "pruned" };
92
+ return line(verified.problem);
93
+ }
94
+ const own = captions.get(SCREENSHOT_CAPTURE.exec(artifact.capture)?.[1] ?? "") ?? null;
95
+ shots.push({
96
+ artifact: artifact.id, sha256: artifact.sha256, bytes: verified.bytes, format: verified.format,
97
+ fileName: resultImageFileName(row.taskId, row.run, artifact.id, verified.format),
98
+ caption: index === 0 ? (own === null ? title : `${title} · ${own}`) : own ?? `${title} · screenshot ${index + 1}`,
99
+ photo: isPhoto(verified.bytes, verified.format),
100
+ });
101
+ }
102
+ const remaining = shots.filter(one => !sent.has(one.artifact));
103
+ return remaining.length === 0 ? { kind: "none", why: "sent" } : { kind: "send", taskId: row.taskId, run: row.run, shots: remaining };
104
+ }
105
+ /** A screenshot whose result message is not posted yet waits for that message's own next try (at least a second). */
106
+ export function shotWaitsUntil(resultNextAt, now) {
107
+ const soonest = new Date(now.getTime() + 1_000).toISOString();
108
+ return typeof resultNextAt === "string" && resultNextAt > soonest ? resultNextAt : soonest;
109
+ }
110
+ /** Retention removed this result's files: its screenshots go quietly, never as a complaint. */
111
+ export function resultShotsPruned(evidenceRoot, run) {
112
+ return existsSync(join(evidenceRoot, String(run), RETENTION_NOTE));
113
+ }
114
+ /**
115
+ * The chat app refused the upload (no file permission there): this part becomes one plain line and the result's
116
+ * other screenshots in the same message are dropped, so the person reads one line, not one per screenshot.
117
+ */
118
+ export function refuseResultShots(state, store, part, content, app, now) {
119
+ const title = chatTitle(store, content.image?.taskId ?? content.task ?? "");
120
+ const line = { text: `Screenshots for ${title} weren't sent: the ${app} app isn't allowed to upload files here.`,
121
+ ...(content.task === undefined ? {} : { task: content.task }), ...(content.run === undefined ? {} : { run: content.run }) };
122
+ state.prepare("UPDATE chat_part SET payload=?,state='pending',next_at=NULL,problem=NULL,file=NULL,uploaded=0,uncertain=0,created=? WHERE id=?").run(JSON.stringify(line), now.toISOString(), part.id);
123
+ state.prepare("UPDATE chat_part SET state='dropped',problem='Uploads are not allowed here' WHERE event=? AND id<>? AND state='pending'").run(part.event, part.id);
124
+ }
package/dist/serve.js CHANGED
@@ -93,6 +93,7 @@ import { DISCLOSURE_CSS } from "./disclosure.js";
93
93
  import { styleAsset } from "./style-asset.js";
94
94
  import { MOBILE_VIEWPORT_SCRIPT } from "./mobile-viewport.js";
95
95
  import { authorizePlanUnderMode, applyModeToNewFiling, planAutoPending } from "./plan-auto.js";
96
+ import { permissionPlainWords } from "./chat-decide.js";
96
97
  import { LEDGER_CSV_HEADER, ledgerCsvRows } from "./ledger-csv.js";
97
98
  import { Readable } from "node:stream";
98
99
  import { pipeline } from "node:stream/promises";
@@ -188,7 +189,8 @@ import { dirname } from "node:path";
188
189
  import { AsyncLocalStorage } from "node:async_hooks";
189
190
  import { loadOrCreateVapidKeys, validatePushEndpoint } from "./push.js";
190
191
  import { parseGithubRepo, previewGithubRepo, cloneGithubRepo, listGithubRepos, isLargeRepo } from "./onboard.js";
191
- import { verifiedAuthor, LEAD_THREAD, isDigestTime, MATE_ASK_OTHER } from "./store.js";
192
+ import { verifiedAuthor, LEAD_THREAD, isDigestTime, RESULT_SCREENSHOTS, MATE_ASK_OTHER } from "./store.js";
193
+ import { RESULT_SHOT_CHOICES } from "./result-shots.js";
192
194
  import { digestTimes } from "./digest-times.js";
193
195
  import { updateRepos, addRepos, removeRepos } from "./repos.js";
194
196
  import { run as execRun } from "./exec.js";
@@ -2648,6 +2650,10 @@ export function createDecisionServer(options) {
2648
2650
  `<option value="0">wait for my approval</option>` +
2649
2651
  `<option value="1">auto-approve plans that preserve my filed contract</option>` +
2650
2652
  `</select><span class="meta">Requires automatic filing approval. File the goal, paths, and acceptance criteria upfront. Changed scope and unanswered questions still pause.</span></label>`,
2653
+ `<label>From my chat app<select name="chat-approve">` +
2654
+ `<option value="">approve plans and merges here, with my password (the default)</option>` +
2655
+ `<option value="1">approve plans and merge ready pull requests from my paired chat, two taps each</option>` +
2656
+ `</select><span class="meta">Plans that widen permissions, exceed the attempt cap or touch protected paths still open Toolroll.</span></label>`,
2651
2657
  `<label>If a subscription runs out<select name="allow-paid-fallback">` +
2652
2658
  `<option value="">never switch to a paid API key on its own (the default, every preset)</option>` +
2653
2659
  `<option value="1">allow the approved fallback — spend moves to that account</option>` +
@@ -4231,7 +4237,7 @@ export function createDecisionServer(options) {
4231
4237
  return at === null || since === null ? null : firstResultWords(since, at);
4232
4238
  })(), (() => {
4233
4239
  const chosen = store.notificationPreference(who.name);
4234
- return { mode: chosen.mode, digestAt: chosen.digestAt, projects: notificationProjects(store, who.name) };
4240
+ return { mode: chosen.mode, digestAt: chosen.digestAt, screenshots: chosen.screenshots, projects: notificationProjects(store, who.name) };
4235
4241
  })()));
4236
4242
  }
4237
4243
  if (url.pathname === "/push/key") {
@@ -8236,16 +8242,21 @@ export function createDecisionServer(options) {
8236
8242
  }
8237
8243
  if (url.pathname === "/settings/notifications") {
8238
8244
  // Each person's own choice: a closed list of modes and a 24-hour HH:MM, or off.
8239
- const mode = body.get("mode"), time = (body.get("digest") ?? "").trim();
8245
+ const mode = body.get("mode"), time = (body.get("digest") ?? "").trim(), shots = body.get("screenshots");
8240
8246
  if (mode !== null && mode !== "quiet" && mode !== "all")
8241
8247
  return refuse(response, who, 400, "choose Only when I'm needed or Every step", "/settings");
8242
8248
  if (time !== "" && time !== "off" && !isDigestTime(time))
8243
8249
  return refuse(response, who, 400, "the evening digest time is HH:MM, or off", "/settings");
8250
+ if (shots !== null && !RESULT_SCREENSHOTS.includes(shots))
8251
+ return refuse(response, who, 400, "choose Off, First one or Up to 4", "/settings");
8244
8252
  const before = store.notificationPreference(who.name);
8245
- const after = store.setNotificationPreference(who.name, { ...(mode === null ? {} : { mode }), ...(time === "" ? {} : { digestAt: time === "off" ? null : time }) }, who.name, now);
8253
+ const after = store.setNotificationPreference(who.name, { ...(mode === null ? {} : { mode }), ...(time === "" ? {} : { digestAt: time === "off" ? null : time }),
8254
+ ...(shots === null ? {} : { screenshots: shots }) }, who.name, now);
8246
8255
  const said = after.mode !== before.mode
8247
8256
  ? after.mode === "quiet" ? "Chats now message you only when you're needed." : "Chats now message you at every step."
8248
- : after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
8257
+ : after.screenshots !== before.screenshots
8258
+ ? after.screenshots === "off" ? "Results arrive without screenshots." : `Results arrive with ${after.screenshots === "first" ? "their first screenshot" : "up to 4 screenshots"}.`
8259
+ : after.digestAt === null ? "Evening digest off." : `Evening digest at ${after.digestAt}.`;
8249
8260
  return redirect(response, `/settings?said=${encodeURIComponent(said)}`);
8250
8261
  }
8251
8262
  if (url.pathname === "/settings/notifications/mute") {
@@ -8712,6 +8723,8 @@ export function createDecisionServer(options) {
8712
8723
  // attempt cap it carries is meaningless without it.
8713
8724
  repairAuto: false,
8714
8725
  repairMaxAttempts: 0,
8726
+ // Approving from the paired chat: only the explicit choice grants it.
8727
+ chatApprove: body.get("chat-approve") === "1",
8715
8728
  publication: body.get("publication") === "automerge" ? "automerge" : "notify",
8716
8729
  };
8717
8730
  if (["review-auto", "review-retry-auto", "repair-auto"].some(field => body.get(field) === "1")) {
@@ -8733,7 +8746,7 @@ export function createDecisionServer(options) {
8733
8746
  `<input type="hidden" name="csrf" value="${escape(who.session.csrf)}">` +
8734
8747
  `<input type="hidden" name="nonce" value="${escape(nonce)}">` +
8735
8748
  `<input type="hidden" name="digest" value="${escape(digest)}">` +
8736
- ["name", "days", "publication", "auto-approve", "plan-auto", "allow-paid-fallback"]
8749
+ ["name", "days", "publication", "auto-approve", "plan-auto", "chat-approve", "allow-paid-fallback"]
8737
8750
  .map(field => `<input type="hidden" name="${field}" value="${escape(body.get(field) ?? "")}">`)
8738
8751
  .join("") +
8739
8752
  `<input type="hidden" name="expiry" value="${escape(expiry)}">` +
@@ -12543,7 +12556,7 @@ function approvalSheetHtml(input) {
12543
12556
  // What the yes allows, in plain words, right above it: the agent's
12544
12557
  // permissions, the per-attempt limit, and an earlier version still running.
12545
12558
  const allowing = [];
12546
- const allowed = permissionPlainWordsOf(scope.profile);
12559
+ const allowed = permissionPlainWords(scope.profile);
12547
12560
  if (allowed !== null)
12548
12561
  allowing.push(allowed);
12549
12562
  if (raceTerms === null)
@@ -12694,20 +12707,6 @@ class PermissionsWouldChange extends Error {
12694
12707
  }
12695
12708
  /** Evidence kinds in plain words, for the signed criteria in Details. */
12696
12709
  const EVIDENCE_PLAIN = { check: "the project check", screenshot: "screenshots", "changed-path": "changed files", "manual-review": "your own check" };
12697
- /** What an agent may do on its own, in a few plain words. */
12698
- function permissionPlainWordsOf(profile) {
12699
- if (profile === null || profile === undefined)
12700
- return null;
12701
- const full = "full access, nothing asks first";
12702
- if (profile.provider === "claude") {
12703
- return profile.permissionArgv === "bypassPermissions" ? full
12704
- : profile.permissionArgv === "auto" ? "file edits and routine commands; anything risky stops"
12705
- : "file edits only; commands are refused";
12706
- }
12707
- if (profile.provider === "gemini")
12708
- return profile.approvalArgv === "yolo" ? full : "file edits only; other tools are refused";
12709
- return profile.sandboxMode === "danger-full-access" ? full : "file edits and commands inside the project only";
12710
- }
12711
12710
  /** The permission choice a sealed profile carries, as the scope form names it. */
12712
12711
  function permissionModeOfProfile(profile) {
12713
12712
  if (profile === null || profile === undefined)
@@ -23941,6 +23940,9 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
23941
23940
  digestTimes(chatNotices.digestAt).map(([value, label]) => `<option value="${value}"${value === (chatNotices.digestAt ?? "off") ? " selected" : ""}>${label}</option>`).join("") +
23942
23941
  `</select></label>`,
23943
23942
  `<p class="meta">One message: what finished, what waits, what failed.</p>`,
23943
+ `<label>Screenshots with results<select name="screenshots">` +
23944
+ RESULT_SHOT_CHOICES.map(([value, label]) => `<option value="${value}"${value === (chatNotices.screenshots ?? "off") ? " selected" : ""}>${label}</option>`).join("") +
23945
+ `</select></label>`,
23944
23946
  `<button type="submit">Save</button>`,
23945
23947
  `</form>`,
23946
23948
  ...((chatNotices.projects ?? []).length === 0 ? [] : [