amicus 4.6.1 → 4.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +152 -0
- package/README.md +7 -8
- package/docs/ROADMAP.md +38 -4
- package/docs/configuration.md +18 -12
- package/docs/council.md +7 -2
- package/docs/troubleshooting.md +49 -20
- package/docs/usage.md +30 -3
- package/electron/setup-ui-aliases.js +2 -2
- package/electron/workspace-ui/index.html +3 -0
- package/electron/workspace-ui/live-model.js +146 -2
- package/electron/workspace-ui/workspace-app.js +8 -3
- package/electron/workspace-ui/workspace-panels.js +9 -10
- package/electron/workspace-ui/workspace-render.js +9 -3
- package/electron/workspace-ui/workspace-seats.js +132 -0
- package/electron/workspace-ui/workspace-verbs.js +2 -1
- package/electron/workspace-ui/workspace.css +6 -0
- package/package.json +1 -1
- package/schemas/alias-audit.schema.json +6 -1
- package/schemas/council-run.schema.json +14 -0
- package/src/cli-handlers-council.js +9 -0
- package/src/cli-handlers-doctor.js +25 -7
- package/src/cli.js +4 -0
- package/src/council/presets-cli.js +6 -2
- package/src/council/run-chair.js +55 -6
- package/src/headless.js +119 -9
- package/src/mcp-council-awareness.js +1 -0
- package/src/opencode-client.js +21 -0
- package/src/session-manager.js +6 -2
- package/src/sidecar/fanout-leg.js +2 -2
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/models-probe.js +119 -0
- package/src/sidecar/models.js +81 -6
- package/src/utils/alias-audit.js +71 -1
- package/src/utils/base-url-classify.js +74 -0
- package/src/utils/council-presets.js +6 -2
- package/src/utils/curated-models.js +71 -16
- package/src/utils/doctor-base-url-check.js +41 -0
- package/src/utils/gateway-route-audit.js +16 -3
- package/src/utils/model-fetcher.js +9 -6
- package/src/utils/model-tiers.js +28 -7
- package/src/utils/no-output-backstop.js +48 -0
- package/src/utils/remediation-hints.js +14 -0
- package/src/utils/result-schema.js +29 -2
- package/src/utils/session-metadata-tmp-sweep.js +136 -0
- package/src/workspace/live-normalize.js +1 -0
package/src/utils/model-tiers.js
CHANGED
|
@@ -28,7 +28,13 @@ const { pickCurrent } = require('./quick-picks');
|
|
|
28
28
|
const { isDirectProvider } = require('./provider-registry');
|
|
29
29
|
const { toDefaultAliases, listCuratedRoutes } = require('./curated-models');
|
|
30
30
|
|
|
31
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* Tier pattern table: each tier holds a regex — or an ORDERED regex list,
|
|
33
|
+
* tried first-match-wins — over the model segment after `<vendor>/` (or
|
|
34
|
+
* `openrouter/<vendor>/`). List order expresses preference, which a single
|
|
35
|
+
* regex cannot: ids sort numeric-descending, so `-pro` siblings would
|
|
36
|
+
* otherwise outrank their same-priced base.
|
|
37
|
+
*/
|
|
32
38
|
const TIERS = {
|
|
33
39
|
anthropic: {
|
|
34
40
|
economy: /^claude-haiku-/,
|
|
@@ -36,9 +42,17 @@ const TIERS = {
|
|
|
36
42
|
frontier: /^claude-opus-/,
|
|
37
43
|
},
|
|
38
44
|
openai: {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
// The 5.6 line renamed the flagship into tiers — luna ($0.10/$0.60 per
|
|
46
|
+
// Mtok in/out), terra ($1/$6), sol ($5/$30), each with a -pro sibling
|
|
47
|
+
// priced at its base tier; no bare/-mini/-pro 5.6 ids exist. Owner
|
|
48
|
+
// ruling 2026-08-04: economy→luna, balanced→terra, frontier→sol, each
|
|
49
|
+
// preferring the base name, then its -pro sibling, then the 5.5-era
|
|
50
|
+
// naming so a stale catalog still resolves. balanced's primary is the
|
|
51
|
+
// same bare-or-terra pattern the `gpt` family uses (curated-models.js),
|
|
52
|
+
// so a future return to bare flagship ids is tracked automatically.
|
|
53
|
+
economy: [/^gpt-[\d.]+-luna$/, /^gpt-[\d.]+-luna-pro$/, /^gpt-[\d.]+-mini$/],
|
|
54
|
+
balanced: [/^gpt-[\d.]+(-terra)?$/, /^gpt-[\d.]+-terra-pro$/],
|
|
55
|
+
frontier: [/^gpt-[\d.]+-sol$/, /^gpt-[\d.]+-sol-pro$/, /^gpt-[\d.]+-pro$/],
|
|
42
56
|
},
|
|
43
57
|
google: {
|
|
44
58
|
economy: /^gemini-[\d.]+-flash-lite/,
|
|
@@ -77,9 +91,16 @@ function buildGatewayOnlyAliasMap() {
|
|
|
77
91
|
|
|
78
92
|
const GATEWAY_ONLY_ALIAS = buildGatewayOnlyAliasMap();
|
|
79
93
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
82
|
-
|
|
94
|
+
/**
|
|
95
|
+
* First match wins over a tier's ordered patterns; within each pattern the
|
|
96
|
+
* newest matching id is picked, direct namespace preferred over OpenRouter's.
|
|
97
|
+
*/
|
|
98
|
+
function pickForTier(catalog, vendor, patterns) {
|
|
99
|
+
for (const regex of [].concat(patterns)) {
|
|
100
|
+
const pick = pickCurrent(catalog, '', vendor, regex) || pickCurrent(catalog, 'openrouter/', vendor, regex);
|
|
101
|
+
if (pick) { return pick; }
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
83
104
|
}
|
|
84
105
|
|
|
85
106
|
/** True when the catalog has ANY row (any tier) under this vendor's namespace, in either gateway. */
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/no-output-backstop
|
|
3
|
+
* v4.6.2 PR2 (spec §5, D4): fail a headless leg fast when the model produces
|
|
4
|
+
* ZERO output, reasoning, and tool calls — the "accepted but not serving"
|
|
5
|
+
* class (the v4.6.1 gemini release-gate incident: requests accepted, zero
|
|
6
|
+
* tokens, three suites burned 130s timeouts each to learn nothing).
|
|
7
|
+
*
|
|
8
|
+
* Pure state machine, loop-driven (no timers of its own — the poll loop
|
|
9
|
+
* ticks it): armed at leg start, DISARMED PERMANENTLY by the first
|
|
10
|
+
* `progressed` tick (a 30-90s cold-prefill local model is never affected),
|
|
11
|
+
* fired when the deadline passes with nothing ever observed. `fired` is
|
|
12
|
+
* terminal. `ms <= 0` never arms — 0 is the documented escape hatch, which
|
|
13
|
+
* is why the env resolver uses envNumber (explicit 0 honored) rather than
|
|
14
|
+
* the `Number(env) || default` idiom.
|
|
15
|
+
*
|
|
16
|
+
* PR3's live probe reuses this with a 30s override — ms is an input; only
|
|
17
|
+
* the exported resolver reads the environment.
|
|
18
|
+
*/
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const { envNumber } = require('./env-num');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_NO_OUTPUT_BACKSTOP_MS = 120000;
|
|
24
|
+
|
|
25
|
+
/** @param {object} [env] test seam; defaults to process.env */
|
|
26
|
+
function resolveNoOutputBackstopMs(env) {
|
|
27
|
+
return envNumber('AMICUS_NO_OUTPUT_BACKSTOP_MS', DEFAULT_NO_OUTPUT_BACKSTOP_MS, env);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{ms:number, startedAt:number}} opts
|
|
32
|
+
* @returns {{tick:(progressed:boolean, nowMs:number)=>string, state:()=>string}}
|
|
33
|
+
*/
|
|
34
|
+
function createNoOutputBackstop({ ms, startedAt }) {
|
|
35
|
+
let state = ms > 0 ? 'armed' : 'disarmed';
|
|
36
|
+
const deadline = startedAt + ms;
|
|
37
|
+
return {
|
|
38
|
+
tick(progressed, nowMs) {
|
|
39
|
+
if (state !== 'armed') { return state; }
|
|
40
|
+
if (progressed) { state = 'disarmed'; return state; }
|
|
41
|
+
if (nowMs >= deadline) { state = 'fired'; }
|
|
42
|
+
return state;
|
|
43
|
+
},
|
|
44
|
+
state() { return state; },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { resolveNoOutputBackstopMs, createNoOutputBackstop, DEFAULT_NO_OUTPUT_BACKSTOP_MS };
|
|
@@ -87,6 +87,20 @@ const REMEDIATION_HINTS = Object.freeze({
|
|
|
87
87
|
*/
|
|
88
88
|
sweepSessionIndexTmp:
|
|
89
89
|
'amicus doctor --fix (sweeps orphaned .sessions-index.json.*.tmp files left by an interrupted write)',
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Orphaned per-session metadata.json.*.tmp files (v4.6.3 PR3 Task 3 / D8):
|
|
93
|
+
* same producer shape as sweepSessionIndexTmp above, one level down — a
|
|
94
|
+
* kill between an atomic write's tmp-write and rename leaves a stray temp
|
|
95
|
+
* file in a session directory forever. `doctor --fix` sweeps files older
|
|
96
|
+
* than 60s (never a live writer's ms-lived tmp).
|
|
97
|
+
*
|
|
98
|
+
* Voice ruling (Christian, 2026-08-03, sweepSessionIndexTmp above): applies
|
|
99
|
+
* verbatim here — the cause is definitional, not a guess, so this hint also
|
|
100
|
+
* keeps its confident voice rather than the unverified-cause voice.
|
|
101
|
+
*/
|
|
102
|
+
sweepSessionMetadataTmp:
|
|
103
|
+
'amicus doctor --fix (sweeps orphaned .metadata.json.*.tmp files left by an interrupted write)',
|
|
90
104
|
});
|
|
91
105
|
|
|
92
106
|
module.exports = REMEDIATION_HINTS;
|
|
@@ -192,10 +192,32 @@ function buildCatalogDoc({ models, fetchedAt, refreshed = false, search = null,
|
|
|
192
192
|
* of curated DEFAULT aliases (toGatewayRoutes() vs. the live catalog),
|
|
193
193
|
* distinct from the flat `stale` audit above. Defaults to [] so existing
|
|
194
194
|
* callers that omit it are unaffected.
|
|
195
|
+
* `drifted` (v4.6.2 PR1, 2A) is additive: stored user-config aliases whose
|
|
196
|
+
* target is still catalog-live but behind the current quick-pick family
|
|
197
|
+
* resolution (findDriftedStoredAliases). Defaults to [] so existing callers
|
|
198
|
+
* that omit it are unaffected. Paired with an additive `driftedCount`
|
|
199
|
+
* (drifted.length), mirroring the staleCount/stale and
|
|
200
|
+
* gatewayFindingsCount/gatewayFindings pairs above.
|
|
201
|
+
* `probe` (v4.6.2 PR3, spec §6 D5) is additive: the `--live` opt-in per-alias
|
|
202
|
+
* probe outcomes (probeStoredAliases — served/accepted-but-silent/error), one
|
|
203
|
+
* row per stored alias actually probed. Defaults to [] so every call without
|
|
204
|
+
* --live (i.e. every existing caller) is unaffected. Paired with an additive
|
|
205
|
+
* `probeCount` (probe.length), mirroring the driftedCount/drifted pair above.
|
|
206
|
+
* `probeSkipped` (v4.6.2 PR3 Task 4) is additive: null when the probe ran or
|
|
207
|
+
* `--live` wasn't requested; otherwise a stable reason slug (e.g.
|
|
208
|
+
* 'catalog-unavailable') for the one case this function can see — the
|
|
209
|
+
* `--refresh --check --live` skip (runRefresh returns before this is ever
|
|
210
|
+
* called) is announced by the caller instead, since that path emits a
|
|
211
|
+
* model-catalog doc, not this one.
|
|
195
212
|
* @param {{stale: Array<{alias,model,source,suggestions}>, catalogAvailable: boolean,
|
|
196
|
-
* gatewayFindings?: Array<{alias,gateway,kind,model,expected?}
|
|
213
|
+
* gatewayFindings?: Array<{alias,gateway,kind,model,expected?}>,
|
|
214
|
+
* drifted?: Array<{alias,stored,current}>,
|
|
215
|
+
* probe?: Array<{alias,target,outcome,detail,cost}>,
|
|
216
|
+
* probeSkipped?: string|null}} opts
|
|
197
217
|
*/
|
|
198
|
-
function buildAuditDoc({
|
|
218
|
+
function buildAuditDoc({
|
|
219
|
+
stale, catalogAvailable, gatewayFindings = [], drifted = [], probe = [], probeSkipped = null
|
|
220
|
+
}) {
|
|
199
221
|
return {
|
|
200
222
|
schemaVersion: SCHEMA_VERSION,
|
|
201
223
|
type: 'alias-audit',
|
|
@@ -204,6 +226,11 @@ function buildAuditDoc({ stale, catalogAvailable, gatewayFindings = [] }) {
|
|
|
204
226
|
stale,
|
|
205
227
|
gatewayFindingsCount: gatewayFindings.length,
|
|
206
228
|
gatewayFindings,
|
|
229
|
+
driftedCount: drifted.length,
|
|
230
|
+
drifted,
|
|
231
|
+
probeCount: probe.length,
|
|
232
|
+
probe,
|
|
233
|
+
probeSkipped,
|
|
207
234
|
};
|
|
208
235
|
}
|
|
209
236
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// src/utils/session-metadata-tmp-sweep.js
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* v4.6.3 PR3 Task 3 (D8): orphaned per-session metadata.json.*.tmp sweep for
|
|
6
|
+
* `amicus doctor --fix`.
|
|
7
|
+
*
|
|
8
|
+
* A kill between writeFileAtomic's tmp-write and rename (any of the ~30
|
|
9
|
+
* metadata.json write sites — session-manager.js, sidecar/session-finalize.js,
|
|
10
|
+
* etc.) leaves a stray `.metadata.json.<pid>.<hex>.tmp` file in a session
|
|
11
|
+
* directory forever (the B09 orphan class). This module lists and removes
|
|
12
|
+
* them; src/cli-handlers-doctor.js composes the result into a check line.
|
|
13
|
+
* Structurally mirrors utils/session-index-tmp-sweep.js (B15's sibling).
|
|
14
|
+
*
|
|
15
|
+
* Enumeration decision (cwd-scoped, index rejected — recorded at plan time,
|
|
16
|
+
* docs/superpowers/plans/2026-08-05-v463-pr3-cli-doctor-odds.md): this walks
|
|
17
|
+
* `<process.cwd()>/.claude/amicus_sessions/` — each taskId dir plus its
|
|
18
|
+
* `subagents/<id>/` children — rather than consulting sessions-index.json.
|
|
19
|
+
* `amicus doctor` is a per-project surface; the index is best-effort and can
|
|
20
|
+
* point at OTHER projects (issue #40's cross-project fallback exists exactly
|
|
21
|
+
* because the index can be stale). Trusting it here to decide which
|
|
22
|
+
* directories a --fix sweep may unlink from risks touching an unrelated
|
|
23
|
+
* project on stale data — the wrong failure direction for a destructive
|
|
24
|
+
* operation. Walking the cwd-scoped tree directly can only ever find/remove
|
|
25
|
+
* files under the project doctor is already running against.
|
|
26
|
+
*
|
|
27
|
+
* Symlink safety (Task 3 review carry, v4.6.3 PR3): every stat in this walk
|
|
28
|
+
* is lstatSync, never statSync — this module never follows symlinks. A
|
|
29
|
+
* symlinked taskId or subagents directory could otherwise be traversed and
|
|
30
|
+
* have files unlinked through the link, effectively outside the sessions
|
|
31
|
+
* root; lstat closes that off at zero cost.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('fs');
|
|
35
|
+
const path = require('path');
|
|
36
|
+
const HINTS = require('./remediation-hints');
|
|
37
|
+
|
|
38
|
+
/** Files older than this survive to the next --fix, never a live writer's ms-lived tmp. */
|
|
39
|
+
const AGE_THRESHOLD_MS = 60 * 1000;
|
|
40
|
+
|
|
41
|
+
/** The cwd-scoped sessions root: <cwd>/.claude/amicus_sessions. */
|
|
42
|
+
function sessionsRoot() {
|
|
43
|
+
const { SESSIONS_DIR } = require('../session-manager');
|
|
44
|
+
return path.join(process.cwd(), '.claude', SESSIONS_DIR);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** True when `basename` is an orphaned metadata tmp file (not e.g. progress.json.*.tmp). */
|
|
48
|
+
function isMetadataTmp(basename) {
|
|
49
|
+
return basename.startsWith('.metadata.json.') && basename.endsWith('.tmp');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** List metadata tmp files directly inside `dir`, named relative to `root`. */
|
|
53
|
+
function listTmpIn(dir, root) {
|
|
54
|
+
let entries;
|
|
55
|
+
try { entries = fs.readdirSync(dir); } catch { return []; }
|
|
56
|
+
return entries
|
|
57
|
+
.filter(isMetadataTmp)
|
|
58
|
+
.map((basename) => {
|
|
59
|
+
let mtimeMs = null;
|
|
60
|
+
try { mtimeMs = fs.lstatSync(path.join(dir, basename)).mtimeMs; } catch { /* raced away — skip below */ }
|
|
61
|
+
return { name: path.relative(root, path.join(dir, basename)), mtimeMs };
|
|
62
|
+
})
|
|
63
|
+
.filter((f) => f.mtimeMs !== null);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* List orphaned per-session metadata.json.*.tmp files under the cwd-scoped
|
|
68
|
+
* sessions root, covering both `<taskId>/` and `<taskId>/subagents/<id>/`.
|
|
69
|
+
* @returns {Array<{name: string, mtimeMs: number}>}
|
|
70
|
+
*/
|
|
71
|
+
function listSessionMetadataTmpFiles() {
|
|
72
|
+
const { SUBAGENTS_DIR } = require('../session-manager');
|
|
73
|
+
const root = sessionsRoot();
|
|
74
|
+
let taskIds;
|
|
75
|
+
try { taskIds = fs.readdirSync(root); } catch { return []; }
|
|
76
|
+
const found = [];
|
|
77
|
+
for (const taskId of taskIds) {
|
|
78
|
+
const taskDir = path.join(root, taskId);
|
|
79
|
+
let stat;
|
|
80
|
+
try { stat = fs.lstatSync(taskDir); } catch { continue; }
|
|
81
|
+
if (!stat.isDirectory()) { continue; }
|
|
82
|
+
found.push(...listTmpIn(taskDir, root));
|
|
83
|
+
|
|
84
|
+
const subagentsDir = path.join(taskDir, SUBAGENTS_DIR);
|
|
85
|
+
let subIds;
|
|
86
|
+
try { subIds = fs.readdirSync(subagentsDir); } catch { continue; }
|
|
87
|
+
for (const subId of subIds) {
|
|
88
|
+
const subDir = path.join(subagentsDir, subId);
|
|
89
|
+
let subStat;
|
|
90
|
+
try { subStat = fs.lstatSync(subDir); } catch { continue; }
|
|
91
|
+
if (!subStat.isDirectory()) { continue; }
|
|
92
|
+
found.push(...listTmpIn(subDir, root));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return found;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Delete one orphaned tmp file by name (relative to the sessions root; never an absolute/caller path). */
|
|
99
|
+
function unlinkSessionMetadataTmp(name) {
|
|
100
|
+
const root = sessionsRoot();
|
|
101
|
+
fs.unlinkSync(path.join(root, name));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Compose the doctor check line for the metadata tmp-orphan sweep. Pure
|
|
106
|
+
* decision logic (list/sweep side effects come in via `d`); src/cli-handlers-doctor.js
|
|
107
|
+
* wraps this in guard() the same way it wires the sibling sessions-index-tmp check.
|
|
108
|
+
* @param {{listSessionMetadataTmpFiles: () => Array<{name:string, mtimeMs:number}>,
|
|
109
|
+
* fix?: boolean, now: () => number, unlinkSessionMetadataTmp: (name: string) => void}} d
|
|
110
|
+
*/
|
|
111
|
+
function evaluateSessionMetadataTmpSweep(d) {
|
|
112
|
+
const id = 'session-metadata-tmp'; const name = 'Session metadata tmp files';
|
|
113
|
+
const files = d.listSessionMetadataTmpFiles() || [];
|
|
114
|
+
if (files.length === 0) {
|
|
115
|
+
return { id, name, status: 'ok', message: '0 orphaned tmp files', hint: null };
|
|
116
|
+
}
|
|
117
|
+
if (!d.fix) {
|
|
118
|
+
return { id, name, status: 'warn', message: `${files.length} orphaned tmp file(s) — run with --fix`, hint: HINTS.sweepSessionMetadataTmp };
|
|
119
|
+
}
|
|
120
|
+
const nowMs = d.now();
|
|
121
|
+
const sweepable = files.filter((f) => (nowMs - f.mtimeMs) > AGE_THRESHOLD_MS);
|
|
122
|
+
let swept = 0;
|
|
123
|
+
for (const f of sweepable) {
|
|
124
|
+
try { d.unlinkSessionMetadataTmp(f.name); swept += 1; } catch { /* best-effort — report what we got */ }
|
|
125
|
+
}
|
|
126
|
+
const remaining = files.length - swept;
|
|
127
|
+
if (remaining === 0) {
|
|
128
|
+
const fixFields = swept > 0 ? { fixed: true, fixDetail: `swept ${swept} orphaned session-metadata tmp file(s)` } : {};
|
|
129
|
+
return { id, name, status: 'ok', message: `swept ${swept} orphaned tmp file(s)`, hint: null, ...fixFields };
|
|
130
|
+
}
|
|
131
|
+
return { id, name, status: 'warn', message: `swept ${swept}, ${remaining} remaining (too fresh or unremovable)`, hint: HINTS.sweepSessionMetadataTmp };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
module.exports = {
|
|
135
|
+
AGE_THRESHOLD_MS, listSessionMetadataTmpFiles, unlinkSessionMetadataTmp, evaluateSessionMetadataTmpSweep,
|
|
136
|
+
};
|
|
@@ -126,6 +126,7 @@ function normalizeLive(doc) {
|
|
|
126
126
|
stageName: active ? active.name : null,
|
|
127
127
|
stages,
|
|
128
128
|
seats: legRowsOf(doc).map(seatOf),
|
|
129
|
+
degrades: Array.isArray(doc.degrades) ? doc.degrades : [],
|
|
129
130
|
// ⚠️ v4.4.1 RN-8 (D1 ruling: delete the promise). `legsTotal`/`legsComplete` used to be
|
|
130
131
|
// mapped here under a comment promising them as "the honest fallback readout if a seat row
|
|
131
132
|
// is ever unavailable" — a UI fallback nobody ever wired. Nothing in electron/workspace-ui/
|