clementine-agent 1.18.14 → 1.18.16

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/README.md CHANGED
@@ -324,14 +324,20 @@ clementine restart # apply changes
324
324
 
325
325
  Your overrides live in `~/.clementine/.env` — **they survive every `npm update -g` / `clementine update`** because they're in your data home, not the package directory.
326
326
 
327
+ The dashboard exposes these spend controls in Settings -> Channels & Env ->
328
+ Spend Guards & Context Health, including direct dollar-cap editing, Default
329
+ Caps, Safe Recovery, and No Caps presets.
330
+
327
331
  For spend/context tuning, `clementine budgets` gives a safer shortcut:
328
332
 
329
333
  ```bash
330
334
  clementine budgets # show chat/cron/heartbeat caps and 1M context state
331
- clementine budgets safe # lower background budgets and disable Claude 1M context
332
- clementine budgets 1m on # enable 1M context for eligible accounts / Extra Usage
335
+ clementine budgets safe # lower background budgets and force standard 200K context
336
+ clementine budgets 1m auto # allow included Opus 1M, keep Sonnet on 200K
337
+ clementine budgets 1m on # force 1M context for Extra Usage/API users
333
338
  clementine budgets 1m off # disable 1M context for maximum compatibility
334
339
  clementine budgets set chat 10 # raise one budget cap
340
+ clementine budgets set chat 0 # remove one cap
335
341
  ```
336
342
 
337
343
  **Commonly tuned knobs:**
@@ -342,7 +348,8 @@ clementine budgets set chat 10 # raise one budget cap
342
348
  | `BUDGET_CRON_T1_USD` | `0.75` | Max spend per tier-1 cron job |
343
349
  | `BUDGET_CRON_T2_USD` | `1.50` | Max spend per tier-2 cron job |
344
350
  | `BUDGET_HEARTBEAT_USD` | `0.25` | Max spend per heartbeat tick |
345
- | `CLAUDE_CODE_DISABLE_1M_CONTEXT` | `true` | `true`/`1` keeps Claude Code on 200K context unless the user explicitly enables 1M |
351
+ | `CLEMENTINE_1M_CONTEXT_MODE` | `auto` | `auto` allows included Opus 1M on Max/Team/Enterprise while keeping Sonnet on 200K; `off` forces 200K; `on` forces 1M |
352
+ | `CLAUDE_CODE_DISABLE_1M_CONTEXT` | legacy | Backward-compatible Claude Code switch; `budgets safe` writes `1`, `budgets 1m auto` removes it |
346
353
  | `DEFAULT_MODEL_TIER` | `sonnet` | Default model: `haiku` / `sonnet` / `opus` |
347
354
  | `HEARTBEAT_INTERVAL_MINUTES` | `30` | How often the agent auto-checks in |
348
355
  | `HEARTBEAT_ACTIVE_START` | `8` | First hour of the active window (0–23) |
@@ -13,7 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { query as rawQuery, listSubagents, getSubagentMessages, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, } from '@anthropic-ai/claude-agent-sdk';
15
15
  import pino from 'pino';
16
- import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, TASK_BUDGET_TOKENS, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, envSnapshot, } from '../config.js';
16
+ import { BASE_DIR, PKG_DIR, VAULT_DIR, DAILY_NOTES_DIR, SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR, ASSISTANT_NAME, OWNER_NAME, MODEL, MODELS, HEARTBEAT_MAX_TURNS, SEARCH_CONTEXT_LIMIT, SEARCH_RECENCY_LIMIT, SYSTEM_PROMPT_MAX_CONTEXT_CHARS, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, UNLEASHED_PHASE_TURNS, UNLEASHED_DEFAULT_MAX_HOURS, UNLEASHED_MAX_PHASES, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, HANDOFFS_DIR, BUDGET, TASK_BUDGET_TOKENS, IDENTITY_FILE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, claudeCodeDisableOneMillionForModel, currentOneMillionContextMode, normalizeClaudeModelForOneMillionContext, usesOneMillionContext, envSnapshot, } from '../config.js';
17
17
  import { summarizeIntegrationStatus } from '../config/integrations-registry.js';
18
18
  import { loadToolPreferences, computeAvailability, buildPromptInstruction, buildComposioStatusBlock, } from '../integrations/tool-preferences.js';
19
19
  import { loadClaudeIntegrations } from './mcp-bridge.js';
@@ -320,6 +320,8 @@ const MODEL_CONTEXT_WINDOWS = {
320
320
  'opus': 200_000,
321
321
  };
322
322
  function getContextWindow(model) {
323
+ if (usesOneMillionContext(model))
324
+ return 1_000_000;
323
325
  for (const [family, size] of Object.entries(MODEL_CONTEXT_WINDOWS)) {
324
326
  if (model.includes(family))
325
327
  return size;
@@ -338,10 +340,6 @@ function resultInputTokens(result) {
338
340
  }
339
341
  return total;
340
342
  }
341
- function oneMillionContextDisabled() {
342
- const value = process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT;
343
- return value === undefined || !/^(0|false|no)$/i.test(value);
344
- }
345
343
  export function looksLikeOneMillionContextError(value) {
346
344
  const text = String(value ?? '');
347
345
  return /extra usage.*1m context|1m context.*extra usage|context-1m/i.test(text);
@@ -537,12 +535,6 @@ function buildSafeEnv() {
537
535
  sanitized.ANTHROPIC_API_KEY = apiKeyVal;
538
536
  }
539
537
  // When all are absent: HOME lets the subprocess find Keychain OAuth automatically.
540
- // Preserve trusted Claude Code runtime flags set by config.ts. In
541
- // particular, CLAUDE_CODE_DISABLE_1M_CONTEXT defaults on so background
542
- // helper queries do not silently re-enable the 1M context beta.
543
- if (process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT !== undefined) {
544
- sanitized.CLAUDE_CODE_DISABLE_1M_CONTEXT = process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT;
545
- }
546
538
  // Step 3: Add trusted markers AFTER sanitization
547
539
  sanitized.CLEMENTINE_HOME = BASE_DIR;
548
540
  return sanitized;
@@ -2148,7 +2140,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2148
2140
  && toolsDisabledForCall
2149
2141
  && turnPolicy?.retrievalTier === 'none'
2150
2142
  && turnPolicy.effort === 'low';
2151
- const resolvedModel = resolveModel(requestedModel) ?? (lightweightModelEligible ? MODELS.haiku : MODEL);
2143
+ const rawResolvedModel = resolveModel(requestedModel) ?? (lightweightModelEligible ? MODELS.haiku : MODEL);
2144
+ const resolvedModel = normalizeClaudeModelForOneMillionContext(rawResolvedModel);
2145
+ const oneMillionModeValue = currentOneMillionContextMode();
2146
+ const oneMillionDisableValue = claudeCodeDisableOneMillionForModel(resolvedModel);
2152
2147
  const modelRouteReason = model
2153
2148
  ? 'explicit'
2154
2149
  : profile?.model
@@ -2421,7 +2416,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2421
2416
  systemPrompt: fullSystemPrompt,
2422
2417
  model: resolvedModel,
2423
2418
  ...(fallback ? { fallbackModel: fallback } : {}),
2424
- ...(oneMillionContextDisabled() ? { betas: [] } : {}),
2419
+ ...(oneMillionDisableValue === '1' ? { betas: [] } : {}),
2425
2420
  permissionMode: effectivePermissionMode,
2426
2421
  allowDangerouslySkipPermissions: true,
2427
2422
  ...(sessionStore ? { sessionStore } : {}),
@@ -2459,6 +2454,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2459
2454
  CLEMENTINE_TEAM_AGENT: profile?.slug ?? 'clementine',
2460
2455
  CLEMENTINE_INTERACTION_SOURCE: sourceOverride ?? inferInteractionSource(sessionKey),
2461
2456
  CLEMENTINE_TOOL_ALLOWLIST: clementineToolAllowlist,
2457
+ CLEMENTINE_1M_CONTEXT_MODE: oneMillionModeValue,
2458
+ ...(oneMillionDisableValue !== undefined
2459
+ ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: oneMillionDisableValue }
2460
+ : {}),
2462
2461
  },
