toolroll 0.6.0 → 0.7.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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Knowing, calmly, when a newer Toolroll exists.
3
+ *
4
+ * At most once a day one anonymous GET asks the npm registry for the latest
5
+ * version, and one more reads that version's GitHub release notes (the body
6
+ * only). Nothing identifies this installation: no telemetry, no id, no
7
+ * query string. The answer is cached in a small JSON file beside the
8
+ * database; offline or any other failure is silent and changes nothing.
9
+ * TOOLROLL_NO_UPDATE_CHECK=1 or the switch on Settings → Updates turns the
10
+ * check off entirely.
11
+ */
12
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { envValue } from "./names.js";
15
+ export const RELEASE_CACHE_FILE = "latest-release.json";
16
+ /** Holds "off" while a person has switched the daily check off on Settings → Updates. */
17
+ export const UPDATE_SWITCH_FILE = "update-check";
18
+ export const RUNNER_VERSIONS_FILE = "runner-versions.json";
19
+ export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
20
+ export const CHECK_TIMEOUT_MS = 5_000;
21
+ export const REGISTRY_URL = "https://registry.npmjs.org/toolroll/latest";
22
+ export const RELEASES_PAGE = "https://github.com/ap9000/toolroll/releases/tag/";
23
+ const NOTES_API = "https://api.github.com/repos/ap9000/toolroll/releases/tags/";
24
+ const NOTES_MAX = 20_000;
25
+ const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,40})?$/;
26
+ /** Off by the environment (it wins) or by the Settings switch. */
27
+ export function updateChecksOff(env, dir) {
28
+ const flag = envValue(env, "NO_UPDATE_CHECK");
29
+ if (flag !== undefined && flag !== "" && flag !== "0")
30
+ return { off: true, byEnv: true };
31
+ try {
32
+ return { off: readFileSync(join(dir, UPDATE_SWITCH_FILE), "utf8").trim() === "off", byEnv: false };
33
+ }
34
+ catch {
35
+ return { off: false, byEnv: false };
36
+ }
37
+ }
38
+ export function setUpdateChecks(dir, on) {
39
+ writeAtomic(join(dir, UPDATE_SWITCH_FILE), on ? "on\n" : "off\n");
40
+ }
41
+ /** What the last check found, whenever it ran; null when none has succeeded. Never touches the network. */
42
+ export function cachedRelease(dir) {
43
+ return readCache(dir)?.release ?? null;
44
+ }
45
+ /**
46
+ * The latest published release, checked at most once a day. Returns what the
47
+ * cache knows while it is fresh, asks again once it is a day old, and on any
48
+ * failure keeps what it knew before (null when it knew nothing). Never throws.
49
+ */
50
+ export async function latestRelease(dir, io = {}) {
51
+ const env = io.env ?? process.env;
52
+ if (updateChecksOff(env, dir).off)
53
+ return null;
54
+ const now = (io.now ?? (() => new Date()))();
55
+ const cache = readCache(dir);
56
+ if (cache !== null && now.getTime() - Date.parse(cache.checkedAt) < CHECK_EVERY_MS && Date.parse(cache.checkedAt) <= now.getTime())
57
+ return cache.release;
58
+ const request = io.fetch ?? fetch;
59
+ let release = cache?.release ?? null;
60
+ try {
61
+ const version = await askVersion(request);
62
+ if (version !== null) {
63
+ const notes = version === cache?.release?.version && cache.release.notes !== "" ? cache.release.notes : await askNotes(request, version);
64
+ release = { version, notes, url: `${RELEASES_PAGE}v${version}`, security: isSecurityRelease(notes) };
65
+ }
66
+ }
67
+ catch {
68
+ // Offline, slow or refused: nothing to say.
69
+ }
70
+ // The attempt is stamped either way, so a failure does not ask again until tomorrow.
71
+ try {
72
+ writeAtomic(join(dir, RELEASE_CACHE_FILE), `${JSON.stringify({ checkedAt: now.toISOString(), release })}\n`);
73
+ }
74
+ catch {
75
+ // A read-only folder only means asking again next time.
76
+ }
77
+ return release;
78
+ }
79
+ /** Notes that mention "Security" mark a security release. */
80
+ /** A release marks itself as a security release with a line or heading that starts with "Security" (for example
81
+ * "## Security" or "- **Security fix.**"), not by mentioning the word ("no security changes"). */
82
+ export function isSecurityRelease(notes) {
83
+ return /^\s*(?:#{1,6}\s*|[-*]\s*(?:\*\*)?)?security\b/im.test(notes);
84
+ }
85
+ /** a > b for dotted versions; a pre-release sorts before its release. */
86
+ export function isNewer(a, b) {
87
+ const parse = (value) => {
88
+ const [core = "", pre] = value.replace(/^v/, "").split("-", 2);
89
+ return { parts: core.split(".").map(part => Number.parseInt(part, 10) || 0), pre: pre ?? null };
90
+ };
91
+ const left = parse(a), right = parse(b);
92
+ for (let index = 0; index < 3; index++) {
93
+ const diff = (left.parts[index] ?? 0) - (right.parts[index] ?? 0);
94
+ if (diff !== 0)
95
+ return diff > 0;
96
+ }
97
+ if (left.pre === right.pre)
98
+ return false;
99
+ if (left.pre === null)
100
+ return true;
101
+ if (right.pre === null)
102
+ return false;
103
+ return left.pre.localeCompare(right.pre, "en", { numeric: true }) > 0;
104
+ }
105
+ /** The release worth mentioning: newer than `current`, else null. */
106
+ export function newerRelease(release, current) {
107
+ return release !== null && isNewer(release.version, current) ? release : null;
108
+ }
109
+ /** The one status line, only when a newer release exists. */
110
+ export function updateLine(release, current, updateCommand) {
111
+ const newer = newerRelease(release, current);
112
+ return newer === null ? null : `${newer.version} is available — ${updateCommand} · ${newer.url}`;
113
+ }
114
+ /**
115
+ * A security release tells each instance operator once per version, through
116
+ * the outbox and addressed to them (like a sign-in pause): only someone at
117
+ * this computer can update it. Returns how many messages were queued.
118
+ */
119
+ export function notifySecurityRelease(store, release, current, updateCommand, now) {
120
+ const newer = newerRelease(release, current);
121
+ if (newer === null || !newer.security)
122
+ return 0;
123
+ const operators = store.accountFacts().map(one => one.name).filter(name => store.isInstanceOperator(name));
124
+ let queued = 0;
125
+ for (const recipient of operators) {
126
+ const added = store.enqueueNotification({
127
+ dedupeKey: `release:security:${newer.version}:${recipient}`,
128
+ kind: "security-release",
129
+ pushClass: "attention",
130
+ link: "/settings#updates",
131
+ subject: `Toolroll ${newer.version} is a security release`,
132
+ body: `This computer runs ${current}. Update with \`${updateCommand}\`. Notes: ${newer.url}`,
133
+ recipient,
134
+ source: { installation: true },
135
+ }, now);
136
+ if (added)
137
+ queued++;
138
+ }
139
+ return queued;
140
+ }
141
+ export function recordRunnerVersion(dir, runner, version, now) {
142
+ const known = runnerVersions(dir).filter(one => one.runner !== runner);
143
+ known.push({ runner, version, at: now.toISOString() });
144
+ try {
145
+ writeAtomic(join(dir, RUNNER_VERSIONS_FILE), `${JSON.stringify(known.sort((a, b) => a.runner.localeCompare(b.runner)))}\n`);
146
+ }
147
+ catch {
148
+ // The version is a display fact; a failed write only leaves it unknown.
149
+ }
150
+ }
151
+ export function runnerVersions(dir) {
152
+ try {
153
+ const parsed = JSON.parse(readFileSync(join(dir, RUNNER_VERSIONS_FILE), "utf8"));
154
+ if (!Array.isArray(parsed))
155
+ return [];
156
+ return parsed.filter((one) => typeof one === "object" && one !== null
157
+ && typeof one.runner === "string" && typeof one.version === "string" && VERSION.test(one.version) && typeof one.at === "string");
158
+ }
159
+ catch {
160
+ return [];
161
+ }
162
+ }
163
+ /**
164
+ * The console's background check: at start and then hourly (the daily limit
165
+ * is latestRelease's own), and a security release notifies operators.
166
+ */
167
+ export function startUpdateChecks(store, dir, current, updateCommand, io = {}) {
168
+ let stopped = false;
169
+ const pass = async () => {
170
+ const release = await latestRelease(dir, io);
171
+ if (!stopped) {
172
+ try {
173
+ notifySecurityRelease(store, release, current, updateCommand, (io.now ?? (() => new Date()))());
174
+ }
175
+ catch {
176
+ // A closed store at shutdown: nothing to tell.
177
+ }
178
+ }
179
+ };
180
+ void pass();
181
+ const timer = setInterval(() => void pass(), io.everyMs ?? 60 * 60_000);
182
+ timer.unref?.();
183
+ return () => { stopped = true; clearInterval(timer); };
184
+ }
185
+ async function askVersion(request) {
186
+ const answer = await request(REGISTRY_URL, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), credentials: "omit", referrerPolicy: "no-referrer" });
187
+ if (!answer.ok)
188
+ return null;
189
+ const version = (await answer.json()).version;
190
+ return typeof version === "string" && VERSION.test(version) ? version : null;
191
+ }
192
+ async function askNotes(request, version) {
193
+ try {
194
+ const answer = await request(`${NOTES_API}v${version}`, { headers: { accept: "application/vnd.github+json" }, signal: AbortSignal.timeout(CHECK_TIMEOUT_MS), credentials: "omit", referrerPolicy: "no-referrer" });
195
+ if (!answer.ok)
196
+ return "";
197
+ const body = (await answer.json()).body;
198
+ return typeof body === "string" ? body.replace(/\r\n/g, "\n").slice(0, NOTES_MAX) : "";
199
+ }
200
+ catch {
201
+ return "";
202
+ }
203
+ }
204
+ function readCache(dir) {
205
+ const file = join(dir, RELEASE_CACHE_FILE);
206
+ if (!existsSync(file))
207
+ return null;
208
+ try {
209
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
210
+ if (typeof parsed.checkedAt !== "string" || Number.isNaN(Date.parse(parsed.checkedAt)))
211
+ return null;
212
+ const release = parsed.release;
213
+ const valid = release !== null && typeof release === "object" && release !== undefined
214
+ && typeof release.version === "string" && VERSION.test(release.version) && typeof release.notes === "string";
215
+ return { checkedAt: parsed.checkedAt, release: valid ? { version: release.version, notes: release.notes, url: `${RELEASES_PAGE}v${release.version}`, security: isSecurityRelease(release.notes) } : null };
216
+ }
217
+ catch {
218
+ return null;
219
+ }
220
+ }
221
+ function writeAtomic(file, content) {
222
+ const temporary = `${file}.${process.pid}.tmp`;
223
+ writeFileSync(temporary, content, { mode: 0o600 });
224
+ renameSync(temporary, file);
225
+ }
package/dist/serve.d.ts CHANGED
@@ -14,6 +14,8 @@ import { type Phase } from "./provider.js";
14
14
  import type { RevisionLineage } from "./store.js";
