clementine-agent 1.18.174 → 1.18.176

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.
@@ -260,6 +260,8 @@ export declare class PersonalAssistant {
260
260
  */
261
261
  injectContext(sessionKey: string, userText: string, assistantText: string, opts?: {
262
262
  pending?: boolean;
263
+ model?: string;
264
+ countExchange?: boolean;
263
265
  }): void;
264
266
  getRecentActivity(sinceIso: string, maxEntries?: number): Array<{
265
267
  sessionKey: string;
@@ -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, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, BUDGET, TASK_BUDGET_TOKENS, TIMEZONE, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, claudeCodeDisableOneMillionForModel, currentOneMillionContextMode, normalizeClaudeModelForOneMillionContext, normalizeClaudeSdkOptionsForOneMillionContext, looksLikeClaudeOneMillionContextError, 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, SESSION_EXCHANGE_HISTORY_SIZE, SESSION_EXCHANGE_MAX_CHARS, INJECTED_CONTEXT_MAX_CHARS, PROJECTS_META_FILE, CRON_PROGRESS_DIR, CRON_REFLECTIONS_DIR, BUDGET, TASK_BUDGET_TOKENS, currentTimeZone, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY as CONFIG_ANTHROPIC_API_KEY, claudeCodeDisableOneMillionForModel, currentOneMillionContextMode, normalizeClaudeModelForOneMillionContext, normalizeClaudeSdkOptionsForOneMillionContext, looksLikeClaudeOneMillionContextError, envSnapshot, } from '../config.js';
17
17
  import { summarizeIntegrationStatus } from '../config/integrations-registry.js';
18
18
  import { loadToolPreferences, computeAvailability, buildPromptInstruction, buildComposioStatusBlock, KNOWN_SERVICES, } from '../integrations/tool-preferences.js';
19
19
  import { loadClaudeIntegrations } from './mcp-bridge.js';
@@ -563,17 +563,17 @@ function extractText(blocks) {
563
563
  }
564
564
  // ── Date Helpers ────────────────────────────────────────────────────
565
565
  function formatDate(d) {
566
- return formatDateInTimeZone(d, TIMEZONE);
566
+ return formatDateInTimeZone(d, currentTimeZone());
567
567
  }
568
568
  function formatTime(d) {
569
- return formatTimeInTimeZone(d, TIMEZONE);
569
+ return formatTimeInTimeZone(d, currentTimeZone());
570
570
  }
571
571
  /** Local-time YYYY-MM-DD (avoids UTC date mismatch late at night). */
572
572
  function todayISO() {
573
- return dateKeyInTimeZone(new Date(), TIMEZONE);
573
+ return dateKeyInTimeZone(new Date(), currentTimeZone());
574
574
  }
575
575
  function yesterdayISO() {
576
- return dateKeyInTimeZone(new Date(Date.now() - 24 * 60 * 60 * 1000), TIMEZONE);
576
+ return dateKeyInTimeZone(new Date(Date.now() - 24 * 60 * 60 * 1000), currentTimeZone());
577
577
  }
578
578
  // ── Cron Output Extraction ──────────────────────────────────────────
579
579
  /** Autonomous jobs use this sentinel to mean "completed, but do not notify the owner." */
@@ -1139,7 +1139,7 @@ Large tool outputs blow the context window and rotate your session mid-task —
1139
1139
  // Skip yesterday's notes and recent conversation summaries for autonomous runs
1140
1140
  if (!isAutonomous && !skipAmbientContext) {
1141
1141
  if (!retrievalContext) {
1142
- const hour = hourInTimeZone(new Date(), TIMEZONE);
1142
+ const hour = hourInTimeZone(new Date(), currentTimeZone());
1143
1143
  const mentionsYesterday = this._lastUserMessage?.toLowerCase().includes('yesterday');
1144
1144
  if (hour < 12 || mentionsYesterday) {
1145
1145
  const yPath = path.join(DAILY_NOTES_DIR, `${yesterdayISO()}.md`);
@@ -1630,11 +1630,12 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1630
1630
  const modelLabel = Object.entries(MODELS).find(([, v]) => v === resolvedModel)?.[0] ?? resolvedModel;
1631
1631
  const caps = !isAutonomous ? getChannelCapabilities(channel) : null;
1632
1632
  const now = new Date();
1633
+ const timeZone = currentTimeZone();
1633
1634
  volatileParts.push(`## Current Context
1634
1635
 
1635
1636
  - **Date:** ${formatDate(now)}
1636
1637
  - **Time:** ${formatTime(now)}
1637
- - **Timezone:** ${TIMEZONE}
1638
+ - **Timezone:** ${timeZone}
1638
1639
  - **Channel:** ${channel}${caps ? ` (${formatCapabilities(caps)})` : ''}
1639
1640
  - **Model:** ${modelLabel} (${resolvedModel})
1640
1641
  - **Vault:** ${vault}
@@ -3190,13 +3191,16 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3190
3191
  pending.shift();
3191
3192
  this.pendingContext.set(sessionKey, pending);
3192
3193
  }
3194
+ if (opts.countExchange) {
3195
+ this.exchangeCounts.set(sessionKey, (this.exchangeCounts.get(sessionKey) ?? 0) + 1);
3196
+ }
3193
3197
  this.sessionTimestamps.set(sessionKey, new Date());
3194
3198
  this.saveSessions();
3195
3199
  // Persist to transcript store
3196
3200
  if (this.memoryStore) {
3197
3201
  try {
3198
3202
  this.memoryStore.saveTurn(sessionKey, 'user', userText);
3199
- this.memoryStore.saveTurn(sessionKey, 'assistant', assistantText, 'cron');
3203
+ this.memoryStore.saveTurn(sessionKey, 'assistant', assistantText, opts.model ?? 'cron');
3200
3204
  }
3201
3205
  catch {
3202
3206
  // Non-fatal
@@ -13,9 +13,9 @@
13
13
  * cron-scheduler tick picks up pending tasks within ~3 seconds.
14
14
  *
15
15
  * Restart safety: on daemon startup, any task left in 'running' is
16
- * aborted (its process is gone). P6b can add resumability; for now,
17
- * fail-fast is clearer than silently re-running a task that may have
18
- * already partially completed.
16
+ * marked interrupted (its process is gone). The user can resume it
17
+ * explicitly, which requeues the same prompt without pretending the
18
+ * prior run finished.
19
19
  */
20
20
  import type { BackgroundTask } from '../types.js';
21
21
  export declare const BACKGROUND_TASK_DIR: string;
@@ -41,17 +41,29 @@ export declare function listBackgroundTasks(filter?: {
41
41
  fromAgent?: string;
42
42
  }, opts?: BackgroundTaskOptions): BackgroundTask[];
43
43
  /** Transition a task to 'running' — daemon picked it up. */
44
- export declare function markRunning(id: string, opts?: BackgroundTaskOptions): BackgroundTask | null;
44
+ export declare function markRunning(id: string, opts?: BackgroundTaskOptions, meta?: {
45
+ jobName?: string;
46
+ runId?: string;
47
+ sdkSessionId?: string;
48
+ }): BackgroundTask | null;
49
+ /** Patch non-status metadata on a task. Used for notification bookkeeping. */
50
+ export declare function updateBackgroundTask(id: string, patch: Partial<Omit<BackgroundTask, 'id'>>, opts?: BackgroundTaskOptions): BackgroundTask | null;
45
51
  /** Transition to 'done' with final result. */
46
52
  export declare function markDone(id: string, result: string, deliverableNote?: string, opts?: BackgroundTaskOptions): BackgroundTask | null;
47
53
  /** Transition to 'failed' or 'aborted' with error message. */
48
- export declare function markFailed(id: string, error: string, reason?: 'failed' | 'aborted', opts?: BackgroundTaskOptions): BackgroundTask | null;
54
+ export declare function markFailed(id: string, error: string, reason?: 'failed' | 'aborted' | 'interrupted', opts?: BackgroundTaskOptions): BackgroundTask | null;
55
+ /**
56
+ * Requeue a task that was interrupted by a daemon restart.
57
+ */
58
+ export declare function resumeBackgroundTask(id: string, opts?: BackgroundTaskOptions): BackgroundTask | null;
49
59
  /**
50
60
  * Daemon-restart hygiene: any task still in 'running' must be from a
51
- * prior daemon process. Mark them aborted so the lifecycle is honest.
52
- * Returns the count of tasks aborted.
61
+ * prior daemon process. Mark it interrupted so it can be resumed
62
+ * explicitly. Returns the count of tasks interrupted.
53
63
  */
54
- export declare function abortStaleRunningTasks(opts?: BackgroundTaskOptions): number;
64
+ export declare function interruptStaleRunningTasks(opts?: BackgroundTaskOptions): number;
65
+ /** Backward-compatible export for callers/tests using the old name. */
66
+ export declare const abortStaleRunningTasks: typeof interruptStaleRunningTasks;
55
67
  /** Delete a task file. Callers should avoid deleting active tasks. */
56
68
  export declare function deleteBackgroundTask(id: string, opts?: BackgroundTaskOptions): void;
57
69
  /** Backward-compatible test helper alias. */
@@ -13,9 +13,9 @@
13
13
  * cron-scheduler tick picks up pending tasks within ~3 seconds.
14
14
  *
15
15
  * Restart safety: on daemon startup, any task left in 'running' is
16
- * aborted (its process is gone). P6b can add resumability; for now,
17
- * fail-fast is clearer than silently re-running a task that may have
18
- * already partially completed.
16
+ * marked interrupted (its process is gone). The user can resume it
17
+ * explicitly, which requeues the same prompt without pretending the
18
+ * prior run finished.
19
19
  */
20
20
  import { randomBytes } from 'node:crypto';
21
21
  import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
@@ -102,7 +102,7 @@ export function listBackgroundTasks(filter = {}, opts) {
102
102
  return out;
103
103
  }
104
104
  /** Transition a task to 'running' — daemon picked it up. */
105
- export function markRunning(id, opts) {
105
+ export function markRunning(id, opts, meta = {}) {
106
106
  const task = loadBackgroundTask(id, opts);
107
107
  if (!task)
108
108
  return null;
@@ -110,9 +110,26 @@ export function markRunning(id, opts) {
110
110
  return null;
111
111
  task.status = 'running';
112
112
  task.startedAt = new Date().toISOString();
113
+ delete task.completedAt;
114
+ delete task.error;
115
+ if (meta.jobName)
116
+ task.jobName = meta.jobName;
117
+ if (meta.runId)
118
+ task.runId = meta.runId;
119
+ if (meta.sdkSessionId)
120
+ task.sdkSessionId = meta.sdkSessionId;
113
121
  safeWrite(pathFor(id, opts), task);
114
122
  return task;
115
123
  }
124
+ /** Patch non-status metadata on a task. Used for notification bookkeeping. */
125
+ export function updateBackgroundTask(id, patch, opts) {
126
+ const task = loadBackgroundTask(id, opts);
127
+ if (!task)
128
+ return null;
129
+ const updated = Object.assign(task, patch, { id: task.id });
130
+ safeWrite(pathFor(id, opts), updated);
131
+ return updated;
132
+ }
116
133
  function writeFullResultFile(id, result, opts) {
117
134
  if (result.length <= RESULT_TRUNCATE_BYTES)
118
135
  return undefined;
@@ -146,28 +163,48 @@ export function markFailed(id, error, reason = 'failed', opts) {
146
163
  const task = loadBackgroundTask(id, opts);
147
164
  if (!task)
148
165
  return null;
149
- if (task.status === 'done' || task.status === 'failed' || task.status === 'aborted')
166
+ if (task.status === 'done' || task.status === 'failed' || task.status === 'aborted' || task.status === 'interrupted')
150
167
  return task;
151
168
  task.status = reason;
152
169
  task.completedAt = new Date().toISOString();
170
+ if (reason === 'interrupted')
171
+ task.interruptedAt = task.completedAt;
153
172
  task.error = error.slice(0, 1000);
154
173
  safeWrite(pathFor(id, opts), task);
155
174
  return task;
156
175
  }
176
+ /**
177
+ * Requeue a task that was interrupted by a daemon restart.
178
+ */
179
+ export function resumeBackgroundTask(id, opts) {
180
+ const task = loadBackgroundTask(id, opts);
181
+ if (!task || task.status !== 'interrupted')
182
+ return null;
183
+ task.status = 'pending';
184
+ task.resumedAt = new Date().toISOString();
185
+ task.resumeCount = (task.resumeCount ?? 0) + 1;
186
+ delete task.startedAt;
187
+ delete task.completedAt;
188
+ delete task.error;
189
+ safeWrite(pathFor(id, opts), task);
190
+ return task;
191
+ }
157
192
  /**
158
193
  * Daemon-restart hygiene: any task still in 'running' must be from a
159
- * prior daemon process. Mark them aborted so the lifecycle is honest.
160
- * Returns the count of tasks aborted.
194
+ * prior daemon process. Mark it interrupted so it can be resumed
195
+ * explicitly. Returns the count of tasks interrupted.
161
196
  */
162
- export function abortStaleRunningTasks(opts) {
197
+ export function interruptStaleRunningTasks(opts) {
163
198
  const stuck = listBackgroundTasks({ status: 'running' }, opts);
164
- let aborted = 0;
199
+ let interrupted = 0;
165
200
  for (const t of stuck) {
166
- markFailed(t.id, 'daemon restarted while task was in flight', 'aborted', opts);
167
- aborted++;
201
+ markFailed(t.id, 'daemon restarted while task was in flight', 'interrupted', opts);
202
+ interrupted++;
168
203
  }
169
- return aborted;
204
+ return interrupted;
170
205
  }
206
+ /** Backward-compatible export for callers/tests using the old name. */
207
+ export const abortStaleRunningTasks = interruptStaleRunningTasks;
171
208
  /** Delete a task file. Callers should avoid deleting active tasks. */
172
209
  export function deleteBackgroundTask(id, opts) {
173
210
  try {
@@ -12,7 +12,7 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { AsyncLocalStorage } from 'node:async_hooks';
14
14
  import { randomUUID } from 'node:crypto';
15
- import { OWNER_NAME, BASE_DIR, TIMEZONE } from '../config.js';
15
+ import { OWNER_NAME, BASE_DIR, currentTimeZone } from '../config.js';
16
16
  import { formatTime24InTimeZone } from '../lib/time.js';
17
17
  // ── Shared state ───────────────────────────────────────────────────────
18
18
  let heartbeatActive = false;
@@ -190,7 +190,7 @@ export function clearActiveQueryContext() {
190
190
  // not by us. setInteractionSource resets it in the relevant transitions.
191
191
  }
192
192
  export function logToolUse(toolName, toolInput) {
193
- const timestamp = formatTime24InTimeZone(new Date(), TIMEZONE);
193
+ const timestamp = formatTime24InTimeZone(new Date(), currentTimeZone());
194
194
  const summary = summarizeToolCall(toolName, toolInput);
195
195
  const entry = `- \`${timestamp}\` **${toolName}** — ${summary}`;
196
196
  auditLog.push(entry);
@@ -310,13 +310,14 @@ function evaluateSendPolicy(policy, agentSlug, recipientEmail) {
310
310
  // 3. Business hours check
311
311
  if (policy.businessHoursOnly) {
312
312
  const now = new Date();
313
- // Use system timezone (from config) for business hours check
313
+ const timeZone = currentTimeZone();
314
+ // Use the configured user timezone for business hours checks.
314
315
  const formatter = new Intl.DateTimeFormat('en-US', {
315
- hour: 'numeric', hour12: false, timeZone: TIMEZONE || undefined,
316
+ hour: 'numeric', hour12: false, timeZone,
316
317
  });
317
318
  const hour = parseInt(formatter.format(now), 10);
318
319
  if (hour < 8 || hour >= 18) {
319
- return { allowed: false, reason: `Outside business hours (8am–6pm ${TIMEZONE || 'local'}).`, policyRef: 'business_hours' };
320
+ return { allowed: false, reason: `Outside business hours (8am–6pm ${timeZone}).`, policyRef: 'business_hours' };
320
321
  }
321
322
  }
322
323
  // 4. Approval mode check
@@ -11,7 +11,7 @@
11
11
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import pino from 'pino';
14
- import { GOALS_DIR, BASE_DIR, MEMORY_DB_PATH, VAULT_DIR, TIMEZONE } from '../config.js';
14
+ import { GOALS_DIR, BASE_DIR, MEMORY_DB_PATH, VAULT_DIR, currentTimeZone } from '../config.js';
15
15
  import { listAllGoals } from '../tools/shared.js';
16
16
  import { computeBrokenJobs } from '../gateway/failure-monitor.js';
17
17
  import { MemoryStore } from '../memory/store.js';
@@ -24,7 +24,7 @@ const UNACKED_THRESHOLD = 3; // double cooldown after this many ignored
24
24
  * Check if it's too soon to send another proactive message.
25
25
  */
26
26
  export function canSendInsight(state) {
27
- const today = dateKeyInTimeZone(new Date(), TIMEZONE);
27
+ const today = dateKeyInTimeZone(new Date(), currentTimeZone());
28
28
  // Reset daily count on new day
29
29
  if (state.currentDate !== today) {
30
30
  state.sentToday = [];
@@ -14,14 +14,14 @@
14
14
  * through the canonical runAgent() instead of buildOptions+query.
15
15
  */
16
16
  import pino from 'pino';
17
- import { OWNER_NAME, MODELS, BUDGET, TIMEZONE, } from '../config.js';
17
+ import { OWNER_NAME, MODELS, BUDGET, currentTimeZone, } from '../config.js';
18
18
  import { formatDateInTimeZone, formatTimeInTimeZone } from '../lib/time.js';
19
19
  const OWNER = OWNER_NAME || 'the user';
20
20
  function formatDate(d) {
21
- return formatDateInTimeZone(d, TIMEZONE);
21
+ return formatDateInTimeZone(d, currentTimeZone());
22
22
  }
23
23
  function formatTime(d) {
24
- return formatTimeInTimeZone(d, TIMEZONE);
24
+ return formatTimeInTimeZone(d, currentTimeZone());
25
25
  }
26
26
  import { runAgent } from './run-agent.js';
27
27
  const logger = pino({ name: 'clementine.run-agent-heartbeat' });
@@ -35,7 +35,7 @@ export async function runAgentHeartbeat(opts) {
35
35
  const now = new Date();
36
36
  const localTime = formatTime(now);
37
37
  const localDate = formatDate(now);
38
- const tz = TIMEZONE;
38
+ const tz = currentTimeZone();
39
39
  const owner = OWNER;
40
40
  const agentName = opts.profile?.name ?? 'personal assistant';
41
41
  const promptParts = [
@@ -75,6 +75,10 @@ export interface RunAgentOptions {
75
75
  /** Optional explicit allowedTools list. When unset, falls back to a sensible default
76
76
  * including Agent (so subagents can be spawned) + core SDK tools + Clementine MCP. */
77
77
  allowedTools?: string[];
78
+ /** Extra tools to pre-approve without making their built-in tools visible to
79
+ * the main agent. Useful when the main agent may only call Agent, but the
80
+ * forced subagent still needs pre-approved MCP/Clementine tools. */
81
+ permissionTools?: string[];
78
82
  /** SDK permission mode. Defaults to dontAsk so allowedTools is enforceable.
79
83
  * Only explicit operator/full-surface paths should request bypassPermissions. */
80
84
  permissionMode?: ExecutionPermissionMode;
@@ -254,6 +254,27 @@ export async function runAgent(prompt, opts) {
254
254
  clementineServerName: TOOLS_SERVER,
255
255
  permissionMode: opts.permissionMode,
256
256
  });
257
+ const permissionToolPolicy = opts.permissionTools
258
+ ? buildExecutionToolPolicy({
259
+ requestedTools: opts.permissionTools,
260
+ defaultBuiltins: CORE_TOOLS_FOR_AGENT_PARENT,
261
+ mcpServerNames: policyMcpServerNames,
262
+ clementineServerName: TOOLS_SERVER,
263
+ permissionMode: opts.permissionMode,
264
+ })
265
+ : null;
266
+ const sdkAllowedTools = permissionToolPolicy
267
+ ? Array.from(new Set([...toolPolicy.allowedTools, ...permissionToolPolicy.allowedTools])).sort()
268
+ : toolPolicy.allowedTools;
269
+ const clementineToolAllowlist = (() => {
270
+ if (!permissionToolPolicy)
271
+ return toolPolicy.clementineToolAllowlist;
272
+ const parts = [toolPolicy.clementineToolAllowlist, permissionToolPolicy.clementineToolAllowlist]
273
+ .flatMap(v => v.split(',').map(s => s.trim()).filter(Boolean));
274
+ if (parts.includes('*'))
275
+ return '*';
276
+ return Array.from(new Set(parts)).sort().join(',');
277
+ })();
257
278
  // SDK accepts a Record<string, McpServerConfig> here. We cast on
258
279
  // assignment because we mix the always-on Clementine stdio server
259
280
  // with caller-supplied servers of various types.
@@ -267,7 +288,7 @@ export async function runAgent(prompt, opts) {
267
288
  CLEMENTINE_HOME: BASE_DIR,
268
289
  ...(opts.profile?.slug ? { CLEMENTINE_TEAM_AGENT: opts.profile.slug } : {}),
269
290
  CLEMENTINE_INTERACTION_SOURCE: interactionSourceForSession(opts.sessionKey, source),
270
- CLEMENTINE_TOOL_ALLOWLIST: toolPolicy.clementineToolAllowlist,
291
+ CLEMENTINE_TOOL_ALLOWLIST: clementineToolAllowlist,
271
292
  },
272
293
  },
273
294
  ...baseMcpServers,
@@ -302,6 +323,20 @@ export async function runAgent(prompt, opts) {
302
323
  }
303
324
  catch { /* never block */ }
304
325
  };
326
+ // Durable SDK session store: mirror parent + subagent SDK transcripts into
327
+ // Clementine's SQLite memory store. The chat gateway already persists the
328
+ // SDK session id; this store makes restart resume independent of local JSONL
329
+ // files and keeps session inspection/querying in the same memory backend.
330
+ let sessionStore;
331
+ if (opts.memoryStore) {
332
+ try {
333
+ const { createMemorySessionStore } = await import('./session-store-adapter.js');
334
+ sessionStore = createMemorySessionStore(opts.memoryStore);
335
+ }
336
+ catch (err) {
337
+ logger.debug({ err }, 'runAgent: SessionStore adapter unavailable (non-fatal)');
338
+ }
339
+ }
305
340
  // ── Tool-output guard hooks (1.18.169) ─────────────────────────────
306
341
  // Bounds the per-tool-call output that reaches the model so SDK
307
342
  // auto-compaction never thrashes on a runaway MCP result. The hook
@@ -393,8 +428,9 @@ export async function runAgent(prompt, opts) {
393
428
  // callers can mix stdio + http + sse server shapes.
394
429
  mcpServers: mcpServers,
395
430
  tools: toolPolicy.builtinTools,
396
- allowedTools: toolPolicy.allowedTools,
431
+ allowedTools: sdkAllowedTools,
397
432
  permissionMode: toolPolicy.permissionMode,
433
+ ...(sessionStore ? { sessionStore } : {}),
398
434
  ...(toolPolicy.allowDangerouslySkipPermissions
399
435
  ? { allowDangerouslySkipPermissions: toolPolicy.allowDangerouslySkipPermissions }
400
436
  : {}),
@@ -432,10 +468,11 @@ export async function runAgent(prompt, opts) {
432
468
  maxBudgetUsd: maxBudgetUsd ?? 'uncapped',
433
469
  agentCount: Object.keys(agents).length,
434
470
  allowedToolCount: allowedTools.length,
435
- sdkAllowedToolCount: toolPolicy.allowedTools.length,
471
+ sdkAllowedToolCount: sdkAllowedTools.length,
436
472
  builtinToolCount: toolPolicy.builtinTools.length,
437
473
  permissionMode: toolPolicy.permissionMode,
438
474
  mcpServerCount: Object.keys(mcpServers).length,
475
+ sessionStore: !!sessionStore,
439
476
  }, 'runAgent: starting query');
440
477
  // PRD §6 / 1.18.85: path A in-process tap. runId / eventLog / writeEvent
441
478
  // are declared earlier (above sdkOptionsRaw) because the tool-output
@@ -723,7 +760,7 @@ export async function runAgent(prompt, opts) {
723
760
  ...(usage ? { usage } : {}),
724
761
  runId,
725
762
  permissionMode: toolPolicy.permissionMode,
726
- allowedToolsApplied: toolPolicy.allowedTools,
763
+ allowedToolsApplied: sdkAllowedTools,
727
764
  builtinToolsApplied: toolPolicy.builtinTools,
728
765
  mcpServersApplied: Object.keys(mcpServers),
729
766
  };
@@ -260,7 +260,7 @@ function resolveAutonomousModel(explicitModel, skillModel) {
260
260
  * Since the parent is `forceSubagent`'d to this worker, the description
261
261
  * mostly serves as transcript context.
262
262
  */
263
- function buildSkillWorkerAgent(skill, effectiveTools, model, workerMaxTurns) {
263
+ function buildSkillWorkerAgent(skill, renderedProcedure, effectiveTools, model, workerMaxTurns) {
264
264
  const def = {
265
265
  description: `Executes the "${skill.frontmatter.name}" scheduled skill end-to-end in an isolated context window. ` +
266
266
  `Reads any data the skill needs, processes it, performs the skill's described delivery action ` +
@@ -272,7 +272,7 @@ function buildSkillWorkerAgent(skill, effectiveTools, model, workerMaxTurns) {
272
272
  `Return a single concise final response describing what happened (e.g., "Sent Discord DM about ` +
273
273
  `2 actionable items, ignored 8 spam"). Do not return raw tool output; do not narrate every step. ` +
274
274
  `If nothing actionable was found and the procedure says exit silently, return "No action needed."\n\n` +
275
- `## Procedure\n\n${skill.body}`,
275
+ `## Procedure\n\n${renderedProcedure}`,
276
276
  tools: effectiveTools,
277
277
  // SDK accepts 'sonnet' / 'opus' / 'haiku' tier aliases OR full model
278
278
  // IDs. We pass the full ID with [1m] when present; the SDK strips
@@ -291,7 +291,7 @@ function buildSkillWorkerAgent(skill, effectiveTools, model, workerMaxTurns) {
291
291
  * return text (typically <2KB). Total parent context per run: ~3KB.
292
292
  * Well below any compaction threshold even on a 200K-window model.
293
293
  */
294
- function buildOrchestratorPrompt(skill, callerContext) {
294
+ function buildOrchestratorPrompt(skill) {
295
295
  const parts = [
296
296
  `## Scheduled Skill Execution`,
297
297
  ``,
@@ -306,9 +306,6 @@ function buildOrchestratorPrompt(skill, callerContext) {
306
306
  ``,
307
307
  `Do NOT call any other tools directly. The worker handles all data access and delivery.`,
308
308
  ];
309
- if (callerContext && callerContext.trim()) {
310
- parts.push('', '## Caller context (forward this to the worker if relevant)', '', callerContext.trim());
311
- }
312
309
  return parts.join('\n');
313
310
  }
314
311
  // ── The primitive ─────────────────────────────────────────────────────
@@ -355,9 +352,10 @@ export async function runSkill(name, options = {}) {
355
352
  // skill reads, so post-compaction refetch loops are structurally
356
353
  // impossible. See shouldAutoDelegate / buildSkillWorkerAgent above.
357
354
  const autoDelegate = shouldAutoDelegate(skill, source);
355
+ const renderedSkillPrompt = buildSkillPrompt(skill, options.inputs, options.context);
358
356
  const prompt = autoDelegate
359
- ? buildOrchestratorPrompt(skill, options.context)
360
- : buildSkillPrompt(skill, options.inputs, options.context);
357
+ ? buildOrchestratorPrompt(skill)
358
+ : renderedSkillPrompt;
361
359
  const limits = skill.frontmatter?.clementine?.limits;
362
360
  const maxTurns = options.maxTurns ?? limits?.maxTurns;
363
361
  const maxBudgetUsd = options.maxBudgetUsd ?? limits?.maxBudgetUsd;
@@ -411,7 +409,7 @@ export async function runSkill(name, options = {}) {
411
409
  // Worker gets enough turns to complete bulk work (skill author's
412
410
  // maxTurns cap, or 30 as a safe default for triage-class work).
413
411
  const workerMaxTurns = (typeof maxTurns === 'number' && maxTurns > 0) ? maxTurns : 30;
414
- const workerDef = buildSkillWorkerAgent(skill, effectiveTools, effectiveModel, workerMaxTurns);
412
+ const workerDef = buildSkillWorkerAgent(skill, renderedSkillPrompt, effectiveTools, effectiveModel, workerMaxTurns);
415
413
  sdkOpts = {
416
414
  sessionKey,
417
415
  source,
@@ -419,6 +417,9 @@ export async function runSkill(name, options = {}) {
419
417
  // the parent's context shape predictable and prevents it from
420
418
  // doing data-heavy work itself even if the LLM disagrees.
421
419
  allowedTools: ['Agent'],
420
+ // SDK permissions are session-level, so the worker's tools still
421
+ // need to be pre-approved even though the parent only sees Agent.
422
+ permissionTools: ['Agent', ...effectiveTools],
422
423
  // Force-routing: SDK wraps the prompt with "Use the skill-worker
423
424
  // agent to handle this request" so dispatch is the natural
424
425
  // first action.
@@ -9,7 +9,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } fr
9
9
  import path from 'node:path';
10
10
  import pino from 'pino';
11
11
  import { listAllGoals } from '../tools/shared.js';
12
- import { TIMEZONE } from '../config.js';
12
+ import { currentTimeZone } from '../config.js';
13
13
  import { dateKeyInTimeZone } from '../lib/time.js';
14
14
  const logger = pino({ name: 'clementine.team-bus' });
15
15
  /** Max inter-agent message depth before rejection (anti-loop). */
@@ -512,7 +512,7 @@ export class TeamBus {
512
512
  * Used for proactive context sharing, not task delegation.
513
513
  */
514
514
  async shareContext(fromSlug, toSlug, content) {
515
- const today = dateKeyInTimeZone(new Date(), TIMEZONE);
515
+ const today = dateKeyInTimeZone(new Date(), currentTimeZone());
516
516
  // Daily cap: max 2 context shares per sender per day
517
517
  const tracker = this.contextShareCounts.get(fromSlug);
518
518
  if (tracker && tracker.date === today && tracker.count >= 2) {
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Pure functions for resolving {{...}} placeholders in workflow step prompts.
5
5
  */
6
- import { OWNER_NAME, ASSISTANT_NAME, TIMEZONE } from '../config.js';
6
+ import { OWNER_NAME, ASSISTANT_NAME, currentTimeZone } from '../config.js';
7
7
  import { dateKeyInTimeZone, formatTime24InTimeZone } from '../lib/time.js';
8
8
  /**
9
9
  * Resolve static variables (available before any step executes).
@@ -11,8 +11,9 @@ import { dateKeyInTimeZone, formatTime24InTimeZone } from '../lib/time.js';
11
11
  */
12
12
  export function resolveStaticVariables(template, inputs, workflowName) {
13
13
  const now = new Date();
14
- const dateStr = dateKeyInTimeZone(now, TIMEZONE);
15
- const timeStr = formatTime24InTimeZone(now, TIMEZONE);
14
+ const timezone = currentTimeZone();
15
+ const dateStr = dateKeyInTimeZone(now, timezone);
16
+ const timeStr = formatTime24InTimeZone(now, timezone);
16
17
  return template.replace(/\{\{([^}]+)\}\}/g, (match, key) => {
17
18
  const trimmed = key.trim();
18
19
  // {{input.topic}} → inputs['topic']
package/dist/cli/cron.js CHANGED
@@ -13,7 +13,7 @@ import cron from 'node-cron';
13
13
  import matter from 'gray-matter';
14
14
  import { HeartbeatScheduler } from '../gateway/heartbeat-scheduler.js';
15
15
  import { parseCronJobs, CronRunLog, classifyError } from '../gateway/cron-scheduler.js';
16
- import { TIMEZONE } from '../config.js';
16
+ import { currentTimeZone } from '../config.js';
17
17
  import { hourInTimeZone } from '../lib/time.js';
18
18
  const BASE_DIR = process.env.CLEMENTINE_HOME || path.join(os.homedir(), '.clementine');
19
19
  const LAST_RUN_FILE = path.join(BASE_DIR, '.cron_last_run.json');
@@ -533,7 +533,7 @@ export async function cmdHeartbeat() {
533
533
  const parsed = matter(raw);
534
534
  standingInstructions = parsed.content;
535
535
  }
536
- const hour = hourInTimeZone(new Date(), TIMEZONE);
536
+ const hour = hourInTimeZone(new Date(), currentTimeZone());
537
537
  const timeContext = HeartbeatScheduler.getTimeContext(hour);
538
538
  console.log('Running one-shot heartbeat...');
539
539
  const response = await gateway.handleHeartbeat(standingInstructions, '', timeContext);
@@ -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, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } from '../config.js';
22
+ import { AGENTS_DIR, MEMORY_FILE, SESSIONS_FILE, TIMEZONE, applyOneMillionContextRecovery, currentTimeZone, looksLikeClaudeOneMillionContextError, normalizeClaudeSdkOptionsForOneMillionContext, } 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 +
@@ -1092,7 +1092,7 @@ function dashboardTimeZone() {
1092
1092
  catch {
1093
1093
  jsonTz = '';
1094
1094
  }
1095
- return resolveTimeZone(process.env.TIMEZONE, readDashboardEnvValue('TIMEZONE'), jsonTz, TIMEZONE);
1095
+ return resolveTimeZone(process.env.TIMEZONE, readDashboardEnvValue('TIMEZONE'), jsonTz, currentTimeZone(), TIMEZONE);
1096
1096
  }
1097
1097
  function dashboardTimeSnapshot(date = new Date()) {
1098
1098
  const timeZone = dashboardTimeZone();
@@ -9229,7 +9229,16 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
9229
9229
  }
9230
9230
  if (key === 'TIMEZONE') {
9231
9231
  process.env.TIMEZONE = value;
9232
- process.env.TZ = value;
9232
+ process.env.TZ = currentTimeZone();
9233
+ try {
9234
+ const gw = await getGateway();
9235
+ const sched = gw.cronScheduler;
9236
+ sched?.reloadJobs?.();
9237
+ sched?.reloadWorkflows?.();
9238
+ }
9239
+ catch {
9240
+ // Best-effort: persisted setting still applies on next scheduler start.
9241
+ }
9233
9242
  }
9234
9243
  res.json({ ok: true, message: `Updated ${key}` });
9235
9244
  }
@@ -9257,7 +9266,16 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
9257
9266
  }
9258
9267
  if (key === 'TIMEZONE') {
9259
9268
  delete process.env.TIMEZONE;
9260
- process.env.TZ = dashboardTimeZone();
9269
+ process.env.TZ = currentTimeZone();
9270
+ try {
9271
+ const gw = await getGateway();
9272
+ const sched = gw.cronScheduler;
9273
+ sched?.reloadJobs?.();
9274
+ sched?.reloadWorkflows?.();
9275
+ }
9276
+ catch {
9277
+ // Best-effort: persisted setting still applies on next scheduler start.
9278
+ }
9261
9279
  }
9262
9280
  res.json({ ok: true, message: `Removed ${key}` });
9263
9281
  }
@@ -25086,6 +25104,7 @@ function settingRequiresDaemonRestart(key) {
25086
25104
  if (!key) return true;
25087
25105
  if (key === 'COMPOSIO_API_KEY' || key === 'COMPOSIO_USER_ID') return false;
25088
25106
  if (key.indexOf('ASSISTANT_') === 0) return false;
25107
+ if (key === 'TIMEZONE') return false;
25089
25108
  return true;
25090
25109
  }
25091
25110
 
@@ -25188,8 +25207,7 @@ async function saveTimezoneSetting() {
25188
25207
  dashboardTimeState.serverIso = new Date().toISOString();
25189
25208
  dashboardTimeState.syncedAt = Date.now();
25190
25209
  renderDashboardTimeWidgets();
25191
- markRestartRequired('Timezone changed. Restart Clementine so Discord, Slack, and background workers use the new local time.');
25192
- if (status) { status.textContent = 'Saved'; status.style.color = 'var(--green)'; setTimeout(function(){ status.textContent = ''; }, 2000); }
25210
+ if (status) { status.textContent = 'Saved; schedules reloaded'; status.style.color = 'var(--green)'; setTimeout(function(){ status.textContent = ''; }, 2500); }
25193
25211
  refreshHomeDigest();
25194
25212
  } else if (status) {
25195
25213
  status.textContent = 'Error';
@@ -8,7 +8,7 @@ import { randomBytes } from 'node:crypto';
8
8
  import path from 'node:path';
9
9
  import matter from 'gray-matter';
10
10
  import { listAllGoals } from '../../tools/shared.js';
11
- import { TIMEZONE } from '../../config.js';
11
+ import { currentTimeZone } from '../../config.js';
12
12
  import { dateKeyInTimeZone, formatDateInTimeZone } from '../../lib/time.js';
13
13
  export function getDigestPrefs(prefsFile) {
14
14
  const defaults = {
@@ -78,8 +78,9 @@ export function digestRouter(deps) {
78
78
  const prefs = getDigestPrefs(prefsFile);
79
79
  const secs = (prefs.sections || {});
80
80
  const now = new Date();
81
- const dateStr = formatDateInTimeZone(now, TIMEZONE);
82
- const todayKey = dateKeyInTimeZone(now, TIMEZONE);
81
+ const timezone = currentTimeZone();
82
+ const dateStr = formatDateInTimeZone(now, timezone);
83
+ const todayKey = dateKeyInTimeZone(now, timezone);
83
84
  const sections = {};
84
85
  let officeSummary = '';
85
86
  try {