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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +316 -0
- package/README.md +8 -3
- package/docs/ROADMAP.md +101 -10
- package/docs/configuration.md +54 -4
- package/docs/council.md +102 -14
- package/docs/troubleshooting.md +9 -2
- package/docs/usage.md +128 -12
- package/electron/ipc-setup.js +41 -18
- package/electron/main.js +19 -5
- package/electron/offer-session.js +51 -0
- package/electron/setup-ui.js +46 -39
- package/electron/workspace-ui/live-dead-seats.js +163 -91
- package/electron/workspace-ui/workspace-banners.js +30 -7
- package/electron/workspace-ui/workspace-matrix.js +23 -3
- package/electron/workspace-ui/workspace-seats.js +95 -79
- package/package.json +1 -1
- package/schemas/council-run.schema.json +2 -2
- package/schemas/council-tally.schema.json +17 -1
- package/schemas/council-verdict.schema.json +12 -4
- package/schemas/run.schema.json +6 -1
- package/skills/second-opinion/COUNCIL-DESIGN.md +1 -1
- package/skills/second-opinion/MANUAL-ORCHESTRATION.md +1 -1
- package/skills/second-opinion/MODEL-NOTES.md +88 -9
- package/skills/second-opinion/SEAT-BRIEFS.md +36 -4
- package/skills/second-opinion/SKILL.md +151 -36
- package/src/cli-council-run-bench.js +98 -6
- package/src/cli-handlers-council-run.js +18 -6
- package/src/cli-handlers-council.js +57 -7
- package/src/cli-handlers-doctor.js +1 -1
- package/src/cli.js +3 -1
- package/src/council/anonymize.js +2 -1
- package/src/council/briefings-chair-task.js +161 -0
- package/src/council/briefings-chair.js +33 -8
- package/src/council/briefings-debate.js +79 -13
- package/src/council/briefings-stage2-task.js +236 -0
- package/src/council/briefings-stage2.js +103 -26
- package/src/council/briefings-task.js +167 -0
- package/src/council/briefings.js +41 -4
- package/src/council/chair-fallback.js +95 -0
- package/src/council/debate.js +38 -21
- package/src/council/findings.js +3 -2
- package/src/council/ledger.js +2 -2
- package/src/council/parse-stage2.js +63 -15
- package/src/council/report-cost.js +61 -0
- package/src/council/report-html.js +26 -4
- package/src/council/report-md.js +30 -2
- package/src/council/report.js +40 -37
- package/src/council/run-assemble.js +21 -6
- package/src/council/run-chair.js +44 -95
- package/src/council/run-debate-revote.js +81 -49
- package/src/council/run-debate.js +51 -34
- package/src/council/run-finish.js +5 -3
- package/src/council/run-retry-keys.js +4 -4
- package/src/council/run-retry-launch.js +4 -4
- package/src/council/run-retry-notes.js +72 -15
- package/src/council/run-stage1-launch.js +4 -4
- package/src/council/run-stage1-rows.js +9 -6
- package/src/council/run-stage2.js +81 -47
- package/src/council/run-stages.js +9 -21
- package/src/council/run-stats-entry.js +46 -1
- package/src/council/run.js +28 -13
- package/src/council/seats.js +2 -2
- package/src/council/stage1-bind.js +3 -2
- package/src/council/verdict-seat-loss.js +124 -0
- package/src/council/verdict.js +108 -99
- package/src/headless.js +256 -49
- package/src/mcp-council-bench.js +64 -3
- package/src/mcp-council-run.js +10 -3
- package/src/mcp-server.js +52 -12
- package/src/mcp-tools.js +41 -5
- package/src/observe/council-legs.js +2 -2
- package/src/opencode-client.js +19 -1
- package/src/pack/pack-forward.js +15 -12
- package/src/pack/pack-resolve.js +1 -1
- package/src/prompt-builder.js +17 -1
- package/src/sidecar/fanout-leg-fallback.js +2 -1
- package/src/sidecar/fanout-leg.js +26 -0
- package/src/sidecar/fanout.js +1 -1
- package/src/sidecar/list-council.js +178 -0
- package/src/sidecar/list-limit.js +3 -1
- package/src/sidecar/list-search.js +2 -1
- package/src/sidecar/models-render.js +71 -0
- package/src/sidecar/models.js +19 -45
- package/src/sidecar/read.js +34 -10
- package/src/sidecar/reopen-spend.js +2 -1
- package/src/sidecar/setup.js +13 -4
- package/src/sidecar/start.js +2 -1
- package/src/template/render.js +16 -7
- package/src/utils/alias-audit.js +10 -3
- package/src/utils/alias-shadow-writer.js +220 -0
- package/src/utils/alias-shadow.js +294 -0
- package/src/utils/config.js +1 -1
- package/src/utils/curated-models.js +19 -9
- package/src/utils/degrade.js +12 -5
- package/src/utils/doctor-alias-check.js +2 -5
- package/src/utils/engine-log-parse.js +289 -0
- package/src/utils/engine-log-tail.js +114 -0
- package/src/utils/engine-log.js +250 -0
- package/src/utils/engine-skew-records.js +146 -0
- package/src/utils/engine-skew.js +300 -0
- package/src/utils/gateway-router.js +21 -3
- package/src/utils/model-canonicalization.js +55 -6
- package/src/utils/model-catalog.js +27 -9
- package/src/utils/model-fetcher.js +69 -16
- package/src/utils/model-shortlist.js +5 -2
- package/src/utils/provider-default-picker.js +6 -3
- package/src/utils/quick-picks.js +45 -7
- package/src/utils/result-schema.js +17 -1
- package/src/utils/text-sanitize.js +81 -0
- package/src/utils/ttft.js +57 -0
- package/src/utils/untrusted-fence.js +111 -1
- package/src/workspace/fold-format.js +28 -7
- package/src/workspace/live-normalize.js +2 -1
- package/src/workspace/matrix-model.js +6 -2
- package/src/workspace/run-detail.js +33 -7
- package/src/workspace/seat-space.js +10 -6
package/src/sidecar/models.js
CHANGED
|
@@ -21,25 +21,11 @@ const { getFamilies } = require('../utils/curated-models');
|
|
|
21
21
|
const { pickCurrent } = require('../utils/quick-picks');
|
|
22
22
|
const { probeStoredAliases, selectStoredAliases } = require('./models-probe');
|
|
23
23
|
const { DEFAULT_MAX_LEGS } = require('./fanout-validate');
|
|
24
|
+
const { fmtRow, fmtGatewayFinding, fmtProbeLine, fmtProviderFailure } = require('./models-render');
|
|
24
25
|
|
|
25
26
|
const CHECK_EXIT_CAP = 100;
|
|
26
27
|
|
|
27
|
-
/** '0.000003' per token → '3.00' per Mtok; '—' when unknown or variable (-1) */
|
|
28
|
-
function perMtok(perToken) {
|
|
29
|
-
if (perToken === null || perToken === undefined) { return '—'; }
|
|
30
|
-
const n = Number(perToken);
|
|
31
|
-
if (Number.isNaN(n) || n < 0) { return '—'; }
|
|
32
|
-
return (n * 1e6).toFixed(2);
|
|
33
|
-
}
|
|
34
28
|
|
|
35
|
-
function fmtRow(m, aliasesById) {
|
|
36
|
-
const alias = aliasesById.get(m.id);
|
|
37
|
-
const aliasCol = alias ? `[${alias}] ` : '';
|
|
38
|
-
const ctx = m.contextLength ?? '—';
|
|
39
|
-
const pIn = perMtok(m.pricing && m.pricing.prompt);
|
|
40
|
-
const pOut = perMtok(m.pricing && m.pricing.completion);
|
|
41
|
-
return `${aliasCol}${m.id}\n ${m.name} ctx ${ctx} $/Mtok in ${pIn} out ${pOut}`;
|
|
42
|
-
}
|
|
43
29
|
|
|
44
30
|
/** alias marks: id → comma-joined alias names (effective user aliases) */
|
|
45
31
|
function aliasMarks() {
|
|
@@ -130,47 +116,31 @@ async function runRefresh(args) {
|
|
|
130
116
|
return 0;
|
|
131
117
|
}
|
|
132
118
|
|
|
133
|
-
/** One readable line per gateway-route finding (Task 6, #gwid). @param {object} f @returns {string} */
|
|
134
|
-
function fmtGatewayFinding(f) {
|
|
135
|
-
if (f.kind === 'stale') {
|
|
136
|
-
return ` GATEWAY STALE (${f.gateway}): ${f.alias} -> ${f.model}`;
|
|
137
|
-
}
|
|
138
|
-
if (f.kind === 'divergent-missing') {
|
|
139
|
-
return ` GATEWAY DIVERGENT: ${f.alias} has no direct form; catalog confirms ${f.model}`;
|
|
140
|
-
}
|
|
141
|
-
return ` GATEWAY DIVERGENT: ${f.alias} direct form ${f.model} no longer matches catalog (now ${f.expected})`;
|
|
142
|
-
}
|
|
143
119
|
|
|
144
|
-
const PROBE_LABELS = { served: 'SERVED', 'accepted-but-silent': 'SILENT', error: 'ERROR' };
|
|
145
120
|
|
|
146
|
-
/** '$0.0004' | '$1.23' | '—' (unknown). Deliberately NOT formatCost (pricing.js):
|
|
147
|
-
* a probe result's `cost` is a bare number (models-probe.js doesn't carry the
|
|
148
|
-
* reported/estimated source tag), so this never claims a precision it can't back. */
|
|
149
|
-
function fmtProbeCost(cost) {
|
|
150
|
-
if (cost === null || cost === undefined || Number.isNaN(cost)) { return '—'; }
|
|
151
|
-
return cost < 1 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
|
|
152
|
-
}
|
|
153
121
|
|
|
154
|
-
/** One readable line per probed alias (`--check --live`, v4.6.2 PR3): uppercase
|
|
155
|
-
* class prefix padded to a fixed column, two-space indent — mirrors the STALE/
|
|
156
|
-
* DRIFTED/GATEWAY line style above. @param {object} r probeStoredAliases() row */
|
|
157
|
-
function fmtProbeLine(r) {
|
|
158
|
-
const head = ` ${(PROBE_LABELS[r.outcome] + ':').padEnd(8)}${r.alias} -> ${r.target}`;
|
|
159
|
-
if (r.outcome === 'served') { return `${head} (${fmtProbeCost(r.cost)})`; }
|
|
160
|
-
if (r.outcome === 'accepted-but-silent') { return `${head} — ${r.detail} (accepted but not serving)`; }
|
|
161
|
-
return `${head} — ${r.detail}`;
|
|
162
|
-
}
|
|
163
122
|
|
|
164
123
|
async function runCheck(args) {
|
|
124
|
+
// v4.9 W13 Task B (BACKLOG C5). FIRST — ahead of the catalog-unavailable return
|
|
125
|
+
// below, exactly when an offline user most needs to know what their aliases bind
|
|
126
|
+
// to. No name list, so the subject is curated ∩ configured — NOT "the whole
|
|
127
|
+
// configured set", the overstatement round 3's B2 struck from findAliasShadows'
|
|
128
|
+
// docstring and whose twin here outlived it (round 5, D2). stderr only, so
|
|
129
|
+
// `--json` stays byte-clean; `audit…` opens a fresh scope (PR #203 A5).
|
|
130
|
+
require('../utils/alias-shadow').auditAliasShadows();
|
|
165
131
|
const catalogInfo = await getCatalogInfo();
|
|
166
132
|
const catalog = catalogInfo.models;
|
|
133
|
+
const providerFailures = Array.isArray(catalogInfo.providerFailures) ? catalogInfo.providerFailures : [];
|
|
167
134
|
if (!catalog || catalog.length === 0) {
|
|
168
135
|
const probeSkipped = args.live ? 'catalog-unavailable' : null;
|
|
169
136
|
if (args.json) {
|
|
170
137
|
process.stdout.write(JSON.stringify(buildAuditDoc({
|
|
171
|
-
stale: [], catalogAvailable: false, probeSkipped
|
|
138
|
+
stale: [], catalogAvailable: false, probeSkipped, providerFailures
|
|
172
139
|
}), null, 2) + '\n');
|
|
173
140
|
} else {
|
|
141
|
+
// Council C2 (PR 215): "catalog unavailable" is precisely when the user
|
|
142
|
+
// needs to know WHICH provider refused them.
|
|
143
|
+
for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
|
|
174
144
|
process.stdout.write('Catalog unavailable (offline or no providers reachable); cannot check.\n');
|
|
175
145
|
if (probeSkipped) { process.stdout.write(fmtLiveSkipped(probeSkipped) + '\n'); }
|
|
176
146
|
}
|
|
@@ -179,7 +149,7 @@ async function runCheck(args) {
|
|
|
179
149
|
const sources = collectAliasSources();
|
|
180
150
|
const stale = findStaleAliases(sources, catalog)
|
|
181
151
|
.map(s => ({ ...s, suggestions: suggestReplacements(s.model, catalog) }));
|
|
182
|
-
const drifted = findDriftedStoredAliases(sources,
|
|
152
|
+
const drifted = findDriftedStoredAliases(sources, catalogInfo);
|
|
183
153
|
// Task 6 (#gwid): per-gateway-form audit of the curated DEFAULTS
|
|
184
154
|
// (toGatewayRoutes()) — additive to the flat audit above. Informational by
|
|
185
155
|
// default; --strict promotes it to a build-breaking exit code (CI gate).
|
|
@@ -214,10 +184,14 @@ async function runCheck(args) {
|
|
|
214
184
|
|
|
215
185
|
if (args.json) {
|
|
216
186
|
process.stdout.write(JSON.stringify(buildAuditDoc({
|
|
217
|
-
stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults
|
|
187
|
+
stale, catalogAvailable: true, gatewayFindings, drifted, probe: probeResults, providerFailures
|
|
218
188
|
}), null, 2) + '\n');
|
|
219
189
|
return exitCode;
|
|
220
190
|
}
|
|
191
|
+
// issue 209: report REJECTED provider fetches before the alias findings -- an
|
|
192
|
+
// empty namespace explains stale/absent aliases downstream, and staying
|
|
193
|
+
// silent about it is the original defect.
|
|
194
|
+
for (const f of providerFailures) { process.stdout.write(fmtProviderFailure(f) + '\n'); }
|
|
221
195
|
const driftLines = buildFallbackDriftReport(catalog);
|
|
222
196
|
if (stale.length === 0 && drifted.length === 0) {
|
|
223
197
|
process.stdout.write(`All aliases resolve to catalog models (${sources.length} checked).\n`);
|
package/src/sidecar/read.js
CHANGED
|
@@ -15,6 +15,10 @@ const { fenceSidecarOutput } = require('../utils/untrusted-fence');
|
|
|
15
15
|
// this module's own searchSessions re-export below.
|
|
16
16
|
const { searchSessions } = require('./list-search');
|
|
17
17
|
const { normalizeLimit, truncationNotice } = require('./list-limit');
|
|
18
|
+
// v4.9 W12: council runs are first-class rows on this surface too — the merge
|
|
19
|
+
// amicus_list has done since v4.0 §8, split out for the same line-budget reason
|
|
20
|
+
// as the two modules above. Owns the MODEL cell's rendering and width.
|
|
21
|
+
const { padModel, modelCell, mergeCouncilRows, councilScopeNotice } = require('./list-council');
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Format a timestamp as relative age
|
|
@@ -134,16 +138,28 @@ function enumerateAllProjects(opts = {}) {
|
|
|
134
138
|
*/
|
|
135
139
|
async function listSidecars(options) {
|
|
136
140
|
const { status, all, json, search, limit, project = process.cwd() } = options;
|
|
137
|
-
// Mirrors
|
|
141
|
+
// Mirrors the valueless-flag shape of `sidecar/models.js :: handleModels`'s
|
|
142
|
+
// first guard (was `:279-281`; re-anchored by symbol in v4.9 W13).
|
|
138
143
|
if (search === true) { throw new Error('--search requires a value'); }
|
|
139
144
|
const cap = normalizeLimit(limit); // throws on valueless/non-integer/negative
|
|
140
145
|
|
|
141
146
|
let sessions = all
|
|
142
147
|
? enumerateAllProjects({ status, project })
|
|
143
148
|
: enumerateSessions(project, { status });
|
|
149
|
+
// Round 3 (B1): the merge's catch degrades to the session rows and used to do
|
|
150
|
+
// it in SILENCE. It now hands the failure out through this sink and the human
|
|
151
|
+
// branch says it — seam rationale at list-council.js :: mergeCouncilRows.
|
|
152
|
+
let unavailable = null;
|
|
153
|
+
sessions = mergeCouncilRows(sessions, project,
|
|
154
|
+
{ status, all, onUnavailable: (note) => { unavailable = note; } });
|
|
144
155
|
if (search) { sessions = searchSessions(sessions, search, { project }); }
|
|
145
156
|
if (sessions.length === 0) {
|
|
146
157
|
console.log('No amicus sessions found.');
|
|
158
|
+
// A1's disclosure as under the table below — an empty `--all` is where the
|
|
159
|
+
// omission is loudest — and B1's note on the same gate for the same reason:
|
|
160
|
+
// this early return is the one line both modes share, and --json cannot move.
|
|
161
|
+
if (!json && unavailable) { console.log(unavailable); }
|
|
162
|
+
if (all && !json) { console.log(councilScopeNotice()); }
|
|
147
163
|
return;
|
|
148
164
|
}
|
|
149
165
|
|
|
@@ -157,9 +173,11 @@ async function listSidecars(options) {
|
|
|
157
173
|
|
|
158
174
|
if (json) {
|
|
159
175
|
console.log(JSON.stringify(sessions, null, 2));
|
|
176
|
+
// No silent caps — but on stderr, so stdout stays one parseable document.
|
|
177
|
+
if (truncated) { console.error(truncationNotice(cap, total)); }
|
|
160
178
|
} else {
|
|
161
179
|
console.log(
|
|
162
|
-
'ID'.padEnd(10) + 'MODEL'
|
|
180
|
+
'ID'.padEnd(10) + padModel('MODEL') + 'STATUS'.padEnd(11) +
|
|
163
181
|
'TAG'.padEnd(12) + 'AGE'.padEnd(12) + 'BRIEFING' +
|
|
164
182
|
(all ? ' PROJECT' : '')
|
|
165
183
|
);
|
|
@@ -170,7 +188,7 @@ async function listSidecars(options) {
|
|
|
170
188
|
((s.briefing?.length > 30) ? '...' : '');
|
|
171
189
|
console.log(
|
|
172
190
|
`${(s.id || '').padEnd(10)}` +
|
|
173
|
-
`${(
|
|
191
|
+
`${padModel(modelCell(s))}` +
|
|
174
192
|
`${(s.status || 'unknown').padEnd(11)}` +
|
|
175
193
|
`${(s.tag || '').padEnd(12)}` +
|
|
176
194
|
`${age.padEnd(12)}` +
|
|
@@ -178,13 +196,19 @@ async function listSidecars(options) {
|
|
|
178
196
|
(all ? ` ${s.project || ''}` : '')
|
|
179
197
|
);
|
|
180
198
|
});
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
199
|
+
// THE TRAILING ORDER IS LOAD-BEARING (round 3, B4): docs/usage.md says every
|
|
200
|
+
// human `--all` listing ENDS with the scope note, and truncation used to
|
|
201
|
+
// print after the fork — after it — so `--all --limit N` made that false.
|
|
202
|
+
// Narrowest qualifier first; the standing limit closes.
|
|
203
|
+
if (truncated) { console.log(truncationNotice(cap, total)); }
|
|
204
|
+
if (unavailable) { console.log(unavailable); }
|
|
205
|
+
// Round 2 (A1): under `--all` the session rows span every indexed project
|
|
206
|
+
// and the council rows only ever span this one — say so. THIS is the seam:
|
|
207
|
+
// `all`, the `mergeCouncilRows` call above and the human table are all
|
|
208
|
+
// visible from here, and the merge itself cannot print. Inside the human
|
|
209
|
+
// branch on purpose — `--json` is a shape contract, and unlike the
|
|
210
|
+
// truncation notice there is no cap here for a script to raise.
|
|
211
|
+
if (all) { console.log(councilScopeNotice()); }
|
|
188
212
|
}
|
|
189
213
|
}
|
|
190
214
|
|
|
@@ -20,7 +20,8 @@ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, proje
|
|
|
20
20
|
metadata.usage = usage; // buildRunResult surfaces metadata.usage into the --json doc for free
|
|
21
21
|
try {
|
|
22
22
|
const { appendSpend } = require('../utils/spend-ledger');
|
|
23
|
-
const
|
|
23
|
+
const { gatewayOf } = require('../utils/gateway-router');
|
|
24
|
+
const gateway = metadata.gateway || gatewayOf(model);
|
|
24
25
|
// v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
|
|
25
26
|
// metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:237.
|
|
26
27
|
appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
|
package/src/sidecar/setup.js
CHANGED
|
@@ -475,9 +475,17 @@ async function runReadlineSetup() {
|
|
|
475
475
|
await warnOnLowOpenRouterCredit();
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
-
const {
|
|
478
|
+
const { getCatalogInfo } = require('../utils/model-catalog');
|
|
479
479
|
let catalog = [];
|
|
480
|
-
|
|
480
|
+
// #208: carry the per-provider fetch outcomes alongside the rows, so the
|
|
481
|
+
// vendor shortlist below cannot synthesise a bare direct id for a
|
|
482
|
+
// namespace whose fetch was rejected rather than never attempted.
|
|
483
|
+
let providerFailures = [];
|
|
484
|
+
try {
|
|
485
|
+
const info = await getCatalogInfo();
|
|
486
|
+
catalog = info.models;
|
|
487
|
+
providerFailures = info.providerFailures || [];
|
|
488
|
+
} catch (_err) { /* offline: pinned */ }
|
|
481
489
|
|
|
482
490
|
// Task 7 (cost-aware defaults P2): per-provider picker, once per keyed
|
|
483
491
|
// provider, BEFORE the mode prompt -- orthogonal to standard-vs-free-council.
|
|
@@ -537,7 +545,7 @@ async function runReadlineSetup() {
|
|
|
537
545
|
}
|
|
538
546
|
|
|
539
547
|
// Read-modify-write — never rebuild the alias table (no-clobber rule).
|
|
540
|
-
const cfg = loadConfig() || { aliases: toLiveSeedAliases(catalog) };
|
|
548
|
+
const cfg = loadConfig() || { aliases: toLiveSeedAliases({ models: catalog, providerFailures }) };
|
|
541
549
|
if (!cfg.aliases) { cfg.aliases = {}; }
|
|
542
550
|
if (chosen.alias) {
|
|
543
551
|
cfg.default = chosen.alias;
|
|
@@ -548,7 +556,7 @@ async function runReadlineSetup() {
|
|
|
548
556
|
// choice), but the alias's VALUE must stay the vendor phase's tier choice --
|
|
549
557
|
// skip the curated-flagship upgrade so it isn't discarded.
|
|
550
558
|
if (pick && !chosen.noUpgrade && !vendorAliasesWritten.has(chosen.alias)) {
|
|
551
|
-
cfg.aliases[chosen.alias] = toStorableRoute(pick);
|
|
559
|
+
cfg.aliases[chosen.alias] = toStorableRoute(pick, { models: catalog, providerFailures });
|
|
552
560
|
} else if (cfg.aliases[chosen.alias] === undefined) {
|
|
553
561
|
const fallback = getDefaultAliases()[chosen.alias];
|
|
554
562
|
if (fallback !== undefined) { cfg.aliases[chosen.alias] = fallback; }
|
|
@@ -599,6 +607,7 @@ async function runReadlineSetup() {
|
|
|
599
607
|
const { buildModelShortlist } = require('../utils/model-shortlist');
|
|
600
608
|
const shortlist = buildModelShortlist(pick.vendorPath, {
|
|
601
609
|
catalog,
|
|
610
|
+
providerFailures,
|
|
602
611
|
recommendedId: cfg.aliases[chosen.alias],
|
|
603
612
|
});
|
|
604
613
|
const specific = await promptForVendorModel(
|
package/src/sidecar/start.js
CHANGED
|
@@ -219,6 +219,7 @@ async function startSidecar(options) {
|
|
|
219
219
|
try {
|
|
220
220
|
const { appendSpend } = require('../utils/spend-ledger');
|
|
221
221
|
const { statusFromResult } = require('../utils/result-schema');
|
|
222
|
+
const { gatewayOf } = require('../utils/gateway-router');
|
|
222
223
|
appendSpend({
|
|
223
224
|
taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive', usage: runUsage,
|
|
224
225
|
op: 'start', status: statusFromResult(result), project: effectiveProject,
|
|
@@ -227,7 +228,7 @@ async function startSidecar(options) {
|
|
|
227
228
|
// inside createSessionMetadata. Reading `metadata.gateway` throws a ReferenceError the
|
|
228
229
|
// best-effort catch swallows → EVERY start-mode spend row silently dropped + start-json.test.js
|
|
229
230
|
// goes red. Use an in-scope value (spec-complete for direct/openrouter):
|
|
230
|
-
gateway:
|
|
231
|
+
gateway: gatewayOf(model),
|
|
231
232
|
// (To also attribute v4.2 'local': thread the resolved route gateway — dropped today at
|
|
232
233
|
// cli-handlers-run.js:47 — into createSessionMetadata and read `meta.gateway`, as continue.js:111 does.)
|
|
233
234
|
// v4.7 F8 D16: same in-scope-value rule as gateway above — `m` is the
|
package/src/template/render.js
CHANGED
|
@@ -14,12 +14,25 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
const VAR_RE = /\{\{\s*([A-Za-z_][\w.]*)\s*\}\}/g;
|
|
17
|
+
// Single source: validation and rendering both derive from this set — adding an
|
|
18
|
+
// entry here is sufficient for both. A simple variable {{foo_bar}} reads
|
|
19
|
+
// data.fooBar (snake_case slot, camelCase data key).
|
|
17
20
|
const KNOWN_VARIABLES = ['prompt', 'artifact', 'artifact_path', 'date', 'project', 'var.<key>'];
|
|
18
21
|
|
|
19
22
|
function knownList() {
|
|
20
23
|
return KNOWN_VARIABLES.map((v) => `{{${v}}}`).join(', ');
|
|
21
24
|
}
|
|
22
25
|
|
|
26
|
+
function isSimpleVariable(name) {
|
|
27
|
+
// Reads KNOWN_VARIABLES live, not a load-time copy, so extending the
|
|
28
|
+
// exported set is sufficient (VAR_RE can never match the 'var.<key>' entry).
|
|
29
|
+
return name !== 'var.<key>' && KNOWN_VARIABLES.includes(name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function dataKeyFor(name) {
|
|
33
|
+
return name.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
/**
|
|
24
37
|
* Render a template with strict typo-safety rules:
|
|
25
38
|
* unknown variable -> error; slot present without its data -> error; data
|
|
@@ -46,7 +59,7 @@ function renderTemplate(text, data) {
|
|
|
46
59
|
}
|
|
47
60
|
continue;
|
|
48
61
|
}
|
|
49
|
-
if (!
|
|
62
|
+
if (!isSimpleVariable(name)) {
|
|
50
63
|
return { error: `Error: Unknown template variable {{${name}}}. Known: ${knownList()}` };
|
|
51
64
|
}
|
|
52
65
|
}
|
|
@@ -76,12 +89,8 @@ function renderTemplate(text, data) {
|
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
const rendered = String(text).replace(VAR_RE, (_, name) => {
|
|
79
|
-
if (name
|
|
80
|
-
|
|
81
|
-
if (name === 'artifact_path') { return data.artifactPath; }
|
|
82
|
-
if (name === 'date') { return data.date; }
|
|
83
|
-
if (name === 'project') { return data.project; }
|
|
84
|
-
return vars[name.slice(4)];
|
|
92
|
+
if (name.startsWith('var.')) { return vars[name.slice(4)]; }
|
|
93
|
+
return data[dataKeyFor(name)];
|
|
85
94
|
});
|
|
86
95
|
|
|
87
96
|
return { text: rendered, notices };
|
package/src/utils/alias-audit.js
CHANGED
|
@@ -172,13 +172,20 @@ function suggestReplacements(staleModel, catalog, n = 3) {
|
|
|
172
172
|
* @param {Array<{id:string}>} catalog
|
|
173
173
|
* @returns {Array<{alias:string,stored:string,current:string}>}
|
|
174
174
|
*/
|
|
175
|
-
function findDriftedStoredAliases(sources,
|
|
176
|
-
|
|
175
|
+
function findDriftedStoredAliases(sources, catalogOrInfo) {
|
|
176
|
+
// Council #216 A1/B2/C1: accepts catalogInfo (or a bare array, for existing
|
|
177
|
+
// callers). Passing models WITHOUT providerFailures made this compute the bare
|
|
178
|
+
// direct form for a REJECTED namespace while sidecar/setup.js persists the
|
|
179
|
+
// gateway form -- reporting drift that does not exist, and suggesting a repair
|
|
180
|
+
// that writes back the unservable direct id issue 208 removed.
|
|
181
|
+
const info = Array.isArray(catalogOrInfo) ? { models: catalogOrInfo } : (catalogOrInfo || { models: [] });
|
|
182
|
+
const catalog = info.models || [];
|
|
183
|
+
if (catalog.length === 0) { return []; }
|
|
177
184
|
const { resolveQuickPicks, toStorableRoute } = require('./quick-picks');
|
|
178
185
|
const current = new Map();
|
|
179
186
|
for (const r of resolveQuickPicks(catalog)) {
|
|
180
187
|
if (r.source !== 'live') { continue; }
|
|
181
|
-
const stored = toStorableRoute(r);
|
|
188
|
+
const stored = toStorableRoute(r, info);
|
|
182
189
|
if (stored) { current.set(r.alias, { display: stored, routeValues: new Set(Object.values(r.routes)) }); }
|
|
183
190
|
}
|
|
184
191
|
const byProvider = idsByProvider(catalog);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module utils/alias-shadow-writer
|
|
3
|
+
* The alias-shadow notice's WRITE half: say it without ever sinking the run.
|
|
4
|
+
*
|
|
5
|
+
* EXTRACTED from src/utils/alias-shadow.js (PR #207 round 4). That file already
|
|
6
|
+
* separated the CHECK (read two alias tables, compare canonical forms) from the
|
|
7
|
+
* WRITE (get one advisory line onto a stream that may be a closed pipe), and
|
|
8
|
+
* rounds 2 and 3 put two independent hardenings on the write half alone —
|
|
9
|
+
* `safeWrite`'s synchronous guard and `armStream`'s asynchronous one. Round 4's
|
|
10
|
+
* B1 landed a third. The seam was already there; the file was at 286/300, and
|
|
11
|
+
* shaving the measurement prose that makes these guards auditable would have
|
|
12
|
+
* been the wrong economy, so the write half moved out whole.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here was ever part of `alias-shadow.js`'s `module.exports` — these are
|
|
15
|
+
* internal helpers, not a public API — so there is no re-export shim and no
|
|
16
|
+
* import path anywhere in the tree that changes.
|
|
17
|
+
*
|
|
18
|
+
* Named mutants live with their red sets in tests/alias-shadow.test.js:
|
|
19
|
+
* "WRITERFATAL" (drop `safeWrite`'s try/catch), "STREAMFATAL" (drop
|
|
20
|
+
* `armStream`'s attach-once 'error' handler) and "STREAMDEAF" (round 5 — put
|
|
21
|
+
* the PURE no-op handler back, so the arming goes deaf to every class again
|
|
22
|
+
* instead of only the benign one).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Write a notice without ever letting the writer sink the run (round 2, B1).
|
|
29
|
+
*
|
|
30
|
+
* The 'never throws' contract used to cover only the CHECK: the guard wrapped
|
|
31
|
+
* `findAliasShadows`, so a writer that threw — a caller-supplied collector that
|
|
32
|
+
* rejects, a stream whose write throws — escaped and killed the launch this
|
|
33
|
+
* diagnosis exists to protect. Worse, on the failure branch it escaped a second
|
|
34
|
+
* time, because that branch announced itself through the SAME broken writer. A
|
|
35
|
+
* notice must never be fatal to what it is describing, so the write is swallowed
|
|
36
|
+
* here and nowhere else.
|
|
37
|
+
*
|
|
38
|
+
* ⚠️ This covers the SYNCHRONOUS half only. A piped stderr fails on a later turn
|
|
39
|
+
* and never throws from `write()` at all — see `armStream` below, which is the
|
|
40
|
+
* other half of the same contract.
|
|
41
|
+
* @param {(line: string) => void} out
|
|
42
|
+
* @param {string} line
|
|
43
|
+
*/
|
|
44
|
+
function safeWrite(out, line) {
|
|
45
|
+
try { out(line); } catch { /* a diagnosis must never sink the run it diagnoses */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The armed marker, keyed ON THE STREAM (PR #207 round 4, B1).
|
|
50
|
+
*
|
|
51
|
+
* It used to be a module-scoped `WeakSet`, which made "attach once" true only
|
|
52
|
+
* once per MODULE INSTANCE. `jest.resetModules()` builds a fresh registry, the
|
|
53
|
+
* fresh copy of this module starts with an empty set, and the SAME
|
|
54
|
+
* `process.stderr` collects a second listener — measured at SEVEN across
|
|
55
|
+
* tests/alias-shadow.test.js alone, against Node's 10-listener
|
|
56
|
+
* MaxListenersExceededWarning. That warning is emitted ASYNCHRONOUSLY onto
|
|
57
|
+
* `process.stderr.write`, which is the method several of this feature's absence
|
|
58
|
+
* controls replace and exact-match on, so the accumulation was a
|
|
59
|
+
* nondeterministic flake waiting for two more fixtures. A marker that lives on
|
|
60
|
+
* the stream is immune: it is the same object either way.
|
|
61
|
+
*
|
|
62
|
+
* `Symbol.for`, not `Symbol()`: the well-known-symbol registry is per REALM, so
|
|
63
|
+
* every instance of this module — reset registry, second resolved path, npx
|
|
64
|
+
* copy loaded beside a global one — computes the SAME key and therefore agrees
|
|
65
|
+
* about what is already armed. A module-local `Symbol()` would be a fresh key
|
|
66
|
+
* per instance and would re-arm exactly like the WeakSet did.
|
|
67
|
+
*
|
|
68
|
+
* It also retains nothing: a swapped-out stream (tests do this) is dropped with
|
|
69
|
+
* its own marker attached, where the WeakSet had to be weak on purpose.
|
|
70
|
+
*/
|
|
71
|
+
const ARMED = Symbol.for('amicus.alias-shadow.armed');
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The self-report marker (PR #207 round 5, A3+B2+D1+C2). Same `Symbol.for`
|
|
75
|
+
* registry discipline, and the same reason, as `ARMED` above: one report per
|
|
76
|
+
* STREAM, agreed on by every instance of this module.
|
|
77
|
+
*/
|
|
78
|
+
const REPORTED = Symbol.for('amicus.alias-shadow.reported');
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stream failures that mean "the reader went away", and nothing more.
|
|
82
|
+
*
|
|
83
|
+
* This is the class the arming exists for: `amicus … | head`, a closed MCP
|
|
84
|
+
* client, a terminal that went away mid-write. There is nobody left to tell, so
|
|
85
|
+
* telling is pointless — these are swallowed in silence. Everything else is a
|
|
86
|
+
* real fault and gets said out loud (see `armStream`).
|
|
87
|
+
*
|
|
88
|
+
* `EOF` is here beside `EPIPE` because that is what a severed pipe reports on
|
|
89
|
+
* Windows, where this is developed; `ERR_STREAM_DESTROYED` and
|
|
90
|
+
* `ERR_STREAM_WRITE_AFTER_END` are Node's own spellings of the same "the sink is
|
|
91
|
+
* gone" fact for a destroyed/ended stream.
|
|
92
|
+
*/
|
|
93
|
+
const BENIGN_STREAM_ERRORS = new Set([
|
|
94
|
+
'EPIPE', 'EOF', 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END',
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Is this the reader-went-away class? A code-less error is NOT assumed benign —
|
|
99
|
+
* nothing says it is, and the honest default for an unrecognised fault is to
|
|
100
|
+
* report it.
|
|
101
|
+
* @param {*} err
|
|
102
|
+
*/
|
|
103
|
+
function isBenignStreamError(err) {
|
|
104
|
+
return !!err && BENIGN_STREAM_ERRORS.has(err.code);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Make a stream's write failures non-fatal, once — WITHOUT going deaf (round 3
|
|
109
|
+
* A1; round 5 A3+B2+D1, and the contested C2, same mechanism).
|
|
110
|
+
*
|
|
111
|
+
* MEASURED, node v24.18.0 on Windows, against a REAL closed pipe (parent spawns
|
|
112
|
+
* a child with `stdio: ['ignore','ignore','pipe']` and destroys the read end;
|
|
113
|
+
* the child then writes to `process.stderr`):
|
|
114
|
+
*
|
|
115
|
+
* · `write(line)` returns FALSE and throws NOTHING. `safeWrite`'s try/catch
|
|
116
|
+
* sees nothing at all. The EPIPE arrives on a LATER turn, as an 'error'
|
|
117
|
+
* event; with no listener, EventEmitter throws it, and that throw is an
|
|
118
|
+
* uncaughtException that ends the process (measured: exit code 7).
|
|
119
|
+
* · Passing a write CALLBACK does NOT fix it. The callback received the EPIPE
|
|
120
|
+
* AND the 'error' event still fired unhandled — same exit 7. This is why
|
|
121
|
+
* there is no callback here: it would observe the failure without disarming
|
|
122
|
+
* it, and read like a guard while being none.
|
|
123
|
+
* · Attaching for the duration of the write and detaching after is not merely
|
|
124
|
+
* racy, it is always WRONG: delivery is always on a later turn, so the
|
|
125
|
+
* detach always wins (measured: exit 7 again, with the handler's own log
|
|
126
|
+
* line showing it was removed before the error landed).
|
|
127
|
+
* · A persistent listener absorbs it, and a SECOND write raises a SECOND
|
|
128
|
+
* 'error' — so this must be `on`, never `once`.
|
|
129
|
+
*
|
|
130
|
+
* Hence: attach once, per stream object, and leave it.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ THE SCOPE IS THE WHOLE PROCESS, PERMANENTLY, and the round-3 text undersold
|
|
133
|
+
* it. From the first notice onward this handler receives EVERY 'error' raised on
|
|
134
|
+
* `process.stderr` by ANY producer in the process — not only the EPIPE from this
|
|
135
|
+
* feature's own write, and not only for the duration of the notice. It is
|
|
136
|
+
* sharpest in the MCP server, which imports this in-tree and then outlives every
|
|
137
|
+
* individual run. A pure no-op there would silently discard unrelated stderr
|
|
138
|
+
* faults for the life of the server, which is precisely the correct-but-SILENT
|
|
139
|
+
* degrade the product principle forbids. So the handler DISCRIMINATES: the
|
|
140
|
+
* benign reader-went-away class above is swallowed, and anything else is
|
|
141
|
+
* reported through the house logger.
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ WHY THE REPORT IS BOUNDED TO ONE. MEASURED: `utils/logger.js` writes with
|
|
144
|
+
* `console.error`, i.e. onto `process.stderr` — the same stream that just
|
|
145
|
+
* failed (its own header: "Outputs JSON-formatted logs to stderr"). An unbounded
|
|
146
|
+
* report could therefore provoke the next 'error', handle it, report again, and
|
|
147
|
+
* spin the event loop forever — round 3's B1 defect ("the announcement becomes
|
|
148
|
+
* the next escape") in its asynchronous form. One report per stream, wrapped in
|
|
149
|
+
* its own try/catch, is what makes speaking up safe. The report is best-effort
|
|
150
|
+
* BY CONSTRUCTION and is documented as such rather than promised.
|
|
151
|
+
*
|
|
152
|
+
* Checked rather than assumed: nothing in src/, bin/ or scripts/ attaches to or
|
|
153
|
+
* depends on `process.stderr`'s 'error' event (the `.on('error')` hits in the
|
|
154
|
+
* tree are all on CHILD process streams), and adding a listener removes nobody
|
|
155
|
+
* else's — any handler another module attaches still runs alongside this one.
|
|
156
|
+
* `electron/main.js` installs a listener on the same stream for the same
|
|
157
|
+
* round-3 reason, but its handler is silent for EVERY code; this one
|
|
158
|
+
* deliberately departs from that precedent for the non-benign class, which is
|
|
159
|
+
* exactly why the bound above had to exist first.
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ Scoped to the module's OWN default writer. An INJECTED writer (every test
|
|
162
|
+
* collector, and the MCP notices array) never reaches here, so nothing is armed
|
|
163
|
+
* on its behalf.
|
|
164
|
+
* @param {NodeJS.WritableStream} stream
|
|
165
|
+
* @param {(msg: string, ctx: object) => void} [log] the diagnostic seam —
|
|
166
|
+
* injected by tests; defaults to the house logger's `error`.
|
|
167
|
+
*/
|
|
168
|
+
function armStream(stream, log) {
|
|
169
|
+
if (!stream || typeof stream.on !== 'function' || stream[ARMED]) { return; }
|
|
170
|
+
try {
|
|
171
|
+
// `configurable` so a test that borrows a real stream can still take it
|
|
172
|
+
// back; symbol-keyed, so it is invisible to `Object.keys`, `JSON.stringify`
|
|
173
|
+
// and anything else that walks the stream object we are borrowing.
|
|
174
|
+
Object.defineProperty(stream, ARMED, { value: true, configurable: true });
|
|
175
|
+
} catch {
|
|
176
|
+
// A stream nobody can MARK cannot be armed exactly once, and arming it
|
|
177
|
+
// without a mark would trade round 3's unhandled-'error' hazard for an
|
|
178
|
+
// unbounded-listener one on that same stream. Node never freezes
|
|
179
|
+
// `process.stderr`, so this is a guard, not a path.
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
stream.on('error', (err) => {
|
|
183
|
+
// The reader went away. Nobody to tell; a diagnosis must never sink the run
|
|
184
|
+
// it diagnoses, and here it must not natter at a dead pipe either.
|
|
185
|
+
if (isBenignStreamError(err)) { return; }
|
|
186
|
+
if (stream[REPORTED]) { return; }
|
|
187
|
+
try {
|
|
188
|
+
Object.defineProperty(stream, REPORTED, { value: true, configurable: true });
|
|
189
|
+
} catch {
|
|
190
|
+
// Unreachable in practice: `ARMED` was defined on this same object moments
|
|
191
|
+
// ago, so the object takes symbol properties. Bounded-over-loud if it ever
|
|
192
|
+
// is reached — an unbounded report is the one failure mode worse than a
|
|
193
|
+
// missed one.
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const report = log || require('./logger').logger.error;
|
|
198
|
+
// The message rides in the CONTEXT, not the format string: the logger
|
|
199
|
+
// JSON.stringifies the entry, and JSON.stringify escapes every C0 byte
|
|
200
|
+
// (MEASURED: ESC serializes as the six characters \u001b), so its text
|
|
201
|
+
// cannot repaint a terminal from here the way `formatAliasShadow`'s
|
|
202
|
+
// config fragments could.
|
|
203
|
+
report('alias-shadow notice: unexpected error on the notice stream', {
|
|
204
|
+
code: (err && err.code) || null,
|
|
205
|
+
error: err && err.message ? String(err.message) : String(err),
|
|
206
|
+
});
|
|
207
|
+
} catch { /* a diagnosis must never sink the run it diagnoses */ }
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The default writer: stderr, armed against its own asynchronous failure. */
|
|
212
|
+
function writeNoticeToStderr(line) {
|
|
213
|
+
const stream = process.stderr;
|
|
214
|
+
armStream(stream);
|
|
215
|
+
stream.write(line);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// `ARMED` is deliberately NOT exported: it is this module's private marker, and
|
|
219
|
+
// a second holder of the key is a second thing that could clear it.
|
|
220
|
+
module.exports = { safeWrite, armStream, writeNoticeToStderr };
|