15
15
  import type { SubscriptionMateRunner } from "./subscription-chat.js";
16
16
  export type ServeOptions = {
17
+ /** Tests: the bin whose real path says how Toolroll was installed (Settings → Updates' command). */
18
+ installBin?: string;
17
19
  /** Native coding workspace injection for isolated integration tests. */
18
20
  codingWorkspace?: CodingWorkspace;
19
21
  store: Store;
package/dist/serve.js CHANGED
@@ -231,6 +231,10 @@ import { verifyApproverByPassword, verifyApproverStanding } from "./principal.js
231
231
  import { runMateTurn, MATE_MESSAGE_MAX_CHARS } from "./mate.js";
232
232
  import { confirmCoordinatorProposal, confirmMateProposal, dismissCoordinatorProposal, dismissMateProposal } from "./mate-doors.js";
233
233
  import { envValue } from "./names.js";
234
+ import { cachedRelease, isNewer, runnerVersions, setUpdateChecks, updateChecksOff } from "./releases.js";
235
+ import { installMethod } from "./install-method.js";
236
+ import { PACKAGE_VERSION } from "./version.js";
237
+ import { updateNoticeWords } from "./update-notice.js";
234
238
  const SESSION_COOKIE = "standing-orders_session";
235
239
  const BODY_CAP = 16 * 1024;
236
240
  // URL encoding can triple UTF-8 bytes. Admit the existing bounded task
@@ -386,6 +390,32 @@ export function createDecisionServer(options) {
386
390
  }
387
391
  }
