amicus 4.9.2 → 4.9.4

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 (72) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/bin/amicus.js +6 -0
  5. package/docs/ROADMAP.md +5 -4
  6. package/docs/architecture-map.md +732 -0
  7. package/docs/configuration.md +175 -1
  8. package/docs/council.md +9 -0
  9. package/docs/doc-system.md +12 -9
  10. package/docs/testing.md +2 -1
  11. package/docs/troubleshooting.md +76 -0
  12. package/docs/usage.md +14 -6
  13. package/electron/main.js +25 -2
  14. package/electron/setup-ui-alias-groups.js +161 -0
  15. package/electron/setup-ui-alias-script.js +70 -4
  16. package/electron/setup-ui-aliases.js +25 -21
  17. package/electron/setup-ui.js +11 -1
  18. package/package.json +1 -1
  19. package/schemas/model-catalog.schema.json +2 -1
  20. package/schemas/run.schema.json +13 -0
  21. package/skills/sidecar/SKILL.md +1 -8
  22. package/src/cli-handlers-doctor.js +12 -16
  23. package/src/cli-handlers-fanout.js +10 -1
  24. package/src/cli-handlers-resume-continue.js +25 -0
  25. package/src/cli-handlers.js +17 -1
  26. package/src/cli.js +5 -8
  27. package/src/council/briefings-chair.js +4 -2
  28. package/src/council/run-assemble.js +7 -2
  29. package/src/council/run-retry-notes.js +21 -1
  30. package/src/council/run-stages.js +8 -1
  31. package/src/headless.js +125 -7
  32. package/src/mcp-server.js +26 -0
  33. package/src/mcp-tools.js +4 -4
  34. package/src/opencode-client.js +84 -8
  35. package/src/pack/pack-validate.js +3 -0
  36. package/src/session-manager.js +2 -2
  37. package/src/sidecar/continue.js +6 -1
  38. package/src/sidecar/conversation-mirror.js +35 -11
  39. package/src/sidecar/fanout-leg-fallback.js +1 -0
  40. package/src/sidecar/fanout-leg.js +10 -2
  41. package/src/sidecar/fanout.js +2 -2
  42. package/src/sidecar/interactive.js +31 -4
  43. package/src/sidecar/models-ceiling-line.js +72 -0
  44. package/src/sidecar/models.js +4 -2
  45. package/src/sidecar/reopen-notices.js +97 -0
  46. package/src/sidecar/reopen-spend.js +3 -2
  47. package/src/sidecar/resume.js +15 -2
  48. package/src/sidecar/session-finalize.js +4 -1
  49. package/src/sidecar/session-utils.js +5 -1
  50. package/src/sidecar/start-metadata.js +1 -1
  51. package/src/sidecar/start.js +10 -5
  52. package/src/utils/api-key-validation.js +183 -94
  53. package/src/utils/config.js +65 -2
  54. package/src/utils/curated-models.js +8 -8
  55. package/src/utils/degrade.js +7 -0
  56. package/src/utils/doctor-credit-check.js +61 -0
  57. package/src/utils/doctor-key-auth-check.js +271 -0
  58. package/src/utils/doctor-output-budget-check.js +198 -0
  59. package/src/utils/engine-output-flag.js +105 -0
  60. package/src/utils/engine-variants.js +298 -0
  61. package/src/utils/http-get.js +284 -0
  62. package/src/utils/live-probes.js +53 -0
  63. package/src/utils/model-catalog.js +36 -4
  64. package/src/utils/model-ceilings-modelsdev.js +230 -0
  65. package/src/utils/model-fetcher.js +14 -36
  66. package/src/utils/model-output-limit.js +132 -0
  67. package/src/utils/openrouter-credit.js +104 -0
  68. package/src/utils/output-length.js +90 -0
  69. package/src/utils/result-schema.js +7 -2
  70. package/src/utils/spend-ledger.js +5 -1
  71. package/src/utils/thinking-validators.js +27 -80
  72. package/src/utils/validators.js +2 -3
