clementine-agent 1.18.177 → 1.18.179

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.
@@ -1,9 +1,26 @@
1
+ /**
2
+ * Explicit background-intent detector.
3
+ *
4
+ * Returns a recommendation ONLY when the user explicitly asks for background
5
+ * / autonomous / overnight execution. We deliberately do not classify "this
6
+ * looks complex" anymore — chat now stays in the live SDK loop, with
7
+ * automatic compaction and inline subagent delegation (Agent → planner /
8
+ * researcher / etc.) for context isolation, just like Claude Code itself.
9
+ * Big work that genuinely blows past the SDK's auto-compact is caught by the
10
+ * gateway's overflow → retry → promote-to-background fallback, which is the
11
+ * *real* escape hatch instead of a regex pre-classifier.
12
+ *
13
+ * The narrow detection here is what lets a user say "go research this
14
+ * overnight" and have it actually queue as a durable background task.
15
+ */
1
16
  export interface ComplexTaskRecommendation {
2
- score: number;
3
17
  reasons: string[];
4
18
  suggestedMaxMinutes: number;
5
19
  plan: string[];
6
- queueImmediately: boolean;
20
+ /** Always true when this function returns a recommendation — the only
21
+ * trigger is the user explicitly asking for background execution. Kept
22
+ * on the type for back-compat with the post-overflow rescue path. */
23
+ queueImmediately: true;
7
24
  }
8
25
  export declare function detectComplexTaskForBackground(text: string): ComplexTaskRecommendation | null;
9
26
  //# sourceMappingURL=complex-task-detector.d.ts.map
@@ -1,32 +1,18 @@
1
+ // Skill authoring is an interactive build-with-the-user flow; never auto-queue.
1
2
  const SKILL_AUTHORING_RE = /\b(create|make|build|draft|write|teach|save|update)\b.{0,40}\b(skill|SKILL\.md)\b|\bskill[- ]creator\b/i;
