toolroll 0.9.60 → 0.9.62

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 (128) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/agent-board.d.ts +208 -0
  3. package/dist/agent-board.js +454 -0
  4. package/dist/agent-fence.d.ts +19 -0
  5. package/dist/agent-fence.js +100 -8
  6. package/dist/agent-pulse.d.ts +43 -0
  7. package/dist/agent-pulse.js +214 -0
  8. package/dist/agent-turns.d.ts +52 -0
  9. package/dist/agent-turns.js +137 -0
  10. package/dist/agents-board.d.ts +101 -0
  11. package/dist/agents-board.js +225 -0
  12. package/dist/assignment-adapters.d.ts +1 -0
  13. package/dist/assignment-status.d.ts +1 -1
  14. package/dist/assignment.d.ts +4 -0
  15. package/dist/assignment.js +8 -6
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +29 -25
  18. package/dist/browser-crew.js +2 -0
  19. package/dist/browser-workspace.d.ts +79 -1
  20. package/dist/browser-workspace.js +1 -0
  21. package/dist/builder.d.ts +8 -0
  22. package/dist/builder.js +113 -13
  23. package/dist/chat-channel.js +9 -0
  24. package/dist/chat-core.d.ts +6 -0
  25. package/dist/chat-core.js +8 -0
  26. package/dist/chat-delivery.js +22 -0
  27. package/dist/chat-messages.js +8 -1
  28. package/dist/chat-quiet.js +1 -0
  29. package/dist/chat-voice.d.ts +2 -0
  30. package/dist/chat-voice.js +2 -0
  31. package/dist/checkout-cleanup.d.ts +3 -0
  32. package/dist/checkout-cleanup.js +11 -2
  33. package/dist/cli.js +28 -11
  34. package/dist/contracts/console-api.d.ts +3 -1
  35. package/dist/contracts/console-api.js +5 -1
  36. package/dist/contracts/lead-tools.d.ts +16 -2
  37. package/dist/contracts/lead-tools.js +5 -1
  38. package/dist/desktop-update.d.ts +1 -1
  39. package/dist/evidence-pack.js +1 -1
  40. package/dist/evidence.d.ts +6 -0
  41. package/dist/evidence.js +14 -3
  42. package/dist/exec.d.ts +32 -0
  43. package/dist/exec.js +229 -10
  44. package/dist/invoke.js +110 -18
  45. package/dist/landed-cleanup.d.ts +170 -0
  46. package/dist/landed-cleanup.js +814 -0
  47. package/dist/lead-cli.js +22 -19
  48. package/dist/lead-contract.d.ts +1 -1
  49. package/dist/lead-contract.js +4 -4
  50. package/dist/lead-doors.js +45 -1
  51. package/dist/lead-tools.js +40 -7
  52. package/dist/live-input.d.ts +68 -0
  53. package/dist/live-input.js +254 -0
  54. package/dist/live.d.ts +1 -1
  55. package/dist/live.js +4 -2
  56. package/dist/maintenance.d.ts +2 -0
  57. package/dist/maintenance.js +5 -2
  58. package/dist/operate-remote-arguments.js +5 -1
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.d.ts +1 -1
  61. package/dist/operate.js +249 -26
  62. package/dist/orphan-sweep.d.ts +2 -0
  63. package/dist/orphan-sweep.js +5 -3
  64. package/dist/project-admission.d.ts +10 -6
  65. package/dist/project-admission.js +33 -11
  66. package/dist/project-delete-ui.d.ts +12 -0
  67. package/dist/project-delete-ui.js +32 -2
  68. package/dist/project-tools.d.ts +7 -11
  69. package/dist/project-tools.js +13 -37
  70. package/dist/provider.d.ts +65 -8
  71. package/dist/provider.js +80 -21
  72. package/dist/remote-main.d.ts +57 -0
  73. package/dist/remote-main.js +179 -0
  74. package/dist/result-completion.d.ts +7 -0
  75. package/dist/result-completion.js +9 -0
  76. package/dist/runner.d.ts +4 -2
  77. package/dist/runner.js +3 -3
  78. package/dist/serve.js +19 -1
  79. package/dist/server/live.d.ts +3 -0
  80. package/dist/server/live.js +35 -2
  81. package/dist/server/pages.js +59 -35
  82. package/dist/server/render-chat.js +12 -0
  83. package/dist/server/render-tasks.d.ts +15 -2
  84. package/dist/server/render-tasks.js +30 -7
  85. package/dist/server/route-table.js +4 -1
  86. package/dist/server/settings.js +5 -3
  87. package/dist/server/task-views.js +11 -0
  88. package/dist/server/tasks.js +63 -0
  89. package/dist/shared-deps.d.ts +9 -4
  90. package/dist/shared-deps.js +89 -13
  91. package/dist/staged-runtimes.d.ts +16 -4
  92. package/dist/staged-runtimes.js +160 -38
  93. package/dist/steer-live.d.ts +19 -0
  94. package/dist/steer-live.js +93 -0
  95. package/dist/steer-status.d.ts +8 -2
  96. package/dist/steer-status.js +19 -5
  97. package/dist/storage-sweep-child.d.ts +1 -0
  98. package/dist/storage-sweep-child.js +76 -0
  99. package/dist/storage-sweep-process.d.ts +48 -0
  100. package/dist/storage-sweep-process.js +204 -0
  101. package/dist/storage-sweep-schedule.d.ts +30 -0
  102. package/dist/storage-sweep-schedule.js +164 -0
  103. package/dist/storage-sweep.d.ts +31 -6
  104. package/dist/storage-sweep.js +165 -39
  105. package/dist/storage-ui.d.ts +4 -0
  106. package/dist/storage-ui.js +22 -7
  107. package/dist/storage.d.ts +6 -0
  108. package/dist/storage.js +35 -0
  109. package/dist/store.d.ts +200 -3
  110. package/dist/store.js +443 -17
  111. package/dist/surface.js +8 -4
  112. package/dist/sweep-quarantine.d.ts +28 -0
  113. package/dist/sweep-quarantine.js +153 -0
  114. package/dist/task-base.d.ts +35 -0
  115. package/dist/task-base.js +51 -0
  116. package/dist/task-status.d.ts +2 -0
  117. package/dist/task-status.js +5 -1
  118. package/dist/test-temp.d.ts +6 -3
  119. package/dist/test-temp.js +15 -9
  120. package/dist/toolroll-update.js +4 -2
  121. package/dist/work-index.d.ts +1 -0
  122. package/dist/work-index.js +32 -12
  123. package/dist/work-summary.d.ts +2 -2
  124. package/dist/workspace-revision.js +3 -1
  125. package/dist/worktree.d.ts +57 -3
  126. package/dist/worktree.js +201 -23
  127. package/package.json +1 -1
  128. package/scripts/provider-canary.mjs +235 -147