388
392
  const clock = options.clock ?? (() => new Date());
393
+ const install = installMethod(options.installBin);
394
+ /** What the last daily check found (the cache only, never the network), or nothing while checks are off. */
395
+ const updateFacts = () => {
396
+ if (options.configDir === undefined)
397
+ return null;
398
+ const switched = updateChecksOff(process.env, options.configDir);
399
+ const release = switched.off ? null : cachedRelease(options.configDir);
400
+ return { release, newer: release !== null && isNewer(release.version, PACKAGE_VERSION), on: !switched.off, byEnv: switched.byEnv };
401
+ };
402
+ /** Settings → Updates, for everyone who can open Settings; only an operator may flip the switch. */
403
+ const settingsUpdates = (actor, csrf) => {
404
+ const facts = updateFacts();
405
+ if (facts === null)
406
+ return null;
407
+ const known = new Map(runnerVersions(options.configDir).map(one => [one.runner, one.version]));
408
+ return {
409
+ current: PACKAGE_VERSION,
410
+ latest: facts.release === null ? null : { version: facts.release.version, newer: facts.newer, security: facts.release.security, notes: facts.release.notes, url: facts.release.url },
411
+ updateCommand: install.updateCommand,
412
+ check: { on: facts.on, byEnv: facts.byEnv, canManage: csrf !== "" && store.isInstanceOperator(actor) },
413
+ workers: store.listRunners().filter(one => one.retiredAt === null).map(one => {
414
+ const version = known.get(one.name) ?? null;
415
+ return { name: one.name, version, older: version !== null && isNewer(PACKAGE_VERSION, version) };
416
+ }),
417
+ };
418
+ };
389
419
  const workspaceRevision = prepareWorkspaceRevision(store);
390
420
  const workspaceIncarnation = randomBytes(16).toString('hex');
391
421
  const workspaceValidators = new WorkspaceValidatorCache();
