From 4a7e16c3ec557b7ad4334addc7caa504e4cfd72d Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sat, 12 Sep 2026 09:52:45 -0500 Subject: [PATCH] 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 --- BUILD-LOG.md | 23 ++++ docs/SESSIONS.md | 1 + docs/plans/2026-09-12_control-board-mvp.md | 19 +++ packages/control-board/README.md | 22 ++++ packages/control-board/src/cli.mjs | 4 +- packages/control-board/src/page.html | 26 +++- packages/control-board/src/scan.mjs | 97 +++++++++++--- packages/control-board/tests/scan.test.mjs | 139 +++++++++++++++++++- packages/control-board/tests/serve.test.mjs | 16 +++ 9 files changed, 321 insertions(+), 26 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 76fb7772..1c70815e 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -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. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 907aebea..d0efbf10 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -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). diff --git a/docs/plans/2026-09-12_control-board-mvp.md b/docs/plans/2026-09-12_control-board-mvp.md index 1b15a244..b9ba82e1 100644 --- a/docs/plans/2026-09-12_control-board-mvp.md +++ b/docs/plans/2026-09-12_control-board-mvp.md @@ -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 diff --git a/packages/control-board/README.md b/packages/control-board/README.md index 1399f04c..cb72c305 100644 --- a/packages/control-board/README.md +++ b/packages/control-board/README.md @@ -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.", diff --git a/packages/control-board/src/cli.mjs b/packages/control-board/src/cli.mjs index f871a9ee..23c3a8b8 100755 --- a/packages/control-board/src/cli.mjs +++ b/packages/control-board/src/cli.mjs @@ -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 }); diff --git a/packages/control-board/src/page.html b/packages/control-board/src/page.html index e2d54404..620b689d 100644 --- a/packages/control-board/src/page.html +++ b/packages/control-board/src/page.html @@ -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 @@ ? '' + esc(rec.lastError) + "" : '' + esc(rec.lastAssistantText || "—") + ""; var projectCell = showProject ? "" + esc(rec.project) + "" : ""; + // Gate A fields: derived from the log and tmux, or "unknown". Never guessed. + var task = rec.task + ? '' + esc(rec.task) + "" + : 'unknown'; + var activeProject = rec.activeProject + ? '' + esc(rec.activeProject) + "" + : 'unknown'; var main = '' + projectCell + '" + "" + badge(rec.state) + seenControl(rec) + "" + "" + esc(humanAge(rec.ageSeconds)) + "" + + "" + task + "" + + "" + activeProject + "" + "" + msg + "" + ""; - 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 @@ '
' + "
Session ID
" + esc(rec.sessionId || "—") + "
" + "
Session file
" + esc(rec.sessionFile || "—") + "
" + - "
Working directory
" + esc(rec.cwd || "—") + "
" + + "
Task
" + (rec.task ? esc(rec.task) : "unknown") + "
" + + "
Active project
" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + "
" + + "
Workspace
" + (rec.workspace ? esc(rec.workspace) : "unknown") + (rec.workspaceSource ? " (from " + esc(rec.workspaceSource) + ")" : "") + "
" + + "
Session cwd
" + esc(rec.cwd || "—") + "
" + "
Tmux session
" + tmux + "
" + "
Last activity
" + esc(rec.lastActivity || "—") + "
" + "
Scanned at
" + esc(rec.scannedAt || "—") + "
" + @@ -236,7 +250,7 @@ var rows = list.map(function (r) { return buildRowPair(r, true); }).join(""); seenBody.innerHTML = '
' + - "" + + "" + "" + rows + "
ProjectAgentStateAgeLast messageProjectAgentStateAgeTaskActive projectLast message
"; } @@ -256,7 +270,7 @@ var rows = list.map(function (r) { return buildRowPair(r, true); }).join(""); waitingBody.innerHTML = '
' + - "" + + "" + "" + rows + "
ProjectAgentStateAgeLast messageProjectAgentStateAgeTaskActive projectLast message
"; } @@ -304,8 +318,8 @@ '" + "" + '
' + - "" + - "" + (rows || '') + "
AgentStateAgeLast message
No agents.
" + + "AgentStateAgeTaskActive projectLast message" + + "" + (rows || 'No agents.') + "" + note + "" ); }).join(""); diff --git a/packages/control-board/src/scan.mjs b/packages/control-board/src/scan.mjs index 64f3c6ce..406aff8f 100644 --- a/packages/control-board/src/scan.mjs +++ b/packages/control-board/src/scan.mjs @@ -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: "\t" (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: { "/": "" }. 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, diff --git a/packages/control-board/tests/scan.test.mjs b/packages/control-board/tests/scan.test.mjs index f466ed60..55dbf605 100644 --- a/packages/control-board/tests/scan.test.mjs +++ b/packages/control-board/tests/scan.test.mjs @@ -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 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 }); }); // --------------------------------------------------------------------------- diff --git a/packages/control-board/tests/serve.test.mjs b/packages/control-board/tests/serve.test.mjs index 0bacbe3c..91f532b9 100644 --- a/packages/control-board/tests/serve.test.mjs +++ b/packages/control-board/tests/serve.test.mjs @@ -572,3 +572,19 @@ test("page.html: a project header reads \"N of N\" only while a checkbox hides r assert.match(body, /\(' \+ headCount \+ "\)<\/span>/, "the header uses headCount, not the raw group length"); assert.doesNotMatch(body, /\(' \+ 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*'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, /
Task<\/dt>/, "detail lists the task"); + assert.match(body, /
Active project<\/dt>/, "detail lists the active project"); + assert.match(body, /
Workspace<\/dt>
" \+ \(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(/Task<\/th>Active project<\/th>/g) || []; + assert.equal(heads.length, 3, "all three tables carry the two new headers"); + assert.match(html, /No agents\.<\/td>/, "the empty project row spans every column"); +});