2463
2462
  },
2464
2463
  ...externalMcpServers,
@@ -2472,14 +2471,21 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2472
2471
  // env only when the prompt/job mentions a connector-backed service.
2473
2472
  // Per-MCP-server env isolation still happens inside each mcpServers
2474
2473
  // entry; this only affects the Claude Code subprocess itself.
2475
- ...(shouldInheritClaudeEnv ? {} : {
2476
- env: {
2474
+ env: shouldInheritClaudeEnv
2475
+ ? {
2476
+ ...process.env,
2477
+ CLEMENTINE_1M_CONTEXT_MODE: oneMillionModeValue,
2478
+ ...(oneMillionDisableValue !== undefined
2479
+ ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: oneMillionDisableValue }
2480
+ : {}),
2481
+ }
2482
+ : {
2477
2483
  ...SAFE_ENV,
2478
- ...(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT !== undefined
2479
- ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT }
2484
+ CLEMENTINE_1M_CONTEXT_MODE: oneMillionModeValue,
2485
+ ...(oneMillionDisableValue !== undefined
2486
+ ? { CLAUDE_CODE_DISABLE_1M_CONTEXT: oneMillionDisableValue }
2480
2487
  : {}),
2481
2488
  },
2482
- }),
2483
2489
  // Avoid ambient Claude Code user/project/local settings and plugins by
2484
2490
  // default. Those can silently attach hundreds of tools. Explicit MCP
2485
2491
  // servers above still work; "all integrations/full tool surface" keeps
@@ -3421,13 +3427,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3421
3427
  responseText = responseText || ('Claude says the account credit balance is too low. I paused background jobs for a few hours so they stop draining/retrying, but interactive chat will also fail until credits are available again.');
3422
3428
  }
3423
3429
  else if (looksLikeOneMillionContextError(errorText)) {
3430
+ process.env.CLEMENTINE_1M_CONTEXT_MODE = 'off';
3424
3431
  process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
3425
3432
  if (sessionKey) {
3426
3433
  this.sessions.delete(sessionKey);
3427
3434
  this.exchangeCounts.set(sessionKey, 0);
3428
3435
  this._compactedSessions.delete(sessionKey);
3429
3436
  }
3430
- responseText = responseText || ("Claude rejected the 1M context beta for this account. I've disabled 1M context for this process and reset the session. To persist the fix across restarts, run `clementine config doctor --fix`, then `clementine restart`.");
3437
+ responseText = responseText || ("Claude rejected 1M context for this account. I've switched this process to 200K recovery mode and reset the session. To persist the fix across restarts, run `clementine budgets safe`, then `clementine restart`.");
3431
3438
  }
3432
3439
  else if (lower.includes('rate') && lower.includes('limit')) {
3433
3440
  hitRateLimit = true;
@@ -3552,13 +3559,14 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3552
3559
  responseText = responseText || ('Claude says the account credit balance is too low. I paused background jobs for a few hours so they stop draining/retrying, but interactive chat will also fail until credits are available again.');
3553
3560
  }
3554
3561
  else if (looksLikeOneMillionContextError(e)) {
3562
+ process.env.CLEMENTINE_1M_CONTEXT_MODE = 'off';
3555
3563
  process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
3556
3564
  if (sessionKey) {
3557
3565
  this.sessions.delete(sessionKey);
3558
3566
  this.exchangeCounts.set(sessionKey, 0);
3559
3567
  this._compactedSessions.delete(sessionKey);
3560
3568
  }
3561
- responseText = responseText || ("Claude rejected the 1M context beta for this account. I've disabled 1M context for this process and reset the session. To persist the fix across restarts, run `clementine config doctor --fix`, then `clementine restart`.");
3569
+ responseText = responseText || ("Claude rejected 1M context for this account. I've switched this process to 200K recovery mode and reset the session. To persist the fix across restarts, run `clementine budgets safe`, then `clementine restart`.");
3562
3570
  }
3563
3571
  else if (errStr.includes('rate') && (errStr.includes('limit') || errStr.includes('rate_limit'))) {
3564
3572
  hitRateLimit = true;
@@ -4814,6 +4822,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
4814
4822
  throw new Error(errText);
4815
4823
  }
4816
4824
  if (looksLikeOneMillionContextError(errText)) {
4825
+ process.env.CLEMENTINE_1M_CONTEXT_MODE = 'off';
4817
4826
  process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
4818
4827
  throw new Error(errText);
4819
4828
  }
@@ -5162,6 +5171,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
5162
5171
  throw new Error(exitText);
5163
5172
  }
5164
5173
  if (looksLikeOneMillionContextError(exitText)) {
5174
+ process.env.CLEMENTINE_1M_CONTEXT_MODE = 'off';
5165
5175
  process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
5166
5176
  throw new Error(exitText);
5167
5177
  }
@@ -5459,6 +5459,8 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5459
5459
  label: 'Model',
5460
5460
  keys: [
5461
5461
  { key: 'DEFAULT_MODEL_TIER', label: 'Default Tier', hint: 'haiku, sonnet, or opus', type: 'select:haiku,sonnet,opus' },
5462
+ { key: 'CLEMENTINE_1M_CONTEXT_MODE', label: '1M Context Mode', hint: 'auto allows included Opus 1M where available; off forces 200K; on forces 1M', type: 'select:auto,off,on' },
5463
+ { key: 'CLAUDE_CODE_DISABLE_1M_CONTEXT', label: 'Legacy 1M Disable', hint: 'Backward-compatible Claude Code switch. Prefer CLEMENTINE_1M_CONTEXT_MODE.', type: 'select:,1,0,true,false' },
5462
5464
  ],
5463
5465
  },
5464
5466
  {
@@ -5585,6 +5587,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5585
5587
  return result;
5586
5588
  }
