feat(board): session attention, Discord rows, task attribution and relaunch activity (rows 18, 22, #1511, #1512)

One cumulative control-board, webui and seat state. The four rows edit the
same files (scan.mjs, page.html, README.md, app.js), so they land together,
each on its own receipt:

- Row 18, Discord connector rows on the board (#1509): R3 approved by
  Darkwing and Dewey, Gitea comment 26257, manifest 254403b8. Jason
  accepted the visual test.
- Row 22, board attention status (#1503): Filbert approved R1, comment
  26248, manifest e40b58ec; restart receipt 26249.
- #1511, task attribution (row 6 code phase): R2 approved by Filbert and
  Dewey, manifest d4c96395. docs/TOOLS.md carries the approved --by usage
  line (tools-usage.patch 86bcba3c).
- #1512, relaunch activity (row 6 pilot): R1 approved by Darkwing and
  Dewey, candidate manifest 47769fad. All seven source files match it.

Row 16, internal development bootstrap (#1510): the seven files outside
shared records match Filbert's R1 pins, receipt 26204 (agents/researcher/*,
scripts/test-darkwing-launch.mjs, the bootstrap plan).

packages/webui/src/public/app.js is committed at its #1512 R1 pin ce7d79a4.
The working copy holds Dewey's unreviewed return-flow candidate on top of
that, and it stays uncommitted.

Also: the four row briefs and Darkwing's evidence records under
agents/darkwing/work, including the 2026-09-26 tree manifest and the #1512
re-run against 21e3e908. Serial acceptance command: 397/397, three runs.
The failures that only show when tests run concurrently are in #1509 engine
tests, and they reproduce on clean HEAD.

Suites on the exact staged tree: config 24, task 90, foundation 43,
conductor 17, release 14, auth 15, discord 63; package union 397/397
(serial); test-darkwing-launch 5/5.

Shared records (BUILD-LOG, QUEUE, CURRENT, DEFERRED, SESSIONS, AGENTS.md,
agents/README.md) follow in Sage's records commit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-09-26 14:54:18 -05:00
co-authored by Claude Opus 5.5
parent 21e3e908b6
commit af4203ca92
67 changed files with 2417 additions and 78 deletions
+88 -4
View File
@@ -12,10 +12,10 @@ rewritable; they are not run records and are not evidence.
| State | Plain-words meaning |
|---------|----------------------|
| working | The agent is in the middle of a turn: thinking or running a tool. A seat whose newest log entry is a tool call or a tool result is working even if its last words looked like a question. |
| waiting | The agent finished its turn with a text-only message. It is your move now. |
| waiting | The agent explicitly requested your input in its completed reply. |
| error | The agent's last turn ended in an error, was aborted, or was cut off. Go look at it. |
| offline | There is no live tmux session for this agent right now, or its session exists but no longer runs `pi`. |
| idle | The agent is live but has not had a conversation yet. |
| idle | The agent is live and available, including after a normal completed reply. |
| unknown | The scanner could not ask tmux (missing or not answering). It does not assume the agent is alive. |
Liveness means a pane in the agent's tmux session is actually running `pi`
@@ -23,6 +23,79 @@ Liveness means a pane in the agent's tmux session is actually running `pi`
exists but only runs bash or some other program counts as offline, not
waiting.
### Explicit human attention
A completed assistant reply requests attention only when its first nonblank text
line begins at column zero with `Input needed: ` and a nonempty request. Example:
```text
Input needed: Choose staging or production for the approved test.
```
Ordinary replies such as `BOARD_REPLY_OK`, completion reports and questions without
this explicit signal are idle. Code/quote examples and thinking blocks do not
count. Tool activity/errors retain precedence. A later ordinary completed reply
clears the previous request; a user/tool message is working, not waiting.
Agents reserve the signal for Jason's decision or input, not another agent's
review or routine completion. It is display state, never action authorization.
Existing unmarked replies cannot establish a human blocker. Seen acknowledges
the current event and removes it from the attention list; it does not resolve a
genuine request or change waiting to idle. A new request reappears.
## Relaunch activity notice
A live native row with a positively live, matching registration gets a
`relaunchedAt` timestamp when that registration's valid `startedAt` is strictly
newer than valid recorded `lastActivity`. Both board presentations then show
`relaunched at X, no messages since` in the current activity/preview positions.
The inspector explicitly labels retained last activity, assistant text and errors
as historical. CLI `scan --print` also replaces its old preview/age with the notice. New recorded session activity at or after the launch timestamp
clears the notice. Equality does not assert a relaunch.
Missing/invalid timestamps, missing or mismatched registrations, unknown/dead PID
or row liveness, and connector rows yield `relaunchedAt: null`. Unknown is not
proof of relaunch. The comparison uses the existing session activity timestamp,
not a new transcript index or authenticated process-incarnation protocol.
This field changes presentation only. Historical transcript files and serialized
lastActivity/lastAssistantText/lastError remain intact. State, attention, Seen,
task selection/attribution and reply eligibility are unchanged. An old unresolved
waiting/error state therefore remains visible, with its text labelled historical,
rather than being silently cleared by the new notice. No launcher or live
registration mutation is required to test this behavior.
## Discord connector rows
The CLI discovers private `<dataRoot>/discord/<binding>.json` files on every
scan, including server rescans. Only matching safe binding name and seat identity
are used. Files must be regular, non-symlink, mode 0600 and at most 1 MiB.
Discovery never resolves token paths or projects binding policy, Discord IDs or
user/channel lists. Invalid bindings produce fixed, content-free discoveryErrors.
Library callers enable this with `discordDataRoot` on scan/startServer.
Rows use project `fleet` and agent `<seat> (discord: <binding>)`, distinct from
native seats. Task is fixed `Discord connector`, with source `connector`, never
inferred from the first user message: Discord routing envelopes contain private
IDs. Ordinary-agent task derivation and assistant transcript display are unchanged.
Session history comes from `sessions/discord-<binding>` without
following linked directories/files. Liveness uses the connector's readPid and
ownerState checks, not tmux or registration: only a live PID with matching boot
ID and start tick is live. Missing, invalid, dead or unverifiable owners are
non-live. The `connector` projection contains binding, alive, ownerState and
braked only; it does not expose the journal directory or owner record.
STOP presence is shown separately as braked, even when the owner is offline.
An unsafe journal path gives brake unknown, not an unbraked claim. STOP contents
are never read. Activity retains the usual idle/waiting/error rules. Both board
pages omit Reply for connector rows; the backend refuses connector replies before
transport even if a stale/forged native registration supplies tmux details.
This is read-only observation, not connector control. No brake, unlock, recovery,
Discord request, counter collection or engine action is performed. Tests use
isolated bindings, sessions and process identities; they do not prove live
service transitions or authorize replacing a running board.
## Commands
```
@@ -156,8 +229,18 @@ name alone. A registered task, project or workspace replaces the derived
value and the row's source field (`taskSource`, `activeProjectSource`,
`workspaceSource`) reads `registration`; the page shows a small source tag
next to the value and a "Registered" line in the detail with the start time,
harness, pid and tmux session. An empty task or a null project or workspace
in the record leaves the derived value in place. Rows with no registration
harness, pid and tmux session. When the task shown is the registered one,
the row also carries `taskSetBy` (#1511): the record's `taskSetBy` from
`mosaic seat task --by NAME` (else `$MOSAIC_AGENT_NAME`, else `unknown`),
or `unknown` for a record written before the field existed. Both pages show
it as a "set by NAME" tag after the task's source tag and a "Task set by"
detail row. It is what the caller claimed, not a verified identity: the
board escapes and displays it and nothing else reads it; in particular the
reply gate looks only at the registration. For every other task source
(`first-user-message`, `connector`, a stale registration, no task) it is
null, so a record never lends its setter to a task it did not set. An empty
task or a null project or workspace in the record leaves the derived value
in place. Rows with no registration
are exactly as before. The board only reads `seats/`; `mosaic launch` and
`mosaic seat task` are the only writers. A malformed record is listed in
`registrationErrors` on the index (and on stderr for `scan`) and skipped.
@@ -194,6 +277,7 @@ values), the scan refuses rather than silently dropping every mark.
"cwd": "/mnt/storage/src/mosaic-stack",
"task": "Read agents/darkwing/work/RESTART.md",
"taskSource": "first-user-message",
"taskSetBy": null,
"workspace": "/mnt/storage/src/mosaic-stack",
"workspaceSource": "tmux-pane",
"activeProject": "mosaic-stack",
+6 -4
View File
@@ -42,7 +42,7 @@ async function main() {
const boardDir = join(dataRoot, "board");
const seatsDir = join(dataRoot, "seats");
if (opts.command === "serve") {
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir });
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
const addr = server.address();
process.stdout.write(`control board: http://${opts.host}:${addr.port}/ (${specs.length} agents; board files in ${boardDir}; Ctrl-C to stop)\n`);
const stop = () => server.close(() => process.exit(0));
@@ -50,13 +50,15 @@ async function main() {
process.on("SIGTERM", stop);
return;
}
const index = scan(specs, { boardDir, isAlive, seatsDir });
const index = scan(specs, { boardDir, isAlive, seatsDir, discordDataRoot: dataRoot });
for (const line of index.discoveryErrors) process.stderr.write(`${line}\n`);
for (const line of index.registrationErrors) process.stderr.write(`registration skipped: ${line}\n`);
if (opts.print) {
for (const s of index.sessions) {
const flag = s.waitingOnYou ? "*" : s.seen ? "s" : " ";
const age = s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`;
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : ""}\n`);
const age = s.relaunchedAt ? "relaunch" : s.ageSeconds == null ? "-" : `${Math.round(s.ageSeconds / 60)}m`;
const preview = s.relaunchedAt ? `relaunched at ${s.relaunchedAt}, no messages since` : s.lastAssistantText ? s.lastAssistantText.slice(0, 80) : "";
process.stdout.write(`${flag} ${s.state.padEnd(8)} ${s.project.padEnd(14)} ${s.agent.padEnd(16)} ${age.padStart(7)} ${preview}\n`);
}
}
process.stdout.write(`board: ${boardDir} (${index.sessions.length} sessions, ${index.waitingOnYou.length} waiting on you, ${index.seen.length} seen, ${index.registered.length} registered, ${index.registrationStale.length} stale)\n`);
+73
View File
@@ -0,0 +1,73 @@
// Read-only connector discovery. Never load binding policy or token resources.
import { lstatSync, readdirSync, openSync, closeSync, fstatSync, readFileSync, constants } from 'node:fs';
import { join, resolve, parse } from 'node:path';
import { readPid, ownerState } from '../../discord/src/journal.mjs';
const NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// Check every existing ancestor, not just the final component.
export function noLinks(path) {
const absolute = resolve(path);
let current = parse(absolute).root;
for (const part of absolute.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part);
try { if (lstatSync(current).isSymbolicLink()) return false; }
catch (e) { if (e.code === 'ENOENT') return true; return false; }
}
return true;
}
function stat(path) { try { return lstatSync(path); } catch { return null; } }
export function discoverDiscordAgents(dataRoot) {
const specs = [], errors = [];
const dir = join(dataRoot, 'discord');
if (!noLinks(dir)) return { specs, errors: ['discord discovery: unsafe directory'] };
const ds = stat(dir);
if (!ds) return { specs, errors };
if (!ds.isDirectory()) return { specs, errors: ['discord discovery: invalid directory'] };
let names;
try { names = readdirSync(dir).sort(); } catch { return { specs, errors: ['discord discovery: unreadable directory'] }; }
for (const file of names.filter(n => n.endsWith('.json'))) {
let fd;
try {
const name = file.slice(0, -5);
if (!NAME.test(name)) throw new Error();
fd = openSync(join(dir, file), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const s = fstatSync(fd);
if (!s.isFile() || (s.mode & 0o7777) !== 0o600 || s.size > 1024 * 1024) throw new Error();
const raw = JSON.parse(readFileSync(fd, 'utf8'));
if (!raw || raw.name !== name || typeof raw.seat !== 'string' || !NAME.test(raw.seat)) throw new Error();
// Do not retain the binding object, even in internal scan specs.
specs.push({ agent: `${raw.seat} (discord: ${name})`, project: 'fleet', sessionsDir: join(dataRoot, 'sessions', `discord-${name}`), tmux: null, connector: { binding: name, journalDir: join(dir, name) } });
} catch { errors.push('discord discovery: skipped invalid binding'); }
finally { if (fd !== undefined) closeSync(fd); }
}
return { specs, errors };
}
export function inspectDiscord(connector, { identity } = {}) {
const dir = connector.journalDir, owner = join(dir, 'run.lock', 'owner.json');
let braked = null;
if (noLinks(dir)) {
try { lstatSync(join(dir, 'STOP')); braked = true; }
catch (e) { if (e.code === 'ENOENT') braked = false; }
}
let ownerStatus = 'invalid';
if (noLinks(owner)) {
const s = stat(owner);
if (!s || s.isFile()) {
try { ownerStatus = ownerState(readPid(dir), identity ? { identity } : {}); } catch { ownerStatus = 'invalid'; }
}
}
return { binding: connector.binding, braked, ownerState: ownerStatus, alive: ownerStatus === 'live' };
}
// Connector session trees must not follow links, including ancestor links.
export function newestDiscordSession(dir) {
if (!noLinks(dir)) return null;
let best = null;
const walk = path => {
const s = stat(path);
if (!s || s.isSymbolicLink()) return;
if (s.isDirectory()) { for (const name of readdirSync(path)) walk(join(path, name)); }
else if (s.isFile() && path.endsWith('.jsonl') && (!best || s.mtimeMs > best.time)) best = { path, time: s.mtimeMs };
};
walk(dir);
return best?.path ?? null;
}
+21 -7
View File
@@ -63,6 +63,7 @@
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
.msg-error{color:var(--danger)}
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.relaunch-notice{display:block;max-width:36ch;overflow-wrap:anywhere;white-space:normal}
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
@@ -71,6 +72,7 @@
.reply-receipt{display:block;margin-top:.3em;font-size:.85em}
.reply-ok{color:var(--muted)}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.set-by{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
.detail-row td{background:var(--raised)}
.detail-list{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;margin:0;font-size:.85rem;font-family:var(--mono)}
.detail-list dt{color:var(--muted);font-family:var(--font)}
@@ -194,6 +196,12 @@
function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
// Who set a registered task (#1511): shown only when the task on the row is
// the registered one (the scan gives null otherwise). It is what the
// caller claimed when it ran `mosaic seat task`, not a verified identity,
// and it never changes what the row lets you do.
function setByTag(rec) { return rec.taskSetBy ? '<span class="set-by" title="set by (as claimed by the caller, not verified): ' + esc(rec.taskSetBy) + '">set by ' + esc(rec.taskSetBy) + "</span>" : ""; }
function setByText(rec) { return rec.taskSetBy ? esc(rec.taskSetBy) + " (as claimed by the caller, not verified)" : "—"; }
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
// hover title. modelText() already escapes, so the title takes it as is.
// Empty when the log has not named one yet.
@@ -205,6 +213,7 @@
var replyReceipts = {};
var replyDrafts = {};
function canReply(rec) {
if (rec.connector) return false;
var reg = rec.registered;
return !!(reg && reg.alive !== false && reg.tmux && reg.tmux.session);
}
@@ -216,6 +225,7 @@
}
function replyControl(rec) {
var id = [rec.project, rec.agent].join("/");
if (rec.connector) return '<span class="unknown">Board replies disabled for Discord connectors</span>';
if (!canReply(rec)) return '<span class="unknown">reply needs a registered seat</span>' + replyReceipt(id);
return '<form class="reply-form" data-id="' + esc(id) + '">' +
'<input type="text" class="reply-text" maxlength="2000" autocomplete="off" aria-label="Reply to ' + esc(rec.agent) + '" placeholder="Reply to ' + esc(rec.agent) + '" value="' + esc(replyDrafts[id] || "") + '">' +
@@ -233,12 +243,14 @@
return parts.join("; ");
}
function relaunchNotice(rec) { return rec.relaunchedAt ? 'relaunched at ' + esc(rec.relaunchedAt) + ', no messages since' : ''; }
function buildRowPair(rec, showProject) {
var idx = rowIdx++;
var key = (showProject ? "waiting:" : "group:") + [rec.project, rec.agent].join("/");
var open = !!openDetails[key];
var cls = "session-row" + (rec.state === "offline" ? " is-offline" : "");
var msg = rec.state === "error" && rec.lastError
var notice = relaunchNotice(rec);
var msg = notice ? '<span class="relaunch-notice">' + notice + '</span>' : rec.state === "error" && rec.lastError
? '<span class="msg-error" title="' + esc(rec.lastError) + '">' + esc(rec.lastError) + "</span>"
: '<span class="msg-text" title="' + esc(rec.lastAssistantText || "") + '">' + esc(rec.lastAssistantText || "—") + "</span>";
var projectCell = showProject ? "<td>" + esc(rec.project) + "</td>" : "";
@@ -246,7 +258,7 @@
// from the log and tmux, else "unknown". Never guessed. The source tag on
// the row says which one is shown.
var task = rec.task
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource)
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>" + sourceTag(rec.taskSource) + setByTag(rec)
: '<span class="unknown">unknown</span>';
var activeProject = rec.activeProject
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>" + sourceTag(rec.activeProjectSource)
@@ -255,8 +267,8 @@
'<tr class="' + cls + '">' +
projectCell +
'<td><button type="button" class="row-toggle" data-idx="' + idx + '" data-key="' + esc(key) + '" aria-expanded="' + (open ? "true" : "false") + '" aria-controls="detail-' + idx + '">' + esc(rec.agent) + "</button>" + modelTag(rec) + "</td>" +
"<td>" + badge(rec.state) + seenControl(rec) + "</td>" +
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
"<td>" + badge(rec.state) + (rec.connector ? '<span class="unknown"> · ' + (rec.connector.braked === true ? 'braked (STOP)' : rec.connector.braked === false ? 'not braked' : 'brake unknown') + ' · owner ' + esc(rec.connector.ownerState) + '</span>' : '') + seenControl(rec) + "</td>" +
"<td>" + (notice ? '<span class="relaunch-notice">' + notice + '</span>' : esc(humanAge(rec.ageSeconds))) + "</td>" +
"<td>" + task + "</td>" +
"<td>" + activeProject + "</td>" +
"<td>" + msg + "</td>" +
@@ -271,6 +283,7 @@
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
"<dt>Task set by</dt><dd>" + setByText(rec) + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
@@ -278,11 +291,12 @@
"<dt>Reply</dt><dd>" + replyControl(rec) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
"<dt>Last activity</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
(notice ? "<dt>Current activity</dt><dd>" + notice + "</dd>" : "") +
"<dt>" + (notice ? "Historical last activity" : "Last activity") + "</dt><dd>" + esc(rec.lastActivity || "—") + "</dd>" +
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
"<dt>Skipped lines</dt><dd>" + esc(rec.skippedLines) + "</dd>" +
"<dt>Last message</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
"<dt>Last error</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
"<dt>" + (notice ? "Historical last message" : "Last message") + "</dt><dd>" + esc(rec.lastAssistantText || "—") + "</dd>" +
"<dt>" + (notice ? "Historical last error" : "Last error") + "</dt><dd>" + esc(rec.lastError || "—") + "</dd>" +
"</dl></td></tr>";
return main + detail;
}
+49 -16
View File
@@ -3,10 +3,10 @@
//
// States (plain words):
// working - the agent is in the middle of a turn (thinking or running tools)
// waiting - the agent finished its turn; it is your move
// waiting - the agent explicitly requests human input in its completed reply
// error - the agent's last turn ended in an error, was aborted, or was cut off; look at it
// offline - no tmux session for this agent, or its session no longer runs pi
// idle - the agent is live but has no conversation yet
// idle - the agent is available, with no explicit input request
// unknown - liveness could not be checked (tmux missing or unresponsive); not a guess
//
// Board files are derived and rewritable. They are not run records. The one
@@ -19,6 +19,8 @@ import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
export const STATES = Object.freeze(["working", "waiting", "error", "offline", "idle", "unknown"]);
const TEXT_LIMIT = 240;
@@ -137,12 +139,20 @@ export function hasToolCall(message) {
return Array.isArray(message?.content) && message.content.some((c) => c && c.type === "toolCall");
}
// Pure state rule. alive: true/false, or null when liveness could not be checked.
// A null check is reported as "unknown" rather than assumed alive (fail closed).
// Acceptance rule (plan page, 2026-09-12): a seat mid-tool-call is working,
// never waiting. The newest entry being an assistant message with a tool call,
// or a tool result, means working even if the last text looked like a question.
// waiting needs a text-only assistant message whose turn ended (stopReason stop).
// Explicit display convention, not a language classifier or control permission.
// Only a plain first nonblank text line can request human input. Quoted/code
// examples and thinking blocks must not create attention events.
function requestsHumanInput(message) {
if (!Array.isArray(message.content)) return false;
const text = message.content.filter((c) => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text).join("\n");
const first = text.split(/\r?\n/).find((line) => line.trim()) ?? "";
const prefix = "Input needed: ";
return first.startsWith(prefix) && first.slice(prefix.length).trim().length > 0;
}
// A missing liveness check stays unknown. Offline/errors/tool activity take
// precedence over attention text. Completion alone means idle, not waiting.
export function deriveState({ alive, session }) {
if (alive === false) return "offline";
if (alive !== true) return "unknown";
@@ -151,7 +161,7 @@ export function deriveState({ alive, session }) {
if (m.role === "assistant") {
if (m.stopReason === "error" || m.stopReason === "aborted" || m.stopReason === "length") return "error";
if (hasToolCall(m)) return "working";
if (m.stopReason === "stop") return "waiting";
if (m.stopReason === "stop") return requestsHumanInput(m) ? "waiting" : "idle";
return "working";
}
return "working";
@@ -317,9 +327,10 @@ export function pidAlive(pid) {
// it is still reported under `registered` (with alive false) but the derived
// values win, because the record describes a launch that is not running.
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {}, registration = null, isPidAlive = pidAlive } = {}) {
const live = liveness(isAlive(spec.tmux));
const connector = spec.connector ? inspectDiscord(spec.connector) : null;
const live = connector ? { alive: connector.alive, workspace: null } : liveness(isAlive(spec.tmux));
const alive = live.alive;
const file = findNewestSession(spec.sessionsDir);
const file = connector ? newestDiscordSession(spec.sessionsDir) : findNewestSession(spec.sessionsDir);
const session = file ? readSession(file) : null;
const state = deriveState({ alive, session });
const scannedAt = now();
@@ -328,8 +339,16 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
const needsYou = state === "waiting" || state === "error";
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
const cwd = session?.cwd ?? null;
const record = registration && typeof registration === "object" ? registration : null;
const record = !connector && registration && typeof registration === "object" ? registration : null;
const registeredAlive = record ? isPidAlive(record.pid) : null;
// Presentation only: retain history and attention/Seen semantics. Unknown
// liveness, unmatched records and unknown timestamps cannot assert relaunch.
const launchTime = typeof record?.startedAt === "string" ? Date.parse(record.startedAt) : NaN;
const activityTime = typeof lastActivity === "string" ? Date.parse(lastActivity) : NaN;
const relaunchedAt = alive === true && registeredAlive === true &&
samePath(record?.sessionsDir, spec.sessionsDir) && Number.isFinite(launchTime) &&
Number.isFinite(activityTime) && launchTime > activityTime
? new Date(launchTime).toISOString() : null;
const reg = record && registeredAlive !== false ? record : null;
const derivedWorkspace = live.workspace ?? cwd;
const workspace = reg?.workspace ?? derivedWorkspace;
@@ -339,11 +358,21 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
const activeProject = reg?.project ?? derivedProject;
const activeProjectSource = reg?.project ? "registration" : derivedProject ? "workspace-git-root" : null;
const firstUserText = session?.firstUserText ?? null;
const task = reg?.task ? reg.task : firstUserText;
const taskSource = reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
// Discord user text starts with a routing envelope containing private IDs.
// Connector task metadata is fixed, never inferred from that transcript.
const task = connector ? "Discord connector" : reg?.task ? reg.task : firstUserText;
const taskSource = connector ? "connector" : reg?.task ? "registration" : firstUserText ? "first-user-message" : null;
// Who set the task (#1511), only when the task shown *is* the registered
// one: a record written before the field existed reads "unknown"; a
// transcript-derived task, a stale registration and a connector's fixed
// task carry no attribution at all (null), so a record can never lend its
// setter to a task it did not set. It is the caller's claim, bounded by
// the seat package on read, and it grants nothing.
const taskSetBy = taskSource === "registration" ? (reg.taskSetBy ?? "unknown") : null;
return {
agent: spec.agent,
project: spec.project,
...(connector ? { connector } : {}),
state,
waitingOnYou: needsYou && !isSeen,
seen: isSeen,
@@ -359,6 +388,7 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
provider: session?.provider ?? null,
task,
taskSource,
taskSetBy,
workspace,
workspaceSource,
activeProject,
@@ -367,6 +397,7 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
? { startedAt: record.startedAt, updatedAt: record.updatedAt, harness: record.harness, pid: record.pid, alive: registeredAlive, tmux: record.tmux, layout: record.layout, launchScript: record.launchScript }
: null,
lastActivity,
relaunchedAt,
ageSeconds,
lastAssistantText: session?.lastAssistantText ?? null,
lastError: session?.lastError ?? null,
@@ -403,12 +434,13 @@ function writeAtomic(path, data) {
// Scan every spec and write <boardDir>/sessions/<project>/<agent>.json plus index.json.
// seatsDir (optional): where `mosaic launch` registrations live; read only.
export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive } = {}) {
export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAlive, discordDataRoot = null } = {}) {
if (!boardDir || !isAbsolute(boardDir)) throw new ConfigError("boardDir must be an absolute path");
if (seatsDir !== null && (typeof seatsDir !== "string" || !isAbsolute(seatsDir))) throw new ConfigError("seatsDir must be an absolute path or null");
const seen = loadSeen(boardDir);
const { registrations, errors: registrationErrors } = loadRegistrations(seatsDir);
const records = specs.map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive }));
const discord = discordDataRoot ? discoverDiscordAgents(discordDataRoot) : { specs: [], errors: [] };
const records = [...specs, ...discord.specs].map((spec) => scanAgent(spec, { isAlive, now, seen, registration: matchRegistration(spec, registrations), isPidAlive }));
for (const rec of records) {
const dir = join(boardDir, "sessions", rec.project);
mkdirSync(dir, { recursive: true, mode: 0o700 });
@@ -423,6 +455,7 @@ export function scan(specs, { boardDir, isAlive, now, seatsDir = null, isPidAliv
registered: records.filter((r) => r.registered).map(seenKey),
registrationStale: records.filter((r) => r.registered && r.registered.alive === false).map(seenKey),
registrationErrors,
discoveryErrors: discord.errors,
sessions: records,
};
mkdirSync(boardDir, { recursive: true, mode: 0o700 });
+3 -2
View File
@@ -53,6 +53,7 @@ export function replyToRow({ index, key, text, agentSend = DEFAULT_AGENT_SEND, e
if (text.length > REPLY_LIMIT) return { status: 400, body: { error: `text is longer than ${REPLY_LIMIT} characters` } };
const rec = index.sessions.find((r) => seenKey(r) === key);
if (!rec) return { status: 404, body: { error: `unknown row: ${key}` } };
if (rec.connector || / \(discord: [a-z0-9][a-z0-9._-]{0,63}\)$/.test(rec.agent)) return { status: 409, body: { error: "board replies are disabled for Discord connectors" } };
const reg = rec.registered;
if (!reg) return { status: 409, body: { error: "reply needs a registered seat (start it through scripts/mosaic launch)" } };
if (reg.alive === false) return { status: 409, body: { error: `registration is stale: pid ${reg.pid} is gone` } };
@@ -130,8 +131,8 @@ export function loadPage(path = join(import.meta.dirname, "page.html")) {
}
// specs: agent specs to scan on each request. boardDir: where scan writes.
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive });
export function createServer({ specs, boardDir, isAlive, now, seatsDir = null, discordDataRoot = null, page = loadPage(), isPidAlive, agentSend = DEFAULT_AGENT_SEND, exec = spawnSync }) {
const rescan = () => scan(specs, { boardDir, isAlive, now, seatsDir, isPidAlive, discordDataRoot });
return createHttpServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (req.method === "POST" && url.pathname === "/api/reply") {
@@ -0,0 +1,55 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { scan, markSeen, deriveState } from '../src/scan.mjs';
test('explicit request, Seen, ordinary completion and a new request have distinct attention states', () => {
const root = mkdtempSync(join(tmpdir(), 'board-attention-flow-'));
try {
const sessionsDir = join(root, 'sessions');
mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl');
writeFileSync(file, JSON.stringify({ type: 'session', id: 'fixture', cwd: root, timestamp: '2026-09-13T00:00:00Z' }) + '\n');
const specs = [{ agent: 'researcher', project: 'fixture', sessionsDir, tmux: {} }];
const boardDir = join(root, 'board');
const get = () => scan(specs, { boardDir, isAlive: () => true }).sessions[0];
const append = (second, text) => appendFileSync(file, JSON.stringify({
type: 'message', timestamp: `2026-09-13T00:00:0${second}Z`,
message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] },
}) + '\n');
append(1, 'Input needed: Choose A or B.');
let r = get();
assert.equal(r.state, 'waiting');
assert.equal(r.waitingOnYou, true);
markSeen(boardDir, { project: 'fixture', agent: 'researcher', lastActivity: r.lastActivity, seen: true });
r = get();
assert.equal(r.state, 'waiting', 'acknowledgment does not resolve an input request');
assert.equal(r.seen, true);
assert.equal(r.waitingOnYou, false);
append(2, 'BOARD_REPLY_OK');
r = get();
assert.equal(r.state, 'idle');
assert.equal(r.waitingOnYou, false);
assert.equal(r.seen, false, 'ordinary completion needs no Seen action');
append(3, 'Input needed: Confirm the revised choice.');
r = get();
assert.equal(r.state, 'waiting');
assert.equal(r.waitingOnYou, true, 'old Seen does not suppress a new request');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('attention convention ignores reasoning/quoted examples and permits leading blank lines', () => {
const derive = (content, alive = true) => deriveState({ alive, session: { lastMessage: { role: 'assistant', stopReason: 'stop', content } } });
const text = (value) => [{ type: 'text', text: value }];
for (const value of ['> Input needed: Choose A.', ' Input needed: Choose A.', '`Input needed: Choose A.`', 'Input needed:\tChoose A.']) {
assert.equal(derive(text(value)), 'idle', value);
}
assert.equal(derive([{ type: 'thinking', thinking: 'Input needed: Choose A.' }, ...text('Done.')]), 'idle');
assert.equal(derive(text('\n\r\nInput needed: Choose A.')), 'waiting');
assert.equal(derive(text('Input needed: Choose A.'), false), 'offline');
assert.equal(derive(text('Input needed: Choose A.'), null), 'unknown');
});
@@ -0,0 +1,29 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { deriveState } from '../src/scan.mjs';
const state = (text, overrides = {}) => deriveState({ alive: true, session: {
lastMessage: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }], ...overrides },
} });
test('completed smoke replies and ordinary questions are idle, not human blockers', () => {
for (const text of ['BOARD_REPLY_OK', 'RESEARCHER_NATIVE_SMOKE_OK', 'Done.', 'Shall I continue?']) {
assert.equal(state(text), 'idle', text);
}
});
test('only an explicit first-line input request makes a finished reply waiting', () => {
assert.equal(state('Input needed: Choose the release target.\nDetails follow.'), 'waiting');
for (const text of ['Input needed:', 'Input needed: ', 'Example:\nInput needed: Choose A.', '```\nInput needed: Choose A.\n```']) {
assert.equal(state(text), 'idle', text);
}
});
test('tool activity, user text, errors and unfinished turns override attention text', () => {
const text = 'Input needed: Choose A.';
assert.equal(state(text, { content: [{ type: 'text', text }, { type: 'toolCall', name: 'read' }] }), 'working');
assert.equal(state(text, { role: 'user' }), 'working');
assert.equal(state(text, { role: 'toolResult' }), 'working');
assert.equal(state(text, { stopReason: 'error' }), 'error');
assert.equal(state(text, { stopReason: undefined }), 'working');
});
@@ -0,0 +1,145 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, symlinkSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { discoverDiscordAgents, inspectDiscord } from '../src/discord.mjs';
import { scan, scanAgent } from '../src/scan.mjs';
import { replyToRow, startServer } from '../src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { envelope } from '../../discord/src/context.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
test('STOP access failure is unknown, not absence, under a non-root identity', () => {
const result = spawnSync(process.execPath, ['--input-type=module', '-e', `
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { inspectDiscord } from ${JSON.stringify(new URL('../src/discord.mjs', import.meta.url).href)};
assert.notEqual(process.getuid(), 0, 'permission regression must not run as root');
const root = mkdtempSync(join(tmpdir(), 'board-stop-permission-'));
const dir = join(root, 'journal'), connector = { binding: 'fixture', journalDir: dir };
mkdirSync(dir);
try {
assert.equal(inspectDiscord(connector).braked, false, 'verified absence');
writeFileSync(join(dir, 'STOP'), 'private contents never read');
assert.equal(inspectDiscord(connector).braked, true, 'verified presence');
chmodSync(dir, 0o000);
const inaccessible = inspectDiscord(connector);
assert.equal(inaccessible.braked, null, 'inaccessible STOP is unknown');
assert.equal(inaccessible.alive, false);
chmodSync(dir, 0o700);
assert.equal(inspectDiscord(connector).braked, true, 'restored access sees STOP');
rmSync(join(dir, 'STOP'));
assert.equal(inspectDiscord(connector).braked, false, 'verified removal');
} finally { chmodSync(dir, 0o700); rmSync(root, { recursive: true, force: true }); }
`], { encoding: 'utf8', timeout: 10000, ...(process.getuid() === 0 ? { uid: 65534, gid: 65534 } : {}) });
assert.equal(result.status, 0, result.stdout + result.stderr);
});
test('connector Task never inherits Discord routing envelopes; ordinary Task still uses user text', () => {
const f = fixture();
try {
const text = envelope({ guildName: 'Synthetic guild', channelName: 'synthetic-channel', authorId: '111111111111111111', messageId: '222222222222222222', text: 'Synthetic user request' });
const sessionsDir = join(f.root, 'sessions', 'discord-demo');
writeFileSync(join(sessionsDir, 's.jsonl'), JSON.stringify({ type: 'message', message: { role: 'user', content: [{ type: 'text', text }] } }) + '\n');
const connector = scanAgent(discoverDiscordAgents(f.root).specs[0]);
assert.equal(connector.task, 'Discord connector');
assert.equal(connector.taskSource, 'connector');
for (const secret of ['111111111111111111', '222222222222222222', 'Synthetic user request']) assert.equal(connector.task.includes(secret), false);
const ordinary = scanAgent({ agent: 'native', project: 'repo', sessionsDir, tmux: {} }, { isAlive: () => true });
assert.equal(ordinary.task, text.replace(/\s+/g, ' ').trim());
assert.equal(ordinary.taskSource, 'first-user-message');
} finally { f.cleanup(); }
});
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'board-discord-'));
mkdirSync(join(root, 'discord', 'demo', 'run.lock'), { recursive: true });
writeFileSync(join(root, 'discord', 'demo.json'), JSON.stringify({ name: 'demo', seat: 'researcher', tokenFile: '/never-read-secret', guildId: 'PRIVATE-ID', users: ['PRIVATE-USER'] }), { mode: 0o600 });
const sessions = join(root, 'sessions', 'discord-demo'); mkdirSync(sessions, { recursive: true });
writeFileSync(join(sessions, 's.jsonl'), JSON.stringify({ type: 'message', timestamp: '2026-09-14T00:00:00Z', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'done' }] } }) + '\n');
const owner = join(root, 'discord', 'demo', 'run.lock', 'owner.json');
writeFileSync(owner, JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
return { root, owner, cleanup: () => rmSync(root, { recursive: true, force: true }) };
}
test('connector discovery keeps only safe identity; rejects modes, mismatches, links and traversal', () => {
const f = fixture();
try {
const result = discoverDiscordAgents(f.root);
assert.equal(result.specs.length, 1); assert.deepEqual(result.errors, []);
assert.equal(result.specs[0].agent, 'researcher (discord: demo)');
assert.equal(JSON.stringify(result).includes('PRIVATE'), false); assert.equal(JSON.stringify(result).includes('tokenFile'), false);
writeFileSync(join(f.root, 'discord', 'bad.json'), '{"name":"bad","seat":"../../escape","secret":"PRIVATE"}', { mode: 0o600 });
symlinkSync(join(f.root, 'discord', 'demo.json'), join(f.root, 'discord', 'linked.json'));
writeFileSync(join(f.root, 'discord', 'other.json'), '{"name":"wrong","seat":"x"}', { mode: 0o600 });
chmodSync(join(f.root, 'discord', 'demo.json'), 0o644);
const bad = discoverDiscordAgents(f.root); assert.equal(bad.specs.length, 0); assert.equal(bad.errors.length, 4);
assert.equal(JSON.stringify(bad).includes('PRIVATE'), false);
} finally { f.cleanup(); }
});
test('canonical owner identity and STOP are independent; no tmux fallback or forged registration', () => {
const f = fixture();
try {
const spec = discoverDiscordAgents(f.root).specs[0];
const row = () => scanAgent(spec, { isAlive: () => { throw Error('must not ask tmux'); }, registration: { pid: process.pid, task: 'forged', taskSetBy: 'forged-setter', tmux: { session: 'forged' } } });
assert.equal(row().alive, true); assert.equal(row().state, 'idle'); assert.equal(row().registered, null);
// #1511: the connector's fixed task borrows no attribution from a native registration.
assert.equal(row().task, 'Discord connector'); assert.equal(row().taskSource, 'connector'); assert.equal(row().taskSetBy, null);
assert.equal(JSON.stringify(row()).includes('forged-setter'), false);
writeFileSync(join(f.root, 'discord', 'demo', 'STOP'), 'PRIVATE BRAKE CONTENT');
assert.equal(row().connector.braked, true); assert.equal(row().state, 'idle');
assert.equal(JSON.stringify(row()).includes('PRIVATE'), false);
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: '1', boot: identityOf(process.pid).boot }) }).alive, false);
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: null, boot: null }) }).ownerState, 'unknown');
assert.equal(inspectDiscord(spec.connector, { identity: () => ({ start: identityOf(process.pid).start, boot: '00000000-0000-0000-0000-000000000000' }) }).ownerState, 'mismatch');
for (const value of ['broken PRIVATE', JSON.stringify({ pid: process.pid }), JSON.stringify({ pid: 2147483647, ...identityOf(process.pid) })]) {
writeFileSync(f.owner, value); assert.equal(row().alive, false); assert.equal(row().state, 'offline');
}
rmSync(f.owner); assert.equal(row().connector.ownerState, 'absent');
symlinkSync('/never-read-secret', f.owner); assert.equal(row().alive, false);
} finally { f.cleanup(); }
});
test('connector reply refusal precedes forged live tmux registration; ordinary agent still sends', () => {
let calls = 0; const exec = () => { calls++; return { status: 0, stdout: '', stderr: '' }; };
const r = { agent: 'researcher (discord: demo)', project: 'fleet', connector: { binding: 'demo' }, registered: { alive: true, tmux: { session: 'forged' } } };
assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409);
delete r.connector;
assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/' + r.agent, text: 'hello', exec }).status, 409);
assert.equal(calls, 0);
r.agent = 'ordinary'; assert.equal(replyToRow({ index: { sessions: [r] }, key: 'fleet/ordinary', text: 'hello', exec }).status, 200); assert.equal(calls, 1);
});
test('server rescans connector discovery and refuses HTTP reply without transport', async () => {
const f = fixture(); let server;
try {
const sessionsDir = join(f.root, 'sessions', 'discord-demo'), seatsDir = join(f.root, 'seats');
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: 'forged', project: 'fleet', sessionsDir, seatDir: sessionsDir, launchScript: join(f.root, 'unused.sh'), layout: 'repo', defaultWorkspace: f.root }, task: 'forged', tmux: { session: 'forged', socket: null }, pid: process.pid }));
server = await startServer({ port: 0, specs: [], seatsDir, boardDir: join(f.root, 'board'), discordDataRoot: f.root, exec: () => { throw Error('transport forbidden'); } });
const base = `http://127.0.0.1:${server.address().port}`;
let response = await fetch(base + '/api/board'); const board = await response.json();
assert.equal(board.sessions[0].state, 'idle'); assert.equal(board.sessions[0].connector.alive, true);
assert.equal(board.sessions[0].registered, null); assert.notEqual(board.sessions[0].task, 'forged');
response = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/researcher (discord: demo)', text: 'hello' }) });
assert.equal(response.status, 409);
rmSync(join(f.root, 'discord', 'demo.json'));
assert.equal((await (await fetch(base + '/api/board')).json()).sessions.length, 0);
} finally { if (server) await new Promise(r => server.close(r)); f.cleanup(); }
});
test('connector session links and linked directories are not read', () => {
const f = fixture();
try {
const sessions = join(f.root, 'sessions', 'discord-demo'); rmSync(sessions, { recursive: true });
symlinkSync(join(f.root, 'discord'), sessions);
const result = scan([], { boardDir: join(f.root, 'board'), discordDataRoot: f.root });
assert.equal(result.sessions[0].sessionFile, null);
rmSync(join(f.root, 'discord'), { recursive: true }); symlinkSync('/never-read-secret', join(f.root, 'discord'));
assert.equal(discoverDiscordAgents(f.root).specs.length, 0);
} finally { f.cleanup(); }
});
@@ -0,0 +1,85 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, renameSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { scanAgent } from '../src/scan.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
const oldAt = '2026-09-14T10:00:00.000Z';
const launchAt = '2026-09-15T10:00:00.000Z';
const newAt = '2026-09-15T10:01:00.000Z';
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'board-relaunch-'));
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 'session.jsonl');
const message = (at, text) => JSON.stringify({ type: 'message', timestamp: at, message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] } }) + '\n';
writeFileSync(file, message(oldAt, 'Input needed: Historical request.'));
const spec = { agent: 'fixture', project: 'repo', sessionsDir, tmux: {} };
const registration = { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Registered task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' };
const read = (extra = {}) => scanAgent(spec, { isAlive: () => true, isPidAlive: () => true, registration, now: () => new Date(newAt), ...extra });
return { root, file, spec, registration, read, message, close: () => rmSync(root, { recursive: true, force: true }) };
}
test('newer live matching launch marks old activity, preserves history/attention/attribution, then clears on new activity', () => {
const f = fixture();
try {
const original = readFileSync(f.file, 'utf8');
const row = f.read();
assert.equal(row.relaunchedAt, launchAt);
assert.equal(row.lastActivity, oldAt); assert.equal(row.lastAssistantText, 'Input needed: Historical request.');
assert.equal(row.state, 'waiting'); assert.equal(row.waitingOnYou, true);
assert.equal(row.taskSetBy, 'fixture-setter'); assert.equal(row.taskSource, 'registration');
assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).seen, true);
assert.equal(f.read({ seen: { 'repo/fixture': oldAt } }).waitingOnYou, false);
assert.equal(readFileSync(f.file, 'utf8'), original, 'scanning never rewrites historical messages');
appendFileSync(f.file, f.message(newAt, 'Fresh completion.'));
const next = f.read(); assert.equal(next.relaunchedAt, null); assert.equal(next.state, 'idle');
assert.equal(next.lastAssistantText, 'Fresh completion.'); assert.equal(next.lastActivity, newAt);
} finally { f.close(); }
});
test('CLI print uses the relaunch notice instead of old current preview', () => {
const f = fixture();
try {
const state = join(f.root, '.pi', 'state', 'fixture'); mkdirSync(state, { recursive: true });
const sessionsDir = join(state, 'sessions'); renameSync(f.spec.sessionsDir, sessionsDir);
const dataRoot = join(f.root, 'data');
writeRegistration(join(dataRoot, 'seats'), { ...f.registration, sessionsDir });
const config = join(f.root, 'config.json'); writeFileSync(config, JSON.stringify({ dataRoot }));
const run = spawnSync(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'scan', '--config', config, '--repo', f.root, '--fleet', 'none', '--liveness', 'assume-alive', '--print'], { encoding: 'utf8', timeout: 10000 });
assert.equal(run.status, 0, run.stderr);
assert.ok(run.stdout.includes(`relaunched at ${launchAt}, no messages since`));
assert.equal(run.stdout.includes('Historical request.'), false);
} finally { f.close(); }
});
test('connector owner and fixed task never inherit a native relaunch notice', () => {
const f = fixture();
try {
const journalDir = join(f.root, 'journal'); mkdirSync(join(journalDir, 'run.lock'), { recursive: true });
writeFileSync(join(journalDir, 'run.lock', 'owner.json'), JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
const row = scanAgent({ ...f.spec, connector: { binding: 'fixture', journalDir } }, { registration: f.registration, isAlive: () => { throw Error('connector cannot use native liveness'); } });
assert.equal(row.alive, true); assert.equal(row.relaunchedAt, null); assert.equal(row.task, 'Discord connector');
assert.equal(row.taskSetBy, null); assert.equal(row.registered, null);
} finally { f.close(); }
});
test('equality, stale/unknown/offline, mismatched registration and unknown activity do not assert relaunch', () => {
const f = fixture();
try {
assert.equal(f.read({ registration: { ...f.registration, startedAt: oldAt } }).relaunchedAt, null);
for (const value of [false, null]) assert.equal(f.read({ isPidAlive: () => value }).relaunchedAt, null);
for (const value of [false, null]) assert.equal(f.read({ isAlive: () => value }).relaunchedAt, null);
assert.equal(f.read({ registration: null }).relaunchedAt, null);
assert.equal(f.read({ registration: { ...f.registration, sessionsDir: join(f.root, 'different') } }).relaunchedAt, null);
assert.equal(f.read({ registration: { ...f.registration, startedAt: 'invalid' } }).relaunchedAt, null);
writeFileSync(f.file, f.message('invalid', 'Undated history.'));
assert.equal(f.read().relaunchedAt, null);
writeFileSync(f.file, JSON.stringify({ type: 'message', message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text: 'No timestamp.' }] } }) + '\n');
assert.equal(f.read().relaunchedAt, null);
rmSync(f.file); assert.equal(f.read().relaunchedAt, null);
} finally { f.close(); }
});
+95 -11
View File
@@ -284,13 +284,13 @@ test("deriveState: full state table", () => {
{ name: "alive=false is offline regardless of session", input: { alive: false, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "offline" },
{ name: "no session is idle", input: { alive: true, session: null }, expected: "idle" },
{ name: "no lastMessage is idle", input: { alive: true, session: { lastMessage: null } }, expected: "idle" },
{ name: "assistant+stop is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "waiting" },
{ name: "assistant+stop is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop" } } }, expected: "idle" },
{ name: "assistant+error is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error" } } }, expected: "error" },
{ name: "assistant+aborted is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "aborted" } } }, expected: "error" },
{ name: "assistant+toolUse is working", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "toolUse" } } }, expected: "working" },
{ name: "assistant with a toolCall block is working even if stopReason says stop", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: "Shall I?" }, TOOL_CALL] } } }, expected: "working" },
{ name: "assistant+error with a toolCall block is still error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "error", content: [TOOL_CALL] } } }, expected: "error" },
{ name: "assistant+stop with thinking and text (no tool call) is waiting", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "waiting" },
{ name: "assistant+stop with thinking and text (no tool call) is idle", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "stop", content: [{ type: "thinking", thinking: "hm" }, { type: "text", text: "done" }] } } }, expected: "idle" },
{ name: "user last is working", input: { alive: true, session: { lastMessage: { role: "user" } } }, expected: "working" },
{ name: "toolResult last is working", input: { alive: true, session: { lastMessage: { role: "toolResult" } } }, expected: "working" },
{ name: "assistant+length (cut off) is error", input: { alive: true, session: { lastMessage: { role: "assistant", stopReason: "length" } } }, expected: "error" },
@@ -335,7 +335,7 @@ test("rule: newest entry is a tool result with no assistant text after it is wor
assert.equal(rec.waitingOnYou, false);
});
test("rule: a finished turn (text-only assistant message, stopReason stop) is waiting", () => {
test("rule: a finished ordinary turn is idle, even if it says your move", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
@@ -344,8 +344,8 @@ test("rule: a finished turn (text-only assistant message, stopReason stop) is wa
messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done. Your move."] }),
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: () => new Date("2026-09-12T14:00:10Z") });
assert.equal(rec.state, "waiting");
assert.equal(rec.waitingOnYou, true);
assert.equal(rec.state, "idle");
assert.equal(rec.waitingOnYou, false);
});
// ---------------------------------------------------------------------------
@@ -764,7 +764,7 @@ test("scanAgent: waitingOnYou is true for waiting/error and false otherwise", ()
mkdirSync(errorDir, { recursive: true });
writeSessionFile(waitingDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
]);
writeSessionFile(workingDir, "s.jsonl", [
sessionLine({ id: "s2", timestamp: "2026-09-01T00:00:00Z", cwd: "/k" }),
@@ -866,7 +866,7 @@ test("scan: writes per-agent files and index.json, rerun overwrites, no leftover
sessionPath,
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
].join("\n") + "\n"
);
@@ -938,7 +938,7 @@ test("CLI: scan with assume-alive liveness exits 0, prints board summary, writes
join(sessionsDir, "s.jsonl"),
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
].join("\n") + "\n"
);
@@ -1131,7 +1131,7 @@ test("scanAgent: a seen mark matching the waiting session's lastTimestamp clears
mkdirSync(sessionsDir, { recursive: true });
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
]);
const seen = { "p/a": "2026-09-01T00:00:01Z" };
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
@@ -1146,7 +1146,7 @@ test("scanAgent: a stale mark (agent wrote something newer) is not seen and wait
mkdirSync(sessionsDir, { recursive: true });
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
]);
const seen = { "p/a": "2026-09-01T00:00:00Z" }; // stale: older than lastTimestamp
const rec = scanAgent({ agent: "a", project: "p", sessionsDir, tmux: {} }, { isAlive: () => true, now: () => new Date("2026-09-01T00:01:00Z"), seen });
@@ -1197,7 +1197,7 @@ test("scan: index.seen and waitingOnYou reflect seen.json, which scan never rewr
const lastActivity = "2026-09-01T00:00:01Z";
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
]);
mkdirSync(boardDir, { recursive: true });
const seenPath = join(boardDir, "seen.json");
@@ -1219,3 +1219,87 @@ test("scan: a corrupt seen.json makes scan throw ConfigError (fail closed)", ()
writeFile(join(boardDir, "seen.json"), "{ bad json");
assert.throws(() => scan([], { boardDir, isAlive: () => true }), ConfigError);
});
// ---------------------------------------------------------------------------
// Task attribution (#1511): taskSetBy on the row, tied to the task source.
// ---------------------------------------------------------------------------
function attributionFixture(root, { taskSetBy, task = "registered task", pid = process.pid } = {}) {
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/derived/cwd" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["derived task"] }),
]);
const spec = { agent: "a", project: "p", sessionsDir, tmux: {} };
const base = makeRegistration({
resolved: { seat: "a", project: "rp", sessionsDir, seatDir: join(root, "seat-dir"), launchScript: join(root, "seat-dir", "launch.sh"), layout: "repo", defaultWorkspace: "/w" },
task, tmux: { socket: null, session: "a" }, pid, now: () => new Date("2026-09-12T13:00:00Z"),
});
const registration = taskSetBy === undefined ? base : { ...base, taskSetBy };
return { spec, sessionsDir, registration };
}
test("taskSetBy: a registered task carries the record's setter; a record without the field (pre-#1511) reads unknown; the value is not copied into registered", () => {
const named = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
const rec = scanAgent(named.spec, { isAlive: () => true, now: GATE_NOW, registration: named.registration });
assert.equal(rec.taskSource, "registration");
assert.equal(rec.taskSetBy, "darkwing");
assert.equal("taskSetBy" in rec.registered, false, "attribution lives on the row next to taskSource only");
const old = attributionFixture(makeRoot());
assert.equal("taskSetBy" in old.registration, false);
const oldRec = scanAgent(old.spec, { isAlive: () => true, now: GATE_NOW, registration: old.registration });
assert.equal(oldRec.taskSource, "registration");
assert.equal(oldRec.taskSetBy, "unknown");
const literal = attributionFixture(makeRoot(), { taskSetBy: "unknown" });
assert.equal(scanAgent(literal.spec, { isAlive: () => true, now: GATE_NOW, registration: literal.registration }).taskSetBy, "unknown");
});
test("taskSetBy: null whenever the task shown is not the registered one: no registration, an empty registered task, a stale registration; the field is always present", () => {
const none = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
const withoutReg = scanAgent(none.spec, { isAlive: () => true, now: GATE_NOW });
assert.equal(withoutReg.taskSource, "first-user-message");
assert.equal(withoutReg.taskSetBy, null);
assert.ok("taskSetBy" in withoutReg);
const empty = attributionFixture(makeRoot(), { taskSetBy: "darkwing", task: "" });
const emptyRec = scanAgent(empty.spec, { isAlive: () => true, now: GATE_NOW, registration: empty.registration });
assert.equal(emptyRec.task, "derived task");
assert.equal(emptyRec.taskSource, "first-user-message");
assert.equal(emptyRec.taskSetBy, null, "a setter on a record whose task is empty is not attached to the transcript's task");
assert.notEqual(emptyRec.registered, null);
const stale = attributionFixture(makeRoot(), { taskSetBy: "darkwing" });
const staleRec = scanAgent(stale.spec, { isAlive: () => true, now: GATE_NOW, registration: stale.registration, isPidAlive: () => false });
assert.equal(staleRec.task, "derived task");
assert.equal(staleRec.taskSource, "first-user-message");
assert.equal(staleRec.taskSetBy, null, "a stale registration lends neither its task nor its setter");
assert.equal(staleRec.registered.alive, false);
const noSession = scanAgent({ agent: "b", project: "p", sessionsDir: join(makeRoot(), "missing"), tmux: {} }, { isAlive: () => false, now: GATE_NOW });
assert.equal(noSession.task, null);
assert.equal(noSession.taskSetBy, null);
});
test("taskSetBy: scan() reads the field from disk through the seat package (bounded there), writes it to the per-agent record and index, and an invalid on-disk value is a registrationError, never a row value", () => {
const root = makeRoot();
const seatsDir = join(root, "seats");
const boardDir = join(root, "board");
const f = attributionFixture(root, { taskSetBy: "jason@host:1" });
writeRegistration(seatsDir, f.registration);
const index = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW });
assert.equal(index.sessions[0].taskSetBy, "jason@host:1");
assert.equal(JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8")).taskSetBy, "jason@host:1");
assert.deepEqual(index.registrationErrors, []);
// A hand-edited record with markup in the field does not reach the board.
writeFileSync(join(seatsDir, "repo", "a", "registration.json"), JSON.stringify({ ...f.registration, taskSetBy: "<img src=x onerror=alert(1)>" }));
const rejected = scan([f.spec], { boardDir, seatsDir, isAlive: () => true, now: GATE_NOW });
assert.equal(rejected.sessions[0].taskSource, "first-user-message");
assert.equal(rejected.sessions[0].taskSetBy, null);
assert.equal(rejected.sessions[0].registered, null);
assert.equal(rejected.registrationErrors.length, 1);
assert.match(rejected.registrationErrors[0], /taskSetBy/);
assert.equal(JSON.stringify(rejected).includes("onerror"), false, "the offending value is not echoed anywhere on the index");
});
+65 -4
View File
@@ -176,8 +176,8 @@ test("startServer: serves page, healthz, and a rescanning /api/board", async ()
assert.equal(first.headers.get("content-type"), "application/json");
assert.equal(first.headers.get("cache-control"), "no-store");
const firstBody = await first.json();
assert.equal(firstBody.sessions[0].state, "waiting");
assert.ok(firstBody.waitingOnYou.includes("proj/agent1"));
assert.equal(firstBody.sessions[0].state, "idle");
assert.ok(!firstBody.waitingOnYou.includes("proj/agent1"));
assert.ok(existsSync(join(boardDir, "index.json")), "index.json must be written by the scan");
assert.ok(existsSync(join(boardDir, "sessions", "proj", "agent1.json")), "per-agent file must be written by the scan");
@@ -397,7 +397,7 @@ test("POST /api/seen marks a row; GET /api/board still shows it seen; seen:false
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
]);
const boardDir = join(root, "board");
const specs = [{ agent: "agent1", project: "proj", sessionsDir, tmux: {} }];
@@ -557,7 +557,7 @@ test("CLI: scan --print marks a seen row with 's' and the summary line ends with
join(sessionsDir, "s.jsonl"),
[
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["done"] }),
messageLine({ timestamp: lastActivity, role: "assistant", stopReason: "stop", texts: ["Input needed: Confirm the fixture."] }),
].join("\n") + "\n"
);
const boardDir = join(dataRoot, "board");
@@ -881,3 +881,64 @@ test("page.html: the reply box appears only where canReply() holds (live registr
assert.match(html, /function withReplyFocus\(render\)/, "a refresh gives the caret back to the reply box");
assert.doesNotMatch(html, /send-keys/, "the page never talks tmux");
});
// ---------------------------------------------------------------------------
// Task attribution (#1511): serialized by the server, rendered by page.html.
// ---------------------------------------------------------------------------
test("startServer: /api/board carries taskSetBy from a live registration and null for the derived rows", async () => {
const root = makeRoot();
const sessionsDir = join(root, "sessions");
const derivedDir = join(root, "derived-sessions");
for (const dir of [sessionsDir, derivedDir]) {
writeSessionFile(dir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-01T00:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-01T00:00:01Z", role: "user", texts: ["derived task"] }),
]);
}
const seatsDir = join(root, "seats");
const reg = makeRegistration({
resolved: { seat: "agent1", project: "proj", sessionsDir, seatDir: join(root, "agent1"), launchScript: join(root, "agent1", "launch.sh"), layout: "repo", defaultWorkspace: null },
task: "registered task",
});
writeRegistration(seatsDir, { ...reg, taskSetBy: "darkwing" });
const specs = [
{ agent: "agent1", project: "proj", sessionsDir, tmux: {} },
{ agent: "agent2", project: "proj", sessionsDir: derivedDir, tmux: {} },
];
const server = await startServer({ host: "127.0.0.1", port: 0, specs, boardDir: join(root, "board"), seatsDir, isAlive: () => ({ alive: true, workspace: null }), page: "<html></html>" });
try {
const body = await (await fetch(`http://127.0.0.1:${server.address().port}/api/board`)).json();
const byAgent = Object.fromEntries(body.sessions.map((r) => [r.agent, r]));
assert.equal(byAgent.agent1.taskSource, "registration");
assert.equal(byAgent.agent1.taskSetBy, "darkwing");
assert.equal(byAgent.agent2.taskSource, "first-user-message");
assert.equal(byAgent.agent2.taskSetBy, null);
assert.deepEqual(body.registrationErrors, []);
} finally {
await new Promise((r) => server.close(r));
}
});
test("page.html: the task cell and detail show who set a registered task via setByTag()/setByText(), both escaped, only from rec.taskSetBy; the reply gate does not read it", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const lines = html.split("\n");
const rowPair = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
assert.ok(rowPair);
assert.match(rowPair[0], /sourceTag\(rec\.taskSource\) \+ setByTag\(rec\)/, "the set-by tag follows the task's source tag on the row");
assert.match(rowPair[0], /<dt>Task set by<\/dt><dd>" \+ setByText\(rec\) \+ "<\/dd>/, "the detail has a Task set by row");
const tagLine = lines.find((l) => l.includes("function setByTag(rec)"));
assert.ok(tagLine);
assert.match(tagLine, /rec\.taskSetBy \?/, "nothing is shown without a value");
assert.equal((tagLine.match(/esc\(rec\.taskSetBy\)/g) || []).length, 2, "the title and the text are both escaped");
assert.match(tagLine, /not verified/);
const textLine = lines.find((l) => l.includes("function setByText(rec)"));
assert.ok(textLine);
assert.match(textLine, /esc\(rec\.taskSetBy\)/);
assert.match(textLine, /not verified/);
// Attribution never enables a reply: canReply() reads the registration only.
const canReplyFn = html.match(/function canReply\(rec\) \{[\s\S]*?\n \}/);
assert.ok(canReplyFn);
assert.equal(canReplyFn[0].includes("taskSetBy"), false);
assert.equal(html.match(/function replyControl\(rec\) \{[\s\S]*?\n \}/)[0].includes("taskSetBy"), false);
});