@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.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/agent-commander.lib.mjs +33 -12
- package/src/agent-token-usage.lib.mjs +5 -9
- package/src/agent.lib.mjs +110 -152
- package/src/ai-tool-scratch.lib.mjs +143 -0
- package/src/anthropic-cost-accumulator.lib.mjs +36 -0
- package/src/auto-restart-budget.lib.mjs +107 -0
- package/src/auto-restart-exhaustion.lib.mjs +122 -0
- package/src/claude.lib.mjs +19 -15
- package/src/claude.runtime-switch.lib.mjs +4 -4
- package/src/codex.lib.mjs +46 -8
- package/src/credential-sanitization-core.lib.mjs +27 -3
- package/src/formal-ai-pricing.lib.mjs +110 -0
- package/src/gemini.lib.mjs +34 -43
- package/src/github-cost-info.lib.mjs +5 -0
- package/src/json-stream.lib.mjs +219 -0
- package/src/opencode.lib.mjs +56 -74
- package/src/pull-request-changes.lib.mjs +166 -0
- package/src/qwen.lib.mjs +33 -32
- package/src/reviewers-hive.mjs +2 -2
- package/src/solve.auto-merge.lib.mjs +68 -44
- package/src/solve.auto-pr.lib.mjs +11 -3
- package/src/solve.finalize.lib.mjs +15 -0
- package/src/solve.repository.lib.mjs +21 -4
- package/src/solve.restart-shared.lib.mjs +6 -2
- package/src/solve.results.lib.mjs +29 -16
- package/src/solve.watch.lib.mjs +47 -18
- package/src/use-m-bootstrap.lib.mjs +23 -4
- package/src/use-m-single-flight.lib.mjs +350 -0
- package/src/use-with-retry.lib.mjs +8 -2
- package/src/working-session-summary.lib.mjs +65 -0
- package/src/youtrack/youtrack-sync.mjs +2 -2
|
@@ -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 };
|
package/src/claude.lib.mjs
CHANGED
|
@@ -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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
994
|
+
const raw = chunk.data.toString();
|
|
982
995
|
if (argv.verbose) {
|
|
983
|
-
await log(
|
|
996
|
+
await log(raw);
|
|
984
997
|
}
|
|
985
|
-
lastMessage =
|
|
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
|
|
1026
|
-
if (
|
|
1027
|
-
await log(
|
|
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
|
-
|
|
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, (
|
|
140
|
-
output = output.replace(UNQUOTED_ASSIGNMENT, (
|
|
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}`);
|