@link-assistant/hive-mind 1.76.0 → 1.76.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,88 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.76.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 5d8d6c1: fix(cost): accumulate Anthropic cost across limit-reset resumes (#1886)
8
+
9
+ The session cost summary could report a large negative "Difference" (e.g.
10
+ `$-11.422796 (-31.66%)`) between the public pricing estimate and the Anthropic
11
+ figure. Root cause: the public estimate is computed from the session JSONL,
12
+ which accumulates the **entire** session across every limit-reset resume, while
13
+ the Anthropic `total_cost_usd` from the stream-json `result` event is scoped to a
14
+ **single** Claude process (only the resumed run). Comparing a full-session
15
+ estimate against a single-process figure produced a misleading gap even though
16
+ both numbers were individually correct.
17
+
18
+ The per-token math (`calculateModelCost`) was audited and is correct; this is a
19
+ scope mismatch, not a pricing error.
20
+
21
+ Fix:
22
+ - New `src/anthropic-cost-accumulator.lib.mjs` keeps a model-agnostic running
23
+ total of Anthropic's per-process `total_cost_usd` (it sums dollars, never
24
+ inspecting per-token prices, so it is correct for all models).
25
+ - `runClaude` seeds from and returns the cumulative total on every terminal path;
26
+ the cross-process limit-reset resume threads it via a new hidden
27
+ `--previous-anthropic-cost` option (`autoContinueWhenLimitResets`).
28
+ - A usage-limit hit ends as `is_error` with no `success` result event, so its
29
+ cost was previously discarded. The cost from a non-success terminal `result`
30
+ event is now kept as a fallback and folded into the accumulator, closing the
31
+ gap in the reported scenario.
32
+ - `displayCostComparison` / `displaySessionTokenUsage` print a verbose
33
+ accumulation breakdown ("cumulative across resume iterations: this run … +
34
+ carried forward … = …") so the figure is never mysterious again.
35
+
36
+ A deep case study (timeline, proven root causes, exact reproduced numbers, online
37
+ prior art incl. `anthropics/claude-code#13088`) is compiled under
38
+ `docs/case-studies/issue-1886/`.
39
+
40
+ ## 1.76.1
41
+
42
+ ### Patch Changes
43
+
44
+ - 13e7e6a: Docker isolation: reuse the host image instead of re-downloading a copy inside the (nested) Docker daemon (#1879).
45
+ - src/isolation-runner.lib.mjs: add `HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG` to pin the
46
+ isolation image tag, and `HIVE_MIND_DOCKER_ISOLATION_PULL` (always|missing|never) to emit a
47
+ `docker run --pull` policy. Verbose mode now logs the resolved image and pull policy.
48
+ - scripts/preload-dind-isolation-image.mjs: seed a DinD container's nested daemon from the
49
+ host (`docker save | docker exec -i … docker load`) so isolated tasks reuse the host image.
50
+ - .env.example: document the Docker isolation image/pull controls.
51
+ - Dockerfile / Dockerfile.dind / coolify/Dockerfile: bump the box base images to
52
+ `konard/box:2.2.0` / `konard/box-dind:2.2.0` (and the `docs/UBUNTU-SERVER*.md` examples).
53
+ v2.2.0 ships box's native host-image passthrough (box#94/#95), so the DinD deployment can seed
54
+ the nested daemon from the host automatically with
55
+ `-v /var/run/docker.sock:/var/run/host-docker.sock:ro -e DIND_HOST_PASSTHROUGH=public`.
56
+ - tests/test-issue-1879-docker-image-reuse.mjs: regression coverage.
57
+ - docs/case-studies/issue-1879: deep case study with logs, timeline, root causes, and runbook;
58
+ records that box#94 shipped in v2.2.0 and reports two upstream follow-ups — box#96
59
+ (public-mode passthrough test false positive) and box#97 (per-repository passthrough allowlist).
60
+
61
+ - 7335a73: Continue fork PRs with "Allow edits by maintainers" instead of halting on a misclassified fork divergence (#1893).
62
+
63
+ When the solver continues a cross-repository PR opened from another contributor's fork, it
64
+ synced the upstream default branch and then tried to push it back to `origin` — the
65
+ contributor's fork, which the operating maintainer does not own. GitHub rejected the push
66
+ with `! [remote rejected] main -> main (permission denied)`, and the solver misclassified
67
+ that permission error as a fork divergence (the heuristic matched the substring `rejected`),
68
+ halting with `Repository setup halted - fork divergence requires user decision` and advising
69
+ `--allow-fork-divergence-resolution-using-force-push-with-lease` — a flag that cannot help,
70
+ since force-push also requires fork write access.
71
+ - src/solve.branch-divergence.lib.mjs: add two pure helpers —
72
+ `shouldPushDefaultBranchToFork({currentUser, forkedRepo})` (skip the push when the user does
73
+ not own the fork; fail-open when owner/user is unknown) and `isPermissionDeniedPushError()`
74
+ (recognize a permission-denied rejection so it is never treated as divergence).
75
+ - src/solve.fork-sync.lib.mjs: new module holding `setupUpstreamAndSync` (extracted from
76
+ solve.repository.lib.mjs to stay under the 1500-line limit, re-exported unchanged). It now
77
+ resolves the current user, skips the fork's default-branch push when the user is not the fork
78
+ owner, and on a permission-denied push warns and continues on the PR branch instead of
79
+ halting. Genuine non-fast-forward divergence still triggers the original guidance. Adds
80
+ verbose diagnostics explaining each skip/continue decision.
81
+ - tests/test-issue-1893-fork-pr-permission-denied.mjs: regression coverage (9 cases) using the
82
+ exact failure output from the run log.
83
+ - docs/case-studies/issue-1893: deep case study with downloaded logs/data, timeline, root
84
+ causes, fix, codebase-wide audit, and existing-components review.
85
+
3
86
  ## 1.76.0
4
87
 
5
88
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.76.0",
3
+ "version": "1.76.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Issue #1886: Cumulative Anthropic cost across resume iterations.
5
+ *
6
+ * Background
7
+ * ----------
8
+ * The "Token Usage Summary" compares two numbers:
9
+ * - "Public pricing estimate" — computed locally from the session JSONL by
10
+ * `calculateSessionTokens`. The JSONL accumulates the *entire* logical
11
+ * session: when a run hits a usage limit and is resumed (either in-process
12
+ * via the auto-merge loop, or cross-process via
13
+ * `autoContinueWhenLimitResets` spawning a fresh `solve` with `--resume`),
14
+ * Claude Code appends to the *same* `<session-id>.jsonl`, so every run's
15
+ * tokens are present.
16
+ * - "Calculated by Anthropic" — taken from the `result` event's
17
+ * `total_cost_usd`. That figure is scoped to a *single* Claude process: it
18
+ * only covers the tokens that process produced, NOT the tokens inherited
19
+ * from a previous run that was interrupted by a limit reset.
20
+ *
21
+ * The result is a scope mismatch, not a pricing bug. In issue #1886 the public
22
+ * estimate ($36.085016, full session) was compared against Anthropic's
23
+ * per-process figure ($24.662220, the resumed run only), yielding a misleading
24
+ * "-31.66%" difference even though both numbers are individually correct.
25
+ *
26
+ * The fix
27
+ * -------
28
+ * Accumulate Anthropic's reported cost across resume iterations so the figure
29
+ * shown next to the full-session public estimate covers the same scope. This
30
+ * module is the single source of truth for that running total:
31
+ *
32
+ * - Each `solve` process seeds the accumulator once from
33
+ * `--previous-anthropic-cost` (0 for the first run; the carried-forward
34
+ * total for an auto-resumed run).
35
+ * - Every finished Claude process adds its own `total_cost_usd` via
36
+ * `addAnthropicRunCost`, which also covers the in-process auto-merge loop
37
+ * (each iteration is a separate Claude process in the same node process).
38
+ * - The display and the cross-process spawn both read the cumulative total,
39
+ * so "Calculated by Anthropic" tracks the full session.
40
+ *
41
+ * The accumulation is model-agnostic: it sums dollar figures and never inspects
42
+ * per-token prices, so it is correct for Fable 5, Opus, Sonnet, Haiku, and any
43
+ * future model. See docs/case-studies/issue-1886/ for the full analysis.
44
+ */
45
+
46
+ // Module-level singleton: the cumulative Anthropic cost for the current logical
47
+ // session (this node process plus everything seeded from prior processes).
48
+ let cumulativeAnthropicCostUSD = 0;
49
+ // Seeding must happen exactly once per node process. The auto-merge loop calls
50
+ // runClaude (and therefore the seed helper) repeatedly within a single process;
51
+ // re-seeding from the same CLI flag each time would wipe out accumulation.
52
+ let seeded = false;
53
+
54
+ /**
55
+ * Coerce an arbitrary value to a non-negative finite USD amount.
56
+ * @param {*} value
57
+ * @returns {number} the sanitized amount, or 0 when not a positive finite number
58
+ */
59
+ const toCostAmount = value => {
60
+ const n = typeof value === 'number' ? value : Number(value);
61
+ return Number.isFinite(n) && n > 0 ? n : 0;
62
+ };
63
+
64
+ /**
65
+ * Seed the accumulator from the carried-forward previous-run cost, exactly once
66
+ * per node process. Subsequent calls are no-ops so the in-process auto-merge
67
+ * loop does not reset the running total.
68
+ * @param {number|string|null|undefined} previousAnthropicCostUSD
69
+ * @returns {number} the cumulative total after seeding
70
+ */
71
+ export const seedCumulativeAnthropicCost = previousAnthropicCostUSD => {
72
+ if (seeded) return cumulativeAnthropicCostUSD;
73
+ cumulativeAnthropicCostUSD = toCostAmount(previousAnthropicCostUSD);
74
+ seeded = true;
75
+ return cumulativeAnthropicCostUSD;
76
+ };
77
+
78
+ /**
79
+ * Add a single Claude process's reported cost to the running total.
80
+ * Non-positive / non-finite inputs (e.g. a null cost when a run was interrupted
81
+ * by a limit before emitting a success result) contribute nothing.
82
+ * @param {number|string|null|undefined} runCostUSD
83
+ * @returns {number} the cumulative total after adding
84
+ */
85
+ export const addAnthropicRunCost = runCostUSD => {
86
+ cumulativeAnthropicCostUSD += toCostAmount(runCostUSD);
87
+ return cumulativeAnthropicCostUSD;
88
+ };
89
+
90
+ /**
91
+ * @returns {number} the cumulative Anthropic cost for the current logical session
92
+ */
93
+ export const getCumulativeAnthropicCost = () => cumulativeAnthropicCostUSD;
94
+
95
+ /**
96
+ * @returns {boolean} true once a positive cost has been seeded or accumulated
97
+ */
98
+ export const hasCumulativeAnthropicCost = () => cumulativeAnthropicCostUSD > 0;
99
+
100
+ /**
101
+ * Reset the accumulator. Intended for tests — production code seeds once and
102
+ * accumulates for the lifetime of the process.
103
+ */
104
+ export const resetCumulativeAnthropicCost = () => {
105
+ cumulativeAnthropicCostUSD = 0;
106
+ seeded = false;
107
+ };
@@ -138,19 +138,38 @@ export const displayModelUsage = async (usage, log) => {
138
138
  /**
139
139
  * Display cost comparison between public pricing and Anthropic's official cost
140
140
  * Issue #1557: Show simplified format when costs match, remove USD suffix
141
- * @param {number|null} publicCost - Public pricing estimate
142
- * @param {number|null} anthropicCost - Anthropic's official cost
141
+ * Issue #1886: `anthropicCost` is the cumulative Anthropic cost across every
142
+ * resume iteration (the session JSONL and therefore `publicCost` — spans
143
+ * the full session, so the Anthropic figure must too). The optional
144
+ * `previousAnthropicCost` is the portion carried in from earlier runs; when
145
+ * non-zero we show a verbose breakdown so the accumulation is auditable.
146
+ * @param {number|null} publicCost - Public pricing estimate (full session)
147
+ * @param {number|null} anthropicCost - Anthropic's cumulative official cost (full session)
143
148
  * @param {Function} log - Logging function
149
+ * @param {Object} [options]
150
+ * @param {number} [options.previousAnthropicCost=0] - cost carried in from earlier resume iterations
144
151
  */
145
- export const displayCostComparison = async (publicCost, anthropicCost, log) => {
152
+ export const displayCostComparison = async (publicCost, anthropicCost, log, options = {}) => {
153
+ const previousAnthropicCost = options.previousAnthropicCost || 0;
146
154
  const hasPublic = publicCost !== null && publicCost !== undefined;
147
155
  const hasAnthropic = anthropicCost !== null && anthropicCost !== undefined;
148
156
  const publicDec = hasPublic ? new Decimal(publicCost) : null;
149
157
  const anthropicDec = hasAnthropic ? new Decimal(anthropicCost) : null;
158
+ // Issue #1886: when the Anthropic figure was accumulated across resumes,
159
+ // expose the breakdown in verbose mode so "this run + carried forward = total"
160
+ // is auditable from the saved log.
161
+ const logAccumulationBreakdown = async () => {
162
+ if (previousAnthropicCost > 0 && anthropicDec) {
163
+ const thisRun = anthropicDec.minus(new Decimal(previousAnthropicCost));
164
+ await log(` ↳ Anthropic cost is cumulative across resume iterations (issue #1886):`, { verbose: true });
165
+ await log(` this run: $${thisRun.toFixed(6)} + carried forward: $${new Decimal(previousAnthropicCost).toFixed(6)} = $${anthropicDec.toFixed(6)}`, { verbose: true });
166
+ }
167
+ };
150
168
  // Issue #1703: also collapse to the short form when the rounded difference is below display precision,
151
169
  // so reports like "Difference: $-0.000000 (-0.00%)" no longer waste two extra lines.
152
170
  if (publicDec && anthropicDec && anthropicDec.minus(publicDec).abs().toFixed(6) === '0.000000') {
153
171
  await log(`\n 💰 Cost: $${anthropicDec.toFixed(6)}`);
172
+ await logAccumulationBreakdown();
154
173
  return;
155
174
  }
156
175
  await log('\n 💰 Cost estimation:');
@@ -163,6 +182,7 @@ export const displayCostComparison = async (publicCost, anthropicCost, log) => {
163
182
  } else {
164
183
  await log(' Difference: unknown');
165
184
  }
185
+ await logAccumulationBreakdown();
166
186
  };
167
187
 
168
188
  /**
@@ -316,11 +336,12 @@ export const displayBudgetStats = async (usage, tokenUsage, log) => {
316
336
  * @param {string} params.sessionId - Claude session id (skips when falsy)
317
337
  * @param {string} params.tempDir - Working directory containing the session JSONL (skips when falsy)
318
338
  * @param {Object|null} params.resultModelUsage - Authoritative per-model usage from the result JSON event
319
- * @param {number} params.anthropicTotalCostUSD - Anthropic's official total cost (for the comparison line)
339
+ * @param {number} params.anthropicTotalCostUSD - Anthropic's cumulative official cost across resume iterations (issue #1886)
340
+ * @param {number} [params.previousAnthropicCostUSD=0] - portion of anthropicTotalCostUSD carried in from earlier resume iterations (issue #1886)
320
341
  * @param {Object} params.argv - Parsed CLI args (reads argv.tokensBudgetStats)
321
342
  * @param {Function} params.log - Logger
322
343
  */
323
- export const displaySessionTokenUsage = async ({ sessionId, tempDir, resultModelUsage, anthropicTotalCostUSD, argv, log }) => {
344
+ export const displaySessionTokenUsage = async ({ sessionId, tempDir, resultModelUsage, anthropicTotalCostUSD, previousAnthropicCostUSD = 0, argv, log }) => {
324
345
  if (!sessionId || !tempDir) return;
325
346
  try {
326
347
  const tokenUsage = await calculateSessionTokens(sessionId, tempDir, resultModelUsage);
@@ -355,7 +376,9 @@ export const displaySessionTokenUsage = async ({ sessionId, tempDir, resultModel
355
376
  await log('\n 📈 Total across all models:');
356
377
  }
357
378
  // Show cost comparison (for both single and multiple models)
358
- await displayCostComparison(tokenUsage.totalCostUSD, anthropicTotalCostUSD, log);
379
+ // Issue #1886: anthropicTotalCostUSD is cumulative across resume iterations
380
+ // so it shares the full-session scope of the JSONL-based public estimate.
381
+ await displayCostComparison(tokenUsage.totalCostUSD, anthropicTotalCostUSD, log, { previousAnthropicCost: previousAnthropicCostUSD });
359
382
  // Show total tokens for single model only
360
383
  if (modelIds.length === 1) {
361
384
  await log(` Total tokens: ${formatNumber(tokenUsage.totalTokens)}`);
@@ -17,6 +17,7 @@ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
17
17
  import Decimal from 'decimal.js-light';
18
18
  import { createEmptySubSessionUsage, accumulateModelUsage, mergeResultModelUsage, createSubAgentCallEntry, accumulateSubAgentUsage, getRawRequestInputTokens, displaySessionTokenUsage } from './claude.budget-stats.lib.mjs';
19
19
  import { buildClaudeResumeCommand, buildClaudeAutonomousResumeCommand } from './claude.command-builder.lib.mjs';
20
+ import { seedCumulativeAnthropicCost, addAnthropicRunCost } from './anthropic-cost-accumulator.lib.mjs'; // Issue #1886
20
21
  import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
21
22
  import { SESSION_FORCE_KILLED_MARKER, postTrackedComment } from './tool-comments.lib.mjs'; // Issue #1625
22
23
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
@@ -662,6 +663,9 @@ export const executeClaudeCommand = async params => {
662
663
  let stderrErrors = [];
663
664
  let resultSuccessReceived = false;
664
665
  let anthropicTotalCostUSD = null;
666
+ // Issue #1886: a usage-limit hit ends as is_error (no success result). Keep
667
+ // the latest cost from ANY result event as a fallback for the failure path.
668
+ let anthropicCostFromAnyResult = null;
665
669
  let errorDuringExecution = false;
666
670
  let resultSummary = null;
667
671
  let resultModelUsage = null;
@@ -942,7 +946,9 @@ export const executeClaudeCommand = async params => {
942
946
  anthropicTotalCostUSD = data.total_cost_usd;
943
947
  await log(`💰 Anthropic official cost captured from success result: $${anthropicTotalCostUSD.toFixed(6)}`, { verbose: true });
944
948
  } else if (data.total_cost_usd !== undefined && data.total_cost_usd !== null) {
945
- await log(`💰 Anthropic cost from ${data.subtype || 'unknown'} result ignored: $${data.total_cost_usd.toFixed(6)}`, { verbose: true });
949
+ // Issue #1886: non-success terminal (e.g. usage-limit hit) still reports this process's cost keep as accumulation fallback.
950
+ anthropicCostFromAnyResult = data.total_cost_usd;
951
+ await log(`💰 Anthropic cost from ${data.subtype || 'unknown'} result kept as fallback for accumulation: $${data.total_cost_usd.toFixed(6)}`, { verbose: true });
946
952
  }
947
953
  // Issue #1263: Extract result summary (AI's summary of work done) for --attach-solution-summary
948
954
  if (data.subtype === 'success' && data.result && typeof data.result === 'string') {
@@ -1106,6 +1112,10 @@ export const executeClaudeCommand = async params => {
1106
1112
  await log(JSON.stringify(data, null, 2));
1107
1113
  if (data.type === 'result' && data.subtype === 'success' && data.total_cost_usd != null) {
1108
1114
  anthropicTotalCostUSD = data.total_cost_usd;
1115
+ } else if (data.type === 'result' && data.total_cost_usd != null) {
1116
+ // Issue #1886: keep a non-success terminal result's cost as a fallback
1117
+ // for accumulation (see the streaming branch above).
1118
+ anthropicCostFromAnyResult = data.total_cost_usd;
1109
1119
  }
1110
1120
  // Issue #1472: Forward remaining buffer event to interactive handler (was previously missed)
1111
1121
  if (interactiveHandler) {
@@ -1187,6 +1197,9 @@ export const executeClaudeCommand = async params => {
1187
1197
  await log(`\n\n❌ API explicitly marked error as not retryable (x-should-retry: false) and session made no progress (num_turns=${resultNumTurns}) after ${retryCount} attempt(s)`, { level: 'error' });
1188
1198
  await log(` This error is not recoverable. Failing fast to avoid a stuck retry loop (Issue #1437).`, { level: 'error' });
1189
1199
  await log(` Check https://status.anthropic.com/ for API status.`, { level: 'error' });
1200
+ // Issue #1886: fold captured cost so a cross-process resume's carried-forward cost is not dropped here.
1201
+ seedCumulativeAnthropicCost(argv.previousAnthropicCost);
1202
+ const cumulativeAnthropicCostUSDOnStuckRetry = addAnthropicRunCost(anthropicTotalCostUSD ?? anthropicCostFromAnyResult);
1190
1203
  return {
1191
1204
  success: false,
1192
1205
  sessionId,
@@ -1196,7 +1209,7 @@ export const executeClaudeCommand = async params => {
1196
1209
  messageCount,
1197
1210
  toolUseCount,
1198
1211
  is503Error,
1199
- anthropicTotalCostUSD,
1212
+ anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnStuckRetry, // Issue #1104/#1886
1200
1213
  resultSummary,
1201
1214
  // Issue #1845: surface the actual error so callers can show it to users
1202
1215
  errorInfo: { message: lastMessage || 'API explicitly marked error as not retryable', exitCode },
@@ -1233,6 +1246,9 @@ export const executeClaudeCommand = async params => {
1233
1246
  return await executeWithRetry();
1234
1247
  } else {
1235
1248
  await log(`\n\n❌ Transient API error persisted after ${maxRetries} retries\n Please try again later or check https://status.anthropic.com/`, { level: 'error' });
1249
+ // Issue #1886: fold captured cost so the carried-forward cost survives this retries-exhausted path.
1250
+ seedCumulativeAnthropicCost(argv.previousAnthropicCost);
1251
+ const cumulativeAnthropicCostUSDOnRetriesExhausted = addAnthropicRunCost(anthropicTotalCostUSD ?? anthropicCostFromAnyResult);
1236
1252
  return {
1237
1253
  success: false,
1238
1254
  sessionId,
@@ -1242,7 +1258,7 @@ export const executeClaudeCommand = async params => {
1242
1258
  messageCount,
1243
1259
  toolUseCount,
1244
1260
  is503Error, // preserve for callers that check this
1245
- anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1261
+ anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnRetriesExhausted, // Issue #1104/#1886: Include cumulative cost even on failure
1246
1262
  resultSummary, // Issue #1263: Include result summary
1247
1263
  // Issue #1845: surface the actual error so callers can show it to users
1248
1264
  errorInfo: { message: lastMessage || `Transient API error persisted after ${maxRetries} retries`, exitCode },
@@ -1294,6 +1310,12 @@ export const executeClaudeCommand = async params => {
1294
1310
  await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1295
1311
  await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1296
1312
  await showResumeCommand(sessionId, tempDir, claudePath, argv.model, log, argv);
1313
+ // Issue #1886: on failure (usually a usage-limit hit → auto-resume) fold
1314
+ // the captured cost into the cumulative total so autoContinueWhenLimitResets
1315
+ // carries it forward. A limit hit ends as is_error → fall back to the
1316
+ // non-success result cost.
1317
+ seedCumulativeAnthropicCost(argv.previousAnthropicCost);
1318
+ const cumulativeAnthropicCostUSDOnFailure = addAnthropicRunCost(anthropicTotalCostUSD ?? anthropicCostFromAnyResult);
1297
1319
  return {
1298
1320
  success: false,
1299
1321
  sessionId,
@@ -1303,7 +1325,7 @@ export const executeClaudeCommand = async params => {
1303
1325
  messageCount,
1304
1326
  toolUseCount,
1305
1327
  errorDuringExecution,
1306
- anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1328
+ anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnFailure, // Issue #1104/#1886: cumulative cost even on failure
1307
1329
  resultSummary, // Issue #1263: Include result summary
1308
1330
  // Issue #1845: surface the core error (e.g. "API Error: Output blocked by content
1309
1331
  // filtering policy") so users see what actually went wrong, not just a generic message.
@@ -1322,7 +1344,13 @@ export const executeClaudeCommand = async params => {
1322
1344
  await log(`📊 Total messages: ${messageCount}, Tool uses: ${toolUseCount}`);
1323
1345
  // Calculate and display total token usage from session JSONL file.
1324
1346
  // Extracted to claude.budget-stats.lib.mjs to keep this file under the line limit (Issue #1834).
1325
- await displaySessionTokenUsage({ sessionId, tempDir, resultModelUsage, anthropicTotalCostUSD, argv, log });
1347
+ // Issue #1886: the JSONL spans every resume iteration but each result
1348
+ // event's total_cost_usd covers only this process; seed the carried-forward
1349
+ // cost + add this process's so the cumulative total shares the JSONL scope.
1350
+ seedCumulativeAnthropicCost(argv.previousAnthropicCost);
1351
+ const cumulativeAnthropicCostUSD = addAnthropicRunCost(anthropicTotalCostUSD);
1352
+ const previousAnthropicCostUSD = cumulativeAnthropicCostUSD - (anthropicTotalCostUSD || 0);
1353
+ await displaySessionTokenUsage({ sessionId, tempDir, resultModelUsage, anthropicTotalCostUSD: cumulativeAnthropicCostUSD, previousAnthropicCostUSD, argv, log });
1326
1354
  await showResumeCommand(sessionId, tempDir, claudePath, argv.model, log, argv);
1327
1355
  return {
1328
1356
  success: true,
@@ -1332,7 +1360,7 @@ export const executeClaudeCommand = async params => {
1332
1360
  limitTimezone,
1333
1361
  messageCount,
1334
1362
  toolUseCount,
1335
- anthropicTotalCostUSD, // Pass Anthropic's official total cost
1363
+ anthropicTotalCostUSD: cumulativeAnthropicCostUSD, // Issue #1104/#1886: cumulative Anthropic cost across resume iterations
1336
1364
  errorDuringExecution, // Issue #1088: Track if error_during_execution subtype occurred
1337
1365
  resultSummary, // Issue #1263: Include result summary for --attach-solution-summary
1338
1366
  resultModelUsage, // Issue #1454
@@ -1378,6 +1406,9 @@ export const executeClaudeCommand = async params => {
1378
1406
  }
1379
1407
  }
1380
1408
  await log(`\n\n❌ Error executing Claude command: ${error.message}`, { level: 'error' });
1409
+ // Issue #1886: fold captured cost so the carried-forward cost survives this exception path too.
1410
+ seedCumulativeAnthropicCost(argv.previousAnthropicCost);
1411
+ const cumulativeAnthropicCostUSDOnException = addAnthropicRunCost(anthropicTotalCostUSD ?? anthropicCostFromAnyResult);
1381
1412
  return {
1382
1413
  success: false,
1383
1414
  sessionId,
@@ -1386,7 +1417,7 @@ export const executeClaudeCommand = async params => {
1386
1417
  limitTimezone: null,
1387
1418
  messageCount,
1388
1419
  toolUseCount,
1389
- anthropicTotalCostUSD, // Issue #1104: Include cost even on failure
1420
+ anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnException, // Issue #1104/#1886: Include cumulative cost even on failure
1390
1421
  resultSummary, // Issue #1263: Include result summary
1391
1422
  // Issue #1845: surface the actual exception message so callers can show it to users
1392
1423
  errorInfo: { message: error.message || error.toString() },
@@ -28,8 +28,13 @@ const { $ } = await use('command-stream');
28
28
  const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
29
29
  const RUNNING_SESSION_STATUSES = new Set(['executing', 'running']);
30
30
  const TERMINAL_SESSION_STATUSES = new Set(['executed', 'completed', 'failed', 'cancelled', 'canceled', 'error']);
31
- const DEFAULT_HIVE_MIND_IMAGE = 'konard/hive-mind:latest';
32
- const DEFAULT_HIVE_MIND_DIND_IMAGE = 'konard/hive-mind-dind:latest';
31
+ const HIVE_MIND_IMAGE_REPO = 'konard/hive-mind';
32
+ const HIVE_MIND_DIND_IMAGE_REPO = 'konard/hive-mind-dind';
33
+ const DEFAULT_HIVE_MIND_IMAGE_TAG = 'latest';
34
+ // Docker's `--pull` accepts these policies. We only emit the flag when an
35
+ // operator explicitly opts in; otherwise Docker's own default ("missing")
36
+ // applies and `docker run` reuses any locally present image. See issue #1879.
37
+ const VALID_DOCKER_PULL_POLICIES = new Set(['always', 'missing', 'never']);
33
38
  const DOCKER_ISOLATION_TRACKING_BACKEND = 'screen';
34
39
  const DOCKER_CONTAINER_HOME = '/home/box';
35
40
  const DOCKER_CONTAINER_PREFIX = 'hive-mind-isolation';
@@ -79,15 +84,52 @@ function maybeAddMount(mounts, source, target, existsSync) {
79
84
  mounts.push({ source, target });
80
85
  }
81
86
 
87
+ /**
88
+ * Resolve the tag used for the Docker isolation image.
89
+ *
90
+ * Defaults to `latest`, but operators can pin it (e.g. to the exact version
91
+ * already present on the host) via `HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG`.
92
+ * Pinning matters for Docker-in-Docker deployments: the nested daemon starts
93
+ * with an empty image store, so an unpinned `:latest` whose registry digest has
94
+ * drifted from the host copy forces a fresh multi-gigabyte pull on every task.
95
+ * A pinned tag lets a pre-seeded image be reused instead. See issue #1879.
96
+ */
97
+ export function resolveDockerIsolationImageTag({ env = process.env } = {}) {
98
+ const explicit = String(env.HIVE_MIND_DOCKER_ISOLATION_IMAGE_TAG || '').trim();
99
+ return explicit || DEFAULT_HIVE_MIND_IMAGE_TAG;
100
+ }
101
+
82
102
  /**
83
103
  * Pick the Docker image used for `--isolation docker`.
84
104
  *
85
105
  * start-command defaults its Docker backend to a base OS image. Hive Mind needs
86
106
  * an image with the same CLI/tooling baseline as the parent process instead.
107
+ *
108
+ * `HIVE_MIND_DOCKER_ISOLATION_IMAGE` is a full override (repo:tag). Otherwise
109
+ * the repo is chosen by image variant and the tag by
110
+ * `resolveDockerIsolationImageTag()`.
87
111
  */
88
112
  export function getDockerIsolationImage({ env = process.env } = {}) {
89
113
  if (env.HIVE_MIND_DOCKER_ISOLATION_IMAGE) return env.HIVE_MIND_DOCKER_ISOLATION_IMAGE;
90
- return String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' ? DEFAULT_HIVE_MIND_DIND_IMAGE : DEFAULT_HIVE_MIND_IMAGE;
114
+ const repo = String(env.HIVE_MIND_IMAGE_VARIANT || '').toLowerCase() === 'dind' ? HIVE_MIND_DIND_IMAGE_REPO : HIVE_MIND_IMAGE_REPO;
115
+ return `${repo}:${resolveDockerIsolationImageTag({ env })}`;
116
+ }
117
+
118
+ /**
119
+ * Resolve the Docker `--pull` policy for isolated tasks.
120
+ *
121
+ * Returns one of `always` | `missing` | `never`, or `null` when unset (in which
122
+ * case the `--pull` flag is omitted and Docker's default applies). Operators set
123
+ * `HIVE_MIND_DOCKER_ISOLATION_PULL=never` to force reuse of an image already
124
+ * present in the (possibly nested) daemon and fail fast instead of silently
125
+ * re-downloading it. Invalid values are ignored. See issue #1879.
126
+ */
127
+ export function getDockerIsolationPullPolicy({ env = process.env } = {}) {
128
+ const raw = String(env.HIVE_MIND_DOCKER_ISOLATION_PULL || '')
129
+ .trim()
130
+ .toLowerCase();
131
+ if (!raw) return null;
132
+ return VALID_DOCKER_PULL_POLICIES.has(raw) ? raw : null;
91
133
  }
92
134
 
93
135
  /**
@@ -123,7 +165,16 @@ export function buildDockerIsolationCommand(command, args = [], options = {}) {
123
165
  const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
124
166
  const image = getDockerIsolationImage({ env });
125
167
  const innerCommand = buildShellCommand(command, args);
126
- const dockerArgs = ['docker', 'run', '--rm', '--name', makeDockerContainerName(sessionId), '--workdir', DOCKER_CONTAINER_HOME, '-e', `HOME=${DOCKER_CONTAINER_HOME}`, '-e', `HIVE_MIND_PARENT_SESSION_ID=${sessionId || ''}`];
168
+ const dockerArgs = ['docker', 'run', '--rm'];
169
+
170
+ // Reuse a locally present image instead of re-downloading it when the
171
+ // operator opts in. Omitted by default so Docker's "missing" policy applies.
172
+ const pullPolicy = getDockerIsolationPullPolicy({ env });
173
+ if (pullPolicy) {
174
+ dockerArgs.push('--pull', pullPolicy);
175
+ }
176
+
177
+ dockerArgs.push('--name', makeDockerContainerName(sessionId), '--workdir', DOCKER_CONTAINER_HOME, '-e', `HOME=${DOCKER_CONTAINER_HOME}`, '-e', `HIVE_MIND_PARENT_SESSION_ID=${sessionId || ''}`);
127
178
 
128
179
  if (shouldRunPrivilegedDockerIsolation(image, env)) {
129
180
  dockerArgs.push('--privileged');
@@ -359,9 +410,12 @@ export async function executeWithIsolation(command, args, options = {}) {
359
410
  if (verbose) {
360
411
  console.log(`[VERBOSE] isolation-runner: ${[binPath, ...startCommandArgs].map(shellQuote).join(' ')}`);
361
412
  if (backend === 'docker') {
362
- const image = getDockerIsolationImage({ env: options.env || process.env });
363
- const mounts = getDockerIsolationAuthMounts({ tool: options.tool, env: options.env || process.env, homeDir: options.homeDir || os.homedir(), existsSync: options.existsSync || fs.existsSync });
413
+ const env = options.env || process.env;
414
+ const image = getDockerIsolationImage({ env });
415
+ const pullPolicy = getDockerIsolationPullPolicy({ env });
416
+ const mounts = getDockerIsolationAuthMounts({ tool: options.tool, env, homeDir: options.homeDir || os.homedir(), existsSync: options.existsSync || fs.existsSync });
364
417
  console.log(`[VERBOSE] isolation-runner: Docker isolation image: ${image}`);
418
+ console.log(`[VERBOSE] isolation-runner: Docker isolation pull policy: ${pullPolicy || '(docker default: missing — reuse local image if present)'}`);
365
419
  console.log(`[VERBOSE] isolation-runner: Docker isolation mounts: ${mounts.map(m => m.target).join(', ') || '(none)'}`);
366
420
  }
367
421
  }
@@ -54,6 +54,9 @@ import { formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIt
54
54
  // Issue #1574: Interruptible sleep so CTRL+C is never blocked by a lingering timer
55
55
  const { interruptibleSleep } = await import('./interruptible-sleep.lib.mjs');
56
56
 
57
+ // Issue #1886: cumulative Anthropic cost carried across cross-process resumes
58
+ const { getCumulativeAnthropicCost } = await import('./anthropic-cost-accumulator.lib.mjs');
59
+
57
60
  const { calculateWaitTime } = validation;
58
61
 
59
62
  /**
@@ -168,6 +171,20 @@ export const autoContinueWhenLimitResets = async (issueUrl, sessionId, argv, sho
168
171
  resumeArgs.push('--auto-resume-iteration', String(nextAutoResumeIteration));
169
172
  resumeArgs.push('--auto-resume-max-iterations', String(maxAutoResumeIterations));
170
173
 
174
+ // Issue #1886: carry the cumulative Anthropic cost into the resumed process.
175
+ // The resumed run reads the same session JSONL (full session, all runs), so
176
+ // its public-pricing estimate spans every run; without this the resumed
177
+ // run's "Calculated by Anthropic" figure would cover only its own process
178
+ // and disagree with the full-session public estimate (the -31.66% gap in
179
+ // issue #1886). getCumulativeAnthropicCost() already includes this run's
180
+ // cost folded in at the runClaude return, plus anything carried from prior
181
+ // iterations via --previous-anthropic-cost.
182
+ const carriedAnthropicCost = getCumulativeAnthropicCost();
183
+ if (carriedAnthropicCost > 0) {
184
+ resumeArgs.push('--previous-anthropic-cost', String(carriedAnthropicCost));
185
+ await log(`💰 Carrying forward cumulative Anthropic cost: $${carriedAnthropicCost.toFixed(6)} (issue #1886)`, { verbose: true });
186
+ }
187
+
171
188
  // Pass session type for proper comment differentiation
172
189
  // See: https://github.com/link-assistant/hive-mind/issues/1152
173
190
  const sessionType = isRestart ? 'auto-restart' : 'auto-resume';
@@ -38,6 +38,61 @@ export function classifyPushRejection(errorOutput = '') {
38
38
  return 'unknown';
39
39
  }
40
40
 
41
+ /**
42
+ * Detect whether a push failure was caused by missing permissions rather than
43
+ * by branch divergence. Git surfaces this as `! [remote rejected] ...
44
+ * (permission denied)` (HTTP 403). This is fundamentally different from a
45
+ * non-fast-forward / divergence rejection: force-pushing or force-with-lease
46
+ * will NOT help because the user simply cannot write to the remote.
47
+ *
48
+ * Issue #1893: when continuing another contributor's fork PR, the maintainer
49
+ * does not own the fork, so pushing the fork's default branch is rejected with
50
+ * "permission denied". The old heuristic matched the substring "rejected" and
51
+ * misclassified this as fork divergence, halting the run and recommending a
52
+ * useless `--allow-fork-divergence-resolution-using-force-push-with-lease`.
53
+ */
54
+ export function isPermissionDeniedPushError(errorOutput = '') {
55
+ const normalized = String(errorOutput || '').toLowerCase();
56
+ return normalized.includes('permission denied') || normalized.includes('permission to') || normalized.includes('error: 403') || normalized.includes('the requested url returned error: 403') || (normalized.includes('denied') && normalized.includes('to https://'));
57
+ }
58
+
59
+ /**
60
+ * Decide whether the solver should push the freshly-synced default branch to
61
+ * the fork's `origin` remote.
62
+ *
63
+ * We only push the default branch to keep a fork we OWN in sync with upstream.
64
+ * When continuing someone else's fork PR (the fork belongs to the contributor,
65
+ * not the current user), the maintainer has push rights only to the PR branch
66
+ * (via "Allow edits by maintainers"), never to the fork's default branch.
67
+ * Attempting the push is both impossible (permission denied) and unnecessary,
68
+ * so we skip it. Issue #1893.
69
+ *
70
+ * @param {object} params
71
+ * @param {string|null} params.currentUser - authenticated GitHub login
72
+ * @param {string|null} params.forkedRepo - "owner/name" of the fork (origin)
73
+ * @returns {{ shouldPush: boolean, reason: string, forkOwner: string|null }}
74
+ */
75
+ export function shouldPushDefaultBranchToFork({ currentUser, forkedRepo } = {}) {
76
+ const forkOwner = forkedRepo && forkedRepo.includes('/') ? forkedRepo.split('/')[0] : null;
77
+
78
+ if (!forkOwner) {
79
+ // Without a parseable fork owner we cannot prove ownership; fall back to the
80
+ // historical behaviour of attempting the push so nothing regresses.
81
+ return { shouldPush: true, reason: 'fork-owner-unknown', forkOwner: null };
82
+ }
83
+
84
+ if (!currentUser) {
85
+ // Could not resolve the current user; attempt the push and let git report.
86
+ return { shouldPush: true, reason: 'current-user-unknown', forkOwner };
87
+ }
88
+
89
+ if (currentUser.toLowerCase() === forkOwner.toLowerCase()) {
90
+ return { shouldPush: true, reason: 'owns-fork', forkOwner };
91
+ }
92
+
93
+ return { shouldPush: false, reason: 'not-fork-owner', forkOwner };
94
+ }
95
+
41
96
  export function shouldTreatPushRejectionAsRemoteSynchronized(divergence = null) {
42
97
  if (!divergence?.remoteExists || divergence.ahead !== 0 || divergence.behind !== 0) {
43
98
  return false;
@@ -218,6 +218,19 @@ export const SOLVE_OPTION_DEFINITIONS = {
218
218
  default: 0,
219
219
  hidden: true,
220
220
  },
221
+ // Issue #1886: carried-forward Anthropic cost from previous resume iterations.
222
+ // The session JSONL accumulates the full session across limit-reset resumes,
223
+ // but Anthropic's result-event total_cost_usd is scoped to a single process.
224
+ // Threading the previous total here lets the resumed run display the
225
+ // full-session Anthropic cost alongside the full-session public estimate,
226
+ // instead of a misleading per-run figure. Internal/hidden: set automatically
227
+ // by autoContinueWhenLimitResets when spawning the resumed solve process.
228
+ 'previous-anthropic-cost': {
229
+ type: 'number',
230
+ description: 'Internal: cumulative Anthropic total_cost_usd carried forward from previous resume iterations (issue #1886)',
231
+ default: 0,
232
+ hidden: true,
233
+ },
221
234
  'auto-merge': {
222
235
  type: 'boolean',
223
236
  description: 'Automatically merge the pull request when the working session is finished and all CI/CD statuses pass and PR is mergeable. Implies --auto-restart-until-mergeable.',
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Fork upstream-sync module for the solve command.
4
+ // Extracted from solve.repository.lib.mjs to keep files under 1500 lines (#1893).
5
+
6
+ // Use use-m to dynamically import modules for cross-runtime compatibility
7
+ // Check if use is already defined globally (when imported from solve.mjs)
8
+ // If not, fetch it (when running standalone)
9
+ if (typeof globalThis.use === 'undefined') {
10
+ globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
11
+ }
12
+ const use = globalThis.use;
13
+
14
+ // Use command-stream for consistent $ behavior; wrap with rate-limit retry (#1726)
15
+ const { wrapDollarWithGhRetry } = await import('./github-rate-limit.lib.mjs');
16
+ const $ = wrapDollarWithGhRetry((await use('command-stream')).$);
17
+
18
+ // Import shared library functions
19
+ const lib = await import('./lib.mjs');
20
+ const { log, formatAligned } = lib;
21
+
22
+ // Import exit handler
23
+ import { safeExit } from './exit-handler.lib.mjs';
24
+
25
+ // Issue #1893: helpers that decide whether the fork's default branch may be
26
+ // pushed and that distinguish a permission-denied rejection from a genuine
27
+ // fork divergence.
28
+ const { isPermissionDeniedPushError, shouldPushDefaultBranchToFork } = await import('./solve.branch-divergence.lib.mjs');
29
+
30
+ // Set up upstream remote and sync fork
31
+ export const setupUpstreamAndSync = async (tempDir, forkedRepo, upstreamRemote, owner, repo, argv) => {
32
+ if (!forkedRepo || !upstreamRemote) return;
33
+
34
+ await log(`${formatAligned('🔗', 'Setting upstream:', upstreamRemote)}`);
35
+
36
+ // Check if upstream remote already exists
37
+ const checkUpstreamResult = await $({ cwd: tempDir })`git remote get-url upstream 2>/dev/null`;
38
+ let upstreamExists = checkUpstreamResult.code === 0;
39
+
40
+ if (upstreamExists) {
41
+ await log(`${formatAligned('ℹ️', 'Upstream exists:', 'Using existing upstream remote')}`);
42
+ } else {
43
+ // Add upstream remote since it doesn't exist
44
+ const upstreamResult = await $({ cwd: tempDir })`git remote add upstream https://github.com/${upstreamRemote}.git`;
45
+
46
+ if (upstreamResult.code === 0) {
47
+ await log(`${formatAligned('✅', 'Upstream set:', upstreamRemote)}`);
48
+ upstreamExists = true;
49
+ } else {
50
+ await log(`${formatAligned('⚠️', 'Warning:', 'Failed to add upstream remote')}`);
51
+ if (upstreamResult.stderr) {
52
+ await log(`${formatAligned('', 'Error details:', upstreamResult.stderr.toString().trim())}`);
53
+ }
54
+ }
55
+ }
56
+
57
+ // Proceed with fork sync if upstream remote is available
58
+ if (upstreamExists) {
59
+ // Fetch upstream
60
+ await log(`${formatAligned('🔄', 'Fetching upstream...', '')}`);
61
+ const fetchResult = await $({ cwd: tempDir })`git fetch upstream`;
62
+ if (fetchResult.code === 0) {
63
+ await log(`${formatAligned('✅', 'Upstream fetched:', 'Successfully')}`);
64
+
65
+ // Sync the default branch with upstream to avoid merge conflicts
66
+ await log(`${formatAligned('🔄', 'Syncing default branch...', '')}`);
67
+
68
+ // Get current branch so we can return to it after sync
69
+ const currentBranchResult = await $({ cwd: tempDir })`git branch --show-current`;
70
+ if (currentBranchResult.code === 0) {
71
+ const currentBranch = currentBranchResult.stdout.toString().trim();
72
+
73
+ // Get the default branch name from the original repository using GitHub API
74
+ const repoInfoResult = await $`gh api repos/${owner}/${repo} --jq .default_branch`;
75
+ if (repoInfoResult.code === 0) {
76
+ const upstreamDefaultBranch = repoInfoResult.stdout.toString().trim();
77
+ await log(`${formatAligned('ℹ️', 'Default branch:', upstreamDefaultBranch)}`);
78
+
79
+ // Always sync the default branch, regardless of current branch
80
+ // This ensures fork is up-to-date even if we're working on a different branch
81
+
82
+ // Step 1: Switch to default branch if not already on it
83
+ let syncSuccessful = true;
84
+ if (currentBranch !== upstreamDefaultBranch) {
85
+ await log(`${formatAligned('🔄', 'Switching to:', `${upstreamDefaultBranch} branch`)}`);
86
+ const checkoutResult = await $({ cwd: tempDir })`git checkout ${upstreamDefaultBranch}`;
87
+ if (checkoutResult.code !== 0) {
88
+ await log(`${formatAligned('⚠️', 'Warning:', `Failed to checkout ${upstreamDefaultBranch}`)}`);
89
+ syncSuccessful = false; // Cannot proceed with sync
90
+ }
91
+ }
92
+
93
+ // Step 2: Sync default branch with upstream (only if checkout was successful)
94
+ if (syncSuccessful) {
95
+ const syncResult = await $({ cwd: tempDir })`git reset --hard upstream/${upstreamDefaultBranch}`;
96
+ if (syncResult.code === 0) {
97
+ await log(`${formatAligned('✅', 'Default branch synced:', `with upstream/${upstreamDefaultBranch}`)}`);
98
+
99
+ // Step 3: Push the updated default branch to fork to keep it in sync.
100
+ //
101
+ // Issue #1893: only push the default branch when the current user
102
+ // OWNS the fork. When continuing another contributor's fork PR the
103
+ // fork belongs to them, and "Allow edits by maintainers" grants
104
+ // push access only to the PR branch — never to the fork's default
105
+ // branch. Attempting the push there is guaranteed to be rejected
106
+ // with "permission denied" and is unnecessary, so we skip it and
107
+ // keep working on the PR branch.
108
+ const currentUserResult = await $`gh api user --jq .login`;
109
+ const currentUser = currentUserResult.code === 0 ? currentUserResult.stdout.toString().trim() : null;
110
+ const pushDecision = shouldPushDefaultBranchToFork({ currentUser, forkedRepo });
111
+
112
+ if (!pushDecision.shouldPush) {
113
+ await log(`${formatAligned('ℹ️', 'Skipping fork push:', `${upstreamDefaultBranch} synced locally only`)}`);
114
+ await log(`${formatAligned('', 'Reason:', `Fork ${forkedRepo} is owned by ${pushDecision.forkOwner}, not ${currentUser || 'the current user'}`)}`, {
115
+ verbose: true,
116
+ });
117
+ await log(`${formatAligned('', 'Next:', 'Continuing on the PR branch (maintainer edits allowed on the PR head only)')}`, {
118
+ verbose: true,
119
+ });
120
+ // Fall through to Step 4 (return to original branch) without pushing.
121
+ if (currentBranch !== upstreamDefaultBranch) {
122
+ await log(`${formatAligned('🔄', 'Returning to:', `${currentBranch} branch`)}`);
123
+ const returnResult = await $({ cwd: tempDir })`git checkout ${currentBranch}`;
124
+ if (returnResult.code === 0) {
125
+ await log(`${formatAligned('✅', 'Branch restored:', `Back on ${currentBranch}`)}`);
126
+ } else {
127
+ await log(`${formatAligned('⚠️', 'Warning:', `Failed to return to ${currentBranch}`)}`);
128
+ }
129
+ }
130
+ return;
131
+ }
132
+
133
+ await log(`${formatAligned('🔄', 'Pushing to fork:', `${upstreamDefaultBranch} branch`)}`);
134
+ const pushResult = await $({ cwd: tempDir })`git push origin ${upstreamDefaultBranch} 2>&1`;
135
+ if (pushResult.code === 0) {
136
+ await log(`${formatAligned('✅', 'Fork updated:', 'Default branch pushed to fork')}`);
137
+ } else {
138
+ // Check if it's a non-fast-forward error (fork has diverged from upstream)
139
+ const errorMsg = (pushResult.stderr ? pushResult.stderr.toString().trim() : '') || (pushResult.stdout ? pushResult.stdout.toString().trim() : '');
140
+
141
+ // Issue #1893: a "permission denied" rejection is NOT a divergence.
142
+ // It means the current user cannot write to this fork (e.g. it
143
+ // belongs to another contributor). Force-push / force-with-lease
144
+ // cannot fix that, so never recommend the divergence flag here.
145
+ // Syncing the default branch is best-effort, so we warn and
146
+ // continue working on the PR branch instead of halting.
147
+ if (isPermissionDeniedPushError(errorMsg)) {
148
+ await log('');
149
+ await log(`${formatAligned('ℹ️', 'Skipping fork sync:', `No push access to ${forkedRepo}`)}`);
150
+ await log(`${formatAligned('', 'Reason:', "Fork's default branch is owned by another user; this is expected when")}`, { verbose: true });
151
+ await log(`${formatAligned('', '', "continuing a contributor's fork PR (maintainer edits cover the PR branch only)")}`, { verbose: true });
152
+ await log(`${formatAligned('', 'Push output:', errorMsg.split('\n')[0] || errorMsg)}`, { verbose: true });
153
+ // Return to the original branch and continue without halting.
154
+ if (currentBranch !== upstreamDefaultBranch) {
155
+ await log(`${formatAligned('🔄', 'Returning to:', `${currentBranch} branch`)}`);
156
+ const returnResult = await $({ cwd: tempDir })`git checkout ${currentBranch}`;
157
+ if (returnResult.code === 0) {
158
+ await log(`${formatAligned('✅', 'Branch restored:', `Back on ${currentBranch}`)}`);
159
+ } else {
160
+ await log(`${formatAligned('⚠️', 'Warning:', `Failed to return to ${currentBranch}`)}`);
161
+ }
162
+ }
163
+ return;
164
+ }
165
+
166
+ const isNonFastForward = errorMsg.includes('non-fast-forward') || errorMsg.includes('rejected') || errorMsg.includes('tip of your current branch is behind');
167
+
168
+ if (isNonFastForward) {
169
+ // Fork has diverged from upstream
170
+ await log('');
171
+ await log(`${formatAligned('⚠️', 'FORK DIVERGENCE DETECTED', '')}`, { level: 'warn' });
172
+ await log('');
173
+ await log(' 🔍 What happened:');
174
+ await log(` Your fork's ${upstreamDefaultBranch} branch has different commits than upstream`);
175
+ await log(' This typically occurs when upstream had a force push (e.g., git reset --hard)');
176
+ await log('');
177
+ await log(' 📦 Current state:');
178
+ await log(` • Fork: ${forkedRepo}`);
179
+ await log(` • Upstream: ${owner}/${repo}`);
180
+ await log(` • Branch: ${upstreamDefaultBranch}`);
181
+ await log('');
182
+
183
+ // Check if user has enabled automatic force push
184
+ if (argv.allowForkDivergenceResolutionUsingForcePushWithLease) {
185
+ await log(' 🔄 Auto-resolution ENABLED (--allow-fork-divergence-resolution-using-force-push-with-lease):');
186
+ await log(' Attempting to force-push with --force-with-lease...');
187
+ await log('');
188
+
189
+ // Use --force-with-lease for safer force push
190
+ // This will only force push if the remote hasn't changed since our last fetch
191
+ await log(`${formatAligned('🔄', 'Force pushing:', 'Syncing fork with upstream (--force-with-lease)')}`);
192
+ const forcePushResult = await $({
193
+ cwd: tempDir,
194
+ })`git push --force-with-lease origin ${upstreamDefaultBranch} 2>&1`;
195
+
196
+ if (forcePushResult.code === 0) {
197
+ await log(`${formatAligned('✅', 'Fork synced:', 'Successfully force-pushed to align with upstream')}`);
198
+ await log('');
199
+ } else {
200
+ // Force push also failed - this is a more serious issue
201
+ await log('');
202
+ await log(`${formatAligned('❌', 'FATAL ERROR:', 'Failed to sync fork with upstream')}`, {
203
+ level: 'error',
204
+ });
205
+ await log('');
206
+ await log(' 🔍 What happened:');
207
+ await log(` Fork branch ${upstreamDefaultBranch} has diverged from upstream`);
208
+ await log(' Both normal push and force-with-lease push failed');
209
+ await log('');
210
+ await log(' 📦 Error details:');
211
+ const forceErrorMsg = forcePushResult.stderr ? forcePushResult.stderr.toString().trim() : '';
212
+ for (const line of forceErrorMsg.split('\n')) {
213
+ if (line.trim()) await log(` ${line}`);
214
+ }
215
+ await log('');
216
+ await log(' 💡 Possible causes:');
217
+ await log(' • Fork branch is protected (branch protection rules prevent force push)');
218
+ await log(' • Someone else pushed to fork after our fetch');
219
+ await log(' • Insufficient permissions to force push');
220
+ await log('');
221
+ await log(' 🔧 Manual resolution:');
222
+ await log(` 1. Visit your fork: https://github.com/${forkedRepo}`);
223
+ await log(' 2. Check branch protection settings');
224
+ await log(' 3. Manually sync fork with upstream:');
225
+ await log(' git fetch upstream');
226
+ await log(` git reset --hard upstream/${upstreamDefaultBranch}`);
227
+ await log(` git push --force origin ${upstreamDefaultBranch}`);
228
+ await log('');
229
+ await safeExit(1, 'Repository setup failed - fork sync failed');
230
+ }
231
+ } else {
232
+ // Flag is not enabled - provide guidance
233
+ await log(' 💡 Your options:');
234
+ await log('');
235
+ await log(' Option 1: Delete your fork and recreate it (SIMPLEST)');
236
+ await log(` gh repo delete ${forkedRepo}`);
237
+ await log(' Then run the solve command again - the fork will be recreated automatically');
238
+ await log(' ⚠️ Only use this if your fork has no unique commits you need to preserve');
239
+ await log('');
240
+ await log(' Option 2: Enable automatic force-push (DANGEROUS)');
241
+ await log(' Add --allow-fork-divergence-resolution-using-force-push-with-lease flag to your command');
242
+ await log(' This will automatically sync your fork with upstream using force-with-lease');
243
+ await log(' ⚠️ Overwrites fork history - any unique commits will be LOST');
244
+ await log('');
245
+ await log(' Option 3: Manually resolve the divergence');
246
+ await log(' 1. Decide if you need any commits unique to your fork');
247
+ await log(' 2. If yes, cherry-pick them after syncing');
248
+ await log(' 3. If no, manually force-push:');
249
+ await log(' git fetch upstream');
250
+ await log(` git reset --hard upstream/${upstreamDefaultBranch}`);
251
+ await log(` git push --force origin ${upstreamDefaultBranch}`);
252
+ await log('');
253
+ await log(' 🔧 To proceed with auto-resolution, restart with:');
254
+ await log(` solve ${argv.url || argv['issue-url'] || argv._[0] || '<issue-url>'} --allow-fork-divergence-resolution-using-force-push-with-lease`);
255
+ await log('');
256
+ await safeExit(1, 'Repository setup halted - fork divergence requires user decision');
257
+ }
258
+ } else {
259
+ // Some other push error (not divergence-related)
260
+ await log(`${formatAligned('❌', 'FATAL ERROR:', 'Failed to push updated default branch to fork')}`);
261
+ await log(`${formatAligned('', 'Push error:', errorMsg)}`);
262
+ await log(`${formatAligned('', 'Reason:', 'Fork must be updated or process must stop')}`);
263
+ await log(`${formatAligned('', 'Solution draft:', 'Fork sync is required for proper workflow')}`);
264
+ await log(`${formatAligned('', 'Next steps:', '1. Check GitHub permissions for the fork')}`);
265
+ await log(`${formatAligned('', '', '2. Ensure fork is not protected')}`);
266
+ await log(`${formatAligned('', '', '3. Try again after resolving fork issues')}`);
267
+ await safeExit(1, 'Repository setup failed');
268
+ }
269
+ }
270
+
271
+ // Step 4: Return to the original branch if it was different
272
+ if (currentBranch !== upstreamDefaultBranch) {
273
+ await log(`${formatAligned('🔄', 'Returning to:', `${currentBranch} branch`)}`);
274
+ const returnResult = await $({ cwd: tempDir })`git checkout ${currentBranch}`;
275
+ if (returnResult.code === 0) {
276
+ await log(`${formatAligned('✅', 'Branch restored:', `Back on ${currentBranch}`)}`);
277
+ } else {
278
+ await log(`${formatAligned('⚠️', 'Warning:', `Failed to return to ${currentBranch}`)}`);
279
+ // This is not fatal, continue with sync on default branch
280
+ }
281
+ }
282
+ } else {
283
+ await log(`${formatAligned('⚠️', 'Warning:', `Failed to sync ${upstreamDefaultBranch} with upstream`)}`);
284
+ if (syncResult.stderr) {
285
+ await log(`${formatAligned('', 'Sync error:', syncResult.stderr.toString().trim())}`);
286
+ }
287
+ }
288
+ }
289
+ } else {
290
+ await log(`${formatAligned('⚠️', 'Warning:', 'Failed to get default branch name')}`);
291
+ }
292
+ } else {
293
+ await log(`${formatAligned('⚠️', 'Warning:', 'Failed to get current branch')}`);
294
+ }
295
+ } else {
296
+ await log(`${formatAligned('⚠️', 'Warning:', 'Failed to fetch upstream')}`);
297
+ if (fetchResult.stderr) {
298
+ await log(`${formatAligned('', 'Fetch error:', fetchResult.stderr.toString().trim())}`);
299
+ }
300
+ }
301
+ }
302
+ };
@@ -1045,220 +1045,10 @@ export const cloneRepository = async (repoToClone, tempDir, argv, owner, repo) =
1045
1045
  await safeExit(1, 'Repository setup failed');
1046
1046
  };
1047
1047
 
1048
- // Set up upstream remote and sync fork
1049
- export const setupUpstreamAndSync = async (tempDir, forkedRepo, upstreamRemote, owner, repo, argv) => {
1050
- if (!forkedRepo || !upstreamRemote) return;
1051
-
1052
- await log(`${formatAligned('🔗', 'Setting upstream:', upstreamRemote)}`);
1053
-
1054
- // Check if upstream remote already exists
1055
- const checkUpstreamResult = await $({ cwd: tempDir })`git remote get-url upstream 2>/dev/null`;
1056
- let upstreamExists = checkUpstreamResult.code === 0;
1057
-
1058
- if (upstreamExists) {
1059
- await log(`${formatAligned('ℹ️', 'Upstream exists:', 'Using existing upstream remote')}`);
1060
- } else {
1061
- // Add upstream remote since it doesn't exist
1062
- const upstreamResult = await $({ cwd: tempDir })`git remote add upstream https://github.com/${upstreamRemote}.git`;
1063
-
1064
- if (upstreamResult.code === 0) {
1065
- await log(`${formatAligned('✅', 'Upstream set:', upstreamRemote)}`);
1066
- upstreamExists = true;
1067
- } else {
1068
- await log(`${formatAligned('⚠️', 'Warning:', 'Failed to add upstream remote')}`);
1069
- if (upstreamResult.stderr) {
1070
- await log(`${formatAligned('', 'Error details:', upstreamResult.stderr.toString().trim())}`);
1071
- }
1072
- }
1073
- }
1074
-
1075
- // Proceed with fork sync if upstream remote is available
1076
- if (upstreamExists) {
1077
- // Fetch upstream
1078
- await log(`${formatAligned('🔄', 'Fetching upstream...', '')}`);
1079
- const fetchResult = await $({ cwd: tempDir })`git fetch upstream`;
1080
- if (fetchResult.code === 0) {
1081
- await log(`${formatAligned('✅', 'Upstream fetched:', 'Successfully')}`);
1082
-
1083
- // Sync the default branch with upstream to avoid merge conflicts
1084
- await log(`${formatAligned('🔄', 'Syncing default branch...', '')}`);
1085
-
1086
- // Get current branch so we can return to it after sync
1087
- const currentBranchResult = await $({ cwd: tempDir })`git branch --show-current`;
1088
- if (currentBranchResult.code === 0) {
1089
- const currentBranch = currentBranchResult.stdout.toString().trim();
1090
-
1091
- // Get the default branch name from the original repository using GitHub API
1092
- const repoInfoResult = await $`gh api repos/${owner}/${repo} --jq .default_branch`;
1093
- if (repoInfoResult.code === 0) {
1094
- const upstreamDefaultBranch = repoInfoResult.stdout.toString().trim();
1095
- await log(`${formatAligned('ℹ️', 'Default branch:', upstreamDefaultBranch)}`);
1096
-
1097
- // Always sync the default branch, regardless of current branch
1098
- // This ensures fork is up-to-date even if we're working on a different branch
1099
-
1100
- // Step 1: Switch to default branch if not already on it
1101
- let syncSuccessful = true;
1102
- if (currentBranch !== upstreamDefaultBranch) {
1103
- await log(`${formatAligned('🔄', 'Switching to:', `${upstreamDefaultBranch} branch`)}`);
1104
- const checkoutResult = await $({ cwd: tempDir })`git checkout ${upstreamDefaultBranch}`;
1105
- if (checkoutResult.code !== 0) {
1106
- await log(`${formatAligned('⚠️', 'Warning:', `Failed to checkout ${upstreamDefaultBranch}`)}`);
1107
- syncSuccessful = false; // Cannot proceed with sync
1108
- }
1109
- }
1110
-
1111
- // Step 2: Sync default branch with upstream (only if checkout was successful)
1112
- if (syncSuccessful) {
1113
- const syncResult = await $({ cwd: tempDir })`git reset --hard upstream/${upstreamDefaultBranch}`;
1114
- if (syncResult.code === 0) {
1115
- await log(`${formatAligned('✅', 'Default branch synced:', `with upstream/${upstreamDefaultBranch}`)}`);
1116
-
1117
- // Step 3: Push the updated default branch to fork to keep it in sync
1118
- await log(`${formatAligned('🔄', 'Pushing to fork:', `${upstreamDefaultBranch} branch`)}`);
1119
- const pushResult = await $({ cwd: tempDir })`git push origin ${upstreamDefaultBranch} 2>&1`;
1120
- if (pushResult.code === 0) {
1121
- await log(`${formatAligned('✅', 'Fork updated:', 'Default branch pushed to fork')}`);
1122
- } else {
1123
- // Check if it's a non-fast-forward error (fork has diverged from upstream)
1124
- const errorMsg = (pushResult.stderr ? pushResult.stderr.toString().trim() : '') || (pushResult.stdout ? pushResult.stdout.toString().trim() : '');
1125
- const isNonFastForward = errorMsg.includes('non-fast-forward') || errorMsg.includes('rejected') || errorMsg.includes('tip of your current branch is behind');
1126
-
1127
- if (isNonFastForward) {
1128
- // Fork has diverged from upstream
1129
- await log('');
1130
- await log(`${formatAligned('⚠️', 'FORK DIVERGENCE DETECTED', '')}`, { level: 'warn' });
1131
- await log('');
1132
- await log(' 🔍 What happened:');
1133
- await log(` Your fork's ${upstreamDefaultBranch} branch has different commits than upstream`);
1134
- await log(' This typically occurs when upstream had a force push (e.g., git reset --hard)');
1135
- await log('');
1136
- await log(' 📦 Current state:');
1137
- await log(` • Fork: ${forkedRepo}`);
1138
- await log(` • Upstream: ${owner}/${repo}`);
1139
- await log(` • Branch: ${upstreamDefaultBranch}`);
1140
- await log('');
1141
-
1142
- // Check if user has enabled automatic force push
1143
- if (argv.allowForkDivergenceResolutionUsingForcePushWithLease) {
1144
- await log(' 🔄 Auto-resolution ENABLED (--allow-fork-divergence-resolution-using-force-push-with-lease):');
1145
- await log(' Attempting to force-push with --force-with-lease...');
1146
- await log('');
1147
-
1148
- // Use --force-with-lease for safer force push
1149
- // This will only force push if the remote hasn't changed since our last fetch
1150
- await log(`${formatAligned('🔄', 'Force pushing:', 'Syncing fork with upstream (--force-with-lease)')}`);
1151
- const forcePushResult = await $({
1152
- cwd: tempDir,
1153
- })`git push --force-with-lease origin ${upstreamDefaultBranch} 2>&1`;
1154
-
1155
- if (forcePushResult.code === 0) {
1156
- await log(`${formatAligned('✅', 'Fork synced:', 'Successfully force-pushed to align with upstream')}`);
1157
- await log('');
1158
- } else {
1159
- // Force push also failed - this is a more serious issue
1160
- await log('');
1161
- await log(`${formatAligned('❌', 'FATAL ERROR:', 'Failed to sync fork with upstream')}`, {
1162
- level: 'error',
1163
- });
1164
- await log('');
1165
- await log(' 🔍 What happened:');
1166
- await log(` Fork branch ${upstreamDefaultBranch} has diverged from upstream`);
1167
- await log(' Both normal push and force-with-lease push failed');
1168
- await log('');
1169
- await log(' 📦 Error details:');
1170
- const forceErrorMsg = forcePushResult.stderr ? forcePushResult.stderr.toString().trim() : '';
1171
- for (const line of forceErrorMsg.split('\n')) {
1172
- if (line.trim()) await log(` ${line}`);
1173
- }
1174
- await log('');
1175
- await log(' 💡 Possible causes:');
1176
- await log(' • Fork branch is protected (branch protection rules prevent force push)');
1177
- await log(' • Someone else pushed to fork after our fetch');
1178
- await log(' • Insufficient permissions to force push');
1179
- await log('');
1180
- await log(' 🔧 Manual resolution:');
1181
- await log(` 1. Visit your fork: https://github.com/${forkedRepo}`);
1182
- await log(' 2. Check branch protection settings');
1183
- await log(' 3. Manually sync fork with upstream:');
1184
- await log(' git fetch upstream');
1185
- await log(` git reset --hard upstream/${upstreamDefaultBranch}`);
1186
- await log(` git push --force origin ${upstreamDefaultBranch}`);
1187
- await log('');
1188
- await safeExit(1, 'Repository setup failed - fork sync failed');
1189
- }
1190
- } else {
1191
- // Flag is not enabled - provide guidance
1192
- await log(' 💡 Your options:');
1193
- await log('');
1194
- await log(' Option 1: Delete your fork and recreate it (SIMPLEST)');
1195
- await log(` gh repo delete ${forkedRepo}`);
1196
- await log(' Then run the solve command again - the fork will be recreated automatically');
1197
- await log(' ⚠️ Only use this if your fork has no unique commits you need to preserve');
1198
- await log('');
1199
- await log(' Option 2: Enable automatic force-push (DANGEROUS)');
1200
- await log(' Add --allow-fork-divergence-resolution-using-force-push-with-lease flag to your command');
1201
- await log(' This will automatically sync your fork with upstream using force-with-lease');
1202
- await log(' ⚠️ Overwrites fork history - any unique commits will be LOST');
1203
- await log('');
1204
- await log(' Option 3: Manually resolve the divergence');
1205
- await log(' 1. Decide if you need any commits unique to your fork');
1206
- await log(' 2. If yes, cherry-pick them after syncing');
1207
- await log(' 3. If no, manually force-push:');
1208
- await log(' git fetch upstream');
1209
- await log(` git reset --hard upstream/${upstreamDefaultBranch}`);
1210
- await log(` git push --force origin ${upstreamDefaultBranch}`);
1211
- await log('');
1212
- await log(' 🔧 To proceed with auto-resolution, restart with:');
1213
- await log(` solve ${argv.url || argv['issue-url'] || argv._[0] || '<issue-url>'} --allow-fork-divergence-resolution-using-force-push-with-lease`);
1214
- await log('');
1215
- await safeExit(1, 'Repository setup halted - fork divergence requires user decision');
1216
- }
1217
- } else {
1218
- // Some other push error (not divergence-related)
1219
- await log(`${formatAligned('❌', 'FATAL ERROR:', 'Failed to push updated default branch to fork')}`);
1220
- await log(`${formatAligned('', 'Push error:', errorMsg)}`);
1221
- await log(`${formatAligned('', 'Reason:', 'Fork must be updated or process must stop')}`);
1222
- await log(`${formatAligned('', 'Solution draft:', 'Fork sync is required for proper workflow')}`);
1223
- await log(`${formatAligned('', 'Next steps:', '1. Check GitHub permissions for the fork')}`);
1224
- await log(`${formatAligned('', '', '2. Ensure fork is not protected')}`);
1225
- await log(`${formatAligned('', '', '3. Try again after resolving fork issues')}`);
1226
- await safeExit(1, 'Repository setup failed');
1227
- }
1228
- }
1229
-
1230
- // Step 4: Return to the original branch if it was different
1231
- if (currentBranch !== upstreamDefaultBranch) {
1232
- await log(`${formatAligned('🔄', 'Returning to:', `${currentBranch} branch`)}`);
1233
- const returnResult = await $({ cwd: tempDir })`git checkout ${currentBranch}`;
1234
- if (returnResult.code === 0) {
1235
- await log(`${formatAligned('✅', 'Branch restored:', `Back on ${currentBranch}`)}`);
1236
- } else {
1237
- await log(`${formatAligned('⚠️', 'Warning:', `Failed to return to ${currentBranch}`)}`);
1238
- // This is not fatal, continue with sync on default branch
1239
- }
1240
- }
1241
- } else {
1242
- await log(`${formatAligned('⚠️', 'Warning:', `Failed to sync ${upstreamDefaultBranch} with upstream`)}`);
1243
- if (syncResult.stderr) {
1244
- await log(`${formatAligned('', 'Sync error:', syncResult.stderr.toString().trim())}`);
1245
- }
1246
- }
1247
- }
1248
- } else {
1249
- await log(`${formatAligned('⚠️', 'Warning:', 'Failed to get default branch name')}`);
1250
- }
1251
- } else {
1252
- await log(`${formatAligned('⚠️', 'Warning:', 'Failed to get current branch')}`);
1253
- }
1254
- } else {
1255
- await log(`${formatAligned('⚠️', 'Warning:', 'Failed to fetch upstream')}`);
1256
- if (fetchResult.stderr) {
1257
- await log(`${formatAligned('', 'Fetch error:', fetchResult.stderr.toString().trim())}`);
1258
- }
1259
- }
1260
- }
1261
- };
1048
+ // Set up upstream remote and sync fork.
1049
+ // Extracted into solve.fork-sync.lib.mjs (#1893) to keep this file under the
1050
+ // 1500-line limit; re-exported here so existing importers keep working.
1051
+ export { setupUpstreamAndSync } from './solve.fork-sync.lib.mjs';
1262
1052
 
1263
1053
  // Set up pr-fork remote for continuing someone else's fork PR with --fork flag
1264
1054
  export const setupPrForkRemote = async (tempDir, argv, prForkOwner, repo, isContinueMode, owner = null) => {