A whole-stack restart races every node against the console for the CA
fetch (compose re-enforces depends_on ordering only on `up`): losers
logged one warning and served plain HTTP for their lifetime, while
winners served mTLS that the plain-HTTP container healthcheck could
never probe — leaving "healthy" plaintext nodes and "unhealthy"
working ones.
- TLSClient.init() grows attempts/base_delay retry (server passes 6
attempts, ~31 s backoff) absorbing the boot race; per-attempt CA-fetch
failures log warning + debug traceback instead of error tracebacks.
- healthcheck.py falls back to HTTPS when the plain probe fails,
presenting the node's own cert as the client cert with the cluster CA
pinned; dials localhost because the internal CA issues DNS SANs only.
Default plain-HTTP deployments are unchanged.
- The server writes boot PEMs under a fixed root (TURNSTONE_TLS_PEM_DIR,
default <tmpdir>/turnstone-tls) so the probe can find them; boot
clears stale dirs and refuses a symlinked/foreign-owned root; renewal
rewrites the PEM dir so the probe's client cert never outlives the
served cert.
- /health reports tls: "active"|"fallback" (absent when TLS is
disabled) so a silently downgraded node is observable.
Review feedback (Copilot), both confirmed against source:
- openPopupMenu: when no menu item has focus (a click on a separator or
the menu surface moves focus off the items without closing), ArrowUp's
unguarded modulo landed on the second-to-last item ((-1-1+n)%n == n-2).
Guarded to enter at the bottom; ArrowDown's (-1+1)%n already entered at
the top. Pre-existing in the tab dropdown this helper was extracted
from — the shared chrome means one fix covers both menus.
- The burger and the [+] tail were focusable non-tab children inside the
element PaneManager stamps role=tablist (the [+] violation pre-existed;
the burger doubled it). The tabs now live in their own .tabstrip, which
becomes the tablist PaneManager owns; burger and tail sit outside it in
.tabbar. The strip is also the mobile horizontal scroller, so burger +
[+] stay pinned while tabs scroll.
Verified: 289 static-suite tests, 28 harness self-tests, and both
real-page boot harnesses green; mobile render unchanged.
test_renderer_js drives renderer.js behaviorally through node via
vm.runInThisContext — script semantics, which choke on the import/
export syntax renderer.js and utils.js now carry (all 68 tests failed
at harness setup). The harness now evaluates _demodulize()d source:
imports drop (the shared vm context resolves cross-file bindings as
globals, exactly like the pre-module classic scripts) and export
keywords peel off.
Deliberately NOT switched to dynamic import(): the mermaid harness
pokes renderer-internal state (_mermaidState = 'ready') that script
evaluation exposes but a real module would encapsulate. Module
semantics are covered by test_shell_js's .mjs parse sweep; these
tests pin renderer behavior.
Review follow-ups: the new rail collapse and mobile drawer had no
committed guards (the repo pattern is per-step string assertions in
test_shell_js.py) and openPopupMenu — now load-bearing for both the
tab dropdown and the footer user menu — was unpinned.
- test_rail_collapse_glyph_strip: persistence key, toggle +
aria-controls, class-flip seam, cpill-label/manage-glyph companions,
52px desktop-scoped CSS.
- test_mobile_drawer_off_canvas: burger, scrim, rail-open flip,
pane-activation auto-close, off-canvas translateX + visibility:hidden.
- test_popup_menu_shared_helper: the export + both consumers (the user
menu's prefer-up path included).
- shell.css: the 769/768 media blocks are a matched pair CSS cannot
express as a shared token — both now carry a cross-referencing
change-both comment.
Review finding (critical): the ESM migration made cards.js a deferred
module, but both classic app.js bundles built their saved-list tables at
TOP LEVEL — const COORD_COLUMNS = [SavedColumns.name(), ...] and
const _coordTable/_wsTable = createSavedTable({...}) execute at parse
time, before the window bridges exist. ReferenceError aborted each
bundle before it could define TS_APP.boot, so neither deployment booted.
(The earlier consumer audit caught bare top-level CALLS and IIFE bodies
but excluded declarations — missing initializers with side effects.)
Construction moves into _initSavedCoordTable()/_initSavedWsTable(),
called first from each boot path (substrate modules have evaluated by
then). The two typeof-undefined guards become null-checks (a let
binding passes typeof). Verified end-to-end with real-page load
harnesses: both index.html script chains (real app/admin/governance +
module substrate, network mocked) boot to a mounted shell with zero
uncaught errors — console over loopback HTTP (the coordinator dynamic
import needs real URL resolution), standalone over file://.
Dead-code removal, all provable (no JS creator / no reachable caller):
- interactive.js drops the !this._embedded branches: focus tracking, the
right-click context menu, and the header with split/close buttons all
referenced shell globals (setFocusedPane, splitPane, splitRoot,
showPaneContextMenu, countLeaves, closePane) that exist nowhere since
the step-6 fork collapse — reaching them was a guaranteed
ReferenceError. The embedded flag goes with them (every pane is
L-shell-hosted; pane--embedded is now unconditional), as do the
call-less updateWsName() and the host adapter's getWsName seam.
- ui/static/style.css drops the orphaned split-pane/tab-bar vocabulary:
.ws-tab*, #new-tab-btn, #split-btn, .split-handle, the pane-header/
action-button block, the unused dropdown-in keyframe, and the dead
entries in the reduced-motion list.
- ui/static/app.js drops the retired settings-gear menu remnants (state
vars for a builder that no longer exists + an always-false Escape
guard) and fixes a real crash: hideNewWsModal() focused the removed
#new-tab-btn unguarded, throwing a TypeError on every create/fork
modal close; focus now returns to the shell's [+] new-session button.
- pane.js exports openPopupMenu — items, positioning (flip + clamp),
dismissal, aria-expanded mirroring, and arrow-key roving in one place.
The tab-action dropdown delegates to it, and shell.js's footer user
menu replaces its hand-rolled duplicate (which lacked Tab-close and
arrow roving — it inherits both).
test_interactive_pane_js.py: the embedded-gate pins flip to retired-
symbol pins (the gate is gone, not gated).
utils/toast/kb/cards/auth/renderer/composer/composer_attachments/
composer_queue/status_bar convert from classic scripts (implicit globals,
IIFE wrappers) to ES modules with explicit exports. Parse-time
cross-dependencies become real imports (auth/kb/cards -> utils,
auth/cards -> toast, cards -> auth, renderer -> utils), which deletes the
implicit script-order contract those files relied on. utils stays
import-free (bottom of the graph); its two upward calls (setMarkdown ->
renderer, export -> toast/auth) late-bind through window at call time to
avoid import cycles.
Each module installs a transitional window bridge for the still-classic
bundles (console app/admin/governance, ui app, inline onclick=), which
only touch the globals at boot/event time — verified by a column-0 /
IIFE-body audit of all four consumers, and including the audit-missed
initLogin() that both app.js boot paths call. theme.js stays classic:
deferring it would flash the wrong theme before first paint. Vendored
katex/hljs/mermaid stay classic and lazily typeof-guarded.
interactive.js and shell.js drop their bare-global reads for real imports
(authFetch, showToast, Composer, StatusBar, queue/attachment controllers,
streaming renderer, setMarkdown). The three HTML entries load the
substrate as module tags (same positions, same version_html stamping);
classic admin/governance/app still parse first, modules evaluate before
shell.js calls TS_APP.boot().
Tests: auth/kb/utils move from test_app_js's classic node-check sweep to
test_shell_js's module-semantics sweep, which now covers all 15 shared
modules (sink scan excludes renderer.js, the sanctioned HTML producer;
the no-var ratchet covers the var-free subset). The const-reassign guard
re-includes the converted files plus the shell modules.
The L-shell rail gains its two deferred responsive modes:
- Desktop collapse (user preference, localStorage turnstone_interface.rail):
the rail shrinks to a 52px glyph-only strip — live Tier-1 state glyphs
remain the navigation, cluster pills stack as glyph+count, Manage becomes
one gear row opening the Admin pane, children flatten to peer glyphs.
Title attrs (now set unconditionally) carry the names; aria-labels were
already complete.
- Mobile drawer (max-width 768px): the rail leaves the grid and overlays
off-canvas at full width behind a scrim. Burger in the tab bar opens it
(focus moves into the rail); Escape (focus returns), scrim tap, or any
pane activation closes it. Closed drawer is visibility:hidden so its
buttons leave the Tab order. The collapse preference lies dormant here.
- Tab titles render in an ellipsizing span capped at 240px (48vw mobile)
instead of growing the tab unbounded; tab bar scrolls horizontally on
mobile.
A dead interactive controller's base goes stale once its node loses or
re-homes the ws, but menuBase() returned it first — so the close/delete
404-as-success lanes could silently drop a tab whose session is alive on
the node it re-homed to. Mirror the revive path: when isDead(), lead with
the live Tier-1 node and fall back to the stale base only when the ws is
gone cluster-wide (its 404 then correctly reads as "already closed").
Two reported console bugs, one shared root: a pane can outlive its
session, and nothing brought the two back together.
Reconnect: an interactive pane whose stream died (ws closed/evicted
elsewhere, node restart, re-home) could never reconnect while its tab
existed — openPane() on an existing pane was focus-only, the
controller's connect() is one-shot, and its 5s recovery loop re-dialed
the SAME node forever (infinite 404 polling through the console proxy).
The only workaround was closing the tab before resuming.
- createInteractivePane now tracks terminal failure: 3 consecutive
CLOSED recovery beats -> give up (stream closed, timers + any pending
history load invalidated, status bar "Disconnected", opts.onDead
fired once). host.onStreamOpen (new hook) resets the counter;
isDead()/markDead()/base join the controller surface; onLogin
ignores a dead controller — revive owns recovery, so a deliberately
closed session is never resurrected by a timer.
- PaneManager.openPane fires pane.onReopen(extra) when it targets an
ALREADY-OPEN pane — the explicit-intent signal (saved-list resume,
rail row, child link) that activate() can't carry (hooks no-op on the
active pane, and onActivate also fires on plain tab switches).
getPane() added for cross-cutting lifecycle signals.
- The shell paints a click-to-reconnect banner on give-up — and
immediately on Tier-1 ws_closed via the new
TS_SHELL.notifySessionClosed seam (the console keeps the tab, unlike
the standalone's auto-close, so the conversation stays readable).
Reopen/banner-click revives: tear down the dead controller,
re-resolve through the origin-first POST /open lane, rebuild. The
forced resolve skips BOTH beginConnect fast paths (a stale Tier-1 row
must not bypass /open) while a live node leads the hint chain (an
origin-first /open then reuses a genuinely-live session instead of
loading a duplicate on the old meta node). The standalone lane POSTs
its local /open on revive too — /events 404s on an unloaded ws.
- Coordinator parity: the factory exposes reconnect() (acts only on a
missing/CLOSED stream; OPEN is healthy, CONNECTING is already being
worked) and the pane's onReopen drives it — the saved-list resume
POSTs /open before openPane, so a fresh stream is all it needs.
Tab menu: a node-proxied interactive pane's dropdown gated every verb
on classic globals that only exist in ui/static/app.js, so the console
got a nearly-empty menu whose one surviving verb (Export) hit the
console origin and 404'd. convTabMenu gains a base-aware fallback lane:
verbs POST against the pane's OWN transport base (controller's exact
base -> persisted node hint -> live Tier-1 node; a verb is omitted
while no base is resolvable — never aimed at the wrong origin).
Close/Delete confirm first (window.confirm, the coordinator precedent)
and treat 404 as intent-satisfied (nothing left to stop/delete -> drop
the tab). exportWorkstreamDownload takes the base. The standalone
keeps its globals lane (incl. Fork) byte-identical, and an empty verb
section no longer renders a leading separator.
Verified: 189 JS-pin tests; two headless-Chrome live-DOM harnesses
driving the real modules — console 16/16 (connect -> ws_closed ->
banner -> reopen revives on a new node with the fresh hint -> give-up
stops retrying -> live-node-led resolve), standalone 10/10 (globals
menu intact, revive POSTs /open exactly once, no cluster resolve).
CI lock-check failed: the Fable 5 commit raised the anthropic floor to
>=0.108 in pyproject.toml but uv.lock still recorded >=0.39 / resolved
0.107.1. Regenerate the lock: anthropic 0.107.1 -> 0.108.0, specifier
0.39 -> 0.108 (no transitive changes).
Also address the Copilot review nit: the operator-instruction trust
declaration docstring wrote the fence marker as <system-reminder_<nonce>>;
align it to the emitted and project-standard <system-reminder_{nonce}>
notation.
- claude-fable-5 capability entry: 1M context / 128K output, adaptive
thinking (summarized display), effort low..max incl. xhigh, no
sampling params, web + tool search, vision, reasoning replay, native
mid-conversation system messages
- document the Fable 5 wire quirk at the capability table: an explicit
thinking={"type": "disabled"} is a 400 on this model; the adaptive
branch never emits "disabled", so adaptive-or-omitted is preserved
- widen the native mid-conversation-system comments from opus-4-8-only
to opus-4-8 + fable-5 (protocol, provider, tool_advisory, prompts,
session)
- raise the anthropic SDK floor 0.39 -> 0.108: 0.39 predates every
named kwarg the provider sends (output_config 0.77, top-level
cache_control 0.83, mid-conversation system blocks 0.105); 0.108
adds claude-fable-5
- tests: capability assertions for claude-fable-5 + dated-variant
prefix match
The pattern attribute on the MCP server-name and model-alias inputs used an unescaped hyphen in its character class. Browsers compile the HTML pattern attribute with the RegExp `v` flag, under which a literal `-` must be escaped — the class failed to compile, so the browser silently dropped the constraint and disabled client-side validation (Firefox). Escaping the hyphen leaves the matched set unchanged and consistent with the server-side ^[a-zA-Z0-9._-]+$ validators.
Add an inline-SVG data: URI favicon to the console, coordinator, and ui entry points so page loads no longer 404 on /favicon.ico. A data URI needs no new static route and survives the /node/{id} proxy path rewrite.
All findings validated against source before fixing; behaviour-preserving:
- coordinator.js: drop the unused `stripAnsi` import; `updateStatusBar` early-returns
on a null evt, so `(evt && evt.effort)` is simplified to `evt.effort` (no
redundant guard).
- pane.js `_onTablistKeydown`: drop the dead `let j = i` initial value — every
branch reassigns j before `tabs[j]` is read (the no-match case returns first).
- rail.js: collapse the redundant `ws.parent_ws_id ? "interactive" : "interactive"`
ternary to `ws.kind || "interactive"`. Tier-1 always stamps `kind`
(console/static/app.js defaults it to "interactive"), so the fallback was dead
and the parent-based arm would have mis-tagged a standalone interactive — the
single default mirrors the snapshot's own and is behaviour-identical.
- status_bar.js: correct the stale JSDoc — it's a THREE-cell bar now (tokens /
tools / turns); the model cell moved to the composer chip.
- ui/static/index.html: the tool-approval `a` shortcut help read "Always approve";
align it to the button language "Approve all".
Multi-stage review (find → verify → sanity) of b8914854 found one critical
bug plus four minor + one nit; all confirmed against source and fixed:
- CRITICAL — the interactive launcher's "Specific node" pick was unusable:
selecting a node fired the composer `change` event → onChange →
_applyLauncherFields → _populateLauncherNodes → setOptionChoices, which
rebuilds the <select> and reset it to the placeholder, wiping the selection
the instant it was made (submit then failed "Choose a node…"). Fix:
_populateLauncherNodes snapshots the current pick before the rebuild and
restores it after (setOptionValue does not dispatch `change`, so no loop).
- perf — every interactive open blocked first paint on a POST /open round-trip,
even on the hot rail / active-row paths where the ws is already live. The
pane now connects DIRECTLY when the Tier-1 snapshot already names the owning
node; only the dormant / reload case (snapshot empty) resolves + opens. This
resolves the uniform-vs-gated /open question left open last change; refresh
safety is unchanged (a reload activates before the snapshot lands → nodeForWs
null → resolve path).
- bug — an errored resolve (capacity / no node free) had no in-place retry
(re-clicking the active tab is a no-op); the error status line is now
click-to-retry.
- quality — resolveInteractiveNode surfaced each failure twice (toast + in-pane
line) with drifted wording; dropped the toasts, the in-pane status line is the
single source of truth.
- quality — corrected a setOptionFieldVisible comment that cited a nonexistent
"flex/grid rule" (the row is `display: contents`).
- nit — buildController skips the redundant sessionStorage re-persist when the
resolved node already matches the persisted hint.
Guard tests extended (bug-1 capture/restore, the live-direct path); the
behavioral harnesses were strengthened to fire a real selection rebuild and to
exercise the live-direct vs reload-resolve split that the first round missed.
Workstream-lifecycle bugfixes on the L-shell:
- Node-proxied interactive panes now SURVIVE a browser reload. On first
activate a pane resolves its owning node and (re)opens the session there
before streaming — the node /events stream 404s on a ws not loaded on its
node, so a rehydrated pane could not just connect blind. Resolution is
origin-first via the new TS_APP.resolveInteractiveNode seam (POST /open with
a rendezvous /route fallback). PaneManager now persists a pane's resolved
nodeId as opaque meta and hands it back on rehydrate, so a reload restores
the pane onto the SAME node even before the Tier-1 snapshot has populated —
the exact timing that used to strand it on base="" (the console, not a node).
- Both launcher personas open the new session as a PANE, not a full-page nav
(coordinator -> coordinator pane; interactive -> node-proxied pane); the
full-page nav stays only as the shell-absent fallback. Every interactive
entry point (create, active row, rail, saved row, child link, reload) now
funnels through one resolve-open-connect path, folding away the bespoke
restoreInteractiveSession helper.
- The interactive launcher gains a node-selection strategy (Least loaded |
Specific node, with a live node picker fed from the cluster snapshot) and a
persona-aware task hint — the shared composer no longer shows
"...coordinator orchestrate?" when the interactive persona is selected.
Guards updated to pin the new wiring; the stale console landing test (asserting
the renovation-retired bottom-bar node picker) is corrected to the rail.
The parallel tool-batch kicker strings ("Parallel · N tools", "Evaluating ·
Parallel N", "Running · Parallel N", "⚠ Approval · Parallel N") and the "1/N"
index label were duplicated byte-for-byte across interactive's three render paths
and the coordinator's kicker state machine, with no shared source enforcing the
visual parity the two surfaces require. Extract batchKicker(state, n) +
indexLabel(idx, n) into conversation.js (both files already import it) and route
all 13 sites through them, so a future label tweak can't silently diverge them.
Byte-identical, harness-verified: the rendered kicker + 1/N labels are unchanged.
From the multi-stage review of this session's changes (all minor):
- bug-1: the footer user-menu's deferred document-listener attach now bails if the
menu was already closed (closeUserMenu nulls the cleanup ref), closing a latent
listener-leak window.
- perf-1: fold paintConvTabGlyphs + paintConvTabTitles into one paintConvTabs — a
single findWs per stateful tab per Tier-1 render instead of two scans.
- q-2: remove the dead StatusBar.paint modelEl branch + its modelInfo arg (both
callers dropped it when the model moved to the composer chip) and the orphaned
.ws-sb-model CSS rules.
(q-1, the parallel-head string-helper extraction, follows separately.)
After the stacked composer box, the action row's margin-left:auto on the send
button pushed send to the right edge but left the mic stranded on the left next
to the model chip (the mic inserts before send). When the STT role is confirmed
the action row now gets .has-mic, which puts margin-left:auto on the MIC instead
so mic + send form a right-aligned cluster (send flush after the mic); without
STT, send keeps its own auto margin and sits alone on the right.
Verified (headless): with .has-mic the mic moves from x=427 (by the model chip)
to x=906 (38px left of send at 944); css audit at baseline 10.
Move the interactive + coordinator composers to the mock's layout: a compact
rounded composer box with the borderless textarea on top and the
[+] / model·effort chip / send row below (layout:"stacked"). The bordered inner
textarea and the boxed paperclip are gone — the box is the frame, and the attach
is a plain "+" glyph. Scoped via a composer--chat class (the model-chip hosts)
so the home launcher's taller stacked composer is untouched.
Verified by headless render against the mock; css audit at baseline 10, guards
green.
After the collapse fix, parallel tool calls render but the head read
"TOOL web_fetch + 1 more", which implies the rest are hidden. Match the
coordinator's presentation across all three interactive paths (announce /
inline / replay) + buildToolDiv:
- "Parallel · N tools" kicker (renders "PARALLEL · N TOOLS") instead of "Tool"
- the conv-batch--parallel class (the numbered-row connecting rail)
- per-row "1/N" index labels
so the "+ N more" summary now reads as a label, not hidden calls.
Verified in the real console shell (headless): a 2-call batch shows kicker
"Parallel · 2 tools", rows "1/2"/"2/2", conv-batch--parallel, both calls named,
no JS errors.
The real regression behind "parallel tool calls don't show / tool cards get
overwritten, leaving a thin stripe with a coloured pixel on the left": the
.conv-* convergence put an `overflow:hidden` card (.conv-batch) into the
interactive pane's SCROLLING flex-column message list
(.pane--embedded .pane-messages, overflow-y:auto). An overflow:hidden flex
item's `min-height:auto` resolves to 0, so flexbox squished the tool batch to
~2px (just its left border) once the column filled — while plain .msg blocks
(overflow visible) kept their height. That asymmetry is why the COORDINATOR
(different container) and main's old `.ts-approval` block (a .msg, overflow
visible) were never hit, and why it impacted ALL models — it was never a
local-model id-collision (that earlier theory + fix were reverted).
Fix: pin every message child to flex-shrink:0 so the column scrolls instead of
collapsing cards. Reproduced + verified in the real console shell (headless):
the parallel-bash tool batch went from 2px (collapsed) to 244px (full content)
once a multi-turn conversation fills the column. Guarded in test_conversation_css.
Per the BRIEFING the composer is the sole model location, but the model still
rendered in the per-pane status bar. Add a display-only "model · effort" chip to
the chat composer (next to the attach button) and remove the status-bar model
cell from both the interactive and coordinator panes; the status bar keeps
tokens/tools/turns. The chip repaints from the same model + status events, with
effort silent on the implicit "medium"/none (mirroring the status bar's old
suffix rule). A per-session model/effort PICKER is a separate deferred task
(needs a backend override path).
Verified in a real interactive pane (headless): the chip renders with its em-dash
placeholder, the status-bar model cell is gone (tokens/tools/turns remain), the
send glyph is intact, no JS errors; JS guards green, css audit at baseline 10.
The embedded message list stacked a 5px flex gap ON TOP of each .msg turn box's
4px margin-bottom (~9px of dead space between segments — "too thick"). Trim the
container gap to 2px so segments land at a compact ~6px, matching the mock's
gap-only intent without overriding the shared .msg margin (no specificity-audit
flip).
whoami returned only user_id (an opaque uuid), so the rail footer rendered the
uuid. whoami now resolves the user record by id and returns the human
username/display_name (best-effort — a storage miss just omits it). The client
stores data.username (no fallback to user_id: a uuid is worse than the generic
"account" placeholder). Hardened against a malformed user record (isinstance
dict guard) so a bad row can't 500 whoami; test stubs get_user + asserts the
display name is surfaced.
Local OpenAI-compatible models (e.g. DeepSeek) reuse tool-call ids across turns
(call_0, call_1 each turn). The interactive pane resolved a call's card via a
PANE-WIDE first-match messagesEl.querySelector('[data-call-id=...]'), so a later
turn's tool_result / verdict / warning / output-chunk landed on an EARLIER turn's
card — corrupting it and leaving the current batch's rows empty (a thin stripe).
It also made parallel calls look like they "didn't show" (the head summary sat
over emptied rows).
The pane is strictly serial, so a live result belongs to the MOST-RECENT matching
card. Add a _lastMatch(root, selector) helper and resolve the five live-path
lookups (appendToolOutput x2, appendToolOutputChunk, showOutputWarning,
updateVerdictBadge) to the LAST match instead of the first. The replay/history
path was already scoped to its block and is untouched.
Known limitation: if a model emits ALL parallel calls in ONE turn sharing the
same id, they still collide within the batch — a separate source-level issue.
The chat composers (coordinator + interactive) now render an up-arrow send glyph
instead of the text label, matching the mock. Opt-in via opts.sendGlyph so
creation-form composers keep their text label; the visible glyph is constant
while the textual sendLabel stays the aria-label and drives setBusy's a11y
rotation (setBusy no longer overwrites the glyph).
Verified in a real interactive pane (headless): the send button is the up-arrow
glyph with aria-label "Send message", no JS errors.
Conversational tabs froze at their open-time title — wsTitle(id), which is the
id-slice when the session isn't in the Tier-1 snapshot yet (e.g. a just-restored
saved session) — and never updated, so the tab read as the raw id instead of the
saved name.
Add PaneManager.setTabTitle(paneId, text) (mirrors setTabGlyph: rewrites the tab's
title text node in place + pane.title for a later rebuild) and a
paintConvTabTitles(pm) Tier-1 hook alongside the glyph repaint. It only UPGRADES
a tab to a real name (ws.name || ws.title) — never flickers a known name back to
the id if the ws blips out of a single frame.
Verified in the real console shell (headless): a named ws shows the name at open,
a dormant ws shows the id-slice then upgrades on repaint, no JS errors.
Mock-review batch (4 items):
- Footer: drop the redundant Admin button — Manage already surfaces every
admin tab, so only the theme toggle relocates from the retired header.
- Footer: the user chip now shows the real logged-in user. whoami returns
user_id but _storePermissions only persisted permissions, so the chip was
stuck on the "account" placeholder; it is now stored as ts.username and the
chip repaints once whoami lands (Tier-1 render hook).
- Footer: Log out moves into a click-menu on the user chip (reuses the
.tab-menu popup chrome; the item clicks the hidden #logout-btn so auth.js
stays the single owner of logout and its in-flight-refresh race guards).
- Manage: groups start collapsed instead of auto-expanding the first one —
the rail is a discovery map, not a wall of open links.
Verified end-to-end in the real console shell (headless): chip is a button
showing the user, no admin button in the footer, the menu opens with Log out
which invokes logout, outside-click/Escape close it, no JS errors.
Saved INTERACTIVE sessions opened from the console did nothing useful.
Coordinators rehydrate because their activation POSTs /open first; the
interactive branch only passed the saved DTO's node_id to openPane and
bailed "Session node unknown" when falsy. Even with a node_id nothing
streamed: the per-pane SSE /events 404s on a not-loaded ws and /history
alone does not rehydrate, so the session was never loaded onto a node.
New restoreInteractiveSession (console app.js) is ORIGIN-FIRST: POST /open
to the session's origin node (the DTO node_id, stamped at create) and pin
the pane there. This keeps node affinity and — load-bearing — REUSES a
session already live on its origin instead of loading a duplicate copy
elsewhere; the interactive pane talks directly to /node/{id} for every
verb, so the load-node and the pane-node must match (no split-brain).
Only when the origin is gone (POST /open 404 = not in registry / 502 =
unreachable) do we re-home onto a fresh rendezvous node via
GET /v1/api/route (the router skips dead nodes; persistence is shared
ws_id-keyed Postgres, so any live node is state-safe). Capacity (429) and
permission (403) are surfaced, not silently re-homed. No origin (legacy/
CLI rows) routes straight away. Mirrors the coordinator open-before-
navigate and the standalone dashboardResumeSession; the active-row path
(already-loaded sessions) is untouched.
Bugs surfaced by the live console (the headless harnesses stubbed data, so these
only showed against a real cluster):
- Saved-session AND active/filtered-table row clicks did full-page nav (interactive
-> /node/{node}/?ws_id=, coordinator -> /coordinator/{ws}) instead of opening an
L-shell tab. Route both through window.TS_SHELL.panes.openPane (interactive =
node-proxied pane, coordinator = coordinator pane). Full-page nav stays only as
the shell-absent fallback. (The broader ?ws_id= URL-pattern cleanup is deferred to
its own session.)
- The cluster health pills wrapped ("idle" fell to a second line) in the 266px rail.
Tightened gaps + font + nowrap so all three fit one line (verified at 266px).
- The [+] new-session button was a no-op when the Dashboard was already active
(showHome focuses it, no visible change). It now also focuses the launcher
composer via a new TS_APP.focusLauncher seam — "new session" lands you ready to
type.
- The cluster node list wasn't collapsible (unlike the Manage groups). The "Nodes"
header is now a toggle (button + rotating caret), state persisted across the
rail's Tier-1 re-renders.
Verified: node + prettier clean; CSS audit baseline; 89 JS guards; cluster-pill +
node-collapse render harnesses (pills one-line, toggle hides/shows + caret rotates);
wiring harnesses errs:[] (no regression).
Three shell-spine P3s from the review:
- Consolidated the three near-identical Tier-1 ws-scans (wsTitle / nodeForWs /
stateForWs each re-walked getClusterState -> nodes -> workstreams) into one
findWs(wsId, skipConsole) helper + three thin wrappers. nodeForWs keeps the
console-pseudo-node skip (coordinators live there, must not be node-proxied);
the other two scan all nodes. Also restored the convTabMenu doc comment that an
earlier glyph-helper insertion had orphaned above stateForWs.
- Simplified PaneManager.activate's dead/misleading guard (_activeId===paneId ||
!has, with a nested re-check) to the equivalent `if (!has) return;`.
- rehydrate now counts a pane restored only when openPane actually returns one — an
auth-gated (denied) coordinator pane returned null but still set restored=true,
which would suppress the Dashboard fallback into a blank shell (latent today: the
non-closable Dashboard is always in the persisted set).
Verified: 28 shell guards; mechanism harness 31/31 (activate/rehydrate/gate); both
wiring harnesses errs:[] (the scans drive the verified tab titles / node-proxy /
state glyphs — console running/idle, standalone full menu). node + prettier clean.
Three console front-door P3s from the review:
- Removed dead module state _lastOverviewJson / _lastNodePickerJson (memo caches
for the removed renderStatusBar / renderNodePicker; only declared, never read).
- Dropped the unreachable popstate view==="admin" branch — Admin is a rehydrated
PaneManager pane now, nothing pushes {view:"admin"}, and Back-from-admin already
lands on the dashboard via the home/filtered path.
- _createInteractive: added an else for a 200 without target_node so a server-
contract drift surfaces an error instead of silently stranding the user (the
branch is currently unreachable — the node is validated non-empty server-side —
but it was a silent failure mode).
Verified: node + prettier clean; 28 shell guards; console wiring harness errs:[].
getFocusedPane() has been a permanent `null` stub since the fork collapse
(PaneManager owns focus; interactive.js owns approval keys). That left ~90 LOC of
unreachable pane-dependent code — and it's exactly where the P1 closeTabDropdown
crash hid. Removed all 4 sites:
- the global-keydown Escape-cancel branch + the whole inline-approval keybinding
block (still referencing the retired .ts-approval-feedback / .verdict-* vocab);
every LIVE shortcut (Escape->dashboard, Ctrl+D/T/1-9, Ctrl+Shift+E/F/X, Ctrl+W)
is kept;
- the dashboardSubmit optimistic-echo block (interactive.js echoes its own turn);
- the new-ws modal model prefill (curModel is always "");
- the stub itself.
Also fixed _formatAttachSize: called 4x (chip size + over-cap error) but defined
nowhere -> a ReferenceError that broke file staging (pre-existing on main; flagged
by the review). Defined a local B/KB/MB formatter mirroring composer_attachments's
IIFE-local formatSize.
Verified: node + prettier clean; 61 app guards; standalone harness errs:[]; all
live keyboard-shortcut verbs asserted present after the splice.
The header removal (5e.2e) left the wait_for_workstream progress surface inert:
_waitIndicatorEl() mounts only into the deleted #coord-header and returns null, so
the whole #14 wait-indicator (handleWaitStarted/Progress/Ended, the activeWaits
Map, _renderWaitIndicator, the reconnect-path clear, the 3 SSE switch cases) ran
but rendered nothing. Ripped it (~110 LOC) + the orphaned .coord-wait-indicator
CSS rule. (The observability loss was a deliberate, tested design decision —
test_coordinator_page.py pins the header absence.)
The review's broader coord-chrome dead-CSS list was a false positive on
verification: `.task-row .status-done/.status-blocked` are LIVE (applied via a
dynamic `"status-" + status` class), `ts-spin` is live (coord-chrome.css:209), and
the rest (.coord-tool-*/.judging/.feed-item/.topbar) appear only in prose comments.
Also (review P3-1): clear aria-busy in _unsetBatchRunningIfAllResults so a batch
that completes via tool_result only (judge + gate bypassed, early-paint on) stops
announcing "busy" to screen readers after completion.
Verified: node + prettier clean; 16 coordinator guards; CSS audit baseline;
coord-keys harness all green (approval keys intact after the rip).
Two lower-severity findings from the full-branch review:
- Backend doc divergence (session_routes.py + console/server.py): the comments
justifying interactive's `state=None` saved listing claimed "the storage layer
already excludes state='deleted' tombstones" — but neither storage impl has such
a filter. It's incidentally safe because delete is a HARD delete (no `deleted`
tombstone is ever written), NOT because of a filter. Corrected both comments to
state the real mechanism + flag that a future soft-delete tombstone would need an
explicit `state != 'deleted'` guard here.
- Dead CSS: the entire #cluster-status-bar / .csb-* block (387 lines) was orphaned
— its HTML element + all JS writers were deleted earlier in this branch and
nothing reuses the vocabulary (grep-confirmed across console+shared). Removed the
main block (per-selector verified all-csb before splicing); the 9 residual csb
rules inside two MIXED @media blocks are left as a safe over-keep, matching the
5e.2f dead-CSS method.
Verified: ruff clean; CSS audit at baseline (zero new flips); braces balanced;
prettier clean; console builds errs:[].
The full-branch pre-push review (6 subsystem slices) found 1 P1 + several real P2
bugs; the confirmed functional ones, fixed here:
- P1 (standalone): two LIVE keydown branches called the deleted closeTabDropdown()
-> ReferenceError that silently killed Ctrl+Shift+E/F/X (edit/fork/delete) and
Ctrl+W (close) before reaching the verb. Removed the dead calls.
- coordinator (this branch's step-7 keys): the deny branch fired on any `d` with no
modifier guard, so Cmd+D (bookmark) / Ctrl+D / Alt+D silently DENIED a pending
batch. Early-return on ctrl/meta/alt (Shift+A still resolves).
- shell.js: window.TS_LOGIN was defined AFTER rehydrate(), so a RESTORED
conversational pane silently skipped its re-auth Tier-2 reconnect (onActivate saw
no TS_LOGIN and never re-fired). Moved the fan-out (+ TS_SHELL) above rehydrate.
- shell.js: TS_LOGIN.subscribe had no unsubscribe -> a closed pane leaked its
controller closure across open/close/re-login. Added unsubscribe + call it in
both onClose hooks.
- standalone dashboard: updateTabIndicator dropped its `extra` arg in the fork
collapse, so a watched row's STATE/TOKENS/CTX went stale on every ws_state tick
until a full reload. Ported the in-place row patch from main (sans the retired
.ws-tab indicator).
Also dropped a redundant index.html NODE-gate comment (the CSS rule + loadDashboard
already document it) that had tipped a fragile 4000-char structural guard.
Verified: 105 JS guards green; mechanism (31/31) + coord-keys (all) + wiring
harnesses errs:[]; node + prettier clean.
The merge-gate designer pass on the CONSOLE persona (coordinator + interactive,
caps on) found 0 P1 — ship-quality — and 4 small CSS P2s, applied here:
- P2-1 (non-optional AA): the pending approval card's "APPROVAL NEEDED" kicker was
4.17:1 in light (sub-AA on the operator's primary decision signal). Darken off
raw --warn via a theme-tracking color-mix toward --ink-2 (~5.5:1 light; dark
stays warm + passing). In conversation.css, so both personas benefit.
- P2-2: rail micro-labels (.sec-label/.nlabel/.node-row .ver/.grp-head .gcount)
were --ink-4 <=11px = ~3.9-4.0:1 in light. Light-scoped --ink-3 (~6.5:1), the
same escape hatch as .tab-menu-key; drift versions keep --yellow.
- P2-3: the tab-dropdown separator was imperceptible. Use the MORE-visible hairline
per theme (--hair-2 dark / --hair light) — the designer's suggested tokens were
reversed; corrected against the actual hex values.
- P2-4: the relocated "Reconnecting..." rail-conn read as an alarming top-of-rail
peer of Cluster health. Quiet it (10px, collapses when connected) + --warn (not
the near-error --yellow) on disconnect.
- P3-5: the Manage active-tab marker (inset --hair-2) nearly vanished in light ->
--ink-4 (reads in both themes, still not the amber `.open` of a live session).
Verified: CSS audit at baseline (zero new flips); 33 conversation/shell guards
green; node + prettier clean. Other P3s noted (model-chip disclosure is the locked
"model lives only in the composer" decision; node-row rhythm / verdict-expand minor).
The settings MODAL backdrop (#settings-overlay) died when MCP connections moved to
the Manage > Connections pane (step 6) — the #settings-mcp-* content rules are
reused there, but the overlay wrapper is gone. Remove the closed loop of dead-but-
mutually-alive references: the CSS rule, the stale "settings-overlay" modal-id
array entry, and the guarded getElementById no-op in the settings-close path.
The other fork-collapse dead-code (the getFocusedPane stub + its null-gated
branches, the partially-retired settings-gear) is woven into still-live handlers —
deferred to the merge-gate /review for a systematic sweep with the review findings.
Verified: 0 settings-overlay refs remain; node + prettier clean; CSS audit at
baseline; standalone harness errs:[].
The console twin of the interactive.js approval-key fix: the coordinator's
tool-batch card shows kbd hints (Enter approve / D deny / Shift+A approve-all) but
they did nothing — the keys were never wired (the standalone routed approval keys
through the app.js global keydown + getFocusedPane, retired in the fork collapse).
Add a pane-owned keydown on `root` that resolves the current pending batch:
- _currentPendingBatch() finds the last .conv-batch with a still-pending
[data-needs-approval="1"] row whose actions aren't already disabled — the
in-flight double-fire guard (a second key during the resolve is a no-op).
- Enter -> approve, D/Esc -> deny, Shift+A -> approve-all, routed to the existing
_resolveBatchAction path.
- A focus guard skips when an input/textarea/contenteditable is focused, so the
keys never hijack composer typing (the coordinator has no feedback field, unlike
interactive, so no feedback special-case).
Verified end-to-end against the real coord pane (keydown -> _currentPendingBatch ->
_resolveBatchAction -> approveWorkstream -> postJSON -> authFetch, stubbed at the
HTTP boundary): Enter/D/Shift+A fire the right verb, the double-fire + focus guards
hold, errs:[] + a coordinator JS guard. Live keypress confirm rides the merge gate.
The WORKSTREAMS table showed a NODE column (a multi-node console-ism) on the
single-node standalone server, where every row reads "local". Drop it: remove the
NODE header span + skip the node cell in loadDashboard, and gate just that table to
6 columns by overriding the --dash-grid VARIABLE (not the grid-template-columns
property — so it stays the var's single declaration, no cascade flip), scoped by
id, so the shared --dash-grid and the Saved Workstreams table keep their 7-col
layout. Matches the brief's capability-derived-affordances thesis (the rail drops
Cluster the same way). Designer P2 (pre-existing, not a step-6 regression).
Verified: standalone DOM shows 0 dash-col-node + the saved table intact; CSS audit
at baseline (zero new flips); node + prettier clean.
The brief defers mobile: the rail -> off-canvas drawer matches no current DS scope
(the DS is desktop-only; the console's mobile drawer was retired in step 3b).
Record the decision in-code at the .app layout seam (the brief is local-only) where
a future max-width @media would slot in. Verified narrow viewports (720px wide)
are cramped, not broken — no silent mobile-support claim.
openPane now auth-gates pane CREATION via an optional per-type canOpen predicate
(deny -> no pane; focusing an already-open pane is never re-gated). PaneManager
stays generic — it holds a _gates map and consults canOpen/onDeny; the shell
supplies the gate.
The coordinator type gates on the admin.coordinator scope — the SAME
sessionStorage-backed _hasCoordPermission helper the launcher + saved-list use.
Because every coordinator open path (rail click, child-link, rehydrate, [+]
launcher) routes through openPane, this gates them all at once — closing the gap
where a rail click opened a coordinator pane a user lacked scope for (it then
404'd server-side). Perms live in sessionStorage so they survive a refresh →
rehydrate gates correctly (an operator's persisted coord pane restores, a
non-operator's is skipped). The backend enforces the scope too; this just avoids
opening a doomed pane.
Verified: 31/31 mechanism harness (gate allow/deny/onDeny) + real-stack console
wiring (authorized operator opens the coord pane; a no-permission stub denies a
new coord pane, gateDenied:true, errs:[]) + a shell JS guard + CSS audit baseline.
The right-floated tabbar tail (empty since the scaffold) gets a [+] button that
focuses the persona launcher — the Dashboard pane hosts the unified
coordinator/interactive launcher, and a new session needs a task prompt, so "new
session" composes there. Cross-deployment via window.showHome (both the console
and standalone expose it) with a pm.openPane("dashboard") fallback; reuses the
scaffold's .tab-add styling. Auth stays the launcher's concern (it gates each
persona option), so focusing it is always safe.
Verified: renders in the real shell (standalone harness DOM) + a shell JS guard.
Conversational tabs now show a live shape+colour state glyph (● ◐ ⚠ ✗ ○) instead
of the static ◆/○ placeholders the header removal (5e.2e) left behind — driven by
the SAME Tier-1 source + builder the rail uses, so tab and rail always agree.
- rail.js: export the glyph() builder (one source of truth for the mapping).
- pane.js: ShellPane.stateful + PaneManager.setTabGlyph/statefulTabs — generic
(PaneManager owns no glyph vocabulary; the shell passes the built element).
A stateful pane builds no static glyph; the shell paints a live .ui-glyph.
- shell.js: stateForWs() reads the Tier-1 snapshot; paintConvTabGlyphs() repaints
every stateful tab on each Tier-1 render (subscribed to TS_APP.onRender) + per
pane on activate. Coordinator + interactive panes are now stateful.
- shell.css: .tab .tab-glyph spacing (static + live); live glyphs keep their own
.ui-glyph-* state colour (no .tab .glyph override).
SINGLE WRITER: the tab glyph is written only by the Tier-1 path (the pane's Tier-2
stream drives its body, not the tab) — no two-tier race, no stale open-time
placeholder on reconnect (BRIEFING L144-147). A coordinator-telemetry-parity gap
(open Q#2) would stale tab + rail equally, consistently.
Verified: 27/27 mechanism harness (8 new glyph asserts) + real-stack wiring
harnesses (console coord ui-glyph-running / int ui-glyph-idle; standalone int
ui-glyph-running — matching the stubbed Tier-1 state, errs:[]) + 26 shell JS
guards + CSS audit at baseline.
PaneManager tabs gain a caret opening a generic, keyboard-navigable action
dropdown — recovering the affordances the pane-header removal (5e.2e) dropped.
The mechanism is generic; the item set is pane-type AND deployment derived.
- pane.js: the caret (a <span>, not a nested <button>) + _openTabMenu/_closeTabMenu
— singleton, right-anchored under the caret with overflow flip + viewport clamp,
Arrow/Home/End/Esc/Tab nav, ContextMenu/Shift+F10 + right-click open.
- shell.css: the .tab-menu chrome promoted to the SHARED sheet (both deployments),
recovered from the retired .ws-tab-dropdown design but translated onto the DS
token vocabulary (--panel-2/--hair-2/--ink-*/--err).
- shell.js: convTabMenu wires each type by capability/feature-detection —
coordinator: Export · Close pane · Close workstream (its controller's
closeSession — the Export + end removed from its header land here)
standalone interactive: Refresh/Edit/Fork · Export · Close pane ·
Close workstream · Delete (classic ui/static globals)
console interactive: Export · Close pane (those globals are standalone-only)
admin: Close pane
Three-verb close is load-bearing: Close pane (drop tab) != Close workstream
(stop session) != Delete (destroy + unsave).
Designer-reviewed both personas, dark+light: resting danger cue on Delete (never
colour-alone), elevated --panel-2 surface, accent-wash hover, light key-hint AA,
viewport y-clamp + max-height.
Verified: 19/19 mechanism harness + real-stack wiring harnesses (all three menus,
errs:[]) + 25 shell JS guards + CSS audit at baseline (zero new flips).