amicus 4.9.3 → 4.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +324 -0
  3. package/README.md +1 -1
  4. package/docs/ROADMAP.md +8 -5
  5. package/docs/architecture-map.md +736 -0
  6. package/docs/configuration.md +165 -26
  7. package/docs/council.md +9 -0
  8. package/docs/doc-system.md +12 -9
  9. package/docs/testing.md +2 -1
  10. package/docs/troubleshooting.md +113 -0
  11. package/docs/usage.md +11 -6
  12. package/package.json +1 -1
  13. package/schemas/model-catalog.schema.json +2 -1
  14. package/schemas/run.schema.json +13 -0
  15. package/scripts/postinstall.js +4 -0
  16. package/skills/sidecar/SKILL.md +1 -8
  17. package/src/cli-handlers-doctor.js +3 -0
  18. package/src/cli-handlers-fanout.js +10 -1
  19. package/src/cli-handlers-resume-continue.js +25 -0
  20. package/src/cli.js +5 -8
  21. package/src/council/briefings-chair.js +4 -2
  22. package/src/council/run-assemble.js +7 -2
  23. package/src/council/run-retry-notes.js +21 -1
  24. package/src/council/run-stages.js +8 -1
  25. package/src/headless.js +125 -7
  26. package/src/mcp-server.js +26 -0
  27. package/src/mcp-tools.js +4 -4
  28. package/src/opencode-client.js +84 -8
  29. package/src/pack/pack-validate.js +3 -0
  30. package/src/session-manager.js +2 -2
  31. package/src/sidecar/continue.js +6 -1
  32. package/src/sidecar/conversation-mirror.js +35 -11
  33. package/src/sidecar/electron-install.js +81 -81
  34. package/src/sidecar/electron-provision.js +179 -0
  35. package/src/sidecar/electron-trust.js +299 -0
  36. package/src/sidecar/fanout-leg-fallback.js +1 -0
  37. package/src/sidecar/fanout-leg.js +10 -2
  38. package/src/sidecar/fanout.js +2 -2
  39. package/src/sidecar/interactive.js +31 -4
  40. package/src/sidecar/models-ceiling-line.js +72 -0
  41. package/src/sidecar/models.js +4 -2
  42. package/src/sidecar/reopen-notices.js +97 -0
  43. package/src/sidecar/reopen-spend.js +3 -2
  44. package/src/sidecar/resume.js +15 -2
  45. package/src/sidecar/session-finalize.js +4 -1
  46. package/src/sidecar/session-utils.js +5 -1
  47. package/src/sidecar/start-metadata.js +1 -1
  48. package/src/sidecar/start.js +10 -5
  49. package/src/sidecar/unzip.js +40 -0
  50. package/src/utils/config.js +33 -12
  51. package/src/utils/curated-models.js +8 -8
  52. package/src/utils/degrade.js +7 -0
  53. package/src/utils/doctor-output-budget-check.js +198 -0
  54. package/src/utils/engine-output-flag.js +105 -0
  55. package/src/utils/engine-variants.js +298 -0
  56. package/src/utils/http-get.js +284 -0
  57. package/src/utils/model-catalog.js +36 -4
  58. package/src/utils/model-ceilings-modelsdev.js +230 -0
  59. package/src/utils/model-fetcher.js +12 -36
  60. package/src/utils/model-output-limit.js +21 -13
  61. package/src/utils/output-length.js +90 -0
  62. package/src/utils/result-schema.js +7 -2
  63. package/src/utils/spend-ledger.js +5 -1
  64. package/src/utils/thinking-validators.js +27 -80
  65. package/src/utils/validators.js +2 -3
@@ -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).
@@ -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 only if no text part arrives)
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 `state.usageByMsg`.
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` from a
56
- * fresh getMessages() snapshot and NOTHING else — no appendLines, no
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 is mutated)
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
- // parts (no visible text), promote the reasoning text to `output` so the headless
249
- // completion gates fire and the answer isn't lost as "No Output". Runs once — once
250
- // `output` is non-empty this is skipped on subsequent polls.
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
- * 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.
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
- * This module is the keystone the rest of the self-heal cluster (#54-#57)
9
- * imports. It does NOT wire itself into any caller. Everything that downloads,
10
- * extracts, spawns, or locks is dependency-INJECTABLE so tests never hit the
11
- * network or extract a real binary.
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
- /** Drive electron's own install.js with force_no_cache semantics. */
173
- function runInstaller({ electronDir, force, spawn }) {
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 = { ...process.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 the last-resort installer spawn (8 min) so a first-GUI-use
208
- // provision that reaches runInstaller without an explicit timeoutMs can't hang
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
- try {
243
- await extractFromCache({ zip, electronDir, platform, extract, fs });
244
- // Non-throwing extract w/ absent exe = the AV-quarantine signature.
245
- return verifyExtractOutcome({ electronDir, platform, fs });
246
- } catch (extractErr) {
247
- // Corrupt cached artifact: delete the bad zip so it can't poison the
248
- // cache, then fall through to a forced fresh download (unless offline).
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
- // install.js spawn — fetch via the SAME @electron/get api install.js uses,
267
- // extract offline, then report the REAL usability. A structurally-successful
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, fs, env: process.env, downloadMs: timeoutMs,
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
- // loop). A controlled FAILURE only reports plain repaired:false.
285
- if (controlledExtracted) { return verifyExtractOutcome({ electronDir, platform, fs }); }
286
- return { repaired: isElectronUsable({ electronDir, platform, fs }) };
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
  }