@@ -148,7 +148,7 @@ function seatKeyedOrder(order, orderSeats) {
148
148
  * changing it would move a shipped tally schema. These are three arrays each
149
149
  * with one consistently-typed field, not one polymorphic field. PR #189's
150
150
  * council raised it as D1 (major, CONTESTED a1/d1/n1); declined on that reading.
151
- * @param {{reviews: Array<{model: string, text: string, seat?: ?object}>,
151
+ * @param {{reviews: Array<{model: string, text: string, seat?: ?object, cut?: boolean}>,
152
152
  * rankings: Array<{judge: string, seat?: ?string, order: Array<string|string[]>,
153
153
  * orderSeats?: ?Array<?string|Array<?string>>}>,
154
154
  * adjudications: Array<{findingId: string, judge: string, seat?: ?string, verdict: string}>,
@@ -182,7 +182,9 @@ function buildChairPacket({ reviews, rankings, adjudications, tierCounts, date,
182
182
  // construction — and the review projection that feeds site (1) applies that
183
183
  // same rule for a reason its own comment gives.
184
184
  const reviewBlocks = reviews
185
- .map(r => `--- Review by ${displayName(r.seat) || r.model} ---\n${r.text}`).join('\n\n');
185
+ // #218 PR 3 (council #232 r2 B1): a cut review says so in its header, so the
186
+ // chair weighs it as partial. Named mutant "NOMARKER".
187
+ .map(r => `--- Review by ${displayName(r.seat) || r.model}${r.cut ? ' — CUT at its output reservation (the provider stopped for length; the text ends where the reservation ended)' : ''} ---\n${r.text}`).join('\n\n');
186
188
  const rankingLines = (rankings || [])
187
189
  .map(r => `${r.seat || r.judge}: ${JSON.stringify(seatKeyedOrder(r.order, r.orderSeats))}`)
188
190
  .join('\n');
@@ -254,14 +254,19 @@ function buildChairPacketFile({ runDir, reviews, claudeReview, tallyInput, recor
254
254
  const packet = buildChairPacket({
255
255
  // §4.4: the chair sees Claude's de-anonymized review like any other; it casts
256
256
  // no rankings/adjudications, so it appears ONLY as one more review block.
257
- // The projection is DELIBERATE — it drops findings/conformance/role/leg; v4.8
257
+ // The projection is DELIBERATE — it drops findings/conformance/role/leg;
258
+ // `cut` (r2 B1) is the one leg fact forwarded, emit-when-cut. v4.8
258
259
  // SI-25 adds `seat`, ⚠️ EMIT-WHEN-DIFFERENT like rankings/adjudications above:
259
260
  // `r.model` is the leg's `modelInput || model`, which falls back to the RESOLVED
260
261
  // id, so an unconditional forward breaks §4.2 byte identity on a NO-TWIN bench.
261
262
  // Mutant: tests/council/chair-packet-seat-mutants.js :: HDRSEATFWD.
262
263
  // ⚠️ The Claude review keeps NO seat and renders `claude` via the fallback.
264
+ // #218 PR 3 (council #232 r2 B1): a review the provider cut at the output
265
+ // reservation is MARKED for the chair, emit-when-cut so an uncut bench's
266
+ // packet stays byte-identical. Named mutant "CUTDROPPED".
263
267
  reviews: reviews.map(r => ({ model: r.model, text: r.text,
264
- ...(r.seat && r.seat.id !== r.seat.alias ? { seat: r.seat } : {}) }))
268
+ ...(r.seat && r.seat.id !== r.seat.alias ? { seat: r.seat } : {}),
269
+ ...(r.leg && r.leg.finish === 'length' ? { cut: true } : {}) }))
265
270
  .concat(claudeReview ? [{ model: 'claude', text: claudeReview.text }] : []),
266
271
  rankings: tallyInput.rankings,
267
272
  adjudications: tallyInput.adjudications,
@@ -179,5 +179,25 @@ function missingLegStillDeadNote(seat, ff, unit, counts) {
179
179
  data: { seat, status: null, reason: null, firstFailure: ff, retryWaveId: unit.waveId } };
180
180
  }
181
181
 
182
+ /**
183
+ * #218 PR 3: a review that reached the packet but was cut at the reservation.
184
+ * `kind: 'info'` -- announced, never a loss (utils/degrade.js on the channel).
185
+ * The counts are the engine's own token record for the leg; the remedy names
186
+ * the one lever that exists today.
187
+ * @param {string} seat the alias every note renders — `materializeReviews` has
188
+ * already resolved it (`leg.modelInput || leg.model`), so the caller passes
189
+ * `m.modelInput` as-is
190
+ * @param {object} leg the leg run document (finish === 'length')
191
+ */
192
+ function truncatedReviewNote(seat, leg) {
193
+ const t = (leg.usage && leg.usage.tokens) || {};
194
+ return { kind: 'info', channel: 'output-truncated',
195
+ what: `seat ${seat}'s review was cut at its output reservation`,
196
+ why: `the provider stopped for length (finish 'length') after ${t.reasoning || 0} reasoning / ${t.output || 0} output tokens; the review ends where the reservation ended`,
197
+ effect: 'The review is in the packet as far as it got, and its header in the chair packet says it was cut; nothing else changes',
198
+ remedy: 'raise outputBudget in config.json (docs/configuration.md, Output budget)',
199
+ data: { seat, finish: 'length', reasoningTokens: t.reasoning || 0, outputTokens: t.output || 0 } };
200
+ }
201
+
182
202
  module.exports = { waveStillDeadNote, skippedWaveNote, srcLegStillDeadNote,
183
- retryLegStillDeadNote, missingLegStillDeadNote };
203
+ retryLegStillDeadNote, missingLegStillDeadNote, truncatedReviewNote };
@@ -27,7 +27,7 @@ const { launchStage1 } = require('./run-stage1-launch');
27
27
  const { buildRunStatsEntry } = require('./run-assemble');
28
28
  const { pushDeadSeatRows } = require('./run-stage1-rows');
29
29
  const { bindStage1Waves, orphanLegNote, missingSeatDeadWave } = require('./stage1-bind');
30
- const { skippedWaveNote } = require('./run-retry-notes');
30
+ const { skippedWaveNote, truncatedReviewNote } = require('./run-retry-notes');
31
31
  // slug lives in ./seats (v4.8 PR1) so that module can stay require-free;
32
32
  // re-exported below — run-stages.test.js imports it from here.
33
33
  const { slug } = require('./seats');
@@ -139,6 +139,13 @@ async function runStage1(ctx) {
139
139
  // it is the twin clobber this PR removes (two healed twins, one file).
140
140
  const allSeatOf = new Map([...seatOf, ...retry.seatOf]);
141
141
  const materialized = materializeReviews(o.runDir, [...legs, ...retry.recoveredLegs], allSeatOf);
142
+ // #218 PR 3: a review the provider cut at the reservation still counts -- it
143
+ // is announced, not lost. Only MATERIALIZED legs qualify (a length-stopped
144
+ // leg with no answer text is a dead leg and never reaches this list); named
145
+ // mutants "DEADNOTED" (iterate `legs` instead) and "NONOTE" (drop the loop).
146
+ for (const m of materialized) {
147
+ if (m.leg && m.leg.finish === 'length') { ctx.degrade.note(truncatedReviewNote(m.modelInput, m.leg)); }
148
+ }
142
149
  const stillDeadLegs = [...retry.skippedDeadLegs, ...retry.stillDeadLegs];
143
150
  const stillDeadWaves = [...retry.skippedDeadWaves, ...retry.stillDeadWaves];
144
151
 
package/src/headless.js CHANGED
@@ -259,6 +259,22 @@ function formatNoOutputBackstopReason({ ms, fromEnv, engineLogExcerpt, engineSke
259
259
  return `${quoted}${formatSkewSuffix(engineSkew)}${formatSessionStatusSuffix(sessionStatus)}`;
260
260
  }
261
261
 
262
+ /**
263
+ * #218 PR 3: the budget for the OUTPUT_LENGTH reason string. The engine's own
264
+ * handle carries the value it was spawned with (opencode-client.js ::
265
+ * startServer, council #232 r1 B3) and wins; config is read only for a handle
266
+ * from outside amicus (a test seam or an older caller), and `undefined` when
267
+ * that read fails -- the string then says so rather than claiming "unset".
268
+ * `read` is a test seam (options._readOutputBudget).
269
+ * @param {{outputBudget?: number|null}} [server] the leg's server handle
270
+ * @param {() => (number|null)} [read]
271
+ * @returns {number|null|undefined}
272
+ */
273
+ function readOutputBudgetSafe(server, read) {
274
+ if (server && Object.prototype.hasOwnProperty.call(server, 'outputBudget')) { return server.outputBudget; }
275
+ try { return (read || require('./utils/config').getOutputBudget)(); } catch { return undefined; }
276
+ }
277
+
262
278
  /**
263
279
  * v4.9 W10 (#133 piece 2): the engine-log lookup, wrapped so it can never
264
280
  * become the failure it reports on. The resolver is already best-effort
@@ -349,8 +365,11 @@ async function waitForServer(client, checkHealthFn, maxAttempts = 30) {
349
365
  * @param {object} [options] - Additional options
350
366
  * @param {object} [options.mcp] - MCP server configurations
351
367
  * @param {string} [options.summaryLength='normal'] - Desired summary length
352
- * @param {object} [options.reasoning] - Reasoning/thinking configuration
353
- * @param {string} [options.reasoning.effort] - Effort level: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'none'
368
+ * @param {string} [options.variant] - #218 PR 4: the effort level, sent as the engine's `variant`
369
+ * prompt field and validated by sendPrompt against the model's declaration; a refusal is thrown
370
+ * before any request and becomes this leg's standard error result through the outer exception
371
+ * handler below (zero spend, nothing polled). The result carries `variant` (emit-when-sent) and
372
+ * `variantUnverified: true` when the engine's catalogue did not know the model in time.
354
373
  * @param {string} [options.nonce] - Per-run fold nonce (15b.3, #BL-7 residual). The
355
374
  * PROMPT the caller built (prompt-builder.js buildPrompts) must have instructed the
356
375
  * model with this SAME nonce — runHeadless only DETECTS, it never re-derives one from
@@ -372,7 +391,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
372
391
  getSessionStatus
373
392
  } = require('./opencode-client');
374
393
 
375
- const { reasoning } = options;
394
+ const { variant } = options;
376
395
  // 15b.3: never fall back to bare-marker detection — an omitted nonce still
377
396
  // gets ONE generated here so findTrailingFoldMarker always has something to
378
397
  // match, but since the prompt (built by the caller) never advertised THIS
@@ -635,9 +654,16 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
635
654
  const agentConfig = mapAgentToOpenCode(agent || 'build');
636
655
  promptOptions.agent = agentConfig.agent;
637
656
 
638
- // Add reasoning/thinking configuration if provided
639
- if (reasoning) {
640
- promptOptions.reasoning = reasoning;
657
+ // #218 PR 4: the effort level goes out as the engine's `variant` prompt field
658
+ // (probe F2), validated in sendPrompt against what the model declares; the
659
+ // budget the engine was spawned with rides the handle (readOutputBudgetSafe,
660
+ // PR 3) so the direct-Anthropic fit check (M2/M17) judges the same number the
661
+ // death report names. Named mutant "VARIANTNOTSENT" (tests/headless-variant.test.js).
662
+ const sendAbort = new AbortController();
663
+ if (variant) {
664
+ promptOptions.variant = variant;
665
+ promptOptions.outputBudget = readOutputBudgetSafe(server, options._readOutputBudget);
666
+ promptOptions.signal = sendAbort.signal; // #218 PR 4 whole-branch review (EP-2): see the backstop catch below
641
667
  }
642
668
 
643
669
  // v4.6.2 PR2 amendment (controller live smoke, field evidence): arm BEFORE
@@ -760,6 +786,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
760
786
  // internally, so this is defensive belt-and-suspenders, not load-bearing
761
787
  // — verified empirically before relying on it).
762
788
  sendPromptPromise.catch(() => {});
789
+ // #218 PR 4 whole-branch review (EP-2): the send may still be INSIDE its declaration
790
+ // wait (sendPrompt reads /config/providers for up to 5 s when a variant was asked for);
791
+ // this leg is finalized and its session aborted below, so the orphan must not send.
792
+ // Named mutant "ORPHANSENDS" (tests/headless-variant.test.js): drop the abort.
793
+ sendAbort.abort();
763
794
  backstopFired = true;
764
795
  logger.warn('No-output backstop fired before the prompt send resolved', {
765
796
  taskId, sessionId, backstopMs: noOutputBackstopMs,
@@ -780,6 +811,27 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
780
811
  sessionError = await noOutputBackstopReason();
781
812
  }
782
813
 
814
+ // #218 PR 4: what was SENT, for the leg record (emit-when-sent; named mutants
815
+ // "VARIANTDROPPED" / "UNVERIFIEDHIDDEN" in tests/headless-variant.test.js). A refused
816
+ // variant never reaches here: sendPrompt throws before the request and the outer
817
+ // exception handler returns the standard error result.
818
+ const sent = promptResult && promptResult.sentVariant;
819
+ const sentVariantFields = sent
820
+ ? { variant: sent.variant, ...(sent.verified ? {} : { variantUnverified: true }) }
821
+ : {};
822
+ if (sent && !sent.verified) {
823
+ const { formatUnverifiedVariantNote } = require('./utils/engine-variants');
824
+ const note = formatUnverifiedVariantNote({ model, variant: sent.variant, waitedMs: sent.waitedMs, unreadable: sent.unreadable });
825
+ logger.warn('Variant sent unverified', { taskId, sessionId, note });
826
+ // council #235 r2 (B2): logger.warn is DROPPED at the shipped default
827
+ // (LOG_LEVEL defaults to 'error', utils/logger.js), so the structured line alone
828
+ // told the user nothing — the silent degrade the product principle forbids, and the
829
+ // same invisibility this release cites against 4.9.3's silent adjustment. stderr
830
+ // carries it in every mode; stdout keeps the run document intact. Named mutant
831
+ // "UNVERIFIEDNOTICESILENT": drop the stderr write.
832
+ process.stderr.write(`Notice: ${note}\n`);
833
+ }
834
+
783
835
  // Hard provider failure detected at the client boundary (#37): a non-2xx /
784
836
  // 402 from promptAsync surfaces here even when the server never emits an
785
837
  // assistant message carrying info.error. Seed sessionError so the loop's
@@ -996,6 +1048,11 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
996
1048
  // seven first, and nothing observes them afterwards.
997
1049
  // Hoisting the whole block (rather than adding a second stamp beside each
998
1050
  // gate) is what keeps ONE definition of the predicate and ONE stamp site.
1051
+ // #218 PR 3: the mirror's promotion reset (conversation-mirror.js ::
1052
+ // mirrorMessages) can SHRINK output once -- the poll where real answer
1053
+ // text replaces a longer promoted stand-in reads no growth here. Bounded
1054
+ // to that poll: TTFT and the backstop were already latched by the
1055
+ // reasoning growth, and the next growth compares against the new length.
999
1056
  const outputGrew = mirror.output.length > lastOutputLength;
1000
1057
  lastOutputLength = mirror.output.length;
1001
1058
  const toolActivity = mirror.toolCalls.length > lastToolCallCount;
@@ -1120,6 +1177,30 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1120
1177
  break;
1121
1178
  }
1122
1179
 
1180
+ // #218 PR 3: the engine finalized the message with finish 'length' and
1181
+ // no answer text -- the Mode 2 death (probe row L1: hidden reasoning,
1182
+ // no content part); decided on THAT message's parts, not on the
1183
+ // session's accumulated output (council #232 r1 B2/D1).
1184
+ // Nothing more will arrive from that message. With EMPTY output (hidden
1185
+ // reasoning, L1) every exit below requires output, so without this one
1186
+ // the leg waits out the no-output backstop and dies under ITS name,
1187
+ // which says "silence past the deadline" about a message the engine had
1188
+ // already finished with a reason. With output -- reasoning the mirror
1189
+ // promoted (L2/L4; the backstop is already disarmed by that growth) or
1190
+ // an earlier message's text -- the idle exits would end it instead: in
1191
+ // this same poll on SDK idle, up to stableFinishedPolls later on the
1192
+ // heuristic. This exit is the same death, no later. Gated on 'length'
1193
+ // only: the finalized message's finish is never a step-level
1194
+ // 'tool-calls' (B4's measured evidence below: time.completed lands after
1195
+ // the tool ends), and a 'stop' with no text is a different, unnamed
1196
+ // death. The message flag here matches the post-loop decision, which is
1197
+ // the pinned one (named mutants NOEXIT and SESSIONWIDE in
1198
+ // tests/headless-output-length.test.js).
1199
+ if (assistantFinished && mirror.lastAssistantFinish === 'length' && !mirror.lastAssistantHasText) {
1200
+ logger.error('Assistant message finished for length with no answer text, exiting', { taskId, pollCount });
1201
+ break;
1202
+ }
1203
+
1123
1204
  // Authoritative idle signal from the OpenCode SDK (preferred over the heuristic).
1124
1205
  // Gate on real output so a pre-processing 'idle' cannot end the run early.
1125
1206
  // Best-effort: on any error, fall back to the activity heuristic below.
@@ -1478,6 +1559,33 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1478
1559
  const { sumPerMessageUsage } = require('./utils/pricing');
1479
1560
  const usage = sumPerMessageUsage(mirror.usageByMsg);
1480
1561
 
1562
+ // #218 PR 3: name the Mode 2 death. The engine records `finish: 'length'`
1563
+ // when the provider stopped at the max_tokens reservation (probe rows
1564
+ // A/H1/L1-L4); with no answer text that is a dead leg, and it used to leave
1565
+ // here as `completed` with an empty summary -- or with its THINKING promoted
1566
+ // to the summary (L2/L4's shape). Named through the channel every other
1567
+ // death uses (sessionError -> leg.error -> metadata.reason -> the dead-leg
1568
+ // note), so no consumer needs a new case. An error the engine itself put on
1569
+ // the message wins: its own name is the better observation. Named mutants
1570
+ // (tests/headless-output-length.test.js): "ENGINEERRORLOST" drops the
1571
+ // `!sessionError` guard; "DEATHNOTFORCED" drops `|| outputLengthDeath` from
1572
+ // failedWithNoUsableOutput below.
1573
+ //
1574
+ // Decided on the LAST message's own facts (council #232 r1 B2/D1): a tool
1575
+ // loop's earlier text or promoted reasoning is not this message's answer.
1576
+ // The ambient flag rides the handle beside the budget (council #232 r3 B1); named mutant "AMBIENTNOTREAD" drops it here.
1577
+ const { isOutputLengthDeath, formatOutputLengthReason } = require('./utils/output-length');
1578
+ const finish = mirror.lastAssistantFinish;
1579
+ const reasoningOnly = mirror.lastAssistantHasReasoning && !mirror.lastAssistantHasText;
1580
+ const outputLengthDeath = isOutputLengthDeath({ finish, hasText: mirror.lastAssistantHasText });
1581
+ if (outputLengthDeath && !sessionError) {
1582
+ sessionError = formatOutputLengthReason({
1583
+ tokens: usage.tokens, budget: readOutputBudgetSafe(server, options._readOutputBudget), reasoningOnly,
1584
+ ambientFlag: server && typeof server.ambientOutputTokenFlag === 'string' ? server.ambientOutputTokenFlag : null,
1585
+ });
1586
+ logger.error('Leg stopped for length with no answer text', { taskId, error: sessionError });
1587
+ }
1588
+
1481
1589
  // ---- v4.4 B3: one TERMINAL progress record carrying the settled usage ----
1482
1590
  // progress.json's `usage` block was previously stamped only on 'receiving'
1483
1591
  // flushes, which fire on text/tool/reasoning GROWTH — always strictly before
@@ -1550,7 +1658,10 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1550
1658
  //
1551
1659
  // `failedWithNoUsableOutput` is hoisted out of the `if` below so the stage
1552
1660
  // and the returned shape are decided by ONE predicate and cannot drift.
1553
- const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled));
1661
+ // #218 PR 3: an OUTPUT_LENGTH death can have a non-empty mirror.output -- a
1662
+ // tool loop's earlier message text, or reasoning promoted before the answer
1663
+ // was known -- and must still fail.
1664
+ const failedWithNoUsableOutput = !!(sessionError && (!mirror.output || pollFailureBail || toolStalled || outputLengthDeath));
1554
1665
  const { resolveTerminalState } = require('./sidecar/session-finalize');
1555
1666
  const terminalStage = resolveTerminalState({
1556
1667
  completed,
@@ -1578,6 +1689,7 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1578
1689
  ...settleResult,
1579
1690
  ...subtreeFlags,
1580
1691
  ...subtreeResult,
1692
+ ...sentVariantFields,
1581
1693
  // #133 P1: sessionId was assigned at :413/:417, well before this
1582
1694
  // return — guaranteed set here, same as `taskId` above.
1583
1695
  opencodeSessionId: sessionId,
@@ -1587,6 +1699,8 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1587
1699
  // and must keep it. (PR #207 round 3, B3: emit-when-VALID too — see the
1588
1700
  // clock-skew ruling at the stamp site above.)
1589
1701
  ...(isMeasuredTtft(ttftMs) ? { ttftMs } : {}),
1702
+ // #218 PR 3: the engine's finish for the last assistant message, emit-when-set like ttftMs.
1703
+ ...(typeof finish === 'string' ? { finish } : {}),
1590
1704
  error: sessionError
1591
1705
  };
1592
1706
  }
@@ -1602,10 +1716,13 @@ async function runHeadless(model, systemPrompt, userMessage, taskId, project, ti
1602
1716
  ...settleResult,
1603
1717
  ...subtreeFlags,
1604
1718
  ...subtreeResult,
1719
+ ...sentVariantFields,
1605
1720
  // #133 P1: see the comment on the sibling return above — guaranteed set.
1606
1721
  opencodeSessionId: sessionId,
1607
1722
  // v4.9 W13 Task A: emit-when-set — see the sibling return above.
1608
1723
  ...(isMeasuredTtft(ttftMs) ? { ttftMs } : {}),
1724
+ // #218 PR 3: the engine's finish for the last assistant message, emit-when-set like ttftMs.
1725
+ ...(typeof finish === 'string' ? { finish } : {}),
1609
1726
  exitCode: 0
1610
1727
  };
1611
1728
 
@@ -1778,6 +1895,7 @@ module.exports = {
1778
1895
  findTrailingFoldMarker,
1779
1896
  formatFoldOutput,
1780
1897
  formatNoOutputBackstopReason,
1898
+ readOutputBudgetSafe,
1781
1899
  DEFAULT_TIMEOUT,
1782
1900
  FOLD_MARKER,
1783
1901
  COMPLETE_MARKER,
package/src/mcp-server.js CHANGED
@@ -332,6 +332,20 @@ const handlers = {
332
332
  };
333
333
  }
334
334
 
335
+ // #218 PR 4 whole-branch review (VCMD-1): the zod enum closes the TYPED door only — a pack
336
+ // fills `thinking` onto `input` after zod ran (pack-resolve.js), and the in-process
337
+ // shared-server branch below never runs validateStartArgs. The same vocabulary check the
338
+ // CLI runs (cli.js), here for both paths. Named mutant "PACKTHINKINGUNCHECKED"
339
+ // (tests/pack/mcp-pack-params.test.js): drop this block.
340
+ {
341
+ const { validateThinkingLevel } = require('./utils/thinking-validators');
342
+ const thinkingCheck = validateThinkingLevel(input.thinking);
343
+ if (!thinkingCheck.valid) {
344
+ const { buildErrorDoc, ERROR_CODES } = require('./utils/error-doc');
345
+ return { isError: true, content: [{ type: 'text', text: JSON.stringify(buildErrorDoc({ code: ERROR_CODES.BAD_ARGS, message: thinkingCheck.error, hint: null })) }] };
346
+ }
347
+ }
348
+
335
349
  // Model routing (#61 Task 6.2): route through the gateway router for MCP
336
350
  // parity with the CLI's resolveLaunchModel (start-helpers.js). Unlike the
337
351
  // CLI, this handler must never process.exit — the MCP server is long-lived
@@ -527,6 +541,7 @@ const handlers = {
527
541
  // so without them status/list/read show a briefing-less, mode-less run.
528
542
  mode: 'headless',
529
543
  agent: agent || 'build',
544
+ ...(input.thinking ? { thinking: input.thinking } : {}), // #218 PR 4 whole-branch review (REC-1): emit-when-requested, as createSessionMetadata does (start-metadata.js)
530
545
  // v4.5 HOLD-gate decision 1: the RENDERED prompt (byte-identical to
531
546
  // input.prompt when no pack template applied) — parity with the CLI,
532
547
  // whose briefing.md on disk is always the rendered text (spec §4).
@@ -598,6 +613,11 @@ const handlers = {
598
613
  // moment a consumer (e.g. metadata/fold-output) needs it (12a.1/B02).
599
614
  amicusClient: detectedClient,
600
615
  nonce: foldNonce,
616
+ // #218 PR 4 whole-branch review (REC-1/VCMD-1/PRT-1): the level goes to the engine
617
+ // on THIS path too — it was argv-only (:434), which this branch never reads, so
618
+ // `thinking` was dropped silently while the tool text promised a refusal.
619
+ // Named mutant "SHAREDPATHNOVARIANT" (tests/mcp-server-wait-wiring.test.js).
620
+ variant: input.thinking || undefined,
601
621
  }
602
622
  ).then((result) => {
603
623
  // Session done — route through resolveTerminalState (same single source
@@ -1305,6 +1325,12 @@ const handlers = {
1305
1325
  && (typeof input.timeout !== 'number' || !Number.isFinite(input.timeout) || input.timeout <= 0)) {
1306
1326
  return textResult('Error: timeout must be a positive number of minutes.', true);
1307
1327
  }
1328
+ // #218 PR 4 whole-branch review (VCMD-2): a pack-filled `thinking` bypasses the zod enum here too.
1329
+ {
1330
+ const { validateThinkingLevel } = require('./utils/thinking-validators');
1331
+ const thinkingCheck = validateThinkingLevel(input.thinking);
1332
+ if (!thinkingCheck.valid) { return textResult(thinkingCheck.error, true); }
1333
+ }
1308
1334
 
1309
1335
  // Resolve a single effective models list (council OR models), validated
1310
1336
  // BEFORE any wave dir / metadata is written so a bad request never strands
package/src/mcp-tools.js CHANGED
@@ -80,9 +80,9 @@ function getTools() {
80
80
  'Run headless without GUI. Default false (opens Electron window).'
81
81
  ),
82
82
  thinking: z.enum([
83
- 'none', 'minimal', 'low', 'medium', 'high', 'xhigh'
83
+ 'none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'
84
84
  ]).optional().describe(
85
- 'Reasoning effort level. Default: medium.'
85
+ 'Reasoning effort level. Omitted: nothing is sent and the provider\'s own default effort governs. A level the model does not declare is refused before anything is spent (the engine lists what each model declares). A model the engine\'s catalogue does not know in time is sent the level unverified (variantUnverified: true on the record).'
86
86
  ),
87
87
  timeout: z.number().optional().describe(
88
88
  'Headless timeout in minutes. Default: 15. Only applies when noUi is true.'
@@ -347,8 +347,8 @@ function getTools() {
347
347
  agent: z.enum(['Plan', 'Build']).optional().describe(
348
348
  'Agent mode for every leg. Build (default): full tool access. Plan: read-only analysis. Chat is not supported headless.'
349
349
  ),
350
- thinking: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh']).optional().describe(
351
- 'Reasoning effort for every leg. Default: medium.'
350
+ thinking: z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']).optional().describe(
351
+ 'Reasoning effort for every leg. Omitted: nothing is sent and each provider\'s own default effort governs. A leg whose model does not declare the level is refused before anything is spent; the other legs run. A leg whose model the engine\'s catalogue does not know in time is sent the level unverified.'
352
352
  ),
353
353
  timeout: z.number().positive('timeout must be a positive number of minutes').optional().describe(
354
354
  'Per-leg timeout in minutes (wall-clock ≈ slowest leg). Default: 15.'
@@ -186,8 +186,21 @@ async function createSession(client, directory) {
186
186
  * @param {Array} options.parts - Message parts
187
187
  * @param {string} [options.agent] - Agent to use (e.g., 'build', 'explore')
188
188
  * @param {object} [options.tools] - Tool configuration
189
- * @param {object} [options.reasoning] - Reasoning/thinking configuration
190
- * @param {string} [options.reasoning.effort] - Effort level: 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'none'
189
+ * @param {string} [options.variant] - #218 PR 4: the effort level to request, sent as the
190
+ * engine's `variant` prompt field (probe F2 the `reasoning` object sent before PR 4 was
191
+ * never a prompt field, F1). Validated against the model's DECLARED variants first
192
+ * (utils/engine-variants.js): refused with a VariantRefusedError (code VARIANT_UNDECLARED
193
+ * or VARIANT_OVER_BUDGET) BEFORE any request when the engine would drop it silently or add
194
+ * its thinking budget over the budget; sent unverified when the engine's catalogue does not
195
+ * know the model within the wait. On a send the result carries
196
+ * `sentVariant: {variant, verified, waitedMs, unreadable?}` (decorated like `providerError`;
197
+ * `unreadable` names why `/config/providers` could not be read, when that is why the send is
198
+ * unverified). The verdict does not depend on `outputBudget` (council #235 r3, C1/B1).
199
+ * @param {number|null} [options.outputBudget] - the budget the engine serving this session
200
+ * was spawned with (the server handle's `outputBudget`); `null` = unset; omitted = unknown.
201
+ * Only the fit check reads it.
202
+ * @param {object} [options._declaration] - test seam: readModelDeclaration's opts (waitMs/pollMs/sleep/now/catalogCeiling/readCache/readTimeoutMs — the per-read deadline, council #235 r2 A1)
203
+ * @param {{aborted: boolean}} [options.signal] - abandon signal (headless.js): when set before the send, nothing is sent
191
204
  * @param {object} [options.watchdog] - IdleWatchdog instance to signal busy/idle around the API call
192
205
  * @param {string} [options.directory] - Optional project directory to scope the
193
206
  * call to (threaded to the SDK as query.directory). Omitting it keeps the
@@ -195,7 +208,7 @@ async function createSession(client, directory) {
195
208
  * @returns {Promise<object>} API response
196
209
  */
197
210
  async function sendPrompt(client, sessionId, options) {
198
- const { model, system, parts, agent, tools, reasoning, watchdog, directory } = options;
211
+ const { model, system, parts, agent, tools, variant, outputBudget, watchdog, directory } = options;
199
212
 
200
213
  // Parse model string to SDK format
201
214
  const modelSpec = parseModelString(model);
@@ -220,8 +233,26 @@ async function sendPrompt(client, sessionId, options) {
220
233
  body.tools = tools;
221
234
  }
222
235
 
223
- if (reasoning) {
224
- body.reasoning = reasoning;
236
+ // #218 PR 4: `variant` is the engine's prompt field for effort (F2); `reasoning`
237
+ // never was one and is not forwarded (F1 — named mutant "REASONINGLEAK" in
238
+ // tests/opencode-client.test.js). Validated against the engine's own declaration
239
+ // BEFORE the request, so a refusal sends nothing (mutant "SENTANYWAY") and the
240
+ // declaration is read only when a variant was asked for (mutant "ALWAYSREAD").
241
+ let sentVariant = null;
242
+ if (variant) {
243
+ const { readModelDeclaration, checkVariant, VariantRefusedError } = require('./utils/engine-variants');
244
+ const modelId = `${modelSpec.providerID}/${modelSpec.modelID}`;
245
+ const declaration = await readModelDeclaration(client, modelId, { ...(options._declaration || {}), signal: options.signal });
246
+ // #218 PR 4 whole-branch review (EP-2): headless races this call against its no-output
247
+ // backstop; when the window is shorter than the declaration wait the leg is already
248
+ // finalized and its session aborted by the time the wait ends. Never send after that —
249
+ // and never let a refusal reach the leg's swallowed orphan as if it had been a send.
250
+ // Named mutant "SENDAFTERABANDON" (tests/opencode-client.test.js): drop this check.
251
+ if (options.signal && options.signal.aborted) { throw new Error('sendPrompt abandoned: the caller gave up during the declaration wait; nothing was sent'); }
252
+ const verdict = checkVariant({ variant, model: modelId, declaration, outputBudget });
253
+ if (!verdict.ok) { throw new VariantRefusedError(verdict.code, verdict.reason); }
254
+ body.variant = variant;
255
+ sentVariant = { variant, verified: verdict.verified, waitedMs: declaration.waitedMs, ...(declaration.unreadable ? { unreadable: declaration.unreadable } : {}) };
225
256
  }
226
257
 
227
258
  if (watchdog) {
@@ -241,6 +272,10 @@ async function sendPrompt(client, sessionId, options) {
241
272
  }
242
273
  }
243
274
 
275
+ // #218 PR 4: what was SENT, for the leg record (headless.js reads it). Same
276
+ // decoration-of-the-SDK-result precedent as `providerError` below.
277
+ if (sentVariant && result && typeof result === 'object') { result.sentVariant = sentVariant; }
278
+
244
279
  // Detect a hard provider failure at the client boundary (#37). A non-2xx /
245
280
  // 402 here must surface as a session error EVEN WHEN the server emits no
246
281
  // assistant message carrying info.error — otherwise the run looks idle/empty.
@@ -493,6 +528,8 @@ function resolveServerStartTimeoutMs(options = {}, env, platform) {
493
528
  * @param {string} [options.client] - Client type ('cowork', 'code-local', etc.)
494
529
  * @param {string} [options.systemPrompt] - System prompt to set on agent config (hidden from UI)
495
530
  * @param {string} [options.agentName] - Agent to set systemPrompt on (default: 'chat')
531
+ * @param {number|null} [options.outputBudget] - #218 PR 3: the per-leg output budget startServer
532
+ * already read; omitted means buildProviderModels reads config itself
496
533
  * @returns {object} Server options ready for createOpencodeServer
497
534
  */
498
535
  function buildServerOptions(options = {}) {
@@ -573,7 +610,7 @@ function buildServerOptions(options = {}) {
573
610
  const resolvedForProvider = (Array.isArray(options.models) && options.models.length)
574
611
  ? options.models
575
612
  : (options.model ? [options.model] : []);
576
- config.provider = buildProviderModels(resolvedForProvider);
613
+ config.provider = buildProviderModels(resolvedForProvider, options.outputBudget);
577
614
 
578
615
  // v4.6.2 PR1 (spec §4, D1/D2): a host-form ANTHROPIC_BASE_URL is correct
579
616
  // for Anthropic SDKs (they append /v1) and fatal for OpenCode's
@@ -764,7 +801,31 @@ async function startServer(options = {}) {
764
801
  // path is otherwise unreachable from a unit test.
765
802
  const createOpencodeServer = options._createOpencodeServer
766
803
  || await getCreateOpencodeServer();
767
- const serverOptions = buildServerOptions(options);
804
+
805
+ // #218 PR 3: ONE config read feeds both levers. The descriptor
806
+ // (buildProviderModels, inside buildServerOptions) and the engine flag
807
+ // (withOutputTokenFlag below) used to call loadConfig() separately; a config
808
+ // write between the two reads could hand the engine a descriptor from one
809
+ // budget and a flag from another. Named mutant "DOUBLEREAD"
810
+ // (tests/opencode-client-output-flag.test.js: buildProviderModels must
811
+ // receive the value startServer read).
812
+ const { getOutputBudget } = require('./utils/config');
813
+ const outputBudget = getOutputBudget();
814
+ const serverOptions = buildServerOptions({ ...options, outputBudget });
815
+
816
+ // #218 PR 2: the engine reads OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX from the
817
+ // env it is SPAWNED with, and the pinned SDK spreads process.env into that
818
+ // spawn synchronously, before its first await. So the flag is set around the
819
+ // synchronous call only and is restored before the promise is awaited — it
820
+ // never reaches the caller's env or any other child amicus starts. The budget
821
+ // is the ONE value read above and handed to buildProviderModels as well, so
822
+ // the two levers cannot disagree (measured agreeing: probe rows C2, K6). Unit
823
+ // pin: tests/opencode-client-output-flag.test.js through the
824
+ // `_createOpencodeServer` seam. SDK-side canary for the spread-before-await
825
+ // fact: tests/opencode-client-sdk-spawn-timing.test.js drives the REAL SDK
826
+ // against a fake engine on PATH. Engine-side canary: probe rows K6/K12/K13,
827
+ // run in CI's keyless job by tests/probe-flag-canary.integration.test.js.
828
+ const { withOutputTokenFlag, OUTPUT_TOKEN_FLAG } = require('./utils/engine-output-flag');
768
829
 
769
830
  // Measure the healthy path. The v4.5.2 timeout had to be sized from the
770
831
  // asymmetry of the failure (a slow start costs latency, a failed one costs a
@@ -772,7 +833,14 @@ async function startServer(options = {}) {
772
833
  // margin against the ceiling was unmeasurable on exactly the slow boxes that
773
834
  // needed it. Now it is one debug line, not an inference.
774
835
  const startedAt = Date.now();
775
- const sdkServer = await createOpencodeServer(serverOptions);
836
+ // #218 PR 3 (council #232 r3 B1): with no budget the wrapper leaves an ambient
837
+ // OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX untouched (PR 2 ruling R2) and the
838
+ // engine honours it (probe C3/J2), so a death report must name THAT value, not
839
+ // the 32000 default. Read here, before the spawn, from the same env the wrapper
840
+ // reads; null whenever a budget is set (the wrapper overrides the flag then).
841
+ const ambientOutputTokenFlag = outputBudget === null && typeof process.env[OUTPUT_TOKEN_FLAG] === 'string'
842
+ ? process.env[OUTPUT_TOKEN_FLAG] : null;
843
+ const sdkServer = await withOutputTokenFlag(outputBudget, () => createOpencodeServer(serverOptions));
776
844
  const { logger } = require('./utils/logger');
777
845
  logger.debug('OpenCode server started', {
778
846
  startMs: Date.now() - startedAt,
@@ -785,6 +853,14 @@ async function startServer(options = {}) {
785
853
  // findListenerPid may return null if the port isn't bound yet (startup race);
786
854
  // in that case close() degrades to SIGTERM-only, which is acceptable best-effort.
787
855
  const server = buildServerHandle(sdkServer);
856
+ // #218 PR 3 (council #232 r1 B3): the budget this engine was SPAWNED with
857
+ // rides its handle, so a death report names the reservation that produced
858
+ // the finish, not whatever config.json says by then (headless.js ::
859
+ // readOutputBudgetSafe reads it first). null = unset.
860
+ server.outputBudget = outputBudget;
861
+ // Stamped beside the budget for the same reader (headless.js's death report).
862
+ // Named mutant "AMBIENTNOTSTAMPED" (tests/opencode-client-output-flag.test.js).
863
+ server.ambientOutputTokenFlag = ambientOutputTokenFlag;
788
864
 
789
865
  return { client, server };
790
866
  }
@@ -64,6 +64,9 @@ function validatePack(pack, { mode } = { mode: 'run' }) {
64
64
  for (const key of Object.keys(opts)) {
65
65
  if (!KIND_OPTIONS[pack.kind].includes(key)) { errors.push(`unknown option '${key}' for kind '${pack.kind}'`); }
66
66
  }
67
+ // #218 PR 4 whole-branch review (VCMD-2): the VALUE too — a saved 'turbo' would reach the wire.
68
+ const { VARIANT_LEVELS } = require('../utils/thinking-validators');
69
+ if (opts.thinking !== undefined && !VARIANT_LEVELS.includes(opts.thinking)) { errors.push(`options.thinking must be one of: ${VARIANT_LEVELS.join(', ')}`); }
67
70
  }
68
71
 
69
72
  const { getEffectiveAliases, getCouncilWithSource } = require('../utils/config');
@@ -78,7 +78,7 @@ function resolveExistingSessionDir(projectDir, taskId) {
78
78
  * @param {string} metadata.project - Project path
79
79
  * @param {string} [metadata.briefing] - Task briefing
80
80
  * @param {string} [metadata.mode] - Mode: 'interactive' or 'headless'
81
- * @param {string} [metadata.thinking='medium'] - Thinking/reasoning intensity level
81
+ * @param {string} [metadata.thinking] - Reasoning effort requested; recorded only when one was (#218 PR 4)
82
82
  * @throws {Error} If session already exists
83
83
  */
84
84
  function createSession(projectDir, taskId, metadata) {
@@ -99,7 +99,7 @@ function createSession(projectDir, taskId, metadata) {
99
99
  project: metadata.project || projectDir,
100
100
  briefing: metadata.briefing || '',
101
101
  mode: metadata.mode || 'interactive',
102
- thinking: metadata.thinking || 'medium',
102
+ ...(metadata.thinking ? { thinking: metadata.thinking } : {}), // #218 PR 4: emit-when-requested (see start-metadata.js)
103
103
  status: SESSION_STATUS.RUNNING,
104
104
  createdAt: new Date().toISOString(),
105
105
  completedAt: null,
@@ -15,6 +15,7 @@ const {
15
15
  createHeartbeat
16
16
  } = require('./session-utils');
17
17
  const { acquireLock, releaseLock } = require('../utils/session-lock');
18
+ const { noticeDroppedLevel } = require('./reopen-notices');
18
19
  const { runHeadless } = require('../headless');
19
20
  const { buildPrompts } = require('../prompt-builder');
20
21
  const { generateFoldNonce } = require('../utils/fold-marker');
@@ -140,6 +141,7 @@ async function continueSidecar(options) {
140
141
  // Load previous session data
141
142
  const { metadata: oldMetadata, summary: previousSummary, conversation: previousConversation } =
142
143
  loadPreviousSession(oldTaskId, project);
144
+ noticeDroppedLevel(oldMetadata, { taskId: oldTaskId, kind: 'continue' }); // council #235 r5 (J1/A3): read against the PARENT's metadata — a continuation opens a NEW session, sends no variant, and `continue` rejects --thinking, so a level the parent ran with silently becomes the provider's default here. Named mutant "CONTINUELEVELSILENT" (tests/sidecar/reopen-thinking-notice.test.js).
143
145
 
144
146
  // Lock the previous (EXISTING) session directory to prevent concurrent
145
147
  // continue operations — resolve dual-dir so a legacy session is locked too.
@@ -245,11 +247,14 @@ async function continueSidecar(options) {
245
247
  if (terminal.status === 'error') {
246
248
  meta.status = 'error';
247
249
  meta.reason = (result && result.error) ? String(result.error) : 'Incomplete';
250
+ 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)
251
+ if (result && typeof result.variant === 'string') { meta.variant = result.variant; } // #218 PR 4: emit-when-set, like finish (named mutant "CONTINUEERRORNOVARIANT", tests/continue-resume-spend.test.js)
252
+ if (result && result.variantUnverified === true) { meta.variantUnverified = true; }
248
253
  meta.completedAt = new Date().toISOString();
249
254
  writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), { mode: 0o600 });
250
255
  logger.error('Continuation completed with error', { taskId: newTaskId, error: meta.reason });
251
256
  } else {
252
- finalizeSession(sessionDir, summary, project, meta, { quietStdout: json, status: terminal.status });
257
+ finalizeSession(sessionDir, summary, project, meta, { quietStdout: json, status: terminal.status, finish: result && result.finish, variant: result && result.variant, variantUnverified: result && result.variantUnverified }); // named mutant "CONTINUEVARIANTDROPPED" (tests/continue-resume-spend.test.js): drop the variant args
253
258
  }
254
259
  // v4.3: attribute continue spend (C9/E4). Reload meta, write usage + append a
255
260
  // ledger row (status: statusFromResult, matching start.js — not terminal.status).