@link-assistant/hive-mind 2.11.2 → 2.11.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,6 +42,8 @@
42
42
  * future model. See docs/case-studies/issue-1886/ for the full analysis.
43
43
  */
44
44
 
45
+ import { isFormalAiModel } from './models/index.mjs'; // Issue #2119
46
+
45
47
  // Module-level singleton: the cumulative Anthropic cost for the active logical
46
48
  // session (including anything seeded by a true resume from a prior process).
47
49
  let cumulativeAnthropicCostUSD = 0;
@@ -120,6 +122,40 @@ export const getCumulativeAnthropicCost = () => cumulativeAnthropicCostUSD;
120
122
  */
121
123
  export const hasCumulativeAnthropicCost = () => cumulativeAnthropicCostUSD > 0;
122
124
 
125
+ /**
126
+ * Interpret a Claude `result` event's `total_cost_usd`.
127
+ *
128
+ * Issue #1886: a non-success terminal event (e.g. a usage-limit hit) still
129
+ * reports this process's cost, so it is kept as an accumulation fallback rather
130
+ * than as the authoritative total.
131
+ *
132
+ * Issue #2119: `--model formal-ai` is served by the local Link.Assistant model
133
+ * server, so the session never billed Anthropic. Claude Code nevertheless
134
+ * reports a `total_cost_usd` derived from Anthropic list prices for the model
135
+ * name it sees ($0.252315 in the issue) - a false positive that must not reach
136
+ * the accumulator or the published cost comment.
137
+ *
138
+ * @param {Object} params
139
+ * @param {Object} params.data the parsed `result` stream event
140
+ * @param {string|null} params.model the model requested on the command line
141
+ * @param {Function} params.log logger
142
+ * @returns {Promise<{total?: number, fallback?: number}|null>} the captured cost, or null when none applies
143
+ */
144
+ export const captureAnthropicResultCost = async ({ data, model, log }) => {
145
+ const cost = data?.total_cost_usd;
146
+ if (cost === undefined || cost === null) return null;
147
+ if (isFormalAiModel(model)) {
148
+ await log(`šŸ’° Ignoring Anthropic cost $${cost.toFixed(6)} reported for a Formal AI session (Link.Assistant, free)`, { verbose: true });
149
+ return null;
150
+ }
151
+ if (data.subtype === 'success') {
152
+ await log(`šŸ’° Anthropic official cost captured from success result: $${cost.toFixed(6)}`, { verbose: true });
153
+ return { total: cost };
154
+ }
155
+ await log(`šŸ’° Anthropic cost from ${data.subtype || 'unknown'} result kept as fallback for accumulation: $${cost.toFixed(6)}`, { verbose: true });
156
+ return { fallback: cost };
157
+ };
158
+
123
159
  /**
124
160
  * Reset the accumulator. Intended for tests; production code starts scopes via
125
161
  * `beginAnthropicCostScope`.
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Issue #2119: one auto-restart budget for the whole run.
5
+ *
6
+ * The problem
7
+ * -----------
8
+ * Hive Mind had two independent auto-restart subsystems, each with its own
9
+ * counter reading the same `--auto-restart-max-iterations` flag:
10
+ *
11
+ * - `solve.watch.lib.mjs` restarts on uncommitted changes / feedback and
12
+ * labels its sessions `šŸ”„ Auto-restart 1/5`;
13
+ * - `solve.auto-merge.lib.mjs` restarts until the PR is mergeable and labels
14
+ * its sessions `šŸ”„ Auto-restart triggered (iteration 1)`.
15
+ *
16
+ * `solve.mjs` runs them one after another, so a limit of 5 allowed up to 10 AI
17
+ * sessions, and the two label formats made the published comments look like two
18
+ * unrelated features. In issue #2119 a `--model formal-ai` run that produced
19
+ * only a `.formal-ai/` scratch directory kept restarting on those "uncommitted
20
+ * changes" without ever reaching a visible failure.
21
+ *
22
+ * The fix
23
+ * -------
24
+ * A single process-wide budget shared by both subsystems:
25
+ *
26
+ * - every AI session started by ANY auto-restart path consumes one iteration;
27
+ * - every label renders as `N/M` (or `N` when the limit is disabled with 0),
28
+ * so the limit is always visible;
29
+ * - once the budget is exhausted the run must actually fail, and the caller
30
+ * must run fail recovery (auto-commit of whatever is uncommitted) so the
31
+ * result stays visible instead of being silently discarded.
32
+ *
33
+ * The counter is a module-level singleton for the same reason
34
+ * `anthropic-cost-accumulator.lib.mjs` is: the two subsystems are separate
35
+ * modules invoked sequentially from `solve.mjs` and never see each other's
36
+ * state, and one `solve` process handles exactly one logical run.
37
+ */
38
+
39
+ import { DEFAULT_AUTO_ITERATION_LIMIT, formatAutoIterationLimit, hasReachedAutoIterationLimit, normalizeAutoIterationLimit } from './auto-iteration-limits.lib.mjs';
40
+
41
+ // Iterations consumed so far by every auto-restart subsystem in this run.
42
+ let iterationsUsed = 0;
43
+ // The active limit; 0 means "no limit" (`--auto-restart-max-iterations 0`).
44
+ let maxIterations = DEFAULT_AUTO_ITERATION_LIMIT;
45
+
46
+ /**
47
+ * Start the shared budget for one `solve` run.
48
+ *
49
+ * Safe to call from every entry point: it is idempotent for the same limit, so
50
+ * the watch loop and the auto-merge loop can both claim the budget without the
51
+ * second one resetting the first one's progress.
52
+ *
53
+ * @param {Object} [options]
54
+ * @param {number|string|null} [options.maxIterations] raw `--auto-restart-max-iterations` value
55
+ * @param {boolean} [options.reset=false] force the counter back to zero (new logical run / tests)
56
+ * @returns {number} the normalized limit in effect
57
+ */
58
+ export const beginAutoRestartBudget = ({ maxIterations: rawMax, reset = false } = {}) => {
59
+ const normalized = normalizeAutoIterationLimit(rawMax);
60
+ if (reset || normalized !== maxIterations) {
61
+ if (reset) iterationsUsed = 0;
62
+ maxIterations = normalized;
63
+ }
64
+ return maxIterations;
65
+ };
66
+
67
+ /** @returns {number} the normalized limit in effect (0 = unlimited) */
68
+ export const getAutoRestartLimit = () => maxIterations;
69
+
70
+ /** @returns {number} how many AI sessions auto-restart has already consumed */
71
+ export const getAutoRestartIterationsUsed = () => iterationsUsed;
72
+
73
+ /** @returns {number|null} iterations still available, or null when unlimited */
74
+ export const getRemainingAutoRestartIterations = () => (maxIterations === 0 ? null : Math.max(0, maxIterations - iterationsUsed));
75
+
76
+ /**
77
+ * @returns {boolean} true when no further auto-restart session may be started.
78
+ * Always false when the limit is disabled (0).
79
+ */
80
+ export const hasExhaustedAutoRestartBudget = () => hasReachedAutoIterationLimit(iterationsUsed, maxIterations);
81
+
82
+ /**
83
+ * Claim one iteration for an AI session that is about to start.
84
+ * Call this only when a tool execution really follows, so the published `N/M`
85
+ * label matches the number of sessions that actually ran.
86
+ * @returns {number} the 1-based iteration number just claimed
87
+ */
88
+ export const consumeAutoRestartIteration = () => {
89
+ iterationsUsed += 1;
90
+ return iterationsUsed;
91
+ };
92
+
93
+ /**
94
+ * Render the shared `N/M` progress label used by every auto-restart message.
95
+ * @param {number} [iteration] the iteration to render; defaults to the current count
96
+ * @returns {string} e.g. `3/5`, or `3` when the limit is disabled
97
+ */
98
+ export const formatAutoRestartLabel = (iteration = iterationsUsed) => (maxIterations === 0 ? `${iteration}` : `${iteration}/${maxIterations}`);
99
+
100
+ /** @returns {string} the configured limit for display (`5` or `unlimited`) */
101
+ export const formatAutoRestartLimit = () => formatAutoIterationLimit(maxIterations);
102
+
103
+ /** Reset the budget. Intended for tests; production code calls `beginAutoRestartBudget`. */
104
+ export const resetAutoRestartBudget = () => {
105
+ iterationsUsed = 0;
106
+ maxIterations = DEFAULT_AUTO_ITERATION_LIMIT;
107
+ };
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Issue #2119: what happens when the shared auto-restart budget runs out.
5
+ *
6
+ * The issue requires that after the configured number of iterations the run
7
+ * "must actually stop (fail + auto-commit on fail recovery). So the result will
8
+ * be actually visible."
9
+ *
10
+ * Before this, the two auto-restart subsystems ended differently and neither
11
+ * preserved the work:
12
+ *
13
+ * - `solve.watch.lib.mjs` logged "MAX ITERATIONS REACHED" and simply `break`ed
14
+ * out of the loop, leaving the uncommitted changes that caused every restart
15
+ * on the disposable temporary clone, where they were deleted with it;
16
+ * - `solve.auto-merge.lib.mjs` posted a comment and returned
17
+ * `auto_restart_limit_reached`, also without committing anything.
18
+ *
19
+ * This module is the one exhaustion path for both: log the failure, auto-commit
20
+ * (and push) whatever is uncommitted through the same critical-error recovery
21
+ * helper used elsewhere, and post a single comment that states the limit, the
22
+ * remaining blocker and what was preserved.
23
+ */
24
+
25
+ import { commitUncommittedChangesOnCriticalError } from './critical-error-commit.lib.mjs';
26
+ import { formatAutoRestartLabel, formatAutoRestartLimit, getAutoRestartIterationsUsed } from './auto-restart-budget.lib.mjs';
27
+ import { AUTO_RESTART_MARKER, postTrackedComment } from './tool-comments.lib.mjs';
28
+ import { reportError } from './sentry.lib.mjs';
29
+
30
+ /**
31
+ * The single reason string returned by every auto-restart subsystem when the
32
+ * shared budget is exhausted, so `solve` can treat both the same way.
33
+ */
34
+ export const AUTO_RESTART_LIMIT_REACHED_REASON = 'auto_restart_limit_reached';
35
+
36
+ // Module-level singleton, like the shared budget itself: `solve.mjs` runs both
37
+ // auto-restart loops sequentially and neither returns through a common result
38
+ // object, so this is what lets `finalizeSolveProcess` exit non-zero. Without it
39
+ // the run reported success even though the blocker was never resolved.
40
+ let limitFailure = null;
41
+
42
+ /** @returns {boolean} true once any auto-restart loop exhausted the shared budget */
43
+ export const hasAutoRestartLimitFailure = () => Boolean(limitFailure);
44
+
45
+ /** @returns {{reason: string, iterationsUsed: number, committed: boolean, pushed: boolean}|null} */
46
+ export const getAutoRestartLimitFailure = () => limitFailure;
47
+
48
+ /** Clear the recorded failure. Intended for tests. */
49
+ export const resetAutoRestartLimitFailure = () => {
50
+ limitFailure = null;
51
+ };
52
+
53
+ /**
54
+ * Fail the run because the shared auto-restart budget is exhausted, preserving
55
+ * any uncommitted work first.
56
+ *
57
+ * Never throws: a failure to commit or comment must not mask the limit itself.
58
+ *
59
+ * @param {Object} params
60
+ * @param {string} params.owner GitHub owner
61
+ * @param {string} params.repo GitHub repository
62
+ * @param {number|null} params.prNumber PR to comment on (comment skipped when absent)
63
+ * @param {string} params.tempDir working tree holding the uncommitted work
64
+ * @param {string|null} params.branchName branch to push the preserved work to
65
+ * @param {Function} params.$ command-stream tagged-template executor
66
+ * @param {Function} params.log async logger
67
+ * @param {Function} params.formatAligned aligned log formatter
68
+ * @param {string} params.blocker the remaining reason that kept triggering restarts
69
+ * @param {string} [params.subsystem] which loop hit the limit, for the log line
70
+ * @returns {Promise<{reason: string, iterationsUsed: number, committed: boolean, pushed: boolean}>}
71
+ */
72
+ export const failOnAutoRestartBudgetExhausted = async ({ owner, repo, prNumber, tempDir, branchName, $, log, formatAligned, blocker = 'uncommitted changes', subsystem = 'auto-restart' }) => {
73
+ const iterationsUsed = getAutoRestartIterationsUsed();
74
+ const label = formatAutoRestartLabel(iterationsUsed);
75
+
76
+ await log('');
77
+ await log(formatAligned('āŒ', 'AUTO-RESTART LIMIT REACHED', `Stopping ${subsystem} after ${label} iterations`), { level: 'error' });
78
+ await log(formatAligned('', 'Configured limit:', formatAutoRestartLimit(), 2), { level: 'error' });
79
+ await log(formatAligned('', 'Remaining blocker:', blocker, 2), { level: 'error' });
80
+ await log('');
81
+
82
+ // Fail recovery: the work that kept triggering restarts lives in a temporary
83
+ // clone that is about to be discarded. Commit and push it so the result is
84
+ // visible in the PR instead of vanishing with the clone.
85
+ const preserved = await commitUncommittedChangesOnCriticalError({
86
+ tempDir,
87
+ branchName,
88
+ $,
89
+ log,
90
+ reason: `auto-restart limit ${label} reached`,
91
+ push: true,
92
+ });
93
+
94
+ if (prNumber) {
95
+ const preservedText = preserved.committed ? `The uncommitted changes were auto-committed${preserved.pushed ? ' and pushed' : ' locally (push failed - see the log)'} so the partial result stays visible in this pull request.` : 'There were no uncommitted changes left to preserve.';
96
+ const body = `## āŒ ${AUTO_RESTART_MARKER} ${label} - limit reached
97
+
98
+ Hive Mind stopped after ${label} automatic restart iterations without resolving the blocker.
99
+
100
+ **Configured limit:** ${formatAutoRestartLimit()}
101
+ **Remaining blocker:** ${blocker}
102
+
103
+ ${preservedText}
104
+
105
+ No further AI sessions will be started automatically for this run. Review the remaining blocker manually, or rerun with a higher \`--auto-restart-max-iterations\` value.
106
+
107
+ ---
108
+ *This run is reported as failed because the auto-restart limit was reached.*`;
109
+ try {
110
+ await postTrackedComment({ $, owner, repo, targetNumber: prNumber, body });
111
+ await log(formatAligned('', 'šŸ’¬ Posted auto-restart limit notification to PR', '', 2));
112
+ } catch (commentError) {
113
+ reportError(commentError, { context: 'post_auto_restart_limit_comment', owner, repo, prNumber, operation: 'comment_on_pr' });
114
+ await log(formatAligned('', 'āš ļø Could not post auto-restart limit comment to PR', '', 2));
115
+ }
116
+ }
117
+
118
+ limitFailure = { reason: AUTO_RESTART_LIMIT_REACHED_REASON, iterationsUsed, committed: preserved.committed, pushed: preserved.pushed };
119
+ return limitFailure;
120
+ };
121
+
122
+ export default { AUTO_RESTART_LIMIT_REACHED_REASON, failOnAutoRestartBudgetExhausted, hasAutoRestartLimitFailure, getAutoRestartLimitFailure, resetAutoRestartLimitFailure };
@@ -17,11 +17,12 @@ 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 { beginAnthropicCostScope, seedCumulativeAnthropicCost, addAnthropicRunCost } from './anthropic-cost-accumulator.lib.mjs'; // Issues #1886, #2056
20
+ import { beginAnthropicCostScope, seedCumulativeAnthropicCost, addAnthropicRunCost, captureAnthropicResultCost } from './anthropic-cost-accumulator.lib.mjs'; // Issues #1886, #2056, #2119
21
21
  import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
