@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.
@@ -18,7 +18,11 @@ if (typeof globalThis.use === 'undefined') {
18
18
  }
19
19
  }
20
20
 
21
- const getenvModule = await use('getenv');
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
- // See: https://code.claude.com/docs/en/model-config
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
- if (publicDec && anthropicDec && publicDec.toFixed(6) === anthropicDec.toFixed(6)) return `\n\n### 💰 Cost: **$${anthropicDec.toFixed(6)}**`;
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
- const linoModule = await use('links-notation');
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('');
@@ -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
+ };