diff --git a/AGENTS.md b/AGENTS.md
index 98b2230..dfa61da 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -253,6 +253,14 @@ Basil, …) — picked per launch, never shared by two running agents, shown as
are held in memory, so a restarted board falls back to plain "Agent" for
sessions that predate it.
+Beside that name, wherever it identifies a run — the sessions list, the
+session and Focus headers, the working card's agent line — sits the model
+the launch rode: a small mono chip in the id hash's dim register
+(`opus-4-8`, the vendor's whole string on hover). Which brain did the work
+is a review question, not a state, so the chip takes no colour. A launch
+that inherited the vendor default, and a session replayed from disk, wear
+no chip at all — the board says nothing rather than guessing.
+
**▸ start work** launches a headless `claude -p` on the task. It exists only
on `in-progress/` cards: moving a card to in-progress is the commitment, and
only then does work start — the server refuses launches from anywhere else.
diff --git a/manager/core/board.html b/manager/core/board.html
index cd403e3..a6026b6 100644
--- a/manager/core/board.html
+++ b/manager/core/board.html
@@ -101,6 +101,17 @@
.livechip[hidden]{display:none}
.dot{width:7px;height:7px;border-radius:99px;background:var(--idle);flex:none}
.dot.live{background:var(--accent);animation:breathe 2.4s ease-in-out infinite}
+ /* the model chip: which brain did this, beside the name that did it.
+ Machine-produced, so mono; a model is not a state, so it borrows no
+ colour — it lives in the session-id hash's register wherever names
+ appear, tracking each site's hash size. cursor:help because the
+ unshortened string is on the title. */
+ .mchip{font-family:var(--mono);font-size:10.5px;font-weight:400;color:var(--dim);white-space:nowrap;cursor:help}
+ /* the card's row is the tight one: there the chip gives way like the
+ mono fact beside it rather than pushing the row wider */
+ .card .whorow .mchip{font-size:11px;overflow:hidden;text-overflow:ellipsis}
+ .f-head .s-title .mchip{font-size:12px}
+ .refline .mchip{font-size:11.5px}
/* ── kanban ── */
main{flex:1;display:flex;min-height:0}
@@ -697,6 +708,24 @@ function isLiveSession(m) {
}
function allTasks() { return (S.state?.board.stages || []).flatMap(s => s.tasks); }
+/* Which model a run rode, shortened for the chip: the provider path an
+ opencode-style id carries (anthropic/model-x) and the vendor word a
+ claude one repeats (claude-opus-4-8) are both redundant beside a board
+ that already knows its vendor. Nothing else is touched — an unknown
+ name is shown as recorded rather than guessed at. */
+function shortModel(model) {
+ return String(model).split('/').pop().replace(/^claude-/, '');
+}
+
+/* One chip, every place a name identifies a run. A launch that never knew
+ its model (it inherited the vendor default, or the session was replayed
+ from disk after a restart) gets nothing at all: no chip is the honest
+ answer, a placeholder would read as a model named "unknown". */
+function modelChip(agent) {
+ if (!agent || !agent.model) return '';
+ return `${esc(shortModel(agent.model))}`;
+}
+
function connectStream() {
const es = new EventSource('/api/stream');
let hadError = false;
@@ -1060,6 +1089,7 @@ function cardFor(task) {
`
`;
@@ -1498,12 +1528,14 @@ function renderFlight() {
const stopBtn = agent && agent.status === 'running'
? `` : '';
const branch = agent && agent.branch ? ` · ${esc(agent.branch)}` : '';
- // Honesty about what the run actually rode: the configured model, or
- // the vendor default it inherited. Interactive sessions say nothing.
- const model = agent ? ` · ${agent.model ? esc(agent.model) : 'model inherited'}` : '';
+ // Honesty about what the run actually rode. A known model is the chip's
+ // job now, beside the name; the line keeps only what the chip cannot
+ // say — that this launch inherited the vendor default. Interactive
+ // sessions say nothing.
+ const model = agent && !agent.model ? ` · model inherited` : '';
$('#fsession').innerHTML =
`
` +
@@ -1690,9 +1722,11 @@ function renderFocus() {
const refBits = [];
if (task) {
refBits.push(task.number ? '#' + esc(task.number) : esc(task.file));
- refBits.push(`${esc((meta.label || '').split(' · ')[0])}`);
+ refBits.push(`${esc((meta.label || '').split(' · ')[0])}` +
+ modelChip(agent));
if (agent && agent.branch) refBits.push('worktree ' + esc(agent.branch));
- if (agent) refBits.push(agent.model ? esc(agent.model) : 'model inherited');
+ // the chip says which model; the line is left saying only what it can't
+ if (agent && !agent.model) refBits.push('model inherited');
refBits.push(esc(task.stage) + '/' + esc(task.file));
} else {
refBits.push(esc(sid.slice(0, 8)), 'no task attached');
diff --git a/tests/test_model_chip.py b/tests/test_model_chip.py
new file mode 100644
index 0000000..73c8931
--- /dev/null
+++ b/tests/test_model_chip.py
@@ -0,0 +1,171 @@
+"""The model chip beside every agent name (task 24).
+
+Task 12 recorded which model each launch rode; this puts it where eyes
+land — one chip, in the session-id hash's register, beside the name that
+identifies a run.
+
+Two halves. The chip's behaviour (shortening, escaping, and the silence
+that means "this launch never knew") is exercised for real: the two
+functions are lifted out of board.html and run in node, skipped where
+node is absent. The placement — which four render sites wear it, and
+that they all wear the same one — is a source-level invariant, board.html
+being a single file with inline JS and no frontend test runner.
+
+ python3 -m unittest discover -s tests -v
+"""
+
+from __future__ import annotations
+
+import json
+import re
+import shutil
+import subprocess
+import unittest
+from pathlib import Path
+
+BOARD = Path(__file__).resolve().parents[1] / "manager" / "core" / "board.html"
+
+# The pieces the chip is made of, lifted from the page as written.
+PARTS = (
+ r"const esc = \(s\) =>.*?\}\[c\]\)\);",
+ r"function shortModel\(model\) \{.*?\n\}",
+ r"function modelChip\(agent\) \{.*?\n\}",
+)
+
+
+def _harness() -> str:
+ html = BOARD.read_text(encoding="utf-8")
+ out = []
+ for pattern in PARTS:
+ m = re.search(pattern, html, re.S)
+ if m is None:
+ raise AssertionError(f"board.html no longer defines {pattern!r}")
+ out.append(m.group(0))
+ return "\n".join(out)
+
+
+class ChipBehaviour(unittest.TestCase):
+ """What the chip actually renders, run as the browser would run it."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.node = shutil.which("node")
+ if not cls.node:
+ raise unittest.SkipTest("node not available — chip behaviour unrun")
+ cls.src = _harness()
+
+ def chips(self, agents: list) -> list:
+ """modelChip(a) for each given agent record."""
+ script = (self.src + "\nconsole.log(JSON.stringify("
+ + json.dumps(agents) + ".map(modelChip)));")
+ out = subprocess.run([self.node, "-e", script],
+ capture_output=True, text=True)
+ self.assertEqual(out.returncode, 0, out.stderr)
+ return json.loads(out.stdout)
+
+ def test_a_launch_that_never_knew_its_model_shows_nothing(self):
+ """No chip is the honest answer for an inherited launch, a session
+ with no agent record (yours, or one replayed from disk after a
+ restart), and a pre-task-12 record: a placeholder would read as a
+ model actually named that."""
+ self.assertEqual(
+ self.chips([None, {}, {"model": None}, {"model": ""},
+ {"name": "Wren"}]),
+ ["", "", "", "", ""])
+
+ def test_the_vendor_prefix_is_dropped_and_kept_on_hover(self):
+ """claude-opus-4-8 → opus-4-8 on the face, whole on the title."""
+ chip, = self.chips([{"model": "claude-opus-4-8"}])
+ self.assertIn(">opus-4-8", chip)
+ self.assertIn('title="claude-opus-4-8"', chip)
+ self.assertIn('class="mchip"', chip)
+
+ def test_a_provider_path_is_dropped_the_same_way(self):
+ """The opencode adapter's ids are provider/model."""
+ chip, = self.chips([{"model": "anthropic/claude-opus-4-8"}])
+ self.assertIn(">opus-4-8", chip)
+ self.assertIn('title="anthropic/claude-opus-4-8"', chip)
+
+ def test_an_unfamiliar_name_is_shown_as_recorded_not_guessed_at(self):
+ """Shortening only removes what this board knows is redundant —
+ it never eats the first word of a name it does not recognise."""
+ for model in ("gpt-4o", "some-model", "opus-4-8"):
+ chip, = self.chips([{"model": model}])
+ self.assertIn(f">{model}", chip)
+
+ def test_the_model_string_is_escaped_on_both_face_and_title(self):
+ chip, = self.chips([{"model": 'a"${esc(m.id.slice(0, 8))}',
+ "the session-detail header":
+ '${esc(sid.slice(0, 8))}',
+ "the Focus header":
+ '${esc((meta.label || \'\').split(\' · \')[0])}',
+ "the working card's agent line":
+ '${esc(who)}',
+ }
+
+ def test_every_name_that_identifies_a_run_wears_the_chip(self):
+ for where, anchor in self.SITES.items():
+ pos = self.html.find(anchor)
+ self.assertNotEqual(pos, -1, f"{where} no longer renders as expected")
+ window = self.html[pos + len(anchor):pos + len(anchor) + 40]
+ self.assertIn("modelChip(", window,
+ f"{where} lost the model chip beside its name")
+
+ def test_there_is_one_chip_component_not_four(self):
+ """One component means one register: a second inline copy is how
+ the four drift apart."""
+ self.assertEqual(self.html.count('class="mchip"'), 1,
+ "the chip's markup must live only in modelChip()")
+ self.assertEqual(self.html.count("function modelChip("), 1)
+
+ def test_the_chip_is_the_id_hashs_register_and_means_no_state(self):
+ """Mono because a model name is machine-produced, dim because it
+ is a footnote to the name — and no state colour, because a model
+ is not a state."""
+ rule = re.search(r"\n \.mchip\{([^}]*)\}", self.html)
+ self.assertIsNotNone(rule, "the .mchip rule is gone")
+ self.assertIn("font-family:var(--mono)", rule.group(1))
+ self.assertIn("color:var(--dim)", rule.group(1))
+ for state_colour in ("--accent", "--calm", "--alarm"):
+ self.assertNotIn(state_colour, rule.group(1),
+ f"the chip took on {state_colour}: colour "
+ "would start meaning 'model' as well as state")
+
+ def test_the_chip_never_wraps_a_line_it_joins(self):
+ """It joins flex rows carrying names and timestamps; a wrapping
+ chip would move them."""
+ rule = re.search(r"\n \.mchip\{([^}]*)\}", self.html)
+ self.assertIn("white-space:nowrap", rule.group(1))
+
+ def test_the_metadata_lines_no_longer_repeat_a_known_model(self):
+ """The chip says which model; the two lines that used to carry it
+ keep only what the chip cannot say — that a launch inherited the
+ vendor default — and say it nowhere else."""
+ for m in re.finditer(r"model inherited", self.html):
+ window = self.html[max(0, m.start() - 200):m.start()]
+ self.assertIn("!agent.model", window,
+ "'model inherited' must be reached only when the "
+ "model is genuinely unknown")
+ self.assertEqual(self.html.count("model inherited"), 2,
+ "the session-detail line and the Focus refline are "
+ "the two places that say it")
+
+
+if __name__ == "__main__":
+ unittest.main()