toolroll 0.9.45 → 0.9.47

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 (68) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/activity-line.d.ts +26 -0
  3. package/dist/activity-line.js +47 -0
  4. package/dist/api-tokens.d.ts +33 -0
  5. package/dist/api-tokens.js +39 -0
  6. package/dist/audit-cli.d.ts +17 -0
  7. package/dist/audit-cli.js +86 -0
  8. package/dist/browser/workspace.js +25 -25
  9. package/dist/browser-workspace.d.ts +18 -1
  10. package/dist/browser-workspace.js +9 -3
  11. package/dist/builder.js +4 -2
  12. package/dist/cli-http.d.ts +5 -0
  13. package/dist/cli-http.js +12 -2
  14. package/dist/cli.js +7 -1
  15. package/dist/contracts/cli.d.ts +47 -0
  16. package/dist/contracts/cli.js +11 -0
  17. package/dist/contracts/console-api.d.ts +1 -0
  18. package/dist/contracts/console-api.js +1 -0
  19. package/dist/contracts/mcp-oauth.d.ts +53 -0
  20. package/dist/contracts/mcp-oauth.js +40 -0
  21. package/dist/credentials-ui.d.ts +5 -0
  22. package/dist/credentials-ui.js +16 -4
  23. package/dist/export.js +4 -3
  24. package/dist/flow-card-task.d.ts +2 -0
  25. package/dist/flow-card-task.js +57 -0
  26. package/dist/flow-live.d.ts +2 -2
  27. package/dist/flow-live.js +69 -8
  28. package/dist/flows-ui.js +15 -1
  29. package/dist/live.d.ts +14 -0
  30. package/dist/live.js +71 -22
  31. package/dist/mcp-http.d.ts +6 -1
  32. package/dist/mcp-http.js +22 -4
  33. package/dist/mcp-oauth.d.ts +82 -0
  34. package/dist/mcp-oauth.js +479 -0
  35. package/dist/mcp-person.js +1 -1
  36. package/dist/operate-remote-arguments.js +2 -0
  37. package/dist/operate-remote.d.ts +1 -1
  38. package/dist/operate-remote.js +7 -1
  39. package/dist/operate.d.ts +3 -0
  40. package/dist/operate.js +58 -2
  41. package/dist/people-audit-ui.d.ts +14 -0
  42. package/dist/people-audit-ui.js +38 -0
  43. package/dist/planner.js +4 -3
  44. package/dist/public-access.d.ts +50 -0
  45. package/dist/public-access.js +87 -0
  46. package/dist/public-check.d.ts +37 -0
  47. package/dist/public-check.js +170 -0
  48. package/dist/remote-audit.d.ts +79 -0
  49. package/dist/remote-audit.js +161 -0
  50. package/dist/request-budget-ui.d.ts +11 -0
  51. package/dist/request-budget-ui.js +31 -0
  52. package/dist/request-budget.d.ts +96 -0
  53. package/dist/request-budget.js +227 -0
  54. package/dist/serve.d.ts +9 -1
  55. package/dist/serve.js +211 -52
  56. package/dist/setup-guide.d.ts +2 -2
  57. package/dist/store.d.ts +134 -2
  58. package/dist/store.js +270 -7
  59. package/dist/surface.js +19 -2
  60. package/dist/task-activity.d.ts +27 -0
  61. package/dist/task-activity.js +53 -0
  62. package/dist/task-live.d.ts +17 -0
  63. package/dist/task-live.js +144 -0
  64. package/dist/token-notices.d.ts +22 -0
  65. package/dist/token-notices.js +52 -0
  66. package/dist/tokens-cli.d.ts +21 -0
  67. package/dist/tokens-cli.js +184 -0
  68. package/package.json +1 -1
package/dist/serve.js CHANGED
@@ -8,7 +8,7 @@ import { projectBatchChecks, setProjectBatchChecks } from './batch-policy.js';
8
8
  import { CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, suggestQuickCommand } from './check-levels.js';
9
9
  import { repositoryContextHtml } from './repository-context-ui.js';
10
10
  import { browserAssetsAvailable, browserWorkspaceDocument, serveBrowserAsset } from './browser-shell.js';
11
- import { browserCrewOf, browserCrewFromIndex, browserWorkActionHref, browserProjectsOf, browserNavigationOf } from './browser-workspace.js';
11
+ import { browserCrewOf, browserCrewFromIndex, browserWorkActionHref, browserProjectsOf, browserNavigationOf, needsYouLabelOf } from './browser-workspace.js';
12
12
  import { configureLeadFollow, leadFollowStatus, runLeadFollowPass } from './lead-follow.js';
13
13
  import { cancelCommitment, conditionWords, openCommitments } from './lead-commitments.js';
14
14
  import { startMaintenance } from './maintenance.js';
@@ -44,6 +44,8 @@ import { addRoutine, removeRoutine, runRoutine } from "./teammate-desk.js";
44
44
  import { sendTeammateWeeklies, undoCall } from "./teammate-week.js";
45
45
  import { answerTeammateQuestion } from "./teammate-work.js";
46
46
  import { createFlowRooms, flowFingerprint } from "./flow-live.js";
47
+ import { createTaskRooms, taskFingerprint } from "./task-live.js";
48
+ import { runActivityOf } from "./task-activity.js";
47
49
  import { disconnectGoogle, finishGoogleConsent, GOOGLE_CALLBACK, googleConnected, googleConsent, readGoogleMail, saveGoogleClient } from "./google-mail.js";
48
50
  import { mailboxAccess, readThroughImap } from "./mailbox.js";
49
51
  import { readEmailSettings, saveEmailSettings, sendingAccount, sendThroughServer, setFlowSecret } from "./flow-actions.js";
@@ -262,9 +264,15 @@ import { LIMITS_CSS, limitsHtml, limitsView } from "./limits-ui.js";
262
264
  import { budgetHoldWords, budgetLabel, budgetStates, monthNamed, monthOf, spendItems, teammateNames as teammateNamesOf, usd as spendUsd } from "./spend.js";
263
265
  import { targetOf } from "./monitoring.js";
264
266
  import { SSO_CSS, ssoSettingsHtml } from "./sso-ui.js";
265
- import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS } from "./api-tokens.js";
267
+ import { mintApiToken, parseApiToken, secretMatches, TOKEN_DAYS, tokenLive, tokenProjects } from "./api-tokens.js";
266
268
  import { createMcpHttp } from "./mcp-http.js";
269
+ import { FLUSH_MS as REQUEST_BUDGET_FLUSH_MS, parseLimit, PER_DAY_MAX as REQUEST_BUDGET_PER_DAY_MAX, PER_MINUTE_MAX as REQUEST_BUDGET_PER_MINUTE_MAX, RequestBudget, setLimitOverride } from "./request-budget.js";
270
+ import { hstsFor, LOOPBACK_PEERS, plainHttpRefusal, transportOf } from "./public-access.js";
271
+ import { createOAuthHttp, oauthTokenAllowed, resourceMetadataUrl } from "./mcp-oauth.js";
267
272
  import { CREDENTIALS_CSS, credentialsHtml, tokenShownHtml } from "./credentials-ui.js";
