@link-assistant/hive-mind 1.57.3 → 1.59.0
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 +186 -0
- package/package.json +1 -1
- package/src/anthropic-server-tool-pricing.lib.mjs +34 -0
- package/src/bidirectional-interactive.lib.mjs +392 -21
- package/src/claude.budget-stats.lib.mjs +154 -27
- package/src/claude.cost.lib.mjs +88 -0
- package/src/claude.lib.mjs +54 -58
- package/src/codex.lib.mjs +31 -0
- package/src/config.lib.mjs +39 -2
- package/src/github-cost-info.lib.mjs +4 -1
- package/src/lino.lib.mjs +3 -1
- package/src/solve.auto-merge.lib.mjs +5 -0
- package/src/solve.config.lib.mjs +39 -0
- package/src/sub-session-size.lib.mjs +239 -0
- package/src/use-with-retry.lib.mjs +91 -0
package/src/config.lib.mjs
CHANGED
|
@@ -18,7 +18,11 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Issue #1710: use-m occasionally hands back a truncated/corrupt global package
|
|
22
|
+
// (npm install -g flake on hosted CI). useWithRetry deletes the broken install
|
|
23
|
+
// dir and re-fetches when the failure is a SyntaxError mid-import.
|
|
24
|
+
const { useWithRetry } = await import('./use-with-retry.lib.mjs');
|
|
25
|
+
const getenvModule = await useWithRetry(globalThis.use, 'getenv');
|
|
22
26
|
// Node 24 CJS/ESM interop may return the whole module object instead of the function directly
|
|
23
27
|
const getenv = typeof getenvModule === 'function' ? getenvModule : getenvModule.default || getenvModule;
|
|
24
28
|
|
|
@@ -413,9 +417,12 @@ export const supportsThinkingBudget = (version, minVersion = '2.1.12') => {
|
|
|
413
417
|
// Supports model-specific max output tokens for Opus 4.6 (Issue #1221)
|
|
414
418
|
// Sets CLAUDE_CODE_EFFORT_LEVEL for Opus 4.6 models (Issue #1238)
|
|
415
419
|
// Supports planModel/executionModel for opusplan mode (Issue #1223)
|
|
416
|
-
//
|
|
420
|
+
// Issue #1706: supports subSessionSize (parsed) + disable1mContext to cap
|
|
421
|
+
// auto-compaction sub-session size and opt out of the 1M extended context.
|
|
422
|
+
// See: https://code.claude.com/docs/en/env-vars and https://code.claude.com/docs/en/model-config
|
|
417
423
|
// ANTHROPIC_DEFAULT_OPUS_MODEL → model used in plan mode (and for 'opus' alias)
|
|
418
424
|
// ANTHROPIC_DEFAULT_SONNET_MODEL → model used in execution mode (and for 'sonnet' alias)
|
|
425
|
+
// CLAUDE_CODE_DISABLE_1M_CONTEXT, CLAUDE_CODE_AUTO_COMPACT_WINDOW, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
|
|
419
426
|
export const getClaudeEnv = (options = {}) => {
|
|
420
427
|
// Get max output tokens based on model (Issue #1221)
|
|
421
428
|
const maxOutputTokens = options.model ? getMaxOutputTokensForModel(options.model) : claudeCode.maxOutputTokens;
|
|
@@ -483,6 +490,36 @@ export const getClaudeEnv = (options = {}) => {
|
|
|
483
490
|
env.ANTHROPIC_DEFAULT_SONNET_MODEL = String(options.executionModel);
|
|
484
491
|
}
|
|
485
492
|
|
|
493
|
+
// Issue #1706: --disable-1m-context. Sets CLAUDE_CODE_DISABLE_1M_CONTEXT=1.
|
|
494
|
+
if (options.disable1mContext) {
|
|
495
|
+
env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Issue #1706: --sub-session-size. Caller passes a pre-parsed descriptor and the
|
|
499
|
+
// model context window so we can convert percentages to absolute tokens.
|
|
500
|
+
if (options.subSessionSize && options.subSessionSize.kind && options.subSessionSize.kind !== 'default') {
|
|
501
|
+
const window = Number.isFinite(options.contextWindowTokens) && options.contextWindowTokens > 0 ? options.contextWindowTokens : null;
|
|
502
|
+
if (options.subSessionSize.kind === 'tokens') {
|
|
503
|
+
const tokens = options.subSessionSize.tokens;
|
|
504
|
+
if (Number.isFinite(tokens) && tokens > 0) {
|
|
505
|
+
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(tokens);
|
|
506
|
+
// Compute percentage relative to the context window so the override stays
|
|
507
|
+
// within Claude Code's "lower-only" semantics. Default to 95 when unknown.
|
|
508
|
+
let pct = 95;
|
|
509
|
+
if (window) {
|
|
510
|
+
pct = Math.max(1, Math.min(95, Math.round((tokens / window) * 100)));
|
|
511
|
+
}
|
|
512
|
+
env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(pct);
|
|
513
|
+
}
|
|
514
|
+
} else if (options.subSessionSize.kind === 'percent') {
|
|
515
|
+
const pct = Math.max(1, Math.min(95, Math.round(options.subSessionSize.percent)));
|
|
516
|
+
env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(pct);
|
|
517
|
+
if (window) {
|
|
518
|
+
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(window);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
486
523
|
return env;
|
|
487
524
|
};
|
|
488
525
|
|
|
@@ -30,7 +30,10 @@ export const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricing
|
|
|
30
30
|
if (!hasPublic && !hasAnthropic && !hasPricing && !hasOpencodeCost) return '';
|
|
31
31
|
const publicDec = hasPublic ? new Decimal(totalCostUSD) : null;
|
|
32
32
|
const anthropicDec = hasAnthropic ? new Decimal(anthropicTotalCostUSD) : null;
|
|
33
|
-
|
|
33
|
+
// Issue #1703: collapse to short form when the rounded difference is below 6-decimal display precision.
|
|
34
|
+
// Without this, near-matching values like $11.219694 vs $11.219693 still printed the full breakdown
|
|
35
|
+
// even though "Difference: $-0.000000 (-0.00%)" carries no meaningful information.
|
|
36
|
+
if (publicDec && anthropicDec && anthropicDec.minus(publicDec).abs().toFixed(6) === '0.000000') return `\n\n### 💰 Cost: **$${anthropicDec.toFixed(6)}**`;
|
|
34
37
|
let costInfo = '\n\n### 💰 **Cost estimation:**';
|
|
35
38
|
if (pricingInfo?.modelName) {
|
|
36
39
|
costInfo += `\n- Model: ${pricingInfo.modelName}`;
|
package/src/lino.lib.mjs
CHANGED
|
@@ -2,7 +2,9 @@ if (typeof use === 'undefined') {
|
|
|
2
2
|
globalThis.use = (await eval(await (await fetch('https://unpkg.com/use-m/use.js')).text())).use;
|
|
3
3
|
}
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Issue #1710: hosted CI npm-install flake — retry once on a corrupt install.
|
|
6
|
+
const { useWithRetry } = await import('./use-with-retry.lib.mjs');
|
|
7
|
+
const linoModule = await useWithRetry(globalThis.use, 'links-notation');
|
|
6
8
|
const LinoParser = linoModule.Parser || linoModule.default?.Parser;
|
|
7
9
|
|
|
8
10
|
const fs = await import('fs');
|
|
@@ -111,6 +111,11 @@ export const watchUntilMergeable = async params => {
|
|
|
111
111
|
await log(formatAligned('', 'Wait for all repo actions:', waitForAllRepoActionsFlag ? 'Yes (strict repo-wide safety)' : 'No (PR-scoped CI only)', 2));
|
|
112
112
|
await log(formatAligned('', 'Stop conditions:', 'PR merged, PR closed, or becomes mergeable', 2));
|
|
113
113
|
await log(formatAligned('', 'Restart triggers:', 'New non-bot comments, CI failures, merge conflicts', 2));
|
|
114
|
+
// Issue #1708: Surface that --auto-input-until-mergeable streamed feedback
|
|
115
|
+
// into the prior session, so any restart triggered here is a fallback.
|
|
116
|
+
if (argv.autoInputUntilMergeable) {
|
|
117
|
+
await log(formatAligned('', 'Streaming-first:', '--auto-input-until-mergeable was active; this loop is the fallback', 2));
|
|
118
|
+
}
|
|
114
119
|
await log('');
|
|
115
120
|
await log('Press Ctrl+C to stop watching manually');
|
|
116
121
|
await log('');
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -198,6 +198,15 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
198
198
|
description: 'Auto-restart until PR becomes mergeable (no iteration limit). Restarts on new comments from non-bot users, CI failures, merge conflicts, or other issues. Does NOT auto-merge.',
|
|
199
199
|
default: true,
|
|
200
200
|
},
|
|
201
|
+
// Issue #1708: Stage 1 introduces this flag inert — it parses, appears in
|
|
202
|
+
// --help, and is read by validateAutoInputUntilMergeable below, but does not
|
|
203
|
+
// change the runtime loop yet. Stages 2-6 will wire it into watchUntilMergeable
|
|
204
|
+
// and the bidirectional NDJSON pipe (see docs/case-studies/issue-1708/).
|
|
205
|
+
'auto-input-until-mergeable': {
|
|
206
|
+
type: 'boolean',
|
|
207
|
+
description: '[EXPERIMENTAL] Extend a single AI tool session as long as possible by streaming new input (uncommitted changes, CI/CD failures, PR/issue comments, issue title/body updates) directly into the running session, instead of restarting it. Implies --accept-incomming-comments-as-input and --queue-comments-to-input by default (comments are deferred until the AI finishes the current step and is waiting for input). Existing auto-restart/auto-resume loops remain enabled as a fallback, but the goal is to keep them dormant. The full streaming-aware watchUntilMergeable replacement and per-tool wiring is staged in subsequent PRs (see docs/case-studies/issue-1708/). Falls back gracefully on non-Claude tools and on streaming errors. Disabled by default.',
|
|
208
|
+
default: false,
|
|
209
|
+
},
|
|
201
210
|
'wait-for-all-actions-in-repository-before-mergeable': {
|
|
202
211
|
type: 'boolean',
|
|
203
212
|
description: 'Wait for ALL active GitHub Actions workflow runs in the entire repository to complete before declaring PR mergeable. When enabled, blocks merge if ANY CI/CD run in the repository is active, regardless of branch — this is a strict safety mode for repositories with cross-branch CI/CD coupling. Disabled by default.',
|
|
@@ -260,6 +269,16 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
260
269
|
description: 'Maximum thinking budget for calculating --think level mappings (default: 31999 for Claude Code). Values: off=0, low=max/4, medium=max/2, high=max*3/4, max=max.',
|
|
261
270
|
default: 31999,
|
|
262
271
|
},
|
|
272
|
+
'sub-session-size': {
|
|
273
|
+
type: 'string',
|
|
274
|
+
description: 'Cap on sub-session size between auto-compaction events. Accepts a token count (e.g. 150k, 1m, 200000), a percentage of the model context window (e.g. 50%), or "default" to keep the tool\'s built-in threshold. Default: 150k. For Claude this maps to CLAUDE_CODE_AUTO_COMPACT_WINDOW + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE env vars. For Codex this maps to -c model_auto_compact_token_limit. (Issue #1706)',
|
|
275
|
+
default: '150k',
|
|
276
|
+
},
|
|
277
|
+
'disable-1m-context': {
|
|
278
|
+
type: 'boolean',
|
|
279
|
+
description: 'Disable 1M extended context window so the model uses its standard 200K-400K window. Helps preserve reasoning quality and reduces cost. Default: true. For Claude this sets CLAUDE_CODE_DISABLE_1M_CONTEXT=1 (also forbids the [1m] model-name suffix). For Codex this sets -c model_context_window=200000. Use --no-disable-1m-context to allow the 1M window. (Issue #1706)',
|
|
280
|
+
default: true,
|
|
281
|
+
},
|
|
263
282
|
'fallback-model': {
|
|
264
283
|
type: 'string',
|
|
265
284
|
description: 'Fallback model to switch to on model capacity/overload errors. When supported, retries resume the same session with this model. Defaults: claude opus/opus-4-7 -> opus-4-6; codex gpt-5.5 -> gpt-5.4; all others unset.',
|
|
@@ -367,6 +386,26 @@ export const SOLVE_OPTION_DEFINITIONS = {
|
|
|
367
386
|
description: '[EXPERIMENTAL] Convenience flag that enables --interactive-mode, --accept-incomming-comments-as-input and --exclude-all-own-incomming-comments-from-input together. Only supported for --tool claude.',
|
|
368
387
|
default: false,
|
|
369
388
|
},
|
|
389
|
+
// Issue #1708: Comment delivery mode for --accept-incomming-comments-as-input.
|
|
390
|
+
// --stream-comments-to-input: forward comments immediately as they arrive
|
|
391
|
+
// (the default for --accept-incomming-comments-as-input on its own; matches
|
|
392
|
+
// the existing #817 behavior of pushing comments to Claude as soon as
|
|
393
|
+
// pollIncomingComments sees them).
|
|
394
|
+
// --queue-comments-to-input: hold comments until the AI signals it is idle
|
|
395
|
+
// (waiting for input), then flush the queue. Used by
|
|
396
|
+
// --auto-input-until-mergeable so the model finishes the current step
|
|
397
|
+
// before getting interrupted with new instructions.
|
|
398
|
+
// The two flags are mutually exclusive; if both are set, queue mode wins.
|
|
399
|
+
'stream-comments-to-input': {
|
|
400
|
+
type: 'boolean',
|
|
401
|
+
description: '[EXPERIMENTAL] When --accept-incomming-comments-as-input is enabled, forward each new PR/issue comment to the AI immediately as it arrives (real-time streaming). This is the default behavior for --accept-incomming-comments-as-input on its own. Mutually exclusive with --queue-comments-to-input; queue mode wins if both are set. Only supported for --tool claude.',
|
|
402
|
+
default: false,
|
|
403
|
+
},
|
|
404
|
+
'queue-comments-to-input': {
|
|
405
|
+
type: 'boolean',
|
|
406
|
+
description: '[EXPERIMENTAL] When --accept-incomming-comments-as-input is enabled, queue new PR/issue comments and only flush them once the AI signals it is idle (waiting for input). This is the default mode implied by --auto-input-until-mergeable so the AI completes the current step before being interrupted with new instructions. Mutually exclusive with --stream-comments-to-input; queue mode wins if both are set. Only supported for --tool claude.',
|
|
407
|
+
default: false,
|
|
408
|
+
},
|
|
370
409
|
'prompt-explore-sub-agent': {
|
|
371
410
|
type: 'boolean',
|
|
372
411
|
description: 'Encourage AI to use Explore-style sub-agent workflow for codebase exploration. Supported for --tool claude and --tool codex.',
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sub-session size and 1M-context controls.
|
|
5
|
+
*
|
|
6
|
+
* Implements --sub-session-size and --disable-1m-context (issue #1706).
|
|
7
|
+
*
|
|
8
|
+
* --sub-session-size accepts:
|
|
9
|
+
* - "default" / "auto" → keep tool's built-in compaction threshold (no override)
|
|
10
|
+
* - "off" / "0" → keep tool's default (alias for "default")
|
|
11
|
+
* - A token count → "150k", "150K", "150000", "1.5m", "1M"
|
|
12
|
+
* - A percentage → "50%", "75%" (relative to model context window)
|
|
13
|
+
*
|
|
14
|
+
* --disable-1m-context (boolean, default true) opts out of the 1M extended
|
|
15
|
+
* context window so models fall back to their standard 200K-400K window.
|
|
16
|
+
*
|
|
17
|
+
* Claude Code controls (env vars only — no CLI flags exist):
|
|
18
|
+
* - CLAUDE_CODE_DISABLE_1M_CONTEXT=1
|
|
19
|
+
* - CLAUDE_CODE_AUTO_COMPACT_WINDOW=<tokens> (basis for compaction math)
|
|
20
|
+
* - CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=<1..100> (only lowers; clamped to <= 95)
|
|
21
|
+
*
|
|
22
|
+
* Codex controls (via -c key=value, same mechanism as model_reasoning_effort):
|
|
23
|
+
* - -c model_context_window=<tokens> (forces 200K window)
|
|
24
|
+
* - -c model_auto_compact_token_limit=<tokens> (compaction threshold)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const PARSE_ERROR_PREFIX = '--sub-session-size';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_TOKENS_VALUES = new Set(['default', 'auto', 'off', '0', 'none']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Parse a token count expression: "150k", "150K", "150000", "1.5m", "1M".
|
|
33
|
+
* Returns null if the input doesn't match the token-count format.
|
|
34
|
+
*/
|
|
35
|
+
const parseTokenCount = value => {
|
|
36
|
+
const match = String(value)
|
|
37
|
+
.trim()
|
|
38
|
+
.match(/^(\d+(?:\.\d+)?)\s*([kmKM]?)$/);
|
|
39
|
+
if (!match) return null;
|
|
40
|
+
const number = parseFloat(match[1]);
|
|
41
|
+
if (!Number.isFinite(number) || number < 0) return null;
|
|
42
|
+
const suffix = match[2].toLowerCase();
|
|
43
|
+
const multiplier = suffix === 'k' ? 1_000 : suffix === 'm' ? 1_000_000 : 1;
|
|
44
|
+
return Math.round(number * multiplier);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a percentage expression: "50%", "75%".
|
|
49
|
+
* Returns null if the input doesn't match the percentage format.
|
|
50
|
+
*/
|
|
51
|
+
const parsePercent = value => {
|
|
52
|
+
const match = String(value)
|
|
53
|
+
.trim()
|
|
54
|
+
.match(/^(\d+(?:\.\d+)?)\s*%$/);
|
|
55
|
+
if (!match) return null;
|
|
56
|
+
const percent = parseFloat(match[1]);
|
|
57
|
+
if (!Number.isFinite(percent) || percent <= 0 || percent > 100) return null;
|
|
58
|
+
return percent;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse the --sub-session-size option value into a normalized descriptor.
|
|
63
|
+
*
|
|
64
|
+
* @param {string|undefined|null} rawValue - The raw option value.
|
|
65
|
+
* @param {Object} [options]
|
|
66
|
+
* @param {number|null} [options.contextWindow] - Model context window in tokens (used for percentage values).
|
|
67
|
+
* @returns {{ kind: 'default' | 'tokens' | 'percent', tokens: number | null, percent: number | null, raw: string }}
|
|
68
|
+
* @throws {Error} If the value cannot be parsed.
|
|
69
|
+
*/
|
|
70
|
+
export const parseSubSessionSize = (rawValue, { contextWindow = null } = {}) => {
|
|
71
|
+
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
|
72
|
+
return { kind: 'default', tokens: null, percent: null, raw: '' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const trimmed = String(rawValue).trim();
|
|
76
|
+
const lower = trimmed.toLowerCase();
|
|
77
|
+
|
|
78
|
+
if (DEFAULT_TOKENS_VALUES.has(lower)) {
|
|
79
|
+
return { kind: 'default', tokens: null, percent: null, raw: trimmed };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const percent = parsePercent(trimmed);
|
|
83
|
+
if (percent !== null) {
|
|
84
|
+
const tokens = Number.isFinite(contextWindow) && contextWindow > 0 ? Math.round((contextWindow * percent) / 100) : null;
|
|
85
|
+
return { kind: 'percent', percent, tokens, raw: trimmed };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tokens = parseTokenCount(trimmed);
|
|
89
|
+
if (tokens !== null) {
|
|
90
|
+
return { kind: 'tokens', tokens, percent: null, raw: trimmed };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new Error(`${PARSE_ERROR_PREFIX}: invalid value "${rawValue}". Expected a token count (e.g. 150k, 1m), a percentage (e.g. 50%), or "default".`);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Apply --sub-session-size to a Claude Code env object.
|
|
98
|
+
*
|
|
99
|
+
* Claude Code uses CLAUDE_CODE_AUTO_COMPACT_WINDOW + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE.
|
|
100
|
+
* The percentage override only *lowers* the default ~95% threshold (per upstream
|
|
101
|
+
* docs), so we clamp it at 95 to avoid silently being ignored.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} env - Mutable env object to update.
|
|
104
|
+
* @param {Object} parsed - Result of parseSubSessionSize.
|
|
105
|
+
* @param {Object} [options]
|
|
106
|
+
* @param {number|null} [options.contextWindow] - Model context window in tokens.
|
|
107
|
+
* @returns {{ applied: boolean, summary: string|null }}
|
|
108
|
+
*/
|
|
109
|
+
export const applySubSessionSizeToClaudeEnv = (env, parsed, { contextWindow = null } = {}) => {
|
|
110
|
+
if (!parsed || parsed.kind === 'default') {
|
|
111
|
+
return { applied: false, summary: null };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const window = Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : null;
|
|
115
|
+
|
|
116
|
+
if (parsed.kind === 'tokens') {
|
|
117
|
+
const tokens = parsed.tokens;
|
|
118
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return { applied: false, summary: null };
|
|
119
|
+
|
|
120
|
+
// Use the tokens value as the compaction window basis and apply 100%.
|
|
121
|
+
// Capped to the model's actual window by Claude Code itself.
|
|
122
|
+
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(tokens);
|
|
123
|
+
// Compute percentage relative to the model context window (if known) so the
|
|
124
|
+
// override stays within Claude Code's "lower-only" semantics. Default to 95.
|
|
125
|
+
let pct = 95;
|
|
126
|
+
if (window) {
|
|
127
|
+
pct = Math.max(1, Math.min(95, Math.round((tokens / window) * 100)));
|
|
128
|
+
}
|
|
129
|
+
env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(pct);
|
|
130
|
+
return {
|
|
131
|
+
applied: true,
|
|
132
|
+
summary: `CLAUDE_CODE_AUTO_COMPACT_WINDOW=${tokens}, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=${pct}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (parsed.kind === 'percent') {
|
|
137
|
+
const pct = Math.max(1, Math.min(95, Math.round(parsed.percent)));
|
|
138
|
+
env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(pct);
|
|
139
|
+
if (window) {
|
|
140
|
+
env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(window);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
applied: true,
|
|
144
|
+
summary: `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=${pct}${window ? `, CLAUDE_CODE_AUTO_COMPACT_WINDOW=${window}` : ''}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { applied: false, summary: null };
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Apply --disable-1m-context to a Claude Code env object.
|
|
153
|
+
* Sets CLAUDE_CODE_DISABLE_1M_CONTEXT=1 when disabled is true.
|
|
154
|
+
*/
|
|
155
|
+
export const applyDisable1mContextToClaudeEnv = (env, disabled) => {
|
|
156
|
+
if (!disabled) return { applied: false };
|
|
157
|
+
env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
|
|
158
|
+
return { applied: true };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Build Codex `-c` config args for --sub-session-size.
|
|
163
|
+
* Returns an array like ['-c', 'model_auto_compact_token_limit=150000'] or [].
|
|
164
|
+
*/
|
|
165
|
+
export const buildCodexSubSessionSizeConfigArgs = (parsed, { contextWindow = null } = {}) => {
|
|
166
|
+
if (!parsed || parsed.kind === 'default') return [];
|
|
167
|
+
|
|
168
|
+
let tokens = null;
|
|
169
|
+
if (parsed.kind === 'tokens') {
|
|
170
|
+
tokens = parsed.tokens;
|
|
171
|
+
} else if (parsed.kind === 'percent') {
|
|
172
|
+
if (!Number.isFinite(contextWindow) || contextWindow <= 0) return [];
|
|
173
|
+
tokens = Math.round((contextWindow * parsed.percent) / 100);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return [];
|
|
177
|
+
return ['-c', `model_auto_compact_token_limit=${tokens}`];
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Build Codex `-c` config args for --disable-1m-context.
|
|
182
|
+
*
|
|
183
|
+
* Codex doesn't have a 1M-specific opt-out flag, but setting
|
|
184
|
+
* `model_context_window=200000` forces the standard window.
|
|
185
|
+
*
|
|
186
|
+
* @param {boolean} disabled - True when --disable-1m-context is in effect.
|
|
187
|
+
* @param {Object} [options]
|
|
188
|
+
* @param {number} [options.fallbackTokens] - Tokens to set when disabling (default: 200_000).
|
|
189
|
+
* @returns {string[]} Codex `-c` args, possibly empty.
|
|
190
|
+
*/
|
|
191
|
+
export const buildCodexDisable1mContextConfigArgs = (disabled, { fallbackTokens = 200_000 } = {}) => {
|
|
192
|
+
if (!disabled) return [];
|
|
193
|
+
return ['-c', `model_context_window=${fallbackTokens}`];
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Resolve --sub-session-size for a given tool, including fetching the model
|
|
198
|
+
* context window when a percentage is provided. Tolerates fetch failures.
|
|
199
|
+
*
|
|
200
|
+
* @param {Object} params
|
|
201
|
+
* @param {string|undefined|null} params.rawValue - The argv.subSessionSize value.
|
|
202
|
+
* @param {string} params.tool - 'claude' or 'codex'.
|
|
203
|
+
* @param {string} params.modelId - Model id (used for models.dev lookup when percent).
|
|
204
|
+
* @param {Function} [params.fetchModelInfo] - models.dev fetcher (injected for testability).
|
|
205
|
+
* @param {Function} [params.log] - log function (used for parse warnings).
|
|
206
|
+
* @returns {Promise<{ parsed: Object, contextWindowTokens: number|null }>}
|
|
207
|
+
*/
|
|
208
|
+
export const resolveSubSessionSize = async ({ rawValue, tool, modelId, fetchModelInfo, log }) => {
|
|
209
|
+
let parsed;
|
|
210
|
+
try {
|
|
211
|
+
parsed = parseSubSessionSize(rawValue);
|
|
212
|
+
} catch (parseError) {
|
|
213
|
+
if (log) await log(`⚠️ ${parseError.message}`, { level: 'warn' });
|
|
214
|
+
parsed = { kind: 'default', tokens: null, percent: null, raw: '' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let contextWindowTokens = null;
|
|
218
|
+
if (parsed.kind === 'percent' && typeof fetchModelInfo === 'function') {
|
|
219
|
+
try {
|
|
220
|
+
const baseModelId = String(modelId || '').replace(/\[1m\]$/i, '');
|
|
221
|
+
const preferredProviderIds = tool === 'codex' ? ['openai'] : ['anthropic'];
|
|
222
|
+
const meta = await fetchModelInfo(baseModelId, { preferredProviderIds });
|
|
223
|
+
contextWindowTokens = meta?.limit?.context || null;
|
|
224
|
+
} catch {
|
|
225
|
+
contextWindowTokens = null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return { parsed, contextWindowTokens };
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
export default {
|
|
233
|
+
parseSubSessionSize,
|
|
234
|
+
applySubSessionSizeToClaudeEnv,
|
|
235
|
+
applyDisable1mContextToClaudeEnv,
|
|
236
|
+
buildCodexSubSessionSizeConfigArgs,
|
|
237
|
+
buildCodexDisable1mContextConfigArgs,
|
|
238
|
+
resolveSubSessionSize,
|
|
239
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Retry wrapper for `use-m` package loading.
|
|
5
|
+
*
|
|
6
|
+
* Issue #1710: Hosted CI runners occasionally hand back a truncated or
|
|
7
|
+
* partially-installed global package after `npm install -g <pkg>`. Two
|
|
8
|
+
* surface symptoms have been observed:
|
|
9
|
+
*
|
|
10
|
+
* 1. `import` throws a SyntaxError ("Unexpected end of input") wrapped
|
|
11
|
+
* in use-m's `Failed to import module from '<path>'.` — the file on
|
|
12
|
+
* disk is cut off mid-line.
|
|
13
|
+
* 2. use-m throws `Failed to resolve the path to '<pkg>' from '<dir>'`
|
|
14
|
+
* — the install completed without error but the package tree is
|
|
15
|
+
* missing files that the `main`/`exports` entry depends on.
|
|
16
|
+
*
|
|
17
|
+
* The recovery is identical for both: delete the broken alias install
|
|
18
|
+
* directory and ask use-m to re-fetch. A clean reinstall almost always
|
|
19
|
+
* succeeds. This helper centralises that retry so every call site picks
|
|
20
|
+
* it up.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {(specifier: string) => Promise<unknown>} use - the use-m loader.
|
|
25
|
+
* @param {string} specifier - the npm specifier to load (e.g. `'getenv'`).
|
|
26
|
+
* @param {object} [options]
|
|
27
|
+
* @param {number} [options.attempts=3] - total attempts including the first try.
|
|
28
|
+
* @param {(path: string) => Promise<void>} [options.cleanup] - injectable cleanup
|
|
29
|
+
* for the corrupted install directory (defaults to recursive `rm`).
|
|
30
|
+
* @returns {Promise<unknown>} the module returned by use-m.
|
|
31
|
+
*/
|
|
32
|
+
export const useWithRetry = async (use, specifier, options = {}) => {
|
|
33
|
+
const attempts = options.attempts ?? 3;
|
|
34
|
+
const cleanup = options.cleanup ?? defaultCleanup;
|
|
35
|
+
let lastError;
|
|
36
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
37
|
+
try {
|
|
38
|
+
return await use(specifier);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
lastError = error;
|
|
41
|
+
if (attempt === attempts || !isCorruptInstallError(error)) {
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
const corruptedPath = extractCorruptedFilePath(error);
|
|
45
|
+
if (corruptedPath) {
|
|
46
|
+
try {
|
|
47
|
+
// Two failure modes:
|
|
48
|
+
// * "Failed to import module from '<file>'" — corruptedPath is a file
|
|
49
|
+
// inside the use-m alias dir (e.g. /.../getenv-v-latest/index.js).
|
|
50
|
+
// * "Failed to resolve the path to 'pkg' from '<dir>'" — corruptedPath
|
|
51
|
+
// is the alias dir itself (e.g. /.../links-notation-v-latest).
|
|
52
|
+
// For files, walk up to the alias dir; otherwise remove the dir as-is.
|
|
53
|
+
const { dirname } = await import('node:path');
|
|
54
|
+
const target = corruptedPath.endsWith('-v-latest') || /-v-\d/.test(corruptedPath) ? corruptedPath : dirname(corruptedPath);
|
|
55
|
+
await cleanup(target);
|
|
56
|
+
} catch {
|
|
57
|
+
// Best-effort cleanup; fall through to retry regardless.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Unreachable — the loop either returns or throws.
|
|
63
|
+
throw lastError;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const isCorruptInstallError = error => {
|
|
67
|
+
const cause = error?.cause;
|
|
68
|
+
if (cause instanceof SyntaxError) return true;
|
|
69
|
+
const causeMessage = typeof cause?.message === 'string' ? cause.message : '';
|
|
70
|
+
if (/Unexpected end of input|Unexpected token/.test(causeMessage)) return true;
|
|
71
|
+
// Mode 2 (also seen on hosted CI): npm install completes but the package
|
|
72
|
+
// tree is incomplete, so use-m can't resolve the entry point.
|
|
73
|
+
const message = typeof error?.message === 'string' ? error.message : '';
|
|
74
|
+
return /^Failed to resolve the path to /.test(message);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const extractCorruptedFilePath = error => {
|
|
78
|
+
const message = typeof error?.message === 'string' ? error.message : '';
|
|
79
|
+
const importMatch = message.match(/Failed to import module from '([^']+)'/);
|
|
80
|
+
if (importMatch) return importMatch[1];
|
|
81
|
+
// For "Failed to resolve the path to 'pkg' from '<dir>'" the second path
|
|
82
|
+
// is already the alias install directory — return it directly so callers
|
|
83
|
+
// can clean it up (cleanup() handles both files and directories).
|
|
84
|
+
const resolveMatch = message.match(/Failed to resolve the path to '[^']+' from '([^']+)'/);
|
|
85
|
+
return resolveMatch ? resolveMatch[1] : null;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const defaultCleanup = async path => {
|
|
89
|
+
const { rm } = await import('node:fs/promises');
|
|
90
|
+
await rm(path, { recursive: true, force: true });
|
|
91
|
+
};
|