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.
@@ -2,6 +2,7 @@
2
2
  * produced by the existing trusted server renderers, never by a model-supplied
3
3
  * fragment. These projections do not authenticate, mutate or grant authority. */
4
4
  import type { TeamSnapshot } from './team-contract.js';
5
+ import type { RunActivity } from './activity-line.js';
5
6
  import type { BrandIconId } from './brand-icons.js';
6
7
  import type { AssignmentSnapshot } from './assignment.js';
7
8
  export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
@@ -26,7 +27,12 @@ export type BrowserNavigationItem = {
26
27
  href: string;
27
28
  active: boolean;
28
29
  count?: number;
30
+ /** What the count covers, said aloud and on hover: "3 need you in shop". */
31
+ countLabel?: string;
29
32
  };
33
+ /** The Tasks badge's words: the count and the projects it covers
34
+ * ("3 need you in shop", "1 needs you across all your projects"). */
35
+ export declare function needsYouLabelOf(count: number, projectName: string | null, saturated?: boolean): string;
30
36
  export type BrowserChatLink = {
31
37
  kind: 'project' | 'task';
32
38
  title: string;
@@ -53,6 +59,8 @@ export type BrowserCrewItem = {
53
59
  detail?: string;
54
60
  /** "<name> is on it.": the person's own lead took it on, by the name they gave it. */
55
61
  lead?: string;
62
+ /** A running task: what its agent did last, and when. */
63
+ activity?: RunActivity;
56
64
  };
57
65
  export type BrowserMessage = {
58
66
  id: number;
@@ -419,6 +427,13 @@ export type BrowserTaskView = {
419
427
  action: BrowserLink | null;
420
428
  } | null;
421
429
  } | null;
430
+ /** What the agent did last on the live run, and when; the page words it against its own clock (activity-line.ts). */
431
+ activity?: RunActivity | null;
432
+ /** The task's live stream (/t/<id>/live): change nudges and who else has it open. Absent: the page reads on its beat. */
433
+ live?: {
434
+ href: string;
435
+ at: string | null;
436
+ } | null;
422
437
  /** Stop, on the Building card: the exact live build's stop form (posts its run id). */
423
438
  stop?: {
424
439
  action: string;
@@ -1166,6 +1181,8 @@ export type BrowserHomeAgent = {
1166
1181
  phase: string;
1167
1182
  project: string | null;
1168
1183
  since: string;
1184
+ /** What the agent did last, and when. */
1185
+ activity?: RunActivity;
1169
1186
  };
1170
1187
  export type BrowserHomeCount = {
1171
1188
  key: 'working' | 'waiting' | 'ready' | 'done';
@@ -1277,4 +1294,4 @@ export declare function browserProjectsOf(projects: readonly {
1277
1294
  name: string;
1278
1295
  path: string;
1279
1296
  }[]): BrowserProject[];
1280
- export declare function browserNavigationOf(path: string, project?: string | null, needsYou?: number): BrowserNavigationItem[];
1297
+ export declare function browserNavigationOf(path: string, project?: string | null, needsYou?: number, tasksProject?: string | null, needsYouLabel?: string): BrowserNavigationItem[];
@@ -1,6 +1,12 @@
1
1
  import { browserCrewFromIndex } from './browser-crew.js';
2
2
  export { browserCrewFromIndex, browserWorkActionHref } from './browser-crew.js';
3
3
  import { workIndexPage } from './work-index.js';
4
+ /** The Tasks badge's words: the count and the projects it covers
5
+ * ("3 need you in shop", "1 needs you across all your projects"). */
6
+ export function needsYouLabelOf(count, projectName, saturated = false) {
7
+ const where = projectName === null ? 'across all your projects' : `in ${projectName}`;
8
+ return `${count}${saturated ? '+' : ''} ${count === 1 && !saturated ? 'needs' : 'need'} you ${where}`;
9
+ }
4
10
  /** Safe inside a script[type=application/json] element. JSON escaping alone
5
11
  * does not stop the HTML parser from closing that element at </script>. */
6
12
  export function serializeBrowserWorkspace(workspace) {
@@ -31,14 +37,14 @@ export function browserProjectsOf(projects) {
31
37
  knowledgeHref: `/settings/knowledge?repo=${encodeURIComponent(project.path)}` }];
32
38
  });
33
39
  }
