amicus 4.9.3 → 4.9.5
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 +324 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +8 -5
- package/docs/architecture-map.md +736 -0
- package/docs/configuration.md +165 -26
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +113 -0
- package/docs/usage.md +11 -6
- package/package.json +1 -1
- package/schemas/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/scripts/postinstall.js +4 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +3 -0
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli.js +5 -8
- package/src/council/briefings-chair.js +4 -2
- package/src/council/run-assemble.js +7 -2
- package/src/council/run-retry-notes.js +21 -1
- package/src/council/run-stages.js +8 -1
- package/src/headless.js +125 -7
- package/src/mcp-server.js +26 -0
- package/src/mcp-tools.js +4 -4
- package/src/opencode-client.js +84 -8
- package/src/pack/pack-validate.js +3 -0
- package/src/session-manager.js +2 -2
- package/src/sidecar/continue.js +6 -1
- package/src/sidecar/conversation-mirror.js +35 -11
- package/src/sidecar/electron-install.js +81 -81
- package/src/sidecar/electron-provision.js +179 -0
- package/src/sidecar/electron-trust.js +299 -0
- package/src/sidecar/fanout-leg-fallback.js +1 -0
- package/src/sidecar/fanout-leg.js +10 -2
- package/src/sidecar/fanout.js +2 -2
- package/src/sidecar/interactive.js +31 -4
- package/src/sidecar/models-ceiling-line.js +72 -0
- package/src/sidecar/models.js +4 -2
- package/src/sidecar/reopen-notices.js +97 -0
- package/src/sidecar/reopen-spend.js +3 -2
- package/src/sidecar/resume.js +15 -2
- package/src/sidecar/session-finalize.js +4 -1
- package/src/sidecar/session-utils.js +5 -1
- package/src/sidecar/start-metadata.js +1 -1
- package/src/sidecar/start.js +10 -5
- package/src/sidecar/unzip.js +40 -0
- package/src/utils/config.js +33 -12
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-output-budget-check.js +198 -0
- package/src/utils/engine-output-flag.js +105 -0
- package/src/utils/engine-variants.js +298 -0
- package/src/utils/http-get.js +284 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +12 -36
- package/src/utils/model-output-limit.js +21 -13
- package/src/utils/output-length.js +90 -0
- package/src/utils/result-schema.js +7 -2
- package/src/utils/spend-ledger.js +5 -1
- package/src/utils/thinking-validators.js +27 -80
- package/src/utils/validators.js +2 -3
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module models-ceiling-line
|
|
3
|
+
* The one `Ceilings:` line `amicus models --refresh` prints (#218 P3).
|
|
4
|
+
*
|
|
5
|
+
* It is an honest report of where the direct-provider context/output ceilings
|
|
6
|
+
* came from, or why they did not come at all — filled, already complete, still
|
|
7
|
+
* missing a number, failed, or skipped.
|
|
8
|
+
*
|
|
9
|
+
* Extracted from `models.js` (council #230 r4) because that file sat at 298 of
|
|
10
|
+
* the 300-line budget and this round adds two more branches to the formatter.
|
|
11
|
+
* It owns the WORDING only: the outcome object it renders is built by
|
|
12
|
+
* `src/utils/model-ceilings-modelsdev.js` and persisted by
|
|
13
|
+
* `src/utils/model-catalog.js` as the cache document's `ceilingEnrichment`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How a ceiling-enrichment failure is worded, keyed by its `failure.reason`
|
|
20
|
+
* (council #230 D4/C1). `http-status`, `parse-error`, `too-large` and
|
|
21
|
+
* `bad-shape` all mean models.dev ANSWERED; `exception` — and any reason a later
|
|
22
|
+
* failure invents — is a local bug, so it falls through to a neutral lead.
|
|
23
|
+
*/
|
|
24
|
+
const CEILING_FAILURE_LEAD = {
|
|
25
|
+
timeout: 'models.dev unreachable',
|
|
26
|
+
'network-error': 'models.dev unreachable',
|
|
27
|
+
'http-status': 'models.dev answered but could not be used',
|
|
28
|
+
'parse-error': 'models.dev answered but could not be used',
|
|
29
|
+
'too-large': 'models.dev answered but could not be used',
|
|
30
|
+
'bad-shape': 'models.dev answered but could not be used',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* #218 P3: one honest line about where the direct-provider ceilings came from.
|
|
35
|
+
* @param {object|null} e the persisted `ceilingEnrichment` outcome
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function fmtCeilingLine(e) {
|
|
39
|
+
// Unreachable in production after #218 P3 (every successful refresh persists a
|
|
40
|
+
// ceilingEnrichment object); kept for a hand-built or pre-field cache doc.
|
|
41
|
+
if (!e) { return 'Ceilings: not attempted'; }
|
|
42
|
+
if (e.failure) {
|
|
43
|
+
const f = e.failure;
|
|
44
|
+
const why = f.reason + (f.status ? ` ${f.status}` : '') + (f.detail ? `: ${f.detail}` : '');
|
|
45
|
+
const lead = CEILING_FAILURE_LEAD[f.reason] || 'ceiling enrichment failed';
|
|
46
|
+
// #218 PR 4 (whole-branch review VCMD-4): the flag never reaches a direct openai row — M5/M13/M22, the same fact the disabled literal below states.
|
|
47
|
+
return `Ceilings: ${lead} (${why}); rows without a ceiling get an outputBudget through the engine flag alone, clamped only where the engine's own catalog knows the model; direct openai rows send no output reservation at all (#218 PR 4, M5/M13/M22)`;
|
|
48
|
+
}
|
|
49
|
+
// A skip is not a failure and not a fill: naming which one it was is the
|
|
50
|
+
// difference between "you turned this off" and "there was nothing to do".
|
|
51
|
+
if (e.skipped === 'disabled') {
|
|
52
|
+
// Named, not "direct routes": Google publishes its own ceiling first-party
|
|
53
|
+
// and OpenRouter rows keep OpenRouter's. Since PR 2 the budget still
|
|
54
|
+
// reaches the anthropic/deepseek rows through the engine flag, clamped by
|
|
55
|
+
// the engine's own catalog (probe K5/K12); it never reaches a direct
|
|
56
|
+
// openai row at all (#218 PR 4, M5/M13/M22).
|
|
57
|
+
return 'Ceilings: models.dev lookup disabled (modelsDevCeilings: false); anthropic/deepseek direct rows carry no ceiling here and are clamped by the engine\'s own catalog instead; direct openai rows send no output reservation at all (#218 PR 4) (Google publishes its own ceiling and OpenRouter rows keep OpenRouter\'s)';
|
|
58
|
+
}
|
|
59
|
+
if (e.skipped === 'nothing-to-fill') {
|
|
60
|
+
// NOT "every row": routers, local rows and malformed rows are not
|
|
61
|
+
// candidates and are never asked about (council #230 C2).
|
|
62
|
+
return 'Ceilings: nothing to fill (no candidate row is missing a number)';
|
|
63
|
+
}
|
|
64
|
+
// `?? 0`: a hand-built or pre-field cache doc can carry a partial object, and
|
|
65
|
+
// `undefined already complete` would be a worse lie than a zero. `stillMissing`
|
|
66
|
+
// deliberately overlaps the other counters — it is the STATE the pass left,
|
|
67
|
+
// and it is the number that says whether outputBudget can clamp those rows.
|
|
68
|
+
return `Ceilings: ${e.filled ?? 0} rows filled from models.dev (${e.alreadyKnown ?? 0} already complete, ` +
|
|
69
|
+
`${e.unknown ?? 0} unknown to models.dev, ${e.stillMissing ?? 0} still missing a number)`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { fmtCeilingLine };
|
package/src/sidecar/models.js
CHANGED
|
@@ -22,6 +22,7 @@ const { pickCurrent } = require('../utils/quick-picks');
|
|
|
22
22
|
const { probeStoredAliases, selectStoredAliases } = require('./models-probe');
|
|
23
23
|
const { DEFAULT_MAX_LEGS } = require('./fanout-validate');
|
|
24
24
|
const { fmtRow, fmtGatewayFinding, fmtProbeLine, fmtProviderFailure } = require('./models-render');
|
|
25
|
+
const { fmtCeilingLine } = require('./models-ceiling-line');
|
|
25
26
|
|
|
26
27
|
const CHECK_EXIT_CAP = 100;
|
|
27
28
|
|
|
@@ -87,7 +88,7 @@ function fmtLiveSkipped(reason) {
|
|
|
87
88
|
|
|
88
89
|
async function runRefresh(args) {
|
|
89
90
|
const models = await refreshCatalog();
|
|
90
|
-
const { fetchedAt, lastRefreshAttempt, lastRefreshError } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
|
|
91
|
+
const { fetchedAt, lastRefreshAttempt, lastRefreshError, ceilingEnrichment } = await getCatalogInfo({ maxAgeMs: Number.POSITIVE_INFINITY });
|
|
91
92
|
// --refresh short-circuits --check below (args.check is guaranteed true here) — must announce, not silently skip.
|
|
92
93
|
if (args.live) {
|
|
93
94
|
const line = fmtLiveSkipped('refresh-precedes-check');
|
|
@@ -95,7 +96,7 @@ async function runRefresh(args) {
|
|
|
95
96
|
}
|
|
96
97
|
if (args.json) {
|
|
97
98
|
process.stdout.write(JSON.stringify(buildCatalogDoc({
|
|
98
|
-
models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError
|
|
99
|
+
models, fetchedAt, refreshed: true, lastRefreshAttempt, lastRefreshError, ceilingEnrichment
|
|
99
100
|
}), null, 2) + '\n');
|
|
100
101
|
return models.length === 0 && !fetchedAt ? 1 : 0;
|
|
101
102
|
}
|
|
@@ -112,6 +113,7 @@ async function runRefresh(args) {
|
|
|
112
113
|
return 1; // no cache at all: a real failure
|
|
113
114
|
}
|
|
114
115
|
process.stdout.write(`Refreshed catalog: ${models.length} models.\n`);
|
|
116
|
+
process.stdout.write(fmtCeilingLine(ceilingEnrichment) + '\n');
|
|
115
117
|
process.stdout.write(`Cache: ${catalogPath()}\n`);
|
|
116
118
|
return 0;
|
|
117
119
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module reopen-notices
|
|
3
|
+
* The stderr Notice a reopen owes the user for the effort level it does NOT carry (#218 PR 4, council #235 r5 J1/A3).
|
|
4
|
+
* A session started with `--thinking <level>` sends no variant on any resumed or
|
|
5
|
+
* continued leg, and since council #235 r2 both commands REJECT the flag, so the
|
|
6
|
+
* user cannot ask for one there either. Level inheritance was scoped out of this PR
|
|
7
|
+
* deliberately and stays out (filed, not built) — but the silence is the defect: it
|
|
8
|
+
* is the same mid-conversation degrade this release cites against 4.9.3, and the
|
|
9
|
+
* project's rule is that a level which will not take effect says so. Named mutants
|
|
10
|
+
* "RESUMELEVELSILENT" / "CONTINUELEVELSILENT" (tests/sidecar/reopen-thinking-notice.test.js):
|
|
11
|
+
* drop the call at either reopen site.
|
|
12
|
+
*
|
|
13
|
+
* Notices go to STDERR only — stdout carries the `--json` run document and the
|
|
14
|
+
* fold summary, and nothing here may perturb either (same rule as the unverified
|
|
15
|
+
* note in src/headless.js and src/sidecar/interactive.js).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { collapseExcerpt } = require('../utils/text-sanitize');
|
|
19
|
+
|
|
20
|
+
/** Longest LEVEL echoed from on-disk metadata into a one-line Notice — a level is one word. */
|
|
21
|
+
const MAX_LEVEL_CHARS = 40;
|
|
22
|
+
/**
|
|
23
|
+
* Longest TASK ID echoed. It is not an attacker-shaped fragment: `validators.js ::
|
|
24
|
+
* TASK_ID_PATTERN` is `/^[a-zA-Z0-9_-]{1,64}$/`, which already excludes every character the
|
|
25
|
+
* sanitizer strips — so the cap is what a VALID id can be. Capping it at the LEVEL's 40
|
|
26
|
+
* named a session that does not exist (council #235 r5 wave 6 repair).
|
|
27
|
+
*/
|
|
28
|
+
const MAX_TASK_ID_CHARS = 64;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Collapse an on-disk fragment to one safe line.
|
|
32
|
+
* metadata.json is a FILE — a hand-edited or corrupted `thinking` value must not be
|
|
33
|
+
* able to forge a second `Notice:` line or smuggle control characters into a terminal
|
|
34
|
+
* (the hazard src/utils/alias-shadow.js :: safeFragment was written against).
|
|
35
|
+
*
|
|
36
|
+
* The SANITIZING is the house sanitizer's: `utils/text-sanitize.js :: collapseExcerpt` is the
|
|
37
|
+
* only one ("a second implementation would be a second set of holes", and the holes are the
|
|
38
|
+
* point — ANSI sequences and the PRINTABLE-range bidi controls are classes a private
|
|
39
|
+
* control-character regex cannot see, and this module quotes both straight out of a file).
|
|
40
|
+
* The fence/tag defang here is ADDITIVE and runs BEFORE that pass, so the house whitespace
|
|
41
|
+
* collapse and cap still govern the result: it adds a class, it never replaces one. Same
|
|
42
|
+
* shape as src/utils/alias-shadow.js :: safeFragment (council #235 r5 wave 6 repair).
|
|
43
|
+
* @param {unknown} value
|
|
44
|
+
* @param {number} maxChars
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
function safeFragment(value, maxChars) {
|
|
48
|
+
return collapseExcerpt(String(value).replace(/[`<>]/g, ' '), maxChars);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The value 4.9.3 and earlier stamped on EVERY session's metadata whether or not the flag was
|
|
53
|
+
* typed — and never sent (probe F1). It is the one recorded level whose provenance is unknowable
|
|
54
|
+
* from disk, so the line SAYS that rather than reporting it as something the user asked for.
|
|
55
|
+
*/
|
|
56
|
+
const LEGACY_UNCONDITIONAL_STAMP = 'medium';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The Notice line for a reopen that drops the session's recorded effort level, or null.
|
|
60
|
+
* Null whenever the session records no level — nothing was asked for, so nothing is dropped.
|
|
61
|
+
*
|
|
62
|
+
* It reports what the metadata RECORDS, not what was typed (council #235 r5 wave 6 repair).
|
|
63
|
+
* Those are not the same proposition: 4.9.3 and earlier wrote `thinking: 'medium'` on every
|
|
64
|
+
* session, flag or no flag, so the entire on-disk session history at upgrade time reaches this
|
|
65
|
+
* line carrying a level nobody requested — and for those legs nothing degraded, because that
|
|
66
|
+
* `medium` was never sent either. Nothing on disk tells the two apart (the same reason this
|
|
67
|
+
* release refuses a pack migration), so the line states the record and names the ambiguity.
|
|
68
|
+
* @param {{taskId: string, level: unknown, kind: 'resume'|'continue'}} a
|
|
69
|
+
* @returns {string|null}
|
|
70
|
+
*/
|
|
71
|
+
function formatDroppedLevelNotice({ taskId, level, kind }) {
|
|
72
|
+
if (typeof level !== 'string') { return null; }
|
|
73
|
+
const shown = safeFragment(level, MAX_LEVEL_CHARS);
|
|
74
|
+
if (!shown) { return null; }
|
|
75
|
+
// Named mutant "NOTICECLAIMSINTENT": say "was started with" again, or drop this clause.
|
|
76
|
+
const provenance = shown === LEGACY_UNCONDITIONAL_STAMP
|
|
77
|
+
? ' (4.9.3 and earlier recorded medium on every session, typed or not, and never sent it)'
|
|
78
|
+
: '';
|
|
79
|
+
const what = kind === 'continue'
|
|
80
|
+
? 'this continuation opens a NEW session and sends no effort level, so it runs at the provider\'s default — a level belongs on the `start` that opens a session and is not carried across a reopen'
|
|
81
|
+
: 'this resumed leg sends no effort level and runs at the provider\'s default — a level is not carried across a reopen';
|
|
82
|
+
return `Notice: session ${safeFragment(taskId, MAX_TASK_ID_CHARS)} records --thinking ${shown}${provenance}; ${what}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Write that Notice to stderr when the session recorded a level. No-op otherwise.
|
|
87
|
+
* @param {object} metadata - the session metadata whose `thinking` is read (resume: the session's own; continue: the PARENT's)
|
|
88
|
+
* @param {{taskId: string, kind: 'resume'|'continue'}} a
|
|
89
|
+
* @returns {string|null} the line written, or null when nothing was written
|
|
90
|
+
*/
|
|
91
|
+
function noticeDroppedLevel(metadata, { taskId, kind }) {
|
|
92
|
+
const note = formatDroppedLevelNotice({ taskId, level: metadata && metadata.thinking, kind });
|
|
93
|
+
if (note) { process.stderr.write(`${note}\n`); }
|
|
94
|
+
return note;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { formatDroppedLevelNotice, noticeDroppedLevel };
|
|
@@ -23,8 +23,9 @@ function finalizeSpendForReopen({ taskId, model, mode, op, result, status, proje
|
|
|
23
23
|
const { gatewayOf } = require('../utils/gateway-router');
|
|
24
24
|
const gateway = metadata.gateway || gatewayOf(model);
|
|
25
25
|
// v4.7.1 Task 7 D16: null-not-absent, the OPPOSITE convention from
|
|
26
|
-
// metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:
|
|
27
|
-
|
|
26
|
+
// metadata.tag's absent-not-null (D13) — same `|| null` idiom as start.js:240.
|
|
27
|
+
// #218 PR 3/PR 4: `finish` and `variant` are the OPPOSITE — absent-not-null: appendSpend writes each key only when it is a string. Named mutant "SOLOROWNOVARIANT" (tests/continue-resume-spend.test.js): drop `variant`.
|
|
28
|
+
appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null, finish: result && result.finish, variant: result && result.variant }, ctx);
|
|
28
29
|
} catch { /* best-effort */ }
|
|
29
30
|
}
|
|
30
31
|
return { usage };
|
package/src/sidecar/resume.js
CHANGED
|
@@ -16,6 +16,7 @@ const {
|
|
|
16
16
|
checkSessionLiveness
|
|
17
17
|
} = require('./session-utils');
|
|
18
18
|
const { acquireLock, releaseLock } = require('../utils/session-lock');
|
|
19
|
+
const { noticeDroppedLevel } = require('./reopen-notices');
|
|
19
20
|
const { runHeadless } = require('../headless');
|
|
20
21
|
const { extractNonceFromText, generateFoldNonce, stripFoldMarkers } = require('../utils/fold-marker');
|
|
21
22
|
const { logger } = require('../utils/logger');
|
|
@@ -41,7 +42,8 @@ function loadInitialContext(sessionDir) {
|
|
|
41
42
|
/** Check for file drift - files that were read may have changed */
|
|
42
43
|
function checkFileDrift(metadata, project) {
|
|
43
44
|
const filesRead = metadata.filesRead || [];
|
|
44
|
-
|
|
45
|
+
// council #235 r5 (J2/A4): the previous attempt's terminal timestamps are all deleted on every running write (updateSessionStatus below), so this — their one legitimate reader — falls back through them. `abortedAt` is a terminal stamp of that attempt and later than its start, so it is read first (wave 6 repair); `resumedAt` is when a crashed attempt STARTED, strictly more accurate than the previous attempt's completion. Named mutants "DRIFTNORESUMEDAT" / "DRIFTNOABORTEDAT": drop either middle term.
|
|
46
|
+
const lastActivity = metadata.completedAt || metadata.abortedAt || metadata.resumedAt || metadata.createdAt;
|
|
45
47
|
const lastActivityTime = new Date(lastActivity).getTime();
|
|
46
48
|
const changedFiles = [];
|
|
47
49
|
|
|
@@ -112,6 +114,13 @@ function updateSessionStatus(sessionDir, status) {
|
|
|
112
114
|
meta.status = status;
|
|
113
115
|
if (status === 'running') {
|
|
114
116
|
meta.resumedAt = new Date().toISOString();
|
|
117
|
+
// #218 PR 4 whole-branch review (REC-3): the per-attempt fields are this attempt's to stamp
|
|
118
|
+
// — an abort or crash of the resumed run must not ship the previous attempt's as its own
|
|
119
|
+
// (the terminal writers preserve every key they do not set). Named mutant
|
|
120
|
+
// "RESUMESTALEVARIANT" (tests/sidecar/resume.test.js): drop the three deletes.
|
|
121
|
+
// council #235 r5 (J2/A4): `reason` and the terminal timestamps are stamped ONLY by a terminal writer, so an attempt that never reaches one must not inherit the previous attempt's — a resume that crashes mid-attempt used to leave `status: 'running'` beside the last failure's reason and completion time, and both are read (src/utils/result-schema.js reports `metadata.reason` for every non-complete status and `completedAt || abortedAt` as the end; the MCP server prints the reason and adds `crashedAt` to that chain). ALL THREE timestamps go (wave 6 repair): `abortedAt` is the one an `amicus abort` writes and `completedAt` is never written on that path, so clearing only `completedAt` left the defect live on the commonest precursor to a resume — and, for an attempt that stamped both, made the reported end fall through to the OLDER one. Named mutants "RESUMESTALEREASON" (drop `reason`/`completedAt`) and "RESUMESTALEABORTEDAT" (drop `abortedAt`/`crashedAt`).
|
|
122
|
+
delete meta.finish; delete meta.variant; delete meta.variantUnverified;
|
|
123
|
+
delete meta.reason; delete meta.completedAt; delete meta.abortedAt; delete meta.crashedAt;
|
|
115
124
|
}
|
|
116
125
|
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2));
|
|
117
126
|
return meta;
|
|
@@ -135,6 +144,7 @@ async function resumeSidecar(options) {
|
|
|
135
144
|
|
|
136
145
|
// Load previous session data
|
|
137
146
|
const metadata = loadSessionMetadata(sessionDir);
|
|
147
|
+
noticeDroppedLevel(metadata, { taskId, kind: 'resume' }); // council #235 r5 (J1/A3): this leg sends no variant and `resume` rejects --thinking, so a session started with a level silently degrades to the provider's default — the very degrade this release cites against 4.9.3. Inheritance stays filed, not built; the silence does not. Named mutant "RESUMELEVELSILENT" (tests/sidecar/reopen-thinking-notice.test.js).
|
|
138
148
|
const systemPrompt = loadInitialContext(sessionDir);
|
|
139
149
|
|
|
140
150
|
// Dead-process detection: log if the previous process is no longer alive
|
|
@@ -242,11 +252,14 @@ async function resumeSidecar(options) {
|
|
|
242
252
|
if (terminal.status === 'error') {
|
|
243
253
|
updatedMetadata.status = 'error';
|
|
244
254
|
updatedMetadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
255
|
+
if (result && typeof result.finish === 'string') { updatedMetadata.finish = result.finish; } else { delete updatedMetadata.finish; } // #218 PR 3: emit-when-set; a stale one is removed (council #232 r1 B1)
|
|
256
|
+
if (result && typeof result.variant === 'string') { updatedMetadata.variant = result.variant; } else { delete updatedMetadata.variant; } // #218 PR 4: same rule as finish (named mutant "RESUMEERRORNOVARIANT", tests/continue-resume-spend.test.js)
|
|
257
|
+
if (result && result.variantUnverified === true) { updatedMetadata.variantUnverified = true; } else { delete updatedMetadata.variantUnverified; }
|
|
245
258
|
updatedMetadata.completedAt = new Date().toISOString();
|
|
246
259
|
writeFileAtomic(metaPath, JSON.stringify(updatedMetadata, null, 2), { mode: 0o600 });
|
|
247
260
|
logger.error('Resume completed with error', { taskId, error: updatedMetadata.reason });
|
|
248
261
|
} else {
|
|
249
|
-
finalizeSession(sessionDir, summary, project, updatedMetadata, { quietStdout: json, status: terminal.status });
|
|
262
|
+
finalizeSession(sessionDir, summary, project, updatedMetadata, { quietStdout: json, status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified }); // named mutant "RESUMEVARIANTDROPPED" (tests/continue-resume-spend.test.js)
|
|
250
263
|
}
|
|
251
264
|
// v4.3: attribute resume spend (C9/E4). Reload metadata, write usage + append
|
|
252
265
|
// a ledger row (status: statusFromResult, matching start.js — not terminal.status).
|
|
@@ -51,6 +51,9 @@ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
|
|
|
51
51
|
fs.writeFileSync(SessionPaths.summaryFile(sessionDir), result && result.summary ? result.summary : '', { mode: 0o600 });
|
|
52
52
|
metadata.status = 'error';
|
|
53
53
|
metadata.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
54
|
+
if (result && typeof result.finish === 'string') { metadata.finish = result.finish; } else { delete metadata.finish; } // #218 PR 3: emit-when-set; a stale one is removed (council #232 r1 B1)
|
|
55
|
+
if (result && typeof result.variant === 'string') { metadata.variant = result.variant; } else { delete metadata.variant; } // #218 PR 4: same rule as finish (named mutant "SHAREDNOVARIANT", tests/shared-server-finalize.test.js)
|
|
56
|
+
if (result && result.variantUnverified === true) { metadata.variantUnverified = true; } else { delete metadata.variantUnverified; }
|
|
54
57
|
metadata.completedAt = new Date().toISOString();
|
|
55
58
|
writeFileAtomic(
|
|
56
59
|
path.join(sessionDir, 'metadata.json'),
|
|
@@ -61,7 +64,7 @@ function finalizeHeadlessResult(sessionDir, result, project, metadata) {
|
|
|
61
64
|
}
|
|
62
65
|
// complete / timed-out / aborted: persist the (possibly partial) summary with
|
|
63
66
|
// the resolved status. Explicit status means the #36 guard won't re-classify.
|
|
64
|
-
finalizeSession(sessionDir, (result && result.summary) || '', project, metadata, { status: terminal.status });
|
|
67
|
+
finalizeSession(sessionDir, (result && result.summary) || '', project, metadata, { status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified });
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
module.exports = { resolveTerminalState, finalizeHeadlessResult };
|
|
@@ -70,7 +70,7 @@ function saveInitialContext(sessionDir, systemPrompt, userMessage) {
|
|
|
70
70
|
fs.writeFileSync(SessionPaths.contextFile(sessionDir), content, { mode: 0o600 });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/** Finalize session - detect conflicts, save summary, update metadata */
|
|
73
|
+
/** Finalize session - detect conflicts, save summary, update metadata. opts.finish (#218 PR 3) stamps metadata.finish when set and REMOVES a prior one otherwise; opts.variant / opts.variantUnverified (#218 PR 4) follow the same rule — a resumed run reuses the same metadata and must not inherit the last attempt's finish (council #232 r1 B1). */
|
|
74
74
|
function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
|
|
75
75
|
const metaPath = SessionPaths.metadataFile(sessionDir);
|
|
76
76
|
|
|
@@ -102,6 +102,10 @@ function finalizeSession(sessionDir, summary, project, metadata, opts = {}) {
|
|
|
102
102
|
// summary must never silently default to 'complete' — that hid errored/empty
|
|
103
103
|
// shared-server runs behind a 0-byte summary and a false success.
|
|
104
104
|
const hasSummary = typeof summary === 'string' && summary.trim().length > 0;
|
|
105
|
+
if (typeof opts.finish === 'string') { metadata.finish = opts.finish; } else { delete metadata.finish; }
|
|
106
|
+
// #218 PR 4: the effort level SENT and whether the engine's catalogue knew the model — the same emit-when-set / delete-when-absent rule as finish. Named mutants "SOLOVARIANTDROPPED" / "STALEVARIANT" (tests/sidecar/session-utils.test.js).
|
|
107
|
+
if (typeof opts.variant === 'string') { metadata.variant = opts.variant; } else { delete metadata.variant; }
|
|
108
|
+
if (opts.variantUnverified === true) { metadata.variantUnverified = true; } else { delete metadata.variantUnverified; }
|
|
105
109
|
metadata.status = opts.status || (hasSummary ? 'complete' : 'error');
|
|
106
110
|
metadata.completedAt = new Date().toISOString();
|
|
107
111
|
writeFileAtomic(metaPath, JSON.stringify(metadata, null, 2), { mode: 0o600 });
|
|
@@ -42,7 +42,7 @@ function createSessionMetadata(taskId, project, options) {
|
|
|
42
42
|
briefing: effectiveBriefing,
|
|
43
43
|
mode: isHeadless ? 'headless' : 'interactive',
|
|
44
44
|
agent: agent || (isHeadless ? 'build' : 'chat'),
|
|
45
|
-
thinking
|
|
45
|
+
...(thinking ? { thinking } : {}), // #218 PR 4: emit-when-requested — 'medium' was never sent (probe F1), so a run with no --thinking runs at the provider's default and records none (named mutant "MEDIUMDEFAULT")
|
|
46
46
|
status: 'running',
|
|
47
47
|
pid: existing.pid || process.pid,
|
|
48
48
|
createdAt: existing.createdAt || new Date().toISOString(),
|
package/src/sidecar/start.js
CHANGED
|
@@ -125,7 +125,7 @@ async function startSidecar(options) {
|
|
|
125
125
|
mcp, mcpConfig, clientType: client, noMcp, excludeMcp, projectDir: effectiveProject
|
|
126
126
|
});
|
|
127
127
|
const taskId = options.taskId || generateTaskId();
|
|
128
|
-
const
|
|
128
|
+
const variant = thinking || undefined; // #218 PR 4: the level itself is the engine's `variant` field (named mutant "STARTVARIANTDROPPED", tests/sidecar/start.test.js: drop `variant` from the two options objects below)
|
|
129
129
|
// 15b.3: one nonce per run, generated BEFORE prompt construction so the
|
|
130
130
|
// SAME value can be baked into the prompt's instruction (buildPrompts) and
|
|
131
131
|
// handed to the detector (runHeadless.options.nonce / the GUI fold writer
|
|
@@ -158,7 +158,7 @@ async function startSidecar(options) {
|
|
|
158
158
|
result = await runHeadless(
|
|
159
159
|
model, systemPrompt, userMessage, taskId, effectiveProject,
|
|
160
160
|
timeout * 60 * 1000, agent || 'build',
|
|
161
|
-
{ mcp: mcpServers, summaryLength,
|
|
161
|
+
{ mcp: mcpServers, summaryLength, variant, port: opencodePort, nonce: foldNonce }
|
|
162
162
|
);
|
|
163
163
|
} catch (err) {
|
|
164
164
|
if (!json) { throw err; }
|
|
@@ -174,7 +174,7 @@ async function startSidecar(options) {
|
|
|
174
174
|
logger.info('Launching interactive sidecar', { taskId, model, agent: effectiveAgent });
|
|
175
175
|
result = await runInteractive(
|
|
176
176
|
model, systemPrompt, userMessage, taskId, effectiveProject,
|
|
177
|
-
{ agent, mcp: mcpServers,
|
|
177
|
+
{ agent, mcp: mcpServers, variant, client, windowPosition: position, foldNonce }
|
|
178
178
|
);
|
|
179
179
|
summary = result.summary || '';
|
|
180
180
|
if (result.error) { logger.error('Interactive task error', { taskId, error: result.error }); }
|
|
@@ -200,12 +200,15 @@ async function startSidecar(options) {
|
|
|
200
200
|
if (terminal.status === 'error') {
|
|
201
201
|
meta.status = 'error';
|
|
202
202
|
meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
|
|
203
|
+
if (result && typeof result.finish === 'string') { meta.finish = result.finish; } // #218 PR 3: emit-when-set; a fresh session's metadata has no prior finish to remove (resume's does -- resume.js)
|
|
204
|
+
if (result && typeof result.variant === 'string') { meta.variant = result.variant; } // #218 PR 4: emit-when-set, like finish (named mutant "SOLOERRORNOVARIANT", tests/start-terminal-status.test.js)
|
|
205
|
+
if (result && result.variantUnverified === true) { meta.variantUnverified = true; }
|
|
203
206
|
meta.completedAt = new Date().toISOString();
|
|
204
207
|
writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
|
|
205
208
|
logger.error('Session completed with error', { taskId, error: meta.reason });
|
|
206
209
|
} else {
|
|
207
210
|
// complete / timed-out / aborted: persist the (possibly partial) summary with the correct status.
|
|
208
|
-
finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json, status: terminal.status });
|
|
211
|
+
finalizeSession(sessDir, summary, effectiveProject, meta, { quietStdout: json, status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified });
|
|
209
212
|
}
|
|
210
213
|
|
|
211
214
|
const { resolveUsage } = require('../utils/pricing');
|
|
@@ -223,6 +226,8 @@ async function startSidecar(options) {
|
|
|
223
226
|
appendSpend({
|
|
224
227
|
taskId, model, mode: effectiveHeadless ? 'headless' : 'interactive', usage: runUsage,
|
|
225
228
|
op: 'start', status: statusFromResult(result), project: effectiveProject,
|
|
229
|
+
finish: result && result.finish, // #218 PR 3: appendSpend keeps it only when it is a string
|
|
230
|
+
variant: result && result.variant, // #218 PR 4: same emit-when-set rule (named mutant "SOLOROWNOVARIANT", tests/start-json.test.js)
|
|
226
231
|
// ⚠️ DE-ROT: `metadata` is NOT in scope at startSidecar's finalize site — the objects
|
|
227
232
|
// there are `meta` (createSessionMetadata result) and `m`; `metadata` is a local only
|
|
228
233
|
// inside createSessionMetadata. Reading `metadata.gateway` throws a ReferenceError the
|
|
@@ -232,7 +237,7 @@ async function startSidecar(options) {
|
|
|
232
237
|
// (To also attribute v4.2 'local': thread the resolved route gateway — dropped today at
|
|
233
238
|
// cli-handlers-run.js:47 — into createSessionMetadata and read `meta.gateway`, as continue.js:111 does.)
|
|
234
239
|
// v4.7 F8 D16: same in-scope-value rule as gateway above — `m` is the
|
|
235
|
-
// just-re-read metadata (line
|
|
240
|
+
// just-re-read metadata (line 215), which carries `tag` when
|
|
236
241
|
// createSessionMetadata stored one (absent otherwise); `|| null` folds
|
|
237
242
|
// that into spend-ledger.js's null-not-absent dim convention.
|
|
238
243
|
tag: m.tag || null,
|
package/src/sidecar/unzip.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* the exact electron zip the field box choked on.
|
|
18
18
|
* 3. Only report success when files actually landed on disk. The electron
|
|
19
19
|
* exe-stat verify stays upstream (electron-quarantine.verifyExtractOutcome).
|
|
20
|
+
* Layer 2 has ONE exception: a path-traversal REFUSAL is terminal and is never
|
|
21
|
+
* retried natively (UNSAFE_PATTERNS below).
|
|
20
22
|
*
|
|
21
23
|
* Everything network/spawn/timer-facing is dependency-INJECTABLE so tests never
|
|
22
24
|
* hit the real clock, spawn a real process, or extract a real binary.
|
|
@@ -28,6 +30,32 @@ const path = require('path');
|
|
|
28
30
|
const fsDefault = require('fs');
|
|
29
31
|
const { spawnSync } = require('child_process');
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* A SECURITY REFUSAL IS A REFUSAL, NOT A RETRY (M9).
|
|
35
|
+
*
|
|
36
|
+
* Strategy 1 used to collapse a stall, a plain throw, and extract-zip's / yauzl's
|
|
37
|
+
* own path-traversal refusals into ONE branch that cleans the directory and re-runs
|
|
38
|
+
* the IDENTICAL archive through OS extractors amicus does not control — laundering
|
|
39
|
+
* a "this archive tried to escape its directory" into an unsupervised retry.
|
|
40
|
+
*
|
|
41
|
+
* These four strings are verified against the installed versions:
|
|
42
|
+
* extract-zip@2.0.1 raises `Out of bound path "<dir>" found while processing file <n>`
|
|
43
|
+
* yauzl@2.10.0 validateFileName returns the other three, raised as new Error(msg).
|
|
44
|
+
* NOTE: with strictFileNames unset (extract-zip's default) yauzl rewrites
|
|
45
|
+
* backslashes before validating, so `invalid characters in fileName: ` is not
|
|
46
|
+
* reachable through extract-zip today. Classified anyway — it costs one line and
|
|
47
|
+
* yauzl's defaults can change.
|
|
48
|
+
*
|
|
49
|
+
* DELIBERATELY NARROW. A stall must still fall back, or the Node-24 workaround
|
|
50
|
+
* this whole module exists for is destroyed.
|
|
51
|
+
*/
|
|
52
|
+
const UNSAFE_PATTERNS = [
|
|
53
|
+
/^Out of bound path /,
|
|
54
|
+
/^absolute path: /,
|
|
55
|
+
/^invalid relative path: /,
|
|
56
|
+
/^invalid characters in fileName: /,
|
|
57
|
+
];
|
|
58
|
+
|
|
31
59
|
// No-progress window: if extract-zip reports no new entry for this long AND has
|
|
32
60
|
// not settled, treat it as the silent stall. Reset on every onEntry so a slow-
|
|
33
61
|
// but-progressing extract is never falsely aborted.
|
|
@@ -162,6 +190,8 @@ function runExtractZipBounded({ zip, dir, onEntry, extractZip, idleMs, maxMs, se
|
|
|
162
190
|
* @param {object} [opts.deps] injected { fs, extractZip, spawn, setTimeout, clearTimeout, log }
|
|
163
191
|
* @returns {Promise<{strategy:string, fallback?:boolean, extractZipReason?:string}>}
|
|
164
192
|
* @throws {Error} code 'UNZIP_ALL_FAILED' when no strategy produced files.
|
|
193
|
+
* @throws {Error} code 'UNZIP_UNSAFE_ARCHIVE' when the archive was REFUSED for a
|
|
194
|
+
* path-traversal attempt — terminal, with no native retry (see UNSAFE_PATTERNS).
|
|
165
195
|
*/
|
|
166
196
|
async function robustExtract(zip, opts = {}) {
|
|
167
197
|
const {
|
|
@@ -202,6 +232,16 @@ async function robustExtract(zip, opts = {}) {
|
|
|
202
232
|
return { strategy: 'extract-zip' };
|
|
203
233
|
}
|
|
204
234
|
|
|
235
|
+
// TERMINAL: the archive was REFUSED for trying to write outside `dir`. Never
|
|
236
|
+
// cleanDir (the partial output is evidence), never fall back — handing the same
|
|
237
|
+
// archive to tar/Expand-Archive would ask a tool with no such check to do what
|
|
238
|
+
// extract-zip just declined to.
|
|
239
|
+
if (!z.ok && UNSAFE_PATTERNS.some((p) => p.test(z.reason || ''))) {
|
|
240
|
+
const err = new Error(`refusing to extract ${zip}: ${z.reason}`);
|
|
241
|
+
err.code = 'UNZIP_UNSAFE_ARCHIVE';
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
|
|
205
245
|
// extract-zip stalled / threw / produced nothing → clean partial output, go native.
|
|
206
246
|
const zipReason = z.ok ? 'extract-zip produced no files' : z.reason;
|
|
207
247
|
cleanDir(fs, dir);
|
package/src/utils/config.js
CHANGED
|
@@ -340,8 +340,13 @@ const LOCAL_REQUEST_TIMEOUT_MS = 300000;
|
|
|
340
340
|
* serve (fixed in v4.1.2). Aliases the user has overridden fall back to the
|
|
341
341
|
* prefix form, since no authored gateway route describes them.
|
|
342
342
|
* @param {string[]} [resolvedRoutes] executable model id(s) actually launched
|
|
343
|
+
* @param {number|null} [outputBudget] the budget to clamp with. `undefined` (every
|
|
344
|
+
* caller but startServer) reads config here; `null` means unset. #218 PR 3:
|
|
345
|
+
* opencode-client.js :: startServer reads config ONCE and hands the same value
|
|
346
|
+
* to this descriptor and to the engine flag, so a config write between two
|
|
347
|
+
* reads can no longer split the levers. Named mutant "PARAMIGNORED".
|
|
343
348
|
* @returns {object} e.g. { openrouter: { models: { "x-ai/grok-4.3": {}, ... } } } */
|
|
344
|
-
function buildProviderModels(resolvedRoutes = []) {
|
|
349
|
+
function buildProviderModels(resolvedRoutes = [], outputBudget) {
|
|
345
350
|
const aliases = getEffectiveAliases();
|
|
346
351
|
const providers = {};
|
|
347
352
|
|
|
@@ -353,7 +358,7 @@ function buildProviderModels(resolvedRoutes = []) {
|
|
|
353
358
|
// ConfigInvalidError that poisons the whole config, not a per-model degrade.
|
|
354
359
|
const { normalizeOutputBudget, buildLimitLookup, computeModelLimit } =
|
|
355
360
|
require('./model-output-limit');
|
|
356
|
-
const budget = normalizeOutputBudget(getOutputBudget());
|
|
361
|
+
const budget = normalizeOutputBudget(outputBudget === undefined ? getOutputBudget() : outputBudget);
|
|
357
362
|
let limits = new Map();
|
|
358
363
|
if (budget !== null) {
|
|
359
364
|
try {
|
|
@@ -382,6 +387,20 @@ function buildProviderModels(resolvedRoutes = []) {
|
|
|
382
387
|
if (!Object.prototype.hasOwnProperty.call(providers, providerID)) {
|
|
383
388
|
providers[providerID] = { models: {} };
|
|
384
389
|
}
|
|
390
|
+
// #218 PR 2: direct `anthropic/*` is no longer held out (council #230 A1
|
|
391
|
+
// held it out until descriptor x thinking-budget was measured). Measured,
|
|
392
|
+
// probe rows K1/K2/K3/K4/K9/K10: the descriptor lowers the reservation on
|
|
393
|
+
// that route exactly as on OpenRouter (K1: 8000); a thinking variant's
|
|
394
|
+
// budget is ADDED on top of it (K2: 8000 + 16000 = 24000); and the sum is
|
|
395
|
+
// clamped to the model's real ceiling whatever the descriptor or flag said
|
|
396
|
+
// (K3/K4/K10: 64000 for haiku). No descriptor can push a thinking leg over
|
|
397
|
+
// the ceiling. #218 PR 4 now sends `--thinking` to the engine as its `variant`
|
|
398
|
+
// field, validated against the model's own declaration before any request,
|
|
399
|
+
// so these are the numbers a thinking leg now reserves: M2 is K2's
|
|
400
|
+
// arithmetic at the shipped budget (24000 + 16000 = 40000, refused rather
|
|
401
|
+
// than sent), and M17 is the fit (8000 + 16000 = 24000) that would need the
|
|
402
|
+
// variant's budget before the spawn.
|
|
403
|
+
// Named mutant "ANTHROPICHELDOUT" in tests/build-provider-models-output-limit.test.js.
|
|
385
404
|
// #218: `{}` unless a budget is set AND the catalog knows both numbers.
|
|
386
405
|
const limit = computeModelLimit(limits.get(fullModel), budget);
|
|
387
406
|
providers[providerID].models[modelID] = limit ? { limit } : {};
|
|
@@ -615,17 +634,19 @@ function resolveCouncilMembers(name, catalog = []) {
|
|
|
615
634
|
/**
|
|
616
635
|
* #218: the configured per-leg output budget, or null when unset.
|
|
617
636
|
*
|
|
618
|
-
* OPT-IN BY DESIGN — unset means "register every model as `{}`
|
|
619
|
-
* pre-#218 behaviour exactly. Set it and
|
|
620
|
-
* model's real ceiling)
|
|
637
|
+
* OPT-IN BY DESIGN — unset means "register every model as `{}` and set no
|
|
638
|
+
* engine flag", which is pre-#218 behaviour exactly. Set it and every leg
|
|
639
|
+
* reserves min(budget, the model's real ceiling) wherever a ceiling is known.
|
|
640
|
+
* This one value feeds BOTH levers so they can never disagree: the per-model
|
|
641
|
+
* `limit` descriptor (buildProviderModels, for routes the amicus catalog knows)
|
|
642
|
+
* and OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX (opencode-client.js :: startServer,
|
|
643
|
+
* set to the budget for every engine amicus starts and clamped by the engine's
|
|
644
|
+
* own catalog). Values above 32000 are LIVE since PR 2 (probe K6: 100000 on the
|
|
645
|
+
* wire); a model neither catalog knows receives the budget as-is (J2/K13).
|
|
621
646
|
*
|
|
622
|
-
* ⚠️
|
|
623
|
-
* `
|
|
624
|
-
*
|
|
625
|
-
* requires OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX, a different lever.
|
|
626
|
-
*
|
|
627
|
-
* ⚠️ Requires a catalog refreshed since #218 added `maxOutputTokens`
|
|
628
|
-
* (`amicus models --refresh`); older rows have no ceiling and stay `{}`.
|
|
647
|
+
* ⚠️ The descriptor half needs a catalog refreshed since #218 added
|
|
648
|
+
* `maxOutputTokens` (`amicus models --refresh`); the flag half needs nothing.
|
|
649
|
+
* `amicus doctor`'s `output-budget` row says which routes get which.
|
|
629
650
|
*
|
|
630
651
|
* @returns {number|null} positive integer, or null when unset/malformed
|
|
631
652
|
*/
|
|
@@ -98,14 +98,14 @@ const CARDLESS = [
|
|
|
98
98
|
// (live smoke wave 47278069) — the entry was OpenRouter-only at authoring.
|
|
99
99
|
{ alias: 'fable', routes: { openrouter: 'openrouter/anthropic/claude-fable-5',
|
|
100
100
|
anthropic: 'anthropic/claude-fable-5' } },
|
|
101
|
-
// qwen/kimi refreshed 2026-08-26 (v4.9 W13)
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.8-max' } },
|
|
101
|
+
// qwen/kimi refreshed 2026-08-26 (v4.9 W13); qwen again 2026-09-05, when
|
|
102
|
+
// OpenRouter and models.dev dropped the un-dated `qwen3.8-max` for the dated
|
|
103
|
+
// `qwen3.8-max-0902` (the #218 PR 2 probe caught it: F4 went silent). These are
|
|
104
|
+
// the FALLBACK FLOOR — a fork with no CI alias map resolves its bench here; the
|
|
105
|
+
// owner's machine and .github/amicus-ci-aliases.json (qwen3.8-27b) run newer ids.
|
|
106
|
+
// Cardless entries have no `idPattern`, so `models --check` only asks whether the
|
|
107
|
+
// OLD id still EXISTS (scripts/check-ci-alias-pins.js asks the other question).
|
|
108
|
+
{ alias: 'qwen', routes: { openrouter: 'openrouter/qwen/qwen3.8-max-0902' } },
|
|
109
109
|
{ alias: 'qwen-coder', routes: { openrouter: 'openrouter/qwen/qwen3-coder-next' } },
|
|
110
110
|
{ alias: 'qwen-flash', routes: { openrouter: 'openrouter/qwen/qwen3.6-flash' } },
|
|
111
111
|
{ alias: 'mistral', routes: { openrouter: 'openrouter/mistralai/mistral-medium-3-5' } },
|
package/src/utils/degrade.js
CHANGED
|
@@ -17,6 +17,13 @@ const DEGRADE_CHANNELS = Object.freeze(new Set([
|
|
|
17
17
|
'dropped-members', 'chair-skipped-cost-ceiling', 'chair-failed',
|
|
18
18
|
'thin-cross-review', 'debate-degraded', 'inexact-under-ceiling',
|
|
19
19
|
'stage1-retry',
|
|
20
|
+
// #218 PR 3: a Stage-1 review the provider cut at the max_tokens reservation
|
|
21
|
+
// (the leg's `finish` is 'length' and it still carried answer text). kind
|
|
22
|
+
// 'info' only -- the review is in the packet, nothing was lost, the exit code
|
|
23
|
+
// does not move; the chair just reads a review that ends where the
|
|
24
|
+
// reservation ended. A cut with NO answer text is a dead leg (leg.error
|
|
25
|
+
// starts `OUTPUT_LENGTH:`) and rides `dead-leg` like every other death.
|
|
26
|
+
'output-truncated',
|
|
20
27
|
// v4.9 task mode: a task run writes no reliability-ledger rows — announced as kind:'info'.
|
|
21
28
|
'ledger-skipped',
|
|
22
29
|
// v4.8: the seat<->leg join failed. THREE shapes, one channel: a launched seat whose wave
|