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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user