5587
5589
  function writeEnvValue(key, value) {
5590
+ mkdirSync(BASE_DIR, { recursive: true });
5588
5591
  let content = existsSync(ENV_PATH) ? readFileSync(ENV_PATH, 'utf-8') : '';
5589
5592
  const re = new RegExp(`^${key}=.*$`, 'm');
5590
5593
  if (re.test(content)) {
@@ -5593,7 +5596,72 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5593
5596
  else {
5594
5597
  content = content.trimEnd() + `\n${key}=${value}\n`;
5595
5598
  }
5596
- writeFileSync(ENV_PATH, content);
5599
+ writeFileSync(ENV_PATH, content, { mode: 0o600 });
5600
+ }
5601
+ function deleteEnvValue(key) {
5602
+ if (!existsSync(ENV_PATH))
5603
+ return;
5604
+ const re = new RegExp(`^${key}=.*\n?`, 'm');
5605
+ const content = readFileSync(ENV_PATH, 'utf-8').replace(re, '');
5606
+ writeFileSync(ENV_PATH, content, { mode: 0o600 });
5607
+ }
5608
+ const DASHBOARD_BUDGET_ROWS = [
5609
+ { key: 'BUDGET_CHAT_USD', value: '5', label: 'Chat', hint: 'Per interactive chat turn' },
5610
+ { key: 'BUDGET_HEARTBEAT_USD', value: '0.25', label: 'Heartbeat', hint: 'Per proactive heartbeat tick' },
5611
+ { key: 'BUDGET_CRON_T1_USD', value: '0.75', label: 'Tier 1 cron', hint: 'Per lightweight scheduled job' },
5612
+ { key: 'BUDGET_CRON_T2_USD', value: '1.5', label: 'Tier 2 cron', hint: 'Per deeper scheduled job' },
5613
+ ];
5614
+ const SAFE_DASHBOARD_BUDGETS = [
5615
+ { key: 'BUDGET_HEARTBEAT_USD', value: '0.25', label: 'Heartbeat' },
5616
+ { key: 'BUDGET_CRON_T1_USD', value: '0.75', label: 'Tier 1 cron' },
5617
+ { key: 'BUDGET_CRON_T2_USD', value: '1.5', label: 'Tier 2 cron' },
5618
+ ];
5619
+ const DASHBOARD_BUDGET_KEYS = new Set(DASHBOARD_BUDGET_ROWS.map(row => row.key));
5620
+ function normalizeDashboardOneMillionMode(value) {
5621
+ const v = String(value ?? '').trim().toLowerCase();
5622
+ if (!v)
5623
+ return null;
5624
+ if (v === 'auto')
5625
+ return 'auto';
5626
+ if (['off', 'disable', 'disabled', 'safe', '200k', 'standard'].includes(v))
5627
+ return 'off';
5628
+ if (['on', 'enable', 'enabled', 'yes', 'true', '1'].includes(v))
5629
+ return 'on';
5630
+ return null;
5631
+ }
5632
+ function legacyDisableToDashboardMode(value) {
5633
+ const v = String(value ?? '').trim().toLowerCase();
5634
+ if (!v)
5635
+ return null;
5636
+ if (['1', 'true', 'yes', 'on'].includes(v))
5637
+ return 'off';
5638
+ if (['0', 'false', 'no', 'off'].includes(v))
5639
+ return 'on';
5640
+ return null;
5641
+ }
5642
+ function formatDashboardBudgetValue(value) {
5643
+ const n = Number(value);
5644
+ if (!Number.isFinite(n))
5645
+ return String(value ?? '');
5646
+ if (n === 0)
5647
+ return 'No cap';
5648
+ return `$${n.toFixed(2)}`;
5649
+ }
5650
+ function writeDashboardBudgetCap(key, value) {
5651
+ if (!DASHBOARD_BUDGET_KEYS.has(key)) {
5652
+ return { ok: false, error: 'Unknown budget key' };
5653
+ }
5654
+ const n = Number(value);
5655
+ if (!Number.isFinite(n) || n < 0) {
5656
+ return { ok: false, error: 'Budget must be a non-negative dollar amount. Use 0 for no cap.' };
5657
+ }
5658
+ if (n > 1000) {
5659
+ return { ok: false, error: 'Budget cap is too high for the dashboard. Use the CLI if you really need a cap above $1000.' };
5660
+ }
5661
+ const normalized = n === 0 ? '0' : String(Math.round(n * 100) / 100);
5662
+ writeEnvValue(key, normalized);
5663
+ process.env[key] = normalized;
5664
+ return { ok: true, value: normalized };
5597
5665
  }
5598
5666
  const ASSISTANT_PREF_OPTIONS = {
5599
5667
  proactivity: ['quiet', 'balanced', 'proactive', 'operator'],
@@ -5653,6 +5721,188 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5653
5721
  res.status(500).json({ error: String(err) });
5654
5722
  }
5655
5723
  });
