amicus 4.9.3 → 4.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +324 -0
- package/README.md +1 -1
- package/docs/ROADMAP.md +8 -5
- package/docs/architecture-map.md +736 -0
- package/docs/configuration.md +165 -26
- package/docs/council.md +9 -0
- package/docs/doc-system.md +12 -9
- package/docs/testing.md +2 -1
- package/docs/troubleshooting.md +113 -0
- package/docs/usage.md +11 -6
- package/package.json +1 -1
- package/schemas/model-catalog.schema.json +2 -1
- package/schemas/run.schema.json +13 -0
- package/scripts/postinstall.js +4 -0
- package/skills/sidecar/SKILL.md +1 -8
- package/src/cli-handlers-doctor.js +3 -0
- package/src/cli-handlers-fanout.js +10 -1
- package/src/cli-handlers-resume-continue.js +25 -0
- package/src/cli.js +5 -8
- package/src/council/briefings-chair.js +4 -2
- package/src/council/run-assemble.js +7 -2
- package/src/council/run-retry-notes.js +21 -1
- package/src/council/run-stages.js +8 -1
- package/src/headless.js +125 -7
- package/src/mcp-server.js +26 -0
- package/src/mcp-tools.js +4 -4
- package/src/opencode-client.js +84 -8
- package/src/pack/pack-validate.js +3 -0
- package/src/session-manager.js +2 -2
- package/src/sidecar/continue.js +6 -1
- package/src/sidecar/conversation-mirror.js +35 -11
- package/src/sidecar/electron-install.js +81 -81
- package/src/sidecar/electron-provision.js +179 -0
- package/src/sidecar/electron-trust.js +299 -0
- package/src/sidecar/fanout-leg-fallback.js +1 -0
- package/src/sidecar/fanout-leg.js +10 -2
- package/src/sidecar/fanout.js +2 -2
- package/src/sidecar/interactive.js +31 -4
- package/src/sidecar/models-ceiling-line.js +72 -0
- package/src/sidecar/models.js +4 -2
- package/src/sidecar/reopen-notices.js +97 -0
- package/src/sidecar/reopen-spend.js +3 -2
- package/src/sidecar/resume.js +15 -2
- package/src/sidecar/session-finalize.js +4 -1
- package/src/sidecar/session-utils.js +5 -1
- package/src/sidecar/start-metadata.js +1 -1
- package/src/sidecar/start.js +10 -5
- package/src/sidecar/unzip.js +40 -0
- package/src/utils/config.js +33 -12
- package/src/utils/curated-models.js +8 -8
- package/src/utils/degrade.js +7 -0
- package/src/utils/doctor-output-budget-check.js +198 -0
- package/src/utils/engine-output-flag.js +105 -0
- package/src/utils/engine-variants.js +298 -0
- package/src/utils/http-get.js +284 -0
- package/src/utils/model-catalog.js +36 -4
- package/src/utils/model-ceilings-modelsdev.js +230 -0
- package/src/utils/model-fetcher.js +12 -36
- package/src/utils/model-output-limit.js +21 -13
- package/src/utils/output-length.js +90 -0
- package/src/utils/result-schema.js +7 -2
- package/src/utils/spend-ledger.js +5 -1
- package/src/utils/thinking-validators.js +27 -80
- package/src/utils/validators.js +2 -3
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).
|
|
@@ -32,18 +32,39 @@ function createMirrorState() {
|
|
|
32
32
|
receivingReported: false,
|
|
33
33
|
output: '', // accumulated assistant text
|
|
34
34
|
seenReasoningParts: new Map(), // partId -> last captured reasoning length
|
|
35
|
-
reasoningOutput: '', // accumulated reasoning text (promoted to output
|
|
35
|
+
reasoningOutput: '', // accumulated reasoning text (promoted to output when no text part has arrived; see promotedOutput)
|
|
36
36
|
usageByMsg: new Map(), // msgId -> {tokens, cost}
|
|
37
|
+
// #218 PR 3: three facts about the LAST assistant message in the snapshot
|
|
38
|
+
// -- its `finish` (stamped at finalization, beside tokens/cost), whether it
|
|
39
|
+
// carries answer text, whether it carries reasoning -- the whole input of
|
|
40
|
+
// the death test in utils/output-length.js. Per MESSAGE on purpose: `output`
|
|
41
|
+
// above accumulates across a tool loop's messages and would let earlier
|
|
42
|
+
// text hide a final length stop, or earlier promoted reasoning condemn a
|
|
43
|
+
// later message that answered (council #232 r1 B2/D1).
|
|
44
|
+
lastAssistantFinish: null,
|
|
45
|
+
lastAssistantHasText: false,
|
|
46
|
+
lastAssistantHasReasoning: false,
|
|
47
|
+
promotedOutput: '', // the reasoning a promotion put into `output` as a stand-in; the first real answer text on a later message replaces it (council #232 r1)
|
|
37
48
|
};
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
/**
|
|
41
|
-
* Capture one assistant message's usage snapshot into
|
|
52
|
+
* Capture one assistant message's usage snapshot AND its finish into the state.
|
|
42
53
|
* The poll loop re-reads ALL messages every poll, so the latest snapshot per
|
|
43
54
|
* message id wins (keyed Map, never additive) — see pricing.sumPerMessageUsage.
|
|
44
55
|
* @returns {boolean} true when this message carried a usage payload
|
|
45
56
|
*/
|
|
46
57
|
function captureMsgUsage(msg, state) {
|
|
58
|
+
// #218 PR 3: `finish` was observed beside tokens/cost on every probe L row, so
|
|
59
|
+
// both mirror passes record it here; the last assistant message in the
|
|
60
|
+
// snapshot wins, and one still streaming (no finish yet) resets it to null; '' counts as none (council #232 r3 C1; mutant "EMPTYFINISH").
|
|
61
|
+
// The two part flags are read off THIS message's parts, never off `output`
|
|
62
|
+
// (council #232 r1 B2/D1). Named mutants "NOFINISH", "TEXTOFFOUTPUT"
|
|
63
|
+
// (tests/conversation-mirror.test.js).
|
|
64
|
+
state.lastAssistantFinish = (typeof msg.info.finish === 'string' && msg.info.finish.length > 0) ? msg.info.finish : null;
|
|
65
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : [];
|
|
66
|
+
state.lastAssistantHasText = parts.some((p) => p && p.type === 'text' && typeof p.text === 'string' && p.text.trim().length > 0);
|
|
67
|
+
state.lastAssistantHasReasoning = parts.some((p) => p && p.type === 'reasoning' && typeof p.text === 'string' && p.text.length > 0);
|
|
47
68
|
if (msg.info.tokens || typeof msg.info.cost === 'number') {
|
|
48
69
|
state.usageByMsg.set(msg.info.id, { tokens: msg.info.tokens, cost: msg.info.cost });
|
|
49
70
|
return true;
|
|
@@ -52,9 +73,9 @@ function captureMsgUsage(msg, state) {
|
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
/**
|
|
55
|
-
* USAGE-ONLY mirror pass (v4.4 B1). Captures `info.tokens`/`info.cost`
|
|
56
|
-
* fresh getMessages() snapshot and NOTHING else —
|
|
57
|
-
* `state.output` growth, no progress updates, no pending-tool bookkeeping.
|
|
76
|
+
* USAGE-ONLY mirror pass (v4.4 B1). Captures `info.tokens`/`info.cost` — and, since #218 PR 3,
|
|
77
|
+
* the last assistant message's `finish` — from a fresh getMessages() snapshot and NOTHING else —
|
|
78
|
+
* no appendLines, no `state.output` growth, no progress updates, no pending-tool bookkeeping.
|
|
58
79
|
*
|
|
59
80
|
* This exists because the headless poll loop's fast-path exits (trailing fold
|
|
60
81
|
* marker, SDK `idle`) break BEFORE OpenCode stamps usage at finalization, so a
|
|
@@ -63,7 +84,7 @@ function captureMsgUsage(msg, state) {
|
|
|
63
84
|
* conversation.jsonl a second time; this function cannot, because it never
|
|
64
85
|
* touches seenTextParts/output at all.
|
|
65
86
|
* @param {Array} messages getMessages() snapshot
|
|
66
|
-
* @param {object} state from createMirrorState() (only usageByMsg
|
|
87
|
+
* @param {object} state from createMirrorState() (only usageByMsg and the three lastAssistant* facts are mutated)
|
|
67
88
|
* @returns {number} count of messages whose usage was captured
|
|
68
89
|
*/
|
|
69
90
|
function mirrorUsageOnly(messages, state) {
|
|
@@ -138,6 +159,9 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
138
159
|
if (part.text.length > prevLen) {
|
|
139
160
|
// Append only the new portion (handles streaming growth)
|
|
140
161
|
const newText = part.text.slice(prevLen);
|
|
162
|
+
// The first non-whitespace text replaces the stand-in; output restarts from the answer;
|
|
163
|
+
// conversation.jsonl keeps its reasoning. KEEPPROMOTED; WHITESPACERESET drops the trim.
|
|
164
|
+
if (state.promotedOutput && newText.trim().length > 0) { state.output = ''; state.promotedOutput = ''; }
|
|
141
165
|
state.output += newText;
|
|
142
166
|
state.seenTextParts.set(partId, part.text.length);
|
|
143
167
|
appendLines.push({ role: 'assistant', content: newText, timestamp: now() });
|
|
@@ -244,12 +268,12 @@ function mirrorMessages(messages, state, opts = {}) {
|
|
|
244
268
|
const lastAssistant = list.filter(m => m.info && m.info.role === 'assistant').pop();
|
|
245
269
|
assistantFinished = !!(lastAssistant && lastAssistant.info.time && lastAssistant.info.time.completed);
|
|
246
270
|
|
|
247
|
-
// Reasoning-only fallback: if the assistant finished but emitted only reasoning
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
// `
|
|
271
|
+
// Reasoning-only fallback: if the assistant finished but emitted only reasoning parts (no
|
|
272
|
+
// visible text), promote the reasoning text to `output` so the headless completion gates fire
|
|
273
|
+
// and the answer isn't lost as "No Output". Runs once — a non-empty `output` skips it on later
|
|
274
|
+
// polls. `promotedOutput` records the stand-in, which the first real text part replaces above.
|
|
251
275
|
if (assistantFinished && !state.output && state.reasoningOutput) {
|
|
252
|
-
state.output = state.reasoningOutput;
|
|
276
|
+
state.output = state.promotedOutput = state.reasoningOutput;
|
|
253
277
|
appendLines.push({ role: 'assistant', content: state.reasoningOutput, timestamp: now() });
|
|
254
278
|
}
|
|
255
279
|
|
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Electron self-heal primitive (#53, #59).
|
|
3
3
|
*
|
|
4
|
-
* Electron is an optionalDependency (^28.0.0). A flaky / interrupted extract
|
|
5
|
-
*
|
|
6
|
-
* path.txt on disk while dist/<exe> is MISSING, so the GUI silently fails.
|
|
4
|
+
* Electron is an optionalDependency (^28.0.0). A flaky / interrupted extract —
|
|
5
|
+
* or Windows Defender quarantining electron.exe — can leave the package's
|
|
6
|
+
* path.txt on disk while dist/<exe> is MISSING, so the GUI silently fails. This
|
|
7
|
+
* module is the keystone the rest of the self-heal cluster (#54-#57) imports; it
|
|
8
|
+
* wires itself into no caller, and everything that downloads, extracts, spawns or
|
|
9
|
+
* locks is dependency-INJECTABLE so tests never hit the network or extract a real
|
|
10
|
+
* binary.
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Layout reference (npm `electron` package):
|
|
14
|
-
* node_modules/electron/path.txt -> "electron.exe" (the exe basename)
|
|
15
|
-
* node_modules/electron/dist/<exe> -> the actual binary
|
|
16
|
-
* #59: when ELECTRON_OVERRIDE_DIST_PATH is set, the exe lives in that dir
|
|
17
|
-
* instead of <pkg>/dist (mirrors electron/index.js + install.js semantics).
|
|
18
|
-
* Cache layout (@electron/get): <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
|
|
12
|
+
* Layout (npm `electron`): path.txt -> the exe basename, dist/<exe> -> the binary.
|
|
13
|
+
* #59: ELECTRON_OVERRIDE_DIST_PATH moves the exe to <override>/<exe> (mirrors
|
|
14
|
+
* electron/index.js + install.js semantics). Cache layout (@electron/get):
|
|
15
|
+
* <cacheRoot>/<sha256>/electron-v<ver>-<platform>-<arch>.zip
|
|
19
16
|
*/
|
|
20
17
|
|
|
21
18
|
'use strict';
|
|
@@ -27,6 +24,8 @@ const { spawnSync } = require('child_process');
|
|
|
27
24
|
const { resolveCacheRoots } = require('./electron-cache');
|
|
28
25
|
const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
|
|
29
26
|
const { acquireRepairLock } = require('./electron-lock');
|
|
27
|
+
const { controlledProvision, isUnsafeArchive, refuseUnsafeArchive, rejectCachedZip } = require('./electron-provision');
|
|
28
|
+
const { artifactFileName, electronTrustPolicy, resolveAnchor, scrubbedChildEnv, verifyArtifact } = require('./electron-trust');
|
|
30
29
|
const { robustExtract } = require('./unzip');
|
|
31
30
|
|
|
32
31
|
/** Self-heal progress line to stderr (visible during first-GUI provision). */
|
|
@@ -134,32 +133,6 @@ async function extractFromCache({ zip, electronDir, platform, extract, fs }) {
|
|
|
134
133
|
writePathTxt({ electronDir, platform, fs });
|
|
135
134
|
}
|
|
136
135
|
|
|
137
|
-
/** Best-effort cache root for downloadArtifact (first resolved root). */
|
|
138
|
-
function cacheRootFor(env = process.env) {
|
|
139
|
-
return resolveCacheRoots(env)[0];
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* CONTROLLED provision: fetch the zip ourselves with the SAME @electron/get
|
|
144
|
-
* api install.js uses (downloadArtifact, force:true), extract offline, and let
|
|
145
|
-
* the caller verify isElectronUsable(). No blind install.js spawn.
|
|
146
|
-
* @returns {Promise<void>}
|
|
147
|
-
*/
|
|
148
|
-
async function controlledProvision({
|
|
149
|
-
electronDir, platform, arch, version, downloadArtifact, extract, fs, env = process.env, downloadMs = 480000,
|
|
150
|
-
}) {
|
|
151
|
-
const zip = await downloadArtifact({
|
|
152
|
-
version,
|
|
153
|
-
artifactName: 'electron',
|
|
154
|
-
force: true,
|
|
155
|
-
cacheRoot: cacheRootFor(env),
|
|
156
|
-
platform,
|
|
157
|
-
arch,
|
|
158
|
-
downloadOptions: { signal: AbortSignal.timeout(downloadMs) }, // 5.x native fetch: bound stalled downloads, free the lock
|
|
159
|
-
});
|
|
160
|
-
await extractFromCache({ zip, electronDir, platform, extract, fs });
|
|
161
|
-
}
|
|
162
|
-
|
|
163
136
|
/** Bind the fs-aware probes for the post-extract AV-quarantine verify. */
|
|
164
137
|
function verifyExtractOutcome({ electronDir, platform, fs }) {
|
|
165
138
|
return verifyQuarantine({
|
|
@@ -169,10 +142,16 @@ function verifyExtractOutcome({ electronDir, platform, fs }) {
|
|
|
169
142
|
});
|
|
170
143
|
}
|
|
171
144
|
|
|
172
|
-
/**
|
|
173
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Drive electron's own install.js with force_no_cache semantics. C3: the spawn env
|
|
147
|
+
* is SCRUBBED — install.js honours `npm_config_electron_mirror` AND
|
|
148
|
+
* `npm_config_electron_use_remote_checksums` (which turns its own bundled pin off),
|
|
149
|
+
* so `{...process.env}` here would funnel a blocked attacker into an unpinned
|
|
150
|
+
* downloader and undo the pin on the route above.
|
|
151
|
+
*/
|
|
152
|
+
function runInstaller({ electronDir, force, spawn, platform, arch }) {
|
|
174
153
|
const installScript = path.join(electronDir, 'install.js');
|
|
175
|
-
const env = {
|
|
154
|
+
const env = scrubbedChildEnv({ env: process.env, platform, arch });
|
|
176
155
|
if (force) {
|
|
177
156
|
env.force_no_cache = 'true';
|
|
178
157
|
}
|
|
@@ -187,7 +166,8 @@ function runInstaller({ electronDir, force, spawn }) {
|
|
|
187
166
|
* {deferred,reason} when there is no cached zip.
|
|
188
167
|
* @param {boolean} [opts.force] force a fresh (no-cache) installer download.
|
|
189
168
|
* @param {number} [opts.timeoutMs] best-effort installer timeout.
|
|
190
|
-
* @param {object} [opts.deps] injected { cachedZip, extract, spawn, acquireLock, fs
|
|
169
|
+
* @param {object} [opts.deps] injected { cachedZip, extract, spawn, acquireLock, fs,
|
|
170
|
+
* selfElectronDir } — the last pins the digest anchor's top rung (null disables it).
|
|
191
171
|
* @returns {Promise<{repaired?:boolean, deferred?:boolean, contended?:boolean, reason?:string}>}
|
|
192
172
|
*/
|
|
193
173
|
async function repairElectron({
|
|
@@ -204,9 +184,8 @@ async function repairElectron({
|
|
|
204
184
|
// Default extract: extract-zip bounded (idle/max) + native-unzip fallback (extract-zip-node24 stall).
|
|
205
185
|
const extract = deps.extract
|
|
206
186
|
|| ((zipPath, o) => robustExtract(zipPath, { ...o, platform, deps: { fs, log: stderrLog } }));
|
|
207
|
-
// Default-bound
|
|
208
|
-
//
|
|
209
|
-
// the holder — the caller's timeoutMs still wins when provided.
|
|
187
|
+
// Default-bound (8 min) so a first-GUI-use provision that reaches runInstaller
|
|
188
|
+
// without an explicit timeoutMs can't hang the holder; caller's value wins.
|
|
210
189
|
const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs || 480000 }));
|
|
211
190
|
const findZip = deps.cachedZip || ((o) => cachedZip(o));
|
|
212
191
|
const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
|
|
@@ -217,13 +196,17 @@ async function repairElectron({
|
|
|
217
196
|
: async () => (await import('@electron/get')).downloadArtifact;
|
|
218
197
|
|
|
219
198
|
if (!version) {
|
|
220
|
-
try {
|
|
221
|
-
version = require(path.join(electronDir, 'package.json')).version;
|
|
222
|
-
} catch {
|
|
223
|
-
version = undefined;
|
|
224
|
-
}
|
|
199
|
+
try { version = require(path.join(electronDir, 'package.json')).version; } catch { version = undefined; }
|
|
225
200
|
}
|
|
226
201
|
|
|
202
|
+
// The digest anchor and the trust policy, resolved ONCE for both routes. NOTE
|
|
203
|
+
// `version` is deliberately NOT passed to resolveAnchor: it may have just been
|
|
204
|
+
// read out of electronDir's own package.json above, and letting an untrusted
|
|
205
|
+
// directory pick which anchor judges its bytes is the ANCHORFROMTARGET hole.
|
|
206
|
+
const fileName = artifactFileName({ version, platform, arch });
|
|
207
|
+
const policy = electronTrustPolicy(process.env);
|
|
208
|
+
const anchor = resolveAnchor({ electronDir, fs, selfElectronDir: deps.selfElectronDir });
|
|
209
|
+
|
|
227
210
|
// Single-flight: bail out gracefully if another caller is already repairing.
|
|
228
211
|
let lock;
|
|
229
212
|
try {
|
|
@@ -235,55 +218,72 @@ async function repairElectron({
|
|
|
235
218
|
throw e;
|
|
236
219
|
}
|
|
237
220
|
|
|
221
|
+
let refusal = null; // a cache refusal the caller must still hear about if the download also fails
|
|
238
222
|
try {
|
|
239
223
|
// Attempt 1: extract from cache (always preferred, fully offline).
|
|
240
224
|
const zip = findZip({ version, platform, arch, env: process.env, fs });
|
|
241
225
|
if (zip) {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
|
|
250
|
-
if (cacheOnly) {
|
|
251
|
-
return {
|
|
252
|
-
repaired: false,
|
|
253
|
-
reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}`,
|
|
254
|
-
};
|
|
255
|
-
}
|
|
226
|
+
// C2: anything that can write the cache dir can swap these bytes, so HASH
|
|
227
|
+
// BEFORE EXTRACT — extractFromCache must be unreachable for an artifact the
|
|
228
|
+
// anchor contradicts. A missing anchor is NOT a refusal (see verifyArtifact).
|
|
229
|
+
const gate = verifyArtifact({ zip, anchor, fileName, policy, fs, log: stderrLog });
|
|
230
|
+
if (!gate.allowed) {
|
|
231
|
+
refusal = rejectCachedZip({ gate, zip, fileName, env: process.env, fs, log: stderrLog });
|
|
232
|
+
if (cacheOnly) { return refusal; }
|
|
256
233
|
// else: drop into the controlled download below.
|
|
234
|
+
} else {
|
|
235
|
+
try {
|
|
236
|
+
await extractFromCache({ zip, electronDir, platform, extract, fs });
|
|
237
|
+
// Non-throwing extract w/ absent exe = the AV-quarantine signature.
|
|
238
|
+
const outcome = verifyExtractOutcome({ electronDir, platform, fs });
|
|
239
|
+
return gate.verdict === 'no-digest' ? { ...outcome, unverified: true } : outcome;
|
|
240
|
+
} catch (extractErr) {
|
|
241
|
+
// C4 IS A CALL-SITE INVARIANT. A path-traversal refusal must not be
|
|
242
|
+
// deleted-and-retried, nor reported as "corrupt" — it stops here.
|
|
243
|
+
if (isUnsafeArchive(extractErr)) { return refuseUnsafeArchive({ err: extractErr, fileName, log: stderrLog }); }
|
|
244
|
+
// Corrupt cached artifact: delete the bad zip so it can't poison the
|
|
245
|
+
// cache, then fall through to a forced fresh download (unless offline).
|
|
246
|
+
try { fs.rmSync(zip, { force: true }); } catch { /* ignore */ }
|
|
247
|
+
if (cacheOnly) {
|
|
248
|
+
return { repaired: false, reason: `Cached electron zip for v${version} (${platform}-${arch}) was corrupt and removed; deferring re-download.${avHint(platform)}` };
|
|
249
|
+
}
|
|
250
|
+
// else: drop into the controlled download below.
|
|
251
|
+
}
|
|
257
252
|
}
|
|
258
253
|
} else if (cacheOnly) {
|
|
259
|
-
return {
|
|
260
|
-
deferred: true,
|
|
261
|
-
reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}`,
|
|
262
|
-
};
|
|
254
|
+
return { deferred: true, reason: `No cached electron zip found for v${version} (${platform}-${arch}); deferring download.${avHint(platform)}` };
|
|
263
255
|
}
|
|
264
256
|
|
|
265
|
-
// Attempt 2 (online): CONTROLLED download+extract instead of a blind
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
// download that produced no usable exe is a FAILURE (no false success; #53).
|
|
257
|
+
// Attempt 2 (online): CONTROLLED download+extract instead of a blind install.js
|
|
258
|
+
// spawn — the SAME @electron/get api install.js uses, extracted offline, then the
|
|
259
|
+
// REAL usability reported. A download that produced no usable exe is a FAILURE (#53).
|
|
269
260
|
let controlledExtracted = false;
|
|
270
261
|
try {
|
|
271
262
|
const downloadArtifact = await resolveDownloadArtifact();
|
|
272
263
|
await controlledProvision({
|
|
273
|
-
electronDir, platform, arch, version, downloadArtifact, extract,
|
|
264
|
+
electronDir, platform, arch, version, anchor, downloadArtifact, extract, extractFromCache,
|
|
265
|
+
fs, env: process.env, downloadMs: timeoutMs, policy, log: stderrLog,
|
|
274
266
|
});
|
|
275
267
|
controlledExtracted = true; // download + extract returned without throwing
|
|
276
|
-
} catch {
|
|
268
|
+
} catch (provisionErr) {
|
|
269
|
+
// C4 again: an unsafe archive here must NOT reach runInstaller, which would
|
|
270
|
+
// re-download and re-extract it through an extractor amicus does not drive.
|
|
271
|
+
if (isUnsafeArchive(provisionErr)) { return refuseUnsafeArchive({ err: provisionErr, fileName, log: stderrLog }); }
|
|
277
272
|
// Controlled download/extract failed (network, checksum, unzip). Try the
|
|
278
273
|
// installer as a LAST resort — it can NEVER short-circuit the honest
|
|
279
274
|
// verify below; we always return isElectronUsable().
|
|
280
|
-
try { runInstaller({ electronDir, force, spawn }); } catch { /* ignore */ }
|
|
275
|
+
try { runInstaller({ electronDir, force, spawn, platform, arch }); } catch { /* ignore */ }
|
|
281
276
|
}
|
|
282
277
|
// A NON-throwing controlled extract that left no usable exe is the
|
|
283
|
-
// AV-quarantine signature — surface it actionably (no false success, no
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
278
|
+
// AV-quarantine signature — surface it actionably (no false success, no loop).
|
|
279
|
+
const out = controlledExtracted
|
|
280
|
+
? verifyExtractOutcome({ electronDir, platform, fs })
|
|
281
|
+
: { repaired: isElectronUsable({ electronDir, platform, fs }) };
|
|
282
|
+
// A refusal the download did not rescue must reach doctor and the postinstall
|
|
283
|
+
// notice; plain {repaired:false} is what made a REFUSED artifact read as an
|
|
284
|
+
// ordinary "not provisioned" everywhere outside the cacheOnly path.
|
|
285
|
+
if (!out.repaired && refusal) { return { ...out, integrity: refusal.integrity, reason: [refusal.reason, out.reason].filter(Boolean).join(' ') }; }
|
|
286
|
+
return out;
|
|
287
287
|
} finally {
|
|
288
288
|
try { lock.release(); } catch { /* ignore */ }
|
|
289
289
|
}
|