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