toolroll 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +413 -0
  2. package/README.md +15 -0
  3. package/dist/agent-onboard.d.ts +106 -0
  4. package/dist/agent-onboard.js +282 -0
  5. package/dist/browser/workspace.css +2 -2
  6. package/dist/browser/workspace.js +23 -23
  7. package/dist/browser-shell.js +5 -3
  8. package/dist/browser-workspace.d.ts +45 -0
  9. package/dist/cli.d.ts +3 -1
  10. package/dist/cli.js +8 -1
  11. package/dist/control-setup.d.ts +4 -4
  12. package/dist/daemon.d.ts +4 -0
  13. package/dist/daemon.js +28 -4
  14. package/dist/desktop-host.js +2 -2
  15. package/dist/desktop-update.d.ts +7 -1
  16. package/dist/desktop-update.js +39 -22
  17. package/dist/evidence.d.ts +24 -0
  18. package/dist/evidence.js +103 -5
  19. package/dist/first-run.d.ts +54 -0
  20. package/dist/first-run.js +101 -0
  21. package/dist/install-method.d.ts +17 -0
  22. package/dist/install-method.js +65 -0
  23. package/dist/operate.d.ts +14 -0
  24. package/dist/operate.js +114 -18
  25. package/dist/provider-auth.d.ts +33 -8
  26. package/dist/provider-auth.js +54 -13
  27. package/dist/releases.d.ts +85 -0
  28. package/dist/releases.js +238 -0
  29. package/dist/serve.d.ts +18 -0
  30. package/dist/serve.js +237 -81
  31. package/dist/skills.d.ts +2 -0
  32. package/dist/skills.js +3 -1
  33. package/dist/store.d.ts +3 -3
  34. package/dist/store.js +9 -4
  35. package/dist/surface.js +2 -0
  36. package/dist/toolroll-update-cli.d.ts +15 -0
  37. package/dist/toolroll-update-cli.js +155 -0
  38. package/dist/toolroll-update-ui.d.ts +38 -0
  39. package/dist/toolroll-update-ui.js +89 -0
  40. package/dist/toolroll-update.d.ts +235 -0
  41. package/dist/toolroll-update.js +929 -0
  42. package/dist/update-notice.d.ts +5 -0
  43. package/dist/update-notice.js +4 -0
  44. package/dist/work-index.js +5 -3
  45. package/package.json +2 -1
package/dist/serve.js CHANGED
@@ -125,7 +125,6 @@ import { openRouterModelsCache, openRouterPickerScript } from "./openrouter-mode
125
125
  import { checkModels, isNewModel, livePin, modelOptions, modelWords, RUNTIME_TOOLS, runtimeStates, seenModels, setWatch, updateRuntime, watchState } from "./model-catalog.js";
126
126
  import { MODELS_CSS, modelsHtml, modelsScript } from "./models-ui.js";
127
127
  import { ASSISTANTS, modelChoices, detectPreparation, previewProjectInstructions, addProjectInstructions } from "./setup-guide.js";
128
- import { projectSkillInstalled } from "./skills.js";
129
128
  import { previewSetup, approveSetup } from "./control-setup.js";
130
129
  import { controlSetupHtml, setupPreviewHtml, connectionHtml, connectionWords, hiddenFields } from "./control-ui.js";
131
130
  import { composerSchedule, scheduleEditorHtml, scheduleEditorScript } from "./task-composer.js";