5724
+ app.get('/api/budgets', async (_req, res) => {
5725
+ try {
5726
+ const [{ computeEffectiveConfig }, { runDoctor }] = await Promise.all([
5727
+ import('../config/effective-config.js'),
5728
+ import('../config/config-doctor.js'),
5729
+ ]);
5730
+ const cfg = computeEffectiveConfig(BASE_DIR);
5731
+ const doctor = runDoctor(BASE_DIR);
5732
+ const byKey = new Map(cfg.entries.map(e => [e.key, e]));
5733
+ const oneMModeEntry = byKey.get('CLEMENTINE_1M_CONTEXT_MODE');
5734
+ const legacyEntry = byKey.get('CLAUDE_CODE_DISABLE_1M_CONTEXT');
5735
+ const legacyMode = legacyDisableToDashboardMode(legacyEntry?.value);
5736
+ const mode = normalizeDashboardOneMillionMode(oneMModeEntry?.value)
5737
+ ?? legacyMode
5738
+ ?? 'auto';
5739
+ const source = oneMModeEntry?.source !== 'default'
5740
+ ? oneMModeEntry?.source
5741
+ : legacyMode
5742
+ ? legacyEntry?.source ?? 'default'
5743
+ : oneMModeEntry?.source ?? 'default';
5744
+ const summary = mode === 'off'
5745
+ ? 'Recovery mode: all models stay on standard 200K context.'
5746
+ : mode === 'on'
5747
+ ? 'Forced 1M: Sonnet and Pro subscriptions may require Claude Extra Usage.'
5748
+ : 'Smart auto: Opus can use included 1M on eligible Max/Team/Enterprise accounts; Sonnet stays on 200K.';
5749
+ const relevantKeys = new Set([
5750
+ 'BUDGET_CHAT_USD',
5751
+ 'BUDGET_HEARTBEAT_USD',
5752
+ 'BUDGET_CRON_T1_USD',
5753
+ 'BUDGET_CRON_T2_USD',
5754
+ 'CLEMENTINE_1M_CONTEXT_MODE',
5755
+ 'CLAUDE_CODE_DISABLE_1M_CONTEXT',
5756
+ ]);
5757
+ const findings = doctor.findings
5758
+ .filter(f => (f.key && relevantKeys.has(f.key)) || /budget|credit|1m|context/i.test(f.message))
5759
+ .slice(0, 8);
5760
+ res.json({
5761
+ ok: true,
5762
+ baseDir: cfg.baseDir,
5763
+ budgets: DASHBOARD_BUDGET_ROWS.map(row => {
5764
+ const entry = byKey.get(row.key);
5765
+ return {
5766
+ label: row.label,
5767
+ hint: row.hint,
5768
+ key: row.key,
5769
+ value: entry?.value ?? '',
5770
+ displayValue: formatDashboardBudgetValue(entry?.value),
5771
+ source: entry?.source ?? 'unknown',
5772
+ };
5773
+ }),
5774
+ context: {
5775
+ mode,
5776
+ source,
5777
+ summary,
5778
+ modeValue: oneMModeEntry?.value ?? 'auto',
5779
+ legacyValue: legacyEntry?.value ?? '',
5780
+ legacySource: legacyEntry?.source ?? 'default',
5781
+ legacyMode,
5782
+ },
5783
+ findings,
5784
+ counts: doctor.counts,
5785
+ });
5786
+ }
5787
+ catch (err) {
5788
+ res.status(500).json({ error: String(err) });
5789
+ }
5790
+ });
5791
+ app.post('/api/budgets/set', (req, res) => {
5792
+ try {
5793
+ const body = req.body;
5794
+ const key = String(body?.key ?? '');
5795
+ const result = writeDashboardBudgetCap(key, body?.value);
5796
+ if (!result.ok) {
5797
+ res.status(400).json({ error: result.error });
5798
+ return;
5799
+ }
5800
+ res.json({
5801
+ ok: true,
5802
+ message: `${key} set to ${formatDashboardBudgetValue(result.value)}. Restart Clementine to apply to running workers.`,
5803
+ });
5804
+ }
5805
+ catch (err) {
5806
+ res.status(500).json({ error: String(err) });
5807
+ }
5808
+ });
5809
+ app.post('/api/budgets/preset', (req, res) => {
5810
+ try {
5811
+ const preset = String(req.body?.preset ?? '').trim().toLowerCase();
5812
+ let writes;
5813
+ let message;
5814
+ if (preset === 'defaults' || preset === 'standard') {
5815
+ writes = DASHBOARD_BUDGET_ROWS.map(row => ({ key: row.key, value: row.value }));
5816
+ message = 'Restored the standard spend caps. Restart Clementine to apply to running workers.';
5817
+ }
5818
+ else if (preset === 'uncapped' || preset === 'off' || preset === 'none') {
5819
+ writes = DASHBOARD_BUDGET_ROWS.map(row => ({ key: row.key, value: '0' }));
5820
+ message = 'Removed spend caps by setting all budget values to 0. Restart Clementine to apply to running workers.';
5821
+ }
5822
+ else {
5823
+ res.status(400).json({ error: 'preset must be defaults or uncapped' });
5824
+ return;
5825
+ }
5826
+ for (const item of writes) {
5827
+ const result = writeDashboardBudgetCap(item.key, item.value);
5828
+ if (!result.ok) {
5829
+ res.status(400).json({ error: result.error });
5830
+ return;
5831
+ }
5832
+ }
5833
+ res.json({ ok: true, message });
5834
+ }
5835
+ catch (err) {
5836
+ res.status(500).json({ error: String(err) });
5837
+ }
5838
+ });
5839
+ app.post('/api/budgets/safe', (_req, res) => {
5840
+ try {
5841
+ for (const item of SAFE_DASHBOARD_BUDGETS) {
5842
+ const result = writeDashboardBudgetCap(item.key, item.value);
5843
+ if (!result.ok) {
5844
+ res.status(400).json({ error: result.error });
5845
+ return;
5846
+ }
5847
+ }
5848
+ writeEnvValue('CLEMENTINE_1M_CONTEXT_MODE', 'off');
5849
+ writeEnvValue('CLAUDE_CODE_DISABLE_1M_CONTEXT', '1');
5850
+ process.env.CLEMENTINE_1M_CONTEXT_MODE = 'off';
5851
+ process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
5852
+ res.json({
5853
+ ok: true,
5854
+ message: 'Applied safe budgets and 200K recovery mode. Restart Clementine to apply to running chat workers.',
5855
+ });
5856
+ }
5857
+ catch (err) {
5858
+ res.status(500).json({ error: String(err) });
5859
+ }
5860
+ });
5861
+ app.post('/api/budgets/1m', (req, res) => {
5862
+ try {
5863
+ const mode = normalizeDashboardOneMillionMode(req.body?.mode);
5864
+ if (!mode) {
5865
+ res.status(400).json({ error: 'mode must be auto, off, or on' });
5866
+ return;
5867
+ }
5868
+ writeEnvValue('CLEMENTINE_1M_CONTEXT_MODE', mode);
5869
+ process.env.CLEMENTINE_1M_CONTEXT_MODE = mode;
5870
+ if (mode === 'auto') {
5871
+ deleteEnvValue('CLAUDE_CODE_DISABLE_1M_CONTEXT');
5872
+ delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT;
5873
+ }
5874
+ else {
5875
+ const legacyValue = mode === 'on' ? '0' : '1';
5876
+ writeEnvValue('CLAUDE_CODE_DISABLE_1M_CONTEXT', legacyValue);
5877
+ process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = legacyValue;
5878
+ }
5879
+ const message = mode === 'auto'
5880
+ ? 'Set 1M context to smart auto. Restart Clementine to apply everywhere.'
5881
+ : mode === 'off'
5882
+ ? 'Disabled 1M context for recovery. Restart Clementine to apply everywhere.'
5883
+ : 'Forced 1M context on. Sonnet and Pro subscriptions may require Claude Extra Usage.';
5884
+ res.json({ ok: true, message });
5885
+ }
5886
+ catch (err) {
5887
+ res.status(500).json({ error: String(err) });
5888
+ }
5889
+ });
5890
+ app.post('/api/budgets/doctor-fix', async (_req, res) => {
5891
+ try {
5892
+ const { applyDoctorFixes } = await import('../config/config-doctor.js');
5893
+ const result = applyDoctorFixes(BASE_DIR);
5894
+ for (const item of result.changed) {
5895
+ process.env[item.key] = item.value;
5896
+ }
5897
+ const message = result.changed.length
5898
+ ? `Applied ${result.changed.length} config fix${result.changed.length === 1 ? '' : 'es'}. Restart Clementine to apply everywhere.`
5899
+ : 'No budget or context fixes were needed.';
5900
+ res.json({ ok: true, message, result });
5901
+ }
5902
+ catch (err) {
5903
+ res.status(500).json({ error: String(err) });
5904
+ }
5905
+ });
5656
5906
  app.get('/api/settings', (_req, res) => {
5657
5907
  try {
5658
5908
  const env = parseEnvFile();
@@ -5739,10 +5989,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
5739
5989
  res.status(404).json({ error: '.env file not found' });
5740
5990
  return;
5741
5991
  }
5742
- let content = readFileSync(ENV_PATH, 'utf-8');
5743
- const re = new RegExp(`^${key}=.*\n?`, 'm');
5744
- content = content.replace(re, '');
5745
- writeFileSync(ENV_PATH, content);
5992
+ deleteEnvValue(key);
5746
5993
  // Hot-reload mirror of the PUT handler — drop process.env entry +
5747
5994
  // reset Composio client so removal takes effect without a restart.
5748
5995
  if (key === 'COMPOSIO_API_KEY' || key === 'COMPOSIO_USER_ID') {
@@ -15169,6 +15416,7 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
15169
15416
  <p style="color:var(--text-muted);margin:0">Manage API keys and configuration. Changes are saved to <code>~/.clementine/.env</code>.</p>
15170
15417
  <button class="btn-sm" style="white-space:nowrap;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary);padding:6px 12px;border-radius:6px;cursor:pointer" onclick="restartDashboard()">Restart Dashboard</button>
15171
15418
  </div>
15419
+ <div id="budget-health-content" style="margin-bottom:16px"><div class="empty-state">Loading budget health...</div></div>
15172
15420
  <div id="settings-content"><div class="empty-state">Loading settings...</div></div>
15173
15421
  </div>
15174
15422
  <div class="tab-pane" id="tab-settings-remote">
@@ -19717,8 +19965,153 @@ async function listenToDigest() {
19717
19965
  } catch(e) { toast(String(e), 'error'); }
19718
19966
  }
19719
19967
 
19968
+ async function refreshBudgetHealth() {
19969
+ var container = document.getElementById('budget-health-content');
19970
+ if (!container) return;
19971
+ try {
19972
+ var r = await apiFetch('/api/budgets');
19973
+ var d = await r.json();
19974
+ if (!d.ok) {
19975
+ container.innerHTML = '<div class="empty-state" style="color:var(--red)">Failed to load budget health: ' + esc(d.error || 'Unknown error') + '</div>';
19976
+ return;
19977
+ }
19978
+ var context = d.context || {};
19979
+ var mode = context.mode || 'auto';
19980
+ var modeClass = mode === 'off' ? 'badge-green' : mode === 'on' ? 'badge-yellow' : 'badge-blue';
19981
+ var rows = d.budgets || [];
19982
+ var findings = d.findings || [];
19983
+ var html = '<div class="card">'
19984
+ + '<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:12px">'
19985
+ + '<div style="display:flex;align-items:center;gap:8px"><span>Spend Guards &amp; Context Health</span><span class="badge ' + modeClass + '" style="font-size:10px">1M ' + esc(mode) + '</span></div>'
19986
+ + '<div style="display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end">'
19987
+ + '<button class="btn-sm btn-primary" onclick="applySafeBudgetPreset()">Safe Recovery</button>'
19988
+ + '<button class="btn-sm" onclick="applyBudgetPreset(\\x27defaults\\x27)">Default Caps</button>'
19989
+ + '<button class="btn-sm" onclick="applyBudgetPreset(\\x27uncapped\\x27)">No Caps</button>'
19990
+ + '<button class="btn-sm" onclick="setBudgetContextMode(\\x27auto\\x27)">Smart Auto</button>'
19991
+ + '<button class="btn-sm" onclick="setBudgetContextMode(\\x27off\\x27)">Force 200K</button>'
19992
+ + '<button class="btn-sm" onclick="forceBudgetOneMillion()">Force 1M</button>'
19993
+ + '<button class="btn-sm" onclick="applyBudgetDoctorFix()">Doctor Fix</button>'
19994
+ + '</div></div>'
19995
+ + '<div class="card-body" style="padding:16px">';
19996
+ html += '<div style="font-size:12px;color:var(--text-secondary);margin-bottom:12px">Spend guards are per-run dollar caps. They prevent runaway background work, but setting a cap to 0 removes that guard.</div>';
19997
+ html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px;margin-bottom:12px">';
19998
+ for (var i = 0; i < rows.length; i++) {
19999
+ var row = rows[i];
20000
+ var inputId = 'budget-cap-' + String(row.key).replace(/[^A-Za-z0-9_-]/g, '-');
20001
+ var rawValue = Number(row.value);
20002
+ var inputValue = Number.isFinite(rawValue) ? String(rawValue) : '';
20003
+ html += '<div style="border:1px solid var(--border);border-radius:8px;padding:10px;background:var(--bg-secondary)">'
20004
+ + '<div style="display:flex;justify-content:space-between;gap:8px;align-items:flex-start">'
20005
+ + '<div><div style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0">' + esc(row.label) + '</div>'
20006
+ + '<div style="font-size:11px;color:var(--text-muted);margin-top:2px">' + esc(row.hint || row.key) + '</div></div>'
20007
+ + '<span class="badge badge-gray" style="font-size:10px">' + esc(row.source || 'unknown') + '</span>'
20008
+ + '</div>'
20009
+ + '<div style="display:flex;gap:6px;align-items:center;margin-top:10px">'
20010
+ + '<span style="font-size:13px;color:var(--text-muted)">$</span>'
20011
+ + '<input id="' + inputId + '" type="number" min="0" step="0.05" value="' + esc(inputValue) + '" data-budget-key="' + esc(row.key) + '"'
20012
+ + ' style="flex:1;min-width:0;padding:6px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-primary);color:var(--text-primary);font-size:13px">'
20013
+ + '<button class="btn-sm" onclick="saveBudgetCap(\\x27' + esc(row.key) + '\\x27)">Save</button>'
20014
+ + '</div>'
20015
+ + '<div style="font-size:11px;color:var(--text-muted);margin-top:6px">' + esc(row.key) + ' - ' + esc(row.displayValue || row.value || '') + '</div>'
20016
+ + '</div>';
20017
+ }
20018
+ html += '</div>';
20019
+ html += '<div style="display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;margin-bottom:12px">'
20020
+ + '<div style="flex:1;min-width:240px;border:1px solid var(--border);border-radius:8px;padding:10px;background:var(--bg-primary)">'
20021
+ + '<div style="font-weight:600;font-size:13px">1M context mode</div>'
20022
+ + '<div style="font-size:12px;color:var(--text-secondary);margin-top:4px">' + esc(context.summary || '') + '</div>'
20023
+ + '<div style="font-size:11px;color:var(--text-muted);margin-top:6px">Source: ' + esc(context.source || 'default')
20024
+ + (context.legacyValue ? ' | legacy CLAUDE_CODE_DISABLE_1M_CONTEXT=' + esc(context.legacyValue) + ' from ' + esc(context.legacySource || 'default') : '')
20025
+ + '</div></div>'
20026
+ + '<div style="flex:1;min-width:240px;border:1px solid var(--border);border-radius:8px;padding:10px;background:var(--bg-primary)">'
20027
+ + '<div style="font-weight:600;font-size:13px">What this protects</div>'
20028
+ + '<div style="font-size:12px;color:var(--text-secondary);margin-top:4px">Safe Recovery lowers autonomous spend and disables 1M context for accounts seeing credit or entitlement errors.</div>'
20029
+ + '<div style="font-size:11px;color:var(--text-muted);margin-top:6px">Restart the daemon after changing budgets or context mode.</div>'
20030
+ + '</div></div>';
20031
+ if (findings.length) {
20032
+ html += '<div style="border-top:1px solid var(--border);padding-top:10px">'
20033
+ + '<div style="font-weight:600;font-size:13px;margin-bottom:6px">Potential causes</div>';
20034
+ for (var j = 0; j < findings.length; j++) {
20035
+ var f = findings[j];
20036
+ var cls = f.severity === 'error' ? 'badge-red' : f.severity === 'warning' ? 'badge-yellow' : 'badge-gray';
20037
+ html += '<div style="display:flex;gap:8px;align-items:flex-start;padding:6px 0;border-bottom:1px solid rgba(127,127,127,0.12)">'
20038
+ + '<span class="badge ' + cls + '" style="font-size:10px;min-width:54px;text-align:center">' + esc(f.severity || 'info') + '</span>'
20039
+ + '<div style="font-size:12px;color:var(--text-secondary);line-height:1.4">'
20040
+ + (f.key ? '<code>' + esc(f.key) + '</code>: ' : '') + esc(f.message || '')
20041
+ + (f.fix ? '<div style="font-size:11px;color:var(--text-muted);margin-top:2px">Fix: <code>' + esc(f.fix) + '</code></div>' : '')
20042
+ + '</div></div>';
20043
+ }
20044
+ html += '</div>';
20045
+ } else {
20046
+ html += '<div style="font-size:12px;color:var(--text-muted);border-top:1px solid var(--border);padding-top:10px">No budget or context warnings found.</div>';
20047
+ }
20048
+ html += '</div></div>';
20049
+ container.innerHTML = html;
20050
+ } catch(e) {
20051
+ container.innerHTML = '<div class="empty-state" style="color:var(--red)">Failed to load budget health: ' + esc(String(e)) + '</div>';
20052
+ }
20053
+ }
20054
+
20055
+ async function postBudgetAction(url, body) {
20056
+ try {
20057
+ var r = await apiFetch(url, {
20058
+ method: 'POST',
20059
+ headers: { 'Content-Type': 'application/json' },
20060
+ body: JSON.stringify(body || {}),
20061
+ });
20062
+ var d = await r.json();
20063
+ if (d.ok) toast(d.message || 'Updated', 'success');
20064
+ else toast(d.error || 'Error', 'error');
20065
+ await refreshBudgetHealth();
20066
+ return d;
20067
+ } catch(e) {
20068
+ toast(String(e), 'error');
20069
+ return null;
20070
+ }
20071
+ }
20072
+
20073
+ async function applySafeBudgetPreset() {
20074
+ await postBudgetAction('/api/budgets/safe', {});
20075
+ refreshSettings();
20076
+ }
20077
+
20078
+ async function applyBudgetPreset(preset) {
20079
+ if (preset === 'uncapped' && !confirm('Remove all spend caps? Clementine can still hit account limits or credits if a job runs long.')) return;
20080
+ await postBudgetAction('/api/budgets/preset', { preset: preset });
20081
+ refreshSettings();
20082
+ }
20083
+
20084
+ async function saveBudgetCap(key) {
20085
+ var inputId = 'budget-cap-' + String(key).replace(/[^A-Za-z0-9_-]/g, '-');
20086
+ var input = document.getElementById(inputId);
20087
+ if (!input) return;
20088
+ var value = Number(input.value);
20089
+ if (!Number.isFinite(value) || value < 0) {
20090
+ toast('Budget must be a non-negative dollar amount. Use 0 for no cap.', 'error');
20091
+ return;
20092
+ }
20093
+ await postBudgetAction('/api/budgets/set', { key: key, value: value });
20094
+ refreshSettings();
20095
+ }
20096
+
20097
+ async function setBudgetContextMode(mode) {
20098
+ await postBudgetAction('/api/budgets/1m', { mode: mode });
20099
+ refreshSettings();
20100
+ }
20101
+
20102
+ async function forceBudgetOneMillion() {
20103
+ if (!confirm('Force 1M context on? Sonnet and Pro subscriptions may require Claude Extra Usage.')) return;
20104
+ await setBudgetContextMode('on');
20105
+ }
20106
+
20107
+ async function applyBudgetDoctorFix() {
20108
+ await postBudgetAction('/api/budgets/doctor-fix', {});
20109
+ refreshSettings();
20110
+ }
20111
+
19720
20112
  async function refreshSettings() {
19721
20113
  var container = document.getElementById('settings-content');
20114
+ refreshBudgetHealth();
19722
20115
  try {
19723
20116
  var r = await apiFetch('/api/settings');
19724
20117
  var d = await r.json();
package/dist/cli/index.js CHANGED
@@ -959,6 +959,14 @@ function upsertEnvValue(key, value) {
959
959
  // harden-permissions`.
960
960
  writeFileSync(ENV_PATH, content, { mode: 0o600 });
961
961
  }
962
+ function removeEnvValue(key) {
963
+ if (!existsSync(ENV_PATH))
964
+ return;
965
+ const upperKey = key.toUpperCase();
966
+ const content = readFileSync(ENV_PATH, 'utf-8');
967
+ const lines = content.split(/\r?\n/).filter(line => !new RegExp(`^${upperKey}=`).test(line));
968
+ writeFileSync(ENV_PATH, lines.join('\n').trimEnd() + '\n', { mode: 0o600 });
969
+ }
962
970
  function cmdConfigSet(key, value) {
963
971
  const upperKey = key.toUpperCase();
964
972
  upsertEnvValue(upperKey, value);
@@ -1033,8 +1041,27 @@ const BUDGET_ALIASES = {
1033
1041
  'cron-t2': 'BUDGET_CRON_T2_USD',
1034
1042
  t2: 'BUDGET_CRON_T2_USD',
1035
1043
  };
1036
- function isOneMillionContextEnabled(value) {
1037
- return /^(0|false|no)$/i.test(String(value).trim());
1044
+ function normalizeOneMillionCliMode(value) {
1045
+ const v = String(value ?? '').trim().toLowerCase();
1046
+ if (!v)
1047
+ return null;
1048
+ if (v === 'auto')
1049
+ return 'auto';
1050
+ if (['off', 'disable', 'disabled', 'safe', '200k', 'standard'].includes(v))
1051
+ return 'off';
1052
+ if (['on', 'enable', 'enabled', 'yes', 'true', '1'].includes(v))
1053
+ return 'on';
1054
+ return null;
1055
+ }
1056
+ function legacyDisableToOneMillionCliMode(value) {
1057
+ const v = String(value ?? '').trim().toLowerCase();
1058
+ if (!v)
1059
+ return null;
1060
+ if (['1', 'true', 'yes', 'on'].includes(v))
1061
+ return 'off';
1062
+ if (['0', 'false', 'no', 'off'].includes(v))
1063
+ return 'on';
1064
+ return null;
1038
1065
  }
1039
1066
  function normalizeBudgetKey(name) {
1040
1067
  const raw = name.trim();
@@ -1084,20 +1111,33 @@ async function cmdBudgetsShow() {
1084
1111
  const source = entry?.source ?? 'unknown';
1085
1112
  console.log(` ${label.padEnd(12)} ${BOLD}${formatBudgetValue(entry?.value).padEnd(8)}${RESET} ${DIM}${key} from ${source}${RESET}`);
1086
1113
  }
1114
+ const oneMModeEntry = byKey.get('CLEMENTINE_1M_CONTEXT_MODE');
1115
+ const persistedOneMMode = readPersistedEnvValue('CLEMENTINE_1M_CONTEXT_MODE');
1116
+ const oneMMode = normalizeOneMillionCliMode(persistedOneMMode ?? oneMModeEntry?.value)
1117
+ ?? 'auto';
1118
+ const oneMModeSource = persistedOneMMode !== undefined ? '.env' : oneMModeEntry?.source ?? 'default';
1087
1119
  const oneM = byKey.get('CLAUDE_CODE_DISABLE_1M_CONTEXT');
1088
1120
  const persistedOneM = readPersistedEnvValue('CLAUDE_CODE_DISABLE_1M_CONTEXT');
1089
1121
  const oneMValue = persistedOneM ?? oneM?.value;
1090
1122
  const oneMSource = persistedOneM !== undefined ? '.env' : oneM?.source ?? 'unknown';
1091
- const oneMEnabled = isOneMillionContextEnabled(oneMValue);
1123
+ const legacyMode = legacyDisableToOneMillionCliMode(oneMValue);
1092
1124
  console.log();
1093
- console.log(` 1M context ${oneMEnabled ? `${YELLOW}enabled${RESET}` : `${GREEN}disabled${RESET}`} ${DIM}CLAUDE_CODE_DISABLE_1M_CONTEXT=${String(oneMValue ?? '')} from ${oneMSource}${RESET}`);
1094
- if (oneMEnabled) {
1095
- console.log(` ${YELLOW}Note:${RESET} 1M context requires an eligible plan or Claude Extra Usage; accounts without it will fail calls.`);
1125
+ const modeColor = oneMMode === 'off' ? GREEN : oneMMode === 'on' ? YELLOW : BOLD;
1126
+ console.log(` 1M context ${modeColor}${oneMMode}${RESET} ${DIM}CLEMENTINE_1M_CONTEXT_MODE=${String(persistedOneMMode ?? oneMModeEntry?.value ?? 'auto')} from ${oneMModeSource}${RESET}`);
1127
+ if (legacyMode && oneMModeEntry?.source === 'default') {
1128
+ console.log(` ${DIM}legacy CLAUDE_CODE_DISABLE_1M_CONTEXT=${String(oneMValue ?? '')} from ${oneMSource} maps to mode=${legacyMode}${RESET}`);
1129
+ }
1130
+ if (oneMMode === 'auto') {
1131
+ console.log(` ${DIM}allows included Opus 1M on Max/Team/Enterprise; keeps Sonnet on 200K unless mode=on${RESET}`);
1132
+ }
1133
+ else if (oneMMode === 'on') {
1134
+ console.log(` ${YELLOW}Note:${RESET} forced 1M requires Extra Usage for Sonnet and for Pro subscriptions.`);
1096
1135
  }
1097
1136
  console.log();
1098
1137
  console.log(` ${DIM}Useful commands:${RESET}`);
1099
- console.log(` clementine budgets safe ${DIM}lower background budgets and disable 1M context${RESET}`);
1100
- console.log(` clementine budgets 1m on ${DIM}enable 1M context for accounts with entitlement/Extra Usage${RESET}`);
1138
+ console.log(` clementine budgets safe ${DIM}lower background budgets and force 200K context${RESET}`);
1139
+ console.log(` clementine budgets 1m auto ${DIM}allow included Opus 1M, keep Sonnet safe${RESET}`);
1140
+ console.log(` clementine budgets 1m on ${DIM}force 1M context for Extra Usage/API users${RESET}`);
1101
1141
  console.log(` clementine budgets 1m off ${DIM}disable 1M context for maximum compatibility${RESET}`);
1102
1142
  console.log(` clementine budgets set chat 10 ${DIM}raise one budget cap${RESET}`);
1103
1143
  console.log();
@@ -1113,6 +1153,7 @@ async function cmdBudgetsSafe() {
1113
1153
  const RESET = '\x1b[0m';
1114
1154
  const writes = [
1115
1155
  ...SAFE_BACKGROUND_BUDGETS,
1156
+ { key: 'CLEMENTINE_1M_CONTEXT_MODE', value: 'off', label: '1M context mode' },
1116
1157
  { key: 'CLAUDE_CODE_DISABLE_1M_CONTEXT', value: '1', label: '1M context disabled' },
1117
1158
  ];
1118
1159
  for (const item of writes) {
@@ -1134,23 +1175,34 @@ function cmdBudgetsOneMillion(mode) {
1134
1175
  const normalized = mode.trim().toLowerCase();
1135
1176
  const on = new Set(['on', 'enable', 'enabled', 'yes', 'true', '1']);
1136
1177
  const off = new Set(['off', 'disable', 'disabled', 'no', 'false', '0']);
1137
- if (!on.has(normalized) && !off.has(normalized)) {
1138
- console.error(' Usage: clementine budgets 1m <on|off>');
1178
+ const auto = new Set(['auto', 'smart', 'default']);
1179
+ if (!on.has(normalized) && !off.has(normalized) && !auto.has(normalized)) {
1180
+ console.error(' Usage: clementine budgets 1m <auto|on|off>');
1139
1181
  process.exitCode = 1;
1140
1182
  return;
1141
1183
  }
1184
+ if (auto.has(normalized)) {
1185
+ upsertEnvValue('CLEMENTINE_1M_CONTEXT_MODE', 'auto');
1186
+ removeEnvValue('CLAUDE_CODE_DISABLE_1M_CONTEXT');
1187
+ console.log();
1188
+ console.log(' Set Claude 1M context mode to auto.');
1189
+ console.log(' Opus can use included 1M on Max/Team/Enterprise; Sonnet stays on 200K unless you force mode=on. Restart Clementine to apply.');
1190
+ console.log();
1191
+ return;
1192
+ }
1142
1193
  const enable = on.has(normalized);
1194
+ upsertEnvValue('CLEMENTINE_1M_CONTEXT_MODE', enable ? 'on' : 'off');
1143
1195
  upsertEnvValue('CLAUDE_CODE_DISABLE_1M_CONTEXT', enable ? '0' : '1');
1144
1196
  if (enable) {
1145
1197
  console.log();
1146
- console.log(' Enabled Claude 1M context for Clementine.');
1147
- console.log(' Requires an eligible account or Claude Extra Usage; restart Clementine to apply.');
1198
+ console.log(' Forced Claude 1M context on for Clementine.');
1199
+ console.log(' Requires Extra Usage for Sonnet and Pro subscriptions, or API/PAYG billing. Restart Clementine to apply.');
1148
1200
  console.log();
1149
1201
  }
1150
1202
  else {
1151
1203
  console.log();
1152
1204
  console.log(' Disabled Claude 1M context for Clementine.');
1153
- console.log(' This is the safest default for users without Claude Extra Usage. Restart Clementine to apply.');
1205
+ console.log(' This is the safest recovery mode for users hitting 1M entitlement errors. Restart Clementine to apply.');
1154
1206
  console.log();
1155
1207
  }
1156
1208
  }
@@ -2368,13 +2420,13 @@ budgetsCmd
2368
2420
  });
2369
2421
  budgetsCmd
2370
2422
  .command('safe')
2371
- .description('Apply the stable local-safe preset: lower background budgets and disable 1M context')
2423
+ .description('Apply the stable local-safe preset: lower background budgets and force 200K context')
2372
2424
  .action(async () => {
2373
2425
  await cmdBudgetsSafe();
2374
2426
  });
2375
2427
  budgetsCmd
2376
2428
  .command('1m <mode>')
2377
- .description('Toggle Claude 1M context for Clementine (on | off)')
2429
+ .description('Set Claude 1M context mode for Clementine (auto | on | off)')
2378
2430
  .action(cmdBudgetsOneMillion);
2379
2431
  budgetsCmd
2380
2432
  .command('set <name> <value>')
@@ -38,6 +38,7 @@ const NUMERIC_KEYS = new Set([
38
38
  const ENUM_KEYS = {
39
39
  CLEMENTINE_ADVISOR_RULES_LOADER: ['off', 'shadow', 'primary'],
40
40
  DEFAULT_MODEL_TIER: ['haiku', 'sonnet', 'opus'],
41
+ CLEMENTINE_1M_CONTEXT_MODE: ['auto', 'off', 'on'],
41
42
  WEBHOOK_ENABLED: ['true', 'false'],
42
43
  ALLOW_ALL_USERS: ['true', 'false'],
43
44
  CLEMENTINE_ALLOW_SOURCE_EDITS: ['true', 'false', '1', '0', 'yes', 'no'],
@@ -109,9 +110,11 @@ export function applyDoctorFixes(baseDir) {
109
110
  }
110
111
  changed.push({ key, value, reason });
111
112
  };
113
+ const oneMMode = byKey.get('CLEMENTINE_1M_CONTEXT_MODE');
112
114
  const oneM = byKey.get('CLAUDE_CODE_DISABLE_1M_CONTEXT');
113
- if (oneM && isFalseyToggle(oneM.value)) {
114
- setSafeValue('CLAUDE_CODE_DISABLE_1M_CONTEXT', '1', 'Disable Claude Code 1M context beta unless the account explicitly has Extra Usage.');
115
+ if ((oneMMode && String(oneMMode.value).toLowerCase() === 'on') || (oneM && isFalseyToggle(oneM.value))) {
116
+ setSafeValue('CLEMENTINE_1M_CONTEXT_MODE', 'off', 'Force standard 200K context until the account is confirmed stable.');
117
+ setSafeValue('CLAUDE_CODE_DISABLE_1M_CONTEXT', '1', 'Disable Claude Code 1M context for backward compatibility with older Claude Code versions.');
115
118
  }
116
119
  for (const [key, recommended] of Object.entries(SAFE_BACKGROUND_DEFAULTS)) {
117
120
  const entry = byKey.get(key);
@@ -273,16 +276,28 @@ function checkRangeSanity(cfg, findings) {
273
276
  function checkOperationalOverrides(cfg, baseDir, findings) {
274
277
  const byKey = new Map(cfg.entries.map(e => [e.key, e]));
275
278
  const persistedEnv = readEnvValues(baseDir);
279
+ const oneMMode = byKey.get('CLEMENTINE_1M_CONTEXT_MODE');
280
+ if (oneMMode && oneMMode.source !== 'default' && String(oneMMode.value).toLowerCase() === 'on') {
281
+ findings.push({
282
+ severity: 'warning',
283
+ key: 'CLEMENTINE_1M_CONTEXT_MODE',
284
+ message: `1M context is forced on from ${oneMMode.source}. Sonnet 1M requires Claude Extra Usage, and Pro subscriptions require Extra Usage for both Sonnet and Opus 1M.`,
285
+ fix: 'clementine budgets 1m auto # smart mode, or clementine budgets safe for recovery',
286
+ });
287
+ }
276
288
  const oneM = byKey.get('CLAUDE_CODE_DISABLE_1M_CONTEXT');
277
- if (oneM && oneM.source !== 'default' && isFalseyToggle(oneM.value)) {
289
+ if (oneM
290
+ && oneM.source !== 'default'
291
+ && isFalseyToggle(oneM.value)
292
+ && (!oneMMode || oneMMode.source === 'default')) {
278
293
  const source = oneM.source === 'process.env' && persistedEnv.CLAUDE_CODE_DISABLE_1M_CONTEXT !== undefined
279
294
  ? '.env'
280
295
  : oneM.source;
281
296
  findings.push({
282
297
  severity: 'warning',
283
298
  key: 'CLAUDE_CODE_DISABLE_1M_CONTEXT',
284
- message: `1M context is explicitly enabled from ${source}. Accounts without Claude Extra Usage will fail every call with "Extra usage is required for 1M context."`,
285
- fix: 'clementine config doctor --fix # sets CLAUDE_CODE_DISABLE_1M_CONTEXT=1',
299
+ message: `Legacy 1M context is explicitly enabled from ${source}. Sonnet 1M requires Extra Usage and can fail calls with "Extra usage is required for 1M context."`,
300
+ fix: 'clementine budgets 1m auto # smart mode, or clementine budgets safe for recovery',
286
301
  });
287
302
  }
288
303
  for (const [key, recommended] of Object.entries(SAFE_BACKGROUND_DEFAULTS)) {
@@ -30,7 +30,8 @@ const SPECS = [
30
30
  { key: 'HAIKU_MODEL', group: 'models', jsonPath: 'models.haiku', default: 'claude-haiku-4-5-20251001' },
31
31
  { key: 'SONNET_MODEL', group: 'models', jsonPath: 'models.sonnet', default: 'claude-sonnet-4-6' },
32
32
  { key: 'OPUS_MODEL', group: 'models', jsonPath: 'models.opus', default: 'claude-opus-4-7' },
33
- { key: 'CLAUDE_CODE_DISABLE_1M_CONTEXT', group: 'models', default: true },
33
+ { key: 'CLEMENTINE_1M_CONTEXT_MODE', group: 'models', default: 'auto' },
34
+ { key: 'CLAUDE_CODE_DISABLE_1M_CONTEXT', group: 'models', default: '' },
34
35
  // Team routing
35
36
  { key: 'AUTO_DELEGATE_ENABLED', group: 'team', default: false },
36
37
  // Budgets
package/dist/config.d.ts CHANGED
@@ -11,6 +11,12 @@ export declare const PKG_DIR: string;
11
11
  /** Data home — user data, vault, .env, logs, sessions. */
12
12
  export declare const BASE_DIR: string;
13
13
  import { shellEscape as _shellEscape } from './config/env-parser.js';
14
+ export type OneMillionContextMode = 'auto' | 'off' | 'on';
15
+ export declare const CLEMENTINE_1M_CONTEXT_MODE: OneMillionContextMode;
16
+ export declare function currentOneMillionContextMode(): OneMillionContextMode;
17
+ export declare function claudeCodeDisableOneMillionForModel(model: string | null | undefined, mode?: OneMillionContextMode): '1' | '0' | undefined;
18
+ export declare function normalizeClaudeModelForOneMillionContext(model: string, mode?: OneMillionContextMode): string;
19
+ export declare function usesOneMillionContext(model: string | null | undefined, mode?: OneMillionContextMode): boolean;
14
20
  /**
15
21
  * Look up a config value: local .env first, then process.env fallback.
16
22
  * Keychain refs in either source are resolved lazily; failed resolution
package/dist/config.js CHANGED
@@ -27,31 +27,84 @@ function readEnvFile() {
27
27
  return parseEnvText(readFileSync(envPath, 'utf-8'));
28
28
  }
29
29
  const env = readEnvFile();
30
- // ── Claude Code CLI runtime env propagation ─────────────────────────
31
- //
32
- // The Claude Code CLI binary bundled inside @anthropic-ai/claude-agent-sdk
33
- // auto-attaches the `context-1m-2025-08-07` beta header for any model that
34
- // supports a 1M context (Sonnet 4.6, Opus 4.6/4.7, etc.). On accounts
35
- // without the "extra usage" entitlement, every API call then fails with
36
- // "Extra usage is required for 1M context."
37
- //
38
- // We don't ask for 1M anywhere in our code, but the CLI does on its own.
39
- // The CLI honors CLAUDE_CODE_DISABLE_1M_CONTEXT — when truthy, the auto-
40
- // enable path is skipped and the standard 200K context is used.
41
- //
42
- // Default to disabled here so users without extra usage stop hitting the
43
- // gate. Anyone who has paid for / been entitled to 1M can opt back in by
44
- // setting CLAUDE_CODE_DISABLE_1M_CONTEXT=0 in their .env.
45
- {
46
- const userPref = env['CLAUDE_CODE_DISABLE_1M_CONTEXT'] ?? process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT;
47
- if (userPref === undefined || userPref === '') {
48
- process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
49
- }
50
- else {
51
- // Propagate the user's explicit choice from .env into process.env so the
52
- // spawned CLI subprocess inherits it.
53
- process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = userPref;
54
- }
30
+ function normalizeOneMillionContextMode(value) {
31
+ const v = String(value ?? '').trim().toLowerCase();
32
+ if (!v)
33
+ return null;
34
+ if (v === 'auto')
35
+ return 'auto';
36
+ if (['off', 'disable', 'disabled', 'safe', '200k', 'standard'].includes(v))
37
+ return 'off';
38
+ if (['on', 'enable', 'enabled', 'yes', 'true', '1'].includes(v))
39
+ return 'on';
40
+ return null;
41
+ }
42
+ function legacyDisableToOneMillionContextMode(value) {
43
+ const v = String(value ?? '').trim().toLowerCase();
44
+ if (!v)
45
+ return null;
46
+ if (['1', 'true', 'yes', 'on'].includes(v))
47
+ return 'off';
48
+ if (['0', 'false', 'no', 'off'].includes(v))
49
+ return 'on';
50
+ return null;
51
+ }
52
+ const oneMillionModePref = env['CLEMENTINE_1M_CONTEXT_MODE'] ?? process.env.CLEMENTINE_1M_CONTEXT_MODE;
53
+ const legacyOneMillionPref = env['CLAUDE_CODE_DISABLE_1M_CONTEXT'] ?? process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT;
54
+ export const CLEMENTINE_1M_CONTEXT_MODE = normalizeOneMillionContextMode(oneMillionModePref)
55
+ ?? legacyDisableToOneMillionContextMode(legacyOneMillionPref)
56
+ ?? 'auto';
57
+ if (CLEMENTINE_1M_CONTEXT_MODE === 'off') {
58
+ process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '1';
59
+ }
60
+ else if (CLEMENTINE_1M_CONTEXT_MODE === 'on') {
61
+ process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT = '0';
62
+ }
63
+ else if (oneMillionModePref !== undefined) {
64
+ // Explicit auto mode should override stale shell/package env inherited from
65
+ // older installs. Per-query SDK options decide whether to disable 1M.
66
+ delete process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT;
67
+ }
68
+ function modelFamily(model) {
69
+ const m = String(model ?? '').toLowerCase();
70
+ if (m.includes('opusplan'))
71
+ return 'opusplan';
72
+ if (m.includes('opus'))
73
+ return 'opus';
74
+ if (m.includes('sonnet'))
75
+ return 'sonnet';
76
+ if (m.includes('haiku'))
77
+ return 'haiku';
78
+ return 'other';
79
+ }
80
+ export function currentOneMillionContextMode() {
81
+ return normalizeOneMillionContextMode(process.env.CLEMENTINE_1M_CONTEXT_MODE)
82
+ ?? CLEMENTINE_1M_CONTEXT_MODE;
83
+ }
84
+ export function claudeCodeDisableOneMillionForModel(model, mode = currentOneMillionContextMode()) {
85
+ if (mode === 'off')
86
+ return '1';
87
+ if (mode === 'on')
88
+ return '0';
89
+ return modelFamily(model) === 'opus' ? undefined : '1';
90
+ }
91
+ export function normalizeClaudeModelForOneMillionContext(model, mode = currentOneMillionContextMode()) {
92
+ if (mode === 'on')
93
+ return model;
94
+ const family = modelFamily(model);
95
+ const shouldStrip = mode === 'off'
96
+ || family === 'sonnet'
97
+ || family === 'haiku'
98
+ || family === 'opusplan';
99
+ return shouldStrip ? model.replace(/\[1m\]/ig, '') : model;
100
+ }
101
+ export function usesOneMillionContext(model, mode = currentOneMillionContextMode()) {
102
+ if (mode === 'off')
103
+ return false;
104
+ const family = modelFamily(model);
105
+ if (mode === 'on')
106
+ return family === 'opus' || family === 'sonnet';
107
+ return family === 'opus';
55
108
  }
56
109
  // ── Keychain-ref resolution (lazy, cached) ──────────────────────────
57
110
  //
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.14",
3
+ "version": "1.18.16",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",