22
22
  import { SESSION_FORCE_KILLED_MARKER, postTrackedComment } from './tool-comments.lib.mjs'; // Issue #1625
23
23
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
24
24
  import { CLAUDE_MODELS as availableModels, mapClaudeSubAgentModelToEnvValue } from './models/index.mjs'; // Issue #1221, #1978
25
+ import { applyFormalAiPricingOverride } from './formal-ai-pricing.lib.mjs'; // Issue #2119
25
26
  import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
26
27
  import { buildMcpConfigWithoutPlaywright, ensureClaudePlaywrightMcpServer } from './playwright-mcp.lib.mjs';
27
28
  import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
@@ -500,6 +501,7 @@ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsag
500
501
  };
501
502
  // Extracted to claude.stderr.lib.mjs (Issue #477, #1337)
502
503
  import { isStderrError } from './claude.stderr.lib.mjs';
504
+ import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
503
505
  export { isStderrError };
504
506
  export const executeClaudeCommand = async params => {
505
507
  const {
@@ -876,14 +878,9 @@ export const executeClaudeCommand = async params => {
876
878
  }
877
879
  }
878
880
  if (data.subtype === 'success') resultSuccessReceived = true;
879
- if (data.subtype === 'success' && data.total_cost_usd !== undefined && data.total_cost_usd !== null) {
880
- anthropicTotalCostUSD = data.total_cost_usd;
881
- await log(`šŸ’° Anthropic official cost captured from success result: $${anthropicTotalCostUSD.toFixed(6)}`, { verbose: true });
882
- } else if (data.total_cost_usd !== undefined && data.total_cost_usd !== null) {
883
- // Issue #1886: non-success terminal (e.g. usage-limit hit) still reports this process's cost — keep as accumulation fallback.
884
- anthropicCostFromAnyResult = data.total_cost_usd;
885
- await log(`šŸ’° Anthropic cost from ${data.subtype || 'unknown'} result kept as fallback for accumulation: $${data.total_cost_usd.toFixed(6)}`, { verbose: true });
886
- }
881
+ const capturedCost = await captureAnthropicResultCost({ data, model: argv.model, log });
882
+ if (capturedCost?.total !== undefined) anthropicTotalCostUSD = capturedCost.total;
883
+ if (capturedCost?.fallback !== undefined) anthropicCostFromAnyResult = capturedCost.fallback;
887
884
  // Issue #1263: Extract result summary (AI's summary of work done) for --attach-solution-summary
888
885
  if (data.subtype === 'success' && data.result && typeof data.result === 'string') {
889
886
  resultSummary = data.result;
@@ -1070,10 +1067,9 @@ export const executeClaudeCommand = async params => {
1070
1067
  if (data.result && typeof data.result === 'string') resultSummary = data.result;
1071
1068
  if (data.modelUsage) resultModelUsage = data.modelUsage;
1072
1069
  }
1073
- if (data.total_cost_usd != null) {
1074
- if (data.subtype === 'success') anthropicTotalCostUSD = data.total_cost_usd;
1075
- else anthropicCostFromAnyResult = data.total_cost_usd;
1076
- }
1070
+ const capturedCost = await captureAnthropicResultCost({ data, model: argv.model, log });
1071
+ if (capturedCost?.total !== undefined) anthropicTotalCostUSD = capturedCost.total;
1072
+ if (capturedCost?.fallback !== undefined) anthropicCostFromAnyResult = capturedCost.fallback;
1077
1073
  }
1078
1074
  // Issue #1472: Forward remaining buffer event to interactive handler (was previously missed)
1079
1075
  if (interactiveHandler) {
@@ -1422,14 +1418,22 @@ export const executeClaudeCommand = async params => {
1422
1418
  }
1423
1419
  }; // End of executeWithRetry function
1424
1420
  // Start the execution with retry logic
1425
- return await executeWithRetry();
1421
+ const claudeResult = (await executeWithRetry()) || {};
1422
+ // Issue #2119: `--model formal-ai` runs against the local Link.Assistant model
1423
+ // server. Claude reports no pricing record of its own, so without this the
1424
+ // session was published with Anthropic's cost and no provider at all.
1425
+ const formalAiPricing = applyFormalAiPricingOverride({ model: argv.model, pricingInfo: claudeResult.pricingInfo ?? null, publicPricingEstimate: claudeResult.publicPricingEstimate ?? null, anthropicTotalCostUSD: claudeResult.anthropicTotalCostUSD ?? null, tokenUsage: claudeResult.streamTokenUsage ?? null });
1426
+ return { ...claudeResult, ...formalAiPricing };
1426
1427
  };