@@ -1349,6 +1379,7 @@ export function createDecisionServer(options) {
1349
1379
  const requestFacts = {
1350
1380
  theme: pinnedTheme(request.headers.cookie),
1351
1381
  accent: pinnedAccent(request.headers.cookie),
1382
+ updateSeen: /(?:^|;\s*)so-update-seen=([0-9A-Za-z.-]{1,60})(?:;|$)/.exec(request.headers.cookie ?? "")?.[1] ?? null,
1352
1383
  actor: who.name,
1353
1384
  csrf: who.via === "cookie" ? who.session.csrf : "",
1354
1385
  returnTo: safeReturn(url.pathname + url.search),
@@ -3763,7 +3794,7 @@ export function createDecisionServer(options) {
3763
3794
  const googleView = { connected: googleConnected(options.configDir ?? null)?.address ?? null, clientId: google?.clientId ?? "", redirect: origin === null ? null : `${origin}${GOOGLE_CALLBACK}` };
3764
3795
  return email === null ? { set: false, host: "", port: 587, secure: false, user: "", from: "", imapHost: "", imapPort: 993, google: googleView }
3765
3796
  : { set: true, host: email.host, port: email.port, secure: email.secure, user: email.user, from: email.from, imapHost: email.imap?.host ?? "", imapPort: email.imap?.port ?? 993, google: googleView };
3766
- })() : null, telegramDeliveryWords(store, loadBotToken(process.env, options.telegramTokenFile)), settingsWorkers(store, now)));
3797
+ })() : null, telegramDeliveryWords(store, loadBotToken(process.env, options.telegramTokenFile)), settingsWorkers(store, now), settingsUpdates(who.name, csrf)));
3767
3798
  }
