toolroll 0.9.46 → 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.
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";
@@ -749,6 +751,7 @@ export function createDecisionServer(options) {
749
751
  const connectVisits = new Map();
750
752
  /** Flows open in a browser (v88): who's here, and a nudge when one changes. */
751
753
  const flowRooms = createFlowRooms(flow => flowFingerprint(store, flow));
754
+ const taskRooms = createTaskRooms(root => taskFingerprint(store, root, clock()));
752
755
  const teamChatProvider = () => { const enabled = chatEnablement(); return enabled.ok ? { config: enabled.config, key: enabled.key } : null; };
753
756
  const team = createTeamRuntime({ store, repos: codingProjects, evidenceRoot, clock, workspaceRevision,
754
757
  ...(options.chatFetcher ? { fetcher: options.chatFetcher } : {}),
@@ -1831,9 +1834,9 @@ export function createDecisionServer(options) {
1831
1834
  }
1832
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"]);
1833
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"]);
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)$");
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)$");
1835
1838
  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)
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)
1837
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);
1838
1841
  if (!(request.method === "GET" ? read : write).has(path) && task === null && !resource) {
1839
1842
  refuse(response, who, 403, "This area requires instance access. Your account operates within its assigned projects.", "/projects");
@@ -1891,6 +1894,10 @@ export function createDecisionServer(options) {
1891
1894
  }
1892
1895
  project = wanted;
1893
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;
1894
1901
  // Exact result links carry their own read context. Check the stored
1895
1902
  // placement against both account and instance access before using it;
1896
1903
  // viewing a result never changes the session's selected project, and
@@ -1975,7 +1982,7 @@ export function createDecisionServer(options) {
1975
1982
  url.pathname !== "/menu" &&
1976
1983
  url.pathname !== "/recipes" &&
1977
1984
  // 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) &&
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) &&
1979
1986
  // The gallery (Flows → New) asks for its project on each template's page.
1980
1987
  url.pathname !== "/flows/new" && !/^\/flows\/new\/[a-z-]{1,40}$/.test(url.pathname) &&
1981
1988
  // Teammates (v92) name their own project, like flows.
@@ -1983,7 +1990,7 @@ export function createDecisionServer(options) {
1983
1990
  // New work names its project in the form (a dropdown of known
1984
1991
  // projects); /tasks/add still admits the posted repo on its own.
1985
1992
  url.pathname !== "/tasks/new" && url.pathname !== "/tasks/add" &&
1986
- !/^\/t\/[^/]+(\/evidence)?$/.test(url.pathname) &&
1993
+ !/^\/t\/[^/]+(\/evidence|\/live)?$/.test(url.pathname) &&
1987
1994
  !/^\/r\/[0-9]{1,15}(?:\/evidence\/[0-9]{1,15})?$/.test(url.pathname) &&
1988
1995
  !url.pathname.startsWith("/d/") && !url.pathname.startsWith("/contest/") &&
1989
1996
  url.pathname !== "/projects" &&
@@ -2276,6 +2283,8 @@ export function createDecisionServer(options) {
2276
2283
  const docked = projectThread === null ? null : dockedConversation(who, null, projectThread, now, `/work?project=${encodeURIComponent(projectThread)}`);
2277
2284
  if (docked !== null)
2278
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;
2279
2288
  return sendScreen(response, 200, page);
2280
2289
  }
2281
2290
  if (url.pathname === "/next") {
@@ -3057,6 +3066,26 @@ export function createDecisionServer(options) {
3057
3066
  const chrome = chromeFor(project, "tasks");
3058
3067
  return sendScreen(response, 200, newTaskPage(chrome, project, csrf, revision, null, chainable, store.permissionDefault().mode, store.qualityDefault().mode, chrome.projects ?? []));
3059
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
+ }
3060
3089
  const task = matchTaskPath(url.pathname, "");
3061
3090
  if (task !== null) {
3062
3091
  const family = familyOf(task.taskId);
@@ -3841,7 +3870,8 @@ export function createDecisionServer(options) {
3841
3870
  // Console v2: the landing's live view (who is working, four counts, Catch up in tabs).
3842
3871
  const home = focusTask === null && chatProject === null && !roomId ? chatHomeOf(who, repos, now) : null;
3843
3872
  const withFirstRun = (shown) => firstRun === undefined && phone === undefined && home === null ? shown
3844
- : { ...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 }) } };
3845
3875
  if (enabled.ok && mateSession !== null && principal !== null && !ceilingStale) {
3846
3876
  {
3847
3877
  const said = takeMateNote(who.session.csrf, mateSession.id);
@@ -5173,17 +5203,24 @@ export function createDecisionServer(options) {
5173
5203
  catch {
5174
5204
  notices.push('Crew updates are unavailable. Open Tasks to inspect saved work.');
5175
5205
  }
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;
5206
+ // A running row says what its agent did last, and when (the task page's own line).
5180
5207
  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
- }
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;
5187
5224
  // The person's own project and task conversations (v77), for the
5188
5225
  // sidebar's chat list; a thread whose project is out of view is left out.
5189
5226
  let chats = [];
@@ -5219,7 +5256,7 @@ export function createDecisionServer(options) {
5219
5256
  catchUpHtml: extras.catchUpHtml ?? '', controlsHtml: extras.controlsHtml ?? '', notices, view: extras.view ?? null,
5220
5257
  ...(s.chrome.demo ? { demo: { text: DEMO_BANNER, short: DEMO_BANNER_SHORT } } : {}),
5221
5258
  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' }],
5259
+ navigation: [...browserNavigationOf(currentPath, s.chrome.project, needsYou, tasksProject, s.chrome.inboxLabel), { label: 'Workspace tools', href: '/menu', active: path.pathname === '/menu' }],
5223
5260
  chats,
5224
5261
  ...(s.refreshSeconds === undefined ? {} : { refreshSeconds: Math.max(5, Math.floor(s.refreshSeconds)) }),
5225
5262
  ...(s.chrome.signIn === undefined ? {} : { signIn: s.chrome.signIn }),
@@ -5274,12 +5311,6 @@ export function createDecisionServer(options) {
5274
5311
  // functional script polls.
5275
5312
  return page(response, status, html, nonce, s.functional?.fetches === true || chromeLayer || sensitiveChrome !== "");
5276
5313
  }
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
5314
  /** The Chat landing's live view: each agent at work now with its task and
5284
5315
  * phase, four counts, plan-window use (never dollars: subscriptions don't
5285
5316
  * bill per run) and Catch up's items, each tagged with its tab. Reads only. */
@@ -5289,7 +5320,8 @@ export function createDecisionServer(options) {
5289
5320
  const agents = store.liveRuns(now).filter(run => admitted(run.repo)).map(run => {
5290
5321
  const root = familyOf(run.taskId)?.root ?? null;
5291
5322
  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 };
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) };
5293
5325
  });
5294
5326
  const all = workIndexPage(store, now, access, { view: "all", limit: WORK_INDEX_MAX_LIMIT });
5295
5327
  const needs = workIndexPage(store, now, access, { view: "needs-you", limit: WORK_INDEX_MAX_LIMIT });
@@ -5323,19 +5355,13 @@ export function createDecisionServer(options) {
5323
5355
  planUse: (windows?.tiles ?? []).map(one => ({ name: one.name, window: one.window, percent: one.percent, detail: one.detail, tone: one.tone })),
5324
5356
  };
5325
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. */
5326
5361
  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;
5362
+ return needsYouCount(project);
5336
5363
  }
5337
5364
  const bustBadge = () => {
5338
- badgeCache.clear();
5339
5365
  paletteCache.clear();
5340
5366
  };
5341
5367
  /** The sidebar's facts for this request. */
@@ -5343,7 +5369,12 @@ export function createDecisionServer(options) {
5343
5369
  if (restricted() && !visible(project))
5344
5370
  project = null;
5345
5371
  const actor = requestContext.getStore()?.actor;
5346
- 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);
5347
5378
  const liveMode = project === null ? null : store.activeMode(project, clock());
5348
5379
  const liveModeTerms = liveMode === null ? null : modeTermsFromJson(liveMode.termsJson);
5349
5380
  let projectPeek = project === null ? null : undefined;
@@ -5383,6 +5414,9 @@ export function createDecisionServer(options) {
5383
5414
  ...(facts === undefined ? {} : { csrf: facts.csrf, returnTo: facts.returnTo }),
5384
5415
  inboxCount: badge.count,
5385
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),
5386
5420
  settings: true,
5387
5421
  ...(store.isDemo() ? { demo: true } : {}),
5388
5422
  ...(() => { const signIn = signInNotices(store); return signIn.length === 0 ? {} : { signIn }; })(),
@@ -5436,6 +5470,10 @@ export function createDecisionServer(options) {
5436
5470
  .reduce((sum, one) => sum + one.totals['needs-you'], 0);
5437
5471
  return { count, saturated: false };
5438
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
+ }
5439
5477
  function projectFamilyPeek(repo, now) {
5440
5478
  const row = projectCounts(now).find(one => one.repo === repo);
5441
5479
  return { waiting: row?.totals['needs-you'] ?? 0, running: row?.totals.running ?? 0,
@@ -6239,6 +6277,9 @@ export function createDecisionServer(options) {
6239
6277
  // What went wrong reads the family's latest finished attempt, as the Tasks row does.
6240
6278
  presentedData.failure = failureOf(presentedData.family?.runs ?? data.runs, data.assignment ?? null);
6241
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());
6242
6283
  presentedData.demo = store.isDemo();
6243
6284
  const paneProject = restricted() ? store.lookupRef(taskId)?.repo ?? null : who.via === "cookie" ? who.session.project : null;
6244
6285
  const page = taskPage(paneProject === null && !unscopedMode
@@ -6249,11 +6290,19 @@ export function createDecisionServer(options) {
6249
6290
  const docked = focus === null ? null : dockedConversation(who, focus, null, clock(), taskHref(focus.id));
6250
6291
  // The conversation is the page's own thread here, so the page drops its Ask tab;
6251
6292
  // 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;
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;
6253
6301
  if (docked !== null)
6254
6302
  page.workspace = { ...page.workspace, ...(taskView === undefined ? {} : { view: taskView }), conversation: docked, pageHtml: page.body };
6255
6303
  else if (taskView !== undefined && page.workspace !== undefined)
6256
6304
  page.workspace = { ...page.workspace, view: taskView };
6305
+ page.refreshSeconds = liveRefreshSeconds();
6257
6306
  return sendScreen(response, status, page);
6258
6307
  }
6259
6308
  /** The routine screen: the standing order restated, its verbs, its ledger. */
@@ -12509,6 +12558,7 @@ export function createDecisionServer(options) {
12509
12558
  stream.end();
12510
12559
  chatStreams.clear();
12511
12560
  flowRooms.close();
12561
+ taskRooms.close();
12512
12562
  void Promise.all([closeCoding(), bounded(team.close()), bounded(leadMaintenance?.stop())]).then(() => closeServer(callback)).catch(error => {
12513
12563
  if (callback)
12514
12564
  callback(error instanceof Error ? error : Error('Coding session shutdown failed.'));
@@ -16781,9 +16831,9 @@ function shell(title, body, options = {}) {
16781
16831
  // destination it now lives under. The count rides Work: it is the
16782
16832
  // saturated needs-you count, exactly as the inbox row wore it.
16783
16833
  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}"` : ""}>` +
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}"` : ""}>` +
16785
16835
  `<span class="glyph">${NAV_ICONS[key] ?? ""}</span>${label}` +
16786
- `${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>`;
16787
16837
  const side = [
16788
16838
  `<aside class="side">`,
16789
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>`,
@@ -16869,7 +16919,7 @@ function shell(title, body, options = {}) {
16869
16919
  // A phone tab says THAT something waits, with a dot; the number is on
16870
16920
  // the Work views themselves (Linear Mobile's rule — one tap away).
16871
16921
  `<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>`;
16922
+ `${count !== undefined && count > 0 ? `<span class="dot-badge" role="img" aria-label="${escape(chrome.inboxLabel ?? `${count} waiting`)}"></span>` : ""}</a>`;
16873
16923
  // Three primary tabs, the same three as the rail: chat where allowed,
16874
16924
  // work, projects. Tools and settings sit behind the header's menu action.
16875
16925
  const tabbar = [
@@ -22037,6 +22087,8 @@ function taskBodyParts(data) {
22037
22087
  retry,
22038
22088
  runChecks,
22039
22089
  progress,
22090
+ // One quiet line under the step: what the agent did last, and when.
22091
+ activity: liveRun === undefined ? null : data.activity ?? null,
22040
22092
  stop,
22041
22093
  record,
22042
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;
package/dist/store.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { DecisionOption } from "./contracts/decision.js";
2
2
  import { type SavedToolAction, type SavedToolRule } from "./contracts/store-json.js";
3
3
  import type { FlowCardChange, FlowCardFields } from "./contracts/flow-card.js";
4
4
  import { type Actor } from "./actor.js";
5
+ import { type LiveActivityKind } from "./live.js";
5
6
  import { type TokenPurpose } from "./api-tokens.js";
6
7
  import { type CriterionMatrixRow, type CriterionJudgementWord } from "./proof.js";
7
8
  import { type ExecutionProfile, type ChainEntry, type UnattendedPermissionMode, type AcceptanceCriterion } from "./scope.js";
@@ -69,6 +70,10 @@ export declare const FLOW_SEND_SCHEMA = "\nCREATE TABLE IF NOT EXISTS flow_send
69
70
  /** An owner's lasting choice to approve plans and merges from their paired chat (chat-approval.ts), for all their
70
71
  * projects (scope '*') or one (its path), with the limits they agreed to. Off unless a row says on; a project's own row
71
72
  * outranks the all-projects one. `generation` is the account's at the change: a reset password ends it. */
73
+ /** What a running agent did last (live tasks): one row per run, a fixed kind from live.ts's
74
+ * vocabulary and when — never text, tool names or arguments. Display state, not evidence;
75
+ * a finished run's row goes with the live log's own retention (sweepRunActivity). */
76
+ export declare const RUN_ACTIVITY_SCHEMA = "\nCREATE TABLE IF NOT EXISTS run_activity (\n run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,\n kind TEXT NOT NULL CHECK (kind IN ('session','message','edit','command','read','search','lookup','plan','delegate','tool')),\n at TEXT NOT NULL\n);\n";
72
77
  export declare const CHAT_APPROVAL_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_approval_setting (\n approver TEXT NOT NULL,\n scope TEXT NOT NULL,\n enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),\n full_access INTEGER NOT NULL DEFAULT 0 CHECK (full_access IN (0,1)),\n attempt_cap_microusd INTEGER CHECK (attempt_cap_microusd IS NULL OR attempt_cap_microusd >= 0),\n generation INTEGER NOT NULL,\n digest TEXT NOT NULL,\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (approver, scope)\n);\n";
73
78
  /** The second tap on a flow decision in Telegram (telegram-flow.ts): Approve arms Yes and Cancel, bound to the binding,
74
79
  * chat, message and exact visit of the card; Yes decides it once. */
@@ -5601,6 +5606,17 @@ export declare class Store {
5601
5606
  * page and focused chat render live, including once the run that wrote
5602
5607
  * it has ended. */
5603
5608
  latestCheckpointForTask(taskRef: number): RunCheckpoint | null;
5609
+ /** What the agent did last on this run (live tasks): one fixed kind and when, replacing the last. */
5610
+ recordRunActivity(run: number, kind: LiveActivityKind, now: Date): void;
5611
+ /** A run's last recorded activity and its newest progress checkpoint's time, in one read. */
5612
+ runActivity(run: number): {
5613
+ kind: LiveActivityKind | null;
5614
+ at: string | null;
5615
+ progressAt: string | null;
5616
+ };
5617
+ /** The live log's own retention: a finished run's activity goes once older than `retainMs`, and beyond the newest
5618
+ * `bound` finished runs; a live run's always stays. */
5619
+ sweepRunActivity(now: Date, retainMs: number, bound: number): number;
5604
5620
  /** One run's full checkpoint history, oldest first. */
5605
5621
  checkpointHistory(run: number): RunCheckpoint[];
5606
5622
  /** The newest scout run's report for a task (v34), when one exists. */
package/dist/store.js CHANGED
@@ -12,6 +12,7 @@ import { cardOutputsForStore, cardOutputsFromStore } from "./contracts/stage-out
12
12
  import { chatControlHref, chatResultHref } from "./chat-controls.js";
13
13
  import { actorLabel, currentActor, leadSecretMatches, mintLeadToken, parseLeadToken } from "./actor.js";
14
14
  import { scanForSecrets } from "./evidence.js";
15
+ import { LIVE_ACTIVITY_KINDS } from "./live.js";
15
16
  import { tokenPurpose, tokenRotationProblem } from "./api-tokens.js";
16
17
  /**
17
18
  * The database: a small task store, and the operational overlay beside it.
@@ -359,6 +360,16 @@ CREATE INDEX IF NOT EXISTS telegram_flow_choice_visit ON telegram_flow_choice (c
359
360
  /** An owner's lasting choice to approve plans and merges from their paired chat (chat-approval.ts), for all their
360
361
  * projects (scope '*') or one (its path), with the limits they agreed to. Off unless a row says on; a project's own row
361
362
  * outranks the all-projects one. `generation` is the account's at the change: a reset password ends it. */
363
+ /** What a running agent did last (live tasks): one row per run, a fixed kind from live.ts's
364
+ * vocabulary and when — never text, tool names or arguments. Display state, not evidence;
365
+ * a finished run's row goes with the live log's own retention (sweepRunActivity). */
366
+ export const RUN_ACTIVITY_SCHEMA = `
367
+ CREATE TABLE IF NOT EXISTS run_activity (
368
+ run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,
369
+ kind TEXT NOT NULL CHECK (kind IN ('session','message','edit','command','read','search','lookup','plan','delegate','tool')),
370
+ at TEXT NOT NULL
371
+ );
372
+ `;
362
373
  export const CHAT_APPROVAL_SCHEMA = `
363
374
  CREATE TABLE IF NOT EXISTS chat_approval_setting (
364
375
  approver TEXT NOT NULL,
@@ -4090,6 +4101,8 @@ function initializeStore(db, file) {
4090
4101
  db.exec(CHAT_DECIDE_SCHEMA);
4091
4102
  // An owner's lasting chat-approval setting (no version bump: additive only; absent means off).
4092
4103
  db.exec(CHAT_APPROVAL_SCHEMA);
4104
+ // What a running agent did last (no version bump: additive only; absent means no word yet).
4105
+ db.exec(RUN_ACTIVITY_SCHEMA);
4093
4106
  db.exec(TELEGRAM_FLOW_CONFIRM_SCHEMA);
4094
4107
  // A flow's "Send to me" and "Person chooses" zones (no version bump: additive only).
4095
4108
  db.exec(FLOW_SEND_SCHEMA);
@@ -17523,6 +17536,27 @@ export class Store {
17523
17536
  const row = this.db.prepare("SELECT * FROM run_checkpoint WHERE task_ref = ? ORDER BY id DESC LIMIT 1").get(taskRef);
17524
17537
  return row === undefined ? null : readRunCheckpoint(row);
17525
17538
  }
17539
+ /** What the agent did last on this run (live tasks): one fixed kind and when, replacing the last. */
17540
+ recordRunActivity(run, kind, now) {
17541
+ this.db.prepare("INSERT INTO run_activity (run, kind, at) VALUES (?, ?, ?) ON CONFLICT (run) DO UPDATE SET kind = excluded.kind, at = excluded.at")
17542
+ .run(run, kind, now.toISOString());
17543
+ }
17544
+ /** A run's last recorded activity and its newest progress checkpoint's time, in one read. */
17545
+ runActivity(run) {
17546
+ const row = this.db.prepare(`SELECT (SELECT kind FROM run_activity WHERE run = ?1) AS kind, (SELECT at FROM run_activity WHERE run = ?1) AS at,
17547
+ (SELECT created_at FROM run_checkpoint WHERE run = ?1 ORDER BY id DESC LIMIT 1) AS progress_at`).get(run);
17548
+ const kind = typeof row["kind"] === "string" && LIVE_ACTIVITY_KINDS.includes(row["kind"]) ? row["kind"] : null;
17549
+ return { kind, at: kind === null ? null : String(row["at"]), progressAt: row["progress_at"] == null ? null : String(row["progress_at"]) };
17550
+ }
17551
+ /** The live log's own retention: a finished run's activity goes once older than `retainMs`, and beyond the newest
17552
+ * `bound` finished runs; a live run's always stays. */
17553
+ sweepRunActivity(now, retainMs, bound) {
17554
+ const finished = "run IN (SELECT id FROM run WHERE outcome IS NOT NULL)";
17555
+ const aged = this.db.prepare(`DELETE FROM run_activity WHERE at < ? AND ${finished}`).run(new Date(now.getTime() - retainMs).toISOString()).changes;
17556
+ const over = this.db.prepare(`DELETE FROM run_activity WHERE ${finished} AND run NOT IN (SELECT run FROM run_activity WHERE ${finished} ORDER BY at DESC, run DESC LIMIT ?)`)
17557
+ .run(Math.max(0, Math.floor(bound))).changes;
17558
+ return Number(aged) + Number(over);
17559
+ }
17526
17560
  /** One run's full checkpoint history, oldest first. */
17527
17561
  checkpointHistory(run) {
17528
17562
  return this.db.prepare("SELECT * FROM run_checkpoint WHERE run = ? ORDER BY id ASC").all(run).map(readRunCheckpoint);
@@ -0,0 +1,27 @@
1
+ /**
2
+ * What a running agent did last, recorded and read (live tasks).
3
+ *
4
+ * The recorder sits beside the live log on the agent's stream and keeps one
5
+ * row per run: a fixed kind from live.ts's vocabulary and when. No text, no
6
+ * tool names, no arguments are ever kept, so nothing here can leak a secret.
7
+ * The reader picks the newer of that row and the run's latest progress
8
+ * checkpoint, and asks the runner's heartbeat whether the worker still
9
+ * answers. Neither side reads or stats a live-log file.
10
+ */
11
+ import { type RunActivity } from "./activity-line.js";
12
+ import type { Store } from "./store.js";
13
+ type Observer = {
14
+ observe(event: Record<string, unknown>): void;
15
+ close(): void;
16
+ };
17
+ /** Watch a run's stream for what the agent does, alongside its live log (which may be null). Fail-soft: a write
18
+ * that fails never touches the run. */
19
+ export declare function withRunActivity(liveLog: Observer | null, store: Pick<Store, "recordRunActivity">, run: number, clock: () => Date): Observer;
20
+ /** A live run's last activity: the newer of its recorded kind and its latest progress checkpoint, else when it
21
+ * started; and "offline" only when its registered worker's heartbeat has lapsed. */
22
+ export declare function runActivityOf(store: Store, run: {
23
+ id: number;
24
+ runner: string;
25
+ startedAt: string;
26
+ }, now: Date): RunActivity;
27
+ export {};
@@ -0,0 +1,53 @@
1
+ /**
2
+ * What a running agent did last, recorded and read (live tasks).
3
+ *
4
+ * The recorder sits beside the live log on the agent's stream and keeps one
5
+ * row per run: a fixed kind from live.ts's vocabulary and when. No text, no
6
+ * tool names, no arguments are ever kept, so nothing here can leak a secret.
7
+ * The reader picks the newer of that row and the run's latest progress
8
+ * checkpoint, and asks the runner's heartbeat whether the worker still
9
+ * answers. Neither side reads or stats a live-log file.
10
+ */
11
+ import { ACTIVITY_WORDS } from "./activity-line.js";
12
+ import { liveActivityKind } from "./live.js";
13
+ import { isAlive } from "./runner.js";
14
+ /** At most one write per run this often while the kind stays the same: the age is in seconds, not milliseconds. */
15
+ const SAME_KIND_EVERY_MS = 5_000;
16
+ /** Watch a run's stream for what the agent does, alongside its live log (which may be null). Fail-soft: a write
17
+ * that fails never touches the run. */
18
+ export function withRunActivity(liveLog, store, run, clock) {
19
+ let last = null;
20
+ return {
21
+ observe(event) {
22
+ liveLog?.observe(event);
23
+ const kind = liveActivityKind(event);
24
+ if (kind === null)
25
+ return;
26
+ try {
27
+ const now = clock();
28
+ if (last !== null && last.kind === kind && now.getTime() - last.at < SAME_KIND_EVERY_MS)
29
+ return;
30
+ store.recordRunActivity(run, kind, now);
31
+ last = { kind, at: now.getTime() };
32
+ }
33
+ catch { /* display state: the next event tries again */ }
34
+ },
35
+ close() { liveLog?.close(); },
36
+ };
37
+ }
38
+ /** A live run's last activity: the newer of its recorded kind and its latest progress checkpoint, else when it
39
+ * started; and "offline" only when its registered worker's heartbeat has lapsed. */
40
+ export function runActivityOf(store, run, now) {
41
+ const recorded = store.runActivity(run.id);
42
+ let what = ACTIVITY_WORDS["started"], at = run.startedAt;
43
+ if (recorded.kind !== null && recorded.at !== null && recorded.at >= at) {
44
+ what = ACTIVITY_WORDS[recorded.kind] ?? ACTIVITY_WORDS["tool"];
45
+ at = recorded.at;
46
+ }
47
+ if (recorded.progressAt !== null && recorded.progressAt > at) {
48
+ what = ACTIVITY_WORDS["progress"];
49
+ at = recorded.progressAt;
50
+ }
51
+ const worker = store.getRunner(run.runner)?.runner;
52
+ return { what, at, worker: worker === undefined || isAlive(worker, now) ? "online" : "offline" };
53
+ }
@@ -0,0 +1,17 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import { type Store } from "./store.js";
3
+ export type TaskViewer = {
4
+ name: string;
5
+ response: ServerResponse;
6
+ valid: () => boolean;
7
+ };
8
+ /** What changes when anything on the task page changes: one read over the family, kept short and opaque. */
9
+ export declare function taskFingerprint(store: Store, root: string, now: Date, livenessMs?: number): string | null;
10
+ export type TaskRooms = {
11
+ /** Keep this page's stream on the task family until it closes; the first events say where it stands and who's here. */
12
+ join: (root: string, viewer: TaskViewer) => void;
13
+ /** Every stream, ended (the server is closing). */
14
+ close: () => void;
15
+ size: () => number;
16
+ };
17
+ export declare function createTaskRooms(fingerprint: (root: string) => string | null, tickMs?: number): TaskRooms;