toolroll 0.9.46 → 0.9.48

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 (52) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/activity-line.d.ts +26 -0
  3. package/dist/activity-line.js +47 -0
  4. package/dist/browser/workspace.js +25 -25
  5. package/dist/browser-workspace.d.ts +18 -1
  6. package/dist/browser-workspace.js +9 -3
  7. package/dist/builder.js +4 -2
  8. package/dist/cli.d.ts +1 -1
  9. package/dist/cli.js +4 -0
  10. package/dist/contracts/cli.d.ts +87 -0
  11. package/dist/contracts/cli.js +2 -0
  12. package/dist/contracts/health.d.ts +84 -0
  13. package/dist/contracts/health.js +11 -0
  14. package/dist/flow-card-task.d.ts +2 -0
  15. package/dist/flow-card-task.js +57 -0
  16. package/dist/flow-live.d.ts +7 -15
  17. package/dist/flow-live.js +77 -115
  18. package/dist/flows-ui.js +15 -1
  19. package/dist/health-cli.d.ts +3 -0
  20. package/dist/health-cli.js +79 -0
  21. package/dist/live-bus.d.ts +89 -0
  22. package/dist/live-bus.js +321 -0
  23. package/dist/live.d.ts +14 -0
  24. package/dist/live.js +71 -22
  25. package/dist/metrics.d.ts +3 -0
  26. package/dist/metrics.js +53 -1
  27. package/dist/operate.js +43 -25
  28. package/dist/planner.js +4 -3
  29. package/dist/read-executor-worker.d.ts +1 -0
  30. package/dist/read-executor-worker.js +26 -0
  31. package/dist/read-executor.d.ts +49 -0
  32. package/dist/read-executor.js +119 -0
  33. package/dist/request-budget.d.ts +1 -0
  34. package/dist/request-budget.js +6 -0
  35. package/dist/serve.d.ts +7 -0
  36. package/dist/serve.js +118 -42
  37. package/dist/server-telemetry.d.ts +86 -0
  38. package/dist/server-telemetry.js +263 -0
  39. package/dist/setup-guide.d.ts +2 -2
  40. package/dist/sqlite-telemetry.d.ts +9 -0
  41. package/dist/sqlite-telemetry.js +98 -0
  42. package/dist/store.d.ts +22 -0
  43. package/dist/store.js +59 -3
  44. package/dist/surface.js +2 -1
  45. package/dist/task-activity.d.ts +27 -0
  46. package/dist/task-activity.js +53 -0
  47. package/dist/task-live.d.ts +8 -0
  48. package/dist/task-live.js +47 -0
  49. package/dist/work-index.js +12 -1
  50. package/dist/workspace-revision.d.ts +2 -0
  51. package/dist/workspace-revision.js +3 -1
  52. 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,9 @@ 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 { createLiveBus, followWorkspace } from "./live-bus.js";
48
+ import { createTaskRooms, taskFingerprint } from "./task-live.js";
49
+ import { runActivityOf } from "./task-activity.js";
47
50
  import { disconnectGoogle, finishGoogleConsent, GOOGLE_CALLBACK, googleConnected, googleConsent, readGoogleMail, saveGoogleClient } from "./google-mail.js";
48
51
  import { mailboxAccess, readThroughImap } from "./mailbox.js";
49
52
  import { readEmailSettings, saveEmailSettings, sendingAccount, sendThroughServer, setFlowSecret } from "./flow-actions.js";
@@ -240,6 +243,7 @@ import { createIntegrationMonitor } from "./integrations.js";
240
243
  import { BACKUP_CSS, backupHtml } from "./backup-ui.js";
241
244
  import { BACKUP_EVERY_HOURS, MAX_KEEP, backupFolderOf, backupNow, checkBackupFolder, defaultBackupFolder } from "./backup.js";
242
245
  import { prometheusMetrics } from "./metrics.js";
246
+ import { healthWords, instrumentRequest } from "./server-telemetry.js";
243
247
  import { SPEND_CSS, spendCsv, spendHtml } from "./spend-ui.js";
244
248
  import { RETENTION_CSS, retentionHtml } from "./retention-ui.js";
245
249
  import { STORAGE_CSS, storageHtml } from "./storage-ui.js";
@@ -289,6 +293,7 @@ import { installMethod } from "./install-method.js";
289
293
  import { PACKAGE_VERSION } from "./version.js";