3768
3799
  if (url.pathname === "/push/key") {
3769
3800
  // Session-gated: the key is not secret, but strangers get nothing.
@@ -4441,6 +4472,7 @@ export function createDecisionServer(options) {
4441
4472
  chats,
4442
4473
  ...(s.refreshSeconds === undefined ? {} : { refreshSeconds: Math.max(5, Math.floor(s.refreshSeconds)) }),
4443
4474
  ...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
4475
+ ...(s.chrome.update === undefined ? {} : { update: s.chrome.update }),
4444
4476
  };
4445
4477
  if (requestFacts.workspaceRead) {
4446
4478
  const validator = requestFacts.workspaceValidator;
@@ -4557,6 +4589,15 @@ export function createDecisionServer(options) {
4557
4589
  settings: true,
4558
4590
  ...(store.isDemo() ? { demo: true } : {}),
4559
4591
  ...(() => { const signIn = signInNotices(store); return signIn.length === 0 ? {} : { signIn }; })(),
4592
+ ...(() => {
4593
+ // Operators only (only someone at this computer can update it), and gone once this version is dismissed.
4594
+ if (!facts?.csrf || !actor || !store.isInstanceOperator(actor))
4595
+ return {};
4596
+ const update = updateFacts();
4597
+ if (update === null || !update.newer || update.release === null || facts.updateSeen === update.release.version)
4598
+ return {};
4599
+ return { update: { version: update.release.version, security: update.release.security, href: "/settings#updates", dismissHref: "/settings/updates/dismiss" } };
4600
+ })(),
4560
4601
  ...(liveMode === null || liveModeTerms === null
4561
4602
  ? {}
4562
4603
  : {
@@ -5754,6 +5795,29 @@ export function createDecisionServer(options) {
5754
5795
  liftAuthPause(store, provider, "person", who.name, clock());
5755
5796
  return redirect(response, "/work");
5756
5797
  }
5798
+ // The update notice is dismissed per browser and per version: a newer release shows it again.
5799
+ if (url.pathname === "/settings/updates/dismiss") {
5800
+ const version = body.get("version") ?? "";
5801
+ if (!/^[0-9A-Za-z.-]{1,60}$/.test(version))
5802
+ return refuse(response, who, 400, "Choose a version to dismiss.", "/settings#updates");
5803
+ response.setHeader("Set-Cookie", `so-update-seen=${version}; SameSite=Lax; Path=/; Max-Age=31536000`);
5804
+ if (body.get("quiet") === "1") {
5805
+ response.writeHead(204);
5806
+ response.end();
5807
+ return;
5808
+ }
5809
+ return redirect(response, safeReturn(body.get("return") ?? "/settings#updates"));
5810
+ }
5811
+ // The daily check's switch: an installation setting, so an operator's.
5812
+ if (url.pathname === "/settings/updates") {
5813
+ if (options.configDir === undefined)
5814
+ return refuse(response, who, 404, "Updates are not set up on this console.", "/settings");
5815
+ if (!store.isInstanceOperator(who.name))
5816
+ return refuse(response, who, 403, "Only an operator can change update checks.", "/settings#updates");
5817
+ const on = body.get("check") === "on";
5818
+ setUpdateChecks(options.configDir, on);
5819
+ return redirect(response, `/settings?said=${encodeURIComponent(on ? "Toolroll looks for a newer version once a day." : "Update checks are off.")}#updates`);
5820
+ }
5757
5821
  if (url.pathname === '/code' || url.pathname.startsWith('/code/')) {
5758
5822
  const wantsJson = request.headers.accept?.includes('application/json') === true;
5759
5823
  const fail = (status, message, delivery = 'rejected', sessionId) => wantsJson
@@ -13917,7 +13981,7 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
13917
13981
  }
13918
13982
  `;
13919
13983
  /** Appearance: a three-way segmented switch, one tap per choice. */
13920
- const THEME_CONTROLS_CSS = `.task-repo select{width:100%;min-height:2.75rem;font-size:1rem}.task-repo-add{margin:.35rem .1rem .5rem}.task-repo-add a{display:inline-flex;align-items:center;min-height:2.25rem}details.result-request-open.result-request-form>summary{border:0;background:transparent;padding:.5rem 0;min-height:2.75rem;font-weight:600;display:list-item;list-style:revert}details.result-request-open.result-request-form>summary::-webkit-details-marker{display:revert}form.js-autosave button[type=submit]{display:none}.provider-row{border-bottom:1px solid var(--so-line);padding:.35rem 0}.provider-row:first-of-type{border-top:1px solid var(--so-line)}.provider-head{display:flex;align-items:center;gap:.75rem;margin:.4rem 0 0}.provider-status{display:inline-flex;align-items:center;gap:.4rem;color:var(--so-muted);font-size:.875rem}.provider-status i{width:.5rem;height:.5rem;border-radius:50%;background:var(--so-muted)}.provider-status--ok i{background:var(--so-success)}.provider-status--warn i{background:var(--so-attention)}.provider-status--off i{background:transparent;border:1.5px solid var(--so-muted)}details.provider-manage>summary{cursor:pointer;color:var(--so-accent-text);font-size:.875rem;min-height:2.5rem;display:list-item;padding-block:.5rem}.card.props .row{display:grid;gap:.1rem;margin:0 0 .75rem}.card.props .row>.meta{display:block;font-size:.75rem}.card.props .row>.meta::first-letter{text-transform:uppercase}.card.props .row>.mono{font-family:var(--font-sans);font-size:.875rem}.card.props .row>.mono .seal{font-family:var(--font-mono);font-size:.8125rem}details.evidence-files{margin:1rem 0}details.evidence-files>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:600}details.evidence-files ul{list-style:none;margin:0;padding:0}details.evidence-files li{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--so-line)}.result-action .result-feedback-link{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;text-decoration:none}@media(hover:hover) and (pointer:fine){.result-action .result-feedback-link:hover{background:var(--so-raised)}}.so-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.verdict{margin:.5rem 0 .75rem}.verdict-chips{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:0}.verdict-chip{display:inline-flex;align-items:center;gap:.3rem;min-height:1.75rem;padding:.2rem .65rem;border-radius:999px;font-size:.8125rem;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.verdict-chip svg{width:.9rem;height:.9rem}.verdict-chip--success{background:var(--so-success-soft);color:var(--so-success)}.verdict-chip--danger{background:var(--so-danger-soft);color:var(--so-danger)}.verdict-chip--warning{background:var(--so-warning-soft);color:var(--so-warning)}.verdict-chip--info{background:var(--so-info-soft);color:var(--so-info)}.verdict-by{margin:.4rem 0 0}details.result-request-open{margin:.5rem 0}details.result-request-open>summary{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;cursor:pointer;list-style:none}details.result-request-open>summary::-webkit-details-marker{display:none}details.result-request-open[open]>summary{margin-bottom:.75rem}.settings-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.5rem;margin:0 0 2rem}.settings-tiles a{display:flex;align-items:center;gap:.6rem;min-height:3rem;padding:.65rem .8rem;border:1px solid var(--so-line);border-radius:.625rem;background:var(--so-paper);color:var(--so-ink);text-decoration:none;font-weight:550;font-size:.875rem}@media(hover:hover) and (pointer:fine){.settings-tiles a:hover{border-color:var(--so-input-line);background:var(--so-raised)}}.settings-tiles svg{width:1.1rem;height:1.1rem;flex-shrink:0;color:var(--so-accent-text)}details.settings-more{margin:.25rem 0 1.25rem}details.settings-more>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:550}details.settings-more>summary .meta{font-weight:400;margin-left:.35rem}.settings-changed{margin-top:-.25rem}.appearance{margin:0 0 28px}.appearance h2{margin:0 0 10px}.theme-switch{display:inline-flex;flex-wrap:nowrap;max-width:100%;gap:4px;padding:4px;margin:0;border:1px solid var(--so-line);border-radius:10px;background:var(--so-raised)}.theme-switch .theme-choice,.so-native-region .theme-switch .theme-choice{flex:1 1 0;width:auto;white-space:nowrap;min-height:40px;padding:8px 16px;border:0;border-radius:7px;background:transparent;color:var(--so-muted);font:inherit;font-weight:550;box-shadow:none;cursor:pointer}@media(hover:hover) and (pointer:fine){.theme-switch .theme-choice:hover{color:var(--so-ink)}}.theme-switch .theme-choice[aria-pressed="true"]{background:var(--so-paper);color:var(--so-ink);box-shadow:0 1px 2px rgb(0 0 0 / .1)}.appearance .meta{margin:8px 0 0}@media(max-width:600px){.theme-switch .theme-choice{min-height:44px}}`;
13984
+ const THEME_CONTROLS_CSS = `.task-repo select{width:100%;min-height:2.75rem;font-size:1rem}.task-repo-add{margin:.35rem .1rem .5rem}.task-repo-add a{display:inline-flex;align-items:center;min-height:2.25rem}details.result-request-open.result-request-form>summary{border:0;background:transparent;padding:.5rem 0;min-height:2.75rem;font-weight:600;display:list-item;list-style:revert}details.result-request-open.result-request-form>summary::-webkit-details-marker{display:revert}form.js-autosave button[type=submit]{display:none}.provider-row{border-bottom:1px solid var(--so-line);padding:.35rem 0}.provider-row:first-of-type{border-top:1px solid var(--so-line)}.provider-head{display:flex;align-items:center;gap:.75rem;margin:.4rem 0 0}.provider-status{display:inline-flex;align-items:center;gap:.4rem;color:var(--so-muted);font-size:.875rem}.provider-status i{width:.5rem;height:.5rem;border-radius:50%;background:var(--so-muted)}.provider-status--ok i{background:var(--so-success)}.provider-status--warn i{background:var(--so-attention)}.provider-status--off i{background:transparent;border:1.5px solid var(--so-muted)}details.provider-manage>summary{cursor:pointer;color:var(--so-accent-text);font-size:.875rem;min-height:2.5rem;display:list-item;padding-block:.5rem}.card.props .row{display:grid;gap:.1rem;margin:0 0 .75rem}.card.props .row>.meta{display:block;font-size:.75rem}.card.props .row>.meta::first-letter{text-transform:uppercase}.card.props .row>.mono{font-family:var(--font-sans);font-size:.875rem}.card.props .row>.mono .seal{font-family:var(--font-mono);font-size:.8125rem}details.evidence-files{margin:1rem 0}details.evidence-files>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:600}details.evidence-files ul{list-style:none;margin:0;padding:0}details.evidence-files li{display:flex;justify-content:space-between;gap:1rem;padding:.5rem 0;border-bottom:1px solid var(--so-line)}.result-action .result-feedback-link{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;text-decoration:none}@media(hover:hover) and (pointer:fine){.result-action .result-feedback-link:hover{background:var(--so-raised)}}.so-sr-only{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.verdict{margin:.5rem 0 .75rem}.verdict-chips{display:flex;flex-wrap:wrap;gap:.4rem;list-style:none;padding:0;margin:0}.verdict-chip{display:inline-flex;align-items:center;gap:.3rem;min-height:1.75rem;padding:.2rem .65rem;border-radius:999px;font-size:.8125rem;font-weight:600;background:var(--so-neutral-soft);color:var(--so-neutral-ink)}.verdict-chip svg{width:.9rem;height:.9rem}.verdict-chip--success{background:var(--so-success-soft);color:var(--so-success)}.verdict-chip--danger{background:var(--so-danger-soft);color:var(--so-danger)}.verdict-chip--warning{background:var(--so-warning-soft);color:var(--so-warning)}.verdict-chip--info{background:var(--so-info-soft);color:var(--so-info)}.verdict-by{margin:.4rem 0 0}details.result-request-open{margin:.5rem 0}details.result-request-open>summary{display:inline-flex;align-items:center;min-height:2.5rem;padding:.5rem 1rem;border:1px solid var(--so-input-line);border-radius:.5rem;background:var(--so-paper);color:var(--so-ink);font-weight:600;cursor:pointer;list-style:none}details.result-request-open>summary::-webkit-details-marker{display:none}details.result-request-open[open]>summary{margin-bottom:.75rem}.settings-tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(8.5rem,1fr));gap:.5rem;margin:0 0 2rem}.settings-tiles a{display:flex;align-items:center;gap:.6rem;min-height:3rem;padding:.65rem .8rem;border:1px solid var(--so-line);border-radius:.625rem;background:var(--so-paper);color:var(--so-ink);text-decoration:none;font-weight:550;font-size:.875rem}@media(hover:hover) and (pointer:fine){.settings-tiles a:hover{border-color:var(--so-input-line);background:var(--so-raised)}}.settings-tiles svg{width:1.1rem;height:1.1rem;flex-shrink:0;color:var(--so-accent-text)}details.settings-more{margin:.25rem 0 1.25rem}details.settings-more>summary{cursor:pointer;min-height:2.75rem;display:list-item;padding-block:.7rem;font-weight:550}details.settings-more>summary .meta{font-weight:400;margin-left:.35rem}.settings-changed{margin-top:-.25rem}.appearance{margin:0 0 28px}.appearance h2{margin:0 0 10px}.theme-switch{display:inline-flex;flex-wrap:nowrap;max-width:100%;gap:4px;padding:4px;margin:0;border:1px solid var(--so-line);border-radius:10px;background:var(--so-raised)}.theme-switch .theme-choice,.so-native-region .theme-switch .theme-choice{flex:1 1 0;width:auto;white-space:nowrap;min-height:40px;padding:8px 16px;border:0;border-radius:7px;background:transparent;color:var(--so-muted);font:inherit;font-weight:550;box-shadow:none;cursor:pointer}@media(hover:hover) and (pointer:fine){.theme-switch .theme-choice:hover{color:var(--so-ink)}}.theme-switch .theme-choice[aria-pressed="true"]{background:var(--so-paper);color:var(--so-ink);box-shadow:0 1px 2px rgb(0 0 0 / .1)}.appearance .meta{margin:8px 0 0}@media(max-width:600px){.theme-switch .theme-choice{min-height:44px}}.update-notes{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit;max-height:18rem;overflow:auto}`;
13921
13985
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
13922
13986
  export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
13923
13987
  const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + LIMITS_CSS + MONITORING_CSS + BACKUP_CSS + EXPORT_CSS + PROJECT_DELETE_CSS + POLICY_CSS + EVIDENCE_PACK_CSS + THEME_CONTROLS_CSS + CODING_CSS + CODING_SHIPPING_CSS + RECIPE_CSS + SKILLS_CSS + TOOLS_CSS + FLOWS_CSS + TEAMMATE_CSS + KITS_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + LEAD_CONTEXT_CSS + '.learning{min-width:0;overflow-wrap:anywhere}.learning .card{min-width:0}.learning code,.learning blockquote,.learning pre{white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word}.learning button,.learning summary,.learning .button-link{min-height:44px}.learning button{white-space:nowrap}.learning summary{padding:12px 0;cursor:pointer}.learning form{margin:12px 0}.learning select{max-width:100%}.learning blockquote{margin:8px 0}.learning ul{padding-left:20px}');
@@ -14327,7 +14391,11 @@ function shell(title, body, options = {}) {
14327
14391
  ? ""
14328
14392
  : `<div class="banner"><span class="badge badge-running">mode</span>${escape(chrome.modeBanner.words)} \u00b7 <a href="/mode">the terms \u00b7 end it</a></div>`) +
14329
14393
  (chrome.signIn ?? []).map(one => `<div class="banner sign-in-banner" data-sign-in="${escape(one.provider)}"><strong>${escape(one.title)}</strong> \u00b7 run <code>${escape(one.command)}</code> on this computer, then resume.${one.detail === "" ? "" : ` ${escape(one.detail)}`}` +
14330
- `<form method="post" action="${escape(one.resumeHref)}" class="inline"><input type="hidden" name="csrf" value="${escape(chrome.csrf ?? "")}"><button type="submit">${escape(one.resumeLabel)}</button></form></div>`).join("");
14394
+ `<form method="post" action="${escape(one.resumeHref)}" class="inline"><input type="hidden" name="csrf" value="${escape(chrome.csrf ?? "")}"><button type="submit">${escape(one.resumeLabel)}</button></form></div>`).join("") +
14395
+ (chrome.update === undefined
14396
+ ? ""
14397
+ : `<div class="banner update-banner" data-update="${escape(chrome.update.version)}">${escape(updateNoticeWords(chrome.update))} \u00b7 <a href="${escape(chrome.update.href)}">What's new</a>` +
14398
+ `<form method="post" action="${escape(chrome.update.dismissHref)}" class="inline"><input type="hidden" name="csrf" value="${escape(chrome.csrf ?? "")}"><input type="hidden" name="version" value="${escape(chrome.update.version)}"><button type="submit">Dismiss</button></form></div>`);
14331
14399
  // The scope bar sits between the banners and the main/split body, so it