package/dist/surface.js CHANGED
@@ -67,9 +67,11 @@ const GUIDE_ROWS = [
67
67
  // D5: the central team service (connect, the central lead verbs, conversation …) and native coding sessions are
68
68
  // deprecated: they still run this release, with a warning, but are no longer declared here.
69
69
  operator("chat", "talk to your lead: one conversation across every project; --say sends one message; --follow enables crew updates and --no-follow pauses them; proposals keep their approval requirements"),
70
- { invocation: "repos add", synopsis: "add a project: a Git repository, in the console at once; the lead adds with its token", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
70
+ // Unkeyed: --remote with a different URL replaces the project's trusted remote (recorded in the action ledger).
71
+ { invocation: "repos add", synopsis: "add a project: a Git repository, in the console at once; the lead adds with its token; --remote records or replaces its trusted remote", audience: "agent", agentMayInvoke: true, mutation: "unkeyed",
71
72
  positionals: [{ name: "path", required: false, meaning: "the repository (default: the one you are in)" }],
72
- flags: [jsonFlag, { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" }],
73
+ flags: [jsonFlag, { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" },
74
+ { name: "remote", takesValue: true, meaning: "an HTTPS or SSH URL without credentials whose main may complete landed work; replaces a different recorded one (owner or an instance operator's lead only)" }],
73
75
  notableReasons: ["not-git", "unavailable", "refused", "locked"] },
74
76
  { invocation: "repos remove", synopsis: "remove a project from the console and the builder; its tasks and results stay saved", audience: "agent", agentMayInvoke: true, mutation: "identity-idempotent",
75
77
  positionals: [{ name: "path", required: true, meaning: "the repository" }],
@@ -242,7 +244,9 @@ const GUIDE_ROWS = [
242
244
  positionals: [{ name: "id", required: true, meaning: "the task" }],
243
245
  flags: [jsonFlag, dbFlag, keyFlag, { name: "undo", takesValue: false, meaning: "back to filing order" }],
244
246
  notableReasons: ["unknown-task", "claimed"] },
245
- operator("task steer", "one credentialed note for the task's agent — a running build reads it when it next checks for notes, otherwise the next attempt does; steering speaks with the operator's voice"),
247
+ operator("task steer", "one credentialed note for the task's agent — a running Claude build gets it in seconds, another running build reads it when it next checks for notes, otherwise the next attempt does; --now pauses the running Claude build first; steering speaks with the operator's voice"),
248
+ operator("task pause", "stop a running Claude build's current turn and keep the build (--run <id> names the attempt): its next note continues it; never a stop"),
249
+ operator("task continue", "resume a paused Claude build where it stopped, without a note (--run <id> names the attempt)"),
246
250
  { invocation: "task assign", synopsis: "reserve a task for one worker, or return it to the shared queue", audience: "agent", agentMayInvoke: true, mutation: "keyed",
247
251
  positionals: [{ name: "id", required: true, meaning: "the task" }],
248
252
  flags: [jsonFlag, dbFlag, keyFlag,
@@ -449,7 +453,7 @@ const REMOTE_STEP_UP = [
449
453
  const REMOTE_YES = [
450
454
  "status", "integrations", "ready", "gaps", "grants", "sync", "audit",
451
455
  "task add", "task ask", "task checks", "task add-tests", "task list", "task show", "check-progress", "task wait", "task complete", "task revise",
452
- "task state", "task block", "task unblock", "task next", "task steer", "task assign", "task scope", "task plan", "task hold", "task unhold", "task require",
456
+ "task state", "task block", "task unblock", "task next", "task steer", "task pause", "task continue", "task assign", "task scope", "task plan", "task hold", "task unhold", "task require",
453
457
  "task requeue", "task review", "task repair", "task route", "task reopen", "task stop", "task resume",
454
458
  "runner list", "coordinator list", "cap list", "cap add", "outbox list", "incident list", "incident resolve",
455
459
  "config show", "verify show", "intake show", "intake run", "intake preview", "intake pr-comments", "template list", "template show",
@@ -0,0 +1,28 @@
1
+ export declare const QUARANTINE_NOTE = ".toolroll-sweep.json";
2
+ type Journal<T> = {
3
+ version: 1;
4
+ name: string;
5
+ data: T;
6
+ deleting: boolean;
7
+ pid: number;
8
+ host: string;
9
+ bootId: string | null;
10
+ };
11
+ export type Quarantine<T> = {
12
+ path: string;
13
+ original: string;
14
+ journal: Journal<T>;
15
+ };
16
+ export declare function quarantineEntries<T>(root: string): Quarantine<T>[];
17
+ /** A small marker closes discovery before the atomic rename. It is never published half-written. */
18
+ export declare function quarantine<T>(original: string, data: T): Quarantine<T> | null;
19
+ /** Returns null if another sweep owns it (or won recovery). */
20
+ export declare function claimQuarantine<T>(entry: Quarantine<T>): Quarantine<T> | null;
21
+ export declare function releaseQuarantine<T>(entry: Quarantine<T>): void;
22
+ /** A partially deleted tree must never become discoverable again. */
23
+ export declare function restoreQuarantine<T>(entry: Quarantine<T>): boolean;
24
+ /** Keep the journal until all potentially large removals succeeded. Recovery never guesses at a half-deleted tree. */
25
+ export declare function deleteQuarantine<T>(entry: Quarantine<T>, remove: (path: string) => void | Promise<void>): Promise<void>;
26
+ /** Staged pruning is also called synchronously by update/deploy. */
27
+ export declare function deleteQuarantineSync<T>(entry: Quarantine<T>, remove: (path: string) => void): void;
28
+ export {};
@@ -0,0 +1,153 @@
1
+ /** Atomic rename before deletion. Trash is never a reusable dependency copy or staged runtime.
2
+ * Its journal survives partial deletion so the next sweep can recheck the original identity and retry.
3
+ * No database write lock spans filesystem work. A live owner keeps its trash; recovery claims a dead
4
+ * owner's folder by renaming it to a fresh, never-reused name, so two recoverers cannot both win. */
5
+ import { randomBytes } from "node:crypto";
6
+ import { existsSync, linkSync, lstatSync, readFileSync, readdirSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { hostname } from "node:os";
8
+ import { basename, dirname, join } from "node:path";
9
+ import { currentBootId, provenDeadByBootChange } from "./boot-identity.js";
10
+ export const QUARANTINE_NOTE = ".toolroll-sweep.json";
11
+ const PREFIX = ".toolroll-trash-";
12
+ const freshPath = (root) => join(root, `${PREFIX}${randomBytes(16).toString("hex")}`);
13
+ const owner = () => ({ pid: process.pid, host: hostname(), bootId: currentBootId() });
14
+ function alive(one) {
15
+ if (one.pid === 0)
16
+ return false;
17
+ if (one.host !== hostname())
18
+ return true;
19
+ if (one.bootId !== null && provenDeadByBootChange(one))
20
+ return false;
21
+ try {
22
+ process.kill(one.pid, 0);
23
+ return true;
24
+ }
25
+ catch (error) {
26
+ return error.code !== "ESRCH";
27
+ }
28
+ }
29
+ function read(path) {
30
+ try {
31
+ const j = JSON.parse(readFileSync(join(path, QUARANTINE_NOTE), "utf8"));
32
+ return j.version === 1 && typeof j.name === "string" && j.name !== "." && j.name !== ".." &&
33
+ j.name !== "" && !/[\\/\0]/.test(j.name) && Number.isSafeInteger(j.pid) && j.pid >= 0 && typeof j.host === "string" &&
34
+ (j.bootId === null || typeof j.bootId === "string") && typeof j.deleting === "boolean" ? j : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function write(path, journal, exclusive = false) {
41
+ const note = join(path, QUARANTINE_NOTE), temp = `${note}.${randomBytes(8).toString("hex")}`;
42
+ writeFileSync(temp, JSON.stringify(journal), { mode: 0o600, flag: "wx" });
43
+ try {
44
+ if (exclusive)
45
+ linkSync(temp, note);
46
+ else
47
+ renameSync(temp, note);
48
+ }
49
+ finally {
50
+ try {
51
+ unlinkSync(temp);
52
+ }
53
+ catch { /* renamed */ }
54
+ }
55
+ }
56
+ export function quarantineEntries(root) {
57
+ let names;
58
+ try {
59
+ names = readdirSync(root);
60
+ }
61
+ catch {
62
+ return [];
63
+ }
64
+ return names.filter(name => /^\.toolroll-trash-[0-9a-f]{32}$/.test(name)).flatMap(name => {
65
+ const path = join(root, name);
66
+ try {
67
+ if (!lstatSync(path).isDirectory())
68
+ return [];
69
+ }
70
+ catch {
71
+ return [];
72
+ }
73
+ const journal = read(path);
74
+ // A crash after unlinking the journal can leave only an empty folder. No resource data remains in it.
75
+ if (journal === null) {
76
+ try {
77
+ if (readdirSync(path).length === 0)
78
+ rmdirSync(path);
79
+ }
80
+ catch { /* next pass */ }
81
+ }
82
+ return journal === null ? [] : [{ path, original: join(root, journal.name), journal }];
83
+ });
84
+ }
85
+ /** A small marker closes discovery before the atomic rename. It is never published half-written. */
86
+ export function quarantine(original, data) {
87
+ if (!lstatSync(original).isDirectory())
88
+ return null;
89
+ let journal = read(original);
90
+ if (journal !== null && alive(journal))
91
+ return null;
92
+ if (journal === null) {
93
+ journal = { version: 1, name: basename(original), data, deleting: false, ...owner() };
94
+ write(original, journal, true);
95
+ }
96
+ else {
97
+ journal = { ...journal, ...owner() };
98
+ write(original, journal);
99
+ }
100
+ const path = freshPath(dirname(original));
101
+ renameSync(original, path);
102
+ return { path, original, journal };
103
+ }
104
+ /** Returns null if another sweep owns it (or won recovery). */
105
+ export function claimQuarantine(entry) {
106
+ const seen = read(entry.path);
107
+ if (seen === null || alive(seen))
108
+ return null;
109
+ const journal = { ...seen, ...owner() };
110
+ const path = freshPath(dirname(entry.path));
111
+ try {
112
+ // Mark live before moving. Contenders already reading the old name race the rename; just one wins.
113
+ write(entry.path, journal);
114
+ renameSync(entry.path, path);
115
+ write(path, journal);
116
+ return { ...entry, path, journal };
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ export function releaseQuarantine(entry) {
123
+ if (existsSync(entry.path))
124
+ write(entry.path, { ...entry.journal, pid: 0 });
125
+ }
126
+ /** A partially deleted tree must never become discoverable again. */
127
+ export function restoreQuarantine(entry) {
128
+ if (entry.journal.deleting || existsSync(entry.original))
129
+ return false;
130
+ renameSync(entry.path, entry.original);
131
+ unlinkSync(join(entry.original, QUARANTINE_NOTE));
132
+ return true;
133
+ }
134
+ /** Keep the journal until all potentially large removals succeeded. Recovery never guesses at a half-deleted tree. */
135
+ export async function deleteQuarantine(entry, remove) {
136
+ entry.journal = { ...entry.journal, deleting: true };
137
+ write(entry.path, entry.journal);
138
+ for (const name of readdirSync(entry.path))
139
+ if (name !== QUARANTINE_NOTE)
140
+ await remove(join(entry.path, name));
141
+ unlinkSync(join(entry.path, QUARANTINE_NOTE));
142
+ rmdirSync(entry.path);
143
+ }
144
+ /** Staged pruning is also called synchronously by update/deploy. */
145
+ export function deleteQuarantineSync(entry, remove) {
146
+ entry.journal = { ...entry.journal, deleting: true };
147
+ write(entry.path, entry.journal);
148
+ for (const name of readdirSync(entry.path))
149
+ if (name !== QUARANTINE_NOTE)
150
+ remove(join(entry.path, name));
151
+ unlinkSync(join(entry.path, QUARANTINE_NOTE));
152
+ rmdirSync(entry.path);
153
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Where a task's work starts, against current main (fresh base): one view for
3
+ * the task page and `task show`. The base is saved run lineage — a builder
4
+ * branch's pinned origin, or the newest plan's base — and the behind count is
5
+ * read from Git now. Reads only.
6
+ */
7
+ import type { Store } from "./store.js";
8
+ export type TaskBaseView = {
9
+ /** The base commit, in full. */
10
+ base: string;
11
+ branch: string;
12
+ /** The project's main: its checked-out branch, as a dispatch pass resolves it. Null when Git could not say. */
13
+ main: string | null;
14
+ /** Commits on main the base does not have; null when Git could not say. */
15
+ behind: number | null;
16
+ /** One short line: `Base 9d087efe · Behind main by 2 commits`. */
17
+ words: string;
18
+ };
19
+ /** A synchronous, read-only Git call in `cwd`: its trimmed output, or null on any failure. */
20
+ export type GitRead = (cwd: string, args: readonly string[]) => string | null;
21
+ export declare const readGit: GitRead;
22
+ /** The base line on its own, for surfaces that label it already: `9d087efe · Behind main by 2 commits`. */
23
+ export declare function baseStandingWords(view: Pick<TaskBaseView, "base" | "main" | "behind">): string;
24
+ /**
25
+ * The lease's fresh-base request for one attempt on `branch`, or nothing when the task's start is pinned elsewhere: a
26
+ * revision keeps the head of the attempt it revises, and a prepared candidate or coding review keeps the base its
27
+ * candidate was proved against.
28
+ */
29
+ export declare function freshBaseFor(store: Store, taskId: string, branch: string, main: string): {
30
+ freshBase: {
31
+ main: string;
32
+ recordedBase: string | null;
33
+ };
34
+ } | Record<string, never>;
35
+ export declare function taskBaseOf(store: Store, taskId: string, git?: GitRead): TaskBaseView | null;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Where a task's work starts, against current main (fresh base): one view for
3
+ * the task page and `task show`. The base is saved run lineage — a builder
4
+ * branch's pinned origin, or the newest plan's base — and the behind count is
5
+ * read from Git now. Reads only.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ export const readGit = (cwd, args) => {
9
+ try {
10
+ return execFileSync("git", ["--no-optional-locks", ...args], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, maxBuffer: 65_536 }).trim();
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ };
16
+ const commits = (count) => `${count} commit${count === 1 ? "" : "s"}`;
17
+ /** The base line on its own, for surfaces that label it already: `9d087efe · Behind main by 2 commits`. */
18
+ export function baseStandingWords(view) {
19
+ const short = view.base.slice(0, 8);
20
+ if (view.main === null || view.behind === null)
21
+ return short;
22
+ return view.behind === 0 ? `${short} · Up to date with ${view.main}` : `${short} · Behind ${view.main} by ${commits(view.behind)}`;
23
+ }
24
+ /**
25
+ * The lease's fresh-base request for one attempt on `branch`, or nothing when the task's start is pinned elsewhere: a
26
+ * revision keeps the head of the attempt it revises, and a prepared candidate or coding review keeps the base its
27
+ * candidate was proved against.
28
+ */
29
+ export function freshBaseFor(store, taskId, branch, main) {
30
+ const ref = store.lookupRef(taskId);
31
+ if (ref === null || ref.revisionOf !== null || store.getScope(taskId)?.candidate != null || store.filedViaOf(taskId)?.startsWith("coding:") === true)
32
+ return {};
33
+ return { freshBase: { main, recordedBase: store.recordedBranchBase(ref.id, branch) } };
34
+ }
35
+ export function taskBaseOf(store, taskId, git = readGit) {
36
+ const ref = store.lookupRef(taskId);
37
+ if (ref === null || ref.repo === null)
38
+ return null;
39
+ const latest = store.runsFor(ref.id).find(one => one.role !== "reviewer" && one.branch !== null && one.baseRevision !== null);
40
+ if (latest === undefined || latest.branch === null || latest.baseRevision === null)
41
+ return null;
42
+ const branch = latest.branch;
43
+ // A move to current main newer than the latest attempt is where the next one starts.
44
+ const move = store.latestBranchBaseMove(ref.id, branch);
45
+ const base = move !== null && move.afterRun >= latest.id ? move.to : store.recordedBranchBase(ref.id, branch) ?? latest.baseRevision;
46
+ const main = git(ref.repo, ["symbolic-ref", "--short", "-q", "HEAD"]) || null;
47
+ const counted = main === null ? null : git(ref.repo, ["rev-list", "--count", `${base}..refs/heads/${main}`]);
48
+ const behind = counted === null || !/^\d+$/.test(counted) ? null : Number(counted);
49
+ const view = { base, branch, main: behind === null ? null : main, behind };
50
+ return { ...view, words: `Base ${baseStandingWords(view)}` };
51
+ }
@@ -134,6 +134,8 @@ export type TaskStatusFacts = {
134
134
  damaged: number;
135
135
  } | null;
136
136
  completedBy?: string | null;
137
+ /** Marked complete by Toolroll because its work landed: why, in a few words ("its pull request merged"). */
138
+ landed?: string | null;
137
139
  action?: StatusAction | null;
138
140
  /** Where a row's one action leads: the result (and its Checks tab), the task's pull request, Run checks. */
139
141
  links?: {
@@ -127,6 +127,9 @@ function sentenceOf(headline, facts) {
127
127
  return "Review the change, then mark it complete.";
128
128
  return "Built without a passing project check. Review the change, then mark it complete.";
129
129
  case "Complete": {
130
+ // Landed work says why once; its merged pull request is that reason, not a second sentence.
131
+ if (facts.landed)
132
+ return `Marked complete automatically: ${facts.landed}.`;
130
133
  const by = facts.completedBy ? `Marked complete by ${facts.completedBy}.` : "Marked complete.";
131
134
  const pr = facts.pullRequest;
132
135
  return pr?.state === "merged" ? `${by} ${pr.number === null ? "Its pull request" : `Pull request #${pr.number}`} merged.` : by;
@@ -488,7 +491,8 @@ export function assignmentStatusFacts(assignment, options = {}) {
488
491
  ...(withResult && receipt?.proofAcceptance != null ? { accepted: true } : {}),
489
492
  ...(withResult && (assignment.readiness?.blockers.length ?? 0) > 0 ? { blockers: assignment.readiness.blockers } : {}),
490
493
  evidence: withResult ? options.evidence ?? evidenceOf(receipt, [...(receipt?.caveats ?? []), ...assignment.attention]) : null,
491
- completedBy: assignment.completion === null ? null : assignment.completion.lead === true ? "the lead" : assignment.completion.actor.replace(/^(?:operator|coordinator|lead):/, ""),
494
+ completedBy: assignment.completion === null || assignment.completion.landed !== undefined ? null : assignment.completion.lead === true ? "the lead" : assignment.completion.actor.replace(/^(?:operator|coordinator|lead):/, ""),
495
+ ...(assignment.completion?.landed === undefined ? {} : { landed: assignment.completion.landed }),
492
496
  action: options.action ?? null,
493
497
  ...(options.links === undefined ? {} : { links: options.links }),
494
498
  why: options.why ?? [],
@@ -25,13 +25,16 @@ export type RemoveOptions = {
25
25
  ownerAlive?: (owner: TempOwner) => boolean;
26
26
  /** At most this many go in one pass (a sweep after weeks can find 100,000); `more` says some were left. */
27
27
  max?: number;
28
+ /** Something live work uses (storage-sweep.ts): asked right before each folder goes; true keeps it. */
29
+ spare?: (path: string) => boolean;
28
30
  };
29
31
  /**
30
32
  * Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes (still a
31
- * folder of its own, not a link, still directly in its temp folder, still a day untouched, its owner gone).
33
+ * folder of its own, not a link, still directly in its temp folder, still a day untouched, its owner gone, nothing
34
+ * live using it). Removal never holds the event loop: a worker's heartbeats keep beating while thousands go.
32
35
  */
33
- export declare function removeStaleTestTemp(roots: readonly string[], now: Date, options?: RemoveOptions): {
36
+ export declare function removeStaleTestTemp(roots: readonly string[], now: Date, options?: RemoveOptions): Promise<{
34
37
  removed: string[];
35
38
  failed: string[];
36
39
  more: boolean;
37
- };
40
+ }>;
package/dist/test-temp.js CHANGED
@@ -8,8 +8,9 @@
8
8
  * Every prefix the tests and scripts give tmpdir() is covered here (test-temp.test.ts reads the sources to keep it
9
9
  * so), and Playwright's own browser profiles.
10
10
  */
11
- import { spawnSync } from "node:child_process";
12
- import { lstatSync, readFileSync, readdirSync, rmSync } from "node:fs";
11
+ import { execFile } from "node:child_process";
12
+ import { lstatSync, readFileSync, readdirSync } from "node:fs";
13
+ import { rm } from "node:fs/promises";
13
14
  import { tmpdir } from "node:os";
14
15
  import { dirname, join, resolve } from "node:path";
15
16
  const DAY_MS = 86_400_000;
@@ -96,9 +97,10 @@ export function testTempFolders(roots, now) {
96
97
  }
97
98
  /**
98
99
  * Remove the test temp folders nothing has touched for a day; each is looked at again right before it goes (still a
99
- * folder of its own, not a link, still directly in its temp folder, still a day untouched, its owner gone).
100
+ * folder of its own, not a link, still directly in its temp folder, still a day untouched, its owner gone, nothing
101
+ * live using it). Removal never holds the event loop: a worker's heartbeats keep beating while thousands go.
100
102
  */
101
- export function removeStaleTestTemp(roots, now, options = {}) {
103
+ export async function removeStaleTestTemp(roots, now, options = {}) {
102
104
  const removed = [];
103
105
  const failed = [];
104
106
  const stale = testTempFolders(roots, now).stale;
@@ -107,6 +109,7 @@ export function removeStaleTestTemp(roots, now, options = {}) {
107
109
  for (const one of stale) {
108
110
  if (removed.length + failed.length >= max)
109
111
  return { removed, failed, more: true };
112
+ await nextTurn();
110
113
  try {
111
114
  const stat = lstatSync(one.path);
112
115
  if (stat.isSymbolicLink() || !inside.has(resolve(dirname(one.path))))
@@ -116,7 +119,9 @@ export function removeStaleTestTemp(roots, now, options = {}) {
116
119
  const owner = stat.isDirectory() ? tempOwner(one.path) : null;
117
120
  if (owner !== null && (options.ownerAlive ?? (() => false))(owner))
118
121
  continue;
119
- removeTree(one.path);
122
+ if (options.spare?.(one.path) === true)
123
+ continue;
124
+ await removeTree(one.path);
120
125
  removed.push(one.path);
121
126
  }
122
127
  catch {
@@ -125,14 +130,15 @@ export function removeStaleTestTemp(roots, now, options = {}) {
125
130
  }
126
131
  return { removed, failed, more: false };
127
132
  }
133
+ const nextTurn = () => new Promise(resolve => setImmediate(resolve));
128
134
  /** rm -r; a tree a test made read-only (a shared dependency copy, a locked fixture) is made writable first. */
129
- function removeTree(path) {
135
+ async function removeTree(path) {
130
136
  try {
131
- rmSync(path, { recursive: true, force: true, maxRetries: 3 });
137
+ await rm(path, { recursive: true, force: true, maxRetries: 3 });
132
138
  return;
133
139
  }
134
140
  catch { /* read-only inside */ }
135
141
  if (process.platform !== "win32")
136
- spawnSync("chmod", ["-R", "u+w", path], { timeout: 120_000 });
137
- rmSync(path, { recursive: true, force: true, maxRetries: 3 });
142
+ await new Promise(resolve => execFile("chmod", ["-R", "u+w", path], { timeout: 120_000 }, () => resolve()));
143
+ await rm(path, { recursive: true, force: true, maxRetries: 3 });
138
144
  }
@@ -35,7 +35,7 @@ import { processMayBeAlive } from "./process-liveness.js";
35
35
  import { assertCodingUpdateStopped, backupCodingCatalog, codingCatalogExists, releaseStaleCodingOwner, removeCodingUpdateGate } from "./coding-update.js";
36
36
  import { installLaunchdService, launchdPlist, stopLaunchdService, writeFileDurably } from "./daemon.js";
37
37
  import { NAME } from "./names.js";
38
- import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS } from "./store.js";
38
+ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES, V118_MERGED_TABLES, V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS } from "./store.js";
39
39
  import { isNewer, REGISTRY } from "./releases.js";
40
40
  import { markNeverIndex } from "./never-index.js";
41
41
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -398,7 +398,9 @@ const HISTORY_RULES = {
398
398
  // the declared value (generation 0, no closure reason).
399
399
  // v120: a steering note's mailing and acknowledgement, and a run's steering folder, are new columns; every row
400
400
  // saved before them reads none.
401
- ...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS),
401
+ // v121: live steering's columns and a run's attempt key read none, and no sends, the same way.
402
+ // v122: a project's recorded remote reads none the same way.
403
+ ...addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS),
402
404
  };
403
405
  /** The tables a migration may remove whole (v115's removed features). */
404
406
  export const RETIRED_TABLES = Object.freeze(Object.keys(HISTORY_RULES).filter(name => HISTORY_RULES[name].retired));
@@ -32,6 +32,7 @@ export type WorkIndexItem = {
32
32
  actor: string;
33
33
  at: string;
34
34
  digest: string;
35
+ landed?: string;
35
36
  } | null;
36
37
  evidence: 'recorded';
37
38
  /** The viewer's own lead took it on (lead-voice.ts): "<name> is on it.", by the name they gave it, read now. */
@@ -16,6 +16,7 @@ import { shownVerdictOf } from './proof.js';
16
16
  import { isCheckLevel, requiredCheckCommandFor } from './check-levels.js';
17
17
  import { MARKER } from './worktree.js';
18
18
  import { withFollowUps } from './result-follow-ups.js';
19
+ import { LANDED_ACTOR, landedReasonOf } from './result-completion.js';
19
20
  import { ASKS, NEED_ASK, NEEDS, askChipOf, failedAttemptSentence, processNeedOf, resultHoldUpSentence } from './needs-you.js';
20
21
  import { LEAD_CLAIM_SQL, LEAD_IDLE_MS } from './lead-voice.js';
21
22
  export const WORK_INDEX_PAGE_LIMIT = 40;
@@ -26,6 +27,9 @@ const FAMILY_PROBLEM = 'Task history is unavailable or incomplete. This task is
26
27
  const VIEWS = ['all', 'needs-you', 'running', 'completed'];
27
28
  const n = (row, key) => Number(row[key] ?? 0);
28
29
  const s = (row, key) => row[key] == null ? null : String(row[key]);
30
+ /** The running agent's oldest open question (agent-board.ts), from its open newest attempt only. */
31
+ const AGENT_ASK = `(SELECT q.id FROM agent_question q JOIN run qr ON qr.id=q.run WHERE q.task_ref=a.ref_id AND q.closed_at IS NULL
32
+ AND qr.outcome IS NULL AND qr.attempt_key=q.attempt_key ORDER BY q.id LIMIT 1)`;
29
33
  /** Keep the lineage predicates identical to Store.taskFamilyProjection: an
30
34
  * admitted child needs its parent's exact revision brief, in the same repo.
31
35
  * All subsequent reads are scalar/indexed metadata, never artifact bytes.
@@ -88,7 +92,7 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
88
92
  (SELECT id FROM run WHERE task_ref=a.ref_id AND outcome IS NULL ORDER BY id DESC LIMIT 1) unfinished,
89
93
  CASE WHEN a.state='done' THEN (SELECT id FROM run WHERE task_ref=a.ref_id AND finished_at IS NOT NULL AND role IN ('builder','scout') ORDER BY id DESC LIMIT 1) END result_id,
90
94
  (SELECT h.id FROM hold h WHERE h.task_ref=a.ref_id AND (h.until IS NULL OR h.until>$now) ORDER BY h.held_at,h.id LIMIT 1) hold_id,
91
- q.decision_id,(cu.task_ref IS NOT NULL OR a.ref_id IN (SELECT value FROM json_each($workspaceCustody)))
95
+ q.decision_id,${AGENT_ASK} agent_ask,(cu.task_ref IS NOT NULL OR a.ref_id IN (SELECT value FROM json_each($workspaceCustody)))
92
96
  AND a.ref_id NOT IN (SELECT value FROM json_each($settledCustody)) custody_unresolved
93
97
  FROM admitted a LEFT JOIN lineage l ON l.ref_id=a.ref_id
94
98
  LEFT JOIN questions q ON q.task_ref=a.ref_id LEFT JOIN custody cu ON cu.task_ref=a.ref_id
@@ -186,7 +190,7 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
186
190
  AND a.action='assignment handoff checked' AND a.source='work' AND a.repo IS f.repo
187
191
  AND length(a.outcome)=64 AND a.outcome NOT GLOB '*[^a-f0-9]*'
188
192
  AND a.at>=f.family_updated AND a.at>=f.finished_at AND a.at>=f.approved_at AND a.at>=f.proposed_at
189
- AND (a.actor GLOB 'operator:?*' OR a.actor GLOB 'coordinator:?*' OR a.actor GLOB 'lead:?*')
193
+ AND (a.actor GLOB 'operator:?*' OR a.actor GLOB 'coordinator:?*' OR a.actor GLOB 'lead:?*' OR a.actor='${LANDED_ACTOR}')
190
194
  AND (cm.changed_id IS NULL OR a.id>=cm.changed_id) AND (cm.artifact_at IS NULL OR a.at>=cm.artifact_at)
191
195
  AND NOT EXISTS(SELECT 1 FROM proof_verdict p WHERE p.run=f.result_id AND p.decided_at>a.at)
192
196
  AND NOT EXISTS(SELECT 1 FROM proof_acceptance p WHERE p.run=f.result_id AND p.accepted_at>a.at)
@@ -203,7 +207,7 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
203
207
  WHEN state='done' THEN 'result-needs-attention'
204
208
  WHEN custody_unresolved AND live_lease IS NULL THEN 'process-needs-attention'
205
209
  WHEN state='failed' THEN 'failed'
206
- WHEN live_lease IS NOT NULL THEN CASE WHEN question_id IS NOT NULL THEN 'waiting-decision' ELSE 'running' END
210
+ WHEN live_lease IS NOT NULL THEN CASE WHEN question_id IS NOT NULL OR agent_ask IS NOT NULL THEN 'waiting-decision' ELSE 'running' END
207
211
  WHEN state='running' OR unfinished IS NOT NULL THEN 'vanished-run'
208
212
  WHEN EXISTS(SELECT 1 FROM sqlite_master WHERE type='trigger' AND name GLOB 'so_desktop_update_*') THEN 'updating'
209
213
  WHEN hold_kind='backoff' THEN 'retry-scheduled'
@@ -239,7 +243,7 @@ const PROJECTION = `WITH RECURSIVE admitted AS MATERIALIZED (
239
243
  WHEN ${CUSTODY_WAITS} OR code IN ('updating','retry-scheduled','waiting-dependency','worker-at-capacity','planning-ready','scouting-ready','queued') THEN 2 ELSE 0 END rank,
240
244
  ${ASK_RANK} ask_rank,
241
245
  CASE WHEN code='complete' THEN completed.at ELSE family_updated END sort_at,
242
- completed.actor checked_actor,completed.at checked_at,completed.outcome checked_digest
246
+ completed.actor checked_actor,completed.at checked_at,completed.outcome checked_digest,completed.detail checked_detail
243
247
  FROM claimed c LEFT JOIN action_ledger completed ON completed.id=c.checked_id WHERE ($state IS NULL OR c.state=$state) AND ($leadId IS NULL OR EXISTS(SELECT 1 FROM team_task_owner own WHERE own.task_ref=c.root_ref AND own.lead=$leadId))
244
248
  )`;
245
249
  const registered = new WeakMap();
@@ -280,7 +284,11 @@ function registerValidators(store) {
280
284
  const available = new Set(db.prepare(`SELECT name FROM sqlite_master WHERE type='index'
281
285
  AND name IN ('work_open_decision','work_spawned_run','work_unsettled_custody','work_result')`)
282
286
  .all().map(row => String(row['name'])));
283
- const projection = available.size === 4 ? PROJECTION : PROJECTION.replace(/ INDEXED BY (work_open_decision|work_spawned_run|work_unsettled_custody|work_result)\b/g, (hint, name) => available.has(name) ? hint : '');
287
+ const indexed = available.size === 4 ? PROJECTION : PROJECTION.replace(/ INDEXED BY (work_open_decision|work_spawned_run|work_unsettled_custody|work_result)\b/g, (hint, name) => available.has(name) ? hint : '');
288
+ // A same-schema reader whose file has not taken the Agents board's tables yet reads no agent questions.
289
+ const asks = db.prepare("SELECT 1 AS hit FROM sqlite_master WHERE type='table' AND name='agent_question'").get() !== undefined;
290
+ const projection = asks ? indexed : indexed.replace(AGENT_ASK, 'NULL')
291
+ .replace(`'agent_ask_run',(SELECT run FROM agent_question WHERE id=page.agent_ask),'agent_ask_text',(SELECT substr(question,1,300) FROM agent_question WHERE id=page.agent_ask),`, '');
284
292
  registered.set(db, projection);
285
293
  return projection;
286
294
  }
@@ -398,10 +406,11 @@ function readWorkIndexPage(store, now, access, options, taskId = null) {
398
406
  AND f.action='checks finished' AND f.outcome LIKE q.id||':%' LIMIT 1),'open')) FROM (SELECT id,outcome FROM action_ledger WHERE repo IS page.repo AND run_id=page_result_id AND action='checks requested' ORDER BY id) q),
399
407
  'proof_verdict',(SELECT COALESCE(machine_verdict,verdict) FROM proof_verdict WHERE run=page_result_id),
400
408
  'proof_failed_check',(SELECT verdict='refuted' AND reasons_json LIKE '%approved verification command exited%' FROM proof_verdict WHERE run=page_result_id),'code',code,'needs',needs,'person_need',person_need,'lead_claim',lead_claim,'family_running',family_running,'rank',rank,'ask_rank',ask_rank,'list_order',${ORDER},'sort_at',sort_at,
401
- 'checked_actor',checked_actor,'checked_at',checked_at,'checked_digest',checked_digest,'question_id',question_id,
409
+ 'checked_actor',checked_actor,'checked_at',checked_at,'checked_digest',checked_digest,'checked_detail',checked_detail,'question_id',question_id,
402
410
  'dependency_id',dependency_id,'dependency_state',dependency_state,'custody_unresolved',custody_unresolved,'hold_kind',hold_kind,'hold_reason',substr(hold_reason,1,300),'hold_id',hold_id,'live_lease',live_lease IS NOT NULL,'approved',COALESCE(approved,0),
403
411
  'plan_changed',scope_digest IS NOT NULL AND digest IS NOT scope_digest,'head_ok',length(head_revision)=40 AND head_revision NOT GLOB '*[^a-f0-9]*','stop_run',stop_run,'unfinished',unfinished,'signed_out',signed_out,
404
- 'question_run',(SELECT run FROM decision WHERE id=page.question_id),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
412
+ 'question_run',(SELECT run FROM decision WHERE id=page.question_id),'agent_ask',page.agent_ask,
413
+ 'agent_ask_run',(SELECT run FROM agent_question WHERE id=page.agent_ask),'agent_ask_text',(SELECT substr(question,1,300) FROM agent_question WHERE id=page.agent_ask),'replaced_by',(SELECT successor FROM task_replacement WHERE task_ref=page.ref_id),
405
414
  'completed_by_lead',(SELECT lead FROM task_act WHERE task_ref=page.root_ref AND act='completed' ORDER BY id DESC LIMIT 1),
406
415
  'question_task',(SELECT r.external_id FROM decision d JOIN run ON run.id=d.run JOIN task_ref r ON r.id=run.task_ref WHERE d.id=page.question_id)) FROM page`)
407
416
  .all({ ...parameters(now, access, options, custody), $taskId: taskId, $cursorRoot: cursor?.root ?? 0, $cursorRank: cursor?.rank ?? 0, $cursorAt: cursor?.at ?? '', $limit: limit + 1, $recent: new Date(now.getTime() - 86_400_000).toISOString() });
@@ -508,7 +517,7 @@ function itemOf(row, principal, probe, leadName, readiness) {
508
517
  complete: ['Complete', 'Completion is recorded for this result. Open it to inspect the saved evidence.', 'open-result', 'Open result'],
509
518
  cancelled: ['Cancelled', 'This assignment was cancelled.', 'inspect-task', 'View assignment'],
510
519
  running: ['Running now', '', null, ''],
511
- 'waiting-decision': ['Needs your decision', 'An unanswered question is waiting.', n(row, 'question_id') ? 'answer-decision' : 'inspect-decisions', 'Answer question'],
520
+ 'waiting-decision': ['Needs your decision', 'An unanswered question is waiting.', n(row, 'question_id') ? 'answer-decision' : n(row, 'agent_ask') ? 'answer-agent' : 'inspect-decisions', 'Answer question'],
512
521
  'decision-queue': ['Needs your decision', 'The decision queue is full.', 'inspect-decisions', 'Review open questions'],
513
522
  // Its own reason replaces this below (resultHoldUpSentence).
514
523
  'result-needs-attention': ['Needs your decision', '', 'inspect-run', 'Inspect run'],
@@ -588,11 +597,11 @@ function itemOf(row, principal, probe, leadName, readiness) {
588
597
  const read = actionCode !== null && ['inspect-task', 'inspect-run', 'inspect-stop', 'inspect-decisions', 'open-result'].includes(actionCode);
589
598
  const primaryAction = actionCode === null ? null : { code: actionCode, label: actionLabel,
590
599
  target: { taskId: successor ?? (code === 'earlier-active' ? s(row, 'earlier_id') ?? id : actionCode === 'answer-decision' ? s(row, 'question_task') ?? id : id),
591
- runId: actionCode === 'answer-decision' ? n(row, 'question_run') || null : actionCode === 'inspect-run' && code === 'running' ? n(row, 'live_run') || null : actionCode === 'open-result' || actionCode === 'inspect-run' ? n(row, 'result_id') || null
600
+ runId: actionCode === 'answer-decision' ? n(row, 'question_run') || null : actionCode === 'answer-agent' ? n(row, 'agent_ask_run') || null : actionCode === 'inspect-run' && code === 'running' ? n(row, 'live_run') || null : actionCode === 'open-result' || actionCode === 'inspect-run' ? n(row, 'result_id') || null
592
601
  : actionCode === 'reconcile-run' ? n(row, 'unfinished') || null : ['resume-run', 'inspect-stop'].includes(actionCode) ? n(row, 'stop_run') || null
593
602
  : actionCode === 'confirm-stopped' ? process?.build ?? (n(row, 'result_id') || null) : null,
594
603
  decisionId: actionCode === 'answer-decision' ? n(row, 'question_id') || null : null },
595
- access: read ? 'read' : principal === 'operator' ? 'operator-control' : ['answer-decision', 'write-scope', 'unhold'].includes(actionCode) ? 'proposal-only' : 'operator-handoff',
604
+ access: read ? 'read' : principal === 'operator' ? 'operator-control' : ['answer-decision', 'answer-agent', 'write-scope', 'unhold'].includes(actionCode) ? 'proposal-only' : 'operator-handoff',
596
605
  retry: actionCode === 'reconcile-run' ? 'reconcile-before-retry' : read ? 'read-again' : 'refresh-before-acting' };
597
606
  // The one shared status: the headline and sentence every surface uses.
598
607
  const coded = stageOfCode(code, { needsPerson: personNeed, planning: s(row, 'live_role') === 'planner', operatorHold: s(row, 'hold_kind') === 'operator' });
@@ -605,12 +614,15 @@ function itemOf(row, principal, probe, leadName, readiness) {
605
614
  const own = checkStatus === 'failed' || n(row, 'proof_failed_check') === 1 ? 'failed' : checkStatus === 'passed' || verdict === 'verified' ? 'passed' : null;
606
615
  const listedChecks = !finished ? null : listChecksOf(own, s(row, 'check_level'), row['check_follows']);
607
616
  const ready = reading.stage === 'finished' && readiness !== undefined ? readiness(row, listedChecks) : null;
617
+ // Toolroll marked it complete because its work landed: the reason, said once.
618
+ const landed = code === 'complete' ? landedReasonOf(s(row, 'checked_actor'), s(row, 'checked_detail')) : null;
608
619
  const shared = taskStatusOf({ stage: reading.stage, ...(reading.need === undefined ? {} : { need: reading.need }), ...(reading.wait === undefined ? {} : { wait: reading.wait }),
609
620
  ...(process === null ? {} : { needContext: { build: process.build } }),
610
621
  reason: finished ? null : plainReasonOf(reading.stage, code, detail || null), report: s(row, 'result_role') === 'scout',
611
622
  checks: ready?.checks ?? listedChecks,
612
623
  ...(ready === null ? {} : { requirements: ready.requirements ?? null, ...(ready.accepted ? { accepted: true } : {}), ...(ready.blockers === undefined ? {} : { blockers: ready.blockers }) }),
613
- completedBy: code !== 'complete' ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, ''),
624
+ completedBy: code !== 'complete' || landed !== null ? null : n(row, 'completed_by_lead') === 1 ? 'the lead' : String(row['checked_actor']).replace(/^(?:operator|coordinator|lead):/, ''),
625
+ ...(landed === null ? {} : { landed }),
614
626
  ...(lead === null ? {} : { lead, ...(leadName === undefined ? {} : { leadName }) }) });
615
627
  label = shared.headline;
616
628
  detail = shared.sentence;
@@ -619,6 +631,14 @@ function itemOf(row, principal, probe, leadName, readiness) {
619
631
  actionLabel = shared.need.action.label;
620
632
  primaryAction.label = actionLabel;
621
633
  }
634
+ // A running agent's own question (agent-board.ts): its words, and Reply on the Agents board.
635
+ if (actionCode === 'answer-agent' && primaryAction !== null) {
636
+ actionLabel = 'Reply';
637
+ primaryAction.label = actionLabel;
638
+ const asked = s(row, 'agent_ask_text');
639
+ if (asked !== null)
640
+ detail = `The agent asks: ${asked}`;
641
+ }
622
642
  const views = ['all'];
623
643
  if (need)
624
644
  views.push('needs-you');
@@ -635,6 +655,6 @@ function itemOf(row, principal, probe, leadName, readiness) {
635
655
  action: primaryAction === null ? null : { label: actionLabel, kind: actionCode === 'open-result' ? 'open-result' : 'open-task' }, views, rank: n(row, 'rank') },
636
656
  ask: need ? ASKS[n(row, 'ask_rank')] ?? 'unblock' : null,
637
657
  chip: need ? askChipOf({ headline: shared.headline, need: shared.need?.key ?? null, planChanged, mismatch: verdict === 'refuted' && n(row, 'proof_failed_check') !== 1, ask: ASKS[n(row, 'ask_rank')] ?? 'unblock' }) : null,
638
- primaryAction, completion: code === 'complete' ? { actor: String(row['checked_actor']), at: String(row['checked_at']), digest: String(row['checked_digest']) } : null,
658
+ primaryAction, completion: code === 'complete' ? { actor: String(row['checked_actor']), at: String(row['checked_at']), digest: String(row['checked_digest']), ...(landed === null ? {} : { landed }) } : null,
639
659
  evidence: 'recorded', ...(lead === 'on-it' ? { lead: leadOnIt(leadName) } : {}) };
640
660
  }
@@ -9,8 +9,8 @@ export type WorkTarget = {
9
9
  };
10
10
  export type WorkAction = {
11
11
  /** `confirm-stopped`: an approver confirms a finished build stopped (behind the password);
12
- * `sign-in`: the agent's sign-in, in Settings. */
13
- code: DispatchAction | "inspect-task" | "inspect-run" | "inspect-stop" | "inspect-decisions" | "reconcile-run" | "open-pr" | "confirm-stopped" | "sign-in";
12
+ * `sign-in`: the agent's sign-in, in Settings; `answer-agent`: Reply to a running agent's question on the Agents board. */
13
+ code: DispatchAction | "inspect-task" | "inspect-run" | "inspect-stop" | "inspect-decisions" | "reconcile-run" | "open-pr" | "confirm-stopped" | "sign-in" | "answer-agent";
14
14
  label: string;
15
15
  target: WorkTarget;
16
16
  access: "read" | "operator-control" | "proposal-only" | "operator-handoff";
@@ -4,7 +4,9 @@ export const WORKSPACE_REVISION_KEY = 'workspace-content:v1';
4
4
  const KEY = WORKSPACE_REVISION_KEY;
5
5
  const MAX_AGE_MS = 60_000;
6
6
  const OMIT = new Set(['schema_version', 'service_cursor', 'wake', 'notification_delivery', 'sqlite_sequence',
7
- 'team_read', 'team_request', 'push_delivery', 'chat_message_ref']);
7
+ 'team_read', 'team_request', 'push_delivery', 'chat_message_ref',
8
+ // Who is watching an agent right now: renewed by every open page's keep-alive, never news.
9
+ 'agent_view']);
8
10
  const QUIET = {
9
11
  runner: ['heartbeat_at'], claim: ['heartbeat_at', 'expires_at'], watch_lease: ['heartbeat_at', 'expires_at'],
10
12
  watch_episode: ['ticks'], provider_readiness: ['observed_at'],