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:
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user