14332
14400
  // can never disappear with a responsive pane (portfolio arc §1).
14333
14401
  const content = chrome.listPane === undefined
@@ -21345,7 +21413,7 @@ function telegramDeliveryWords(store, bot) {
21345
21413
  return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
21346
21414
  return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
21347
21415
  }
21348
- function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null, push = null, providerKeys = null, digest = null, permissionDefault = null, qualityDefault = null, email = null, telegramDelivery = null, workers = null) {
21416
+ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null, push = null, providerKeys = null, digest = null, permissionDefault = null, qualityDefault = null, email = null, telegramDelivery = null, workers = null, updates = null) {
21349
21417
  const permissionCard = permissionDefault === null
21350
21418
  ? ""
21351
21419
  : [
@@ -21541,6 +21609,7 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21541
21609
  telegram: { state: hasEnv ? "from the environment" : existing === null ? "not set" : "saved", current, delivery: telegramDelivery },
21542
21610
  email,
21543
21611
  workers,
21612
+ updates,
21544
21613
  };
21545
21614
  return screen("Settings", [
21546
21615
  "<h1>Settings</h1>",
@@ -21550,6 +21619,7 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21550
21619
  qualityCard,
21551
21620
  keysCard,
21552
21621
  workersCard(workers),
21622
+ updatesCard(updates, csrf),
21553
21623
  pushCard === "" && messagingCard === "" && digestCard === "" ? "" : `<h2>Notifications</h2>`,
21554
21624
  messagingCard,
21555
21625
  pushCard,
@@ -21604,6 +21674,23 @@ function workersCard(workers) {
21604
21674
  `<p class="meta">To change how many a worker runs at once: <code>toolroll runner capacity &lt;name&gt; &lt;n&gt;</code></p>`) +
21605
21675
  `</section>`;
21606
21676
  }
21677
+ /** Settings → Updates, for browsers without the app script. */
21678
+ function updatesCard(updates, csrf) {
21679
+ if (updates === null)
21680
+ return "";
21681
+ const latest = updates.latest;
21682
+ return `<section id="updates" aria-labelledby="updates-title"><h2 id="updates-title">Updates</h2>` +
21683
+ `<p class="row">This version <span class="mono">${escape(updates.current)}</span> \u00b7 ${!updates.check.on ? "Update checks are off" : latest === null ? "Not checked yet" : latest.newer ? `Latest <span class="mono">${escape(latest.version)}</span>` : "Up to date"}</p>` +
21684
+ (latest !== null && latest.newer
21685
+ ? `<p>Update with <code>${escape(updates.updateCommand)}</code> \u00b7 <a href="${escape(latest.url)}">Release notes</a></p>` +
21686
+ (latest.notes === "" ? "" : `<details class="settings-more"><summary>What's new in ${escape(latest.version)}</summary><pre class="update-notes">${escape(latest.notes)}</pre></details>`)
21687
+ : "") +
21688
+ (updates.check.canManage && !updates.check.byEnv
21689
+ ? `<form method="post" action="/settings/updates" class="inline"><input type="hidden" name="csrf" value="${escape(csrf)}"><input type="hidden" name="check" value="${updates.check.on ? "off" : "on"}"><button type="submit">${updates.check.on ? "Turn off daily check" : "Turn on daily check"}</button></form>`
21690
+ : updates.check.byEnv ? `<p class="meta">Off by TOOLROLL_NO_UPDATE_CHECK.</p>` : "") +
21691
+ (updates.workers.length === 0 ? "" : `<ul>${updates.workers.map(one => `<li data-worker-version="${escape(one.name)}">${escape(one.name)} <span class="mono">${escape(one.version ?? "unknown")}</span>${one.older ? " \u00b7 older" : ""}</li>`).join("")}</ul>`) +
21692
+ `</section>`;
21693
+ }
21607
21694
  /** Choices save the moment they change; without the script the Save
21608
21695
  * button stays and the form works the same. */
21609
21696
  const SETTINGS_AUTOSAVE_SCRIPT = `(function(){document.querySelectorAll('form[data-autosave]').forEach(function(form){form.classList.add('js-autosave');form.addEventListener('change',function(ev){var t=ev.target;if(t&&(t.type==='radio'||t.tagName==='SELECT')){if(form.requestSubmit)form.requestSubmit();else form.submit();}});});})();`;
@@ -0,0 +1,5 @@
1
+ /** The update notice's words, shared by the server page and the console. Plain text; callers escape. */
2
+ export declare function updateNoticeWords(notice: {
3
+ version: string;
4
+ security: boolean;
5
+ }): string;
@@ -0,0 +1,4 @@
1
+ /** The update notice's words, shared by the server page and the console. Plain text; callers escape. */
2
+ export function updateNoticeWords(notice) {
3
+ return notice.security ? `Toolroll ${notice.version} is available, with security fixes` : `Toolroll ${notice.version} is available`;
4
+ }
@@ -2,7 +2,7 @@
2
2
  * not verify artifacts, inspect processes, or authorize task operations. */
3
3
  import { createHash } from 'node:crypto';
4
4
  import { readProjectAccess } from './project-access.js';
5
- import { providerName, signInReason } from './provider-auth.js';
5
+ import { pauseOnTaskProviders, providerName, signInReason } from './provider-auth.js';
6
6
  import { scopeTermsProblem } from './store.js';
7
7
  export const WORK_INDEX_PAGE_LIMIT = 40;
8
8
  export const WORK_INDEX_MAX_LIMIT = 100;
@@ -15,6 +15,8 @@ const s = (row, key) => row[key] == null ? null : String(row[key]);
15
15
  * admitted child needs its parent's exact revision brief, in the same repo.
16
16
  * All subsequent reads are scalar/indexed metadata, never artifact bytes.
17
17
  * MATERIALIZED prevents counts + page selection repeating correlated reads. */
18
+ /** A gate note counts only while its pause is on one of the task's current providers. */
19
+ const GATE_PAUSE_ON_TASK = pauseOnTaskProviders('p', 'c.ref_id');
18
20
  const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
19
21
  SELECT t.id, t.title, t.state, t.created_at, t.updated_at, r.id ref_id, r.repo,
20
22
  r.revision_of, r.revision_brief_artifact, r.assigned_runner, r.plan,
@@ -85,8 +87,8 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
85
87
  sc.task_id scope_id,sc.digest,sc.approved_at,sc.proposed_at,sc.profile_state,
86
88
  -- The pause the dispatch gate itself left this task waiting on (the provider it resolved, pins,
87
89
  -- configuration and planners included) first; the approved terms answer before any gate has looked.
88
- EXISTS(SELECT 1 FROM provider_auth_pause p WHERE p.id=c.auth_wait_pause AND p.lifted_at IS NULL) gate_signed_out,
89
- COALESCE((SELECT p.provider||':'||p.auth_mode FROM provider_auth_pause p WHERE p.id=c.auth_wait_pause AND p.lifted_at IS NULL),
90
+ EXISTS(SELECT 1 FROM provider_auth_pause p WHERE p.id=c.auth_wait_pause AND p.lifted_at IS NULL AND ${GATE_PAUSE_ON_TASK}) gate_signed_out,
91
+ COALESCE((SELECT p.provider||':'||p.auth_mode FROM provider_auth_pause p WHERE p.id=c.auth_wait_pause AND p.lifted_at IS NULL AND ${GATE_PAUSE_ON_TASK}),
90
92
  (SELECT p.provider||':'||p.auth_mode FROM provider_auth_pause p WHERE p.lifted_at IS NULL AND p.provider IN (
91
93
  SELECT json_extract(sc.approved_profile_json,'$.provider') WHERE json_valid(sc.approved_profile_json)
92
94
  UNION ALL SELECT json_extract(leg.value,'$.provider') FROM json_each(CASE WHEN json_valid(sc.approved_route_json) THEN sc.approved_route_json ELSE '{}' END,'$.legs') leg
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"