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.
Files changed (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +8 -5
  5. package/docs/architecture-map.md +736 -0
  6. package/docs/configuration.md +165 -26
  7. package/docs/council.md +9 -0
  8. package/docs/doc-system.md +12 -9
  9. package/docs/testing.md +2 -1
  10. package/docs/troubleshooting.md +113 -0
  11. package/docs/usage.md +11 -6
  12. package/package.json +1 -1
  13. package/schemas/model-catalog.schema.json +2 -1
  14. package/schemas/run.schema.json +13 -0
  15. package/scripts/postinstall.js +4 -0
  16. package/skills/sidecar/SKILL.md +1 -8
  17. package/src/cli-handlers-doctor.js +3 -0
  18. package/src/cli-handlers-fanout.js +10 -1
  19. package/src/cli-handlers-resume-continue.js +25 -0
  20. package/src/cli.js +5 -8
  21. package/src/council/briefings-chair.js +4 -2
  22. package/src/council/run-assemble.js +7 -2
  23. package/src/council/run-retry-notes.js +21 -1
  24. package/src/council/run-stages.js +8 -1
  25. package/src/headless.js +125 -7
  26. package/src/mcp-server.js +26 -0
  27. package/src/mcp-tools.js +4 -4
  28. package/src/opencode-client.js +84 -8
  29. package/src/pack/pack-validate.js +3 -0
  30. package/src/session-manager.js +2 -2
  31. package/src/sidecar/continue.js +6 -1
  32. package/src/sidecar/conversation-mirror.js +35 -11
  33. package/src/sidecar/electron-install.js +81 -81
  34. package/src/sidecar/electron-provision.js +179 -0
  35. package/src/sidecar/electron-trust.js +299 -0
  36. package/src/sidecar/fanout-leg-fallback.js +1 -0
  37. package/src/sidecar/fanout-leg.js +10 -2
  38. package/src/sidecar/fanout.js +2 -2
  39. package/src/sidecar/interactive.js +31 -4
  40. package/src/sidecar/models-ceiling-line.js +72 -0
  41. package/src/sidecar/models.js +4 -2
  42. package/src/sidecar/reopen-notices.js +97 -0
  43. package/src/sidecar/reopen-spend.js +3 -2
  44. package/src/sidecar/resume.js +15 -2
  45. package/src/sidecar/session-finalize.js +4 -1
  46. package/src/sidecar/session-utils.js +5 -1
  47. package/src/sidecar/start-metadata.js +1 -1
  48. package/src/sidecar/start.js +10 -5
  49. package/src/sidecar/unzip.js +40 -0
  50. package/src/utils/config.js +33 -12
  51. package/src/utils/curated-models.js +8 -8
  52. package/src/utils/degrade.js +7 -0
  53. package/src/utils/doctor-output-budget-check.js +198 -0
  54. package/src/utils/engine-output-flag.js +105 -0
  55. package/src/utils/engine-variants.js +298 -0
  56. package/src/utils/http-get.js +284 -0
  57. package/src/utils/model-catalog.js +36 -4
  58. package/src/utils/model-ceilings-modelsdev.js +230 -0
  59. package/src/utils/model-fetcher.js +12 -36
  60. package/src/utils/model-output-limit.js +21 -13
  61. package/src/utils/output-length.js +90 -0
  62. package/src/utils/result-schema.js +7 -2
  63. package/src/utils/spend-ledger.js +5 -1
  64. package/src/utils/thinking-validators.js +27 -80
  65. 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 };
@@ -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:237.
27
- appendSpend({ taskId, model, mode, usage, op, status, project, gateway, tag: metadata.tag || null }, ctx);
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 };
@@ -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
- const lastActivity = metadata.completedAt || metadata.createdAt;
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: thinking || 'medium',
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(),
@@ -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 reasoning = thinking ? { effort: thinking } : undefined;
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, reasoning, port: opencodePort, nonce: foldNonce }
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, reasoning, client, windowPosition: position, foldNonce }
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 214), which carries `tag` when
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,
@@ -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);
@@ -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 `{}`", which is
619
- * pre-#218 behaviour exactly. Set it and each leg reserves min(budget, the
620
- * model's real ceiling) instead of opencode's fixed 32000 default.
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
- * ⚠️ Values >= 32000 are ACCEPTED but INERT: opencode computes
623
- * `Math.min(limit.output, 32000)` (measured in the 1.18.15 binary), so the
624
- * reservation can only be lowered here, never raised. Raising it at all
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): both were a model generation
102
- // behind. These are the FALLBACK FLOOR a caller or fork with no CI alias map
103
- // resolves its whole bench through this table (see `inkling` below), while the
104
- // owner's machine and .github/amicus-ci-aliases.json already ran the newer ids.
105
- // Cardless entries have no `idPattern`, so `models --check` can only ask
106
- // whether the OLD id still EXISTS which is how a pin sits a generation back
107
- // with every gate green (scripts/check-ci-alias-pins.js asks the other one).
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' } },
@@ -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