control board: show the model each seat is running (#1503)

Jason asked for the running model (sonnet, opus, gpt-6-astra) on the
board. readSession keeps model and provider from the log's latest
model_change entry or assistant turn, whichever is later, so a /model
switch shows on the next scan; scanAgent exposes both; the page shows the
model under the agent name with the provider in the hover and a Model
detail row. Blank when the log names none.

Live check on a scratch board against the real data root: all 42 rows
carried a model. Board 91/91. Sonnet review caught a double-escaped hover
title; fixed.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
2026-09-12 11:13:32 -05:00
co-authored by Claude Fable 5.1
parent d0fb5b0f20
commit 0bed9ba3db
8 changed files with 99 additions and 4 deletions
+11
View File
@@ -2122,3 +2122,14 @@ gone pid marks the record stale: kept on the Registered line with
index, stale count in the CLI summary. Fleet launchers marked not planned index, stale count in the CLI summary. Fleet launchers marked not planned
per Jason. Tests: board 90/90, seat 15/15, launch scripts 5/5, real data per Jason. Tests: board 90/90, seat 15/15, launch scripts 5/5, real data
root untouched by the run. The three fixture records were deleted by hand. root untouched by the run. The three fixture records were deleted by hand.
## 2026-09-12 — Control board: model per row (#1503)
Before: the board did not say which model a seat was running. After:
`readSession` keeps `model` and `provider` from the log's latest
`model_change` entry or assistant turn, whichever is later; the record
carries both; the page shows the model under the agent name with the
provider in the hover, and a Model detail row. Blank when the log names
none. Live check against the real data root on a scratch board: all 42
rows carried a model. Board suite 91/91 (was 90). Sonnet review recorded
in the plan page entry.
+1
View File
@@ -222,3 +222,4 @@ are never rewritten or removed; corrections are new entries.
- 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). - 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).
- 2026-09-12 — darkwing (Claude Code, coordinator; formerly jarvis) — Seat registration (#1504), Jason's go on the professor session's brief: new `packages/seat` and `scripts/mosaic` (`launch <seat>` registers then execs the launch script unchanged; `seat task` edits the task); board reads `<dataRoot>/seats/<layout>/<seat>/registration.json` and lets registered task/project/workspace override derived values with a source tag; the four repo launch scripts register themselves; fleet one-liner written for orch-01. Tests: seat 15, board 79 → 89, launch scripts 5, registry 69, config 24. Sonnet review found a seat-name collision across layouts; fixed by keying on layout, second pass APPROVED. Live check on a scratch server against a temp data root. Also found Jason's launch refused on the Pi pin (node_modules 0.84.4 vs pin 0.85.1 since 557aba0f); `npm ci` fixed it, the refusal now names both versions; the pin stays unless Jason decides otherwise. - 2026-09-12 — darkwing (Claude Code, coordinator; formerly jarvis) — Seat registration (#1504), Jason's go on the professor session's brief: new `packages/seat` and `scripts/mosaic` (`launch <seat>` registers then execs the launch script unchanged; `seat task` edits the task); board reads `<dataRoot>/seats/<layout>/<seat>/registration.json` and lets registered task/project/workspace override derived values with a source tag; the four repo launch scripts register themselves; fleet one-liner written for orch-01. Tests: seat 15, board 79 → 89, launch scripts 5, registry 69, config 24. Sonnet review found a seat-name collision across layouts; fixed by keying on layout, second pass APPROVED. Live check on a scratch server against a temp data root. Also found Jason's launch refused on the Pi pin (node_modules 0.84.4 vs pin 0.85.1 since 557aba0f); `npm ci` fixed it, the refusal now names both versions; the pin stays unless Jason decides otherwise.
- 2026-09-12 — darkwing (Claude Code, coordinator) — #1504 follow-up on two defects the professor session reported and this session verified: the darkwing launch test leaked fixture registrations into the real data root (flock spawn without the fixture config; fixed and guarded so a leak fails), and a refused launch left a dead-pid record the board honoured (scanner now probes the pid; stale records are shown but do not override). Fleet launchers marked not planned per Jason. Board 90/90, seat 15/15, launch scripts 5/5. Committed and pushed to refactor. - 2026-09-12 — darkwing (Claude Code, coordinator) — #1504 follow-up on two defects the professor session reported and this session verified: the darkwing launch test leaked fixture registrations into the real data root (flock spawn without the fixture config; fixed and guarded so a leak fails), and a refused launch left a dead-pid record the board honoured (scanner now probes the pid; stale records are shown but do not override). Fleet launchers marked not planned per Jason. Board 90/90, seat 15/15, launch scripts 5/5. Committed and pushed to refactor.
- 2026-09-12 — darkwing (Claude Code, coordinator) — Control board: model per row (#1503) on Jason's ask: readSession tracks model/provider from the latest model_change entry or assistant turn; page shows the model under the agent name and a Model detail row. Live check on a scratch board: all 42 real rows named a model. Board 91/91. Committed and pushed to refactor.
+3 -1
View File
@@ -347,4 +347,6 @@ log names no model; rocko has no pi log and stays off the board. Checked
on a scratch board against the real data root: all 42 rows carried a on a scratch board against the real data root: all 42 rows carried a
model (darkwing and sage `gpt-6-astra`, filbert `glm-5.3`, fleet rows a mix model (darkwing and sage `gpt-6-astra`, filbert `glm-5.3`, fleet rows a mix
of `gpt-5.6-*` and `glm-5.3`). Board suite 91/91. Jason's board on 7331 of `gpt-5.6-*` and `glm-5.3`). Board suite 91/91. Jason's board on 7331
needs a restart to pick this up. needs a restart to pick this up. Sonnet review found the model hover title
escaped an already escaped string (double encoding for names with `&` or
`<`); fixed before commit, then APPROVED on that basis.
+8
View File
@@ -113,6 +113,12 @@ tmux, else shown as the word "unknown", never guessed:
tmux could not be asked, the `cwd` from the session log. The record says tmux could not be asked, the `cwd` from the session log. The record says
which one it used (`workspaceSource`: `tmux-pane` or `session-cwd`). which one it used (`workspaceSource`: `tmux-pane` or `session-cwd`).
- **Model** (under the agent name, and a detail row with the provider) —
the model the seat is running, from the log's latest `model_change` entry
or assistant turn, whichever is later, so a `/model` switch mid-session
shows up on the next scan. Blank until the log names one; a seat with no
pi log (rocko) has none.
A seat started through `scripts/mosaic launch <seat>` (see A seat started through `scripts/mosaic launch <seat>` (see
`packages/seat/README.md`, #1504) has a registration at `packages/seat/README.md`, #1504) has a registration at
`<dataRoot>/seats/<layout>/<seat>/registration.json`. The scan reads every one of `<dataRoot>/seats/<layout>/<seat>/registration.json`. The scan reads every one of
@@ -166,6 +172,8 @@ values), the scan refuses rather than silently dropping every mark.
"registered": null, "registered": null,
"lastActivity": "2026-09-12T15:04:33.000Z", "lastActivity": "2026-09-12T15:04:33.000Z",
"ageSeconds": 42, "ageSeconds": 42,
"model": "gpt-6-astra",
"provider": "openai-codex",
"lastAssistantText": "Ready for the next step whenever you are.", "lastAssistantText": "Ready for the next step whenever you are.",
"lastError": null, "lastError": null,
"skippedLines": 0, "skippedLines": 0,
+8 -1
View File
@@ -63,6 +63,7 @@
.seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle} .seen-tag{font-size:.72rem;color:var(--muted);margin-left:6px;vertical-align:middle}
.msg-error{color:var(--danger)} .msg-error{color:var(--danger)}
.msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .msg-text,.msg-error{display:block;max-width:36ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.model{display:block;color:var(--muted);font-size:.75em;white-space:nowrap}
.task-text{display:block;max-width:28ch;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} .unknown{color:var(--muted);font-style:italic}
.source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap} .source{color:var(--muted);font-size:.75em;margin-left:.35em;white-space:nowrap}
@@ -189,6 +190,11 @@
function sourceLabel(source) { return SOURCE_LABEL[source] || source; } function sourceLabel(source) { return SOURCE_LABEL[source] || source; }
function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; } function sourceTag(source) { return source ? '<span class="source" title="source: ' + esc(source) + '">' + esc(sourceLabel(source)) + "</span>" : ""; }
function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; } function fromSource(source) { return source ? " (from " + esc(sourceLabel(source)) + ")" : ""; }
// The model a seat is running, e.g. "gpt-6-astra" with the provider in the
// hover title. modelText() already escapes, so the title takes it as is.
// Empty when the log has not named one yet.
function modelTag(rec) { return rec.model ? '<span class="model" title="' + modelText(rec) + '">' + esc(rec.model) + "</span>" : ""; }
function modelText(rec) { return rec.model ? (rec.provider ? esc(rec.provider) + "/" : "") + esc(rec.model) : "unknown"; }
function registeredText(reg) { function registeredText(reg) {
if (!reg) return "no (not started through mosaic launch)"; if (!reg) return "no (not started through mosaic launch)";
var parts = ["started " + esc(reg.startedAt || "—")]; var parts = ["started " + esc(reg.startedAt || "—")];
@@ -222,7 +228,7 @@
var main = var main =
'<tr class="' + cls + '">' + '<tr class="' + cls + '">' +
projectCell + 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><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>" + modelTag(rec) + "</td>" +
"<td>" + badge(rec.state) + seenControl(rec) + "</td>" + "<td>" + badge(rec.state) + seenControl(rec) + "</td>" +
"<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" + "<td>" + esc(humanAge(rec.ageSeconds)) + "</td>" +
"<td>" + task + "</td>" + "<td>" + task + "</td>" +
@@ -241,6 +247,7 @@
"<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" + "<dt>Task</dt><dd>" + (rec.task ? esc(rec.task) : "unknown") + fromSource(rec.taskSource) + "</dd>" +
"<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" + "<dt>Active project</dt><dd>" + (rec.activeProject ? esc(rec.activeProject) : "unknown") + fromSource(rec.activeProjectSource) + "</dd>" +
"<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" + "<dt>Workspace</dt><dd>" + (rec.workspace ? esc(rec.workspace) : "unknown") + fromSource(rec.workspaceSource) + "</dd>" +
"<dt>Model</dt><dd>" + modelText(rec) + "</dd>" +
"<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" + "<dt>Registered</dt><dd>" + registeredText(rec.registered) + "</dd>" +
"<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" + "<dt>Session cwd</dt><dd>" + esc(rec.cwd || "—") + "</dd>" +
"<dt>Tmux session</dt><dd>" + tmux + "</dd>" + "<dt>Tmux session</dt><dd>" + tmux + "</dd>" +
+17 -1
View File
@@ -71,6 +71,7 @@ export function readSession(file) {
const lines = readFileSync(file, "utf8").split("\n"); const lines = readFileSync(file, "utf8").split("\n");
let sessionId = null, cwd = null, lastTimestamp = null, lastMessage = null, lastAssistantText = null, lastError = null; let sessionId = null, cwd = null, lastTimestamp = null, lastMessage = null, lastAssistantText = null, lastError = null;
let firstUserText = null; let firstUserText = null;
let model = null, provider = null;
let skippedLines = 0; let skippedLines = 0;
for (const line of lines) { for (const line of lines) {
if (!line.trim()) continue; if (!line.trim()) continue;
@@ -85,8 +86,18 @@ export function readSession(file) {
if (entry.type === "session") { if (entry.type === "session") {
sessionId = entry.id ?? sessionId; sessionId = entry.id ?? sessionId;
cwd = entry.cwd ?? cwd; cwd = entry.cwd ?? cwd;
} else if (entry.type === "model_change") {
// pi logs a model switch (/model, or the launch default) as its own
// entry: { provider, modelId }. The latest one is the model in use.
if (typeof entry.modelId === "string" && entry.modelId) model = entry.modelId;
if (typeof entry.provider === "string" && entry.provider) provider = entry.provider;
} else if (entry.type === "message" && entry.message) { } else if (entry.type === "message" && entry.message) {
lastMessage = entry.message; lastMessage = entry.message;
if (entry.message.role === "assistant") {
// Each assistant turn also names the model that produced it.
if (typeof entry.message.model === "string" && entry.message.model) model = entry.message.model;
if (typeof entry.message.provider === "string" && entry.message.provider) provider = entry.message.provider;
}
if (entry.message.role === "user" && firstUserText === null) { if (entry.message.role === "user" && firstUserText === null) {
const text = userText(entry.message.content); const text = userText(entry.message.content);
if (text.trim()) firstUserText = collapse(text); if (text.trim()) firstUserText = collapse(text);
@@ -98,7 +109,7 @@ export function readSession(file) {
} }
} }
} }
return { file, sessionId, cwd, lastTimestamp, lastMessage, lastAssistantText, lastError, firstUserText, skippedLines }; return { file, sessionId, cwd, lastTimestamp, lastMessage, lastAssistantText, lastError, firstUserText, model, provider, skippedLines };
} }
// A user message's text: pi writes either a plain string or a list of blocks. // A user message's text: pi writes either a plain string or a list of blocks.
@@ -341,6 +352,11 @@ export function scanAgent(spec, { isAlive = tmuxInspect, now = () => new Date(),
sessionFile: file, sessionFile: file,
sessionId: session?.sessionId ?? null, sessionId: session?.sessionId ?? null,
cwd, cwd,
// The model the seat is running, from the log's latest model_change entry
// or assistant turn, whichever came last. null when the log has neither
// (a seat with no log, or one that has not answered yet).
model: session?.model ?? null,
provider: session?.provider ?? null,
task, task,
taskSource, taskSource,
workspace, workspace,
+42 -1
View File
@@ -60,9 +60,11 @@ function writeFile(path, content) {
function sessionLine({ id, timestamp, cwd }) { function sessionLine({ id, timestamp, cwd }) {
return JSON.stringify({ type: "session", id, timestamp, cwd }); return JSON.stringify({ type: "session", id, timestamp, cwd });
} }
function messageLine({ timestamp, role, stopReason, texts, blocks }) { function messageLine({ timestamp, role, stopReason, texts, blocks, model, provider }) {
const message = { role }; const message = { role };
if (stopReason !== undefined) message.stopReason = stopReason; if (stopReason !== undefined) message.stopReason = stopReason;
if (model !== undefined) message.model = model;
if (provider !== undefined) message.provider = provider;
if (texts) message.content = texts.map((text) => ({ type: "text", text })); if (texts) message.content = texts.map((text) => ({ type: "text", text }));
if (blocks) message.content = (message.content || []).concat(blocks); if (blocks) message.content = (message.content || []).concat(blocks);
return JSON.stringify({ type: "message", timestamp, message }); return JSON.stringify({ type: "message", timestamp, message });
@@ -190,6 +192,45 @@ test("readSession: extracts fields, collapses/truncates text, counts a truncated
assert.equal(result.skippedLines, 1); assert.equal(result.skippedLines, 1);
}); });
test("readSession: model and provider follow the latest model_change entry or assistant turn; null when the log names neither; scanAgent carries them", () => {
const root = makeRoot();
const modelChange = (timestamp, provider, modelId) => JSON.stringify({ type: "model_change", id: "m", parentId: null, timestamp, provider, modelId });
const switchedDir = join(root, "switched");
writeSessionFile(switchedDir, "model.jsonl", [
sessionLine({ id: "s", timestamp: "2026-09-12T16:06:52.940Z", cwd: "/x" }),
modelChange("2026-09-12T16:06:52.988Z", "zai", "glm-5.3-flash"),
messageLine({ timestamp: "2026-09-12T16:07:00Z", role: "user", texts: ["hi"] }),
messageLine({ timestamp: "2026-09-12T16:07:05Z", role: "assistant", stopReason: "stop", texts: ["hello"], model: "glm-5.3-flash", provider: "zai" }),
modelChange("2026-09-12T16:08:52.233Z", "openai-codex", "gpt-6-astra"),
]);
const switched = readSession(join(switchedDir, "model.jsonl"));
assert.equal(switched.model, "gpt-6-astra", "a /model switch after the last answer wins");
assert.equal(switched.provider, "openai-codex");
const answered = writeSessionFile(root, "answered.jsonl", [
sessionLine({ id: "s", timestamp: "2026-09-12T16:06:52.940Z", cwd: "/x" }),
modelChange("2026-09-12T16:06:52.988Z", "zai", "glm-5.3-flash"),
messageLine({ timestamp: "2026-09-12T16:07:05Z", role: "assistant", stopReason: "stop", texts: ["hello"], model: "claude-sonnet-5", provider: "anthropic" }),
messageLine({ timestamp: "2026-09-12T16:07:06Z", role: "user", texts: ["and?"] }),
]);
assert.deepEqual([readSession(answered).model, readSession(answered).provider], ["claude-sonnet-5", "anthropic"], "the assistant turn names the model that produced it");
const noneDir = join(root, "none");
writeSessionFile(noneDir, "none.jsonl", [
sessionLine({ id: "s", timestamp: "2026-09-12T16:06:52.940Z", cwd: "/x" }),
messageLine({ timestamp: "2026-09-12T16:07:00Z", role: "user", texts: ["hi"] }),
]);
assert.equal(readSession(join(noneDir, "none.jsonl")).model, null);
assert.equal(readSession(join(noneDir, "none.jsonl")).provider, null);
const rec = scanAgent({ agent: "a", project: "p", sessionsDir: switchedDir, tmux: {} }, { isAlive: () => true, now: GATE_NOW });
assert.equal(rec.model, "gpt-6-astra", "scanAgent carries model through from the newest log");
assert.equal(rec.provider, "openai-codex");
const bare = scanAgent({ agent: "a", project: "p", sessionsDir: noneDir, tmux: {} }, { isAlive: () => true, now: GATE_NOW });
assert.equal(bare.model, null);
assert.equal(bare.provider, null);
});
test("readSession: lastError carries the assistant errorMessage only when the last assistant turn errored", () => { test("readSession: lastError carries the assistant errorMessage only when the last assistant turn errored", () => {
const root = makeRoot(); const root = makeRoot();
const errored = JSON.stringify({ const errored = JSON.stringify({
@@ -655,6 +655,15 @@ test("page.html: task and active project cells show their source via sourceTag()
"the detail list has a Registered row built by registeredText()", "the detail list has a Registered row built by registeredText()",
); );
assert.match(rowPair[0], /esc\(rec\.agent\) \+ "<\/button>" \+ modelTag\(rec\)/, "the agent cell shows the model under the name");
assert.match(rowPair[0], /<dt>Model<\/dt><dd>" \+ modelText\(rec\) \+ "<\/dd>/, "the detail list has a Model row");
for (const name of ["modelTag", "modelText"]) {
const line = html.split("\n").find((l) => l.includes(`function ${name}(rec)`));
assert.ok(line, `${name}() must exist in page.html`);
assert.match(line, /esc\(rec\.model\)/, `${name}() escapes the model`);
if (name === "modelText") assert.match(line, /esc\(rec\.provider\)/, "modelText() escapes the provider");
}
const sourceLabelMatch = html.match(/var SOURCE_LABEL = (\{.*\});/); const sourceLabelMatch = html.match(/var SOURCE_LABEL = (\{.*\});/);
assert.ok(sourceLabelMatch, "SOURCE_LABEL must exist in page.html"); assert.ok(sourceLabelMatch, "SOURCE_LABEL must exist in page.html");
const sourceLabel = JSON.parse(sourceLabelMatch[1]); const sourceLabel = JSON.parse(sourceLabelMatch[1]);