2
- const EXPLICIT_BACKGROUND_RE = /\b(background|deep mode|keep working|don't stop|dont stop|autonomous|long[- ]running|run overnight|take your time)\b/i;
3
- const COMPLEX_WORK_RE = /\b(audit|research|analy[sz]e|review|scrape|crawl|extract|enrich|compile|compare|verify|cross[- ]check|triage|reconcile|draft|generate|update|sync|report back|write back)\b/i;
4
- const BATCH_RE = /\b(all|every|each|bulk|batch|list of|contacts?|leads?|accounts?|tasks?|tickets?|records?|rows?|pages?|repos?|projects?)\b/i;
5
- const SIDE_EFFECT_RE = /\b(update|write|create|draft|send|post|comment|reply|upload|append|sync|mark|close|move)\b/i;
6
- const MULTI_STEP_RE = /\b(and then|then|after that|finally|from .* to |against .* and |across|compile .* into|check .* then)\b/i;
3
+ // The ONLY trigger. Matches "in the background", "overnight", "keep working",
4
+ // "don't stop", "autonomous", "long-running", "take your time", "deep mode".
5
+ const EXPLICIT_BACKGROUND_RE = /\b(background|deep mode|keep working|don't stop|dont stop|autonomous|long[- ]running|run overnight|overnight|take your time)\b/i;
6
+ // Light scope hints used only for the duration estimate + plan text. None of
7
+ // these alter whether the function fires they shape the recommendation
8
+ // once the explicit-intent gate has already opened.
9
+ const BATCH_RE = /\b(all|every|each|bulk|batch|list of|contacts?|leads?|accounts?|tasks?|tickets?|records?|rows?|pages?|repos?|projects?|firms?|metros?|prospects?)\b/i;
10
+ const SIDE_EFFECT_RE = /\b(update|write|create|draft|send|post|comment|reply|upload|append|sync|mark|close|move|deploy|host|publish)\b/i;
7
11
  const SYSTEM_KEYWORDS = [
8
- 'asana',
9
- 'salesforce',
10
- 'google sheet',
11
- 'google sheets',
12
- 'sheet',
13
- 'sheets',
14
- 'dataforseo',
15
- 'hubspot',
16
- 'notion',
17
- 'github',
18
- 'gmail',
19
- 'outlook',
20
- 'slack',
21
- 'discord',
22
- 'website',
23
- 'websites',
24
- 'crm',
25
- 'spreadsheet',
26
- 'csv',
27
- 'airtable',
28
- 'linear',
29
- 'jira',
12
+ 'asana', 'salesforce', 'google sheet', 'google sheets', 'sheet', 'sheets',
13
+ 'dataforseo', 'hubspot', 'notion', 'github', 'gmail', 'outlook', 'slack',
14
+ 'discord', 'website', 'websites', 'crm', 'spreadsheet', 'csv', 'netlify',
15
+ 'vercel', 'airtable', 'linear', 'jira',
30
16
  ];
31
17
  function countSystemMentions(text) {
32
18
  const lower = text.toLowerCase();
@@ -37,10 +23,10 @@ function countSystemMentions(text) {
37
23
  }
38
24
  return count;
39
25
  }
40
- function estimatedMinutes(score, systemCount) {
41
- if (score >= 8 || systemCount >= 4)
26
+ function estimatedMinutes(systemCount, textLength) {
27
+ if (systemCount >= 4 || textLength > 800)
42
28
  return 90;
43
- if (score >= 6 || systemCount >= 3)
29
+ if (systemCount >= 2 || textLength > 400)
44
30
  return 60;
45
31
  return 30;
46
32
  }
@@ -72,47 +58,17 @@ export function detectComplexTaskForBackground(text) {
72
58
  return null;
73
59
  if (SKILL_AUTHORING_RE.test(trimmed))
74
60
  return null;
75
- const systemCount = countSystemMentions(trimmed);
76
- const reasons = [];
77
- let score = 0;
78
- if (EXPLICIT_BACKGROUND_RE.test(trimmed)) {
79
- score += 4;
80
- reasons.push('explicit background/deep-work wording');
81
- }
82
- if (COMPLEX_WORK_RE.test(trimmed)) {
83
- score += 2;
84
- reasons.push('multi-step work verb');
85
- }
86
- if (BATCH_RE.test(trimmed)) {
87
- score += 2;
88
- reasons.push('batch or many-record scope');
89
- }
90
- if (SIDE_EFFECT_RE.test(trimmed)) {
91
- score += 1;
92
- reasons.push('write/draft/update side effects');
93
- }
94
- if (MULTI_STEP_RE.test(trimmed)) {
95
- score += 1;
96
- reasons.push('multi-step sequencing');
97
- }
98
- if (systemCount >= 2) {
99
- score += Math.min(4, systemCount);
100
- reasons.push(`${systemCount} named systems or data surfaces`);
101
- }
102
- if (trimmed.length > 450) {
103
- score += 1;
104
- reasons.push('long detailed request');
105
- }
106
- const queueImmediately = EXPLICIT_BACKGROUND_RE.test(trimmed) && score >= 5;
107
- const shouldOffer = queueImmediately || score >= 5 || (systemCount >= 2 && (BATCH_RE.test(trimmed) || SIDE_EFFECT_RE.test(trimmed)));
108
- if (!shouldOffer)
61
+ if (!EXPLICIT_BACKGROUND_RE.test(trimmed))
109
62
  return null;
63
+ const systemCount = countSystemMentions(trimmed);
64
+ const reasons = ['explicit background/deep-work wording'];
65
+ if (systemCount >= 2)
66
+ reasons.push(`${systemCount} named systems`);
110
67
  return {
111
- score,
112
68
  reasons,
113
- suggestedMaxMinutes: estimatedMinutes(score, systemCount),
69
+ suggestedMaxMinutes: estimatedMinutes(systemCount, trimmed.length),
114
70
  plan: buildPlan(trimmed, systemCount),
115
- queueImmediately,
71
+ queueImmediately: true,
116
72
  };
117
73
  }
118
74
  //# sourceMappingURL=complex-task-detector.js.map
@@ -26,6 +26,31 @@ const CRON_PROGRESS_PENDING_MAX_ITEMS = 20;
26
26
  const CRON_PROGRESS_NOTES_MAX_CHARS = 2000;
27
27
  const logger = pino({ name: 'clementine.run-agent-cron' });
28
28
  const CRON_CONTEXT_ITEM_MAX = 80;
29
+ const CLEMENTINE_TOOLS_SERVER = `${(process.env.ASSISTANT_NAME ?? 'Clementine').toLowerCase()}-tools`;
30
+ const BACKGROUND_TASK_WORKER_NAME = 'background-task-worker';
31
+ const DEFAULT_BACKGROUND_WORKER_TOOLS = [
32
+ 'Agent',
33
+ 'Read',
34
+ 'Write',
35
+ 'Edit',
36
+ 'Glob',
37
+ 'Grep',
38
+ 'Bash',
39
+ 'WebSearch',
40
+ 'WebFetch',
41
+ 'TodoWrite',
42
+ ];
43
+ const BACKGROUND_TASK_WORKER_PROMPT = [
44
+ 'You are Clementine\'s background task worker for long-running user requests.',
45
+ '',
46
+ 'Run the assigned task to completion using the available tools. Keep raw API responses, scraped pages, and large file contents out of the final answer; extract the fields you need and continue.',
47
+ '',
48
+ 'Use TodoWrite for multi-step state. Process batch work in bounded chunks, checkpoint meaningful progress in durable artifacts when useful, and avoid repeating the same expensive read or tool call.',
49
+ '',
50
+ 'If credentials, missing scope, human approval, or an irreversible action blocks completion, stop with one concise blocker/question and the exact next action needed. Do not keep retrying blindly.',
51
+ '',
52
+ 'Return only the final user-facing result: links or changed locations, counts, skipped/error records, and the next recommended action.',
53
+ ].join('\n');
29
54
  /** Total number of skill blocks injected into a cron prompt — pinned + auto. */
30
55
  const MAX_INJECTED_SKILLS = 4;
31
56
  /**
@@ -172,6 +197,27 @@ function capContextBlock(s, max) {
172
197
  return '';
173
198
  return s.length <= max ? s : s.slice(0, max - 3) + '...';
174
199
  }
200
+ function backgroundWorkerTools(effectiveAllowedTools, mcpServersApplied) {
201
+ if (effectiveAllowedTools)
202
+ return [...new Set(['Agent', ...effectiveAllowedTools])];
203
+ const mcpWildcards = [CLEMENTINE_TOOLS_SERVER, ...mcpServersApplied]
204
+ .filter(Boolean)
205
+ .map((server) => `mcp__${server}__*`);
206
+ return [...new Set([...DEFAULT_BACKGROUND_WORKER_TOOLS, ...mcpWildcards])];
207
+ }
208
+ function buildBackgroundTaskWorker(tools, model, maxTurns) {
209
+ return {
210
+ description: [
211
+ 'Use for background tasks queued from chat, especially multi-step work, batch data collection, project builds, deployments, and external-system writebacks.',
212
+ 'This agent owns the heavy tool loop so the parent task context stays small.',
213
+ ].join(' '),
214
+ prompt: BACKGROUND_TASK_WORKER_PROMPT,
215
+ tools,
216
+ ...(model ? { model } : { model: 'sonnet' }),
217
+ effort: 'medium',
218
+ maxTurns: typeof maxTurns === 'number' && maxTurns > 0 ? maxTurns : 40,
219
+ };
220
+ }
175
221
  /**
176
222
  * Build the previous-progress block from the cron progress JSON file.
177
223
  * Lets the agent continue where the prior run left off without re-doing
@@ -749,10 +795,18 @@ export async function runAgentCron(opts) {
749
795
  skillsMissing: plan.skillsMissing.length,
750
796
  trickAllowedTools: effectiveAllowedTools?.length,
751
797
  trickAllowedMcp: opts.allowedMcpServers?.length,
798
+ forcedBackgroundWorker: opts.jobName.startsWith('bg:'),
752
799
  widenedFromSkills: plan.widenedFromSkills,
753
800
  ...(promptOversized ? { warning: 'prompt > 50KB; risk of "Prompt is too long" failure' } : {}),
754
801
  }, 'runAgentCron: dispatching to runAgent');
755
802
  const startedAt = Date.now();
803
+ const forceBackgroundWorker = opts.jobName.startsWith('bg:');
804
+ const workerTools = forceBackgroundWorker
805
+ ? backgroundWorkerTools(effectiveAllowedTools, mcpServersApplied)
806
+ : [];
807
+ const workerDef = forceBackgroundWorker
808
+ ? buildBackgroundTaskWorker(workerTools, opts.model, opts.maxTurns)
809
+ : null;
756
810
  const result = await runAgent(builtPrompt, {
757
811
  sessionKey: `cron:${opts.jobName}`,
758
812
  source: 'cron',
@@ -762,9 +816,16 @@ export async function runAgentCron(opts) {
762
816
  model: opts.model,
763
817
  effort,
764
818
  ...(maxBudget !== undefined ? { maxBudgetUsd: maxBudget } : {}),
765
- maxTurns: opts.maxTurns,
819
+ maxTurns: forceBackgroundWorker ? 5 : opts.maxTurns,
766
820
  abortSignal: opts.abortSignal,
767
- ...(effectiveAllowedTools ? { allowedTools: effectiveAllowedTools } : {}),
821
+ ...(forceBackgroundWorker
822
+ ? {
823
+ allowedTools: ['Agent'],
824
+ permissionTools: workerTools,
825
+ forceSubagent: BACKGROUND_TASK_WORKER_NAME,
826
+ agents: { [BACKGROUND_TASK_WORKER_NAME]: workerDef },
827
+ }
828
+ : (effectiveAllowedTools ? { allowedTools: effectiveAllowedTools } : {})),
768
829
  extraMcpServers: mcpServerMap,
769
830
  // 1.18.121 — pipe the merged addDirs+pinned-skill folders to the SDK
770
831
  // so a skill's bundled scripts/templates are reachable via Bash/Read
@@ -16,6 +16,7 @@
16
16
  * long-task preflight, NO mode=unleashed wrapper.
17
17
  */
18
18
  import { type AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
19
+ import type { TerminalReason } from '../types.js';
19
20
  /** Read the latest MCP status snapshot. Safe to call from any module. */
20
21
  export declare function getLatestMcpStatusSnapshot(): {
21
22
  servers: Array<{
@@ -26,6 +27,10 @@ export declare function getLatestMcpStatusSnapshot(): {
26
27
  };
27
28
  /** Write a fresh snapshot. Called from system/init handlers. */
28
29
  export declare function recordMcpStatusFromSystemInit(rawMcpServers: unknown): void;
30
+ /** True when the SDK emits an internal context-pressure diagnostic as an
31
+ * assistant text block. These are operational warnings, not useful user
32
+ * output, and they can appear while the run is still recovering/continuing. */
33
+ export declare function isSdkContextDiagnosticText(text: string): boolean;
29
34
  /** Drop one server from the cache so the next query repopulates it. */
30
35
  export declare function invalidateMcpStatusEntry(name: string): {
31
36
  cleared: boolean;
@@ -133,6 +138,8 @@ export interface RunAgentResult {
133
138
  sessionId: string;
134
139
  /** Final stop reason from the SDK (success, error_max_turns, error_max_budget_usd, etc). */
135
140
  subtype: string;
141
+ /** Precise SDK loop terminal reason, when available. */
142
+ terminalReason?: TerminalReason;
136
143
  /** Token usage breakdown (input, output, cache). */
137
144
  usage?: {
138
145
  input_tokens?: number;
@@ -75,6 +75,16 @@ function truncateForLog(value, maxBytes) {
75
75
  return { _unstringifiable: true };
76
76
  }
77
77
  }
78
+ /** True when the SDK emits an internal context-pressure diagnostic as an
79
+ * assistant text block. These are operational warnings, not useful user
80
+ * output, and they can appear while the run is still recovering/continuing. */
81
+ export function isSdkContextDiagnosticText(text) {
82
+ const t = text.trim();
83
+ if (!t)
84
+ return false;
85
+ return /^Autocompact is thrashing:\s*the context refilled to the limit/i.test(t)
86
+ || /^rapid_refill_breaker\b/i.test(t);
87
+ }
78
88
  /** Drop one server from the cache so the next query repopulates it. */
79
89
  export function invalidateMcpStatusEntry(name) {
80
90
  const before = _lastMcpStatusSnapshot.servers.length;
@@ -483,6 +493,7 @@ export async function runAgent(prompt, opts) {
483
493
  let totalCostUsd = 0;
484
494
  let numTurns = 0;
485
495
  let subtype = 'unknown';
496
+ let terminalReason;
486
497
  let usage;
487
498
  const stream = query({ prompt: effectivePrompt, options: sdkOptions });
488
499
  try {
@@ -538,6 +549,15 @@ export async function runAgent(prompt, opts) {
538
549
  const blocks = (am.message?.content ?? []);
539
550
  for (const block of blocks) {
540
551
  if (block.type === 'text' && typeof block.text === 'string') {
552
+ if (isSdkContextDiagnosticText(block.text)) {
553
+ logger.warn({
554
+ sessionKey: opts.sessionKey,
555
+ source,
556
+ subtype,
557
+ preview: block.text.slice(0, 240),
558
+ }, 'runAgent: suppressed SDK context diagnostic text');
559
+ continue;
560
+ }
541
561
  finalText += block.text;
542
562
  // PRD Phase 4a / 1.18.85: llm_text Event. Truncate at 8KB to keep
543
563
  // the JSONL light — full text is reachable via the SDK transcript.
@@ -611,6 +631,7 @@ export async function runAgent(prompt, opts) {
611
631
  const result = message;
612
632
  sessionId = sessionId || (result.session_id ?? '');
613
633
  subtype = result.subtype ?? 'unknown';
634
+ terminalReason = result.terminal_reason;
614
635
  numTurns = result.num_turns ?? numTurns;
615
636
  totalCostUsd = result.total_cost_usd ?? 0;
616
637
  const u = result.usage;
@@ -629,7 +650,7 @@ export async function runAgent(prompt, opts) {
629
650
  ts: new Date().toISOString(),
630
651
  sessionId,
631
652
  costUsd: totalCostUsd,
632
- stopReason: subtype,
653
+ stopReason: terminalReason && terminalReason !== 'completed' ? `${subtype}:${terminalReason}` : subtype,
633
654
  });
634
655
  // PRD Phase 4d / 1.18.101: unregister from the hook-session registry.
635
656
  // Late-arriving hook events for this sessionId silently drop after this.
@@ -758,6 +779,7 @@ export async function runAgent(prompt, opts) {
758
779
  numTurns,
759
780
  sessionId,
760
781
  subtype,
782
+ ...(terminalReason ? { terminalReason } : {}),
761
783
  ...(usage ? { usage } : {}),
762
784
  runId,
763
785
  permissionMode: toolPolicy.permissionMode,
@@ -19,7 +19,7 @@ import { TunnelManager } from './tunnel.js';
19
19
  import { AgentManager } from '../agent/agent-manager.js';
20
20
  import { discoverMcpServers, getClaudeIntegrations, KNOWN_MCP_DESCRIPTIONS } from '../agent/mcp-bridge.js';
21
21
  import { buildBuilderEnrichedMessage, builderSessionKey } from '../dashboard/builder/prompt.js';
22
- import { AGENTS_DIR, MEMORY_FILE, SESSIONS_FILE, TIMEZONE, applyOneMillionContextRecovery, currentTimeZone, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
22
+ import { AGENTS_DIR, MEMORY_FILE, SESSIONS_FILE, TIMEZONE, applyOneMillionContextRecovery, currentTimeZone, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, setEnvOverride, } from '../config.js';
23
23
  import { parseTasks } from '../tools/shared.js';
24
24
  // 1.18.160 — also pull parseCronJobs + parseAgentCronJobs so getCronJobs()
25
25
  // returns the same merged set the runtime fires (CRON.md + agent CRON +
@@ -8724,6 +8724,11 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
8724
8724
  content = content.trimEnd() + `\n${key}=${value}\n`;
8725
8725
  }
8726
8726
  writeFileSync(ENV_PATH, content, { mode: 0o600 });
8727
+ // Always mirror the disk write into the live env cache. Without this,
8728
+ // BUDGET.* and any other getEnv-backed config stays at the value it
8729
+ // was first read with — that's how "Saved $0 in the dashboard" can
8730
+ // coexist with "Hit the $1.00 cron budget cap" in the same minute.
8731
+ setEnvOverride(key, value);
8727
8732
  }
8728
8733
  function deleteEnvValue(key) {
8729
8734
  if (!existsSync(ENV_PATH))
@@ -8731,6 +8736,9 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
8731
8736
  const re = new RegExp(`^${key}=.*\n?`, 'm');
8732
8737
  const content = readFileSync(ENV_PATH, 'utf-8').replace(re, '');
8733
8738
  writeFileSync(ENV_PATH, content, { mode: 0o600 });
8739
+ // Mirror the delete so live readers don't keep seeing the cached value.
8740
+ setEnvOverride(key, '');
8741
+ delete process.env[key];
8734
8742
  }
8735
8743
  const DASHBOARD_BUDGET_ROWS = [
8736
8744
  { key: 'BUDGET_CHAT_USD', value: '5', label: 'Chat', hint: 'Per interactive chat turn' },
@@ -8786,8 +8794,9 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
8786
8794
  return { ok: false, error: 'Budget cap is too high for the dashboard. Use the CLI if you really need a cap above $1000.' };
8787
8795
  }
8788
8796
  const normalized = n === 0 ? '0' : String(Math.round(n * 100) / 100);
8797
+ // `writeEnvValue` mirrors into the live env cache, so BUDGET.* (now
8798
+ // backed by getters) sees the new value on the very next tool call.
8789
8799
  writeEnvValue(key, normalized);
8790
- process.env[key] = normalized;
8791
8800
  return { ok: true, value: normalized };
8792
8801
  }
8793
8802
  function readRecentDashboardChatFailures(limit = 5) {
@@ -9046,7 +9055,7 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
9046
9055
  }
9047
9056
  res.json({
9048
9057
  ok: true,
9049
- message: `${key} set to ${formatDashboardBudgetValue(result.value)}. Restart Clementine to apply to running workers.`,
9058
+ message: `${key} set to ${formatDashboardBudgetValue(result.value)}. Applied to running workers immediately.`,
9050
9059
  });
9051
9060
  }
9052
9061
  catch (err) {
@@ -9060,11 +9069,11 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
9060
9069
  let message;
9061
9070
  if (preset === 'defaults' || preset === 'standard') {
9062
9071
  writes = DASHBOARD_BUDGET_ROWS.map(row => ({ key: row.key, value: row.value }));
9063
- message = 'Restored the standard spend caps. Restart Clementine to apply to running workers.';
9072
+ message = 'Restored the standard spend caps. Applied to running workers immediately.';
9064
9073
  }
9065
9074
  else if (preset === 'uncapped' || preset === 'off' || preset === 'none') {
9066
9075
  writes = DASHBOARD_BUDGET_ROWS.map(row => ({ key: row.key, value: '0' }));
9067
- message = 'Removed spend caps by setting all budget values to 0. Restart Clementine for the change to take effect on running workers. (1M context mode is separate — use Force 200K or Safe Recovery for 1M errors.)';
9076
+ message = 'Removed spend caps by setting all budget values to 0. Applied to running workers immediately. (1M context mode is separate — use Force 200K or Safe Recovery for 1M errors.)';
9068
9077
  }
9069
9078
  else {
9070
9079
  res.status(400).json({ error: 'preset must be defaults or uncapped' });
package/dist/config.d.ts CHANGED
@@ -40,6 +40,17 @@ export declare function usesOneMillionContext(model: string | null | undefined,
40
40
  export declare function getEnv(key: string, fallback?: string): string;
41
41
  /** Merged view of process.env overlaid with .env. Use for classifyIntegrations / summarizeIntegrationStatus. */
42
42
  export declare function envSnapshot(): Record<string, string | undefined>;
43
+ /**
44
+ * Hot-update a config value at runtime. Call this from any code path that
45
+ * persists a config change (e.g. dashboard `/api/budgets/set`) so the
46
+ * in-module `env` cache stays in sync with what's on disk + in process.env.
47
+ *
48
+ * Without this, `getEnv` keeps returning the value that was read from .env
49
+ * at module init and frozen objects like BUDGET stay stale until the
50
+ * daemon restarts — that's how a "Budgets at zero in the dashboard" UI can
51
+ * coexist with a `Hit the $1.00 cron budget cap` error on the same minute.
52
+ */
53
+ export declare function setEnvOverride(key: string, value: string): void;
43
54
  /** Test-only: clear the keychain ref cache so re-resolution can be tested. */
44
55
  export declare function _resetKeychainRefCache(): void;
45
56
  /**
@@ -83,14 +94,14 @@ export declare const ASSISTANT_EXPERIENCE: {
83
94
  export declare const shellEscape: typeof _shellEscape;
84
95
  export declare const MODELS: Models;
85
96
  export declare const BUDGET: {
86
- heartbeat: number;
87
- cronT1: number;
88
- cronT2: number;
89
- chat: number;
90
- unleashedPhase: undefined;
91
- memoryExtraction: undefined;
92
- summarization: undefined;
93
- reflection: undefined;
97
+ readonly heartbeat: number;
98
+ readonly cronT1: number;
99
+ readonly cronT2: number;
100
+ readonly chat: number;
101
+ readonly unleashedPhase: number | undefined;
102
+ readonly memoryExtraction: number | undefined;
103
+ readonly summarization: number | undefined;
104
+ readonly reflection: number | undefined;
94
105
  };
95
106
  export declare const MEMORY_JANITOR: {
96
107
  consolidatedExpireDays: number;
package/dist/config.js CHANGED
@@ -285,6 +285,20 @@ export function getEnv(key, fallback = '') {
285
285
  export function envSnapshot() {
286
286
  return { ...process.env, ...env };
287
287
  }
288
+ /**
289
+ * Hot-update a config value at runtime. Call this from any code path that
290
+ * persists a config change (e.g. dashboard `/api/budgets/set`) so the
291
+ * in-module `env` cache stays in sync with what's on disk + in process.env.
292
+ *
293
+ * Without this, `getEnv` keeps returning the value that was read from .env
294
+ * at module init and frozen objects like BUDGET stay stale until the
295
+ * daemon restarts — that's how a "Budgets at zero in the dashboard" UI can
296
+ * coexist with a `Hit the $1.00 cron budget cap` error on the same minute.
297
+ */
298
+ export function setEnvOverride(key, value) {
299
+ env[key] = value;
300
+ process.env[key] = value;
301
+ }
288
302
  /** Test-only: clear the keychain ref cache so re-resolution can be tested. */
289
303
  export function _resetKeychainRefCache() {
290
304
  resolvedKeychainRefs.clear();
@@ -379,11 +393,14 @@ export const MODELS = {
379
393
  // User-tunable via `clementine config set BUDGET_<NAME>_USD <value>`
380
394
  // (writes to ~/.clementine/.env, survives npm update -g) or via
381
395
  // `budgets.*` keys in clementine.json.
396
+ // Live getters — each property re-reads .env + process.env on access so a
397
+ // dashboard write (via setEnvOverride) takes effect on the *next* tool call
398
+ // without needing a daemon restart. Defaults match the previous fixed values.
382
399
  export const BUDGET = {
383
- heartbeat: getEnvOrJsonNumber('BUDGET_HEARTBEAT_USD', json.budgets?.heartbeat, 0.25), // per heartbeat (Haiku)
384
- cronT1: getEnvOrJsonNumber('BUDGET_CRON_T1_USD', json.budgets?.cronT1, 0.75), // per tier-1 cron job
385
- cronT2: getEnvOrJsonNumber('BUDGET_CRON_T2_USD', json.budgets?.cronT2, 1.50), // per tier-2 cron job
386
- chat: getEnvOrJsonNumber('BUDGET_CHAT_USD', json.budgets?.chat, 5.00), // per interactive chat
400
+ get heartbeat() { return getEnvOrJsonNumber('BUDGET_HEARTBEAT_USD', json.budgets?.heartbeat, 0.25); },
401
+ get cronT1() { return getEnvOrJsonNumber('BUDGET_CRON_T1_USD', json.budgets?.cronT1, 0.75); },
402
+ get cronT2() { return getEnvOrJsonNumber('BUDGET_CRON_T2_USD', json.budgets?.cronT2, 1.50); },
403
+ get chat() { return getEnvOrJsonNumber('BUDGET_CHAT_USD', json.budgets?.chat, 5.00); },
387
404
  unleashedPhase: undefined,
388
405
  memoryExtraction: undefined,
389
406
  summarization: undefined,
@@ -18,6 +18,11 @@ export declare function buildContextOverflowRetryPrompt(opts: {
18
18
  turnContextPrefix?: string;
19
19
  project?: ProjectMeta | null;
20
20
  }): string;
21
+ export declare function runAgentResultIndicatesContextOverflow(result: {
22
+ subtype?: string;
23
+ terminalReason?: string;
24
+ text?: string;
25
+ }): boolean;
21
26
  export type ChatErrorKind = 'rate_limit' | 'one_million_context' | 'context_overflow' | 'auth' | 'billing' | 'transient' | 'unknown';
22
27
  export declare function classifyChatError(err: unknown): ChatErrorKind;
23
28
  /** Detect auth-like errors in response text that the SDK returned as "successful" results. */
@@ -64,13 +69,14 @@ export declare class Gateway {
64
69
  private extractBackgroundTaskId;
65
70
  private makeBackgroundOfferId;
66
71
  private backgroundAgentForSession;
72
+ private buildBackgroundTaskPrompt;
67
73
  private pruneExpiredBackgroundOffers;
68
74
  private latestBackgroundOfferForSession;
69
75
  private getBackgroundOfferForSession;
70
76
  private createBackgroundOffer;
71
77
  private queueBackgroundOffer;
72
78
  private formatBackgroundQueuedResponse;
73
- private formatBackgroundOfferResponse;
79
+ private queueBackgroundTaskAfterContextOverflow;
74
80
  acceptBackgroundOffer(sessionKey: string, id: string): {
75
81
  ok: boolean;
76
82
  response: string;
@@ -96,6 +96,17 @@ export function buildContextOverflowRetryPrompt(opts) {
96
96
  parts.push(opts.chatPrompt);
97
97
  return parts.filter(Boolean).join('\n\n');
98
98
  }
99
+ export function runAgentResultIndicatesContextOverflow(result) {
100
+ const terminalReason = (result.terminalReason ?? '').trim();
101
+ if (terminalReason && classifyChatError(terminalReason) === 'context_overflow')
102
+ return true;
103
+ const subtype = (result.subtype ?? '').trim();
104
+ if (subtype && subtype !== 'success' && classifyChatError(subtype) === 'context_overflow')
105
+ return true;
106
+ const text = (result.text ?? '').trim();
107
+ return /^Autocompact is thrashing:\s*the context refilled to the limit/i.test(text)
108
+ || /^rapid_refill_breaker\b/i.test(text);
109
+ }
99
110
  export function classifyChatError(err) {
100
111
  const msg = String(err);
101
112
  if (isCreditBalanceError(msg))
@@ -299,6 +310,18 @@ export class Gateway {
299
310
  backgroundAgentForSession(sessionKey) {
300
311
  return this._agentSlugFromSessionKey(sessionKey) ?? this.getSessionProfile(sessionKey) ?? 'clementine';
301
312
  }
313
+ buildBackgroundTaskPrompt(sessionKey, prompt) {
314
+ const sess = this.sessions.get(sessionKey);
315
+ const parts = [
316
+ '[Background task from chat: run this in a fresh task execution. Do not rely on the live chat transcript being resumed; use the self-contained request below.]',
317
+ ];
318
+ if (sess?.project?.path) {
319
+ const description = sess.project.description ? ` (${sess.project.description})` : '';
320
+ parts.push(`[Active project: ${sess.project.path}${description}]`);
321
+ }
322
+ parts.push(prompt.trim());
323
+ return parts.filter(Boolean).join('\n\n');
324
+ }
302
325
  pruneExpiredBackgroundOffers() {
303
326
  const now = Date.now();
304
327
  for (const [id, offer] of this.pendingBackgroundOffers) {
@@ -327,6 +350,7 @@ export class Gateway {
327
350
  sessionKey,
328
351
  fromAgent: this.backgroundAgentForSession(sessionKey),
329
352
  prompt,
353
+ taskPrompt: this.buildBackgroundTaskPrompt(sessionKey, prompt),
330
354
  recommendation,
331
355
  createdAt: Date.now(),
332
356
  expiresAt: Date.now() + 30 * 60_000,
@@ -337,7 +361,7 @@ export class Gateway {
337
361
  queueBackgroundOffer(offer) {
338
362
  const task = createBackgroundTask({
339
363
  fromAgent: offer.fromAgent,
340
- prompt: offer.prompt,
364
+ prompt: offer.taskPrompt,
341
365
  maxMinutes: offer.recommendation.suggestedMaxMinutes,
342
366
  sessionKey: offer.sessionKey,
343
367
  });
@@ -355,25 +379,37 @@ export class Gateway {
355
379
  return [
356
380
  `Queued background task **${task.id}**.`,
357
381
  '',
358
- `It will run as **${task.fromAgent}** with a ${task.maxMinutes} minute cap.`,
382
+ `It will run as **${task.fromAgent}** in a fresh task session with a ${task.maxMinutes} minute cap.`,
359
383
  `Use \`status ${task.id}\` or check the dashboard Background Tasks panel for progress.`,
360
384
  ].join('\n');
361
385
  }
362
- formatBackgroundOfferResponse(offer) {
363
- const lines = [
364
- 'This looks like long-running, multi-tool work. I recommend running it in the background so chat does not go stale.',
365
- '',
366
- '**Plan**',
367
- ...offer.recommendation.plan.map((step, idx) => `${idx + 1}. ${step}`),
368
- '',
369
- `**Why background:** ${offer.recommendation.reasons.join('; ')}.`,
370
- `**Estimated cap:** ${offer.recommendation.suggestedMaxMinutes} minutes.`,
371
- `**Background offer:** ${offer.id}`,
372
- '',
373
- `Reply \`run background ${offer.id}\` to queue it, \`run inline ${offer.id}\` to run it in this chat, or \`save skill ${offer.id}\` to make it reusable first.`,
374
- ];
375
- return lines.join('\n');
386
+ queueBackgroundTaskAfterContextOverflow(sessionKey, prompt) {
387
+ const recommendation = detectComplexTaskForBackground(prompt);
388
+ const task = createBackgroundTask({
389
+ fromAgent: this.backgroundAgentForSession(sessionKey),
390
+ prompt,
391
+ maxMinutes: recommendation?.suggestedMaxMinutes ?? 60,
392
+ sessionKey,
393
+ });
394
+ logger.warn({
395
+ taskId: task.id,
396
+ sessionKey,
397
+ fromAgent: task.fromAgent,
398
+ maxMinutes: task.maxMinutes,
399
+ }, 'Queued background task after repeated chat context overflow');
400
+ return {
401
+ task,
402
+ response: [
403
+ `The live chat context hit the limit, so I moved this into background task **${task.id}** and kept your request attached.`,
404
+ '',
405
+ `It will run as **${task.fromAgent}** in a fresh task session with a ${task.maxMinutes} minute cap.`,
406
+ `Use \`status ${task.id}\` or check the dashboard Background Tasks panel for progress.`,
407
+ ].join('\n'),
408
+ };
376
409
  }
410
+ // Offer-message formatter was removed in the Saturday-feel restoration —
411
+ // the chat path no longer asks "want me to run this in the background?".
412
+ // Auto-queue on explicit user intent is silent; everything else just runs.
377
413
  acceptBackgroundOffer(sessionKey, id) {
378
414
  const offer = this.getBackgroundOfferForSession(sessionKey, id);
379
415
  if (!offer) {
@@ -1918,45 +1954,38 @@ export class Gateway {
1918
1954
  || text.startsWith('[Approval:')
1919
1955
  || text.startsWith('[Reaction:')
1920
1956
  || text.startsWith('[System:');
1957
+ // ── Explicit background-intent shortcut ────────────────────────
1958
+ // Chat normally runs in-place — the SDK auto-compacts and the model
1959
+ // can spawn `planner` / `researcher` subagents for context-heavy
1960
+ // sub-steps, just like Claude Code. We only auto-queue a durable
1961
+ // background task when the user *explicitly* says "in the
1962
+ // background", "overnight", "keep working", "don't stop", etc. The
1963
+ // post-overflow rescue path below still catches the rare case
1964
+ // where chat actually drowns despite all that.
1921
1965
  if (!skipBackgroundOffer && !isBuilderSession && !isInternalMsg && this.isTrustedPersonalSession(sessionKey)) {
1922
1966
  const recommendation = detectComplexTaskForBackground(text);
1923
1967
  if (recommendation) {
1924
1968
  const offer = this.createBackgroundOffer(sessionKey, text, recommendation);
1925
- if (recommendation.queueImmediately) {
1926
- const task = this.queueBackgroundOffer(offer);
1927
- const queued = this.formatBackgroundQueuedResponse(task);
1928
- if (ledgerRunMetadata) {
1929
- ledgerRunMetadata.executionMode = 'background_queued';
1930
- ledgerRunMetadata.backgroundTaskId = task.id;
1931
- }
1932
- if (onText) {
1933
- try {
1934
- await onText(queued);
1935
- }
1936
- catch { /* channel streaming is best-effort */ }
1937
- }
1938
- this.mirrorChatExchange(sessionKey, originalText, queued, { model: 'chat-control' });
1939
- return queued;
1940
- }
1941
- const offerText = this.formatBackgroundOfferResponse(offer);
1969
+ const task = this.queueBackgroundOffer(offer);
1970
+ const queued = this.formatBackgroundQueuedResponse(task);
1942
1971
  if (ledgerRunMetadata) {
1943
- ledgerRunMetadata.executionMode = 'background_offer';
1972
+ ledgerRunMetadata.executionMode = 'background_queued';
1973
+ ledgerRunMetadata.backgroundTaskId = task.id;
1944
1974
  }
1945
1975
  logger.info({
1946
1976
  sessionKey,
1947
- offerId: offer.id,
1948
- score: recommendation.score,
1977
+ taskId: task.id,
1949
1978
  reasons: recommendation.reasons,
1950
1979
  maxMinutes: recommendation.suggestedMaxMinutes,
1951
- }, 'Offering background execution for complex chat request');
1980
+ }, 'Auto-queued background task on explicit user intent');
1952
1981
  if (onText) {
1953
1982
  try {
1954
- await onText(offerText);
1983
+ await onText(queued);
1955
1984
  }
1956
1985
  catch { /* channel streaming is best-effort */ }
1957
1986
  }
1958
- this.mirrorChatExchange(sessionKey, originalText, offerText, { model: 'chat-control' });
1959
- return offerText;
1987
+ this.mirrorChatExchange(sessionKey, originalText, queued, { model: 'chat-control' });
1988
+ return queued;
1960
1989
  }
1961
1990
  }
1962
1991
  if (!isInternalMsg && !sess?.profile && !text.startsWith('!') && !isStructuredWorkflowMsg && onProgress) {
@@ -2080,6 +2109,7 @@ export class Gateway {
2080
2109
  // Interrupt flag was set but no useful partial text — just clear it.
2081
2110
  delete sessState.pendingInterrupt;
2082
2111
  }
2112
+ let contextOverflowRecoveryPrompt = '';
2083
2113
  try {
2084
2114
  // ── Canonical SDK chat path (Phase 5) ────────────────────────
2085
2115
  // runAgent() owns chat. No legacy fallback — errors propagate
@@ -2201,22 +2231,18 @@ export class Gateway {
2201
2231
  },
2202
2232
  abortSignal: chatAc.signal,
2203
2233
  });
2204
- let runAgentResult;
2205
- try {
2206
- runAgentResult = await runAgent(finalPrompt, buildRunAgentChatOptions({
2207
- ...(priorSdkSessionId ? { resumeSessionId: priorSdkSessionId } : {}),
2208
- ...(chatSystemAppend ? { systemPromptAppend: chatSystemAppend } : {}),
2209
- }));
2210
- }
2211
- catch (err) {
2212
- if (chatAc.signal.aborted || classifyChatError(err) !== 'context_overflow') {
2213
- throw err;
2214
- }
2234
+ let didContextOverflowRetry = false;
2235
+ const contextOverflowAfterRetryError = () => new Error('rapid_refill_breaker after context overflow retry');
2236
+ const retryAfterContextOverflow = async () => {
2237
+ if (didContextOverflowRetry)
2238
+ throw contextOverflowAfterRetryError();
2239
+ didContextOverflowRetry = true;
2215
2240
  const retryPrompt = buildContextOverflowRetryPrompt({
2216
2241
  chatPrompt,
2217
2242
  turnContextPrefix,
2218
2243
  project: sess?.project ?? null,
2219
2244
  });
2245
+ contextOverflowRecoveryPrompt = retryPrompt;
2220
2246
  logger.info({
2221
2247
  sessionKey: effectiveSessionKey,
2222
2248
  hadResume: !!priorSdkSessionId,
@@ -2229,9 +2255,49 @@ export class Gateway {
2229
2255
  await onProgress('refreshing conversation context...').catch(() => { });
2230
2256
  }
2231
2257
  this.assistant.clearSession(effectiveSessionKey);
2232
- runAgentResult = await runAgent(retryPrompt, buildRunAgentChatOptions({
2258
+ return runAgent(retryPrompt, buildRunAgentChatOptions({
2233
2259
  ...(retrySystemAppend ? { systemPromptAppend: retrySystemAppend } : {}),
2234
2260
  }));
2261
+ };
2262
+ let runAgentResult;
2263
+ try {
2264
+ runAgentResult = await runAgent(finalPrompt, buildRunAgentChatOptions({
2265
+ ...(priorSdkSessionId ? { resumeSessionId: priorSdkSessionId } : {}),
2266
+ ...(chatSystemAppend ? { systemPromptAppend: chatSystemAppend } : {}),
2267
+ }));
2268
+ }
2269
+ catch (err) {
2270
+ if (chatAc.signal.aborted || classifyChatError(err) !== 'context_overflow') {
2271
+ throw err;
2272
+ }
2273
+ runAgentResult = await retryAfterContextOverflow();
2274
+ }
2275
+ if (!chatAc.signal.aborted && runAgentResultIndicatesContextOverflow(runAgentResult)) {
2276
+ if (didContextOverflowRetry) {
2277
+ logger.info({
2278
+ sessionKey: effectiveSessionKey,
2279
+ subtype: runAgentResult.subtype,
2280
+ terminalReason: runAgentResult.terminalReason,
2281
+ textPreview: runAgentResult.text?.slice(0, 240),
2282
+ }, 'Context overflow result after retry — queueing background task');
2283
+ throw contextOverflowAfterRetryError();
2284
+ }
2285
+ logger.info({
2286
+ sessionKey: effectiveSessionKey,
2287
+ subtype: runAgentResult.subtype,
2288
+ terminalReason: runAgentResult.terminalReason,
2289
+ textPreview: runAgentResult.text?.slice(0, 240),
2290
+ }, 'Context overflow result — retrying current message in fresh SDK session');
2291
+ runAgentResult = await retryAfterContextOverflow();
2292
+ if (runAgentResultIndicatesContextOverflow(runAgentResult)) {
2293
+ logger.info({
2294
+ sessionKey: effectiveSessionKey,
2295
+ subtype: runAgentResult.subtype,
2296
+ terminalReason: runAgentResult.terminalReason,
2297
+ textPreview: runAgentResult.text?.slice(0, 240),
2298
+ }, 'Context overflow result after retry — queueing background task');
2299
+ throw contextOverflowAfterRetryError();
2300
+ }
2235
2301
  }
2236
2302
  if (ledgerRunMetadata) {
2237
2303
  ledgerRunMetadata.runId = runAgentResult.runId;
@@ -2309,9 +2375,18 @@ export class Gateway {
2309
2375
  this.clearSession(effectiveSessionKey);
2310
2376
  return oneMillionContextRecoveryMessage();
2311
2377
  case 'context_overflow':
2312
- logger.info({ sessionKey }, 'Context overflow — rotating session');
2378
+ logger.info({ sessionKey }, 'Context overflow after retry queueing background task');
2313
2379
  this.assistant.clearSession(effectiveSessionKey);
2314
- return "That conversation got too long — I've started a fresh session. Please resend your message.";
2380
+ {
2381
+ const promptForBackground = contextOverflowRecoveryPrompt || chatPrompt;
2382
+ const { response, task } = this.queueBackgroundTaskAfterContextOverflow(sessionKey, promptForBackground);
2383
+ if (ledgerRunMetadata) {
2384
+ ledgerRunMetadata.executionMode = 'background_queued';
2385
+ ledgerRunMetadata.backgroundTaskId = task.id;
2386
+ }
2387
+ this.mirrorChatExchange(sessionKey, originalText, response, { model: 'chat-control' });
2388
+ return response;
2389
+ }
2315
2390
  case 'auth':
2316
2391
  this.recordAuthFailure();
2317
2392
  return "I'm temporarily offline due to an authentication issue. The owner needs to re-authenticate — I'll recover automatically once it's resolved.";
@@ -32,6 +32,12 @@ export declare function isComposioEnabled(): boolean;
32
32
  * the dashboard PUT /api/settings/COMPOSIO_API_KEY handler.
33
33
  */
34
34
  export declare function resetComposioClient(): void;
35
+ /**
36
+ * Drop the per-process connection-list cache so the next call to
37
+ * `listConnectedToolkits()` hits Composio fresh. Used after authorize /
38
+ * disconnect / rename so the dashboard and agent see the change immediately.
39
+ */
40
+ export declare function clearConnectedToolkitsCache(): void;
35
41
  export declare function getPreferredUserId(): Promise<string>;
36
42
  export declare function clementineUserId(): string;
37
43
  export declare function displayNameFor(slug: string): string;
@@ -83,6 +83,30 @@ export function resetComposioClient() {
83
83
  identityCache.clear();
84
84
  catalogCache = null;
85
85
  detectedPreferredUserId = null;
86
+ connectionsCache = null;
87
+ void busComposioMcpCache();
88
+ }
89
+ /**
90
+ * Drop the per-process connection-list cache so the next call to
91
+ * `listConnectedToolkits()` hits Composio fresh. Used after authorize /
92
+ * disconnect / rename so the dashboard and agent see the change immediately.
93
+ */
94
+ export function clearConnectedToolkitsCache() {
95
+ connectionsCache = null;
96
+ }
97
+ /**
98
+ * Fire-and-forget MCP-server cache bust. Imported lazily to avoid the
99
+ * client → mcp-bridge → client cycle that an `import { ... }` at the top
100
+ * would create.
101
+ */
102
+ async function busComposioMcpCache() {
103
+ try {
104
+ const mod = await import('./mcp-bridge.js');
105
+ mod.clearComposioMcpCache?.();
106
+ }
107
+ catch {
108
+ /* mcp-bridge optional at boot; safe to ignore */
109
+ }
86
110
  }
87
111
  // Public: same logic as the internal detector, exposed for the MCP bridge so
88
112
  // agent sessions land on the right user_id.
@@ -310,10 +334,22 @@ async function getIdentityFor(composio, id, slug, seed) {
310
334
  identityCache.set(id, { at: Date.now(), identity });
311
335
  return identity;
312
336
  }
337
+ // Short-lived per-process cache + stale-while-revalidate. Composio API hiccups
338
+ // between turns used to make tools "vanish" from the chat; with this, a single
339
+ // failed list call falls back to the prior good snapshot. TTL is short enough
340
+ // (60s) that legit reconnects / disconnects show up quickly, and the dashboard
341
+ // auth/disconnect handlers explicitly bust the cache via
342
+ // `clearConnectedToolkitsCache()` for instant reflection.
343
+ let connectionsCache = null;
344
+ const CONNECTIONS_TTL_MS = 60_000;
313
345
  export async function listConnectedToolkits() {
314
346
  const composio = getComposio();
315
347
  if (!composio)
316
348
  return [];
349
+ const now = Date.now();
350
+ if (connectionsCache && now - connectionsCache.at < CONNECTIONS_TTL_MS) {
351
+ return connectionsCache.data;
352
+ }
317
353
  try {
318
354
  // No userIds filter: a Composio API key is account-scoped, and a personal
319
355
  // agent should see every connection on the account regardless of which
@@ -337,10 +373,15 @@ export async function listConnectedToolkits() {
337
373
  createdAt: it.createdAt,
338
374
  };
339
375
  }));
376
+ connectionsCache = { at: now, data: enriched };
340
377
  return enriched;
341
378
  }
342
379
  catch (err) {
343
- logger.error({ err }, 'listConnectedToolkits failed');
380
+ if (connectionsCache) {
381
+ logger.warn({ err, staleAgeMs: now - connectionsCache.at, items: connectionsCache.data.length }, 'listConnectedToolkits failed — returning stale cache');
382
+ return connectionsCache.data;
383
+ }
384
+ logger.error({ err }, 'listConnectedToolkits failed (no cache to fall back to)');
344
385
  return [];
345
386
  }
346
387
  }
@@ -481,6 +522,8 @@ _opts) {
481
522
  // others created in parallel via Composio's web UI) get picked up
482
523
  // immediately, even within the 60s TTL window.
483
524
  detectedPreferredUserId = null;
525
+ connectionsCache = null;
526
+ void busComposioMcpCache();
484
527
  return { redirectUrl: conn.redirectUrl ?? null, connectionId: conn.id };
485
528
  }
486
529
  catch (err) {
@@ -509,6 +552,8 @@ export async function disconnectToolkit(connectionId) {
509
552
  throw new Error('COMPOSIO_API_KEY not set');
510
553
  await composio.connectedAccounts.delete(connectionId);
511
554
  identityCache.delete(connectionId);
555
+ connectionsCache = null;
556
+ void busComposioMcpCache();
512
557
  }
513
558
  export async function renameConnection(connectionId, alias) {
514
559
  const composio = getComposio();
@@ -520,5 +565,6 @@ export async function renameConnection(connectionId, alias) {
520
565
  // hatch and the alternative (bypassing the wrapper entirely) loses retry
521
566
  // and auth handling.
522
567
  await composio.client.connectedAccounts.patch(connectionId, { alias });
568
+ connectionsCache = null;
523
569
  }
524
570
  //# sourceMappingURL=client.js.map
@@ -16,6 +16,7 @@
16
16
  * always works — Composio is purely additive.
17
17
  */
18
18
  import type { McpSdkServerConfigWithInstance } from '@anthropic-ai/claude-agent-sdk';
19
+ export declare function clearComposioMcpCache(slug?: string): void;
19
20
  /**
20
21
  * Build SDK MCP server configs for the given toolkit slugs (or all active
21
22
  * connected toolkits when omitted). Each toolkit becomes one MCP server.
@@ -19,6 +19,15 @@ import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
19
19
  import pino from 'pino';
20
20
  import { getComposio, getPreferredUserId, listConnectedToolkits, } from './client.js';
21
21
  const logger = pino({ name: 'clementine.composio.mcp' });
22
+ const serverCache = new Map();
23
+ const SERVER_CACHE_TTL_MS = 5 * 60_000;
24
+ export function clearComposioMcpCache(slug) {
25
+ if (slug) {
26
+ serverCache.delete(slug);
27
+ return;
28
+ }
29
+ serverCache.clear();
30
+ }
22
31
  /**
23
32
  * Build SDK MCP server configs for the given toolkit slugs (or all active
24
33
  * connected toolkits when omitted). Each toolkit becomes one MCP server.
@@ -70,6 +79,11 @@ export async function listComposioToolkitTools(slugs) {
70
79
  return out;
71
80
  }
72
81
  async function buildOne(composio, slug, _connected) {
82
+ const now = Date.now();
83
+ const cached = serverCache.get(slug);
84
+ if (cached && now - cached.at < SERVER_CACHE_TTL_MS) {
85
+ return cached.server;
86
+ }
73
87
  // composio.tools.get() returns the FLAT toolkit tools (OUTLOOK_LIST_MESSAGES,
74
88
  // GMAIL_SEND_EMAIL, …) — exactly the namespacing the agent expects as
75
89
  // mcp__outlook__OUTLOOK_LIST_MESSAGES. The alternative, composio.create()
@@ -83,11 +97,13 @@ async function buildOne(composio, slug, _connected) {
83
97
  // alphabetically come after OUTLOOK_LIST_CALENDAR_GROUP_*. GitHub has
84
98
  // 800+. Set 1000 — comfortable headroom for any single toolkit.
85
99
  const tools = await fetchToolkitTools(composio, slug);
86
- return createSdkMcpServer({
100
+ const server = createSdkMcpServer({
87
101
  name: slug,
88
102
  version: '0.1.0',
89
103
  tools: tools,
90
104
  });
105
+ serverCache.set(slug, { at: now, server });
106
+ return server;
91
107
  }
92
108
  async function fetchToolkitTools(composio, slug) {
93
109
  const userId = await getPreferredUserId();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.177",
3
+ "version": "1.18.179",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",