amicus 4.8.1 → 4.9.1

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.
Files changed (117) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +316 -0
  3. package/README.md +8 -3
  4. package/docs/ROADMAP.md +101 -10
  5. package/docs/configuration.md +54 -4
  6. package/docs/council.md +102 -14
  7. package/docs/troubleshooting.md +9 -2
  8. package/docs/usage.md +128 -12
  9. package/electron/ipc-setup.js +41 -18
  10. package/electron/main.js +19 -5
  11. package/electron/offer-session.js +51 -0
  12. package/electron/setup-ui.js +46 -39
  13. package/electron/workspace-ui/live-dead-seats.js +163 -91
  14. package/electron/workspace-ui/workspace-banners.js +30 -7
  15. package/electron/workspace-ui/workspace-matrix.js +23 -3
  16. package/electron/workspace-ui/workspace-seats.js +95 -79
  17. package/package.json +1 -1
  18. package/schemas/council-run.schema.json +2 -2
  19. package/schemas/council-tally.schema.json +17 -1
  20. package/schemas/council-verdict.schema.json +12 -4
  21. package/schemas/run.schema.json +6 -1
  22. package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
  23. package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
  24. package/skills/second-opinion/MODEL-NOTES.md +88 -9
  25. package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
  26. package/skills/second-opinion/SKILL.md +151 -36
  27. package/src/cli-council-run-bench.js +98 -6
  28. package/src/cli-handlers-council-run.js +18 -6
  29. package/src/cli-handlers-council.js +57 -7
  30. package/src/cli-handlers-doctor.js +1 -1
  31. package/src/cli.js +3 -1
  32. package/src/council/anonymize.js +2 -1
  33. package/src/council/briefings-chair-task.js +161 -0
  34. package/src/council/briefings-chair.js +33 -8
  35. package/src/council/briefings-debate.js +79 -13
  36. package/src/council/briefings-stage2-task.js +236 -0
  37. package/src/council/briefings-stage2.js +103 -26
  38. package/src/council/briefings-task.js +167 -0
  39. package/src/council/briefings.js +41 -4
  40. package/src/council/chair-fallback.js +95 -0
  41. package/src/council/debate.js +38 -21
  42. package/src/council/findings.js +3 -2
  43. package/src/council/ledger.js +2 -2
  44. package/src/council/parse-stage2.js +63 -15
  45. package/src/council/report-cost.js +61 -0
  46. package/src/council/report-html.js +26 -4
  47. package/src/council/report-md.js +30 -2
  48. package/src/council/report.js +40 -37
  49. package/src/council/run-assemble.js +21 -6
  50. package/src/council/run-chair.js +44 -95
  51. package/src/council/run-debate-revote.js +81 -49
  52. package/src/council/run-debate.js +51 -34
  53. package/src/council/run-finish.js +5 -3
  54. package/src/council/run-retry-keys.js +4 -4
  55. package/src/council/run-retry-launch.js +4 -4
  56. package/src/council/run-retry-notes.js +72 -15
  57. package/src/council/run-stage1-launch.js +4 -4
  58. package/src/council/run-stage1-rows.js +9 -6
  59. package/src/council/run-stage2.js +81 -47
  60. package/src/council/run-stages.js +9 -21
  61. package/src/council/run-stats-entry.js +46 -1
  62. package/src/council/run.js +28 -13
  63. package/src/council/seats.js +2 -2
  64. package/src/council/stage1-bind.js +3 -2
  65. package/src/council/verdict-seat-loss.js +124 -0
  66. package/src/council/verdict.js +108 -99
  67. package/src/headless.js +256 -49
  68. package/src/mcp-council-bench.js +64 -3
  69. package/src/mcp-council-run.js +10 -3
  70. package/src/mcp-server.js +52 -12
  71. package/src/mcp-tools.js +41 -5
  72. package/src/observe/council-legs.js +2 -2
  73. package/src/opencode-client.js +19 -1
  74. package/src/pack/pack-forward.js +15 -12
  75. package/src/pack/pack-resolve.js +1 -1
  76. package/src/prompt-builder.js +17 -1
  77. package/src/sidecar/fanout-leg-fallback.js +2 -1
  78. package/src/sidecar/fanout-leg.js +26 -0
  79. package/src/sidecar/fanout.js +1 -1
  80. package/src/sidecar/list-council.js +178 -0
  81. package/src/sidecar/list-limit.js +3 -1
  82. package/src/sidecar/list-search.js +2 -1
  83. package/src/sidecar/models-render.js +71 -0
  84. package/src/sidecar/models.js +19 -45
  85. package/src/sidecar/read.js +34 -10
  86. package/src/sidecar/reopen-spend.js +2 -1
  87. package/src/sidecar/setup.js +13 -4
  88. package/src/sidecar/start.js +2 -1
  89. package/src/template/render.js +16 -7
  90. package/src/utils/alias-audit.js +10 -3
  91. package/src/utils/alias-shadow-writer.js +220 -0
  92. package/src/utils/alias-shadow.js +294 -0
  93. package/src/utils/config.js +1 -1
  94. package/src/utils/curated-models.js +19 -9
  95. package/src/utils/degrade.js +12 -5
  96. package/src/utils/doctor-alias-check.js +2 -5
  97. package/src/utils/engine-log-parse.js +289 -0
  98. package/src/utils/engine-log-tail.js +114 -0
  99. package/src/utils/engine-log.js +250 -0
  100. package/src/utils/engine-skew-records.js +146 -0
  101. package/src/utils/engine-skew.js +300 -0
  102. package/src/utils/gateway-router.js +21 -3
  103. package/src/utils/model-canonicalization.js +55 -6
  104. package/src/utils/model-catalog.js +27 -9
  105. package/src/utils/model-fetcher.js +69 -16
  106. package/src/utils/model-shortlist.js +5 -2
  107. package/src/utils/provider-default-picker.js +6 -3
  108. package/src/utils/quick-picks.js +45 -7
  109. package/src/utils/result-schema.js +17 -1
  110. package/src/utils/text-sanitize.js +81 -0
  111. package/src/utils/ttft.js +57 -0
  112. package/src/utils/untrusted-fence.js +111 -1
  113. package/src/workspace/fold-format.js +28 -7
  114. package/src/workspace/live-normalize.js +2 -1
  115. package/src/workspace/matrix-model.js +6 -2
  116. package/src/workspace/run-detail.js +33 -7
  117. package/src/workspace/seat-space.js +10 -6
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @module utils/engine-skew-records
3
+ * Server identity and the bounded store of standing engine-skew records.
4
+ *
5
+ * EXTRACTED from src/utils/engine-skew.js (v4.9 W10 round 2), which owns the
6
+ * comparison, the announcement and the remedy text and had reached the 300-line
7
+ * gate. Everything here answers one question instead — WHICH SERVER is this,
8
+ * and what was last observed about it — and that is where the round-2 findings
9
+ * on identity collapse (A3), the shared unknown bucket (B3) and unbounded growth
10
+ * (B7) all landed. `engine-skew.js` re-exports the public half, so callers and
11
+ * tests keep one import site.
12
+ *
13
+ * BEST-EFFORT, like its parent: an identity read on a diagnostic path must not
14
+ * be able to fail a session create, so nothing here throws.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ /**
20
+ * The standing skew records: server identity -> `{server, installed}`, holding
21
+ * the LAST comparison observed for that server (see noteSessionVersion). Not a
22
+ * single process-wide slot (W10 round-1 review A3+B3): one slot stamped the
23
+ * first skew ever seen onto every later failure, including failures of an
24
+ * unrelated server and failures after the skew was fixed mid-run.
25
+ */
26
+ const _skewByServer = new Map();
27
+
28
+ /** The key used when the client cannot name its server. Distinct from every
29
+ * real base URL, so an unidentified server borrows no one else's record. */
30
+ const UNKNOWN_SERVER_KEY = '<unknown server>';
31
+
32
+ /**
33
+ * How many servers the map remembers (round-2 review B7). This is
34
+ * process-lifetime state on a path that runs once per session create — every
35
+ * fanout leg, every council seat, for as long as an MCP server stays up — and
36
+ * its key is a server URL, which an operator controls. 32 is far past any real
37
+ * topology (production talks to ONE spawned server per process) while still
38
+ * being a bound rather than a hope.
39
+ */
40
+ const MAX_SKEW_SERVERS = 32;
41
+
42
+ /**
43
+ * The identity of the server this client talks to — its base URL, normalized to
44
+ * an origin AND path.
45
+ *
46
+ * MEASURED 2026-08-25 against this checkout's `@opencode-ai/sdk`, not read off
47
+ * the types: `createOpencodeClient({ baseUrl })` returns an object whose only
48
+ * non-namespace key is `_client`, and `client._client.getConfig()` answers
49
+ * `{bodySerializer, headers, parseAs, querySerializer, baseUrl, fetch}` — the
50
+ * `baseUrl` being exactly the string passed in, and `undefined` when none was.
51
+ * That is the SMALLEST honest key available at session-create time: no round
52
+ * trip, no new SDK call, and in production `startServer` always builds the
53
+ * client from one spawned server's own `sdkServer.url`, so distinct servers get
54
+ * distinct ports and distinct keys. It is an identity, not proof of identity —
55
+ * two amicus processes that spawn servers on the same port at different times
56
+ * would collide — which is why every record is also refreshed on each create.
57
+ *
58
+ * THE PATH IS PART OF THE KEY (round-2 review A3). `new URL(raw).origin` alone
59
+ * collapses two distinct servers onto one record whenever they share an origin
60
+ * — a reverse proxy at `…:8080/engine-a` and `…:8080/engine-b`, and MEASURED
61
+ * 2026-08-26, EVERY opaque-origin URL: `new URL('unix:///tmp/a.sock').origin`
62
+ * and its `b.sock` twin are both the literal string `"null"`. A shared record
63
+ * is precisely what per-server keying exists to prevent. Amicus's own topology
64
+ * cannot reach it today — `startServer` is the only production client builder
65
+ * and always passes one spawned server's `http://127.0.0.1:<port>` — but the
66
+ * external/shared-server case in engine-skew.js's SCOPE paragraph can, and
67
+ * keeping the path costs one expression.
68
+ *
69
+ * `_client` is the SDK's own internal handle and the ONLY route to that value
70
+ * (measured: the client's other keys are all resource namespaces). If a future
71
+ * SDK renames it, this returns UNKNOWN_SERVER_KEY and every server shares one
72
+ * bucket again. That bucket is genuinely degraded, not merely coarser: under it
73
+ * a record can be attributed to the wrong server AND cleared by the wrong one.
74
+ * `noteSessionVersion` clears from it anyway, on purpose — its docblock records
75
+ * the measurement behind that ruling.
76
+ *
77
+ * @param {object} [client] - the SDK client, or nothing
78
+ * @returns {string} origin + path, the raw value if it will not parse, else
79
+ * UNKNOWN_SERVER_KEY. Never throws: an identity read on a diagnostic path
80
+ * must not be able to fail a session create.
81
+ */
82
+ function serverKeyForClient(client) {
83
+ let raw;
84
+ try {
85
+ raw = client && client._client && typeof client._client.getConfig === 'function'
86
+ ? client._client.getConfig().baseUrl
87
+ : undefined;
88
+ } catch (_e) {
89
+ return UNKNOWN_SERVER_KEY;
90
+ }
91
+ if (typeof raw !== 'string' || !raw.trim()) { return UNKNOWN_SERVER_KEY; }
92
+ try {
93
+ const url = new URL(raw);
94
+ return `${url.origin}${url.pathname}`.replace(/\/+$/, '') || url.origin;
95
+ } catch (_e) { return raw.trim(); }
96
+ }
97
+
98
+ /** The record standing against one KEY, or undefined. */
99
+ function skewForKey(key) {
100
+ return _skewByServer.get(key);
101
+ }
102
+
103
+ /** Record a skew as the most RECENTLY seen, evicting the least recent past the
104
+ * bound. Re-inserting on every observation is what makes "recent" mean last
105
+ * observed rather than first seen. */
106
+ function rememberSkew(key, skew) {
107
+ _skewByServer.delete(key);
108
+ _skewByServer.set(key, skew);
109
+ while (_skewByServer.size > MAX_SKEW_SERVERS) {
110
+ _skewByServer.delete(_skewByServer.keys().next().value);
111
+ }
112
+ }
113
+
114
+ /** Drop one server's record — a retraction, or "an older engine answers here
115
+ * now". Safe on a key that was never recorded. */
116
+ function forgetSkew(key) {
117
+ _skewByServer.delete(key);
118
+ }
119
+
120
+ /**
121
+ * The standing skew record for ONE server, or null.
122
+ *
123
+ * A caller with no client gets the UNKNOWN key's record — never another
124
+ * server's. Reading is defensive but cannot meaningfully throw.
125
+ * @param {object} [client] - the SDK client whose server is being asked about
126
+ * @returns {{server: string, installed: string}|null}
127
+ */
128
+ function currentEngineSkew(client) {
129
+ try { return _skewByServer.get(serverKeyForClient(client)) || null; } catch (_e) { return null; }
130
+ }
131
+
132
+ /** Test-only: forget every standing record. */
133
+ function _resetSkewRecords() {
134
+ _skewByServer.clear();
135
+ }
136
+
137
+ module.exports = {
138
+ serverKeyForClient,
139
+ currentEngineSkew,
140
+ skewForKey,
141
+ rememberSkew,
142
+ forgetSkew,
143
+ UNKNOWN_SERVER_KEY,
144
+ MAX_SKEW_SERVERS,
145
+ _resetSkewRecords,
146
+ };
@@ -0,0 +1,300 @@
1
+ /**
2
+ * @module utils/engine-skew
3
+ * Runtime detection of an opencode ENGINE version skew: server vs installed.
4
+ *
5
+ * The version the server we are actually talking to reports, against the engine
6
+ * sitting in THIS install's node_modules.
7
+ *
8
+ * WHY (#133, piece 3). The outage was one engine version (npx cache, 1.17.3)
9
+ * serving every MCP session while another (global install, 1.18.15) served the
10
+ * CLI, both writing one shared SQLite file — so every MCP prompt died before a
11
+ * model was ever called. `amicus doctor` printed ZERO errors throughout: its
12
+ * skew check (`doctor-engine-check.js`) compares npx-cache copies against the
13
+ * GLOBAL install and says nothing about the copy the running process actually
14
+ * loaded. MEASURED here 2026-08-25: that baseline sees global 1.18.15 vs npx
15
+ * 1.18.15 and reports clean, while the running checkout loads engine **1.2.20**
16
+ * — a live instance of #133's own class, invisible to the check built for it.
17
+ * Hence a RUNTIME comparison with no global baseline.
18
+ *
19
+ * SCOPE, stated honestly (W10 review F2): this handshake fires when the server
20
+ * answering this process was started by a DIFFERENT install (shared or external
21
+ * server, PATH-hoisted binary). In #133's literal recorded topology the npx copy
22
+ * spawned its OWN engine — server and installed versions were EQUAL on that
23
+ * side, the skew being against a global install neither loads — so THIS check
24
+ * stays silent there; the engine-log excerpt (piece 2) names that case instead,
25
+ * by quoting the SQLiteError itself.
26
+ *
27
+ * MEASURED, not reasoned from types (2026-08-25, against a locally spawned
28
+ * engine): `client.session.create({})` returns `data = {directory,id,projectID,
29
+ * slug,time,title,version}` and `data.version` was `"1.2.20"` — byte-identical
30
+ * to this checkout's `node_modules/opencode-ai/package.json`. `Session.version`
31
+ * IS the engine's own version, so the two sides are directly comparable.
32
+ *
33
+ * SHAPE. `src/opencode-client.js :: createSession` pushes the observed version
34
+ * here together with its client (it discarded both before); this module owns
35
+ * the comparison, the announcement and the remedy text, while
36
+ * `./engine-skew-records.js` owns the server identity and the standing record —
37
+ * one per SERVER, refreshed on every create — that `src/headless.js` appends to
38
+ * a leg's death report. That keeps the client's return type a plain session-id
39
+ * string (measured: all three production callers — `headless.js`,
40
+ * `mcp-server.js`, `sidecar/interactive.js` — assign it straight to one).
41
+ *
42
+ * EVERYTHING HERE IS BEST-EFFORT: a diagnosis must never become the failure it
43
+ * reports on. Every path returns null/undefined rather than throwing.
44
+ */
45
+
46
+ 'use strict';
47
+
48
+ const path = require('path');
49
+ const {
50
+ serverKeyForClient, currentEngineSkew, skewForKey, rememberSkew, forgetSkew,
51
+ UNKNOWN_SERVER_KEY, MAX_SKEW_SERVERS, _resetSkewRecords,
52
+ } = require('./engine-skew-records');
53
+ // The SAME sanitizer the log excerpt uses (round-3 reviews B1+C2). A server's
54
+ // reported version is third-party text on its way to a terminal and to MCP,
55
+ // exactly like an engine log line; one implementation, one dialect.
56
+ const { collapseExcerpt } = require('./text-sanitize');
57
+
58
+ /** How much of a server-reported version is ever rendered. Real versions are
59
+ * semver-shaped; past this it is not a version, it is a payload. */
60
+ const MAX_VERSION_CHARS = 32;
61
+
62
+ /** One version string, safe to paste into a one-line notice or an error message:
63
+ * no ANSI, no control bytes, no bidi reordering, no newline that could forge a
64
+ * second `[amicus] ` line, never longer than the cap. */
65
+ const safeVersion = (value) => collapseExcerpt(value, MAX_VERSION_CHARS);
66
+
67
+ /** Memo for the installed-version disk read (see installedEngineVersion). */
68
+ let _installedRead = false;
69
+ let _installedCache;
70
+ /** Whether the "cannot name this server" notice has already been printed. */
71
+ let _identityNoticeSent = false;
72
+
73
+ /** Test-only: clear the standing records, the read memo and the notice latch. */
74
+ function _resetEngineSkew() {
75
+ _resetSkewRecords();
76
+ _installedRead = false;
77
+ _installedCache = undefined;
78
+ _identityNoticeSent = false;
79
+ }
80
+
81
+ /**
82
+ * Read the engine version out of the RUNNING install's node_modules.
83
+ *
84
+ * Roots come from `path-setup.js :: opencodeRoots()` with no argument — the same
85
+ * resolver `engine-ensure`/`hasOpencodeBinary` use to decide whether THIS
86
+ * process's engine binary exists, so "the engine we would run" and "the engine
87
+ * we version-check" are the same by construction.
88
+ *
89
+ * Reading `opencode-ai`'s own package.json (rather than anything next to the
90
+ * binary) mirrors `engine-install-scan.js :: defaultReadEngineVersion`, whose
91
+ * docblock records why: a faithful proxy, because opencode-ai exact-pins all 12
92
+ * platform sub-packages and the binary's own directory has no package.json on
93
+ * POSIX at all.
94
+ * @param {object} [deps]
95
+ * @param {object} [deps.fs] - fs module seam (readFileSync)
96
+ * @param {string[]} [deps.roots] - node_modules roots seam
97
+ * @returns {string|undefined} undefined on every miss path
98
+ */
99
+ function defaultReadInstalledEngineVersion(deps = {}) {
100
+ const fs = deps.fs || require('fs');
101
+ const roots = deps.roots || require('./path-setup').opencodeRoots();
102
+ for (const root of roots) {
103
+ try {
104
+ const raw = fs.readFileSync(path.join(root, 'opencode-ai', 'package.json'), 'utf-8');
105
+ const v = JSON.parse(raw).version;
106
+ if (v) { return String(v); }
107
+ } catch (_e) { /* try the next root */ }
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ /**
113
+ * The installed engine version, read at most ONCE per process.
114
+ *
115
+ * node_modules cannot change under a live process, and this is consulted on
116
+ * every session create (every fanout leg, every council seat) — a per-session
117
+ * stat of two roots would be pure waste. The memo is cleared only by
118
+ * `_resetEngineSkew()`, so a test that swaps seams must reset between cases;
119
+ * production has exactly one call path and never does.
120
+ * @param {object} [deps] - defaultReadInstalledEngineVersion seams, plus:
121
+ * @param {() => (string|undefined)} [deps.readInstalledVersion] - reader seam
122
+ * @returns {string|undefined}
123
+ */
124
+ function installedEngineVersion(deps = {}) {
125
+ if (!_installedRead) {
126
+ _installedRead = true;
127
+ try {
128
+ _installedCache = deps.readInstalledVersion
129
+ ? deps.readInstalledVersion()
130
+ : defaultReadInstalledEngineVersion(deps);
131
+ } catch (_e) {
132
+ _installedCache = undefined;
133
+ }
134
+ }
135
+ return _installedCache;
136
+ }
137
+
138
+ /**
139
+ * The default sink for BOTH one-time notices here — skew, and identity loss.
140
+ * Written to stderr, NOT through `logger.warn`.
141
+ *
142
+ * MEASURED: `utils/logger.js :: getCurrentLevel` defaults `LOG_LEVEL` to
143
+ * `'error'`, so `logger.warn` is filtered out on every default install — a
144
+ * self-diagnosis routed there would be silent exactly where it matters. stderr
145
+ * with an `[amicus] ` prefix is the house pattern for notices the user must see
146
+ * (`mcp-server.js`'s update notice, `engine-ensure.js`'s self-heal) and is safe
147
+ * under MCP stdio, where stdout carries the protocol.
148
+ * @param {string} msg
149
+ */
150
+ function defaultNotify(msg) {
151
+ try { process.stderr.write(`[amicus] ${msg}\n`); } catch (_e) { /* EPIPE at shutdown */ }
152
+ }
153
+
154
+ /**
155
+ * The announcement text. Names both versions (the whole point — #133 cost 30
156
+ * minutes because nothing printed the two numbers side by side) and then the
157
+ * action that ends it: make the two copies the same version.
158
+ *
159
+ * It deliberately does NOT say "see amicus doctor" (W10 round-1 review B1).
160
+ * Doctor's skew check is structurally blind to THIS skew — the module docblock
161
+ * above records the measurement — so pointing there would spend the user's next
162
+ * five minutes on a check that reports clean by construction.
163
+ *
164
+ * BOTH VERSIONS ARE SANITIZED (round-3 reviews B1+C2). `server` is whatever the
165
+ * server chose to say, and it landed verbatim on stderr: an escape sequence, a
166
+ * bidi override or a newline could recolour the terminal, reverse the sentence,
167
+ * or forge a second `[amicus] ` line under our prefix. `installed` goes through
168
+ * it too — also file content, and one rule beats two.
169
+ * @param {{server: string, installed: string}} skew
170
+ * @returns {string} one line
171
+ */
172
+ function formatSkewWarning({ server, installed }) {
173
+ return `engine version skew: server ${safeVersion(server)} ≠ installed ${safeVersion(installed)} — `
174
+ + 'MCP and CLI may be running different engines; update whichever copy is behind '
175
+ + '(`npm i -g amicus`, or re-run the failing surface\'s installer). '
176
+ + '`amicus doctor` cannot see this skew: its baseline compares npx against global, '
177
+ + 'not the server actually answering.';
178
+ }
179
+
180
+ /**
181
+ * The clause appended to an enriched failure message. EMPTY when there is no
182
+ * standing skew, so every message that would not have carried it is
183
+ * byte-identical to what it was before this module existed.
184
+ *
185
+ * Sanitized on the same terms as the notice, and for a sharper reason: this
186
+ * clause rides out through MCP into whatever renders a leg's death report.
187
+ * @param {{server: string, installed: string}|null} [skew]
188
+ * @returns {string}
189
+ */
190
+ function formatSkewSuffix(skew) {
191
+ if (!skew) { return ''; }
192
+ return ` (engine skew: server ${safeVersion(skew.server)} ≠ installed ${safeVersion(skew.installed)})`;
193
+ }
194
+
195
+ /**
196
+ * Say so, ONCE, when a client was handed to us and we could not name its server.
197
+ *
198
+ * Round-3 review C1(a). `serverKeyForClient` reads `client._client.getConfig()`,
199
+ * a PRIVATE SDK handle and the only route to the base URL. If a future SDK
200
+ * renames it, that read returns UNKNOWN_SERVER_KEY, every server in the process
201
+ * silently shares one degraded bucket — where a record can be attributed to the
202
+ * wrong server AND cleared by the wrong one — and nothing says so. That is the
203
+ * correct-but-silent degrade the product principle rates as bad as a crash.
204
+ *
205
+ * A CLIENT WAS PROVIDED is the whole trigger: the caller had a server to name
206
+ * and we could not name it. No client at all is not a shape change — it is a
207
+ * caller that never claimed to have one — so `currentEngineSkew`, whose callers
208
+ * legitimately ask "any skew here?" with nothing in hand, does not raise it.
209
+ * Once per process: this runs on every create, and a wave of dead legs must not
210
+ * each print it. (b) is the other half — a CI shape pin over a REAL SDK client,
211
+ * in tests/utils/engine-skew.test.js.
212
+ */
213
+ function noteIdentityLoss(key, deps) {
214
+ if (_identityNoticeSent || !deps.client || key !== UNKNOWN_SERVER_KEY) { return; }
215
+ _identityNoticeSent = true;
216
+ const notify = deps.notify || defaultNotify;
217
+ try {
218
+ notify('server identity unavailable — skew attribution is process-wide; '
219
+ + 'SDK shape may have changed');
220
+ } catch (_e) { /* best-effort, exactly like the skew notice */ }
221
+ }
222
+
223
+ /**
224
+ * Record a server-reported session version as the LAST comparison for that
225
+ * server, announcing a mismatch the first time that server shows it.
226
+ *
227
+ * REFRESHED ON EVERY CREATE, not written once (W10 round-1 review A3+B3): a
228
+ * matching version RETRACTS a standing skew for that server, so a mid-run fix
229
+ * stops being reported; a different mismatch REPLACES it, the newer observation
230
+ * being the true one. Records are per server identity, so a skewed server's
231
+ * clause never rides out on an unrelated server's death report.
232
+ *
233
+ * A version-less create CLEARS that server's record (round-2 review B4).
234
+ * Absence of the field means an older SDK or engine is answering — not evidence
235
+ * of SKEW, but evidence that what the record describes is no longer what is on
236
+ * that URL. Unknown beats stale: it announces nothing and returns null, exactly
237
+ * like a match. An unreadable INSTALLED version is different — with one side of
238
+ * the comparison missing there is no observation at all, so it touches nothing.
239
+ *
240
+ * BOTH CLEARS APPLY UNDER `UNKNOWN_SERVER_KEY` TOO, deliberately (round-2
241
+ * review B3, which proposed the opposite). Under that shared bucket a clear can
242
+ * be wrong — server B's healthy create can delete server A's real skew — so the
243
+ * rule was re-derived against "never attribute a skew to a server that does not
244
+ * have it" and MEASURED over A skewed, B healthy, A fixed, A regresses:
245
+ * retract → B dies clean, A-after-fix dies clean, A's regression is
246
+ * re-recorded on its very next create
247
+ * never retract → B wears A's skew, A wears its own stale skew after the fix,
248
+ * and NOTHING can ever clear either
249
+ * Retraction's error is self-healing (this runs on EVERY create); the other is a
250
+ * wrong attribution no later observation can undo. The bucket stays genuinely
251
+ * degraded — see `noteIdentityLoss` above, which now says so out loud.
252
+ *
253
+ * The notice repeats only when the record CHANGES: the skewed leg of a 20-seat
254
+ * wave must not print 20 identical lines.
255
+ * @param {string|undefined} serverVersion - `Session.version` from the create response
256
+ * @param {object} [deps] - installedEngineVersion seams, plus:
257
+ * @param {object} [deps.client] - the SDK client, for the server identity
258
+ * @param {(msg: string) => void} [deps.notify] - notice sink seam
259
+ * @returns {{server: string, installed: string}|null} that server's record
260
+ */
261
+ function noteSessionVersion(serverVersion, deps = {}) {
262
+ const key = serverKeyForClient(deps.client);
263
+ noteIdentityLoss(key, deps);
264
+ if (typeof serverVersion !== 'string' || !serverVersion) {
265
+ forgetSkew(key); // an older engine answers here NOW — unknown beats stale
266
+ return null;
267
+ }
268
+ const installed = installedEngineVersion(deps);
269
+ if (typeof installed !== 'string' || !installed) { return null; }
270
+ if (serverVersion === installed) {
271
+ forgetSkew(key); // the two agree NOW — any older mismatch is stale
272
+ return null;
273
+ }
274
+
275
+ const prior = skewForKey(key);
276
+ const skew = { server: serverVersion, installed };
277
+ // Record BEFORE announcing: a notifier that throws must not cost us the
278
+ // enrichment clause too.
279
+ rememberSkew(key, skew);
280
+ if (prior && prior.server === skew.server && prior.installed === skew.installed) { return skew; }
281
+ const notify = deps.notify || defaultNotify;
282
+ try { notify(formatSkewWarning(skew)); } catch (_e) { /* best-effort */ }
283
+ return skew;
284
+ }
285
+
286
+ // Ordered so the FUNCTIONS this module is used for come first: the generated
287
+ // `Key Exports` cell in CLAUDE.md keeps five names and renders each as `name()`,
288
+ // so a constant in that window reads as a function it is not (round-2 B8).
289
+ module.exports = {
290
+ noteSessionVersion,
291
+ currentEngineSkew,
292
+ serverKeyForClient,
293
+ formatSkewWarning,
294
+ formatSkewSuffix,
295
+ installedEngineVersion,
296
+ defaultReadInstalledEngineVersion,
297
+ UNKNOWN_SERVER_KEY,
298
+ MAX_SKEW_SERVERS,
299
+ _resetEngineSkew,
300
+ };
@@ -10,6 +10,17 @@
10
10
  const { resolved, routeError, selectionRequired, parseDescriptor } = require('./model-descriptor');
11
11
  const { classifyModel } = require('./model-classification');
12
12
  const { isDirectProvider } = require('./provider-registry');
13
+ const HINTS = require('./remediation-hints');
14
+
15
+ /**
16
+ * Which gateway an executable id routes through (issue 214). The inverse of
17
+ * `executableFor`. Non-string input reads as 'direct' rather than throwing --
18
+ * callers pass raw values off run metadata.
19
+ * @param {*} id @returns {'openrouter'|'direct'}
20
+ */
21
+ function gatewayOf(id) {
22
+ return typeof id === 'string' && id.startsWith('openrouter/') ? 'openrouter' : 'direct';
23
+ }
13
24
 
14
25
  /** Build the executable id for a gateway. */
15
26
  function executableFor(gateway, vendor, model) {
@@ -86,8 +97,15 @@ function catalogGate({ id, gateway, req }) {
86
97
  if (req.allowSelection) {
87
98
  return { ok: false, result: selectionRequired({ requested: req.descriptor.raw, suggestions: [] }) };
88
99
  }
89
- return { ok: false, result: routeError({ requested: req.descriptor.raw, reason: 'model_not_found',
90
- preferredGateway: gateway, suggestions: [] }) };
100
+ const e = routeError({ requested: req.descriptor.raw, reason: 'model_not_found',
101
+ preferredGateway: gateway, suggestions: [] });
102
+ // B2 (V16): a bare id invalid on direct whose openrouter/ twin IS catalog-
103
+ // confirmed is exactly doctor's repairable fabricated-alias class — point at
104
+ // the repair. Hint only; reason and shape unchanged.
105
+ if (gateway === 'direct' && classifyModel(`openrouter/${id}`, 'openrouter', req.catalogInfo) === 'valid') {
106
+ e.hint = HINTS.repairFabricatedAlias;
107
+ }
108
+ return { ok: false, result: e };
91
109
  }
92
110
 
93
111
  /**
@@ -191,4 +209,4 @@ function resolveRoute(req) {
191
209
  return routeError({ requested: d.raw, reason: 'no_key_for_vendor', preferredGateway: 'direct', suggestions: [] });
192
210
  }
193
211
 
194
- module.exports = { resolveRoute };
212
+ module.exports = { gatewayOf, resolveRoute };
@@ -32,7 +32,19 @@
32
32
 
33
33
  'use strict';
34
34
 
35
- const { toCanonicalDefault, DIVERGENT_VENDORS } = require('./curated-models');
35
+ /*
36
+ * Why `stripGatewayPrefix` (curated-models.js) is not the function to reach for:
37
+ * under its old name `toCanonicalDefault` it read as the CORRECT answer, and three
38
+ * callers took it at its word and persisted ids the direct API may not serve — the
39
+ * wizard's hand-copy `toBareIfDirect`, `toStorableRoute`, and `toDefaultAliases`
40
+ * before it was rewritten. Issue 214 renamed it rather than giving it a
41
+ * `catalogInfo` parameter, because that is circular: it PRODUCES the candidate id
42
+ * that `classifyModel` then checks against the catalog. The evidence check belongs
43
+ * one level up, here. Direct use of the primitive is correct only when normalising
44
+ * two strings before COMPARING them (alias-shadow.js).
45
+ */
46
+
47
+ const { stripGatewayPrefix, DIVERGENT_VENDORS } = require('./curated-models');
36
48
  const { classifyModel } = require('./model-classification');
37
49
 
38
50
  /**
@@ -41,10 +53,47 @@ const { classifyModel } = require('./model-classification');
41
53
  * @param {{models: Array<{id:string, authoritative?: boolean}>}} catalogInfo
42
54
  * @returns {string} the bare direct id when not proven invalid, else `orId` unchanged
43
55
  */
56
+ /**
57
+ * #208: did THIS vendor's direct namespace get ATTEMPTED and REJECTED for the
58
+ * catalog in hand? An empty namespace has two causes and `classifyModel`
59
+ * cannot tell them apart -- it returns 'unknown' for both. "Never fetched"
60
+ * (offline, no key) leaves optimism reasonable; "fetched and refused" means we
61
+ * know nothing about the namespace, and synthesising a direct id out of no
62
+ * knowledge is exactly how `deepseek/deepseek-v4-flash-0731` -- an id no
63
+ * gateway serves -- reached a real user config. Keyed on the VENDOR, never on
64
+ * "any failure": one provider's 401 says nothing about another's namespace.
65
+ * @param {string} vendor
66
+ * @param {{providerFailures?: Array<{provider: string}>}} catalogInfo
67
+ * @returns {boolean}
68
+ */
69
+ function namespaceFetchFailed(vendor, catalogInfo) {
70
+ const failures = catalogInfo && catalogInfo.providerFailures;
71
+ return Array.isArray(failures) && failures.some(f => f && f.provider === vendor);
72
+ }
73
+
74
+ /**
75
+ * Vendor segment of an executable id: `openrouter/<vendor>/<rest>` or
76
+ * `<vendor>/<rest>`. Council #216 (A2/B1): both guards below used to key on the
77
+ * CALLER's `vendor` argument while classifyModel derived its own from the id, so
78
+ * a caller passing none -- which toStorableRoute's JSDoc permits
79
+ * (`vendorPath?:string`) -- silently lost the DIVERGENT and namespace-rejection
80
+ * checks while the catalog check kept working. Deriving closes that asymmetry.
81
+ * @param {*} id @returns {string} '' when the id carries no vendor segment
82
+ */
83
+ function vendorOfId(id) {
84
+ if (typeof id !== 'string') { return ''; }
85
+ const rest = id.startsWith('openrouter/') ? id.slice('openrouter/'.length) : id;
86
+ const idx = rest.indexOf('/');
87
+ return idx > 0 ? rest.slice(0, idx) : '';
88
+ }
89
+
44
90
  function directFormIfSafe(vendor, orId, catalogInfo) {
45
- if (DIVERGENT_VENDORS.has(vendor)) { return orId; }
46
- const bare = toCanonicalDefault(orId);
91
+ const v = vendor || vendorOfId(orId);
92
+ if (DIVERGENT_VENDORS.has(v)) { return orId; }
93
+ const bare = stripGatewayPrefix(orId);
47
94
  if (bare === orId) { return orId; } // gateway-only vendor -- no direct integration at all
95
+ // Optimism is only justified when the namespace was never attempted.
96
+ if (namespaceFetchFailed(v, catalogInfo)) { return orId; }
48
97
  return classifyModel(bare, 'direct', catalogInfo) === 'invalid' ? orId : bare;
49
98
  }
50
99
 
@@ -55,10 +104,10 @@ function directFormIfSafe(vendor, orId, catalogInfo) {
55
104
  * @returns {string} the bare direct id only when PROVEN valid, else `orId` unchanged
56
105
  */
57
106
  function directFormIfProven(vendor, orId, catalogInfo) {
58
- if (DIVERGENT_VENDORS.has(vendor)) { return orId; }
59
- const bare = toCanonicalDefault(orId);
107
+ if (DIVERGENT_VENDORS.has(vendor || vendorOfId(orId))) { return orId; }
108
+ const bare = stripGatewayPrefix(orId);
60
109
  if (bare === orId) { return orId; }
61
110
  return classifyModel(bare, 'direct', catalogInfo) === 'valid' ? bare : orId;
62
111
  }
63
112
 
64
- module.exports = { directFormIfSafe, directFormIfProven };
113
+ module.exports = { directFormIfSafe, directFormIfProven, namespaceFetchFailed, vendorOfId };
@@ -19,7 +19,7 @@ const path = require('path');
19
19
  // this module is first required (the test pattern re-mocks mid-test).
20
20
  function _getConfigDir() { return require('./config').getConfigDir(); }
21
21
  function _readApiKeyValues() { return require('./api-key-store').readApiKeyValues(); }
22
- async function _fetchAllModels(keys) { return require('./model-fetcher').fetchAllModels(keys); }
22
+ async function _fetchAllModels(keys) { return require('./model-fetcher').fetchAllModelsDetailed(keys); }
23
23
 
24
24
  const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
25
25
  const CATALOG_SCHEMA_VERSION = 2;
@@ -69,8 +69,16 @@ function writeCacheDoc(doc) {
69
69
  }
70
70
 
71
71
  /** Write a successful fetch: fresh models/fetchedAt, outcome fields cleared. @param {Array} models */
72
- function writeCache(models) {
73
- writeCacheDoc({ schemaVersion: CATALOG_SCHEMA_VERSION, fetchedAt: Date.now(), models });
72
+ function writeCache(models, providerFailures) {
73
+ writeCacheDoc({
74
+ schemaVersion: CATALOG_SCHEMA_VERSION,
75
+ fetchedAt: Date.now(),
76
+ models,
77
+ // #209: which providers were ATTEMPTED and REJECTED for this fetch. Persisted
78
+ // alongside the rows because it describes THESE rows -- a cache served later
79
+ // is still a catalog whose deepseek namespace is empty for a reason.
80
+ providerFailures: Array.isArray(providerFailures) ? providerFailures : [],
81
+ });
74
82
  }
75
83
 
76
84
  /**
@@ -80,9 +88,17 @@ function writeCache(models) {
80
88
  * carries only the outcome fields (no models/fetchedAt to report).
81
89
  * @param {string} reason short error-class string
82
90
  */
83
- function writeRefreshFailure(reason) {
91
+ function writeRefreshFailure(reason, providerFailures) {
84
92
  const existing = readCache() || { schemaVersion: CATALOG_SCHEMA_VERSION };
85
- writeCacheDoc({ ...existing, lastRefreshAttempt: Date.now(), lastRefreshError: reason });
93
+ const doc = { ...existing, lastRefreshAttempt: Date.now(), lastRefreshError: reason };
94
+ // Council C1 (PR 215): a TOTAL outage is exactly when the per-provider
95
+ // breakdown matters most, and this path used to discard the failures the
96
+ // refresh had just computed. Only overwrite when this attempt produced some --
97
+ // an attempt that learned nothing must not erase a previous attempt's detail.
98
+ if (Array.isArray(providerFailures) && providerFailures.length > 0) {
99
+ doc.providerFailures = providerFailures;
100
+ }
101
+ writeCacheDoc(doc);
86
102
  }
87
103
 
88
104
  /**
@@ -91,7 +107,7 @@ function writeRefreshFailure(reason) {
91
107
  */
92
108
  async function refreshCatalog() {
93
109
  const keys = _readApiKeyValues();
94
- const models = await _fetchAllModels(keys);
110
+ const { rows: models, failures: providerFailures } = await _fetchAllModels(keys);
95
111
  // The anthropic rows are a hardcoded zero-network floor: a result containing
96
112
  // ONLY them means every network provider failed. Treat that as a failed
97
113
  // refresh — never clobber a previously-good cache with the floor (the
@@ -105,10 +121,10 @@ async function refreshCatalog() {
105
121
  const reason = (models || []).length > 0
106
122
  ? 'floor-only: all providers returned no network rows'
107
123
  : 'network-error: all providers unreachable';
108
- writeRefreshFailure(reason);
124
+ writeRefreshFailure(reason, providerFailures);
109
125
  return [];
110
126
  }
111
- writeCache(models);
127
+ writeCache(models, providerFailures);
112
128
  return models;
113
129
  }
114
130
 
@@ -149,7 +165,9 @@ async function getCatalogInfo(opts = {}) {
149
165
  fetchedAt: cache ? cache.fetchedAt : null,
150
166
  lastRefreshAttempt: (doc && doc.lastRefreshAttempt) || null,
151
167
  lastRefreshError: (doc && doc.lastRefreshError) || null,
168
+ // #209: namespace-level fetch outcomes for the CACHED rows above.
169
+ providerFailures: (doc && Array.isArray(doc.providerFailures)) ? doc.providerFailures : [],
152
170
  };
153
171
  }
154
172
 
155
- module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, readCache, CATALOG_SCHEMA_VERSION };
173
+ module.exports = { getCatalog, refreshCatalog, catalogPath, getCatalogInfo, readCache, CATALOG_SCHEMA_VERSION, DEFAULT_MAX_AGE_MS };