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
+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");
});