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);
});
+27 -3
View File
@@ -3,7 +3,7 @@
`mosaic launch <seat>` starts a seat through its existing launch script,
unchanged, and leaves one registration record that the control board reads
instead of guessing. `mosaic seat task <seat> <text>` changes the task on
that record. Nothing else. No provider or auth registry, no roster schema
that record and notes who set it. Nothing else. No provider or auth registry, no roster schema
change, no stopping or killing of seats, one record per seat.
Issue #1504. Plain ESM, no dependencies, Node 24 or newer.
@@ -17,7 +17,7 @@ or `seat` command; run it by path.
```
scripts/mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
[--harness NAME] [--repo PATH] [--config PATH] [-- args...]
scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
scripts/mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
```
- `<seat>` is a name under `<repo>/agents/` (`--repo` defaults to the
@@ -36,6 +36,17 @@ scripts/mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config P
- The launch script receives `MOSAIC_LAUNCH_REGISTERED=<record path>`. A
launch script that sees this variable is already registered and must not
call `mosaic launch` again; `mosaic launch` refuses to run when it is set.
- `seat task` records who set the task in `taskSetBy` (#1511): `--by NAME`
if given, else `$MOSAIC_AGENT_NAME` if set and non-empty, else the word
`unknown`. An explicit value must be one token of 1 to 64 characters
(letters, digits, `.` `_` `@` `:` `-`, starting with a letter or digit);
anything else refuses with exit 4 before the record is touched. A set but
malformed `MOSAIC_AGENT_NAME` also refuses rather than being reported as
`unknown`; `--by` is the explicit way past it. The value is never echoed
back in a refusal. **This is what the caller said, not a verified
identity.** Nothing checks it, nothing grants on it; the board displays
it next to the task and does no more with it. `launch --task` does not
set the field.
The four repository seats (`agents/darkwing`, `agents/dewey`,
`agents/filbert`, `agents/rocko`) register themselves: their `launch.sh`
@@ -81,6 +92,10 @@ scan never changes it.
}
```
After `scripts/mosaic seat task darkwing "Row 6 (#1511)" --by jason` the
same record also carries `"taskSetBy": "jason"` and a non-null
`updatedAt`; `startedAt` and every other field are unchanged.
Fields asked for in the brief: `seat`, `project`, `task` (empty unless
`--task` or a later `seat task`), `workspace`, `tmux` (session name and
socket, from the `TMUX` variable of the pane the launch ran in; null outside
@@ -100,6 +115,11 @@ Fields added, and why:
the record is still written but the board cannot match it).
- `updatedAt`: set only by `seat task`, so a task change is distinguishable
from a relaunch.
- `taskSetBy`: set only by `seat task` (see above). Optional in the shape:
a record written before the field existed loads unchanged, the board shows
`unknown` for it, and nothing rewrites it until the next `seat task` or
launch. The record version stays 1; a value that is not one bounded token
makes the record unreadable (refused, not misread).
- `version`: so a later shape change can be refused rather than misread.
`project` and `workspace` are derived only for the repo layout (the
@@ -119,7 +139,11 @@ registration are unchanged. A malformed record is reported in
`registrationErrors` on the index and skipped; it never takes the board down
and it is never treated as absent silently. A record whose pid is no
longer running is stale: still shown on the Registered line, but it does
not override anything.
not override anything. The row's `taskSetBy` is the record's setter only
while the task shown is the registered one (`taskSource` `registration`);
otherwise it is null, so a stale or empty registration never attributes a
transcript-derived task, and a Discord connector's fixed task never borrows
a native record's setter.
The repository launch scripts are the only launchers that register.
Fleet seats under `~/.mosaic` stay on their own launchers (Jason's ruling,
+14 -7
View File
@@ -2,7 +2,11 @@
// Usage:
// mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH]
// [--harness NAME] [--repo PATH] [--config PATH] [-- args...]
// mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]
// mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]
//
// `seat task` records who set the task as taskSetBy: `--by NAME`, else
// $MOSAIC_AGENT_NAME, else "unknown". This is what the caller claimed; it is
// not verified and it authorizes nothing.
//
// `launch` writes <dataRoot>/seats/<layout>/<seat>/registration.json, then replaces
// itself with the seat's launch.sh, unchanged, with everything after `--` as
@@ -15,12 +19,12 @@
import { rmSync } from "node:fs";
import {
SeatError, defaultConfigPath, loadDataRoot, seatsDir, resolveSeat, tmuxContext,
makeRegistration, writeRegistration, updateTask,
makeRegistration, writeRegistration, updateTask, resolveSetBy,
} from "./seat.mjs";
const USAGE = [
"usage: mosaic launch <seat|seat-dir> [--task TEXT] [--project NAME] [--workspace PATH] [--harness NAME] [--repo PATH] [--config PATH] [-- args...]",
" mosaic seat task <seat> <text> [--layout repo|fleet|unknown] [--config PATH]",
" mosaic seat task <seat> <text> [--by NAME] [--layout repo|fleet|unknown] [--config PATH]",
].join("\n");
export const REGISTERED_ENV = "MOSAIC_LAUNCH_REGISTERED";
@@ -49,10 +53,10 @@ function parseLaunch(argv) {
}
function parseSeatTask(argv) {
const opts = { seat: null, task: null, layout: null, config: defaultConfigPath() };
const opts = { seat: null, task: null, layout: null, by: null, config: defaultConfigPath() };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--config" || a === "--layout") {
if (a === "--config" || a === "--layout" || a === "--by") {
if (i + 1 >= argv.length) throw new SeatError(`missing value for ${a}`, 4);
opts[a.slice(2)] = argv[++i];
} else if (a.startsWith("--")) throw new SeatError(`unknown argument: ${a}\n${USAGE}`, 4);
@@ -90,9 +94,12 @@ function launch(argv) {
function seatTask(argv) {
const opts = parseSeatTask(argv);
// Attribution is resolved (and an invalid value refused) before any read
// or write, so a refusal leaves the record exactly as it was.
const setBy = resolveSetBy({ by: opts.by, env: process.env });
const seats = seatsDir(loadDataRoot(opts.config));
const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout });
process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters)\n`);
const record = updateTask(seats, opts.seat, opts.task, { layout: opts.layout, setBy });
process.stdout.write(`mosaic seat task: ${opts.seat} (${record.layout} layout) task set (${opts.task.length} characters, set by ${setBy})\n`);
return 0;
}
+38 -5
View File
@@ -23,6 +23,15 @@ import { spawnSync } from "node:child_process";
export const REGISTRATION_VERSION = 1;
export const SEAT_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export const TASK_LIMIT = 2000;
// Who set the task (#1511): caller-supplied attribution, bounded to one
// short token so it cannot carry markup or whitespace. The bound is a syntax
// limit, not a privacy filter: a numeric ID or an email-like name still fits,
// so callers choose what they put here. It is what the caller claimed, not
// an authenticated identity, and it grants nothing: the board displays it
// and does no more with it.
export const SET_BY_NAME = /^[A-Za-z0-9][A-Za-z0-9._@:-]{0,63}$/;
export const SET_BY_UNKNOWN = "unknown";
export const SET_BY_ENV = "MOSAIC_AGENT_NAME";
// exitCode follows docs/TOOLS.md: 1 operation failed, 2 invalid data or
// configuration, 4 usage.
@@ -121,6 +130,7 @@ export function tmuxContext({ env = process.env, exec = spawnSync } = {}) {
const FIELDS = Object.freeze([
"version", "seat", "project", "task", "workspace", "tmux", "harness",
"startedAt", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "updatedAt",
"taskSetBy",
]);
const isNullableString = (v) => v === null || typeof v === "string";
@@ -134,6 +144,9 @@ export function validateRegistration(record) {
if (record.version !== REGISTRATION_VERSION) throw new SeatError("registration has an unsupported version");
if (typeof record.seat !== "string" || !SEAT_NAME.test(record.seat)) throw new SeatError("registration.seat is invalid");
if (typeof record.task !== "string" || record.task.length > TASK_LIMIT) throw new SeatError("registration.task must be a string");
// Optional: records written before #1511 have no taskSetBy and still load
// (the board shows "unknown"); no version change, no migration on read.
if (record.taskSetBy !== undefined && !(typeof record.taskSetBy === "string" && SET_BY_NAME.test(record.taskSetBy))) throw new SeatError("registration.taskSetBy is invalid");
for (const key of ["project", "workspace", "harness", "sessionsDir", "seatDir", "launchScript"]) {
if (!isNullableString(record[key])) throw new SeatError(`registration.${key} must be a string or null`);
}
@@ -208,17 +221,37 @@ export function findRegistrations(seats, seat) {
return LAYOUTS.map((layout) => readRegistration(seats, seat, layout)).filter(Boolean);
}
// Change the task field only. Refuses when the seat was never launched
// through `mosaic launch`, because there is nothing to attach the task to.
// A name that exists in more than one layout must be qualified with layout.
export function updateTask(seats, seat, task, { layout = null, now = () => new Date() } = {}) {
// Who a `seat task` is attributed to: an explicit `--by NAME`, else the
// MOSAIC_AGENT_NAME variable, else "unknown". An explicit value must match
// SET_BY_NAME; so must a non-empty environment value, because a malformed
// or unexpected value there is not the same as no value and is refused
// rather than silently reported as "unknown". Neither value is echoed back.
// The result is a claim by the caller, nothing more.
export function resolveSetBy({ by = null, env = process.env } = {}) {
if (by !== null && by !== undefined) {
if (typeof by !== "string" || !SET_BY_NAME.test(by)) throw new SeatError(`--by must be 1-64 characters of letters, digits, . _ @ : or -, starting with a letter or digit`, 4);
return by;
}
const fromEnv = env[SET_BY_ENV];
if (fromEnv === undefined || fromEnv === "") return SET_BY_UNKNOWN;
if (typeof fromEnv !== "string" || !SET_BY_NAME.test(fromEnv)) throw new SeatError(`${SET_BY_ENV} is set but is not a valid name (1-64 characters of letters, digits, . _ @ : or -); pass --by NAME or unset it`, 4);
return fromEnv;
}
// Change the task field, and record who set it (taskSetBy), and updatedAt.
// Every other field, startedAt included, is carried over unchanged. Refuses
// when the seat was never launched through `mosaic launch`, because there is
// nothing to attach the task to. A name that exists in more than one layout
// must be qualified with layout.
export function updateTask(seats, seat, task, { layout = null, now = () => new Date(), setBy = SET_BY_UNKNOWN } = {}) {
if (typeof task !== "string") throw new SeatError("task must be a string", 4);
if (task.length > TASK_LIMIT) throw new SeatError(`task is longer than ${TASK_LIMIT} characters`, 4);
if (typeof setBy !== "string" || !SET_BY_NAME.test(setBy)) throw new SeatError("setBy must be a valid name", 4);
const found = layout ? [readRegistration(seats, seat, layout)].filter(Boolean) : findRegistrations(seats, seat);
if (found.length === 0) throw new SeatError(`no registration for seat ${seat}; launch it through mosaic launch first`, 1);
if (found.length > 1) throw new SeatError(`seat ${seat} is registered in more than one layout (${found.map((r) => r.layout).join(", ")}); pass --layout`, 4);
const record = found[0];
const updated = { ...record, task, updatedAt: now().toISOString() };
const updated = { ...record, task, taskSetBy: setBy, updatedAt: now().toISOString() };
writeRegistration(seats, updated);
return updated;
}
+172 -1
View File
@@ -29,7 +29,10 @@ import {
updateTask,
findRegistrations,
samePath,
resolveSetBy,
TASK_LIMIT,
SET_BY_NAME,
SET_BY_UNKNOWN,
} from "../src/seat.mjs";
const pkgRoot = resolve(import.meta.dirname, "..");
@@ -91,6 +94,9 @@ function buildConfig(root, dataRoot = join(root, "data")) {
function cliEnv(overrides = {}) {
const env = { ...process.env, TMUX: "", TMUX_PANE: "" };
delete env.MOSAIC_LAUNCH_REGISTERED;
// The developer's shell may carry a seat name (agents/rocko/launch.sh
// exports one); attribution tests set it explicitly or not at all.
delete env.MOSAIC_AGENT_NAME;
return { ...env, ...overrides };
}
@@ -290,7 +296,7 @@ test("writeRegistration/readRegistration: round trip, permissions, absence, and
// 7. updateTask
// ---------------------------------------------------------------------------
test("updateTask: changes task and updatedAt only, and refuses appropriately", () => {
test("updateTask: changes task, taskSetBy and updatedAt only, and refuses appropriately", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
@@ -300,11 +306,14 @@ test("updateTask: changes task and updatedAt only, and refuses appropriately", (
const updated = updateTask(seats, "myseat", "revised");
assert.equal(updated.task, "revised");
assert.equal(updated.taskSetBy, SET_BY_UNKNOWN, "no setBy option records the literal unknown");
assert.notEqual(updated.updatedAt, null);
for (const key of Object.keys(record)) {
if (key === "task" || key === "updatedAt") continue;
assert.deepEqual(updated[key], record[key], `field ${key} changed unexpectedly`);
}
assert.equal(updated.startedAt, record.startedAt, "startedAt is the launch time and survives a task update");
assert.deepEqual(Object.keys(updated).sort(), [...Object.keys(record), "taskSetBy"].sort(), "exactly one field is added");
// Refuses when there is no registration to attach to.
assert.throws(() => updateTask(seats, "ghost", "x"), (err) => {
@@ -560,3 +569,165 @@ test("samePath: equal paths, symlinked dirs, distinct dirs, and non-strings", ()
assert.equal(samePath(1, "x"), false);
assert.equal(samePath(null, target), false);
});
// ---------------------------------------------------------------------------
// 14. Task attribution (#1511): taskSetBy on the record, resolveSetBy, and the
// --by / MOSAIC_AGENT_NAME precedence at the CLI. Attribution is a claim by
// the caller: bounded, escaped by the presentations, never authority.
// ---------------------------------------------------------------------------
test("resolveSetBy: explicit --by wins over the environment; absent or empty environment gives unknown; invalid explicit or environment values refuse with exit 4", () => {
assert.equal(resolveSetBy({ by: "darkwing", env: { MOSAIC_AGENT_NAME: "rocko" } }), "darkwing");
assert.equal(resolveSetBy({ by: null, env: { MOSAIC_AGENT_NAME: "rocko" } }), "rocko");
assert.equal(resolveSetBy({ env: {} }), SET_BY_UNKNOWN);
assert.equal(resolveSetBy({ env: { MOSAIC_AGENT_NAME: "" } }), SET_BY_UNKNOWN);
assert.equal(resolveSetBy({ by: "jason@host:1", env: {} }), "jason@host:1");
assert.equal(resolveSetBy({ by: "a".repeat(64), env: {} }), "a".repeat(64), "64 characters is the bound");
const refused = (fn, pattern) => assert.throws(fn, (err) => {
assert.ok(err instanceof SeatError);
assert.equal(err.exitCode, 4);
assert.match(err.message, pattern);
return true;
});
for (const bad of ["", " ", "a".repeat(65), "<b>x</b>", "two words", "-leading", ".leading", "tab\tname", "new\nline", "ünïcode", "a/b", "a;b", "$(x)"]) {
refused(() => resolveSetBy({ by: bad, env: { MOSAIC_AGENT_NAME: "valid" } }), /--by/);
assert.equal(SET_BY_NAME.test(bad), false);
// The offending value is never echoed back.
if (bad.trim().length > 1) assert.throws(() => resolveSetBy({ by: bad, env: {} }), (err) => !err.message.includes(bad));
}
refused(() => resolveSetBy({ by: 42, env: {} }), /--by/);
// A set but malformed environment value is refused, not read as unknown:
// silently reporting "unknown" would hide a misconfigured seat, and --by
// is the explicit way past it.
refused(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "<script>" } }), /MOSAIC_AGENT_NAME/);
refused(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "a".repeat(65) } }), /MOSAIC_AGENT_NAME/);
assert.throws(() => resolveSetBy({ env: { MOSAIC_AGENT_NAME: "<script>" } }), (err) => !err.message.includes("<script>"));
assert.equal(resolveSetBy({ by: "explicit", env: { MOSAIC_AGENT_NAME: "<script>" } }), "explicit", "an explicit value does not consult the environment at all");
});
test("validateRegistration/readRegistration: taskSetBy is optional; a record without it (written before #1511) still loads unchanged; an invalid one is refused; the version does not change", () => {
const root = makeRoot();
const seatDir = buildRepoSeat(root, "old");
const resolved = resolveSeat("old", { repo: root });
const seats = seatsDir(join(root, "data"));
const record = makeRegistration({ resolved, task: "launched task", pid: 1 });
assert.equal("taskSetBy" in record, false, "launch does not attribute; only seat task does");
assert.equal(record.version, 1);
// An old on-disk record, byte for byte, without the field.
const path = registrationPath(seats, "old", "repo");
mkdirSync(dirname(path), { recursive: true });
const oldBytes = JSON.stringify(record, null, 2) + "\n";
writeFileSync(path, oldBytes);
const loaded = readRegistration(seats, "old", "repo");
assert.deepEqual(loaded, record);
assert.equal(loaded.taskSetBy, undefined);
assert.equal(readFileSync(path, "utf8"), oldBytes, "reading never rewrites (no implicit migration)");
assert.equal(findRegistrations(seats, "old").length, 1);
// Valid values.
for (const ok of ["unknown", "darkwing", "jason@host:1", "a".repeat(64)]) validateRegistration({ ...record, taskSetBy: ok });
// Invalid values, never echoed.
for (const bad of [null, 7, "", "<b>", "two words", "a".repeat(65), {}]) {
assert.throws(() => validateRegistration({ ...record, taskSetBy: bad }), (err) => {
assert.ok(err instanceof SeatError);
assert.match(err.message, /taskSetBy/);
if (typeof bad === "string" && bad.length > 1) assert.equal(err.message.includes(bad), false);
return true;
});
}
writeFileSync(path, JSON.stringify({ ...record, taskSetBy: "<b>" }));
assert.throws(() => readRegistration(seats, "old", "repo"), /taskSetBy/);
});
test("updateTask: records setBy, preserves startedAt and every unrelated field, upgrades an old record in place only when the task is set, and replaces a previous attribution", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const resolved = resolveSeat("myseat", { repo: root });
const seats = seatsDir(join(root, "data"));
const started = new Date("2026-09-10T10:00:00Z");
const record = makeRegistration({ resolved, task: "original", harness: "pi", tmux: { socket: "mosaic-fleet", session: "myseat" }, pid: 1, workspace: "/w", project: "proj", now: () => started });
writeRegistration(seats, record);
const first = updateTask(seats, "myseat", "by darkwing", { setBy: "darkwing", now: () => new Date("2026-09-14T10:00:00Z") });
assert.equal(first.taskSetBy, "darkwing");
assert.equal(first.startedAt, started.toISOString());
assert.equal(first.updatedAt, "2026-09-14T10:00:00.000Z");
for (const key of ["seat", "project", "workspace", "tmux", "harness", "pid", "sessionsDir", "seatDir", "launchScript", "layout", "version"]) {
assert.deepEqual(first[key], record[key], `field ${key} changed unexpectedly`);
}
assert.deepEqual(readRegistration(seats, "myseat", "repo"), first);
const second = updateTask(seats, "myseat", "by jason", { setBy: "jason", now: () => new Date("2026-09-14T11:00:00Z") });
assert.equal(second.taskSetBy, "jason", "a later set replaces the attribution; it is not appended");
assert.equal(second.startedAt, started.toISOString());
const third = updateTask(seats, "myseat", "anonymous");
assert.equal(third.taskSetBy, SET_BY_UNKNOWN, "no setBy means unknown, never the previous name");
// Invalid setBy refuses with exit 4 before anything is written.
const before = readFileSync(registrationPath(seats, "myseat", "repo"), "utf8");
assert.throws(() => updateTask(seats, "myseat", "x", { setBy: "<b>" }), (err) => err instanceof SeatError && err.exitCode === 4);
assert.throws(() => updateTask(seats, "myseat", "x", { setBy: "" }), (err) => err instanceof SeatError && err.exitCode === 4);
assert.equal(readFileSync(registrationPath(seats, "myseat", "repo"), "utf8"), before);
});
test("CLI seat task: --by beats MOSAIC_AGENT_NAME, the environment beats nothing, empty environment is unknown, invalid --by or environment refuses with exit 4 and leaves the record byte for byte", () => {
const root = makeRoot();
buildRepoSeat(root, "myseat");
const { configPath, dataRoot } = buildConfig(root);
const path = registrationPath(seatsDir(dataRoot), "myseat", "repo");
const launchResult = runCli(["launch", "myseat", "--task", "before", "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, MOSAIC_AGENT_NAME: "launcher" }) });
assert.equal(launchResult.status, 0, launchResult.stderr);
const launched = JSON.parse(readFileSync(path, "utf8"));
assert.equal("taskSetBy" in launched, false, "launch --task does not attribute, even with the variable set");
const run = (args, env) => runCli(["seat", "task", "myseat", ...args, "--config", configPath], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath, ...env }) });
const read = () => JSON.parse(readFileSync(path, "utf8"));
let r = run(["one", "--by", "darkwing"], { MOSAIC_AGENT_NAME: "rocko" });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /set by darkwing/);
assert.equal(read().taskSetBy, "darkwing");
assert.equal(read().task, "one");
assert.equal(read().startedAt, launched.startedAt);
r = run(["two"], { MOSAIC_AGENT_NAME: "rocko" });
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /set by rocko/);
assert.equal(read().taskSetBy, "rocko");
r = run(["three"], {});
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /set by unknown/);
assert.equal(read().taskSetBy, "unknown");
r = run(["four"], { MOSAIC_AGENT_NAME: "" });
assert.equal(r.status, 0, r.stderr);
assert.equal(read().taskSetBy, "unknown");
for (const key of Object.keys(launched)) {
if (key === "task" || key === "updatedAt") continue;
assert.deepEqual(read()[key], launched[key], `field ${key} changed unexpectedly`);
}
const frozen = readFileSync(path, "utf8");
r = run(["five", "--by", "<script>alert(1)</script>"], { MOSAIC_AGENT_NAME: "rocko" });
assert.equal(r.status, 4);
assert.match(r.stderr, /refused: --by/);
assert.equal(r.stderr.includes("<script>"), false, "the refusal does not echo the value");
assert.equal(readFileSync(path, "utf8"), frozen, "an invalid --by writes nothing");
r = run(["five", "--by", ""], {});
assert.equal(r.status, 4);
assert.equal(readFileSync(path, "utf8"), frozen);
r = runCli(["seat", "task", "myseat", "five", "--config", configPath, "--by"], { cwd: root, env: cliEnv({ MOSAIC_CONFIG: configPath }) });
assert.equal(r.status, 4);
assert.match(r.stderr, /missing value for --by/);
assert.equal(readFileSync(path, "utf8"), frozen);
r = run(["five"], { MOSAIC_AGENT_NAME: "not a name" });
assert.equal(r.status, 4);
assert.match(r.stderr, /MOSAIC_AGENT_NAME/);
assert.equal(r.stderr.includes("not a name"), false);
assert.equal(readFileSync(path, "utf8"), frozen, "an invalid environment value writes nothing");
r = run(["six", "--by", "explicit"], { MOSAIC_AGENT_NAME: "not a name" });
assert.equal(r.status, 0, "an explicit --by is the way past a bad environment value");
assert.equal(read().taskSetBy, "explicit");
assert.equal(read().task, "six");
});
+11 -5
View File
@@ -8,8 +8,12 @@
let board = 'the configured board', palette = 'harbor', mode = 'light';
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
// Who set a registered task (#1511): null from the scan unless the task shown is the registered one. A caller's claim, not verified; it grants nothing.
const setBy = r => r.taskSetBy ? `<span class="source">set by ${esc(r.taskSetBy)}</span>` : '';
const badge = state => `<span class="badge ${ { working: 'badge-ok', waiting: 'badge-attn', error: 'badge-danger' }[state] || 'badge-muted'}">${esc(state || 'unknown')}</span>`;
const canReply = r => r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const connectorStatus = r => r.connector ? `<span class="source">${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'} · owner ${esc(r.connector.ownerState)}</span>` : '';
const relaunchNotice = r => r.relaunchedAt ? `relaunched at ${r.relaunchedAt}, no messages since` : '';
const canReply = r => !r.connector && r.registered && r.registered.alive !== false && r.registered.tmux?.session;
const row = id => data?.sessions.find(r => key(r) === id);
const announce = text => { $('announce').textContent = text; };
function theme() {
@@ -33,11 +37,11 @@
const openButton = r => `<button type="button" class="session-open" data-open="${esc(key(r))}" aria-controls="inspector" aria-expanded="${selected === key(r)}">${esc(r.agent)}</button>`;
function cards(rows) {
if (!rows.length) return '<p class="muted">Nothing here.</p>';
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}</p><p class="preview">${esc(r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
return `<ul class="waiting">${rows.map(r => `<li class="wait-item"><div class="wait-head">${openButton(r)} ${badge(r.state)}${connectorStatus(r)}</div><p class="small muted">${esc(r.project)} · ${esc(r.activeProject || 'unknown')}</p><p>${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</p><p class="preview">${esc(relaunchNotice(r) || r.lastError || r.lastAssistantText || 'No assistant text yet.')}</p><div class="actions"><span class="small muted">${esc(relaunchNotice(r) || r.lastActivity || 'No activity')}</span>${seenButton(r)}</div></li>`).join('')}</ul>`;
}
function table(rows) {
if (!rows.length) return '<p class="state">No sessions match these filters.</p>';
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`;
return `<div class="table-wrap" tabindex="0" role="region" aria-label="Session table, scroll horizontally for all columns"><table class="sessions"><thead><tr>${['Agent', 'State', 'Task', 'Active project', 'Workspace', 'Model', 'Registered', 'Last activity'].map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(r => `<tr data-row="${esc(key(r))}"><td>${openButton(r)}<span class="source">${esc(r.project)}</span></td><td>${badge(r.state)}${connectorStatus(r)}${r.seen ? '<span class="source">seen</span>' : ''}</td><td class="task">${esc(r.task || 'unknown')}${source(r.taskSource)}${setBy(r)}</td><td>${esc(r.activeProject || 'unknown')}${source(r.activeProjectSource)}</td><td>${esc(r.workspace || 'unknown')}${source(r.workspaceSource)}</td><td>${esc(r.model || 'unknown')}<span class="source">${esc(r.provider)}</span></td><td>${r.registered ? r.registered.alive === false ? 'stale' : 'registered' : 'no'}</td><td>${esc(relaunchNotice(r) || r.lastActivity || 'unknown')}</td></tr>`).join('')}</tbody></table></div>`;
}
function registered(r) {
const reg = r.registered;
@@ -51,9 +55,11 @@
if (!selected) return;
if (!r) { $('inspection').innerHTML = '<p>This session is no longer in the board scan. Its draft is kept until this page closes.</p>'; return; }
$('inspector-title').textContent = r.agent;
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], ['Last activity', r.lastActivity || 'unknown'] ];
const fields = [ ['State', r.state], ['Project', r.project], ['Task', r.task || 'unknown'], ['Task source', labels[r.taskSource] || r.taskSource || 'unknown'], ['Task set by', r.taskSetBy ? `${r.taskSetBy} (as claimed by the caller, not verified)` : 'not applicable (task is not from a registration)'], ['Active project', r.activeProject || 'unknown'], ['Workspace', r.workspace || 'unknown'], ['Model', [r.provider, r.model].filter(Boolean).join('/') || 'unknown'], ['Registered', registered(r)], [r.relaunchedAt ? 'Historical last activity' : 'Last activity', r.lastActivity || 'unknown'] ];
if (r.relaunchedAt) fields.unshift(['Current activity', relaunchNotice(r)]);
if (r.connector) fields.push(['Connector', `${r.connector.braked === true ? 'braked (STOP)' : r.connector.braked === false ? 'not braked' : 'brake unknown'}; owner ${r.connector.ownerState}`]);
const receipt = receipts.get(selected);
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>Last assistant text</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>Last error</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : '<p class="muted">reply needs a registered seat</p>'}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
$('inspection').innerHTML = `<dl class="kv">${fields.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('')}<dt>${r.relaunchedAt ? 'Historical last assistant text' : 'Last assistant text'}</dt><dd><pre class="last-text">${esc(r.lastAssistantText || 'No assistant text yet.')}</pre></dd>${r.lastError ? `<dt>${r.relaunchedAt ? 'Historical last error' : 'Last error'}</dt><dd>${esc(r.lastError)}</dd>` : ''}</dl>${seenButton(r)}${canReply(r) ? `<form class="reply-form"><label for="reply">Reply to ${esc(r.agent)}</label><textarea id="reply" maxlength="2000" rows="3" placeholder="Message this seat">${esc(drafts.get(selected) || '')}</textarea><button class="btn primary" type="submit" ${sending.has(selected) ? 'disabled' : ''}>${sending.has(selected) ? 'Sending…' : 'Send'}</button></form>` : `<p class="muted">${r.connector ? 'Board replies disabled for Discord connectors' : 'reply needs a registered seat'}</p>`}${receipt ? `<p class="receipt ${receipt.delivered ? '' : 'failed'}" role="status">${esc(receipt.delivered ? `delivered ${receipt.sentAt} to tmux ${receipt.session}` : `failed${receipt.exitCode == null ? '' : ' (exit ' + receipt.exitCode + ')'}: ${receipt.stderr || receipt.error || 'no output'}`)}</p>` : ''}`;
}
// Restore by data attribute equality, never interpolate API ids into selectors.
function focusSnapshot() {
+28 -1
View File
@@ -29,12 +29,16 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
assert.equal(await b.evaluate('document.querySelector("#waiting-count").textContent'), '0');
assert.equal(await b.evaluate('document.documentElement.dataset.palette'), 'harbor');
const agent = 'agent"[\\<script>', project = '__proto__';
const rec = { agent, project, state: 'waiting', waitingOnYou: true, task: 'Long '.repeat(300), workspace: '/' + 'path/'.repeat(100), taskSource: 'registration', lastActivity: '2026-09-01', lastAssistantText: '<img src=x onerror=alert(1)>', registered: { alive: true, tmux: { session: agent } } };
const rec = { agent, project, state: 'waiting', waitingOnYou: true, task: 'Long '.repeat(300), workspace: '/' + 'path/'.repeat(100), taskSource: 'registration', taskSetBy: '<img src=x onerror=alert(2)>', lastActivity: '2026-09-01', lastAssistantText: '<img src=x onerror=alert(1)>', registered: { alive: true, tmux: { session: agent } } };
index = { sessions: [rec], counts: { waiting: 1 } };
await b.evaluate('document.querySelector("#refresh").click()'); await wait('document.querySelectorAll("table.sessions [data-open]").length===1');
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
assert.equal(await b.evaluate('document.querySelector("#inspector-title").textContent'), agent);
assert.equal(await b.evaluate('document.querySelectorAll("#inspection img").length'), 0);
// #1511: a hostile setter value is text, in the table and the inspector.
assert.equal(await b.evaluate('document.querySelectorAll("img").length'), 0);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*<img src=x onerror=alert\(2\)> \(as claimed/);
assert.match(await b.evaluate('document.querySelector("table.sessions td.task").textContent'), /set by <img src=x onerror=alert\(2\)>/);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
await b.evaluate('document.querySelector("#reply").focus()'); await b.call('Input.insertText', { text: 'original draft' });
await b.evaluate('document.querySelector(".reply-form").requestSubmit();document.querySelector(".reply-form").requestSubmit()');
@@ -50,6 +54,29 @@ test('browser edge states: loading, empty, malformed, stale, hostile/long values
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /reply needs a registered seat/);
// #1511: attribution never enables a reply: a setter with no registration at all still gets no form.
const savedRegistration = index.sessions[0].registered;
index.sessions[0].registered = null; index.sessions[0].taskSetBy = 'someone';
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*someone/);
// R1-U1: null means not applicable (task not selected from a registration); it is never shown as "unknown",
// and a previous setter never lingers after the transition.
index.sessions[0].registered = savedRegistration; index.sessions[0].taskSetBy = null;
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("someone")'), false);
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
assert.equal(await b.evaluate('document.querySelector("table.sessions td.task").textContent.includes("set by")'), false);
// A selected legacy registration (record predates taskSetBy) reads "unknown", distinct from not applicable.
index.sessions[0].taskSource = 'registration'; index.sessions[0].taskSetBy = 'unknown';
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown \(as claimed by the caller, not verified\)/);
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("not applicable")'), false);
assert.match(await b.evaluate('document.querySelector("table.sessions td.task").textContent'), /set by unknown/);
index.sessions[0].taskSource = 'first-user-message'; index.sessions[0].taskSetBy = null;
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled');
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("set by unknown")'), false);
index.sessions[0].registered.alive = true;
await b.evaluate('document.querySelector("#refresh").click()'); await wait('!!document.querySelector("#reply")');
assert.match(await b.evaluate('document.querySelector("#reply").value'), /newer/);
+13
View File
@@ -34,6 +34,19 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
assert.equal(await b.evaluate('document.activeElement.id'), 'inspector-title');
assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), false);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Registered fixture task/);
// #1511: attribution shows for the registered task only, as a claim, and only where the task is the registered one.
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*fixture-setter \(as claimed by the caller, not verified\)/);
assert.match(await b.evaluate('document.querySelector("table.sessions tr[data-row=\\"proj/agent1\\"] td.task").textContent'), /set by fixture-setter/);
assert.equal(await b.evaluate('[...document.querySelectorAll("table.sessions td.task")].filter(td=>td.textContent.includes("set by")).length'), 1, 'transcript-derived rows carry no attribution');
assert.match(await b.evaluate('document.querySelector("#waiting .wait-item p:nth-of-type(2)").textContent'), /set by fixture-setter|Build the fixture task/);
// R1-U1: a transcript-derived task is "not applicable", never "unknown", and never borrows the registered setter.
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open!=="proj/agent1").focus()'); await b.key('Enter');
await wait('!document.querySelector("#inspection").textContent.includes("Registered fixture task")');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
assert.equal(await b.evaluate('document.querySelector("#inspection").textContent.includes("fixture-setter")'), false);
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
await b.evaluate('[...document.querySelectorAll("table.sessions [data-open]")].find(e=>e.dataset.open==="proj/agent1").focus()'); await b.key('Enter');
await wait('document.querySelector("#inspection").textContent.includes("Registered fixture task")');
assert.equal(await b.evaluate('!!window.injected'), false);
await b.evaluate('document.querySelector("#reply").focus()');
await b.call('Input.insertText', { text: 'keep this draft' });
+62
View File
@@ -0,0 +1,62 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { identityOf } from '../../discord/src/journal.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
test('Discord row through real board/WebUI: independent brake/liveness, no Reply, literal content', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-discord-'));
let board, web, b;
try {
const dir = join(root, 'discord', 'pilot'); mkdirSync(join(dir, 'run.lock'), { recursive: true });
writeFileSync(join(root, 'discord', 'pilot.json'), JSON.stringify({ name: 'pilot', seat: 'sage', tokenFile: '/PRIVATE-NOT-READ', guildId: 'PRIVATE-ID' }), { mode: 0o600 });
writeFileSync(join(dir, 'run.lock', 'owner.json'), JSON.stringify({ pid: process.pid, ...identityOf(process.pid) }));
writeFileSync(join(dir, 'STOP'), 'PRIVATE STOP');
const sessions = join(root, 'sessions', 'discord-pilot'); 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: '<script>window.injected=true</script> completed' }] } }) + '\n');
// #1511: a native registration naming the connector's sessions directory, with a setter, must lend it nothing.
const seatsDir = join(root, 'seats');
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'sage', project: 'fleet', sessionsDir: sessions, seatDir: dir, launchScript: join(root, 'unused.sh'), layout: 'fleet', defaultWorkspace: null }, task: 'forged native task', tmux: { session: 'sage', socket: null }, pid: process.pid }), taskSetBy: 'forged-setter' });
board = await startBoard({ port: 0, specs: [], seatsDir, boardDir: join(root, 'board'), discordDataRoot: root, exec: () => { throw Error('no transport'); } });
web = await startServer({ port: 0, board: `http://127.0.0.1:${board.address().port}` });
const base = `http://127.0.0.1:${web.address().port}`;
const payload = await (await fetch(base + '/api/board')).json();
assert.equal(payload.sessions[0].connector.braked, true); assert.equal(payload.sessions[0].state, 'idle');
assert.equal(JSON.stringify(payload).includes('PRIVATE'), false);
assert.equal(payload.sessions[0].task, 'Discord connector'); assert.equal(payload.sessions[0].taskSource, 'connector'); assert.equal(payload.sessions[0].taskSetBy, null);
assert.equal(JSON.stringify(payload).includes('forged'), false);
const reply = await fetch(base + '/api/reply', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ agent: 'fleet/sage (discord: pilot)', text: 'refuse me' }) });
assert.equal(reply.status, 409);
b = await browser(); await b.viewport(320, 1000);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
await b.navigate(`http://127.0.0.1:${board.address().port}`);
await wait('document.querySelector(".session-row")');
assert.match(await b.evaluate('document.body.textContent'), /braked \(STOP\)/);
assert.match(await b.evaluate('document.body.textContent'), /Board replies disabled for Discord connectors/);
assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false);
assert.equal(await b.evaluate('!!window.injected'), false);
await b.navigate(base);
await wait('document.querySelector("table.sessions [data-open]")');
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /braked \(STOP\); owner live/);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Board replies disabled for Discord connectors/);
assert.equal(await b.evaluate('!!document.querySelector(".reply-form")'), false);
assert.equal(await b.evaluate('!!window.injected'), false);
// R1-U1: the connector's fixed task is "not applicable", never "unknown", and never the forged native setter.
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*not applicable \(task is not from a registration\)/);
assert.doesNotMatch(await b.evaluate('document.querySelector("#inspection").textContent'), /Task set by\s*unknown/);
assert.equal(await b.evaluate('document.body.textContent.includes("forged")'), false);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
rmSync(join(dir, 'STOP')); rmSync(join(dir, 'run.lock', 'owner.json'));
await b.evaluate('document.querySelector("#hide-offline").click();document.querySelector("#refresh").click()');
await wait('document.querySelector("#inspection").textContent.includes("owner absent")');
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /not braked; owner absent/);
assert.match(await b.evaluate('document.querySelector("#inspection").textContent'), /offline/);
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
});
+7 -3
View File
@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { makeRegistration, writeRegistration, updateTask } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
export const close = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
export async function fixture() {
@@ -14,7 +14,7 @@ export async function fixture() {
const captures = [];
for (const [agent, project, state] of [['agent1', 'proj', 'waiting'], ['agent2', 'proj', 'working'], ['agent3', 'other', 'error'], ['agent4', 'other', 'offline']]) {
const sessionsDir = join(root, agent); mkdirSync(sessionsDir);
const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: 'done? <script>window.injected=true</script>' }] };
const assistant = { role: 'assistant', model: 'fixture-model', provider: 'fixture-provider', stopReason: state === 'error' ? 'error' : 'stop', content: [{ type: 'text', text: (state === 'waiting' ? 'Input needed: ' : '') + 'done? <script>window.injected=true</script>' }] };
const lines = [
{ type: 'session', id: agent, timestamp: '2026-09-01T00:00:00Z', cwd: '/fixture/workspace' },
{ type: 'message', timestamp: '2026-09-01T00:00:01Z', message: { role: 'user', content: [{ type: 'text', text: 'Build the fixture task' }] } },
@@ -23,7 +23,11 @@ export async function fixture() {
if (state === 'working') lines.push({ type: 'message', timestamp: '2026-09-01T00:00:03Z', message: { role: 'user', content: [{ type: 'text', text: 'continue' }] } });
writeFileSync(join(sessionsDir, 's.jsonl'), lines.map(l => JSON.stringify(l)).join('\n') + '\n');
specs.push({ agent, project, sessionsDir, tmux: { session: agent } });
if (agent === 'agent1') writeRegistration(seatsDir, makeRegistration({ resolved: { seat: agent, project, sessionsDir, seatDir: sessionsDir, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: '/fixture/workspace' }, task: 'Registered fixture task', tmux: { session: agent, socket: null }, pid: process.pid }));
if (agent === 'agent1') {
writeRegistration(seatsDir, makeRegistration({ resolved: { seat: agent, project, sessionsDir, seatDir: sessionsDir, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: '/fixture/workspace' }, task: 'Registered fixture task', tmux: { session: agent, socket: null }, pid: process.pid }));
// #1511: the same path `mosaic seat task --by` takes, on the fixture record only.
updateTask(seatsDir, agent, 'Registered fixture task', { layout: 'repo', setBy: 'fixture-setter' });
}
}
const board = await startBoard({ port: 0, specs, seatsDir, boardDir: join(root, 'board'), isAlive: tmux => tmux.session !== 'agent4', isPidAlive: () => true,
exec: (file, args) => { captures.push({ file, args }); return { status: exitCode, stdout: 'fixture transport', stderr: exitCode ? 'fixture refusal <unsafe>' : '' }; },
+61
View File
@@ -0,0 +1,61 @@
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 { startServer as startBoard } from '../../control-board/src/serve.mjs';
import { makeRegistration, writeRegistration } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const oldAt = '2026-09-14T10:00:00.000Z', launchAt = '2026-09-15T10:00:00.000Z', newAt = '2026-09-15T10:01:00.000Z';
const notice = `relaunched at ${launchAt}, no messages since`;
test('both presentations replace old activity with relaunch notice, label retained history, then resume after new activity', { timeout: 60000 }, async () => {
const root = mkdtempSync(join(tmpdir(), 'webui-relaunch-'));
let board, web, b;
try {
const sessionsDir = join(root, 'sessions'); mkdirSync(sessionsDir);
const file = join(sessionsDir, 's.jsonl');
const line = (at, text) => JSON.stringify({ type: 'message', timestamp: at, message: { role: 'assistant', stopReason: 'stop', content: [{ type: 'text', text }] } }) + '\n';
const history = line(oldAt, 'Input needed: OLD_FIXTURE_REPLY <img src=x onerror=alert(1)>');
writeFileSync(file, history);
const seatsDir = join(root, 'seats');
writeRegistration(seatsDir, { ...makeRegistration({ resolved: { seat: 'fixture', project: 'repo', sessionsDir, seatDir: root, launchScript: join(root, 'unused.sh'), layout: 'repo', defaultWorkspace: root }, task: 'Fixed task', tmux: { session: 'fixture', socket: null }, pid: process.pid, now: () => new Date(launchAt) }), taskSetBy: 'fixture-setter' });
board = await startBoard({ port: 0, specs: [{ agent: 'fixture', project: 'repo', sessionsDir, tmux: {} }], boardDir: join(root, 'board'), seatsDir, isAlive: () => true, isPidAlive: () => true, exec: () => { throw Error('no transport expected'); } });
const boardURL = `http://127.0.0.1:${board.address().port}`;
web = await startServer({ port: 0, board: boardURL });
const webURL = `http://127.0.0.1:${web.address().port}`;
const row = (await (await fetch(webURL + '/api/board')).json()).sessions[0];
assert.equal(row.relaunchedAt, launchAt); assert.equal(row.lastActivity, oldAt);
assert.match(row.lastAssistantText, /OLD_FIXTURE_REPLY/); assert.equal(row.state, 'waiting'); assert.equal(row.taskSetBy, 'fixture-setter');
b = await browser(); await b.viewport(320, 1000);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,50))}throw Error('timeout')})()`);
await b.navigate(boardURL); await wait('document.querySelector(".session-row")');
const legacyRow = await b.evaluate('document.querySelector(".session-row").textContent');
assert.ok(legacyRow.includes(notice)); assert.equal(legacyRow.includes('OLD_FIXTURE_REPLY'), false);
await b.evaluate('document.querySelector(".row-toggle").click()');
assert.match(await b.evaluate('document.querySelector(".detail-row").textContent'), /Historical last message.*OLD_FIXTURE_REPLY/s);
assert.equal(await b.evaluate('!!document.querySelector("img")'), false);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
await b.navigate(webURL); await wait('document.querySelector("table.sessions [data-open]")');
assert.ok((await b.evaluate('document.querySelector("table.sessions").textContent')).includes(notice));
const card = await b.evaluate('document.querySelector("#waiting").textContent');
assert.ok(card.includes(notice)); assert.equal(card.includes('OLD_FIXTURE_REPLY'), false);
await b.evaluate('document.querySelector("table.sessions [data-open]").click()');
const inspector = await b.evaluate('document.querySelector("#inspection").textContent');
assert.ok(inspector.includes(notice)); assert.match(inspector, /Historical last assistant text.*OLD_FIXTURE_REPLY/s);
assert.match(inspector, /Historical last activity/); assert.match(inspector, /fixture-setter/);
assert.equal(await b.evaluate('!!document.querySelector("#reply")'), true, 'reply eligibility unchanged');
assert.equal(await b.evaluate('!!document.querySelector("img")'), false);
assert.equal(await b.evaluate('document.documentElement.scrollWidth<=innerWidth'), true);
appendFileSync(file, line(newAt, 'NEW_FIXTURE_REPLY'));
await b.evaluate('document.querySelector("#refresh").click()');
await wait('document.querySelector("#inspection").textContent.includes("NEW_FIXTURE_REPLY")');
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('relaunched at'), false);
assert.equal((await b.evaluate('document.querySelector("#inspection").textContent')).includes('Historical last'), false);
await b.navigate(boardURL); await wait('document.querySelector(".session-row")?.textContent.includes("NEW_FIXTURE_REPLY")');
assert.equal((await b.evaluate('document.querySelector(".session-row").textContent')).includes('relaunched at'), false);
} finally { if (b) await b.close(); if (web) await close(web); if (board) await close(board); rmSync(root, { recursive: true, force: true }); }
});