1427
1428
  export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
1428
1429
  await log('\nšŸ” Checking for uncommitted changes...');
1430
+ // Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
1431
+ // the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
1432
+ await ensureAiToolScratchIgnored(tempDir, log);
1429
1433
  try {
1430
1434
  const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
1431
1435
  if (gitStatusResult.code === 0) {
1432
- const statusOutput = gitStatusResult.stdout.toString().trim();
1436
+ const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
1433
1437
  if (statusOutput) {
1434
1438
  await log('šŸ“ Found uncommitted changes');
1435
1439
  await log('Changes:');
@@ -53,7 +53,7 @@ export const handleClaudeRuntimeSwitch = async argv => {
53
53
  process.exit(1);
54
54
  }
55
55
  // Read current shebang
56
- const firstLine = await $`head -1 "${claudePath}"`;
56
+ const firstLine = await $`head -1 ${claudePath}`;
57
57
  const currentShebang = firstLine.stdout.toString().trim();
58
58
  await log(` Current shebang: ${currentShebang}`);
59
59
  if (currentShebang.includes('bun')) {
@@ -63,7 +63,7 @@ export const handleClaudeRuntimeSwitch = async argv => {
63
63
 
64
64
  // Create backup
65
65
  const backupPath = `${claudePath}.nodejs-backup`;
66
- await $`cp "${claudePath}" "${backupPath}"`;
66
+ await $`cp ${claudePath} ${backupPath}`;
67
67
  await log(` šŸ“¦ Backup created: ${backupPath}`);
68
68
 
69
69
  // Read file content and replace shebang
@@ -126,7 +126,7 @@ export const handleClaudeRuntimeSwitch = async argv => {
126
126
  process.exit(1);
127
127
  }
128
128
  // Read current shebang
129
- const firstLine = await $`head -1 "${claudePath}"`;
129
+ const firstLine = await $`head -1 ${claudePath}`;
130
130
  const currentShebang = firstLine.stdout.toString().trim();
131
131
  await log(` Current shebang: ${currentShebang}`);
132
132
  if (currentShebang.includes('node') && !currentShebang.includes('bun')) {
@@ -138,7 +138,7 @@ export const handleClaudeRuntimeSwitch = async argv => {
138
138
  try {
139
139
  await fs.access(backupPath);
140
140
  // Restore from backup
141
- await $`cp "${backupPath}" "${claudePath}"`;
141
+ await $`cp ${backupPath} ${claudePath}`;
142
142
  await log(` āœ… Restored Claude from backup: ${backupPath}`);
143
143
  } catch (backupError) {
144
144
  reportError(backupError, {
package/src/codex.lib.mjs CHANGED
@@ -25,13 +25,15 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
25
25
  import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
26
26
  const __codexBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'codex', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
27
27
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
28
+ import { createLineBuffer } from './json-stream.lib.mjs'; // Issue #2119
28
29
  import { mapModelToId, resolveCodexReasoningEffort } from './codex.options.lib.mjs';
29
30
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
30
31
  import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
31
32
  import { ensureCodexPlaywrightMcpServer, getCodexPlaywrightMcpDisableConfigArgs } from './playwright-mcp.lib.mjs';
32
33
  import { fetchModelInfo } from './model-info.lib.mjs';
33
- import { defaultModels } from './models/index.mjs';
34
+ import { defaultModels, isFormalAiModel } from './models/index.mjs';
34
35
  import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
36
+ import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
35
37
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
36
38
  import { parseSubSessionSize, buildCodexSubSessionSizeConfigArgs, buildCodexDisable1mContextConfigArgs } from './sub-session-size.lib.mjs'; // Issue #1706
37
39
  import { getCumulativeContextInputTokens } from './context-fill.lib.mjs';
@@ -39,6 +41,7 @@ import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
39
41
  import { applyCodexCapabilityEnv, runCodexCapabilityPreflight } from './codex-capability-preflight.lib.mjs'; // Issue #2074
40
42
  import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
41
43
  import Decimal from 'decimal.js-light';
44
+ import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
42
45
 
43
46
  const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'reasoning_output_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
44
47
  const CODEX_LONG_CONTEXT_PRICE_THRESHOLD = 272000;
@@ -581,6 +584,9 @@ export const calculateCodexPricingFromModelInfo = (modelId, tokenUsage, modelInf
581
584
 
582
585
  export const calculateCodexPricing = async (modelId, tokenUsage) => {
583
586
  if (!modelId) return null;
587
+ // Issue #2119: a Formal AI session is served by the local Link.Assistant
588
+ // model server, so OpenAI pricing must not be applied to it.
589
+ if (isFormalAiModel(modelId)) return buildFormalAiPricingInfo(modelId, tokenUsage);
584
590
  try {
585
591
  const modelInfo = await fetchModelInfo(modelId, { preferredProviderIds: ['openai'] });
586
592
  return calculateCodexPricingFromModelInfo(modelId, tokenUsage, modelInfo);
@@ -976,13 +982,21 @@ export const executeCodexCommand = async params => {
976
982
  observedModelDiagnosticPaths: [],
977
983
  };
978
984
 
985
+ // Issue #2119: a process chunk boundary can fall in the middle of an
986
+ // NDJSON record. Parsing each raw chunk dropped both halves of a split
987
+ // record (token usage, session id, auth errors). Buffer whole lines so
988
+ // the line-oriented Codex parser never sees a partial record.
989
+ const codexStdoutLines = createLineBuffer();
990
+ const codexStderrLines = createLineBuffer();
991
+
979
992
  for await (const chunk of execCommand.stream()) {
980
993
  if (chunk.type === 'stdout') {
981
- const output = chunk.data.toString();
994
+ const raw = chunk.data.toString();
982
995
  if (argv.verbose) {
983
- await log(output);
996
+ await log(raw);
984
997
  }
985
- lastMessage = output;
998
+ lastMessage = raw;
999
+ const output = codexStdoutLines.write(raw);
986
1000
 
987
1001
  codexJsonState = parseCodexExecJsonOutput(output, codexJsonState, mappedModel);
988
1002
  await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
@@ -1022,10 +1036,11 @@ export const executeCodexCommand = async params => {
1022
1036
  }
1023
1037
 
1024
1038
  if (chunk.type === 'stderr') {
1025
- const errorOutput = chunk.data.toString();
1026
- if (errorOutput && argv.verbose) {
1027
- await log(errorOutput, { stream: 'stderr' });
1039
+ const rawError = chunk.data.toString();
1040
+ if (rawError && argv.verbose) {
1041
+ await log(rawError, { stream: 'stderr' });
1028
1042
  }
1043
+ const errorOutput = codexStderrLines.write(rawError);
1029
1044
  codexJsonState = parseCodexExecJsonOutput(errorOutput, codexJsonState, mappedModel);
1030
1045
  await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
1031
1046
  } else if (chunk.type === 'exit') {
@@ -1033,6 +1048,27 @@ export const executeCodexCommand = async params => {
1033
1048
  }
1034
1049
  }
1035
1050
 
1051
+ // Release any line that was still being assembled when the stream ended.
1052
+ for (const remaining of [codexStdoutLines.flush(), codexStderrLines.flush()]) {
1053
+ if (!remaining.trim()) continue;
1054
+ codexJsonState = parseCodexExecJsonOutput(remaining, codexJsonState, mappedModel);
1055
+ await baseBranchCommandIntervention.handleCommandExecutions(codexJsonState.commandExecutions);
1056
+ }
1057
+
1058
+ if (codexJsonState.sessionId && codexJsonState.sessionId !== sessionId) {
1059
+ sessionId = codexJsonState.sessionId;
1060
+ await log(`šŸ“Œ Session ID: ${sessionId}`);
1061
+ }
1062
+ if (codexJsonState.resultSummary) {
1063
+ lastTextContent = codexJsonState.resultSummary;
1064
+ }
1065
+ if (codexJsonState.authError && !authError) {
1066
+ authError = true;
1067
+ await log('\nāŒ Authentication error detected in Codex JSON stream', { level: 'error' });
1068
+ await log(' This error cannot be resolved by retrying.', { level: 'error' });
1069
+ await log(' šŸ’” Please run: codex login', { level: 'error' });
1070
+ }
1071
+
1036
1072
  if (interactiveHandler) {
1037
1073
  await interactiveHandler.flush();
1038
1074
  }
@@ -1369,11 +1405,13 @@ export const executeCodexCommand = async params => {
1369
1405
  export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
1370
1406
  // Similar to Claude and OpenCode version, check for uncommitted changes
1371
1407
  await log('\nšŸ” Checking for uncommitted changes...');
1408
+ // Issue #2119: keep AI tool scratch state out of this check and of `git add -A`.
1409
+ await ensureAiToolScratchIgnored(tempDir, log);
1372
1410
  try {
1373
1411
  const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
1374
1412
 
1375
1413
  if (gitStatusResult.code === 0) {
1376
- const statusOutput = gitStatusResult.stdout.toString().trim();
1414
+ const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
1377
1415
 
1378
1416
  if (statusOutput) {
1379
1417
  await log('šŸ“ Found uncommitted changes');
@@ -71,9 +71,33 @@ const VENDOR_PATTERNS = Object.freeze([
71
71
  ]);
72
72
 
73
73
  const SENSITIVE_KEY = String.raw`(?:[A-Za-z0-9_.-]*(?:api[-_]?key|account[-_]?key|client[-_]?secret|consumer[-_]?secret|webhook[-_]?secret|access[-_]?token|refresh[-_]?token|auth[-_]?token|password|passwd|pwd|private[-_]?key|secret|token|session[-_]?key|session[-_]?token|cookie|docker[-_]?auth|registry[-_]?auth|shared[-_]?access[-_]?signature|sas[-_]?token)[A-Za-z0-9_.-]*|auth|authorization)`;
74
+
75
+ // Issue #2119: token *accounting* is not a credential. Every AI provider SDK
76
+ // spells usage telemetry with the plural "tokens" (`tokens`, `inputTokens`,
77
+ // `prompt_tokens`, `total_tokens`) or with an explicit quantity suffix
78
+ // (`token_count`, `tokenLimit`). Masking those numbers corrupted the NDJSON
79
+ // telemetry in published logs and destroyed token/cost accounting, while
80
+ // protecting nothing: a credential is never a bare number under a plural name.
81
+ // The exemption stays deliberately narrow - it requires both a counter-shaped
82
+ // key and a purely numeric value, so `access_token=123456` is still masked.
83
+ const TOKEN_COUNTER_KEY = /(?:tokens|token(?:count|limit|usage|budget|used|size|s?remaining)|(?:count|limit|usage|budget|used|size)tokens?)$/;
84
+ const NUMERIC_VALUE = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:e[+-]?\d+)?$/i;
85
+
86
+ const normalizeAssignmentKey = prefix =>
87
+ String(prefix ?? '')
88
+ .replace(/\s*(?:=>|[:=])\s*$/, '')
89
+ .replace(/[^A-Za-z0-9]/g, '')
90
+ .toLowerCase();
91
+
92
+ const isTokenCounterAssignment = (prefix, value) => NUMERIC_VALUE.test(String(value ?? '').trim()) && TOKEN_COUNTER_KEY.test(normalizeAssignmentKey(prefix));
74
93
  const SENSITIVE_ENV_NAME = /(?:API_?KEY|ACCOUNT_?KEY|CLIENT_?SECRET|CONSUMER_?SECRET|WEBHOOK_?SECRET|ACCESS_?TOKEN|REFRESH_?TOKEN|AUTH_?TOKEN|PASSWORD|PASSWD|PRIVATE_?KEY|SECRET|TOKEN|COOKIE|AUTH)$/i;
75
94
  const QUOTED_ASSIGNMENT = new RegExp(`((?:["']?${SENSITIVE_KEY}["']?)\\s*(?:=>|[:=])\\s*)(["'])([^"'\\r\\n]*)(\\2)`, 'gi');
76
- const UNQUOTED_ASSIGNMENT = new RegExp(`((?:["']?${SENSITIVE_KEY}["']?)\\s*(?:=>|[:=])\\s*)(?!["']|(?:Bearer|Basic|SharedAccessSignature)\\s)([^\\s,;}&'"\\r\\n]+)`, 'gi');
95
+ // Issue #2119: a value that *opens* a JSON/JS structure is punctuation, not a
96
+ // secret. Without this guard `"tokens": {` was rewritten to `"tokens": [REDACTED]`,
97
+ // which silently truncated the object and made the whole record unparseable.
98
+ // The guard only rejects a structural character in first position, so a
99
+ // credential that merely contains a brace (`password=ab{cd`) is still masked whole.
100
+ const UNQUOTED_ASSIGNMENT = new RegExp(`((?:["']?${SENSITIVE_KEY}["']?)\\s*(?:=>|[:=])\\s*)(?!["']|[{[]|(?:Bearer|Basic|SharedAccessSignature)\\s)([^\\s,;}&'"\\r\\n]+)`, 'gi');
77
101
  const XML_CREDENTIAL = new RegExp(`(<(${SENSITIVE_KEY})\\b[^>]*>)([\\s\\S]*?)(<\\/\\2\\s*>)`, 'gi');
78
102
  const CLI_CREDENTIAL_QUOTED = new RegExp(`(--${SENSITIVE_KEY}(?:\\s+|=))(["'])([^"'\\r\\n]*)(\\2)`, 'gi');
79
103
  const CLI_CREDENTIAL = new RegExp(`(--${SENSITIVE_KEY}(?:\\s+|=))(?!["'])([^\\s"'\\r\\n]+)`, 'gi');
@@ -136,8 +160,8 @@ export const sanitizeCredentialText = (input, options = {}) => {
136
160
 
137
161
  // XML and JSON/YAML/TOML/INI/shell-style assignments.
138
162
  output = output.replace(XML_CREDENTIAL, (_match, start, _key, value, end) => `${start}${maskValue(value.trim())}${end}`);
139
- output = output.replace(QUOTED_ASSIGNMENT, (_match, prefix, quote, value) => `${prefix}${quote}${maskValue(value)}${quote}`);
140
- output = output.replace(UNQUOTED_ASSIGNMENT, (_match, prefix, value) => `${prefix}${maskValue(value)}`);
163
+ output = output.replace(QUOTED_ASSIGNMENT, (match, prefix, quote, value) => (isTokenCounterAssignment(prefix, value) ? match : `${prefix}${quote}${maskValue(value)}${quote}`));
164
+ output = output.replace(UNQUOTED_ASSIGNMENT, (match, prefix, value) => (isTokenCounterAssignment(prefix, value) ? match : `${prefix}${maskValue(value)}`));
141
165
 
142
166
  // CLI arguments and sensitive query parameters.
143
167
  output = output.replace(CLI_CREDENTIAL_QUOTED, (_match, prefix, quote, value) => `${prefix}${quote}${maskValue(value)}${quote}`);