@link-assistant/hive-mind 1.57.2 → 1.58.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 CHANGED
@@ -1,5 +1,63 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.58.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 3616130: Add `--sub-session-size` and `--disable-1m-context` options for Claude and Codex (issue #1706)
8
+
9
+ `--sub-session-size` (default: `150k`) caps the size of each sub-session
10
+ between auto-compaction events. It accepts a token count (`150k`, `1m`,
11
+ `200000`), a percentage of the model context window (`50%`), or `default`
12
+ to keep the tool's built-in threshold.
13
+
14
+ `--disable-1m-context` (default: `true`) opts out of the 1M extended
15
+ context window so models stay on their standard 200K-400K window. This
16
+ preserves reasoning quality and avoids the long-context price tier.
17
+ Use `--no-disable-1m-context` to allow 1M.
18
+
19
+ Both options work for `--tool claude` and `--tool codex`. For Claude Code
20
+ the wrapper sets `CLAUDE_CODE_DISABLE_1M_CONTEXT`,
21
+ `CLAUDE_CODE_AUTO_COMPACT_WINDOW`, and `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE`
22
+ env vars (clamped per upstream's "lower-only" semantics). For Codex the
23
+ wrapper appends `-c model_context_window=200000` and
24
+ `-c model_auto_compact_token_limit=<tokens>` overrides.
25
+
26
+ Verbose mode logs the applied env vars and `-c` overrides so operators
27
+ can confirm they reached the spawned tool process.
28
+
29
+ - b341775: Hide the cost-estimation breakdown when the public and Anthropic numbers agree to within display precision (issue #1703)
30
+
31
+ Both the live `displayCostComparison` console output and the
32
+ `buildCostInfoString` markdown rendered into PR/issue comments previously
33
+ collapsed to the short `💰 Cost: $X.XXXXXX` form only when the two values
34
+ matched **exactly** at six decimal places. Real-world calls regularly produce
35
+ underlying values that differ by ~`1e-7` and round to **adjacent** displays
36
+ (e.g. `$11.219694` vs `$11.219693`); the rendered difference (`$-0.000000
37
+ (-0.00%)`) was therefore noise yet still printed three full lines. The guard
38
+ now triggers whenever `|public − anthropic|.toFixed(6) === '0.000000'`, which
39
+ preserves the existing behaviour at every meaningful (≥ `$0.000001`) delta and
40
+ adds short-form output for the boundary case from issue #1703. Regression
41
+ tests live in `tests/test-build-cost-info-string.mjs` and
42
+ `tests/test-display-cost-comparison.mjs`.
43
+
44
+ ## 1.57.3
45
+
46
+ ### Patch Changes
47
+
48
+ - 5c65c29: Fix /log and /terminal_watch falsely rejecting real `$` isolation sessions (issue #1700)
49
+
50
+ `parseSessionStatusOutput` looked for the isolation backend at `data.isolation`
51
+ or `data.options.isolation`, but the published `link-foundation/start` 0.25.x
52
+ CLI reports it at `options.isolated` in both JSON and the default
53
+ `links-notation` output. As a result, replying `/log` (or `/terminal_watch`) to
54
+ a `Work session finished` message rejected every screen / tmux / docker session
55
+ with `❌ This command currently supports only sessions launched with $
56
+ isolation`. The parser now reads `options.isolated` first and keeps the legacy
57
+ field names as fallbacks. The rejection site additionally emits a `[VERBOSE]`
58
+ diagnostic line so future contract drifts can be triaged from a single bot log
59
+ entry. Regression test in `tests/test-issue-1700-isolation-parsing.mjs`.
60
+
3
61
  ## 1.57.2
4
62
 
5
63
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.57.2",
3
+ "version": "1.58.0",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -129,7 +129,9 @@ export const displayCostComparison = async (publicCost, anthropicCost, log) => {
129
129
  const hasAnthropic = anthropicCost !== null && anthropicCost !== undefined;
130
130
  const publicDec = hasPublic ? new Decimal(publicCost) : null;
131
131
  const anthropicDec = hasAnthropic ? new Decimal(anthropicCost) : null;
132
- if (publicDec && anthropicDec && publicDec.toFixed(6) === anthropicDec.toFixed(6)) {
132
+ // Issue #1703: also collapse to the short form when the rounded difference is below display precision,
133
+ // so reports like "Difference: $-0.000000 (-0.00%)" no longer waste two extra lines.
134
+ if (publicDec && anthropicDec && anthropicDec.minus(publicDec).abs().toFixed(6) === '0.000000') {
133
135
  await log(`\n 💰 Cost: $${anthropicDec.toFixed(6)}`);
134
136
  return;
135
137
  }
@@ -25,6 +25,7 @@ import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
25
25
  import { ensureClaudeQuietConfig } from './claude-quiet-config.lib.mjs';
26
26
  import { fetchModelInfo } from './model-info.lib.mjs';
27
27
  import { classifyRetryableError, maybeSwitchToFallbackModel } from './tool-retry.lib.mjs';
28
+ import { resolveSubSessionSize } from './sub-session-size.lib.mjs'; // Issue #1706
28
29
  export { availableModels }; // Re-export for backward compatibility
29
30
  export { fetchModelInfo };
30
31
  const showResumeCommand = async (sessionId, tempDir, claudePath, model, log) => {
@@ -761,9 +762,10 @@ export const executeClaudeCommand = async params => {
761
762
  }
762
763
  try {
763
764
  const { thinkingBudget: resolvedThinkingBudget, thinkLevel, isNewVersion, maxBudget } = await resolveThinkingSettings(argv, log);
764
- // Issue #817: Streaming mode sets exitAfterStopDelayMs=60000 so the
765
- // headless Claude process stays alive between NDJSON turns.
766
- const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, showThinkingContent: argv.showThinkingContent, exitAfterStopDelayMs: streamingInput ? 60_000 : undefined });
765
+ // Issue #1706: --sub-session-size + --disable-1m-context. Resolve here, then pass into getClaudeEnv along with the rest.
766
+ const { parsed: parsedSubSessionSize, contextWindowTokens } = await resolveSubSessionSize({ rawValue: argv.subSessionSize, tool: 'claude', modelId: effectiveModel, fetchModelInfo, log });
767
+ // Issue #817: streaming mode sets exitAfterStopDelayMs=60000 so the headless Claude process stays alive between NDJSON turns.
768
+ const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, showThinkingContent: argv.showThinkingContent, exitAfterStopDelayMs: streamingInput ? 60_000 : undefined, disable1mContext: !!argv.disable1mContext, subSessionSize: parsedSubSessionSize, contextWindowTokens });
767
769
  if (argv.verbose) claudeEnv.ANTHROPIC_LOG = 'debug';
768
770
  const modelMaxOutputTokens = getMaxOutputTokensForModel(effectiveModel);
769
771
  if (argv.verbose) {
@@ -772,6 +774,9 @@ export const executeClaudeCommand = async params => {
772
774
  if (resolvedThinkingBudget !== undefined) await log(`📊 MAX_THINKING_TOKENS: ${resolvedThinkingBudget}`, { verbose: true });
773
775
  if (claudeEnv.CLAUDE_CODE_EFFORT_LEVEL) await log(`📊 CLAUDE_CODE_EFFORT_LEVEL: ${claudeEnv.CLAUDE_CODE_EFFORT_LEVEL}`, { verbose: true });
774
776
  if (claudeEnv.CLAUDE_CODE_SHOW_THINKING) await log(`📊 CLAUDE_CODE_SHOW_THINKING: ${claudeEnv.CLAUDE_CODE_SHOW_THINKING}`, { verbose: true });
777
+ // Issue #1706: log applied env vars (--disable-1m-context, --sub-session-size).
778
+ const sub1706 = ['CLAUDE_CODE_DISABLE_1M_CONTEXT', 'CLAUDE_CODE_AUTO_COMPACT_WINDOW', 'CLAUDE_AUTOCOMPACT_PCT_OVERRIDE'].filter(k => claudeEnv[k]).map(k => `${k}=${claudeEnv[k]}`);
779
+ if (sub1706.length) await log(`📊 ${sub1706.join(', ')}`, { verbose: true });
775
780
  if (!isNewVersion && thinkLevel) await log(`📊 Thinking level (via keywords): ${thinkLevel}`, { verbose: true });
776
781
  }
777
782
  const simpleEscapedSystem = systemPrompt.replace(/"/g, '\\"');
package/src/codex.lib.mjs CHANGED
@@ -25,6 +25,7 @@ import { getCodexPlaywrightMcpDisableConfigArgs } from './playwright-mcp.lib.mjs
25
25
  import { fetchModelInfo } from './model-info.lib.mjs';
26
26
  import { defaultModels } from './models/index.mjs';
27
27
  import { classifyRetryableError, getRetryDelayMs, maybeSwitchToFallbackModel, waitWithCountdown } from './tool-retry.lib.mjs';
28
+ import { parseSubSessionSize, buildCodexSubSessionSizeConfigArgs, buildCodexDisable1mContextConfigArgs } from './sub-session-size.lib.mjs'; // Issue #1706
28
29
  import Decimal from 'decimal.js-light';
29
30
 
30
31
  const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_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'];
@@ -741,6 +742,36 @@ export const executeCodexCommand = async params => {
741
742
  }
742
743
  codexArgs += ` --json --skip-git-repo-check -o ${shellQuote(lastMessageFile)} -c ${shellQuote(`model_reasoning_effort=${reasoningEffort}`)} -c ${shellQuote('model_reasoning_summary=auto')} --dangerously-bypass-approvals-and-sandbox`;
743
744
 
745
+ // Issue #1706: Append --disable-1m-context and --sub-session-size as Codex -c overrides.
746
+ let parsedSubSessionSize;
747
+ try {
748
+ parsedSubSessionSize = parseSubSessionSize(argv.subSessionSize);
749
+ } catch (parseError) {
750
+ await log(`⚠️ ${parseError.message}`, { level: 'warn' });
751
+ parsedSubSessionSize = { kind: 'default', tokens: null, percent: null, raw: '' };
752
+ }
753
+ let codexContextWindowTokens = null;
754
+ if (parsedSubSessionSize.kind === 'percent') {
755
+ try {
756
+ const codexModelMeta = await fetchModelInfo(mappedModel, { preferredProviderIds: ['openai'] });
757
+ codexContextWindowTokens = codexModelMeta?.limit?.context || null;
758
+ } catch {
759
+ codexContextWindowTokens = null;
760
+ }
761
+ }
762
+ const disable1mArgs = buildCodexDisable1mContextConfigArgs(!!argv.disable1mContext);
763
+ for (const arg of disable1mArgs) {
764
+ codexArgs += ` ${shellQuote(arg)}`;
765
+ }
766
+ const subSessionSizeArgs = buildCodexSubSessionSizeConfigArgs(parsedSubSessionSize, { contextWindow: codexContextWindowTokens });
767
+ for (const arg of subSessionSizeArgs) {
768
+ codexArgs += ` ${shellQuote(arg)}`;
769
+ }
770
+ if (argv.verbose) {
771
+ if (disable1mArgs.length) await log(`📊 Codex --disable-1m-context: ${disable1mArgs.join(' ')}`, { verbose: true });
772
+ if (subSessionSizeArgs.length) await log(`📊 Codex --sub-session-size: ${subSessionSizeArgs.join(' ')}`, { verbose: true });
773
+ }
774
+
744
775
  const fullCommand = `(cd ${shellQuote(tempDir)} && cat ${shellQuote(promptFile)} | ${codexPath} ${codexArgs})`;
745
776
 
746
777
  await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
@@ -413,9 +413,12 @@ export const supportsThinkingBudget = (version, minVersion = '2.1.12') => {
413
413
  // Supports model-specific max output tokens for Opus 4.6 (Issue #1221)
414
414
  // Sets CLAUDE_CODE_EFFORT_LEVEL for Opus 4.6 models (Issue #1238)
415
415
  // Supports planModel/executionModel for opusplan mode (Issue #1223)
416
- // See: https://code.claude.com/docs/en/model-config
416
+ // Issue #1706: supports subSessionSize (parsed) + disable1mContext to cap
417
+ // auto-compaction sub-session size and opt out of the 1M extended context.
418
+ // See: https://code.claude.com/docs/en/env-vars and https://code.claude.com/docs/en/model-config
417
419
  // ANTHROPIC_DEFAULT_OPUS_MODEL → model used in plan mode (and for 'opus' alias)
418
420
  // ANTHROPIC_DEFAULT_SONNET_MODEL → model used in execution mode (and for 'sonnet' alias)
421
+ // CLAUDE_CODE_DISABLE_1M_CONTEXT, CLAUDE_CODE_AUTO_COMPACT_WINDOW, CLAUDE_AUTOCOMPACT_PCT_OVERRIDE
419
422
  export const getClaudeEnv = (options = {}) => {
420
423
  // Get max output tokens based on model (Issue #1221)
421
424
  const maxOutputTokens = options.model ? getMaxOutputTokensForModel(options.model) : claudeCode.maxOutputTokens;
@@ -483,6 +486,36 @@ export const getClaudeEnv = (options = {}) => {
483
486
  env.ANTHROPIC_DEFAULT_SONNET_MODEL = String(options.executionModel);
484
487
  }
485
488
 
489
+ // Issue #1706: --disable-1m-context. Sets CLAUDE_CODE_DISABLE_1M_CONTEXT=1.
490
+ if (options.disable1mContext) {
491
+ env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
492
+ }
493
+
494
+ // Issue #1706: --sub-session-size. Caller passes a pre-parsed descriptor and the
495
+ // model context window so we can convert percentages to absolute tokens.
496
+ if (options.subSessionSize && options.subSessionSize.kind && options.subSessionSize.kind !== 'default') {
497
+ const window = Number.isFinite(options.contextWindowTokens) && options.contextWindowTokens > 0 ? options.contextWindowTokens : null;
498
+ if (options.subSessionSize.kind === 'tokens') {
499
+ const tokens = options.subSessionSize.tokens;
500
+ if (Number.isFinite(tokens) && tokens > 0) {
501
+ env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(tokens);
502
+ // Compute percentage relative to the context window so the override stays
503
+ // within Claude Code's "lower-only" semantics. Default to 95 when unknown.
504
+ let pct = 95;
505
+ if (window) {
506
+ pct = Math.max(1, Math.min(95, Math.round((tokens / window) * 100)));
507
+ }
508
+ env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(pct);
509
+ }
510
+ } else if (options.subSessionSize.kind === 'percent') {
511
+ const pct = Math.max(1, Math.min(95, Math.round(options.subSessionSize.percent)));
512
+ env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(pct);
513
+ if (window) {
514
+ env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(window);
515
+ }
516
+ }
517
+ }
518
+
486
519
  return env;
487
520
  };
488
521
 
@@ -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}`;
@@ -52,7 +52,12 @@ export function parseSessionStatusOutput(output) {
52
52
  try {
53
53
  const parsed = JSON.parse(raw);
54
54
  const data = Array.isArray(parsed) ? parsed[0] : parsed;
55
- const isolationFromOptions = typeof data?.options?.isolation === 'string' ? data.options.isolation.toLowerCase() : null;
55
+ // start-command (link-foundation/start) reports the isolation backend at
56
+ // `options.isolated` in both JSON and links-notation output. Older
57
+ // hypothetical layouts used `options.isolation` or a top-level `isolation`
58
+ // field — keep accepting all three so we are tolerant of future renames.
59
+ // See https://github.com/link-assistant/hive-mind/issues/1700.
60
+ const isolationCandidate = (typeof data?.isolation === 'string' && data.isolation) || (typeof data?.options?.isolated === 'string' && data.options.isolated) || (typeof data?.options?.isolation === 'string' && data.options.isolation) || null;
56
61
  return {
57
62
  exists: true,
58
63
  uuid: data?.uuid || null,
@@ -63,7 +68,7 @@ export function parseSessionStatusOutput(output) {
63
68
  currentTime: data?.currentTime || null,
64
69
  logPath: data?.logPath || null,
65
70
  command: data?.command || null,
66
- isolation: typeof data?.isolation === 'string' ? data.isolation.toLowerCase() : isolationFromOptions,
71
+ isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
67
72
  workingDirectory: data?.workingDirectory || null,
68
73
  raw,
69
74
  };
@@ -83,6 +88,13 @@ export function parseSessionStatusOutput(output) {
83
88
 
84
89
  const status = readField('status')?.toLowerCase() || null;
85
90
  const exitCodeText = readField('exitCode');
91
+ // `start-command` links-notation output nests the isolation backend under
92
+ // `options` as `isolated <backend>` (not `isolation`). The leading indent
93
+ // varies by depth, but `readField` is anchored with `^\s*` which already
94
+ // matches indented lines. Older code only looked for `isolation`, which
95
+ // returned null for every real session and made /log + /terminal_watch
96
+ // reject screen/tmux/docker sessions. See issue #1700.
97
+ const isolationText = readField('isolated') || readField('isolation');
86
98
 
87
99
  return {
88
100
  exists: Boolean(status || firstLine),
@@ -94,7 +106,7 @@ export function parseSessionStatusOutput(output) {
94
106
  currentTime: readField('currentTime'),
95
107
  logPath: readField('logPath'),
96
108
  command: readField('command'),
97
- isolation: readField('isolation')?.toLowerCase() || null,
109
+ isolation: isolationText?.toLowerCase() || null,
98
110
  workingDirectory: readField('workingDirectory'),
99
111
  raw,
100
112
  };
@@ -260,6 +260,16 @@ export const SOLVE_OPTION_DEFINITIONS = {
260
260
  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
261
  default: 31999,
262
262
  },
263
+ 'sub-session-size': {
264
+ type: 'string',
265
+ 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)',
266
+ default: '150k',
267
+ },
268
+ 'disable-1m-context': {
269
+ type: 'boolean',
270
+ 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)',
271
+ default: true,
272
+ },
263
273
  'fallback-model': {
264
274
  type: 'string',
265
275
  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.',
@@ -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
+ };
@@ -260,6 +260,9 @@ export async function registerLogCommand(bot, options) {
260
260
  // 4. Decide the destination.
261
261
  const decision = decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType });
262
262
  if (decision.destination === 'reject') {
263
+ // Surface enough state to diagnose false-rejections like issue #1700,
264
+ // where the parser missed the isolation field name reported by the host.
265
+ VERBOSE && console.log(`[VERBOSE] /log rejected session ${sessionId}: reason="${decision.reason}" parsedIsolation=${JSON.stringify(statusResult?.isolation)} sessionInfoBackend=${JSON.stringify(sessionInfo?.isolationBackend)} rawHead=${JSON.stringify((statusResult?.raw || '').slice(0, 240))}`);
263
266
  await ctx.reply(`❌ ${decision.reason}`, { reply_to_message_id: message.message_id });
264
267
  return;
265
268
  }
@@ -382,6 +382,8 @@ export async function registerTerminalWatchCommand(bot, options) {
382
382
  const { repoVisibility, repoDescription } = await resolveTerminalWatchRepository({ sessionInfo, statusResult, parseGitHubUrl, detectRepositoryVisibility });
383
383
  const decision = decideLogDestination({ statusResult, sessionInfo, repoVisibility, chatType: chat.type });
384
384
  if (decision.destination === 'reject') {
385
+ // Surface enough state to diagnose false-rejections like issue #1700.
386
+ VERBOSE && console.log(`[VERBOSE] /terminal_watch rejected session ${sessionId}: reason="${decision.reason}" parsedIsolation=${JSON.stringify(statusResult?.isolation)} sessionInfoBackend=${JSON.stringify(sessionInfo?.isolationBackend)} rawHead=${JSON.stringify((statusResult?.raw || '').slice(0, 240))}`);
385
387
  await ctx.reply(`❌ ${decision.reason}`, { reply_to_message_id: message.message_id });
386
388
  return;
387
389
  }