@@ -171,6 +170,7 @@ import { parseGithubRepo, previewGithubRepo, cloneGithubRepo, listGithubRepos, i
171
170
  import { verifiedAuthor, LEAD_THREAD } from "./store.js";
172
171
  import { updateRepos, addRepos, removeRepos } from "./repos.js";
173
172
  import { run as execRun } from "./exec.js";
173
+ import { findFirstTasks, firstResultWords, firstRunSteps, firstTaskSuggestions, SANDBOX_COMMAND } from "./first-run.js";
174
174
  /** A user agent, reduced to safe display words — never echoed raw. */
175
175
  function oneLineUa(raw) {
176
176
  const text = Array.isArray(raw) ? (raw[0] ?? "") : (raw ?? "");
@@ -206,6 +206,9 @@ import { BACKUP_EVERY_HOURS, MAX_KEEP, backupFolderOf, backupNow, checkBackupFol
206
206
  import { prometheusMetrics } from "./metrics.js";
207
207
  import { SPEND_CSS, spendCsv, spendHtml } from "./spend-ui.js";
208
208
  import { RETENTION_CSS, retentionHtml } from "./retention-ui.js";
209
+ import { UPDATES_CSS, newerThan, updateStepsHtml, updatesHtml, updatesScript } from "./toolroll-update-ui.js";
210
+ import { abandonRuntimeUpdate, launchRuntimeUpdate, markWhatsNewSeen, prepareRuntimeUpdate, requestRuntimeUpdateCancel, runningWorkWords, runtimeUpdateStatus, runtimeUpdateTerminal, currentRuntime } from "./toolroll-update.js";
211
+ import { latestVersionNow } from "./releases.js";
209
212
  import { RETENTION_KINDS, lastSweepAt, parsePeriod, retentionPlan } from "./retention.js";
210
213
  import { EXPORT_CSS, dataExportHtml } from "./export-ui.js";
211
214
  import { buildExport, exportZip } from "./export.js";
@@ -231,6 +234,10 @@ import { verifyApproverByPassword, verifyApproverStanding } from "./principal.js
231
234
  import { runMateTurn, MATE_MESSAGE_MAX_CHARS } from "./mate.js";
232
235
  import { confirmCoordinatorProposal, confirmMateProposal, dismissCoordinatorProposal, dismissMateProposal } from "./mate-doors.js";
233
236
  import { envValue } from "./names.js";
237
+ import { cachedRelease, isNewer, runnerVersions, setUpdateChecks, updateChecksOff } from "./releases.js";
238
+ import { installMethod } from "./install-method.js";
239
+ import { PACKAGE_VERSION } from "./version.js";
240
+ import { updateNoticeWords } from "./update-notice.js";
234
241
  const SESSION_COOKIE = "standing-orders_session";
235
242
  const BODY_CAP = 16 * 1024;
236
243
  // URL encoding can triple UTF-8 bytes. Admit the existing bounded task
@@ -386,6 +393,32 @@ export function createDecisionServer(options) {
386
393
  }
387
394
  }
388
395
  const clock = options.clock ?? (() => new Date());
396
+ const install = installMethod(options.installBin);
397
+ /** What the last daily check found (the cache only, never the network), or nothing while checks are off. */
398
+ const updateFacts = () => {
399
+ if (options.configDir === undefined)
400
+ return null;
401
+ const switched = updateChecksOff(process.env, options.configDir);
402
+ const release = switched.off ? null : cachedRelease(options.configDir);
403
+ return { release, newer: release !== null && isNewer(release.version, PACKAGE_VERSION), on: !switched.off, byEnv: switched.byEnv };
404
+ };
405
+ /** Settings → Updates, for everyone who can open Settings; only an operator may flip the switch. */
406
+ const settingsUpdates = (actor, csrf) => {
407
+ const facts = updateFacts();
408
+ if (facts === null)
409
+ return null;
410
+ const known = new Map(runnerVersions(options.configDir).map(one => [one.runner, one.version]));
411
+ return {
412
+ current: PACKAGE_VERSION,
413
+ latest: facts.release === null ? null : { version: facts.release.version, newer: facts.newer, security: facts.release.security, notes: facts.release.notes, url: facts.release.url },
414
+ updateCommand: install.updateCommand,
415
+ check: { on: facts.on, byEnv: facts.byEnv, canManage: csrf !== "" && store.isInstanceOperator(actor) },
416
+ workers: store.listRunners().filter(one => one.retiredAt === null).map(one => {
417
+ const version = known.get(one.name) ?? null;
418
+ return { name: one.name, version, older: version !== null && isNewer(PACKAGE_VERSION, version) };
419
+ }),
420
+ };
421
+ };
389
422
  const workspaceRevision = prepareWorkspaceRevision(store);
390
423
  const workspaceIncarnation = randomBytes(16).toString('hex');
391
424
  const workspaceValidators = new WorkspaceValidatorCache();
@@ -1349,6 +1382,7 @@ export function createDecisionServer(options) {
1349
1382
  const requestFacts = {
1350
1383
  theme: pinnedTheme(request.headers.cookie),
1351
1384
  accent: pinnedAccent(request.headers.cookie),
1385
+ updateSeen: /(?:^|;\s*)so-update-seen=([0-9A-Za-z.-]{1,60})(?:;|$)/.exec(request.headers.cookie ?? "")?.[1] ?? null,
1352
1386
  actor: who.name,
1353
1387
  csrf: who.via === "cookie" ? who.session.csrf : "",
1354
1388
  returnTo: safeReturn(url.pathname + url.search),
@@ -1849,7 +1883,7 @@ export function createDecisionServer(options) {
1849
1883
  })),
1850
1884
  cancelledBlockers: cancelled,
1851
1885
  gaps: project === null ? [] : computeGaps(store, project, now).filter(gap => gap.unblocks.length > 0).slice(0, 10),
1852
- wizard: wizardSteps(now),
1886
+ wizard: await firstRunStepsNow(now),
1853
1887
  worker: (() => {
1854
1888
  // The one fact the inbox must never hide (install review): with
1855
1889
  // no worker answering, nothing here will ever build, and every
@@ -3342,10 +3376,13 @@ export function createDecisionServer(options) {
3342
3376
  // A failed briefing query must not make the conversation vanish.
3343
3377
  }
3344
3378
  }
3379
+ // The first run belongs to the lead conversation, for someone who can act on it.
3380
+ const firstRun = focusTask === null && who.role === "approver" ? await chatFirstRun(now, chatProject) : undefined;
3381
+ const withFirstRun = (shown) => firstRun === undefined ? shown : { ...shown, workspace: { ...shown.workspace, firstRun } };
3345
3382
  if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
3346
3383
  {
3347
3384
  const said = takeMateNote(who.session.csrf, mateSession.id);
3348
- return sendScreen(response, 200, matePage(chromeFor(null, "chat", undefined, "all"), {
3385
+ return sendScreen(response, 200, withFirstRun(matePage(chromeFor(null, "chat", undefined, "all"), {
3349
3386
  session: mateSession,
3350
3387
  ...mateConversationRows(who, principal, focusTask, now, chatProject),
3351
3388
  chatProject,
@@ -3363,10 +3400,10 @@ export function createDecisionServer(options) {
3363
3400
  now,
3364
3401
  resultPanel,
3365
3402
  resultRunId: resultRun?.id ?? null,
3366
- }));
3403
+ })));
3367
3404
  }
3368
3405
  }
3369
- return sendScreen(response, 200, chatPage(chromeFor(null, "chat", undefined, "all"), {
3406
+ return sendScreen(response, 200, withFirstRun(chatPage(chromeFor(null, "chat", undefined, "all"), {
3370
3407
  settingsOpen: settingsView,
3371
3408
  enabled,
3372
3409
  pending,
@@ -3399,7 +3436,7 @@ export function createDecisionServer(options) {
3399
3436
  resultPanel,
3400
3437
  ...(enabled.ok && who.role === "approver" ? { mateMint: mateMintCard(who.session.csrf, enabled, focusTask === null ? "/chat" : taskChatHref(focusTask.id)) } : {}),
3401
3438
  ...(who.role === "approver" ? { coordinatorProposals: coordinatorProposalsSection(coordinatorRows, decisionsFor(store, coordinatorRows), who.session.csrf, now, true, focusTask === null ? null : taskChatHref(focusTask.id)) } : {}),
3402
- }));
3439
+ })));
3403
3440
  }
3404
3441
  const chatAck = /^\/chat\/ack\/([0-9]{1,15})$/.exec(url.pathname);
3405
3442
  if (chatAck !== null) {
@@ -3586,6 +3623,21 @@ export function createDecisionServer(options) {
3586
3623
  const view = { settings: readMonitoring(options.configDir), status: store.monitoringStatus(), head: store.ledgerHeadId(), origin: consoleOrigin(request.headers.host) };
3587
3624
  return sendScreen(response, 200, screen("Monitoring", `<p><a href="/settings">Settings</a></p><h1>Monitoring</h1>${monitoringHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
3588
3625
  }
3626
+ // Settings → Updates: the installed version, three ways to update, the update's steps live, then What's new. An instance operator's page.
3627
+ if (url.pathname === "/settings/updates") {
3628
+ const databaseFile = store.databaseFile();
3629
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
3630
+ return refuse(response, who, 403, "An instance operator updates Toolroll.", "/settings");
3631
+ const status = runtimeUpdateStatus(dirname(databaseFile));
3632
+ if (url.searchParams.get("fragment") === "steps")
3633
+ return respond(response, 200, "text/html; charset=utf-8", status.journal ? updateStepsHtml(status.journal, status.running) : `<div id="update-live" data-done="1"></div>`);
3634
+ const active = status.journal !== null && !runtimeUpdateTerminal(status.journal.phase);
3635
+ const current = options.updates?.current ?? PACKAGE_VERSION;
3636
+ // While update checks are off the page asks npm nothing until Check now.
3637
+ const checksOff = updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off && url.searchParams.get("check") !== "now";
3638
+ const view = { current, latest: active ? { version: current } : checksOff ? { off: true } : await latestReleaseFor(options.updates?.latest), method: options.updates?.method ?? installMethod(), journal: status.journal, running: status.running, whatsNew: status.whatsNew, csrf: who.session.csrf };
3639
+ return sendScreen(response, 200, screen("Updates", `<p><a href="/settings">Settings</a></p><h1>Updates</h1>${updatesHtml(view, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings"), ...(active ? { functional: { script: updatesScript(), fetches: true } } : {}) }));
3640
+ }
3589
3641
  // v105: Settings → Retention, how long each kind of data is kept. An instance operator's page.
3590
3642
  if (url.pathname === "/settings/retention") {
3591
3643
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
@@ -3763,7 +3815,11 @@ export function createDecisionServer(options) {
3763
3815
  const googleView = { connected: googleConnected(options.configDir ?? null)?.address ?? null, clientId: google?.clientId ?? "", redirect: origin === null ? null : `${origin}${GOOGLE_CALLBACK}` };
3764
3816
  return email === null ? { set: false, host: "", port: 587, secure: false, user: "", from: "", imapHost: "", imapPort: 993, google: googleView }
3765
3817
  : { 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)));
3818
+ })() : null, telegramDeliveryWords(store, loadBotToken(process.env, options.telegramTokenFile)), settingsWorkers(store, now), settingsUpdates(who.name, csrf), (() => {
3819
+ // The installation fact: when the first Ready result arrived, measured from the first account.
3820
+ const at = store.firstSuccessAt(now), since = store.installationStartedAt();
3821
+ return at === null || since === null ? null : firstResultWords(since, at);
3822
+ })()));
3767
3823
  }
3768
3824
  if (url.pathname === "/push/key") {
3769
3825
  // Session-gated: the key is not secret, but strangers get nothing.
@@ -3803,62 +3859,46 @@ export function createDecisionServer(options) {
3803
3859
  return refuse(response, who, 404, "There's no page at this address.", "/chat");
3804
3860
  }
3805
3861
  /**
3806
- * The first-run checklist (adoption track, step 3) — derived from live
3807
- * state on every render, never a stored cursor, and retired PERMANENTLY
3808
- * by the first-success installation fact (Codex adoption review,
3809
- * finding 14). Every step is either something this console already has
3810
- * authority for, or the exact command where the CLI owns the act — the
3811
- * checklist instructs, it never gains authority (finding e).
3862
+ * The first run (adoption track, step 3): three plain steps derived from
3863
+ * live state on every render, never a stored cursor, and retired
3864
+ * PERMANENTLY by the first-success installation fact (Codex adoption
3865
+ * review, finding 14). Each step is either something this console can do,
3866
+ * or the exact command where only the CLI can (finding e).
3812
3867
  */
3813
- function wizardSteps(now) {
3814
- if (restricted() || store.firstSuccessAt(now) !== null)
3868
+ async function firstRunStepsNow(now) {
3869
+ if (restricted() || store.isDemo() || store.firstSuccessAt(now) !== null)
3815
3870
  return null;
3816
- const repos = admissionList() ?? [];
3817
- const setupDone = repos.filter(one => store.liveWorktreeSetup(one) !== null).length;
3818
- const skillDone = repos.filter(projectSkillInstalled).length;
3819
- const counted = (done) => repos.length <= 1 ? "" : ` (${done} of ${repos.length} repos)`;
3820
- return [
3821
- {
3822
- done: !unscopedMode,
3823
- title: "name what this console may see",
3824
- detail: unscopedMode
3825
- ? `no ceiling is configured — this server currently shows everything. Restart it naming the repos: <code>toolroll serve --repo &lt;path&gt; --port …</code>`
3826
- : repos.length === 0
3827
- ? `the ceiling is configured but empty — no repository is visible here`
3828
- : `ceiling: ${repos.map(one => escape(projectName(one))).join(", ")}`,
3829
- },
3830
- {
3831
- // A fact about THIS DATABASE only: binaries and authentication live
3832
- // on the worker's machine, which may not be this one — the console
3833
- // never claims to have checked them (finding 15).
3834
- done: store.hasPhaseConfig(),
3835
- title: "route the spend to a provider",
3836
- detail: store.hasPhaseConfig()
3837
- ? `spend routing is configured in this database. Binary and authentication facts stay machine-side: run <code>toolroll providers</code> where the workers run`
3838
- : `nothing routes builds to a provider yet: <code>toolroll config set build --provider claude --as &lt;you&gt; --token &lt;t&gt;</code> — then check <code>toolroll providers</code> on the worker's machine (installed, configured, historically-successful, and authenticated are four separate facts there)`,
3839
- },
3840
- {
3841
- done: repos.length > 0 && setupDone === repos.length,
3842
- title: "say how a fresh checkout gets ready",
3843
- detail: setupDone > 0
3844
- ? `setup command set${counted(setupDone)}`
3845
- : `agents build in throwaway workspaces; give them the preparation step: <code>toolroll setup set --repo &lt;path&gt; --command "npm ci"</code>`,
3846
- },
3847
- {
3848
- done: repos.length > 0 && skillDone === repos.length,
3849
- title: "teach the repo's agents this queue exists",
3850
- detail: skillDone > 0
3851
- ? `skill installed${counted(skillDone)}`
3852
- : `<code>toolroll skills install --repo &lt;path&gt;</code> previews; add <code>--yes</code> to write the skill file`,
3853
- },
3854
- {
3855
- done: store.hasAnyWork(),
3856
- title: "file the first standing order",
3857
- detail: store.hasAnyWork()
3858
- ? `work is filed — approve its scope and the machine takes it from there`
3859
- : `start from a template below, capture a one-off task underneath, or browse <a href="/routines">routines</a>`,
3860
- },
3861
- ];
3871
+ return firstRunSteps({ agentSignedIn: await agentSignedIn(), projects: managedRepos().length, hasTask: store.hasAnyWork(), firstResultAt: null });
3872
+ }
3873
+ /** Whether any coding agent on this computer is signed in: the same
3874
+ * non-spending checks Settings makes, cached briefly. */
3875
+ async function agentSignedIn() {
3876
+ const states = await Promise.all(["claude", "codex"].map(one => connectionCheck(one).then(value => value.state, () => "unverified")));
3877
+ return states.some(state => state === "connected" || state === "key-works" || state === "key-present");
3878
+ }
3879
+ /** First tasks per project, read at most every ten minutes: a `gh`
3880
+ * round trip and a `git grep` never run on every render. */
3881
+ const firstTasks = new Map();
3882
+ function firstTasksFor(repo) {
3883
+ const at = Date.now(), hit = firstTasks.get(repo);
3884
+ if (hit !== undefined && hit.until > at)
3885
+ return hit.value;
3886
+ const value = findFirstTasks(repo, options.firstTaskRunner ?? execRun).catch(() => firstTaskSuggestions({ issues: [], todos: [] }));
3887
+ firstTasks.set(repo, { until: at + 10 * 60_000, value });
3888
+ return value;
3889
+ }
3890
+ /** Chat's first run: the steps, first tasks until the first task exists, and the sandbox while no agent is signed in. */
3891
+ async function chatFirstRun(now, project) {
3892
+ const steps = await firstRunStepsNow(now);
3893
+ if (steps === null)
3894
+ return undefined;
3895
+ const taskFiled = steps.find(one => one.key === "task")?.done ?? true;
3896
+ const repo = project ?? managedRepos()[0] ?? null;
3897
+ return {
3898
+ steps,
3899
+ suggestions: taskFiled || repo === null ? [] : await firstTasksFor(repo),
3900
+ sandbox: steps.find(one => one.key === "agent")?.done === false ? SANDBOX_COMMAND : null,
3901
+ };
3862
3902
  }
3863
3903
  // ---- fleet chat (v13) ----------------------------------------------------
3864
3904
  const chatFetcher = options.chatFetcher ?? fetch;
@@ -4441,6 +4481,8 @@ export function createDecisionServer(options) {
4441
4481
  chats,
4442
4482
  ...(s.refreshSeconds === undefined ? {} : { refreshSeconds: Math.max(5, Math.floor(s.refreshSeconds)) }),
4443
4483
  ...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
4484
+ ...(s.chrome.update === undefined ? {} : { update: s.chrome.update }),
4485
+ ...(extras.firstRun === undefined ? {} : { firstRun: extras.firstRun }),
4444
4486
  };
4445
4487
  if (requestFacts.workspaceRead) {
4446
4488
  const validator = requestFacts.workspaceValidator;
@@ -4557,6 +4599,15 @@ export function createDecisionServer(options) {
4557
4599
  settings: true,
4558
4600
  ...(store.isDemo() ? { demo: true } : {}),
4559
4601
  ...(() => { const signIn = signInNotices(store); return signIn.length === 0 ? {} : { signIn }; })(),
4602
+ ...(() => {
4603
+ // Operators only (only someone at this computer can update it), and gone once this version is dismissed.
4604
+ if (!facts?.csrf || !actor || !store.isInstanceOperator(actor))
4605
+ return {};
4606
+ const update = updateFacts();
4607
+ if (update === null || !update.newer || update.release === null || facts.updateSeen === update.release.version)
4608
+ return {};
4609
+ return { update: { version: update.release.version, security: update.release.security, href: "/settings#updates", dismissHref: "/settings/updates/dismiss" } };
4610
+ })(),
4560
4611
  ...(liveMode === null || liveModeTerms === null
4561
4612
  ? {}
4562
4613
  : {
@@ -5754,6 +5805,29 @@ export function createDecisionServer(options) {
5754
5805
  liftAuthPause(store, provider, "person", who.name, clock());
5755
5806
  return redirect(response, "/work");
5756
5807
  }
5808
+ // The update notice is dismissed per browser and per version: a newer release shows it again.
5809
+ if (url.pathname === "/settings/updates/dismiss") {
5810
+ const version = body.get("version") ?? "";
5811
+ if (!/^[0-9A-Za-z.-]{1,60}$/.test(version))
5812
+ return refuse(response, who, 400, "Choose a version to dismiss.", "/settings#updates");
5813
+ response.setHeader("Set-Cookie", `so-update-seen=${version}; SameSite=Lax; Path=/; Max-Age=31536000`);
5814
+ if (body.get("quiet") === "1") {
5815
+ response.writeHead(204);
5816
+ response.end();
5817
+ return;
5818
+ }
5819
+ return redirect(response, safeReturn(body.get("return") ?? "/settings#updates"));
5820
+ }
5821
+ // The daily check's switch: an installation setting, so an operator's. (Its own address: POST /settings/updates starts an update.)
5822
+ if (url.pathname === "/settings/updates/checks") {
5823
+ if (options.configDir === undefined)
5824
+ return refuse(response, who, 404, "Updates are not set up on this console.", "/settings");
5825
+ if (!store.isInstanceOperator(who.name))
5826
+ return refuse(response, who, 403, "Only an operator can change update checks.", "/settings#updates");
5827
+ const on = body.get("check") === "on";
5828
+ setUpdateChecks(options.configDir, on);
5829
+ return redirect(response, `/settings?said=${encodeURIComponent(on ? "Toolroll looks for a newer version once a day." : "Update checks are off.")}#updates`);
5830
+ }
5757
5831
  if (url.pathname === '/code' || url.pathname.startsWith('/code/')) {
5758
5832
  const wantsJson = request.headers.accept?.includes('application/json') === true;
5759
5833
  const fail = (status, message, delivery = 'rejected', sessionId) => wantsJson
@@ -6458,6 +6532,52 @@ export function createDecisionServer(options) {
6458
6532
  store.recordAction({ at: now.toISOString(), actor: who.name, repo: null, taskId: null, runId: null, action: before === null ? "sign-in with a provider turned on" : "sign-in with a provider changed", outcome: "changed", source: "policy", detail: ssoChangeWords(before, saved.settings) });
6459
6533
  return found.ok ? back("said", `Saved. People can sign in with ${saved.settings.label}.`) : back("problem", `Saved, but ${saved.settings.label} didn't answer: ${found.said}`);
6460
6534
  }
6535
+ // Settings → Updates: start the `toolroll update` job. A step-up; the job writes the ledger entries.
6536
+ if (url.pathname === "/settings/updates" || url.pathname === "/settings/updates/seen" || url.pathname === "/settings/updates/cancel") {
6537
+ const databaseFile = store.databaseFile();
6538
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name) || databaseFile === null)
6539
+ return refuse(response, who, 403, "An instance operator updates Toolroll.", "/settings");
6540
+ const stateDir = dirname(databaseFile);
6541
+ const back = (key, words) => redirect(response, `/settings/updates?${key}=${encodeURIComponent(words)}`);
6542
+ if (url.pathname === "/settings/updates/seen") {
6543
+ markWhatsNewSeen(stateDir);
6544
+ return redirect(response, "/settings/updates");
6545
+ }
6546
+ if (url.pathname === "/settings/updates/cancel")
6547
+ return back("said", requestRuntimeUpdateCancel(stateDir));
6548
+ if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
6549
+ return back("problem", "Enter your Toolroll password to update.");
6550
+ const version = body.get("version") ?? "";
6551
+ const when = { now: "now", "when-idle": "when-idle", tonight: "at" }[body.get("when") ?? ""] ?? null;
6552
+ if (!/^\d+\.\d+\.\d+$/.test(version) || when === null)
6553
+ return back("problem", "Choose when to update.");
6554
+ const current = options.updates?.current ?? PACKAGE_VERSION;
6555
+ if (!newerThan(version, current))
6556
+ return back("problem", `Toolroll ${version} is not newer than ${current}. To go back, run toolroll update --rollback.`);
6557
+ const method = options.updates?.method ?? installMethod();
6558
+ if (method.kind === "npx" || method.kind === "source")
6559
+ return back("problem", method.kind === "npx" ? "npx runs the latest release each time; there is nothing to update." : "This runs from a source checkout. Update it with git.");
6560
+ const before = runtimeUpdateStatus(stateDir).journal;
6561
+ if (before !== null && !runtimeUpdateTerminal(before.phase))
6562
+ return back("problem", "An update is already under way.");
6563
+ if (when === "now") {
6564
+ const running = runningWorkWords(store.raw());
6565
+ if (running !== null)
6566
+ return back("problem", `Work is running: ${running}. Choose When idle to update once it finishes.`);
6567
+ }
6568
+ const prepared = prepareRuntimeUpdate({ stateDir, databaseFile, current: { version: current, dist: options.updates?.dist ?? currentRuntime(current).dist }, actor: who.name, version, when, at: "03:00" }, clock());
6569
+ if ("refused" in prepared)
6570
+ return back("problem", prepared.refused);
6571
+ try {
6572
+ await (options.updates?.launch ?? launchRuntimeUpdate)({ databaseFile, id: prepared.id });
6573
+ }
6574
+ catch (error) {
6575
+ abandonRuntimeUpdate(stateDir, prepared.id, `The update could not start: ${error.message}`, clock());
6576
+ return back("problem", `The update could not start: ${error.message}`);
6577
+ }
6578
+ // The job records its first step within moments; show it rather than the old page.
6579
+ return back("said", when === "at" ? `Update to ${version} scheduled for 03:00.` : `Updating to ${version}.`);
6580
+ }
6461
6581
  // v105: change how long each kind of data is kept. A step-up; the ledger keeps before → after for each kind.
6462
6582
  if (url.pathname === "/settings/retention") {
6463
6583
  if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
@@ -13917,10 +14037,10 @@ button.pick-file { min-height: 1.75rem; padding: 0 .55rem; font-size: .75rem; }
13917
14037
  }
13918
14038
  `;
13919
14039
  /** 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}}`;
14040
+ 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
14041
  /** The page CSS this module writes itself (not the imported modules'), for the stylesheet contract tests. */
13922
14042
  export const PAGE_CSS = STYLE + THEME_CONTROLS_CSS;
13923
- 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}');
14043
+ const WORKSPACE_STYLE = styleAsset(STYLE + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + UPDATES_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}');
13924
14044
  /**
13925
14045
  * The board's liveness: fetch this page's own fragment on a timer and swap
13926
14046
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -14327,7 +14447,11 @@ function shell(title, body, options = {}) {
14327
14447
  ? ""
14328
14448
  : `<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
14449
  (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("");
14450
+ `<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("") +
14451
+ (chrome.update === undefined
14452
+ ? ""
14453
+ : `<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>` +
14454
+ `<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
14455
  // The scope bar sits between the banners and the main/split body, so it
14332
14456
  // can never disappear with a responsive pane (portfolio arc §1).
14333
14457
  const content = chrome.listPane === undefined
@@ -14567,26 +14691,20 @@ function inboxPage(chrome, data) {
14567
14691
  `<span class="meta">frees ${gap.unblocks.length} task${gap.unblocks.length > 1 ? "s" : ""}</span>` +
14568
14692
  `<span class="right meta">how to fix \u2192</span></p>`)
14569
14693
  .join("\n");
14570
- // The first-run checklist replaces the empty-queue card while the
14571
- // installation has never succeeded; each step is live state, and the
14572
- // whole card retires permanently on the first successful run.
14694
+ // The first-run steps, until the first Ready result: each is done or
14695
+ // shows the one action that does it.
14573
14696
  const wizard = data.wizard === null
14574
14697
  ? ""
14575
- : `<div class="card">` +
14576
- `<p><strong>Getting started</strong> <span class="meta">\u2014 disappears after the first successful run</span></p>` +
14577
- `<p class="meta">Keep <span class="mono">toolroll up</span> running on this machine \u2014 it opens the app and reconnects every saved project's builder.</p>` +
14698
+ : `<div class="card" data-first-run>` +
14699
+ `<p><strong>Get to your first result</strong></p>` +
14578
14700
  data.wizard
14579
- .map(step => `<p class="row"><span class="mono">${step.done ? "\u2713" : "\u25cb"}</span> <strong>${escape(step.title)}</strong><br>` +
14580
- `<span class="meta">${step.detail}</span></p>`)
14701
+ .map(step => `<p class="row" data-step="${step.key}"><span class="mono" aria-hidden="true">${step.done ? "\u2713" : "\u25cb"}</span> <strong>${escape(step.title)}</strong>` +
14702
+ (step.action === null ? ` <span class="meta">done</span>`
14703
+ : step.action.kind === "link" ? ` <a href="${escape(step.action.href)}">${escape(step.action.label)}</a>`
14704
+ : ` <code>${escape(step.action.command)}</code>`) +
14705
+ `</p>`)
14581
14706
  .join("\n") +
14582
- `<p><a class="new-task" href="/recipes">Start with a guided recipe →</a></p>` +
14583
- `<p class="meta">templates \u2014 edit, then approve; nothing a template files carries authority: ` +
14584
- TEMPLATES.map(one => one.kind === "routine"
14585
- ? `<a href="/routines?template=${escape(one.name)}">${escape(one.name)}</a>`
14586
- : one.kind === "task"
14587
- ? `<a href="/tasks?template=${escape(one.name)}">${escape(one.name)}</a>`
14588
- : `<span title="a recipe \u2014 walk it in the terminal: toolroll template show ${escape(one.name)}">${escape(one.name)} (recipe)</span>`).join(" \u00b7 ") +
14589
- `</p></div>`;
14707
+ `</div>`;
14590
14708
  const noWorker = data.worker.answering > 0
14591
14709
  ? ""
14592
14710
  : `<div class="card builder-notice" data-builder-status="${data.worker.registered === 0 ? "not-connected" : "disconnected"}">` +
@@ -21345,7 +21463,7 @@ function telegramDeliveryWords(store, bot) {
21345
21463
  return `Telegram pushes new messages to ${state.url}${state.problem === null ? "." : `, but ${state.problem.charAt(0).toLowerCase()}${state.problem.slice(1)}.`}`;
21346
21464
  return `Toolroll asks Telegram for new messages every few seconds${state?.problem ? ` (${state.problem})` : ""}.`;
21347
21465
  }
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) {
21466
+ 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, firstResult = null) {
21349
21467
  const permissionCard = permissionDefault === null
21350
21468
  ? ""
21351
21469
  : [
@@ -21541,6 +21659,8 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21541
21659
  telegram: { state: hasEnv ? "from the environment" : existing === null ? "not set" : "saved", current, delivery: telegramDelivery },
21542
21660
  email,
21543
21661
  workers,
21662
+ updates,
21663
+ firstResult,
21544
21664
  };
21545
21665
  return screen("Settings", [
21546
21666
  "<h1>Settings</h1>",
@@ -21550,6 +21670,8 @@ function settingsPage(chrome, existing, hasEnv, csrf, problem, messaging = null,
21550
21670
  qualityCard,
21551
21671
  keysCard,
21552
21672
  workersCard(workers),
21673
+ updatesCard(updates, csrf),
21674
+ firstResult === null ? "" : `<p class="meta" data-first-result>${escape(firstResult)}</p>`,
21553
21675
  pushCard === "" && messagingCard === "" && digestCard === "" ? "" : `<h2>Notifications</h2>`,
21554
21676
  messagingCard,
21555
21677
  pushCard,
@@ -21604,9 +21726,42 @@ function workersCard(workers) {
21604
21726
  `<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
21727
  `</section>`;
21606
21728
  }
21729
+ /** Settings → Updates, for browsers without the app script. */
21730
+ function updatesCard(updates, csrf) {
21731
+ if (updates === null)
21732
+ return "";
21733
+ const latest = updates.latest;
21734
+ return `<section id="updates" aria-labelledby="updates-title"><h2 id="updates-title">Updates</h2>` +
21735
+ `<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>` +
21736
+ (latest !== null && latest.newer
21737
+ ? `<p>Update with <code>${escape(updates.updateCommand)}</code> \u00b7 <a href="${escape(latest.url)}">Release notes</a></p>` +
21738
+ (latest.notes === "" ? "" : `<details class="settings-more"><summary>What's new in ${escape(latest.version)}</summary><pre class="update-notes">${escape(latest.notes)}</pre></details>`)
21739
+ : "") +
21740
+ (updates.check.canManage && !updates.check.byEnv
21741
+ ? `<form method="post" action="/settings/updates/checks" 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>`
21742
+ : updates.check.byEnv ? `<p class="meta">Off by TOOLROLL_NO_UPDATE_CHECK.</p>` : "") +
21743
+ (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>`) +
21744
+ `</section>`;
21745
+ }
21607
21746
  /** Choices save the moment they change; without the script the Save
21608
21747
  * button stays and the form works the same. */
21609
21748
  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();}});});})();`;
21749
+ /** The newest release, remembered for a while so the page stays quick. */
21750
+ let latestSeen = null;
21751
+ async function latestReleaseFor(latest) {
21752
+ if (latest === undefined && latestSeen !== null && Date.now() - latestSeen.at < ("problem" in latestSeen.value ? 60_000 : 900_000))
21753
+ return latestSeen.value;
21754
+ let value;
21755
+ try {
21756
+ value = { version: (await (latest ?? latestVersionNow)()).version };
21757
+ }
21758
+ catch (error) {
21759
+ value = { problem: error.message };
21760
+ }
21761
+ if (latest === undefined)
21762
+ latestSeen = { at: Date.now(), value };
21763
+ return value;
21764
+ }
21610
21765
  /** Settings destinations as a scannable grid: an icon and a name each. */
21611
21766
  function settingsTiles() {
21612
21767
  const tiles = SETTINGS_TILE_ICONS;
@@ -21629,6 +21784,7 @@ const SETTINGS_TILE_ICONS = [
21629
21784
  ["/settings/approval", "Approval rules", `<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/>`],
21630
21785
  ["/settings/monitoring", "Monitoring", `<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>`],
21631
21786
  ["/settings/retention", "Retention", `<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>`],
21787
+ ["/settings/updates", "Updates", `<path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/>`],
21632
21788
  ["/settings/backups", "Backups", `<ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5"/><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3"/>`],
21633
21789
  ["/settings/data", "Data", `<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/>`],
21634
21790
  ];
package/dist/skills.d.ts CHANGED
@@ -68,6 +68,8 @@ export declare function applyInstall(repo: string, writeContext: boolean): Insta
68
68
  */
69
69
  export declare const CLAUDE_CODE_MANAGED_MARK = "<!-- standing-orders:claude-code-skill:v1 -->";
70
70
  export declare const CLAUDE_CODE_GUIDES: readonly ["console", "operating", "runner"];
71
+ /** The thin user-level skill `toolroll onboard` writes (agent-onboard.ts). */
72
+ export declare const OPERATOR_SKILL_MARK = "<!-- toolroll:operator-skill:v1";
71
73
  export type ClaudeCodeSkillFile = {
72
74
  name: string;
73
75
  path: string;
package/dist/skills.js CHANGED
@@ -162,6 +162,8 @@ export function applyInstall(repo, writeContext) {
162
162
  */
163
163
  export const CLAUDE_CODE_MANAGED_MARK = "<!-- standing-orders:claude-code-skill:v1 -->";
164
164
  export const CLAUDE_CODE_GUIDES = ["console", "operating", "runner"];
165
+ /** The thin user-level skill `toolroll onboard` writes (agent-onboard.ts). */
166
+ export const OPERATOR_SKILL_MARK = "<!-- toolroll:operator-skill:v1";
165
167
  export function defaultClaudeCodeSkillDir(home = homedir()) {
166
168
  return join(home, ".claude", "skills", "toolroll");
167
169
  }
@@ -232,7 +234,7 @@ function claudeCodeFiles(directory) {
232
234
  }
233
235
  function isClaudeCodeManaged(name, content) {
234
236
  if (name === "SKILL.md") {
235
- return ownHeader(content) && content.includes(`\n${CLAUDE_CODE_MANAGED_MARK}\n`);
237
+ return ownHeader(content) && (content.includes(`\n${CLAUDE_CODE_MANAGED_MARK}\n`) || content.includes(`\n${OPERATOR_SKILL_MARK} `));
236
238
  }
237
239
  return content.startsWith(`${CLAUDE_CODE_MANAGED_MARK}\n\n`);
238
240
  }
package/dist/store.d.ts CHANGED
@@ -4344,9 +4344,9 @@ export declare class Store {
4344
4344
  * checklist (adoption review, finding 14).
4345
4345
  */
4346
4346
  firstSuccessAt(now: Date): string | null;
4347
- /** Whether ANY spend routing is configured — a fact about this database,
4348
- * not about any machine's binaries or authentication. */
4349
- hasPhaseConfig(): boolean;
4347
+ /** When this installation began: its first account. Accounts are never
4348
+ * deleted (revocation is a stamp), so this never moves. */
4349
+ installationStartedAt(): string | null;
4350
4350
  /** The jump palette's food: open (queued/running/failed) tasks, admission
4351
4351
  * inside the query before its LIMIT, newest first. */
4352
4352
  paletteTasks(repo: string | null, limit: number, admitted?: string[] | null, includeUnplaced?: boolean): {
package/dist/store.js CHANGED
@@ -14410,10 +14410,11 @@ export class Store {
14410
14410
  this.recordInstallationFact("first-success-at", when, now);
14411
14411
  return when;
14412
14412
  }
14413
- /** Whether ANY spend routing is configured — a fact about this database,
14414
- * not about any machine's binaries or authentication. */
14415
- hasPhaseConfig() {
14416
- return this.db.prepare("SELECT 1 AS hit FROM phase_config LIMIT 1").get() !== undefined;
14413
+ /** When this installation began: its first account. Accounts are never
14414
+ * deleted (revocation is a stamp), so this never moves. */
14415
+ installationStartedAt() {
14416
+ const row = this.db.prepare("SELECT MIN(added_at) AS at FROM approver").get();
14417
+ return row === undefined || row["at"] == null ? null : String(row["at"]);
14417
14418
  }
14418
14419
  /** The jump palette's food: open (queued/running/failed) tasks, admission
14419
14420
  * inside the query before its LIMIT, newest first. */
@@ -15936,6 +15937,10 @@ export class Store {
15936
15937
  // stop after an orphan or held supervisor finishes shutdown.
15937
15938
  this.recordRunProcessExits(id, result.now);
15938
15939
  this.settleRunStop(id, result.stopSettlement ?? "finished", result.now);
15940
+ // The first Ready result is an installation fact the moment it lands,
15941
+ // not only when something later looks for it.
15942
+ if (result.outcome === "built" || result.outcome === "no-change")
15943
+ this.firstSuccessAt(result.now);
15939
15944
  // The park rate is *measured* — parked over concluded builder attempts —
15940
15945
  // and maintained where attempts conclude, because the attention budget's
15941
15946
  // gate reads it inside a claim transaction and must never trust a number