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.
- package/CHANGELOG.md +19 -0
- package/dist/browser/workspace.js +14 -14
- package/dist/browser-workspace.d.ts +2 -0
- package/dist/chat-decide.d.ts +203 -0
- package/dist/chat-decide.js +554 -0
- package/dist/chat-delivery-state.d.ts +5 -0
- package/dist/chat-delivery.js +40 -1
- package/dist/chat-quiet.d.ts +4 -1
- package/dist/chat-quiet.js +6 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord-api.d.ts +2 -0
- package/dist/discord-api.js +3 -1
- package/dist/discord-chat.js +22 -3
- package/dist/modes.d.ts +6 -0
- package/dist/modes.js +6 -0
- package/dist/operate.js +18 -8
- package/dist/project-delete.js +3 -0
- package/dist/result-shots.d.ts +53 -0
- package/dist/result-shots.js +124 -0
- package/dist/serve.js +23 -21
- package/dist/slack-chat.js +23 -4
- package/dist/store.d.ts +24 -0
- package/dist/store.js +119 -7
- package/dist/teams-chat.js +5 -0
- package/dist/telegram-mate.d.ts +10 -0
- package/dist/telegram.d.ts +5 -3
- package/dist/telegram.js +240 -22
- package/package.json +1 -1
package/dist/chat-quiet.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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/;
|
package/dist/desktop-update.d.ts
CHANGED
|
@@ -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
|
} | {
|
package/dist/discord-api.d.ts
CHANGED
|
@@ -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;
|
package/dist/discord-api.js
CHANGED
|
@@ -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
|
-
?
|
|
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
|
package/dist/discord-chat.js
CHANGED
|
@@ -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(
|
|
375
|
+
if (discordId(thread))
|
|
363
376
|
args.message_reference = {
|
|
364
|
-
message_id:
|
|
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
|
|
12242
|
-
* needed (the default), every step,
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/dist/project-delete.js
CHANGED
|
@@ -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 })
|
|
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.
|
|
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 =
|
|
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 ? [] : [
|