290
294
  import { updateNoticeWords } from "./update-notice.js";
291
295
  import { whenHtml } from "./when-html.js";
296
+ import { ReadExecutor, attachReadExecutor, defaultReadWorkers } from "./read-executor.js";
292
297
  const SESSION_COOKIE = "standing-orders_session";
293
298
  /** Stands in for "no project" in a ceiling: no folder resolves to it, so it admits nothing. */
294
299
  const NO_PROJECT = "/\0no-project";
@@ -458,6 +463,11 @@ export function createDecisionServer(options) {
458
463
  }
459
464
  }
460
465
  const clock = options.clock ?? (() => new Date());
466
+ // Heavy reads (the work index behind `task list`) run on read-only worker connections, off the request loop.
467
+ const readFile = store.isDemo() ? null : store.databaseFile();
468
+ const readWorkers = options.readWorkers ?? (process.env["VITEST"] !== undefined ? 0 : defaultReadWorkers());
469
+ const reads = readFile === null || readWorkers < 1 ? null : new ReadExecutor(readFile, readWorkers);
470
+ const detachReads = reads === null ? () => { } : attachReadExecutor(store, reads);
461
471
  /** Task checkouts, for Settings → Storage: the pool's own root, or the folder beside the database. */
462
472
  const storagePool = (databaseFile) => new WorktreePool(store, { root: options.poolRoot ?? join(dirname(databaseFile), "worktrees") });
463
473
  const install = installMethod(options.installBin);
@@ -748,7 +758,11 @@ export function createDecisionServer(options) {
748
758
  /** One-click connections on their way: the service's sign-in page and back, 15 minutes at most. */
749
759
  const connectVisits = new Map();
750
760
  /** Flows open in a browser (v88): who's here, and a nudge when one changes. */
751
- const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow));
761
+ // Live views push on write (live-bus.ts): the revision triggers move, the rooms hear it.
762
+ const liveBus = createLiveBus();
763
+ const liveFollower = followWorkspace(store, () => workspaceRevision.current(), liveBus, { file: store.databaseFile() });
764
+ const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow), { bus: liveBus });
765
+ const taskRooms = createTaskRooms(root => taskFingerprint(store, root, clock()), { bus: liveBus });
752
766
  const teamChatProvider = () => { const enabled = chatEnablement(); return enabled.ok ? { config: enabled.config, key: enabled.key } : null; };
753
767
  const team = createTeamRuntime({ store, repos: codingProjects, evidenceRoot, clock, workspaceRevision,
754
768
  ...(options.chatFetcher ? { fetcher: options.chatFetcher } : {}),
@@ -838,6 +852,7 @@ export function createDecisionServer(options) {
838
852
  /** The task behind a resource, for the ceiling check; null = no ref (visible). */
839
853
  const taskRepoOf = (taskRef) => store.refForId(taskRef)?.repo ?? null;
840
854
  const server = createServer((request, response) => {
855
+ instrumentRequest(store.telemetry, request, response);
841
856
  void handle(request, response).catch(error => {
842
857
  // Every unhandled error is logged (v99): the path and the message, never the request's body or query.
843
858
  logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
@@ -854,6 +869,8 @@ export function createDecisionServer(options) {
854
869
  }
855
870
  });
856
871
  });
872
+ server.once('listening', () => store.telemetry.start());
873
+ server.once('close', () => store.telemetry.stop());
857
874
  // --public-url (arc 3): validated to EXACTLY an https origin. Its host
858
875
  // joins the allowed set, its origin authorizes POSTs, and cookies turn
859
876
  // Secure. Anything malformed refuses at startup, loudly.
@@ -1831,9 +1848,9 @@ export function createDecisionServer(options) {
1831
1848
  }
1832
1849
  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"]);
1833
1850
  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"]);
1834
- 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)$");
1851
+ 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)$");
1835
1852
  const resource = request.method === "GET"
1836
- ? /^\/(?: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)
1853
+ ? /^\/(?: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)
1837
1854
  : /^\/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);
1838
1855
  if (!(request.method === "GET" ? read : write).has(path) && task === null && !resource) {
1839
1856
  refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects");
@@ -1891,6 +1908,10 @@ export function createDecisionServer(options) {
1891
1908
  }
1892
1909
  project = wanted;
1893
1910
  }