34
- export function browserNavigationOf(path, project = null, needsYou = 0) {
40
+ export function browserNavigationOf(path, project = null, needsYou = 0, tasksProject = project, needsYouLabel) {
35
41
  const pathname = path.split('?')[0].split('#')[0];
36
42
  const knowledge = pathname === '/settings/knowledge' || pathname.startsWith('/settings/knowledge/');
37
43
  return [
38
44
  { label: 'Chat', href: '/chat', active: pathname === '/chat' },
39
- { label: 'Tasks', href: `/work${project === null ? '' : `?project=${encodeURIComponent(project)}`}`,
45
+ { label: 'Tasks', href: `/work${tasksProject === null ? '' : `?project=${encodeURIComponent(tasksProject)}`}`,
40
46
  active: pathname === '/work' || pathname === '/tasks' || pathname.startsWith('/t/') || pathname.startsWith('/r/'),
41
- ...(needsYou > 0 ? { count: needsYou } : {}) },
47
+ ...(needsYou > 0 ? { count: needsYou, ...(needsYouLabel === undefined ? {} : { countLabel: needsYouLabel }) } : {}) },
42
48
  { label: 'Flows', href: '/flows', active: pathname === '/flows' || pathname.startsWith('/flows/') },
43
49
  { label: 'Projects', href: '/projects', active: pathname === '/projects' },
44
50
  { label: 'Knowledge', href: `/settings/knowledge${project === null ? '' : `?repo=${encodeURIComponent(project)}`}`, active: knowledge },
package/dist/builder.js CHANGED
@@ -60,6 +60,7 @@ import { invokeAgent } from "./invoke.js";
60
60
  import { TELEGRAM_TOKEN_ENVS } from "./names.js";
61
61
  import { OPENROUTER_ENV_KEY, auditOf, ALL_CREDENTIAL_ENV } from "./provider.js";
62
62
  import { openLiveLog } from "./live.js";
63
+ import { withRunActivity } from "./task-activity.js";
63
64
  import { CheckProgressTracker } from "./check-progress.js";
64
65
  import { captureParkEvidence, captureTerminalDiff, captureBaseTree, evidenceRoot, handoffName, isImagePath, WORKTREE_EVIDENCE_DIR, storeHandoffArtifact, looksLikeProtocolFile, mailboxName, progressFileName, proofFileName, rubricFileName, proposalFileName, quarantineMailboxes, readMailbox, readVerifiedArtifact, scanForSecrets, redactSecretLines, storeEvidence, validateScreenshotBytes, imageDimensions, SCREENSHOT_BYTE_CAP, boundStreamHeadTail } from "./evidence.js";
65
66
  import { PROOF_LIMITS, parseProof, serializeProof, adjudicate, artifactManifestOnly, sameDiffStatFacts } from "./proof.js";
@@ -1139,7 +1140,8 @@ export async function build(store, request) {
1139
1140
  // The live window (arc 1): display state beside the run, never evidence.
1140
1141
  // Every streaming transport emits events now (peek); a file that cannot
1141
1142
  // open is a null, and a null never costs a build.
1142
- const liveLog = openLiveLog(root, request.runId);
1143
+ // What the agent did last rides the same stream into one row (live tasks).
1144
+ const liveLog = withRunActivity(openLiveLog(root, request.runId), store, request.runId, clock);
1143
1145
  // The retry base (steering fix for run 1465's proof): the SAME pinned
1144
1146
  // base the machine will use to capture the sealed diff (settleProof
1145
1147
  // below, mirroring store.firstBuilderBase's own doc). Null on a first
@@ -1329,7 +1331,7 @@ export async function build(store, request) {
1329
1331
  omitEnv: AGENT_ENV_DENYLIST,
1330
1332
  ...(agent === undefined ? {} : { runner: agent }),
1331
1333
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
1332
- ...(liveLog === null ? {} : { onStreamEvent: (event) => liveLog.observe(event) }),
1334
+ onStreamEvent: (event) => liveLog.observe(event),
1333
1335
  // The receipt (finding 8): the stream proved the prompt reached the
1334
1336
  // agent — settle delivery NOW, durably, whatever happens to the run
1335
1337
  // later. The runner latches and isolates this callback; a throw
@@ -94,7 +94,6 @@ export declare function prepareDesktopUpdate(stateDir: string, installed: string
94
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
95
95
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
96
96
  export declare function desktopUpdateStatus(stateDir: string): {
97
- version?: never;
98
97
  active: boolean;
99
98
  phase: string;
100
99
  detail: string;
@@ -108,6 +107,7 @@ export declare function desktopUpdateStatus(stateDir: string): {
108
107
  wasRunning?: never;
109
108
  backupPath?: never;
110
109
  updateId?: never;
110
+ version?: never;
111
111
  buildId?: never;
112
112
  workDir?: never;
113
113
  } | {
@@ -0,0 +1,2 @@
1
+ import type { Store } from "./store.js";
2
+ export declare function flowCardTaskLine(store: Store, taskId: string, repo: string, now: Date, secrets?: readonly string[]): string;
@@ -0,0 +1,57 @@
1
+ /** The task behind an agent zone, read through the same status as its task page.
2
+ * Only saved milestone progress belongs on the card; tool calls and logs do not. */
3
+ import { assignmentOf } from "./assignment.js";
4
+ import { scanForSecrets } from "./evidence.js";
5
+ import { scrubIntegrationText } from "./integrations.js";
6
+ import { assignmentStatusFacts, taskStatusOf } from "./task-status.js";
7
+ import { taskWorkSummaryOf } from "./work-summary.js";
8
+ import { buildProgressOf } from "./workspace-ui.js";
9
+ /** Redact before shortening: a cut-off credential must never slip through a scanner.
10
+ * Structured arguments, commands and credential assignments aren't card copy. */
11
+ function cardReason(text, fallback, secrets) {
12
+ if (text === null)
13
+ return fallback;
14
+ const raw = /[{}\[\]`]|\b[\w.-]+\s*=|(?:^|\s)--[\w-]+|\b(?:arguments|tool_input|command|cmd|password|passwd|token|secret|api[_ -]?key|authorization)\s*:|\bcurl\s/i.test(text);
15
+ if (raw || secrets.some(secret => secret !== "" && text.includes(secret)) || scanForSecrets(text).length > 0)
16
+ return fallback;
17
+ const clean = scrubIntegrationText(text, secrets).replace(/[\p{Cf}]/gu, "").replace(/[.\s]+$/, "");
18
+ if (clean === "" || clean.includes("[hidden]"))
19
+ return fallback;
20
+ return clean.length > 100 ? `${clean.slice(0, 99).trimEnd()}…` : clean;
21
+ }
22
+ export function flowCardTaskLine(store, taskId, repo, now, secrets = []) {
23
+ const access = { principal: "operator", repos: [repo], dependencyRepos: [repo] };
24
+ const assignment = assignmentOf(store, taskId, now, access);
25
+ if (assignment === null)
26
+ return "Task unavailable";
27
+ const work = taskWorkSummaryOf(store, assignment.activeTaskId, now, access);
28
+ if (work === null)
29
+ return "Task unavailable";
30
+ const live = work.liveRunId === null ? null : store.getRun(work.liveRunId);
31
+ const facts = assignmentStatusFacts(assignment, { work: work.status, planning: live?.role === "planner" });
32
+ // The canvas reads no evidence files. Keep the machine's recorded check visible
33
+ // when the full receipt has no artifact root from which to read its check.
34
+ const check = assignment.receipt === null ? null : store.runCheckFor(assignment.receipt.runId);
35
+ if (facts.checks?.status === "unavailable" && check !== null)
36
+ facts.checks = { ...facts.checks, status: check.status, exitCode: check.exitCode };
37
+ const status = taskStatusOf(facts);
38
+ if (status.headline === "Needs you") {
39
+ const fallback = status.need?.action.label ?? "Open the task";
40
+ // The question itself is useful; routine approvals are clearer as the short action.
41
+ const what = status.need?.key === "answer" || status.need?.key === "other"
42
+ ? cardReason(status.sentence, fallback, secrets) : fallback;
43
+ return `Waiting on you: ${what}`;
44
+ }
45
+ if (status.headline === "Building" && live !== null) {
46
+ const ref = store.lookupRef(assignment.activeTaskId);
47
+ const checkpoint = store.latestCheckpointForRun(live.id);
48
+ const progress = buildProgressOf(checkpoint?.taskRef !== ref?.id ? null : checkpoint?.snapshot.milestones.map(one => ({ description: null, state: one.state, note: one.note })));
49
+ if (progress?.stuck != null)
50
+ return `Stuck: ${cardReason(progress.stuck.why, `step ${progress.step} of ${progress.total}`, secrets)}`;
51
+ if (progress !== null)
52
+ return `Building · step ${progress.step} of ${progress.total}`;
53
+ }
54
+ if (status.headline === "Failed")
55
+ return `Stuck: ${cardReason(status.sentence, "Open the task", secrets)}`;
56
+ return status.headline === "Ready for review" ? "Ready" : status.headline;
57
+ }
@@ -12,8 +12,8 @@ export type FlowViewer = {
12
12
  response: ServerResponse;
13
13
  valid: () => boolean;
14
14
  };
15
- /** What changes when anything on the canvas changes: one row, one read, kept short and opaque. */
16
- export declare function flowFingerprint(store: Store, flow: number): string | null;
15
+ /** What changes when anything on the canvas changes: metadata only, kept short and opaque. */
16
+ export declare function flowFingerprint(store: Store, flow: number, now?: Date): string | null;
17
17
  export type FlowRooms = {
18
18
  /** Keep this page's stream on the flow until it closes; the first event says where the flow stands. */
19
19
  join: (flow: number, viewer: FlowViewer) => void;
package/dist/flow-live.js CHANGED
@@ -2,9 +2,9 @@
2
2
  * The live canvas (v88): a flow open in several browsers stays the same in
3
3
  * all of them, and each shows who else has it open.
4
4
  *
5
- * - A change nudge. While anyone has a flow open, one cheap query a second
6
- * reads the flow's fingerprint (its revision, cards, moves, comments,
7
- * watchers, step runs and triggers); when it changes, every open page
5
+ * - A change nudge. While anyone has a flow open, a small read each second
6
+ * checks the flow's fingerprint (its revision, cards, moves, comments,
7
+ * watchers, step runs, linked task progress and triggers); when it changes, every open page
8
8
  * hears "change" and reads the flow again the usual way. The nudge
9
9
  * carries no card data: what a page shows is always what its own read
10
10
  * returned, under its own checks.
@@ -20,17 +20,78 @@ const TICK_MS = 1_000;
20
20
  const CHECK_EVERY = 15;
21
21
  const HEARTBEAT_EVERY = 15;
22
22
  const PRESENCE_SETTLE_MS = 150;
23
- /** What changes when anything on the canvas changes: one row, one read, kept short and opaque. */
24
- export function flowFingerprint(store, flow) {
25
- const row = store.handle.prepare(`SELECT f.state || '|' || f.revision || '|' || f.updated_at || '|' || coalesce(f.owner, '') AS head,
23
+ // Statements belong to their database, without keeping closed Stores alive.
24
+ const fingerprintStatements = new WeakMap();
25
+ function fingerprintStatement(store) {
26
+ const cached = fingerprintStatements.get(store.handle);
27
+ if (cached !== undefined)
28
+ return cached;
29
+ // Match Store.taskFamilyOf/taskFamilyProjection: built-in tasks, same-repo
30
+ // edges, a revision brief bound to the parent's run, and at most 63 edges.
31
+ // Walk up only from active cards, then down from their proven roots. A
32
+ // missing/invalid/cyclic/over-depth ancestry leaves the linked task alone.
33
+ const statement = store.handle.prepare(`WITH RECURSIVE linked AS MATERIALIZED (
34
+ SELECT DISTINCT tr.id AS ref_id, tr.external_id AS id, tr.repo, tr.revision_of, tr.revision_brief_artifact
35
+ FROM flow_card c JOIN task_ref tr ON tr.external_id = c.task AND tr.backend = 'built-in'
36
+ JOIN task t ON t.id = tr.external_id
37
+ WHERE c.flow = $flow AND c.state = 'active' AND tr.repo IS NOT NULL
38
+ ), ancestors(linked_ref, ref_id, id, repo, revision_of, revision_brief_artifact, depth) AS (
39
+ SELECT ref_id, ref_id, id, repo, revision_of, revision_brief_artifact, 0 FROM linked
40
+ UNION ALL
41
+ SELECT child.linked_ref, parent.id, parent.external_id, parent.repo, parent.revision_of, parent.revision_brief_artifact, child.depth + 1
42
+ FROM ancestors child
43
+ JOIN task_ref parent ON parent.backend = 'built-in' AND parent.external_id = child.revision_of AND parent.repo IS child.repo
44
+ JOIN task t ON t.id = parent.external_id
45
+ JOIN artifact brief ON brief.id = child.revision_brief_artifact AND brief.kind = 'revision-brief'
46
+ JOIN run source_run ON source_run.id = brief.run AND source_run.task_ref = parent.id
47
+ WHERE child.depth < 63
48
+ ), roots AS MATERIALIZED (
49
+ SELECT DISTINCT ref_id, id, repo FROM ancestors WHERE revision_of IS NULL
50
+ ), descendants(ref_id, id, repo, depth) AS (
51
+ SELECT ref_id, id, repo, 0 FROM roots
52
+ UNION ALL
53
+ SELECT child.id, child.external_id, child.repo, parent.depth + 1 FROM descendants parent
54
+ JOIN task_ref child ON child.backend = 'built-in' AND child.revision_of = parent.id AND child.repo IS parent.repo
55
+ JOIN task t ON t.id = child.external_id
56
+ JOIN artifact brief ON brief.id = child.revision_brief_artifact AND brief.kind = 'revision-brief'
57
+ JOIN run source_run ON source_run.id = brief.run AND source_run.task_ref = parent.ref_id
58
+ WHERE parent.depth < 63
59
+ ), members AS (
60
+ SELECT ref_id FROM descendants
61
+ UNION
62
+ SELECT ref_id FROM linked WHERE NOT EXISTS (
63
+ SELECT 1 FROM ancestors WHERE linked_ref = linked.ref_id AND revision_of IS NULL
64
+ )
65
+ ) SELECT f.state || '|' || f.revision || '|' || f.updated_at || '|' || coalesce(f.owner, '') AS head,
26
66
  (SELECT count(*) || ':' || coalesce(max(updated_at), '') FROM flow_card WHERE flow = f.id) AS cards,
27
67
  (SELECT coalesce(max(e.id), 0) FROM flow_event e JOIN flow_card c ON c.id = e.card WHERE c.flow = f.id) AS moves,
28
68
  (SELECT coalesce(max(m.id), 0) FROM flow_comment m JOIN flow_card c ON c.id = m.card WHERE c.flow = f.id) AS comments,
29
69
  (SELECT count(*) || ':' || coalesce(max(w.added_at), '') FROM flow_card_watcher w JOIN flow_card c ON c.id = w.card WHERE c.flow = f.id) AS watchers,
30
70
  (SELECT count(*) || ':' || coalesce(sum(r.attempts), 0) || ':' || coalesce(max(coalesce(r.finished_at, r.started_at)), '') || ':' || coalesce(sum(r.state = 'running'), 0)
31
71
  FROM flow_step_run r JOIN flow_card c ON c.id = r.card WHERE c.flow = f.id) AS runs,
32
- (SELECT count(*) || ':' || coalesce(max(updated_at), '') || ':' || coalesce(max(last_at), '') || ':' || coalesce(sum(failures), 0) FROM flow_trigger WHERE flow = f.id) AS triggers
33
- FROM flow f WHERE f.id = ?`).get(flow);
72
+ (SELECT count(*) || ':' || coalesce(max(updated_at), '') || ':' || coalesce(max(last_at), '') || ':' || coalesce(sum(failures), 0) FROM flow_trigger WHERE flow = f.id) AS triggers,
73
+ (SELECT json_group_array(json_array(tr.id, t.id, t.state, t.updated_at, tr.plan,
74
+ s.digest, s.approved_digest, s.approved_at,
75
+ (SELECT max(p.id) FROM run_checkpoint p WHERE p.task_ref = tr.id),
76
+ (SELECT q.lease_id FROM claim q WHERE q.task_ref = tr.id AND q.released_at IS NULL AND q.expires_at > $now ORDER BY q.lease_generation DESC LIMIT 1),
77
+ (SELECT json_group_array(json_array(r.id, r.outcome, r.finished_at, r.reason,
78
+ (SELECT json_array(k.status, k.exit_code, k.updated_at) FROM run_check k WHERE k.run = r.id)) ORDER BY r.id) FROM run r WHERE r.task_ref = tr.id),
79
+ (SELECT json_group_array(json_array(d.id, d.state, d.question, d.answered_at) ORDER BY d.id) FROM decision d JOIN run r ON r.id = d.run WHERE r.task_ref = tr.id),
80
+ (SELECT json_group_array(json_array(h.id, h.reason) ORDER BY h.id) FROM hold h WHERE h.task_ref = tr.id AND (h.until IS NULL OR h.until > $now)),
81
+ (SELECT max(a.id) FROM action_ledger a WHERE a.task_id = t.id)) ORDER BY tr.id)
82
+ FROM task_ref tr JOIN task t ON t.id = tr.external_id
83
+ LEFT JOIN task_scope s ON s.task_id = t.id
84
+ WHERE tr.id IN (SELECT ref_id FROM members)) AS tasks
85
+ FROM flow f WHERE f.id = $flow`);
86
+ fingerprintStatements.set(store.handle, statement);
87
+ return statement;
88
+ }
89
+ /** What changes when anything on the canvas changes: metadata only, kept short and opaque. */
90
+ export function flowFingerprint(store, flow, now = new Date()) {
91
+ // A revision can progress while its card still links to the original task,
92
+ // including a build in another project. The page read admits the viewer;
93
+ // this stream only carries a hash. No evidence files or logs are read.
94
+ const row = fingerprintStatement(store).get({ $flow: flow, $now: now.toISOString() });
34
95
  return row === undefined ? null : createHash("sha256").update(Object.values(row).map(String).join("|")).digest("hex").slice(0, 16);
35
96
  }
36
97
  export function createFlowRooms(fingerprint, tickMs = TICK_MS) {
package/dist/flows-ui.js CHANGED
@@ -14,6 +14,8 @@ import { mailboxReady } from "./mailbox.js";
14
14
  import { labelOf, nameOf } from "./teammate-admin.js";
15
15
  import { callWords, receiptWords } from "./teammate-tools.js";
16
16
  import { undoFor } from "./teammate-week.js";
17
+ import { flowCardTaskLine } from "./flow-card-task.js";
18
+ import { readFlowSecrets } from "./flow-secrets.js";
17
19
  const e = (value) => String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
18
20
  const projectName = (repo) => repo.split(/[\\/]/).filter(Boolean).pop() ?? repo;
19
21
  export const FLOWS_CSS = `.flows{max-width:880px;min-width:0}.flows .card{padding:16px 18px;margin:12px 0}.flows .flow-row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;flex-wrap:wrap}.flows .flow-row h2{font-size:1.05rem;margin:0}.flows .flow-counts{font-size:.85rem;color:var(--so-muted)}.flows form{display:grid;gap:10px;margin:0}.flows label{display:grid;gap:6px}.flows input,.flows select{box-sizing:border-box;width:100%;max-width:100%}.flows button{justify-self:start;min-height:44px}.flows .flow-fallback ol{padding-left:20px}.flows summary{cursor:pointer;min-height:44px;display:flex;align-items:center;font-weight:600}.flows .flow-buttons{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:10px 0 0}.flows .flow-import{margin-top:18px;padding-top:14px;border-top:1px solid var(--so-line)}.flows .flow-import h3{font-size:.95rem;margin:0}.flows .flow-terms{list-style:none;padding:0;margin:8px 0 16px;display:grid;gap:10px}.flows .flow-terms li{white-space:pre-line;overflow-wrap:anywhere}.flows .flow-terms pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:.8rem;margin:6px 0 0}.flows .flow-import-preview h2{font-size:1rem;margin:20px 0 4px}.flows .flow-untrusted{font-weight:600}.flows button.primary{background:var(--primary);color:var(--primary-foreground);border-color:var(--primary)}.flows fieldset{border:0;padding:0;margin:0;display:grid;gap:10px;min-width:0}.flows legend{font-weight:600;padding:0;margin-bottom:6px}.flows textarea[hidden]{display:none}.flows button.secondary{background:transparent;color:var(--foreground);border:1px solid var(--border)}@media(max-width:600px){.flows input,.flows select{font-size:16px}}@media(max-width:760px){.flows .card{padding:10px 12px;margin:8px 0;position:relative}.flows .flow-row{row-gap:2px}.flows .flow-row h2 a::after{content:"";position:absolute;inset:0;border-radius:inherit}.flows .flow-counts{line-height:1.35}}`;
@@ -84,9 +86,19 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
84
86
  const sortZones = new Set(stages.filter(one => one.kind === "sort").map(one => one.id));
85
87
  const decisions = sortZones.size === 0 ? new Map() : store.flowSortDecisions(flow.id);
86
88
  const mates = new Map(store.teammates([flow.repo]).map(one => [one.id, one]));
89
+ const taskSecrets = new Map();
90
+ const secretsFor = (repo) => {
91
+ if (!taskSecrets.has(repo))
92
+ taskSecrets.set(repo, Object.values(readFlowSecrets(setup.dir, repo)));
93
+ return taskSecrets.get(repo);
94
+ };
95
+ const now = new Date();
87
96
  const cards = store.flowCards(flow.id, true).filter(card => card.state === "active" || Date.now() - Date.parse(card.updatedAt) < 7 * 86_400_000).map((card) => {
88
97
  const stage = stages.find(one => one.id === card.stage);
89
98
  const task = card.task ?? card.primaryTask;
99
+ // Build zones may file into another project; the flow alone does not admit its task's detail.
100
+ const taskRepo = card.task === null ? null : store.lookupRef(card.task)?.repo ?? null;
101
+ const canReadTask = taskRepo !== null && (taskRepo === flow.repo || (setup.repos.includes(taskRepo) && store.accountCanAccess(viewer.name, taskRepo)));
90
102
  const discussion = store.flowComments(card.id);
91
103
  const watchers = store.flowCardWatchers(card.id);
92
104
  const decider = stage?.kind === "approval" ? deciderOf(stage, flow) : null;
@@ -136,7 +148,9 @@ export function flowView(store, flow, viewer, selectedCard, setup = { dir: null,
136
148
  ...(content.items === undefined ? {} : { items: content.items.map((item, index) => ({ number: index + 1, title: item.title, lines: whyLines(item.why), source: item.source, url: item.url,
137
149
  image: item.shot === null || content.shots === null ? null : { src: `/r/${content.shots.run}/evidence/${item.shot}`, caption: itemCaption(index + 1, item) } })) }) });
138
150
  return {
139
- id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state, waiting: card.waiting,
151
+ id: card.id, title: card.title, description: card.description, stage: card.stage, state: card.state,
152
+ waiting: card.state === "active" && card.task !== null && (stage?.kind === "task" || stage?.kind === "report")
153
+ ? canReadTask ? flowCardTaskLine(store, card.task, taskRepo, now, secretsFor(taskRepo)) : "Task unavailable" : card.waiting,
140
154
  task: task === null ? null : { id: task, href: `/t/${encodeURIComponent(task)}` },
141
155
  createdBy: card.createdBy, updatedAt: card.updatedAt,
142
156
  canDecide,
package/dist/live.d.ts CHANGED
@@ -25,6 +25,20 @@ export declare function liveDir(root: string): string;
25
25
  /** The exact filename, from the numeric id and nothing else — no user
26
26
  * input is ever parsed into this path. */
27
27
  export declare function liveLogPath(root: string, runId: number): string;
28
+ /**
29
+ * The fixed tool vocabulary. Known harness tools map to OUR kinds and
30
+ * phrases; anything unrecognized gets the generic one. The tool's own
31
+ * name, arguments, and metadata never reach the file: a tool named after
32
+ * a secret stays unspoken.
33
+ */
34
+ export type LiveActivityKind = "session" | "message" | "edit" | "command" | "read" | "search" | "lookup" | "plan" | "delegate" | "tool";
35
+ export declare const LIVE_ACTIVITY_KINDS: readonly LiveActivityKind[];
36
+ /**
37
+ * What one stream event says the agent just did, as one fixed kind: the
38
+ * last-activity line's only input. Never text, names or arguments; an
39
+ * event that says nothing (results, deltas, subagent chatter) is null.
40
+ */
41
+ export declare function liveActivityKind(event: Record<string, unknown>): LiveActivityKind | null;
28
42
  /**
29
43
  * One stream event → zero or more display lines, in the fixed vocabulary.
30
44
  * Assistant TEXT is the agent's own words (sanitized below); tool use is a
package/dist/live.js CHANGED
@@ -32,28 +32,77 @@ export function liveDir(root) {
32
32
  export function liveLogPath(root, runId) {
33
33
  return join(liveDir(root), `${Math.trunc(runId)}.log`);
34
34
  }
35
+ export const LIVE_ACTIVITY_KINDS = ["session", "message", "edit", "command", "read", "search", "lookup", "plan", "delegate", "tool"];
36
+ const TOOL_KINDS = new Map([
37
+ ["Edit", "edit"],
38
+ ["Write", "edit"],
39
+ ["MultiEdit", "edit"],
40
+ ["NotebookEdit", "edit"],
41
+ ["Bash", "command"],
42
+ ["Read", "read"],
43
+ ["Glob", "search"],
44
+ ["Grep", "search"],
45
+ ["LS", "read"],
46
+ ["WebFetch", "lookup"],
47
+ ["WebSearch", "lookup"],
48
+ ["TodoWrite", "plan"],
49
+ ["Task", "delegate"],
50
+ ]);
51
+ const KIND_HEADLINES = {
52
+ session: "the agent session started",
53
+ message: "",
54
+ edit: "→ editing files",
55
+ command: "→ running a command",
56
+ read: "→ reading the code",
57
+ search: "→ searching the code",
58
+ lookup: "→ looking something up",
59
+ plan: "→ organizing its plan",
60
+ delegate: "→ delegating to a helper",
61
+ tool: "→ using a tool",
62
+ };
63
+ const toolKind = (name) => TOOL_KINDS.get(typeof name === "string" ? name : "") ?? "tool";
35
64
  /**
36
- * The fixed tool-headline vocabulary. Known harness tools map to OUR
37
- * phrases; anything unrecognized gets the generic line. The tool's own
38
- * name, arguments, and metadata never reach the file: a tool named after
39
- * a secret stays unspoken.
65
+ * What one stream event says the agent just did, as one fixed kind: the
66
+ * last-activity line's only input. Never text, names or arguments; an
67
+ * event that says nothing (results, deltas, subagent chatter) is null.
40
68
  */
41
- const TOOL_HEADLINES = new Map([
42
- ["Edit", "→ editing files"],
43
- ["Write", "→ editing files"],
44
- ["MultiEdit", "→ editing files"],
45
- ["NotebookEdit", "→ editing files"],
46
- ["Bash", "→ running a command"],
47
- ["Read", "→ reading the code"],
48
- ["Glob", "→ searching the code"],
49
- ["Grep", "→ searching the code"],
50
- ["LS", "→ reading the code"],
51
- ["WebFetch", "→ looking something up"],
52
- ["WebSearch", "→ looking something up"],
53
- ["TodoWrite", "→ organizing its plan"],
54
- ["Task", "→ delegating to a helper"],
55
- ]);
56
- const TOOL_HEADLINE_GENERIC = "→ using a tool";
69
+ export function liveActivityKind(event) {
70
+ try {
71
+ const type = String(event["type"] ?? "");
72
+ if ((type === "system" && String(event["subtype"] ?? "") === "init") || type === "thread.started" || type === "init")
73
+ return "session";
74
+ if (type === "item.completed") {
75
+ const item = event["item"];
76
+ const kind = item === undefined || item === null ? "" : String(item["type"] ?? "");
77
+ return kind === "agent_message" ? "message" : kind === "command_execution" ? "command" : kind === "file_change" ? "edit" : kind === "web_search" ? "lookup" : null;
78
+ }
79
+ if (type === "message")
80
+ return String(event["role"] ?? "") === "assistant" && event["delta"] !== true ? "message" : null;
81
+ if (type === "tool_call" || type === "tool_use")
82
+ return toolKind(event["name"] ?? event["tool"]?.["name"]);
83
+ if (type !== "assistant")
84
+ return null;
85
+ const parent = event["parent_tool_use_id"];
86
+ if (parent !== undefined && parent !== null)
87
+ return null;
88
+ const content = event["message"]?.["content"];
89
+ if (!Array.isArray(content))
90
+ return null;
91
+ let last = null;
92
+ for (const block of content) {
93
+ if (block === null || typeof block !== "object")
94
+ continue;
95
+ if (block["type"] === "tool_use")
96
+ last = toolKind(block["name"]);
97
+ else if (block["type"] === "text" && typeof block["text"] === "string" && block["text"].trim() !== "")
98
+ last = "message";
99
+ }
100
+ return last;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
57
106
  /** Strip ANSI escapes and every control character; tabs become spaces. */
58
107
  function stripControls(text) {
59
108
  return text
@@ -108,7 +157,7 @@ export function renderTranscriptLines(event) {
108
157
  }
109
158
  if (type === "tool_call" || type === "tool_use") {
110
159
  const name = event["name"] ?? event["tool"]?.["name"];
111
- return [TOOL_HEADLINES.get(typeof name === "string" ? name : "") ?? TOOL_HEADLINE_GENERIC];
160
+ return [KIND_HEADLINES[toolKind(name)]];
112
161
  }
113
162
  if (type !== "assistant")
114
163
  return [];
@@ -136,7 +185,7 @@ export function renderTranscriptLines(event) {
136
185
  }
137
186
  else if (kind === "tool_use") {
138
187
  const name = block["name"];
139
- lines.push(TOOL_HEADLINES.get(typeof name === "string" ? name : "") ?? TOOL_HEADLINE_GENERIC);
188
+ lines.push(KIND_HEADLINES[toolKind(name)]);
140
189
  }
141
190
  }
142
191
  return lines;
package/dist/operate.js CHANGED
@@ -90,7 +90,7 @@ import { openStore, openStoreNoMigrate, CONCURRENT_WRITER_WAIT_MS, isDatabaseBus
90
90
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
91
91
  import { authorizePlanUnderMode } from "./plan-auto.js";
92
92
  import { ghDispatchAdapter, mirrorTaskId, syncPass } from "./sync.js";
93
- import { sweepLiveLogs } from "./live.js";
93
+ import { LIVE_FILE_BOUND, LIVE_RETAIN_MS, sweepLiveLogs } from "./live.js";
94
94
  import { configPath, addRepos, removeRepos, updateRepos, loadRepos, loadProjectRegistry, updateProjectRegistry } from "./repos.js";
95
95
  import { deleteProject, holdingsWords, projectHoldings, projectRunning } from "./project-delete.js";
96
96
  import { pushPass } from "./push.js";
@@ -4671,6 +4671,11 @@ async function reconcileCommand(flags, context) {
4671
4671
  // day, orphans on mtime, active runs untouchable. Never evidence, never
4672
4672
  // load-bearing — a sweep that finds nothing is the common case.
4673
4673
  const liveSwept = sweepLiveLogs(store, context.evidenceRoot, clock());
4674
+ // What each agent did last goes by the same rules, from its own row.
4675
+ try {
4676
+ store.sweepRunActivity(clock(), LIVE_RETAIN_MS, LIVE_FILE_BOUND);
4677
+ }
4678
+ catch { /* display state: the next pass sweeps */ }
4674
4679
  const worktrees = new WorktreePool(store, {
4675
4680
  root: pool,
4676
4681
  ...(context.gitRunner === undefined ? {} : { runner: context.gitRunner }),
package/dist/planner.js CHANGED
@@ -31,6 +31,7 @@ import { TOKEN_ENVS as TELEGRAM_TOKEN_ENVS } from "./telegram.js";
31
31
  import { evidenceRoot, mailboxName, planFileName, quarantineMailboxes, readMailbox, storeEvidence, writeEvidenceFile, } from "./evidence.js";
32
32
  import { MARKER as LEASE_MARKER } from "./worktree.js";
33
33
  import { openLiveLog } from "./live.js";
34
+ import { withRunActivity } from "./task-activity.js";
34
35
  import { proveTreeUntouched, snapshotIgnored } from "./tree-proof.js";
35
36
  import { CLAUDE_LIMITS, ACCEPTANCE_LIMITS } from "./scope.js";
36
37
  import { normalizeStructuredJson, storeStructuredAttempt, validationErrorsJson, STRUCTURED_REPAIR_ATTEMPTS, STRUCTURED_REPAIR_MAX_TURNS, STRUCTURED_REPAIR_TIMEOUT_MS, } from "./structured-output.js";
@@ -590,7 +591,7 @@ export async function plan(store, request) {
590
591
  }
591
592
  // The live window (peek): the same transcript file the builder keeps,
592
593
  // so `toolroll peek` and the run page can watch this session too.
593
- const liveLog = openLiveLog(root, request.runId);
594
+ const liveLog = withRunActivity(openLiveLog(root, request.runId), store, request.runId, clock);
594
595
  // The durable run row, not a caller's duplicate options, is the route
595
596
  // every correction inherits.
596
597
  const provider = logicalRun.provider;
@@ -730,7 +731,7 @@ export async function plan(store, request) {
730
731
  ...(agent === undefined ? {} : { runner: agent }),
731
732
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
732
733
  clock,
733
- ...(liveLog === null ? {} : { onStreamEvent: (event) => liveLog.observe(event) }),
734
+ onStreamEvent: (event) => liveLog.observe(event),
734
735
  });
735
736
  const firstProof = await proveAfterInvocation();
736
737
  if (firstProof !== null)
@@ -883,7 +884,7 @@ export async function plan(store, request) {
883
884
  ...(agent === undefined ? {} : { runner: agent }),
884
885
  ...(request.onProviderSpawn === undefined ? {} : { onSpawn: request.onProviderSpawn }),
885
886
  clock,
886
- ...(liveLog === null ? {} : { onStreamEvent: (event) => liveLog.observe(event) }),
887
+ onStreamEvent: (event) => liveLog.observe(event),
887
888
  });
888
889
  }
889
890
  catch (error) {
package/dist/serve.d.ts CHANGED
@@ -274,6 +274,11 @@ type Chrome = {
274
274
  scope?: "all" | "project" | "board-all";
275
275
  /** The saturated inbox count — never a sum of unbounded list reads. */
276
276
  inboxCount: number;
277
+ /** The project the Tasks count covers: the request's own project view
278
+ * (the open project, or /work's ?project=), null for every admitted project. */
279
+ inboxProject?: string | null;
280
+ /** The Tasks badge's words, naming what it covers: "3 need you in shop". */
281
+ inboxLabel?: string;
277
282
  inboxSaturated: boolean;
278
283
  settings: boolean;
279
284
  /** This database is a demo sandbox: banner every page, spend fenced. */