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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +324 -0
- package/README.md +1 -1
- package/bin/amicus.js +6 -0
- package/docs/ROADMAP.md +5 -4
- package/docs/architecture-map.md +732 -0
- package/docs/configuration.md +175 -1
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +76 -0
- package/docs/usage.md +14 -6
- package/electron/main.js +25 -2
- package/electron/setup-ui-alias-groups.js +161 -0
- package/electron/setup-ui-alias-script.js +70 -4
- package/electron/setup-ui-aliases.js +25 -21
- package/electron/setup-ui.js +11 -1
- package/package.json +1 -1
- package/schemas/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +12 -16
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli-handlers.js +17 -1
- 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/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/utils/api-key-validation.js +183 -94
- package/src/utils/config.js +65 -2
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-credit-check.js +61 -0
- package/src/utils/doctor-key-auth-check.js +271 -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/live-probes.js +53 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +14 -36
- package/src/utils/model-output-limit.js +132 -0
- package/src/utils/openrouter-credit.js +104 -0
- 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
|
@@ -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
|
-
|
|
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;
|
|
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 {
|
|
353
|
-
*
|
|
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 {
|
|
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
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.'
|
package/src/opencode-client.js
CHANGED
|
@@ -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 {
|
|
190
|
-
*
|
|
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,
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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');
|
package/src/session-manager.js
CHANGED
|
@@ -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
|
|
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
|
|
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,
|
package/src/sidecar/continue.js
CHANGED
|
@@ -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).
|