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
+23
View File
@@ -2040,3 +2040,26 @@ packages/mosaic 69/69. Live check at 14:27Z on the real board: orch-01
(assistant + toolCall) working, rev-code-01 (tool result last) working,
velma (text-only, stop) waiting. Sonnet review: APPROVED, no findings.
No seat changed state because of this change; it closes a gap, not a bug.
## 2026-09-12 — Control board step 3, Gate A fix: task, project, workspace per row (#1503)
Before: a row said who and what state, but not what the seat was asked to
do or where it was working. Jason asked (through the professor session)
for task, project and workspace per row, with "unknown" instead of a guess.
After: readSession records the first user message (task); tmuxInspect
reads `#{pane_current_command}\t#{pane_current_path}` and returns
{ alive, workspace }; findRepoRoot walks up from the workspace to the
nearest `.git` directory or worktree file (activeProject). The grouping
column and seen.json keys are unchanged. Page: Task and Active project
columns in all three tables, Task, Active project and Workspace in the
detail row, every value through esc(), nulls shown as "unknown". Tests:
six fixture tests (task from blocks, task from a string, no task, workspace
tmux/cwd/none, activeProject dir/file/none, record on disk) plus parsePanes
and two tmuxInspect tests and one page test. Suites: control-board 79/79,
packages/mosaic 69/69. Live check at 14:51Z on a scratch server (port
7332): 42 rows, all three fields derived on every row, 16 workspaces from
a live pane, the rest from the log; every fleet seat reads `.mosaic` as
its active project because `~/.mosaic` is a git checkout and the roster
names no project. Missing launcher signals are listed in the plan page
Step 3 log. Sonnet review: APPROVED, no findings.
+1
View File
@@ -219,3 +219,4 @@ are never rewritten or removed; corrections are new entries.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, third refinement (#1503): Jason suggested a per-project "Hide seen" checkbox beside "Hide offline"; added, on by default, with a combined hidden-count note. Page-only change plus one static test (65/65); sonnet review APPROVED (one test-rigor FYI fixed); live check in Chrome.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, fourth refinement (#1503): Jason asked for "N of N" in the project header; now "shown of total" while a checkbox hides rows, plain total otherwise. Page-only change plus one static test (66/66); sonnet review APPROVED (no findings); live check in Chrome at all four edges.
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, mid-tool-call rule (#1503), asked by the professor session on Jason's behalf: scanner already honoured the rule via stopReason; added a content-level toolCall check, 3 fixture tests + 3 table cases (69/69); live check on orch-01/rev-code-01 (working) and velma (waiting); sonnet review APPROVED (no findings; thinking-block nuance already recorded).
- 2026-09-12 — jarvis (Claude Code, coordinator) — Control board step 3, Gate A fix (#1503), asked by the professor session on Jason's behalf: per-row Task (first user message), Active project (nearest git checkout above the workspace) and Workspace (live tmux pane path, else session cwd), "unknown" when not derivable; missing launcher signals logged on the plan page. Fixture test per field (79/79); live check on a scratch server; sonnet review APPROVED (no findings).
@@ -50,6 +50,25 @@ Acceptance:
### Step 3 log
**2026-09-12 — Gate A fix: task, active project and workspace per row.**
Asked by the professor session on Jason's behalf. Each row now shows
Task (the session's first user message), Active project (basename of the
nearest git checkout above the workspace) and, in the detail row,
Workspace (the live `#{pane_current_path}` of the pane running `pi`, else
the session log's `cwd`). Anything that cannot be derived shows the word
"unknown". Missing signals, for launchers: (1) pi session logs carry no
task envelope entry, so Task is the free-text first user message, which
for fleet seats is a fleet-comms envelope and for hand-started seats is
whatever was typed ("resume", "who are you?"); a launcher that wants a
clean task label should emit one as the first line of the first message
or write a per-seat status file the scanner can read. (2) Neither
`roster.yaml` nor a seat's `profile.json` names a project, and every fleet
seat runs in `~/.mosaic`, which is itself a git checkout, so Active
project reads `.mosaic` for all of them; a `project` field in the roster
would fix that. (3) Workspace is only live while a `pi` pane exists; for
offline seats it is history from the log. Fixture test per field, a
tmux-inspect parse test, and a page test (79/79). Sonnet review: APPROVED, no findings.
**2026-09-12 — liveness follows the tmux pane, not just the session.**
Jason found agents marked "waiting" that were actually dead: their tmux
session still existed but no longer ran `pi` (it had exited to a shell or
+22
View File
@@ -95,6 +95,23 @@ only when Jason clicks "Seen" or "Unsee" on the page (via `POST
the agent's newest message still has that exact `lastActivity` timestamp —
as soon as the agent writes anything new, `lastActivity` changes, the mark
no longer matches, and the row falls back into "Waiting on you" on its own.
Every row also shows three "what and where" fields, each derived from the
session log and tmux or shown as the word "unknown", never guessed:
- **Task** — the session's first user message (collapsed to one line, 240
characters). pi logs carry no task envelope, so this is the only
assignment signal available. For fleet seats it is usually the
fleet-comms envelope the seat was started with.
- **Active project** — the basename of the nearest git checkout (a `.git`
directory or worktree file) above the workspace. Fleet seats that run in
`~/.mosaic` therefore show `.mosaic`. The grouping column "Project" is
unchanged: it still comes from where the seat's logs live, and `seen.json`
keys depend on it.
- **Workspace** (detail row, and the hover title of Active project) — the
current path of the tmux pane running `pi`; when the seat is offline or
tmux could not be asked, the `cwd` from the session log. The record says
which one it used (`workspaceSource`: `tmux-pane` or `session-cwd`).
Marked rows are listed under a collapsed "Seen (N)" section on the page,
each with an "Unsee" button, so nothing marked is ever out of reach. Each
project table has "Hide offline" and "Hide seen" checkboxes (both on by
@@ -118,6 +135,11 @@ values), the scan refuses rather than silently dropping every mark.
"sessionFile": "/mnt/storage/src/mosaic-stack/.pi/state/darkwing/sessions/2026-09-12.jsonl",
"sessionId": "01a06e48-0718-71f2-a889-c263c4800fb9",
"cwd": "/mnt/storage/src/mosaic-stack",
"task": "Read agents/darkwing/work/RESTART.md",
"taskSource": "first-user-message",
"workspace": "/mnt/storage/src/mosaic-stack",
"workspaceSource": "tmux-pane",
"activeProject": "mosaic-stack",
"lastActivity": "2026-09-12T15:04:33.000Z",
"ageSeconds": 42,
"lastAssistantText": "Ready for the next step whenever you are.",
+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,
+137 -2
View File
@@ -25,7 +25,10 @@ import {
discoverFleetAgents,
scan,
panesRunPi,
parsePanes,
tmuxInspect,
tmuxIsAlive,
findRepoRoot,
loadSeen,
markSeen,
} from "../src/scan.mjs";
@@ -299,6 +302,117 @@ test("rule: a finished turn (text-only assistant message, stopReason stop) is wa
assert.equal(rec.waitingOnYou, true);
});
// ---------------------------------------------------------------------------
// 4c. Gate A: task / workspace / active project per row, derived or "unknown"
// (docs/plans/2026-09-12_control-board-mvp.md, Step 3 log). One fixture per field.
// ---------------------------------------------------------------------------
const GATE_NOW = () => new Date("2026-09-12T14:00:10Z");
test("task: the first user message of the session, from text blocks", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["[orch-01 -> code-be-01 class=actionable] T-H2-RE: fix #1466"] }),
messageLine({ timestamp: "2026-09-12T14:00:02Z", role: "assistant", stopReason: "stop", texts: ["On it."] }),
messageLine({ timestamp: "2026-09-12T14:00:03Z", role: "user", texts: ["and then report back"] }),
messageLine({ timestamp: "2026-09-12T14:00:04Z", role: "assistant", stopReason: "stop", texts: ["Done."] }),
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
assert.equal(rec.task, "[orch-01 -> code-be-01 class=actionable] T-H2-RE: fix #1466");
assert.equal(rec.taskSource, "first-user-message");
});
test("task: a plain-string user content is accepted, whitespace collapsed and long text capped", () => {
const root = makeRoot();
const long = "orchestrate " + "x".repeat(300);
const line = JSON.stringify({ type: "message", timestamp: "2026-09-12T14:00:01Z", message: { role: "user", content: " " + long + "\n\n" } });
writeSessionFile(join(root, "sessions"), "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
line,
]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
assert.equal(rec.task, collapseExpected(long));
});
test("task: no user message in the log means null (shown as unknown), never a guess", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" })]);
const rec = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
assert.equal(rec.task, null);
assert.equal(rec.taskSource, null);
const none = scanAgent(ruleSpec(makeRoot()), { isAlive: () => true, now: GATE_NOW });
assert.equal(none.task, null);
});
test("workspace: the live tmux pane path wins; the session cwd is the fallback; neither means null", () => {
const root = makeRoot();
writeSessionFile(join(root, "sessions"), "s.jsonl", [sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/from/log" })]);
const fromTmux = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: "/from/tmux" }), now: GATE_NOW });
assert.equal(fromTmux.workspace, "/from/tmux");
assert.equal(fromTmux.workspaceSource, "tmux-pane");
assert.equal(fromTmux.alive, true);
assert.equal(fromTmux.cwd, "/from/log");
const fromLog = scanAgent(ruleSpec(root), { isAlive: () => true, now: GATE_NOW });
assert.equal(fromLog.workspace, "/from/log");
assert.equal(fromLog.workspaceSource, "session-cwd");
const offline = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: false, workspace: null }), now: GATE_NOW });
assert.equal(offline.state, "offline");
assert.equal(offline.workspace, "/from/log");
const nothing = scanAgent(ruleSpec(makeRoot()), { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.equal(nothing.workspace, null);
assert.equal(nothing.workspaceSource, null);
});
test("activeProject: basename of the nearest .git directory or .git file above the workspace; none means null", () => {
const root = makeRoot();
const repo = join(root, "repos", "my-repo");
mkdirSync(join(repo, ".git"), { recursive: true });
mkdirSync(join(repo, "packages", "deep"), { recursive: true });
const worktree = join(root, "repos", "my-worktree");
mkdirSync(join(worktree, "sub"), { recursive: true });
writeFileSync(join(worktree, ".git"), "gitdir: /elsewhere\n");
const plain = join(root, "plain", "dir");
mkdirSync(plain, { recursive: true });
assert.equal(findRepoRoot(join(repo, "packages", "deep")), repo);
assert.equal(findRepoRoot(join(worktree, "sub")), worktree);
assert.equal(findRepoRoot("relative/path"), null);
const inRepo = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: join(repo, "packages", "deep") }), now: GATE_NOW });
assert.equal(inRepo.activeProject, "my-repo");
const inWorktree = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: join(worktree, "sub") }), now: GATE_NOW });
assert.equal(inWorktree.activeProject, "my-worktree");
// The group column (spec.project) is untouched; seen.json keys depend on it.
assert.equal(inWorktree.project, "p");
const noRepo = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: plain }), now: GATE_NOW });
// A temp dir may sit under a git checkout on some machines; only assert when it does not.
if (findRepoRoot(plain) === null) assert.equal(noRepo.activeProject, null);
const noWorkspace = scanAgent(ruleSpec(root), { isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
assert.equal(noWorkspace.activeProject, null);
});
test("scan: the written record carries task, workspace and activeProject", () => {
const root = makeRoot();
const boardDir = join(root, "board");
const sessionsDir = join(root, "sessions");
writeSessionFile(sessionsDir, "s.jsonl", [
sessionLine({ id: "s1", timestamp: "2026-09-12T14:00:00Z", cwd: "/w" }),
messageLine({ timestamp: "2026-09-12T14:00:01Z", role: "user", texts: ["resume"] }),
]);
const index = scan([{ agent: "a", project: "p", sessionsDir, tmux: {} }], { boardDir, isAlive: () => ({ alive: true, workspace: null }), now: GATE_NOW });
const rec = index.sessions[0];
assert.equal(rec.task, "resume");
assert.equal(rec.workspace, "/w");
assert.equal("activeProject" in rec, true);
const onDisk = JSON.parse(readFileSync(join(boardDir, "sessions", "p", "a.json"), "utf8"));
assert.equal(onDisk.task, "resume");
assert.equal(onDisk.workspace, "/w");
});
// ---------------------------------------------------------------------------
// 5. scanAgent
// ---------------------------------------------------------------------------
@@ -573,11 +687,32 @@ test("tmuxIsAlive: passes -L <socket> only when a socket is given", () => {
const withSocket = fakeExec({ status: 0, stdout: "pi\n" });
tmuxIsAlive({ socket: "mosaic-fleet", session: "name" }, { exec: withSocket });
assert.equal(withSocket.calls[0].cmd, "tmux");
assert.deepEqual(withSocket.calls[0].args, ["-L", "mosaic-fleet", "list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}"]);
assert.deepEqual(withSocket.calls[0].args, ["-L", "mosaic-fleet", "list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}\t#{pane_current_path}"]);
const noSocket = fakeExec({ status: 0, stdout: "pi\n" });
tmuxIsAlive({ socket: null, session: "name" }, { exec: noSocket });
assert.deepEqual(noSocket.calls[0].args, ["list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}"]);
assert.deepEqual(noSocket.calls[0].args, ["list-panes", "-s", "-t", "=name", "-F", "#{pane_current_command}\t#{pane_current_path}"]);
});
test("parsePanes: one pane per line, command and optional tab-separated path", () => {
assert.deepEqual(parsePanes("bash\t/home/x\npi\t/mnt/repo\n"), [
{ command: "bash", path: "/home/x" },
{ command: "pi", path: "/mnt/repo" },
]);
assert.deepEqual(parsePanes("pi\n"), [{ command: "pi", path: null }]);
assert.deepEqual(parsePanes(""), []);
});
test("tmuxInspect: reports the path of the pane running pi, not of a shell pane", () => {
const exec = fakeExec({ status: 0, stdout: "bash\t/home/x\npi\t/mnt/repo/sub\n" });
assert.deepEqual(tmuxInspect({ socket: "mosaic-fleet", session: "a" }, { exec }), { alive: true, workspace: "/mnt/repo/sub" });
assert.deepEqual(exec.calls[0].args, ["-L", "mosaic-fleet", "list-panes", "-s", "-t", "=a", "-F", "#{pane_current_command}\t#{pane_current_path}"]);
});
test("tmuxInspect: no pi pane, no session, or no tmux gives no workspace and the matching liveness", () => {
assert.deepEqual(tmuxInspect({ socket: null, session: "a" }, { exec: fakeExec({ status: 0, stdout: "bash\t/home/x\n" }) }), { alive: false, workspace: null });
assert.deepEqual(tmuxInspect({ socket: null, session: "a" }, { exec: fakeExec({ status: 1, stdout: "" }) }), { alive: false, workspace: null });
assert.deepEqual(tmuxInspect({ socket: null, session: "a" }, { exec: fakeExec({ error: new Error("ENOENT") }) }), { alive: null, workspace: null });
});
// ---------------------------------------------------------------------------
@@ -572,3 +572,19 @@ test("page.html: a project header reads \"N of N\" only while a checkbox hides r
assert.match(body, /<span>\(' \+ headCount \+ "\)<\/span>/, "the header uses headCount, not the raw group length");
assert.doesNotMatch(body, /<span>\(' \+ group\.length/, "the raw group length must no longer be rendered in the header");
});
test("page.html: every row shows Task and Active project, derived or the word unknown, with the workspace in the detail", () => {
const html = readFileSync(join(pkgRoot, "src", "page.html"), "utf8");
const m = html.match(/function buildRowPair\(rec, showProject\) \{[\s\S]*?\n \}/);
assert.ok(m, "buildRowPair() must exist in page.html");
const body = m[0];
assert.match(body, /rec\.task\s*\?[\s\S]*esc\(rec\.task\)[\s\S]*:\s*'<span class="unknown">unknown<\/span>'/, "task is escaped, or reads unknown when null");
assert.match(body, /rec\.activeProject\s*\?[\s\S]*esc\(rec\.activeProject\)[\s\S]*unknown/, "active project is escaped, or reads unknown when null");
assert.match(body, /<dt>Task<\/dt>/, "detail lists the task");
assert.match(body, /<dt>Active project<\/dt>/, "detail lists the active project");
assert.match(body, /<dt>Workspace<\/dt><dd>" \+ \(rec\.workspace \? esc\(rec\.workspace\) : "unknown"\)/, "detail lists the workspace, escaped, or unknown");
assert.match(body, /var span = showProject \? 7 : 6/, "the detail row spans the two new columns");
const heads = html.match(/<th scope=\\"col\\">Task<\/th><th scope=\\"col\\">Active project<\/th>/g) || [];
assert.equal(heads.length, 3, "all three tables carry the two new headers");
assert.match(html, /<td colspan="6" class="empty">No agents\.<\/td>/, "the empty project row spans every column");
});