Show task, active project and workspace per control board row (#1503)

Gate A fix asked by the professor session on Jason's behalf. Each row
now carries three derived fields, shown as "unknown" when the log and
tmux do not hold them:

- task: the session's first user message (pi logs have no task envelope)
- workspace: the live pane path of the pane running pi, else session cwd
- activeProject: basename of the nearest git checkout above the workspace

tmuxInspect replaces the bare liveness call in the CLI and returns
{ alive, workspace }; tmuxIsAlive stays as a wrapper. The grouping column
and seen.json keys are unchanged. Fixture test per field, tmux parse
tests, page test; missing launcher signals are recorded in the plan page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 09:52:45 -05:00
co-authored by Claude Fable 5.1
parent 6ec253de20
commit 4a7e16c3ec
9 changed files with 321 additions and 26 deletions
+2 -2
View File
@@ -5,7 +5,7 @@
// Exit 0 on success, 2 on a config refusal or bad usage.
import { join } from "node:path";
import { homedir } from "node:os";
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxIsAlive, ConfigError } from "./scan.mjs";
import { loadConfig, defaultConfigPath, discoverRepoAgents, discoverFleetAgents, scan, tmuxInspect, ConfigError } from "./scan.mjs";
import { startServer } from "./serve.mjs";
const USAGE = "usage: mosaic-board scan|serve [--config PATH] [--repo PATH] [--fleet PATH|none] [--liveness tmux|assume-alive] [--print] [--port N] [--host 127.0.0.1]";
@@ -38,7 +38,7 @@ async function main() {
const opts = parseArgs(process.argv.slice(2));
const { dataRoot } = loadConfig(opts.config);
const specs = [...discoverRepoAgents(opts.repo), ...(opts.fleet === "none" ? [] : discoverFleetAgents(opts.fleet))];
const isAlive = opts.liveness === "tmux" ? tmuxIsAlive : () => true;
const isAlive = opts.liveness === "tmux" ? tmuxInspect : () => true;
const boardDir = join(dataRoot, "board");
if (opts.command === "serve") {
const server = await startServer({ host: opts.host, port: opts.port, specs, boardDir, isAlive });
+20 -6
View File
@@ -63,6 +63,8 @@
.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}
.task-text{display:block;max-width:28ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.unknown{color:var(--muted);font-style:italic}
.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)}
@@ -191,15 +193,24 @@
? '<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>" : "";
// Gate A fields: derived from the log and tmux, or "unknown". Never guessed.
var task = rec.task
? '<span class="task-text" title="' + esc(rec.task) + '">' + esc(rec.task) + "</span>"
: '<span class="unknown">unknown</span>';
var activeProject = rec.activeProject
? '<span title="' + esc(rec.workspace || "") + '">' + esc(rec.activeProject) + "</span>"
: '<span class="unknown" title="' + esc(rec.workspace || "") + '">unknown</span>';
var main =
'<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></td>" +
"<td>" + badge(rec.state) + seenControl(rec) + "</td>" +
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
"<td>" + task + "</td>" +
"<td>" + activeProject + "</td>" +
"<td>" + msg + "</td>" +
"</tr>";
var span = showProject ? 5 : 4;
var span = showProject ? 7 : 6;
var tmux = rec.tmux && rec.tmux.session
? esc(rec.tmux.session) + (rec.tmux.socket ? " (socket " + esc(rec.tmux.socket) + ")" : "")
: "—";
@@ -208,7 +219,10 @@
'<dl class="detail-list">' +
"<dt>Session ID</dt><dd>" + esc(rec.sessionId || "—") + "</dd>" +
"<dt>Session file</dt><dd>" + esc(rec.sessionFile || "—") + "</dd>" +
"<dt>Working directory</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + (rec.workspaceSource ? " (from " + esc(rec.workspaceSource) + ")" : "") + "</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>" +
"<dt>Scanned at</dt><dd>" + esc(rec.scannedAt || "—") + "</dd>" +
@@ -236,7 +250,7 @@
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
seenBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Last message</th>" +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
@@ -256,7 +270,7 @@
var rows = list.map(function (r) { return buildRowPair(r, true); }).join("");
waitingBody.innerHTML =
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Last message</th>" +
"<th scope=\"col\">Project</th><th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + rows + "</tbody></table></div>";
}
@@ -304,8 +318,8 @@
'<label><input type="checkbox" class="hide-seen-toggle" data-project="' + esc(project) + '" ' + (hideSeen ? "checked" : "") + "> Hide seen</label>" +
"</div></div>" +
'<div class="table-wrap"><table><thead><tr>' +
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + (rows || '<tr><td colspan="4" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
"<th scope=\"col\">Agent</th><th scope=\"col\">State</th><th scope=\"col\">Age</th><th scope=\"col\">Task</th><th scope=\"col\">Active project</th><th scope=\"col\">Last message</th>" +
"</tr></thead><tbody>" + (rows || '<tr><td colspan="6" class="empty">No agents.</td></tr>') + "</tbody></table></div>" +
note + "</div>"
);
}).join("");
+81 -16
View File
@@ -69,6 +69,7 @@ function collapse(text) {
export function readSession(file) {
const lines = readFileSync(file, "utf8").split("\n");
let sessionId = null, cwd = null, lastTimestamp = null, lastMessage = null, lastAssistantText = null, lastError = null;
let firstUserText = null;
let skippedLines = 0;
for (const line of lines) {
if (!line.trim()) continue;
@@ -85,6 +86,10 @@ export function readSession(file) {
cwd = entry.cwd ?? cwd;
} else if (entry.type === "message" && entry.message) {
lastMessage = entry.message;
if (entry.message.role === "user" && firstUserText === null) {
const text = userText(entry.message.content);
if (text.trim()) firstUserText = collapse(text);
}
if (entry.message.role === "assistant" && Array.isArray(entry.message.content)) {
const text = entry.message.content.filter((c) => c && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
if (text.trim()) lastAssistantText = collapse(text);
@@ -92,7 +97,27 @@ export function readSession(file) {
}
}
}
return { file, sessionId, cwd, lastTimestamp, lastMessage, lastAssistantText, lastError, skippedLines };
return { file, sessionId, cwd, lastTimestamp, lastMessage, lastAssistantText, lastError, firstUserText, skippedLines };
}
// A user message's text: pi writes either a plain string or a list of blocks.
function userText(content) {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content.filter((c) => c && c.type === "text" && typeof c.text === "string").map((c) => c.text).join("\n");
}
// Nearest ancestor (including dir itself) that holds a .git entry. A .git
// file counts too, because git worktrees use one. Null when there is none.
export function findRepoRoot(dir) {
if (typeof dir !== "string" || !isAbsolute(dir)) return null;
let cur = resolve(dir);
for (;;) {
if (existsSync(join(cur, ".git"))) return cur;
const parent = resolve(cur, "..");
if (parent === cur) return null;
cur = parent;
}
}
// True when an assistant message carries a tool call in its content.
@@ -126,23 +151,40 @@ export function deriveState({ alive, session }) {
export const PI_COMMANDS = Object.freeze(["pi"]);
export function panesRunPi(listPanesOutput) {
return String(listPanesOutput)
.split("\n")
.map((l) => l.trim())
.some((cmd) => PI_COMMANDS.includes(cmd));
return parsePanes(listPanesOutput).some((p) => PI_COMMANDS.includes(p.command));
}
// true: a pane in the tmux session runs pi. false: no such session, or no pane
// runs pi. null: tmux could not be run at all (reported as "unknown", never
// assumed alive). `exec` is injectable for tests.
export function tmuxIsAlive({ socket, session }, { exec = spawnSync } = {}) {
// One line per pane: "<command>\t<path>" (the path column is optional).
export function parsePanes(listPanesOutput) {
return String(listPanesOutput)
.split("\n")
.filter((l) => l.trim())
.map((l) => {
const [command = "", path = ""] = l.split("\t");
return { command: command.trim(), path: path.trim() || null };
});
}
// Ask tmux about one session. Returns { alive, workspace }:
// alive true when a pane runs pi; false when no such session or no pane
// runs pi; null when tmux could not be run at all (reported as
// "unknown", never assumed alive).
// workspace the current path of the first pane running pi, else null.
// `exec` is injectable for tests.
export function tmuxInspect({ socket, session }, { exec = spawnSync } = {}) {
const args = [];
if (socket) args.push("-L", socket);
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}");
args.push("list-panes", "-s", "-t", `=${session}`, "-F", "#{pane_current_command}\t#{pane_current_path}");
const r = exec("tmux", args, { encoding: "utf8", timeout: 5000 });
if (r.error) return null;
if (r.status !== 0) return false;
return panesRunPi(r.stdout ?? "");
if (r.error) return { alive: null, workspace: null };
if (r.status !== 0) return { alive: false, workspace: null };
const pane = parsePanes(r.stdout ?? "").find((p) => PI_COMMANDS.includes(p.command));
return { alive: Boolean(pane), workspace: pane?.path ?? null };
}
// Liveness only, for callers that do not need the pane path.
export function tmuxIsAlive(tmux, opts) {
return tmuxInspect(tmux, opts).alive;
}
// "Seen" marks: { "<project>/<agent>": "<lastActivity ISO>" }. A mark only
@@ -194,9 +236,24 @@ export function markSeen(boardDir, { project, agent, lastActivity, seen = true }
return marks;
}
// `isAlive` may return a bare liveness value (true/false/null) or the richer
// { alive, workspace } shape from tmuxInspect. Both are accepted.
function liveness(result) {
if (result && typeof result === "object") return { alive: result.alive ?? null, workspace: result.workspace ?? null };
return { alive: result ?? null, workspace: null };
}
// One agent -> one status record.
export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date(), seen = {} } = {}) {
const alive = isAlive(spec.tmux);
//
// Three fields answer "what is this seat doing, and where" (Gate A ask,
// 2026-09-12). Each is derived, never guessed; null means "unknown".
// task the session's first user message. The log has no task
// envelope entry, so this is the only assignment signal it holds.
// workspace the live pane path from tmux, else the session log's cwd.
// activeProject basename of the nearest git repo root above the workspace.
export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(), seen = {} } = {}) {
const live = liveness(isAlive(spec.tmux));
const alive = live.alive;
const file = findNewestSession(spec.sessionsDir);
const session = file ? readSession(file) : null;
const state = deriveState({ alive, session });
@@ -205,6 +262,9 @@ export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date(),
const ageSeconds = lastActivity ? Math.max(0, Math.round((scannedAt.getTime() - Date.parse(lastActivity)) / 1000)) : null;
const needsYou = state === "waiting" || state === "error";
const isSeen = needsYou && lastActivity !== null && seen[seenKey(spec)] === lastActivity;
const cwd = session?.cwd ?? null;
const workspace = live.workspace ?? cwd;
const repoRoot = workspace ? findRepoRoot(workspace) : null;
return {
agent: spec.agent,
project: spec.project,
@@ -215,7 +275,12 @@ export function scanAgent(spec, { isAlive = tmuxIsAlive, now = () => new Date(),
tmux: spec.tmux,
sessionFile: file,
sessionId: session?.sessionId ?? null,
cwd: session?.cwd ?? null,
cwd,
task: session?.firstUserText ?? null,
taskSource: session?.firstUserText ? "first-user-message" : null,
workspace,
workspaceSource: live.workspace ? "tmux-pane" : cwd ? "session-cwd" : null,
activeProject: repoRoot ? basename(repoRoot) : null,
lastActivity,
ageSeconds,
lastAssistantText: session?.lastAssistantText ?? null,