1911
+ // Every page's Tasks count covers this one project view, whatever project the page itself shows.
1912
+ const facts = requestContext.getStore();
1913
+ if (facts !== undefined)
1914
+ facts.lens = project;
1894
1915
  // Exact result links carry their own read context. Check the stored
1895
1916
  // placement against both account and instance access before using it;
1896
1917
  // viewing a result never changes the session's selected project, and
@@ -1975,7 +1996,7 @@ export function createDecisionServer(options) {
1975
1996
  url.pathname !== "/menu" &&
1976
1997
  url.pathname !== "/recipes" &&
1977
1998
  // A flow names its own project; the list spans every project.
1978
- 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) &&
1999
+ 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) &&
1979
2000
  // The gallery (Flows → New) asks for its project on each template's page.
1980
2001
  url.pathname !== "/flows/new" && !/^\/flows\/new\/[a-z-]{1,40}$/.test(url.pathname) &&
1981
2002
  // Teammates (v92) name their own project, like flows.
@@ -1983,7 +2004,7 @@ export function createDecisionServer(options) {
1983
2004
  // New work names its project in the form (a dropdown of known
1984
2005
  // projects); /tasks/add still admits the posted repo on its own.
1985
2006
  url.pathname !== "/tasks/new" && url.pathname !== "/tasks/add" &&
1986
- !/^\/t\/[^/]+(\/evidence)?$/.test(url.pathname) &&
2007
+ !/^\/t\/[^/]+(\/evidence|\/live)?$/.test(url.pathname) &&
1987
2008
  !/^\/r\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(url.pathname) &&
1988
2009
  !url.pathname.startsWith("/d/") && !url.pathname.startsWith("/contest/") &&
1989
2010
  url.pathname !== "/projects" &&
@@ -1993,7 +2014,7 @@ export function createDecisionServer(options) {
1993
2014
  url.pathname !== "/lead/status" && url.pathname !== "/onboarding/phone/dismiss" &&
1994
2015
  !url.pathname.startsWith("/chat/demo/") &&
1995
2016
  !/^\/chat\/action\/[0-9]{1,15}$/.test(url.pathname) &&
1996
- !url.pathname.startsWith("/settings") && url.pathname !== "/logout" && url.pathname !== "/people" && url.pathname !== "/ledger" && url.pathname !== "/ledger/export" && url.pathname !== "/metrics" && url.pathname !== "/spend" && url.pathname !== "/spend/budget" &&
2017
+ !url.pathname.startsWith("/settings") && url.pathname !== "/logout" && url.pathname !== "/people" && url.pathname !== "/ledger" && url.pathname !== "/ledger/export" && url.pathname !== "/metrics" && url.pathname !== "/health" && url.pathname !== "/spend" && url.pathname !== "/spend/budget" &&
1997
2018
  !(url.pathname === "/board" && url.searchParams.get("scope") === "all");
1998
2019
  if (needsProject)
1999
2020
  return redirect(response, `/projects?return=${encodeURIComponent(safeReturn(url.pathname + url.search))}`);
@@ -2276,6 +2297,8 @@ export function createDecisionServer(options) {
2276
2297
  const docked = projectThread === null ? null : dockedConversation(who, null, projectThread, now, `/work?project=${encodeURIComponent(projectThread)}`);
2277
2298
  if (docked !== null)
2278
2299
  page.workspace = { ...page.workspace, conversation: docked, pageHtml: page.body };
2300
+ // Without a docked chat the list reads itself: 10 s while any of its tasks is building, else 30 s.
2301
+ page.refreshSeconds = work.totals.running > 0 ? 10 : 30;
2279
2302
  return sendScreen(response, 200, page);
2280
2303
  }
2281
2304
  if (url.pathname === "/next") {
@@ -3057,6 +3080,26 @@ export function createDecisionServer(options) {
3057
3080
  const chrome = chromeFor(project, "tasks");
3058
3081
  return sendScreen(response, 200, newTaskPage(chrome, project, csrf, revision, null, chainable, store.permissionDefault().mode, store.qualityDefault().mode, chrome.projects ?? []));
3059
3082
  }
3083
+ const taskLive = matchTaskPath(url.pathname, "/live$");
3084
+ if (taskLive !== null) {
3085
+ // The live task page: a nudge the moment the task changes, and who else
3086
+ // has it open. Hints only — the page reads the task the usual way.
3087
+ if (who.via !== "cookie")
3088
+ return respond(response, 403, "application/json", JSON.stringify({ error: "session" }));
3089
+ const family = familyOf(taskLive.taskId);
3090
+ if (family === null || family.problem !== null)
3091
+ return respond(response, 404, "application/json", JSON.stringify({ error: "task" }));
3092
+ response.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", "x-content-type-options": "nosniff", "x-accel-buffering": "no" });
3093
+ const name = who.name, repo = family.root.repo;
3094
+ taskRooms.join(family.root.id, {
3095
+ name, response,
3096
+ // Rechecked while open, from the cookie and the account (never a request's context): a sign-out or a narrowed account stops it.
3097
+ valid: () => { const again = identify(request, false); return again !== null && again.name === name && rowVisible(liveCeiling(), repo) && store.accountCanAccess(name, repo); },
3098
+ });
3099
+ request.once("close", () => { if (!response.writableEnded)
3100
+ response.end(); });
3101
+ return;
3102
+ }
3060
3103
  const task = matchTaskPath(url.pathname, "");
3061
3104
  if (task !== null) {
3062
3105
  const family = familyOf(task.taskId);
@@ -3841,7 +3884,8 @@ export function createDecisionServer(options) {
3841
3884
  // Console v2: the landing's live view (who is working, four counts, Catch up in tabs).
3842
3885
  const home = focusTask === null && chatProject === null && !roomId ? chatHomeOf(who, repos, now) : null;
3843
3886
  const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
3844
- : { ...shown, workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
3887
+ : { ...shown, ...(home === null ? {} : { refreshSeconds: liveRefreshSeconds() }),
3888
+ workspace: { ...shown.workspace, ...(firstRun === undefined ? {} : { firstRun }), ...(phone === undefined ? {} : { phone }), ...(home === null ? {} : { home }) } };
3845
3889
  if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
3846
3890
  {
3847
3891
  const said = takeMateNote(who.session.csrf, mateSession.id);
@@ -4196,6 +4240,14 @@ export function createDecisionServer(options) {
4196
4240
  return sendScreen(response, 200, screen("Data", `<p><a href="/settings">Settings</a></p><h1>Data</h1>${dataExportHtml(who.session.csrf, { problem: url.searchParams.get("problem") })}`, { chrome: chromeFor(project, "settings") }));
4197
4241
  }
4198
4242
  // v104: Prometheus metrics, for an instance operator (a scraper sends one's API token).
4243
+ if (url.pathname === "/health") {
4244
+ if (!store.isInstanceOperator(who.name))
4245
+ return refuse(response, who, 403, "An instance operator reads server health.", "/");
4246
+ const health = store.telemetry.snapshot();
4247
+ return request.headers.accept?.includes('application/json')
4248
+ ? respond(response, 200, 'application/json; charset=utf-8', JSON.stringify(health))
4249
+ : respond(response, 200, 'text/plain; charset=utf-8', `${healthWords(health)}\n`);
4250
+ }
4199
4251
  if (url.pathname === "/metrics") {
4200
4252
  if (!store.isInstanceOperator(who.name))
4201
4253
  return refuse(response, who, 403, "An instance operator reads metrics.", "/");
@@ -5173,17 +5225,24 @@ export function createDecisionServer(options) {
5173
5225
  catch {
5174
5226
  notices.push('Crew updates are unavailable. Open Tasks to inspect saved work.');
5175
5227
  }
5176
- // "Wake me only for these": the navigation carries the count of tasks
5177
- // waiting on a person behind its Tasks link — the open project's, or
5178
- // everything this person may see — so it matches that page's Needs you tab.
5179
- let needsYou = 0;
5228
+ // A running row says what its agent did last, and when (the task page's own line).
5180
5229
  try {
5181
- needsYou = (requestFacts.workCounts ?? workCountsByProject(store, clock(), workAccess()))
5182
- .filter(one => s.chrome?.project == null || one.repo === s.chrome.project).reduce((sum, one) => sum + one.totals['needs-you'], 0);
5183
- }
5184
- catch {
5185
- needsYou = 0;
5186
- }
5230
+ const now = clock(), live = new Map();
5231
+ if (crew.crew.length > 0)
5232
+ for (const run of store.liveRuns(now)) {
5233
+ const root = familyOf(run.taskId)?.root.id;
5234
+ if (root !== undefined && !live.has(root))
5235
+ live.set(root, runActivityOf(store, run, now));
5236
+ }
5237
+ if (live.size > 0)
5238
+ crew = { ...crew, crew: crew.crew.map(item => { const activity = live.get(item.id); return activity === undefined ? item : { ...item, activity }; }) };
5239
+ }
5240
+ catch { /* the rows keep their status; the line returns on the next read */ }
5241
+ // "Wake me only for these": the navigation carries the sidebar's own
5242
+ // Tasks count (chromeFor) and links to the project view it covers, so
5243
+ // it matches that page's Needs you tab on every screen.
5244
+ const needsYou = s.chrome.inboxCount;
5245
+ const tasksProject = s.chrome.inboxProject === undefined ? s.chrome.project : s.chrome.inboxProject;
5187
5246
  // The person's own project and task conversations (v77), for the
5188
5247
  // sidebar's chat list; a thread whose project is out of view is left out.
5189
5248
  let chats = [];
@@ -5219,7 +5278,7 @@ export function createDecisionServer(options) {
5219
5278
  catchUpHtml: extras.catchUpHtml ?? '', controlsHtml: extras.controlsHtml ?? '', notices, view: extras.view ?? null,
5220
5279
  ...(s.chrome.demo ? { demo: { text: DEMO_BANNER, short: DEMO_BANNER_SHORT } } : {}),
5221
5280
  pageHtml: extras.pageHtml === undefined ? (conversation === null ? pageHtml : null) : extras.pageHtml,
5222
- navigation: [...browserNavigationOf(currentPath, s.chrome.project, needsYou), { label: 'Workspace tools', href: '/menu', active: path.pathname === '/menu' }],
5281
+ navigation: [...browserNavigationOf(currentPath, s.chrome.project, needsYou, tasksProject, s.chrome.inboxLabel), { label: 'Workspace tools', href: '/menu', active: path.pathname === '/menu' }],
5223
5282
  chats,
5224
5283
  ...(s.refreshSeconds === undefined ? {} : { refreshSeconds: Math.max(5, Math.floor(s.refreshSeconds)) }),
5225
5284
  ...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
@@ -5274,12 +5333,6 @@ export function createDecisionServer(options) {
5274
5333
  // functional script polls.
5275
5334
  return page(response, status, html, nonce, s.functional?.fetches === true || chromeLayer || sensitiveChrome !== "");
5276
5335
  }
5277
- /** The badge cache: five seconds per project — mutations invalidate it. */
5278
- const badgeCache = new Map();
5279
- /** The needs-you count every surface wears for a project (workspace
5280
- * package 1): Work's own Needs-you membership, cached per viewer and
5281
- * project, so the rail, the phone tab, the scope bar, the project cards,
5282
- * the chat overview, and the Needs-you tab never disagree. */
5283
5336
  /** The Chat landing's live view: each agent at work now with its task and
5284
5337
  * phase, four counts, plan-window use (never dollars: subscriptions don't
5285
5338
  * bill per run) and Catch up's items, each tagged with its tab. Reads only. */
@@ -5289,7 +5342,8 @@ export function createDecisionServer(options) {
5289
5342
  const agents = store.liveRuns(now).filter(run => admitted(run.repo)).map(run => {
5290
5343
  const root = familyOf(run.taskId)?.root ?? null;
5291
5344
  return { runId: run.id, taskId: root?.id ?? run.taskId, title: root?.title ?? run.title, href: taskHref(root?.id ?? run.taskId),
5292
- agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt };
5345
+ agent: `${providerName(run.provider)} on ${run.runner}`, phase: homePhaseWords(run), project: run.repo === null ? null : projectName(run.repo), since: run.startedAt,
5346
+ activity: runActivityOf(store, run, now) };
5293
5347
  });
5294
5348
  const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT });
5295
5349
  const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT });
@@ -5323,19 +5377,13 @@ export function createDecisionServer(options) {
5323
5377
  planUse: (windows?.tiles ?? []).map(one => ({ name: one.name, window: one.window, percent: one.percent, detail: one.detail, tone: one.tone })),
5324
5378
  };
5325
5379
  }
5380
+ /** The needs-you count every surface wears, read from this request's own
5381
+ * Work counts (one query per request, never a cached earlier one), so a
5382
+ * live page's badge and its Needs you tab always agree. */
5326
5383
  function needsYouBadge(project) {
5327
- const actor = requestContext.getStore()?.actor;
5328
- const key = `${actor ?? ""}:${actor === undefined ? "" : store.accountOf(actor)?.generation}:${project ?? ""}`;
5329
- const cached = badgeCache.get(key);
5330
- if (cached !== undefined && Date.now() - cached.at <= 5_000)
5331
- return cached;
5332
- const counted = needsYouCount(project);
5333
- const badge = { at: Date.now(), count: counted.count, saturated: counted.saturated };
5334
- badgeCache.set(key, badge);
5335
- return badge;
5384
+ return needsYouCount(project);
5336
5385
  }
5337
5386
  const bustBadge = () => {
5338
- badgeCache.clear();
5339
5387
  paletteCache.clear();
5340
5388
  };
5341
5389
  /** The sidebar's facts for this request. */
@@ -5343,7 +5391,12 @@ export function createDecisionServer(options) {
5343
5391
  if (restricted() && !visible(project))
5344
5392
  project = null;
5345
5393
  const actor = requestContext.getStore()?.actor;
5346
- const badge = needsYouBadge(project);
5394
+ // One Tasks count per request: Needs you in the request's project view
5395
+ // (the open project, else every admitted project), never the page's own
5396
+ // project — a flow or task in another project shows the same number.
5397
+ const lens = requestContext.getStore()?.lens;
5398
+ const inboxProject = lens !== undefined && (!restricted() || visible(lens)) ? lens : project;
5399
+ const badge = needsYouBadge(inboxProject);
5347
5400
  const liveMode = project === null ? null : store.activeMode(project, clock());
5348
5401
  const liveModeTerms = liveMode === null ? null : modeTermsFromJson(liveMode.termsJson);
5349
5402
  let projectPeek = project === null ? null : undefined;
@@ -5383,6 +5436,9 @@ export function createDecisionServer(options) {
5383
5436
  ...(facts === undefined ? {} : { csrf: facts.csrf, returnTo: facts.returnTo }),
5384
5437
  inboxCount: badge.count,
5385
5438
  inboxSaturated: badge.saturated,
5439
+ inboxProject,
5440
+ inboxLabel: needsYouLabelOf(badge.count, inboxProject === null ? null
5441
+ : store.listProjects().find(one => one.path === inboxProject)?.name ?? projectName(inboxProject), badge.saturated),
5386
5442
  settings: true,
5387
5443
  ...(store.isDemo() ? { demo: true } : {}),
5388
5444
  ...(() => { const signIn = signInNotices(store); return signIn.length === 0 ? {} : { signIn }; })(),
@@ -5436,6 +5492,10 @@ export function createDecisionServer(options) {
5436
5492
  .reduce((sum, one) => sum + one.totals['needs-you'], 0);
5437
5493
  return { count, saturated: false };
5438
5494
  }
5495
+ /** A live page's own beat without a docked chat: 10 s while any admitted task is building, else 30 s. */
5496
+ function liveRefreshSeconds() {
5497
+ return projectCounts(clock()).some(one => one.totals.running > 0) ? 10 : 30;
5498
+ }
5439
5499
  function projectFamilyPeek(repo, now) {
5440
5500
  const row = projectCounts(now).find(one => one.repo === repo);
5441
5501
  return { waiting: row?.totals['needs-you'] ?? 0, running: row?.totals.running ?? 0,
@@ -6239,6 +6299,9 @@ export function createDecisionServer(options) {
6239
6299
  // What went wrong reads the family's latest finished attempt, as the Tasks row does.
6240
6300
  presentedData.failure = failureOf(presentedData.family?.runs ?? data.runs, data.assignment ?? null);
6241
6301
  presentedData.runChecks = runChecksHere(data.assignment?.receipt?.runId ?? null, who, taskHref(taskId));
6302
+ const liveRun = data.liveRunId == null ? undefined : data.runs.find(one => one.id === data.liveRunId);
6303
+ if (liveRun !== undefined)
6304
+ presentedData.activity = runActivityOf(store, liveRun, clock());
6242
6305
  presentedData.demo = store.isDemo();
6243
6306
  const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
6244
6307
  const page = taskPage(paneProject === null && !unscopedMode
@@ -6249,11 +6312,19 @@ export function createDecisionServer(options) {
6249
6312
  const docked = focus === null ? null : dockedConversation(who, focus, null, clock(), taskHref(focus.id));
6250
6313
  // The conversation is the page's own thread here, so the page drops its Ask tab;
6251
6314
  // only an approver is offered a road to message the agent.
6252
- const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, ...(docked === null ? {} : { tabs: [] }), ...(who.role === "approver" ? {} : { chatHref: null }) } : page.workspace?.view;
6315
+ const taskView = page.workspace?.view?.kind === "task" ? { ...page.workspace.view, ...(docked === null ? {} : { tabs: [] }), ...(who.role === "approver" ? {} : { chatHref: null }),
6316
+ // A signed-in page hears the moment the task changes, and who else has it open.
6317
+ ...(who.via === "cookie" ? { live: { href: `${taskHref(family?.root.id ?? taskId)}/live`, at: (() => { try {
6318
+ return taskFingerprint(store, family?.root.id ?? taskId, clock());
6319
+ }
6320
+ catch {
6321
+ return null;
6322
+ } })() } } : {}) } : page.workspace?.view;
6253
6323
  if (docked !== null)
6254
6324
  page.workspace = { ...page.workspace, ...(taskView === undefined ? {} : { view: taskView }), conversation: docked, pageHtml: page.body };
6255
6325
  else if (taskView !== undefined && page.workspace !== undefined)
6256
6326
  page.workspace = { ...page.workspace, view: taskView };
6327
+ page.refreshSeconds = liveRefreshSeconds();
6257
6328
  return sendScreen(response, status, page);
6258
6329
  }
6259
6330
  /** The routine screen: the standing order restated, its verbs, its ledger. */
@@ -12509,7 +12580,10 @@ export function createDecisionServer(options) {
12509
12580
  stream.end();
12510
12581
  chatStreams.clear();
12511
12582
  flowRooms.close();
12512
- void Promise.all([closeCoding(), bounded(team.close()), bounded(leadMaintenance?.stop())]).then(() => closeServer(callback)).catch(error => {
12583
+ taskRooms.close();
12584
+ detachReads();
12585
+ liveFollower.close();
12586
+ void Promise.all([closeCoding(), bounded(team.close()), bounded(leadMaintenance?.stop()), bounded(reads?.close())]).then(() => closeServer(callback)).catch(error => {
12513
12587
  if (callback)
12514
12588
  callback(error instanceof Error ? error : Error('Coding session shutdown failed.'));
12515
12589
  else
@@ -16781,9 +16855,9 @@ function shell(title, body, options = {}) {
16781
16855
  // destination it now lives under. The count rides Work: it is the
16782
16856
  // saturated needs-you count, exactly as the inbox row wore it.
16783
16857
  const primary = chrome.active === "code" ? "work" : primaryDestinationOf(chrome.active);
16784
- 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}"` : ""}>` +
16858
+ 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}"` : ""}>` +
16785
16859
  `<span class="glyph">${NAV_ICONS[key] ?? ""}</span>${label}` +
16786
- `${count !== undefined && count > 0 ? ` <span class="count badge badge-open">${count}${chrome.inboxSaturated ? "+" : ""}</span>` : ""}</a>`;
16860
+ `${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>`;
16787
16861
  const side = [
16788
16862
  `<aside class="side">`,
16789
16863
  `<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>`,
@@ -16869,7 +16943,7 @@ function shell(title, body, options = {}) {
16869
16943
  // A phone tab says THAT something waits, with a dot; the number is on
16870
16944
  // the Work views themselves (Linear Mobile's rule — one tap away).
16871
16945
  `<a href="${href}"${primary === key ? ' class="active" aria-current="page"' : ""}><span class="glyph">${TAB_ICONS[key]}</span>${label}` +
16872
- `${count !== undefined && count > 0 ? `<span class="dot-badge" role="img" aria-label="${count} waiting"></span>` : ""}</a>`;
16946
+ `${count !== undefined && count > 0 ? `<span class="dot-badge" role="img" aria-label="${escape(chrome.inboxLabel ?? `${count} waiting`)}"></span>` : ""}</a>`;
16873
16947
  // Three primary tabs, the same three as the rail: chat where allowed,
16874
16948
  // work, projects. Tools and settings sit behind the header's menu action.
16875
16949
  const tabbar = [
@@ -22037,6 +22111,8 @@ function taskBodyParts(data) {
22037
22111
  retry,
22038
22112
  runChecks,
22039
22113
  progress,
22114
+ // One quiet line under the step: what the agent did last, and when.
22115
+ activity: liveRun === undefined ? null : data.activity ?? null,
22040
22116
  stop,
22041
22117
  record,
22042
22118
  earlier,
@@ -0,0 +1,86 @@
1
+ import type { BudgetLimit } from './request-budget.js';
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ export declare const TELEMETRY_WINDOW_MS = 300000;
4
+ export declare const LATENCY_BUCKETS: number[];
5
+ export declare const ROUTE_FAMILIES: readonly ['cli', 'mcp', 'team', 'team-stream', 'chat', 'chat-stream', 'flows', 'flow-stream', 'tasks', 'results', 'decisions', 'sessions', 'settings', 'projects', 'assets', 'auth', 'metrics', 'health', 'console', 'other'];
6
+ export type RouteFamily = typeof ROUTE_FAMILIES[number];
7
+ export type StreamFamily = 'team-stream' | 'chat-stream' | 'flow-stream' | 'other';
8
+ export declare const STREAM_FAMILIES: readonly StreamFamily[];
9
+ export declare const QUEUE_BUCKETS: number[];
10
+ /** Only literals from the allowlist can become labels, including for unknown or malformed paths. */
11
+ export declare function routeFamily(raw: string): RouteFamily;
12
+ type Counts = {
13
+ bins: number[];
14
+ count: number;
15
+ sum: number;
16
+ max: number;
17
+ };
18
+ export type LatencySummary = {
19
+ count: number;
20
+ p50Ms: number | null;
21
+ p99Ms: number | null;
22
+ maxMs: number | null;
23
+ totalMs: number;
24
+ };
25
+ /** Lifetime histogram plus a ring of ten-second slices. Cost and memory do not grow with traffic. */
26
+ export declare class TimingHistogram {
27
+ private readonly clock;
28
+ readonly total: Counts;
29
+ private readonly slices;
30
+ constructor(clock: () => number);
31
+ observe(seconds: number, weight?: number): void;
32
+ private prune;
33
+ recent(): LatencySummary;
34
+ }
35
+ export type HealthSnapshot = {
36
+ version: 1;
37
+ windowSeconds: number;
38
+ uptimeSeconds: number;
39
+ requests: LatencySummary;
40
+ routes: Partial<Record<RouteFamily, LatencySummary>>;
41
+ eventLoop: LatencySummary;
42
+ writes: {
43
+ wait: LatencySummary;
44
+ hold: LatencySummary;
45
+ statement: LatencySummary;
46
+ };
47
+ streams: {
48
+ open: number;
49
+ queuedBytes: number;
50
+ maxQueuedBytes: number;
51
+ };
52
+ budgetRefusals: number;
53
+ };
54
+ export declare class ServerTelemetry {
55
+ readonly clock: () => number;
56
+ readonly requests: TimingHistogram;
57
+ readonly routes: Map<"assets" | "auth" | "chat" | "chat-stream" | "cli" | "console" | "decisions" | "flow-stream" | "flows" | "health" | "mcp" | "metrics" | "other" | "projects" | "results" | "sessions" | "settings" | "tasks" | "team" | "team-stream", TimingHistogram>;
58
+ readonly eventLoop: TimingHistogram;
59
+ readonly writeWait: TimingHistogram;
60
+ readonly writeHold: TimingHistogram;
61
+ readonly writeStatement: TimingHistogram;
62
+ readonly refusals: Map<string, number>;
63
+ private readonly recentRefusals;
64
+ private readonly streams;
65
+ private monitor;
66
+ private timer;
67
+ private readonly started;
68
+ constructor(clock?: () => number);
69
+ start(): void;
70
+ stop(): void;
71
+ /**
72
+ * Native nanosecond samples, folded into bounded buckets once a second, never one retained object per tick.
73
+ * Each raw sample includes the sampling timer's own interval, so it is subtracted: an idle loop reads near zero.
74
+ */
75
+ sampleEventLoop(): void;
76
+ budgetRefused(route: 'api' | 'mcp', reason: BudgetLimit | 'unavailable'): void;
77
+ private pruneRefusals;
78
+ observeRequest(family: RouteFamily, milliseconds: number): void;
79
+ openStream(response: ServerResponse, family: StreamFamily): void;
80
+ streamQueues(family?: StreamFamily): number[];
81
+ snapshot(): HealthSnapshot;
82
+ }
83
+ /** SSE latency is time to opening headers; a live view's lifetime is not request work. */
84
+ export declare function instrumentRequest(telemetry: ServerTelemetry, request: IncomingMessage, response: ServerResponse): void;
85
+ export declare function healthWords(health: HealthSnapshot): string;
86
+ export {};