273
+ import { REQUEST_LIMITS_CSS, requestLimitsHtml, tokenLimitWords } from "./request-budget-ui.js";
274
+ import { PEOPLE_AUDIT_CSS, personAuditHtml, personHref } from "./people-audit-ui.js";
275
+ import { cursorOf as auditCursorOf, remoteAudit } from "./remote-audit.js";
268
276
  import { logEvent } from "./log.js";
269
277
  import { modeTermsFromJson, modeWords, presetTerms, modeTermsJson, modeDigestOf, MODE_MAX_DAYS } from "./modes.js";
270
278
  import { PROVIDER_KEY_ENV, SUBSCRIPTION_CAPABLE, clearProviderKey, readProviderKey, keyStatus, plausibleKey, readAuthMode, readAuthModeStrict, saveProviderKey, setAuthMode, verifyProviderKey, verdictWords } from "./keys.js";
@@ -535,7 +543,7 @@ export function createDecisionServer(options) {
535
543
  * the trusted proxy itself appended; earlier entries are client-typed). */
536
544
  function joinSourceOf(request) {
537
545
  const peer = request.socket.remoteAddress ?? "unknown";
538
- const loopback = peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1";
546
+ const loopback = LOOPBACK_PEERS.has(peer);
539
547
  if (!loopback)
540
548
  return peer;
541
549
  const forwarded = request.headers["x-forwarded-for"];
@@ -581,7 +589,7 @@ export function createDecisionServer(options) {
581
589
  /** This computer, and nothing in front of it: a loopback peer that names a loopback address, with no forwarding proxy. */
582
590
  const fromThisComputer = (request) => {
583
591
  const peer = request.socket.remoteAddress ?? "";
584
- const loopback = peer === "127.0.0.1" || peer === "::1" || peer === "::ffff:127.0.0.1";
592
+ const loopback = LOOPBACK_PEERS.has(peer);
585
593
  return loopback && request.headers["x-forwarded-for"] === undefined && request.headers["forwarded"] === undefined
586
594
  && /^(localhost|127\.0\.0\.1|\[::1\]):[0-9]{1,5}$/.test(request.headers.host ?? "");
587
595
  };
@@ -743,6 +751,7 @@ export function createDecisionServer(options) {
743
751
  const connectVisits = new Map();
744
752
  /** Flows open in a browser (v88): who's here, and a nudge when one changes. */
745
753
  const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow));
754
+ const taskRooms = createTaskRooms(root => taskFingerprint(store, root, clock()));
746
755
  const teamChatProvider = () => { const enabled = chatEnablement(); return enabled.ok ? { config: enabled.config, key: enabled.key } : null; };
747
756
  const team = createTeamRuntime({ store, repos: codingProjects, evidenceRoot, clock, workspaceRevision,
748
757
  ...(options.chatFetcher ? { fetcher: options.chatFetcher } : {}),
@@ -797,16 +806,19 @@ export function createDecisionServer(options) {
797
806
  return who?.via === 'cookie' ? teamBrowserReply(reply, actor, who.session.csrf) : reply;
798
807
  }, cursor: team.cursor, streams: teamStreams,
799
808
  });
809
+ // v112: one request budget per server for person API tokens, shared by /api/cli and /mcp (request-budget.ts).
810
+ const requestBudget = new RequestBudget({ store, ...(options.requestBudgetClock === undefined ? {} : { clock: options.requestBudgetClock }) });
800
811
  // Remote CLI: one live API token names the person; the shared command boundary decides everything else.
801
812
  const cliEndpoint = (request, response) => handleCliHttp(request, response, {
813
+ admit: principal => requestBudget.admit(principal.tokenId, "api"),
802
814
  authenticate: request => {
803
- const who = identify(request, false);
815
+ const who = identify(request, false, true);
804
816
  const id = parseApiToken(/^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1] ?? "")?.id;
805
817
  const row = id === undefined ? undefined : store.apiTokenSecret(id)?.row;
806
818
  const account = who === null ? null : store.accountOf(who.name);
807
819
  if (who?.via !== "bearer" || who.token === undefined || row === undefined || row.account !== who.name || account === null || account.revokedAt !== null)
808
820
  return null;
809
- return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: account.projects === null ? null : [...account.projects] };
821
+ return { kind: "person", account: who.name, generation: account.generation, scope: row.access, tokenId: row.id, projects: tokenProjects(account.projects, row.projects) };
810
822
  },
811
823
  run: async () => options.cliRunner ?? (await import("./operate.js")).runOperateAs ?? null,
812
824
  modeOf: options.cliModeOf,
@@ -876,6 +888,15 @@ export function createDecisionServer(options) {
876
888
  timer.unref?.();
877
889
  server.on("close", () => clearInterval(timer));
878
890
  }
891
+ {
892
+ const flushBudget = () => { try {
893
+ requestBudget.flush();
894
+ }
895
+ catch { /* the next admit refuses if the database is unwell */ } };
896
+ const timer = setInterval(flushBudget, REQUEST_BUDGET_FLUSH_MS);
897
+ timer.unref?.();
898
+ server.on("close", () => { clearInterval(timer); flushBudget(); });
899
+ }
879
900
  const servedPort = () => {
880
901
  const address = server.address();
881
902
  return typeof address === "object" && address !== null ? address.port : null;
@@ -1237,6 +1258,17 @@ export function createDecisionServer(options) {
1237
1258
  if (!allowedHost(request.headers.host)) {
1238
1259
  return respond(response, 421, "text/html; charset=utf-8", wrongHostPage(wrongHost(request.headers.host)));
1239
1260
  }
1261
+ // A real domain (public-access.ts): HSTS for the public https host reached over HTTPS, and no API token over
1262
+ // plain HTTP from outside this computer and the tailnet — refused before anything reads or checks it.
1263
+ const transport = transportOf({ peer: request.socket.remoteAddress, joinSource: joinSourceOf(request), forwardedProto: request.headers["x-forwarded-proto"], forwarded: request.headers["forwarded"] });
1264
+ const hsts = hstsFor(publicOrigin?.host ?? null, request.headers.host, transport);
1265
+ if (hsts !== null)
1266
+ response.setHeader("Strict-Transport-Security", hsts);
1267
+ const insecure = plainHttpRefusal(transport, hook.pathname, request.headers.authorization);
1268
+ if (insecure !== null) {
1269
+ request.resume();
1270
+ return respond(response, 403, "text/plain; charset=utf-8", insecure);
1271
+ }
1240
1272
  const url = new URL(request.url ?? "/", "http://placeholder");
1241
1273
  // A token in a URL is a token in history, logs, and referers. Refused
1242
1274
  // outright rather than ignored, so nobody learns the habit works.
@@ -1246,6 +1278,9 @@ export function createDecisionServer(options) {
1246
1278
  // The MCP gateway over HTTP (mcp-http.ts): coordinators and people's own API tokens, after the Host check.
1247
1279
  if (url.pathname === "/mcp")
1248
1280
  return mcpHttp(request, response);
1281
+ // MCP sign-in (mcp-oauth.ts): its discovery documents, client registration, consent and tokens.
1282
+ if (await oauthHttp(request, response, url))
1283
+ return;
1249
1284
  // Back from Google's consent screen (v89). The session cookie is SameSite=Strict and stays behind on
1250
1285
  // a return from another site: the visit's one-time state (made by a signed-in approver) is the proof.
1251
1286
  if (url.pathname === GOOGLE_CALLBACK && request.method === "GET")
@@ -1799,9 +1834,9 @@ export function createDecisionServer(options) {
1799
1834
  }
1800
1835
  const read = new Set(["/settings/flows", "/settings/skills", "/settings/knowledge", "/settings", "/settings/learning", "/recipes", "/recipes/run", "/recipes/start", "/recipes/new", "/recipes/edit", "/recipes/from-task", "/recipes/preview", "/recipes/export", "/", "/inbox", "/work", "/projects", "/people", "/ledger", "/ledger/export", "/next", "/board", "/tasks", "/tasks/new", "/runs", "/review", "/done", "/routines", "/menu"]);
1801
1836
  const write = new Set(["/settings/flows/on", "/settings/skills/import", "/settings/skills/change", "/settings/skills/revise", "/settings/knowledge/change", "/settings/knowledge/refresh", "/settings/learning/change", "/recipes/prepare", "/recipes/preview", "/recipes/import", "/recipes/save", "/recipes/launch", "/projects/select", "/tasks/add", "/routines/add"]);
1802
- const task = matchTaskPath(path, request.method === "GET" ? "(/evidence)?$" : "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|next|reopen|steer|accept-proof|accept-revision|reject-revision|route|retry-review|complete|merge|confirm-stopped|stop|resume-arm|resume)$");
1837
+ const task = matchTaskPath(path, request.method === "GET" ? "(/evidence|/live)?$" : "/(hold|unhold|requeue|cancel|scope|approve|plan|plan-edit|next|reopen|steer|accept-proof|accept-revision|reject-revision|route|retry-review|complete|merge|confirm-stopped|stop|resume-arm|resume)$");
1803
1838
  const resource = request.method === "GET"
1804
- ? /^\/(?:r|d)\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(path) || /^\/routines\/[0-9]{1,15}$/.test(path) || path === "/flows" || /^\/flows\/[0-9]{1,15}(\/insights|\/export|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(path) || path === "/flows/new" || /^\/flows\/new\/[a-z-]{1,40}$/.test(path)
1839
+ ? /^\/(?:r|d)\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(path) || /^\/routines\/[0-9]{1,15}$/.test(path) || path === "/flows" || /^\/flows\/[0-9]{1,15}(\/insights|\/export|\/live|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(path) || path === "/flows/new" || /^\/flows\/new\/[a-z-]{1,40}$/.test(path)
1805
1840
  : /^\/d\/[0-9]{1,15}\/answer$/.test(path) || /^\/routines\/[0-9]{1,15}\/(approve|refresh|pause|resume|run-now)$/.test(path) || path === "/flows/new" || /^\/flows\/new\/[a-z-]{1,40}$/.test(path) || path === "/flows/import" || /^\/flows\/[0-9]{1,15}\/(save|cards|archive|scripts)$/.test(path) || /^\/flows\/[0-9]{1,15}\/cards\/[0-9]{1,15}\/(move|decide|cancel|comment|assign|watch)$/.test(path) || /^\/flows\/[0-9]{1,15}\/triggers(\/[0-9]{1,15}\/(pause|resume|remove|check|press|renew|secret|share|unshare))?$/.test(path) || /^\/r\/[0-9]{1,15}\/(note|comment|revise|draft-repair|checks|add-tests)$/.test(path);
1806
1841
  if (!(request.method === "GET" ? read : write).has(path) && task === null && !resource) {
1807
1842
  refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects");
@@ -1859,6 +1894,10 @@ export function createDecisionServer(options) {
1859
1894
  }
1860
1895
  project = wanted;
1861
1896
  }
1897
+ // Every page's Tasks count covers this one project view, whatever project the page itself shows.
1898
+ const facts = requestContext.getStore();
1899
+ if (facts !== undefined)
1900
+ facts.lens = project;
1862
1901
  // Exact result links carry their own read context. Check the stored
1863
1902
  // placement against both account and instance access before using it;
1864
1903
  // viewing a result never changes the session's selected project, and
@@ -1943,7 +1982,7 @@ export function createDecisionServer(options) {
1943
1982
  url.pathname !== "/menu" &&
1944
1983
  url.pathname !== "/recipes" &&
1945
1984
  // A flow names its own project; the list spans every project.
1946
- url.pathname !== "/flows" && url.pathname !== "/flows/import" && !/^\/flows\/[0-9]{1,15}(\/insights|\/export|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(url.pathname) &&
1985
+ url.pathname !== "/flows" && url.pathname !== "/flows/import" && !/^\/flows\/[0-9]{1,15}(\/insights|\/export|\/live|\/runs\/[0-9]{1,15}\/[0-9]{1,15})?$/.test(url.pathname) &&
1947
1986
  // The gallery (Flows → New) asks for its project on each template's page.
1948
1987
  url.pathname !== "/flows/new" && !/^\/flows\/new\/[a-z-]{1,40}$/.test(url.pathname) &&
1949
1988
  // Teammates (v92) name their own project, like flows.
@@ -1951,7 +1990,7 @@ export function createDecisionServer(options) {
1951
1990
  // New work names its project in the form (a dropdown of known
1952
1991
  // projects); /tasks/add still admits the posted repo on its own.
1953
1992
  url.pathname !== "/tasks/new" && url.pathname !== "/tasks/add" &&
1954
- !/^\/t\/[^/]+(\/evidence)?$/.test(url.pathname) &&
1993
+ !/^\/t\/[^/]+(\/evidence|\/live)?$/.test(url.pathname) &&
1955
1994
  !/^\/r\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(url.pathname) &&
1956
1995
  !url.pathname.startsWith("/d/") && !url.pathname.startsWith("/contest/") &&
1957
1996
  url.pathname !== "/projects" &&
@@ -2244,6 +2283,8 @@ export function createDecisionServer(options) {
2244
2283
  const docked = projectThread === null ? null : dockedConversation(who, null, projectThread, now, `/work?project=${encodeURIComponent(projectThread)}`);
2245
2284
  if (docked !== null)
2246
2285
  page.workspace = { ...page.workspace, conversation: docked, pageHtml: page.body };
2286
+ // Without a docked chat the list reads itself: 10 s while any of its tasks is building, else 30 s.
2287
+ page.refreshSeconds = work.totals.running > 0 ? 10 : 30;
2247
2288
  return sendScreen(response, 200, page);
2248
2289
  }
2249
2290
  if (url.pathname === "/next") {
@@ -2752,11 +2793,26 @@ export function createDecisionServer(options) {
2752
2793
  return sendScreen(response, 200, screen("mode", [`<h1>Operating mode</h1><p>Authorize this project's automatic approvals once, for up to ${MODE_MAX_DAYS} days. Select the touchpoints below; the signature covers the exact choices. Existing modes keep their original terms.</p><p class="meta">Scope changes, agent questions, and requested revisions still require your attention. Automatic merging also needs a publication grant.</p>`, current, signForm].join("\n"), { chrome: chromeFor(project, "mode") }));
2753
2794
  }
2754
2795
  if (url.pathname === "/people") {
2796
+ // People → a person: their API tokens and remote activity (people-audit-ui.ts). An approver may open anyone, within
2797
+ // their projects; anyone else only themselves. Another person reads exactly like nobody.
2798
+ const person = url.searchParams.get("person");
2799
+ if (person !== null) {
2800
+ const token = (url.searchParams.get("token-name") ?? "").slice(0, 60) || null, before = url.searchParams.get("before");
2801
+ const cursor = before === null ? null : auditCursorOf(before);
2802
+ if (before !== null && cursor === null)
2803
+ return refuse(response, who, 400, "Invalid activity cursor.", "/people");
2804
+ const page = who.role === "approver" || person === who.name
2805
+ ? remoteAudit(store, { self: who.name, everyone: who.role === "approver", repos: admissionList(), unplaced: store.isInstanceOperator(who.name) }, { person, token, source: null, since: null }, { before: cursor })
2806
+ : null;
2807
+ if (page === null || !page.ok)
2808
+ return refuse(response, who, 404, "No such person.", "/people");
2809
+ return sendScreen(response, 200, screen("people", personAuditHtml({ name: person, tokens: store.apiTokens(person), actions: page.actions, token, nextCursor: page.nextCursor, now }), { chrome: chromeFor(project, "people") }));
2810
+ }
2755
2811
  // Approvers see everyone (D7's ceiling: every fact already passed
2756
2812
  // the process admission); a viewer sees exactly themselves (U3).
2757
2813
  if (restricted()) {
2758
2814
  const projects = admissionList() ?? [];
2759
- return sendScreen(response, 200, screen("people", `<h1>Your access</h1><p>${personChip(who.name)} · ${who.role === "approver" ? "operator" : "viewer"}</p><p>${who.role === "approver" ? "You can create, manage, and approve work in these projects." : "You can read work in these projects."}</p><ul>${projects.map(repo => `<li>${escape(projectName(repo))}</li>`).join("")}</ul><p class="meta">An instance operator manages invitations and project access.</p>`, { chrome: chromeFor(project, "people") }));
2815
+ return sendScreen(response, 200, screen("people", `<h1>Your access</h1><p>${personChip(who.name)} · ${who.role === "approver" ? "operator" : "viewer"}</p><p>${who.role === "approver" ? "You can create, manage, and approve work in these projects." : "You can read work in these projects."}</p><ul>${projects.map(repo => `<li>${escape(projectName(repo))}</li>`).join("")}</ul><p class="meta">An instance operator manages invitations and project access.</p><p><a href="${escape(personHref(who.name))}">Your remote activity</a></p>`, { chrome: chromeFor(project, "people") }));
2760
2816
  }
2761
2817
  const approverView = store.isInstanceOperator(who.name);
2762
2818
  const accounts = store.accountFacts().filter(one => approverView || one.name === who.name);
@@ -2787,6 +2843,7 @@ export function createDecisionServer(options) {
2787
2843
  `<p class="meta">${one.projects === null ? "All projects" : one.projects.length === 0 ? "No project access" : one.projects.map(repo => escape(projectName(repo))).join(", ")}</p>`,
2788
2844
  approverView && one.revokedAt === null ? `<details><summary>Edit project access</summary><form method="post" action="/people/projects">${hiddenFields({ csrf, name: one.name })}${accessFields(one.projects)}<label>Your password<input type="password" name="token" autocomplete="current-password" required></label><p class="meta">Changing access signs this person out and ends their derived sessions and modes. Previously approved work stays recorded.</p><button type="submit">Save project access</button></form></details>` : "",
2789
2845
  `<p class="meta">${seen === null ? "not signed in right now" : `signed in \u2014 active ${escape(new Date(seen).toISOString().slice(11, 16))} UTC`} \u00b7 joined ${escape(one.addedAt.slice(0, 10))}</p>`,
2846
+ `<p><a href="${escape(personHref(one.name))}">Tokens and remote activity</a></p>`,
2790
2847
  attended.length === 0
2791
2848
  ? ""
2792
2849
  : `<p class="row">watching now: ${attended.map(session => `<a href="/t/${escape(session.taskId)}">${escape(session.taskId)}</a>`).join(", ")}</p>`,
@@ -3009,6 +3066,26 @@ export function createDecisionServer(options) {
3009
3066
  const chrome = chromeFor(project, "tasks");
3010
3067
  return sendScreen(response, 200, newTaskPage(chrome, project, csrf, revision, null, chainable, store.permissionDefault().mode, store.qualityDefault().mode, chrome.projects ?? []));
3011
3068
  }
3069
+ const taskLive = matchTaskPath(url.pathname, "/live$");
3070
+ if (taskLive !== null) {
3071
+ // The live task page: a nudge the moment the task changes, and who else
3072
+ // has it open. Hints only — the page reads the task the usual way.
3073
+ if (who.via !== "cookie")
3074
+ return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
3075
+ const family = familyOf(taskLive.taskId);
3076
+ if (family === null || family.problem !== null)
3077
+ return respond(response, 404, "application/json", JSON.stringify({ error: "task" }));
3078
+ response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", "x-content-type-options": "nosniff", "x-accel-buffering": "no" });
3079
+ const name = who.name, repo = family.root.repo;
3080
+ taskRooms.join(family.root.id, {
3081
+ name, response,
3082
+ // Rechecked while open, from the cookie and the account (never a request's context): a sign-out or a narrowed account stops it.
3083
+ valid: () => { const again = identify(request, false); return again !== null && again.name === name && rowVisible(liveCeiling(), repo) && store.accountCanAccess(name, repo); },
3084
+ });
3085
+ request.once("close", () => { if (!response.writableEnded)
3086
+ response.end(); });
3087
+ return;
3088
+ }
3012
3089
  const task = matchTaskPath(url.pathname, "");
3013
3090
  if (task !== null) {
3014
3091
  const family = familyOf(task.taskId);
@@ -3065,7 +3142,7 @@ export function createDecisionServer(options) {
3065
3142
  if (tokenId === null)
3066
3143
  return true;
3067
3144
  const token = store.apiTokenSecret(tokenId)?.row;
3068
- return token !== undefined && token.revokedAt === null && Date.parse(token.expiresAt) > Date.now();
3145
+ return token !== undefined && tokenLive(token, Date.now());
3069
3146
  };
3070
3147
  // The whole walk it starts from is shared by exports in the same minute; a reader who stops reading for a minute is let go.
3071
3148
  const pieces = ledgerExportChunks(store, { from, to }, { repos: access.repos, instance: store.isInstanceOperator(who.name) }, access, who.name, now, evidenceRoot);
@@ -3793,7 +3870,8 @@ export function createDecisionServer(options) {
3793
3870
  // Console v2: the landing's live view (who is working, four counts, Catch up in tabs).
3794
3871
  const home = focusTask === null && chatProject === null && !roomId ? chatHomeOf(who, repos, now) : null;
3795
3872
  const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
3796
- : { ...shown, workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
3873
+ : { ...shown, ...(home === null ? {} : { refreshSeconds: liveRefreshSeconds() }),
3874
+ workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
3797
3875
  if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
3798
3876
  {
3799
3877
  const said = takeMateNote(who.session.csrf, mateSession.id);
@@ -4034,7 +4112,8 @@ export function createDecisionServer(options) {
4034
4112
  const everyone = url.searchParams.get("everyone") === "1" && store.isInstanceOperator(who.name);
4035
4113
  const here = sessions.hashOf(who.session);
4036
4114
  const view = { who: who.name, everyone, canSeeEveryone: store.isInstanceOperator(who.name), now: Date.now(),
4037
- sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name) };
4115
+ sessions: store.webSessions(everyone ? null : who.name).map(one => ({ ...one, here: one.idHash === here })), tokens: store.apiTokens(everyone ? null : who.name),
4116
+ limits: { note: tokenLimitWords.bind(null, store.handle), section: store.isInstanceOperator(who.name) ? requestLimitsHtml(store.handle, store.apiTokens(null), who.session.csrf, Date.now()) : "" } };
4038
4117
  return sendScreen(response, 200, screen("Sessions & tokens", `<p><a href="/settings">Settings</a></p><h1>Sessions &amp; tokens</h1>${credentialsHtml(view, who.session.csrf, { said: url.searchParams.get("said"), problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
4039
4118
  }
4040
4119
  // Settings → Flows: the starter flows, each switched on with one yes. ?starter= marks the one a task's
@@ -5124,17 +5203,24 @@ export function createDecisionServer(options) {
5124
5203
  catch {
5125
5204
  notices.push('Crew updates are unavailable. Open Tasks to inspect saved work.');
5126
5205
  }
5127
- // "Wake me only for these": the navigation carries the count of tasks
5128
- // waiting on a person behind its Tasks link — the open project's, or
5129
- // everything this person may see — so it matches that page's Needs you tab.
5130
- let needsYou = 0;
5206
+ // A running row says what its agent did last, and when (the task page's own line).
5131
5207
  try {
5132
- needsYou = (requestFacts.workCounts ?? workCountsByProject(store, clock(), workAccess()))
5133
- .filter(one => s.chrome?.project == null || one.repo === s.chrome.project).reduce((sum, one) => sum + one.totals['needs-you'], 0);
5134
- }
5135
- catch {
5136
- needsYou = 0;
5137
- }
5208
+ const now = clock(), live = new Map();
5209
+ if (crew.crew.length > 0)
5210
+ for (const run of store.liveRuns(now)) {
5211
+ const root = familyOf(run.taskId)?.root.id;
5212
+ if (root !== undefined && !live.has(root))
5213
+ live.set(root, runActivityOf(store, run, now));
5214
+ }
5215
+ if (live.size > 0)
5216
+ crew = { ...crew, crew: crew.crew.map(item => { const activity = live.get(item.id); return activity === undefined ? item : { ...item, activity }; }) };
5217
+ }
5218
+ catch { /* the rows keep their status; the line returns on the next read */ }
5219
+ // "Wake me only for these": the navigation carries the sidebar's own
5220
+ // Tasks count (chromeFor) and links to the project view it covers, so
5221
+ // it matches that page's Needs you tab on every screen.
5222
+ const needsYou = s.chrome.inboxCount;
5223
+ const tasksProject = s.chrome.inboxProject === undefined ? s.chrome.project : s.chrome.inboxProject;
5138
5224
  // The person's own project and task conversations (v77), for the
5139
5225
  // sidebar's chat list; a thread whose project is out of view is left out.
5140
5226
  let chats = [];
@@ -5170,7 +5256,7 @@ export function createDecisionServer(options) {
5170
5256
  catchUpHtml: extras.catchUpHtml ?? '', controlsHtml: extras.controlsHtml ?? '', notices, view: extras.view ?? null,
5171
5257
  ...(s.chrome.demo ? { demo: { text: DEMO_BANNER, short: DEMO_BANNER_SHORT } } : {}),
5172
5258
  pageHtml: extras.pageHtml === undefined ? (conversation === null ? pageHtml : null) : extras.pageHtml,
5173
- navigation: [...browserNavigationOf(currentPath, s.chrome.project, needsYou), { label: 'Workspace tools', href: '/menu', active: path.pathname === '/menu' }],
5259
+ navigation: [...browserNavigationOf(currentPath, s.chrome.project, needsYou, tasksProject, s.chrome.inboxLabel), { label: 'Workspace tools', href: '/menu', active: path.pathname === '/menu' }],
5174
5260
  chats,
5175
5261
  ...(s.refreshSeconds === undefined ? {} : { refreshSeconds: Math.max(5, Math.floor(s.refreshSeconds)) }),
5176
5262
  ...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
@@ -5225,12 +5311,6 @@ export function createDecisionServer(options) {
5225
5311
  // functional script polls.
5226
5312
  return page(response, status, html, nonce, s.functional?.fetches === true || chromeLayer || sensitiveChrome !== "");
5227
5313
  }
5228
- /** The badge cache: five seconds per project — mutations invalidate it. */
5229
- const badgeCache = new Map();
5230
- /** The needs-you count every surface wears for a project (workspace
5231
- * package 1): Work's own Needs-you membership, cached per viewer and
5232
- * project, so the rail, the phone tab, the scope bar, the project cards,
5233
- * the chat overview, and the Needs-you tab never disagree. */
5234
5314
  /** The Chat landing's live view: each agent at work now with its task and
5235
5315
  * phase, four counts, plan-window use (never dollars: subscriptions don't
5236
5316
  * bill per run) and Catch up's items, each tagged with its tab. Reads only. */
@@ -5240,7 +5320,8 @@ export function createDecisionServer(options) {
5240
5320
  const agents = store.liveRuns(now).filter(run => admitted(run.repo)).map(run => {
5241
5321
  const root = familyOf(run.taskId)?.root ?? null;
5242
5322
  return { runId: run.id, taskId: root?.id ?? run.taskId, title: root?.title ?? run.title, href: taskHref(root?.id ?? run.taskId),
5243
- agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt };
5323
+ agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt,
5324
+ activity: runActivityOf(store, run, now) };
5244
5325
  });
5245
5326
  const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT });
5246
5327
  const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT });
@@ -5274,19 +5355,13 @@ export function createDecisionServer(options) {
5274
5355
  planUse: (windows?.tiles ?? []).map(one => ({ name: one.name, window: one.window, percent: one.percent, detail: one.detail, tone: one.tone })),
5275
5356
  };
5276
5357
  }
5358
+ /** The needs-you count every surface wears, read from this request's own
5359
+ * Work counts (one query per request, never a cached earlier one), so a
5360
+ * live page's badge and its Needs you tab always agree. */
5277
5361
  function needsYouBadge(project) {
5278
- const actor = requestContext.getStore()?.actor;
5279
- const key = `${actor ?? ""}:${actor === undefined ? "" : store.accountOf(actor)?.generation}:${project ?? ""}`;
5280
- const cached = badgeCache.get(key);
5281
- if (cached !== undefined && Date.now() - cached.at <= 5_000)
5282
- return cached;
5283
- const counted = needsYouCount(project);
5284
- const badge = { at: Date.now(), count: counted.count, saturated: counted.saturated };
5285
- badgeCache.set(key, badge);
5286
- return badge;
5362
+ return needsYouCount(project);
5287
5363
  }
5288
5364
  const bustBadge = () => {
5289
- badgeCache.clear();
5290
5365
  paletteCache.clear();
5291
5366
  };
5292
5367
  /** The sidebar's facts for this request. */
@@ -5294,7 +5369,12 @@ export function createDecisionServer(options) {
5294
5369
  if (restricted() && !visible(project))
5295
5370
  project = null;
5296
5371
  const actor = requestContext.getStore()?.actor;
5297
- const badge = needsYouBadge(project);
5372
+ // One Tasks count per request: Needs you in the request's project view
5373
+ // (the open project, else every admitted project), never the page's own
5374
+ // project — a flow or task in another project shows the same number.
5375
+ const lens = requestContext.getStore()?.lens;
5376
+ const inboxProject = lens !== undefined && (!restricted() || visible(lens)) ? lens : project;
5377
+ const badge = needsYouBadge(inboxProject);
5298
5378
  const liveMode = project === null ? null : store.activeMode(project, clock());
5299
5379
  const liveModeTerms = liveMode === null ? null : modeTermsFromJson(liveMode.termsJson);
5300
5380
  let projectPeek = project === null ? null : undefined;
@@ -5334,6 +5414,9 @@ export function createDecisionServer(options) {
5334
5414
  ...(facts === undefined ? {} : { csrf: facts.csrf, returnTo: facts.returnTo }),
5335
5415
  inboxCount: badge.count,
5336
5416
  inboxSaturated: badge.saturated,
5417
+ inboxProject,
5418
+ inboxLabel: needsYouLabelOf(badge.count, inboxProject === null ? null
5419
+ : store.listProjects().find(one => one.path === inboxProject)?.name ?? projectName(inboxProject), badge.saturated),
5337
5420
  settings: true,
5338
5421
  ...(store.isDemo() ? { demo: true } : {}),
5339
5422
  ...(() => { const signIn = signInNotices(store); return signIn.length === 0 ? {} : { signIn }; })(),
@@ -5387,6 +5470,10 @@ export function createDecisionServer(options) {
5387
5470
  .reduce((sum, one) => sum + one.totals['needs-you'], 0);
5388
5471
  return { count, saturated: false };
5389
5472
  }
5473
+ /** A live page's own beat without a docked chat: 10 s while any admitted task is building, else 30 s. */
5474
+ function liveRefreshSeconds() {
5475
+ return projectCounts(clock()).some(one => one.totals.running > 0) ? 10 : 30;
5476
+ }
5390
5477
  function projectFamilyPeek(repo, now) {
5391
5478
  const row = projectCounts(now).find(one => one.repo === repo);
5392
5479
  return { waiting: row?.totals['needs-you'] ?? 0, running: row?.totals.running ?? 0,
@@ -6190,6 +6277,9 @@ export function createDecisionServer(options) {
6190
6277
  // What went wrong reads the family's latest finished attempt, as the Tasks row does.
6191
6278
  presentedData.failure = failureOf(presentedData.family?.runs ?? data.runs, data.assignment ?? null);
6192
6279
  presentedData.runChecks = runChecksHere(data.assignment?.receipt?.runId ?? null, who, taskHref(taskId));
6280
+ const liveRun = data.liveRunId == null ? undefined : data.runs.find(one => one.id === data.liveRunId);
6281
+ if (liveRun !== undefined)
6282
+ presentedData.activity = runActivityOf(store, liveRun, clock());
6193
6283
  presentedData.demo = store.isDemo();
6194
6284
  const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
6195
6285
  const page = taskPage(paneProject === null && !unscopedMode
@@ -6200,11 +6290,19 @@ export function createDecisionServer(options) {
6200
6290
  const docked = focus === null ? null : dockedConversation(who, focus, null, clock(), taskHref(focus.id));
6201
6291
  // The conversation is the page's own thread here, so the page drops its Ask tab;
6202
6292
  // only an approver is offered a road to message the agent.
6203
- const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, ...(docked === null ? {} : { tabs: [] }), ...(who.role === "approver" ? {} : { chatHref: null }) } : page.workspace?.view;
6293
+ const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, ...(docked === null ? {} : { tabs: [] }), ...(who.role === "approver" ? {} : { chatHref: null }),
6294
+ // A signed-in page hears the moment the task changes, and who else has it open.
6295
+ ...(who.via === "cookie" ? { live: { href: `${taskHref(family?.root.id ?? taskId)}/live`, at: (() => { try {
6296
+ return taskFingerprint(store, family?.root.id ?? taskId, clock());
6297
+ }
6298
+ catch {
6299
+ return null;
6300
+ } })() } } : {}) } : page.workspace?.view;
6204
6301
  if (docked !== null)
6205
6302
  page.workspace = { ...page.workspace, ...(taskView === undefined ? {} : { view: taskView }), conversation: docked, pageHtml: page.body };
6206
6303
  else if (taskView !== undefined && page.workspace !== undefined)
6207
6304
  page.workspace = { ...page.workspace, view: taskView };
6305
+ page.refreshSeconds = liveRefreshSeconds();
6208
6306
  return sendScreen(response, status, page);
6209
6307
  }
6210
6308
  /** The routine screen: the standing order restated, its verbs, its ledger. */
@@ -7472,6 +7570,31 @@ export function createDecisionServer(options) {
7472
7570
  store.setApprovalRules(repo, next, who.name, now);
7473
7571
  return back("said", `Saved. ${rulesSummary(next)}`);
7474
7572
  }
7573
+ // v112: request limits for API tokens (request-budget.ts). An instance operator, in a browser, with their password;
7574
+ // never over /api/cli or /mcp (a token never reaches a console form).
7575
+ if (url.pathname === "/settings/request-limits") {
7576
+ const body = readForm(posted, CONSOLE_FORMS.requestLimits);
7577
+ if (who.via !== "cookie" || !store.isInstanceOperator(who.name))
7578
+ return refuse(response, who, 403, "An instance operator sets request limits.", "/settings/sessions");
7579
+ const back = (key, words) => redirect(response, `/settings/sessions?${key}=${encodeURIComponent(words)}`);
7580
+ const target = body.get("target") ?? "";
7581
+ const token = target === "*" ? null : store.apiTokens(null).find(one => one.id === target && one.revokedAt === null) ?? null;
7582
+ if (target !== "*" && token === null)
7583
+ return back("problem", "That token is no longer live. Nothing changed.");
7584
+ if (!authenticateApprover(store, who.name, body.get("password") ?? "").ok)
7585
+ return back("problem", "Enter your Toolroll password to change request limits.");
7586
+ const read = (name) => parseLimit(body.get(name) ?? "", name === "per-day" ? REQUEST_BUDGET_PER_DAY_MAX : REQUEST_BUDGET_PER_MINUTE_MAX);
7587
+ const clear = body.get("action") === "clear";
7588
+ const fields = clear ? { readPerMinute: null, actPerMinute: null, perDay: null }
7589
+ : token === null ? { readPerMinute: read("read-per-minute"), actPerMinute: read("act-per-minute"), perDay: read("per-day") }
7590
+ : { readPerMinute: token.access === "read" ? read("per-minute") : null, actPerMinute: token.access === "act" ? read("per-minute") : null, perDay: read("per-day") };
7591
+ const problem = Object.values(fields).find((value) => value !== null && typeof value === "object");
7592
+ if (problem !== undefined)
7593
+ return back("problem", `${problem.problem} Nothing changed.`);
7594
+ setLimitOverride(store, target, fields, who.name, now);
7595
+ const label = token === null ? "Everyone's tokens" : token.name;
7596
+ return back("said", clear || Object.values(fields).every(value => value === null) ? `${label}: default limits.` : `${label}: limits saved.`);
7597
+ }
7475
7598
  if (url.pathname === "/settings/sessions") {
7476
7599
  const body = readForm(posted, CONSOLE_FORMS.sessions);
7477
7600
  if (who.via !== "cookie")
@@ -11780,10 +11903,32 @@ export function createDecisionServer(options) {
11780
11903
  approved: (() => { const scope = store.getScope(taskId); return scope !== null && approvalOf(scope).approved; })(),
11781
11904
  }));
11782
11905
  }
11783
- const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request) !== null,
11906
+ const mcpHttp = createMcpHttp({ store, clock, evidenceRoot, signedIn: request => identify(request, true, true) !== null, admit: person => requestBudget.admit(person.principal.tokenId, "mcp"),
11907
+ resourceMetadata: request => { const origin = consoleOrigin(request.headers.host); return origin === null ? null : resourceMetadataUrl(origin); },
11784
11908
  enrolled: () => [...new Set([...managedRepos(), ...store.listProjects().map(project => project.path)])], ...(options.runOperateAs === undefined ? {} : { runAs: options.runOperateAs }) });
11909
+ // MCP sign-in: the console's own sign-in and step-up, a same-site consent form, and the person's current projects.
11910
+ const oauthHttp = createOAuthHttp({ store, clock,
11911
+ originOf: request => consoleOrigin(request.headers.host),
11912
+ requesterKey: joinSourceOf,
11913
+ session: request => {
11914
+ const who = identify(request, false);
11915
+ if (who === null || who.via !== "cookie")
11916
+ return null;
11917
+ return { name: who.name, role: who.role, csrf: who.session.csrf,
11918
+ sso: who.session.sso === undefined ? null : { label: ssoSettings()?.label ?? "your identity provider", fresh: Date.now() - who.session.sso.at < SSO_FRESH_MS } };
11919
+ },
11920
+ sameSite: request => {
11921
+ const origin = request.headers.origin, referer = request.headers.referer;
11922
+ const named = typeof origin === "string" && origin !== "null" ? origin : typeof referer === "string" ? referer : null;
11923
+ return named !== null && allowedHost(named.replace(/^https?:\/\//, "").split("/")[0]);
11924
+ },
11925
+ // The step-up for a credential: the person's password, or (empty) a fresh identity-provider check. What they may
11926
+ // grant (their role, their projects) is checked beside it.
11927
+ confirm: (session, typed) => typed === "" ? session.sso?.fresh === true : authenticateAccount(store, session.name, typed).ok,
11928
+ projectsFor: account => store.knownRepos().filter(repo => store.accountCanAccess(account, repo)).sort(),
11929
+ });
11785
11930
  // ---- identity ------------------------------------------------------------
11786
- function identify(request, touch = true) {
11931
+ function identify(request, touch = true, limited = false) {
11787
11932
  // v101: an API token. Wrong ones spend the address's tries like a wrong password; expired or revoked ones, and a removed account's, name no one.
11788
11933
  const presented = /^Bearer (so_\S+)$/.exec(request.headers.authorization ?? "")?.[1];
11789
11934
  if (presented !== undefined) {
@@ -11796,11 +11941,18 @@ export function createDecisionServer(options) {
11796
11941
  signInBudget.failed(source, at);
11797
11942
  return null;
11798
11943
  }
11799
- if (kept.row.revokedAt !== null || Date.parse(kept.row.expiresAt) <= at)
11944
+ if (!tokenLive(kept.row, at))
11945
+ return null;
11946
+ // v111: a token limited to some projects signs in only where its limit travels with it (the remote CLI and MCP,
11947
+ // through Principal.projects); the console's own pages and APIs check the account's access alone, so it is refused there.
11948
+ if (kept.row.projects !== null && !limited)
11800
11949
  return null;
11801
11950
  const account = store.accountOf(kept.row.account);
11802
11951
  if (account === null || account.revokedAt !== null)
11803
11952
  return null;
11953
+ // v113: an MCP sign-in's token works only at /mcp, while its access secret is fresh (mcp-oauth.ts).
11954
+ if (!oauthTokenAllowed(store, kept.row.id, new URL(request.url ?? "/", "http://placeholder").pathname, new Date(at)))
11955
+ return null;
11804
11956
  store.touchApiToken(kept.row.id, new Date(at));
11805
11957
  return { name: kept.row.account, via: "bearer", role: kept.row.access === "read" ? "viewer" : account.role, token: kept.row.name };
11806
11958
  }
@@ -12395,6 +12547,10 @@ export function createDecisionServer(options) {
12395
12547
  const closeServer = server.close.bind(server);
12396
12548
  server.close = ((callback) => {
12397
12549
  leadClosing = true;
12550
+ try {
12551
+ requestBudget.flush();
12552
+ }
12553
+ catch { /* saved again on the close event when it can be */ }
12398
12554
  for (const stream of teamStreams)
12399
12555
  stream.end();
12400
12556
  teamStreams.clear();
@@ -12402,6 +12558,7 @@ export function createDecisionServer(options) {
12402
12558
  stream.end();
12403
12559
  chatStreams.clear();
12404
12560
  flowRooms.close();
12561
+ taskRooms.close();
12405
12562
  void Promise.all([closeCoding(), bounded(team.close()), bounded(leadMaintenance?.stop())]).then(() => closeServer(callback)).catch(error => {
12406
12563
  if (callback)
12407
12564
  callback(error instanceof Error ? error : Error('Coding session shutdown failed.'));
@@ -16304,7 +16461,7 @@ const INBOX_TABS_CSS = '.inbox-tabs{display:inline-flex;gap:2px;max-width:100%;o
16304
16461
  '.inbox-ask{margin:18px 0 0}.inbox-ask>h2{display:flex;align-items:baseline;gap:8px;margin:0 0 4px;font-size:15px;font-weight:600}.inbox-ask>h2 .count{font:500 12px var(--so-mono,ui-monospace,monospace);font-variant-numeric:tabular-nums;color:var(--so-muted)}.inbox-ask h3{font-size:13px;font-weight:600;margin:12px 0 4px}' +
16305
16462
  '.inbox-unread{display:none;position:absolute;top:4px;right:3px;width:6px;height:6px;border-radius:50%;background:var(--so-signal)}' +
16306
16463
  '@media (max-width:760px){.inbox-tabs{display:flex;width:100%}.inbox-tabs a{flex:1;justify-content:center;min-height:44px;padding:0 6px}.inbox-unread{display:block}}';
16307
- const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_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 + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_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}');
16464
+ const WORKSPACE_STYLE = styleAsset(STYLE + BRAND_MARK_CSS + INBOX_TABS_CSS + APPROVAL_RULES_CSS + SPEND_CSS + RETENTION_CSS + STORAGE_CSS + UPDATES_CSS + LIMITS_CSS + MONITORING_CSS + INTEGRATIONS_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 + STARTERS_CSS + GALLERY_CSS + SSO_CSS + CREDENTIALS_CSS + REQUEST_LIMITS_CSS + PEOPLE_AUDIT_CSS + KNOWLEDGE_CSS + MODELS_CSS + CHAT_POLISH_CSS + TRANSITIONS_CSS + WORKSPACE_MOTION_CSS + ASSIGNMENT_CSS + TASK_STATUS_CSS + LEAD_CONTEXT_CSS + PULL_REQUEST_SETTINGS_CSS + CHECK_SETTINGS_CSS + DISCLOSURE_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}');
16308
16465
  /**
16309
16466
  * The board's liveness: fetch this page's own fragment on a timer and swap
16310
16467
  * it in place — no flicker, no scroll reset, no long-lived stream to manage,
@@ -16674,9 +16831,9 @@ function shell(title, body, options = {}) {
16674
16831
  // destination it now lives under. The count rides Work: it is the
16675
16832
  // saturated needs-you count, exactly as the inbox row wore it.
16676
16833
  const primary = chrome.active === "code" ? "work" : primaryDestinationOf(chrome.active);
16677
- const primaryItem = (key, href, label, count) => `<a href="${href}" aria-label="${escape(label)}" title="${escape(label)}"${primary === key ? ' class="active" aria-current="page"' : ""}${key === "work" && count !== undefined ? ` data-waiting="${count}"` : ""}>` +
16834
+ const primaryItem = (key, href, label, count) => `<a href="${href}" aria-label="${escape(key === "work" && count !== undefined && count > 0 && chrome.inboxLabel !== undefined ? `${label}, ${chrome.inboxLabel}` : label)}" title="${escape(label)}"${primary === key ? ' class="active" aria-current="page"' : ""}${key === "work" && count !== undefined ? ` data-waiting="${count}"` : ""}>` +
16678
16835
  `<span class="glyph">${NAV_ICONS[key] ?? ""}</span>${label}` +
16679
- `${count !== undefined && count > 0 ? ` <span class="count badge badge-open">${count}${chrome.inboxSaturated ? "+" : ""}</span>` : ""}</a>`;
16836
+ `${count !== undefined && count > 0 ? ` <span${chrome.inboxLabel === undefined ? "" : ` aria-label="${escape(chrome.inboxLabel)}" title="${escape(chrome.inboxLabel)}"`} class="count badge badge-open">${count}${chrome.inboxSaturated ? "+" : ""}</span>` : ""}</a>`;
16680
16837
  const side = [
16681
16838
  `<aside class="side">`,
16682
16839
  `<div class="side-head"><a class="brand" href="${chrome.chat === true ? "/chat" : "/work"}"><span class="brand-long">Toolroll</span><span class="brand-short">T</span></a>`,
@@ -16762,7 +16919,7 @@ function shell(title, body, options = {}) {
16762
16919
  // A phone tab says THAT something waits, with a dot; the number is on
16763
16920
  // the Work views themselves (Linear Mobile's rule — one tap away).
16764
16921
  `<a href="${href}"${primary === key ? ' class="active" aria-current="page"' : ""}><span class="glyph">${TAB_ICONS[key]}</span>${label}` +
16765
- `${count !== undefined && count > 0 ? `<span class="dot-badge" role="img" aria-label="${count} waiting"></span>` : ""}</a>`;
16922
+ `${count !== undefined && count > 0 ? `<span class="dot-badge" role="img" aria-label="${escape(chrome.inboxLabel ?? `${count} waiting`)}"></span>` : ""}</a>`;
16766
16923
  // Three primary tabs, the same three as the rail: chat where allowed,
16767
16924
  // work, projects. Tools and settings sit behind the header's menu action.
16768
16925
  const tabbar = [
@@ -20132,7 +20289,7 @@ function safeReturn(raw) {
20132
20289
  * button can name. Nothing here is a second redirect framework: one
20133
20290
  * same-site path, or "/".
20134
20291
  */
20135
- const LOGIN_RETURN_PAGES = /^\/(t\/[^/]+|r\/[1-9]\d*|code(?:\/[a-f0-9]{32}(?:\/ship)?)?|review|chat|work|board|projects|routines|recipes|fleet|settings(\/[a-z-]+)?|mode)$/;
20292
+ const LOGIN_RETURN_PAGES = /^\/(oauth\/authorize|t\/[^/]+|r\/[1-9]\d*|code(?:\/[a-f0-9]{32}(?:\/ship)?)?|review|chat|work|board|projects|routines|recipes|fleet|settings(\/[a-z-]+)?|mode)$/;
20136
20293
  function loginReturn(raw) {
20137
20294
  const safe = safeReturn(raw);
20138
20295
  if (safe === "/")
@@ -21930,6 +22087,8 @@ function taskBodyParts(data) {
21930
22087
  retry,
21931
22088
  runChecks,
21932
22089
  progress,
22090
+ // One quiet line under the step: what the agent did last, and when.
22091
+ activity: liveRun === undefined ? null : data.activity ?? null,
21933
22092
  stop,
21934
22093
  record,
21935
22094
  earlier,
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
+ content?: never;
18
19
  ok: false;
19
20
  message: string;
20
21
  plan?: never;
21
- content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
+ content?: never;
33
34
  ok: false;
34
35
  message: string;
35
36
  plan?: never;
36
- content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;