clementine-agent 1.0.25 → 1.0.27

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.
@@ -186,7 +186,7 @@ export declare class PersonalAssistant {
186
186
  };
187
187
  delegateProfile?: AgentProfile;
188
188
  }): Promise<string>;
189
- runCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, timeoutMs?: number, successCriteria?: string[]): Promise<string>;
189
+ runCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
190
190
  /**
191
191
  * Goal-backward verification pass using Haiku after cron job execution.
192
192
  * Instead of vague quality ratings, verifies actual outcomes:
@@ -195,7 +195,7 @@ export declare class PersonalAssistant {
195
195
  * 3. Does it connect to the goal / produce actionable results? (wired)
196
196
  */
197
197
  private runCronReflection;
198
- runUnleashedTask(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, maxHours?: number): Promise<string>;
198
+ runUnleashedTask(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, maxHours?: number, agentSlug?: string): Promise<string>;
199
199
  /**
200
200
  * Run a team message as an unleashed-style autonomous task.
201
201
  * Gives team agents the same multi-phase execution as cron jobs,
@@ -203,7 +203,7 @@ export declare class PersonalAssistant {
203
203
  *
204
204
  * @param onText Streaming callback for real-time progress updates
205
205
  */
206
- runTeamTask(fromName: string, fromSlug: string, content: string, profile: AgentProfile, onText?: (token: string) => void): Promise<string>;
206
+ runTeamTask(fromName: string, fromSlug: string, content: string, profile: AgentProfile, onText?: (token: string) => void, externalAbortController?: AbortController): Promise<string>;
207
207
  /**
208
208
  * Inject a user/assistant exchange into a session's context without running
209
209
  * a query. Used to give the DM session visibility of cron/heartbeat outputs
@@ -1302,11 +1302,16 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1302
1302
  : isCron && !isUnleashed ? 'medium'
1303
1303
  : isPlanStep || isUnleashed ? 'high'
1304
1304
  : undefined);
1305
- // ── Compute budget cap ────────────────────────────────────────
1305
+ // ── Compute budget (telemetry only) ───────────────────────────
1306
+ // Cost is informational on a Claude subscription — killing a job
1307
+ // mid-phase because it hit $5 in tokens is worse than the cost.
1308
+ // We still compute the figure so dashboards/logs can show it, but
1309
+ // do not pass it into the SDK as an enforcement knob.
1306
1310
  const computedBudget = maxBudgetUsd ?? (isHeartbeat && !isCron ? BUDGET.heartbeat
1307
1311
  : isCron && (cronTier ?? 0) < 2 ? BUDGET.cronT1
1308
1312
  : isCron ? BUDGET.cronT2
1309
1313
  : BUDGET.chat);
1314
+ void computedBudget; // reserved for future cost telemetry — not enforced
1310
1315
  // ── Compute adaptive thinking ─────────────────────────────────
1311
1316
  const supportsThinking = !resolvedModel.includes('haiku');
1312
1317
  const needsThinking = !isHeartbeat && (isPlanStep || isUnleashed || !isCron);
@@ -1355,7 +1360,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
1355
1360
  cwd: BASE_DIR,
1356
1361
  env: SAFE_ENV,
1357
1362
  ...(computedEffort ? { effort: computedEffort } : {}),
1358
- ...(computedBudget !== undefined ? { maxBudgetUsd: computedBudget } : {}),
1363
+ // maxBudgetUsd intentionally omitted see comment above.
1359
1364
  ...(computedThinking ? { thinking: computedThinking } : {}),
1360
1365
  ...(computedBetas ? { betas: computedBetas } : {}),
1361
1366
  ...(outputFormat ? { outputFormat } : {}),
@@ -2994,8 +2999,11 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
2994
2999
  return extractDeliverable(trace) ||
2995
3000
  trace.filter(t => t.type === 'text').map(t => t.content).join('').trim();
2996
3001
  }
2997
- async runCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, timeoutMs, successCriteria) {
3002
+ async runCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, timeoutMs, successCriteria, agentSlug) {
2998
3003
  setInteractionSource('autonomous');
3004
+ const cronProfile = agentSlug && agentSlug !== 'clementine'
3005
+ ? this.profileManager.get(agentSlug)
3006
+ : null;
2999
3007
  const cronGuard = new StallGuard();
3000
3008
  const sdkOptions = this.buildOptions({
3001
3009
  isHeartbeat: true,
@@ -3004,6 +3012,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3004
3012
  model: model ?? null,
3005
3013
  enableTeams: true,
3006
3014
  stallGuard: cronGuard,
3015
+ profile: cronProfile,
3007
3016
  });
3008
3017
  // Override cwd if a project workDir is specified
3009
3018
  if (workDir) {
@@ -3406,8 +3415,11 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3406
3415
  }
3407
3416
  }
3408
3417
  // ── Unleashed Mode (Long-Running Autonomous Tasks) ─────────────────
3409
- async runUnleashedTask(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, maxHours) {
3418
+ async runUnleashedTask(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, maxHours, agentSlug) {
3410
3419
  setInteractionSource('autonomous');
3420
+ const unleashedProfile = agentSlug && agentSlug !== 'clementine'
3421
+ ? this.profileManager.get(agentSlug)
3422
+ : null;
3411
3423
  const effectiveMaxHours = maxHours ?? UNLEASHED_DEFAULT_MAX_HOURS;
3412
3424
  const turnsPerPhase = maxTurns ?? UNLEASHED_PHASE_TURNS;
3413
3425
  const deadline = Date.now() + effectiveMaxHours * 60 * 60 * 1000;
@@ -3478,6 +3490,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3478
3490
  isUnleashed: true,
3479
3491
  maxBudgetUsd: BUDGET.unleashedPhase,
3480
3492
  stallGuard: phaseGuard,
3493
+ profile: unleashedProfile,
3481
3494
  });
3482
3495
  // Enable progress summaries for real-time status updates
3483
3496
  sdkOptions.agentProgressSummaries = true;
@@ -3796,7 +3809,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3796
3809
  *
3797
3810
  * @param onText Streaming callback for real-time progress updates
3798
3811
  */
3799
- async runTeamTask(fromName, fromSlug, content, profile, onText) {
3812
+ async runTeamTask(fromName, fromSlug, content, profile, onText, externalAbortController) {
3800
3813
  setInteractionSource('autonomous');
3801
3814
  const taskName = `team-msg:${fromSlug}-to-${profile.slug}`;
3802
3815
  const maxHours = 1; // Team messages get 1 hour max (not 6 like cron unleashed)
@@ -3808,6 +3821,10 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3808
3821
  let lastOutput = '';
3809
3822
  let consecutiveErrors = 0;
3810
3823
  while (phase < maxPhases) {
3824
+ if (externalAbortController?.signal.aborted) {
3825
+ logger.info({ taskName, phase }, 'Team task aborted by caller');
3826
+ return lastOutput || `Team task aborted by caller at phase ${phase}.`;
3827
+ }
3811
3828
  if (Date.now() >= deadline) {
3812
3829
  logger.info({ taskName, phase }, 'Team task timed out');
3813
3830
  return lastOutput || `Team task timed out after ${maxHours}h at phase ${phase}.`;
@@ -3878,6 +3895,17 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3878
3895
  phaseAc.abort();
3879
3896
  logger.warn({ taskName, phase }, `Team task phase ${phase} aborted — deadline reached`);
3880
3897
  }, Math.max(deadline - Date.now(), 0));
3898
+ // Propagate external abort (e.g., user sent "Stop") into the phase controller
3899
+ const onExternalAbort = () => {
3900
+ phaseAc.abort();
3901
+ logger.info({ taskName, phase }, `Team task phase ${phase} aborted by caller`);
3902
+ };
3903
+ if (externalAbortController) {
3904
+ if (externalAbortController.signal.aborted)
3905
+ phaseAc.abort();
3906
+ else
3907
+ externalAbortController.signal.addEventListener('abort', onExternalAbort, { once: true });
3908
+ }
3881
3909
  sdkOptions.abortController = phaseAc;
3882
3910
  try {
3883
3911
  const stream = query({ prompt, options: sdkOptions });
@@ -3933,6 +3961,13 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3933
3961
  }
3934
3962
  catch (err) {
3935
3963
  clearTimeout(phaseTimer);
3964
+ externalAbortController?.signal.removeEventListener('abort', onExternalAbort);
3965
+ // If this phase aborted because the caller cancelled, return cleanly —
3966
+ // no retry, no 3-strikes counter.
3967
+ if (externalAbortController?.signal.aborted) {
3968
+ logger.info({ taskName, phase }, 'Team task aborted mid-phase by caller');
3969
+ return lastOutput || `Team task aborted by caller at phase ${phase}.`;
3970
+ }
3936
3971
  logger.error({ err, taskName, phase }, 'Team task phase error');
3937
3972
  consecutiveErrors++;
3938
3973
  if (consecutiveErrors >= 3) {
@@ -3942,6 +3977,7 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
3942
3977
  continue;
3943
3978
  }
3944
3979
  clearTimeout(phaseTimer);
3980
+ externalAbortController?.signal.removeEventListener('abort', onExternalAbort);
3945
3981
  sessionId = phaseSessionId;
3946
3982
  lastOutput = phaseOutput.trim();
3947
3983
  consecutiveErrors = 0;
@@ -23,6 +23,10 @@ export interface RouteDecision {
23
23
  confidence: number;
24
24
  reasoning: string;
25
25
  }
26
+ export declare function isDirectImperative(userMessage: string): {
27
+ match: boolean;
28
+ pattern?: string;
29
+ };
26
30
  /**
27
31
  * Session keys eligible for routing. Any key NOT in this set is
28
32
  * considered agent-scoped or system-scoped and never routes.
@@ -18,6 +18,36 @@
18
18
  */
19
19
  import pino from 'pino';
20
20
  const logger = pino({ name: 'clementine.route-classifier' });
21
+ /**
22
+ * Direct-imperative guardrail.
23
+ *
24
+ * When the user is explicitly instructing Clementine to do the work herself
25
+ * — "I need you to…", "you need to…", "use the CLI…", "stop/wait" —
26
+ * routing to a specialist is almost always wrong. The user knows who they
27
+ * are talking to and is asking *her* to act. Delegating here produces the
28
+ * "every message spawns a team task" cascade that leaves the user waiting
29
+ * and shouting "Stop".
30
+ *
31
+ * This check runs before the LLM classifier and the explicit-mention fast
32
+ * path, so even "Nora, I need you to do X" stays with Clementine when Nate
33
+ * is DMing Clementine (the `isRoutable` gate already guarantees the session
34
+ * belongs to Clementine).
35
+ */
36
+ const DIRECT_IMPERATIVE_PATTERNS = [
37
+ /\bi (need|want|would like) you to\b/i,
38
+ /\byou (need to|should|gotta|have to|must)\b/i,
39
+ /\b(please )?(use|run|execute|call|invoke|query|check|pull|grab|fetch|look up|lookup|search) (the )?(sf|salesforce|cli|bash|sdk|api|db|database)\b/i,
40
+ /^(stop|wait|hold on|nevermind|never mind|cancel)\b/i,
41
+ ];
42
+ export function isDirectImperative(userMessage) {
43
+ const text = userMessage.trim();
44
+ for (const re of DIRECT_IMPERATIVE_PATTERNS) {
45
+ const m = text.match(re);
46
+ if (m)
47
+ return { match: true, pattern: m[0] };
48
+ }
49
+ return { match: false };
50
+ }
21
51
  /**
22
52
  * Session keys eligible for routing. Any key NOT in this set is
23
53
  * considered agent-scoped or system-scoped and never routes.
@@ -156,6 +186,13 @@ export async function classifyRoute(userMessage, agents, gateway) {
156
186
  const specialists = agents.filter(a => a.slug !== 'clementine');
157
187
  if (specialists.length === 0)
158
188
  return null;
189
+ // Direct-imperative guardrail: user is instructing Clementine to act —
190
+ // do not delegate, even if an agent is named.
191
+ const imperative = isDirectImperative(userMessage);
192
+ if (imperative.match) {
193
+ logger.info({ pattern: imperative.pattern }, 'Routing skipped — direct imperative');
194
+ return null;
195
+ }
159
196
  // Fast path: explicit slug mention anywhere in the message.
160
197
  for (const a of specialists) {
161
198
  const nameLower = a.name.toLowerCase();
@@ -76,8 +76,6 @@ export declare class AgentBotClient {
76
76
  sendDmTo(userId: string, text: string, embed?: EmbedBuilder): Promise<void>;
77
77
  /** Send a message to a specific channel via this agent bot. */
78
78
  sendToChannel(channelId: string, text: string, embed?: EmbedBuilder): Promise<void>;
79
- /** Send a startup status embed to the owner's DMs. */
80
- private sendStartupStatus;
81
79
  private buildAgentStatusEmbed;
82
80
  private sendOrUpdateStatusEmbed;
83
81
  /**
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { ActionRowBuilder, ActivityType, ChannelType, Client, EmbedBuilder, Events, GatewayIntentBits, ModalBuilder, Partials, REST, Routes, SlashCommandBuilder, TextInputBuilder, TextInputStyle, } from 'discord.js';
16
16
  import pino from 'pino';
17
- import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, sanitizeResponse } from './discord-utils.js';
17
+ import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, sanitizeResponse, rehydrateStatusEmbed, setSavedStatusEmbed } from './discord-utils.js';
18
18
  import { MODELS } from '../config.js';
19
19
  import * as cronParser from 'cron-parser';
20
20
  const logger = pino({ name: 'clementine.agent-bot' });
@@ -124,10 +124,14 @@ export class AgentBotClient {
124
124
  type: ActivityType.Custom,
125
125
  }],
126
126
  });
127
- // Send startup status to owner's DMs
128
- await this.sendStartupStatus();
129
- // Send status embed to the agent's primary channel (if available)
127
+ // Intentionally NOT sending a startup DM. The main Clementine bot
128
+ // already posts a consolidated "here's who's online" embed in the
129
+ // owner DM, so per-agent DMs on every restart are pure noise.
130
+ // Send status embed to the agent's primary channel (if available).
131
+ // Rehydrate any prior embed first so we edit-in-place instead of
132
+ // posting a fresh pinned message on every restart.
130
133
  if (this.config.cronScheduler && this.resolvedChannelIds.length > 0) {
134
+ this.statusEmbedMessage = await rehydrateStatusEmbed(this.client, this.config.slug);
131
135
  await this.sendOrUpdateStatusEmbed();
132
136
  // Auto-update status embed on state changes (debounced)
133
137
  this.config.cronScheduler.onStatusChange(() => {
@@ -289,30 +293,6 @@ export class AgentBotClient {
289
293
  }
290
294
  }
291
295
  }
292
- /** Send a startup status embed to the owner's DMs. */
293
- async sendStartupStatus() {
294
- if (!this.config.ownerId)
295
- return;
296
- try {
297
- const owner = await this.client.users.fetch(this.config.ownerId, { force: true });
298
- const dmChannel = await owner.createDM();
299
- // Use the rich agent status embed if cronScheduler is available
300
- const embed = this.config.cronScheduler
301
- ? this.buildAgentStatusEmbed()
302
- : new EmbedBuilder()
303
- .setColor(0x22c55e)
304
- .setTitle(`${this.config.profile.name} is online`)
305
- .setDescription(this.config.profile.description)
306
- .addFields({ name: 'Model', value: this.config.profile.model || 'sonnet', inline: true }, { name: 'Tier', value: String(this.config.profile.tier), inline: true })
307
- .setFooter({ text: `Agent bot \u00b7 ${this.client.user?.tag ?? 'unknown'}` })
308
- .setTimestamp();
309
- await dmChannel.send({ embeds: [embed] });
310
- logger.info({ slug: this.config.slug }, 'Sent startup status embed to owner DMs');
311
- }
312
- catch (err) {
313
- logger.error({ err, slug: this.config.slug }, 'Failed to send startup status embed');
314
- }
315
- }
316
296
  // ── Agent-scoped status embed ──────────────────────────────────────
317
297
  buildAgentStatusEmbed() {
318
298
  const now = new Date();
@@ -454,6 +434,11 @@ export class AgentBotClient {
454
434
  await this.statusEmbedMessage.pin();
455
435
  }
456
436
  catch { /* may lack perms */ }
437
+ // Persist so the next restart edits this same message instead of
438
+ // posting another pinned copy.
439
+ if (this.statusEmbedMessage) {
440
+ setSavedStatusEmbed(this.config.slug, channelId, this.statusEmbedMessage.id);
441
+ }
457
442
  }
458
443
  }
459
444
  }
@@ -854,6 +839,9 @@ export class AgentBotClient {
854
839
  await this.statusEmbedMessage.pin();
855
840
  }
856
841
  catch { /* non-fatal */ }
842
+ if (this.statusEmbedMessage) {
843
+ setSavedStatusEmbed(this.config.slug, message.channel.id, this.statusEmbedMessage.id);
844
+ }
857
845
  }
858
846
  }
859
847
  else {
@@ -4,7 +4,19 @@
4
4
  * Extracted from discord.ts so agent bot clients can reuse streaming,
5
5
  * chunking, and sanitization without importing the monolith.
6
6
  */
7
- import { EmbedBuilder, type Message } from 'discord.js';
7
+ import { EmbedBuilder, type Client, type Message } from 'discord.js';
8
+ export declare function getSavedStatusEmbed(slug: string): {
9
+ channelId: string;
10
+ messageId: string;
11
+ } | null;
12
+ export declare function setSavedStatusEmbed(slug: string, channelId: string, messageId: string): void;
13
+ export declare function clearSavedStatusEmbed(slug: string): void;
14
+ /**
15
+ * Try to re-hydrate a previously-saved status embed message so that on
16
+ * restart the bot edits it in place instead of posting a fresh one.
17
+ * Returns the Message if still reachable, else null.
18
+ */
19
+ export declare function rehydrateStatusEmbed(client: Client, slug: string): Promise<Message | null>;
8
20
  export declare const STREAM_EDIT_INTERVAL = 400;
9
21
  export declare const THINKING_INDICATOR = "\u2728 *thinking...*";
10
22
  export declare const DISCORD_MSG_LIMIT = 2000;
@@ -5,6 +5,70 @@
5
5
  * chunking, and sanitization without importing the monolith.
6
6
  */
7
7
  import { EmbedBuilder } from 'discord.js';
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import path from 'node:path';
10
+ import pino from 'pino';
11
+ import { BASE_DIR } from '../config.js';
12
+ const utilsLogger = pino({ name: 'clementine.discord-utils' });
13
+ // ── Persistent status-embed state ─────────────────────────────────────
14
+ //
15
+ // When the daemon restarts, in-memory references to status-embed messages
16
+ // are lost, so the bots used to post a fresh pinned embed every time.
17
+ // Persist (channelId, messageId) per slug so the next boot can fetch the
18
+ // existing message and edit it in place — no restart ping spam.
19
+ const STATUS_EMBED_STATE_FILE = path.join(BASE_DIR, '.agent-status-embeds.json');
20
+ function loadStatusEmbedState() {
21
+ try {
22
+ if (!existsSync(STATUS_EMBED_STATE_FILE))
23
+ return {};
24
+ return JSON.parse(readFileSync(STATUS_EMBED_STATE_FILE, 'utf-8'));
25
+ }
26
+ catch {
27
+ return {};
28
+ }
29
+ }
30
+ function saveStatusEmbedState(state) {
31
+ try {
32
+ writeFileSync(STATUS_EMBED_STATE_FILE, JSON.stringify(state, null, 2));
33
+ }
34
+ catch (err) {
35
+ utilsLogger.debug({ err }, 'Failed to save status embed state (non-fatal)');
36
+ }
37
+ }
38
+ export function getSavedStatusEmbed(slug) {
39
+ const s = loadStatusEmbedState()[slug];
40
+ return s ?? null;
41
+ }
42
+ export function setSavedStatusEmbed(slug, channelId, messageId) {
43
+ const state = loadStatusEmbedState();
44
+ state[slug] = { channelId, messageId };
45
+ saveStatusEmbedState(state);
46
+ }
47
+ export function clearSavedStatusEmbed(slug) {
48
+ const state = loadStatusEmbedState();
49
+ delete state[slug];
50
+ saveStatusEmbedState(state);
51
+ }
52
+ /**
53
+ * Try to re-hydrate a previously-saved status embed message so that on
54
+ * restart the bot edits it in place instead of posting a fresh one.
55
+ * Returns the Message if still reachable, else null.
56
+ */
57
+ export async function rehydrateStatusEmbed(client, slug) {
58
+ const saved = getSavedStatusEmbed(slug);
59
+ if (!saved)
60
+ return null;
61
+ try {
62
+ const channel = await client.channels.fetch(saved.channelId).catch(() => null);
63
+ if (!channel || !('messages' in channel))
64
+ return null;
65
+ const msg = await channel.messages.fetch(saved.messageId).catch(() => null);
66
+ return msg ?? null;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
8
72
  export const STREAM_EDIT_INTERVAL = 400;
9
73
  export const THINKING_INDICATOR = '\u2728 *thinking...*';
10
74
  export const DISCORD_MSG_LIMIT = 2000;
@@ -10,7 +10,7 @@ import pino from 'pino';
10
10
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
11
11
  import os from 'node:os';
12
12
  import path from 'node:path';
13
- import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, formatCronEmbed, } from './discord-utils.js';
13
+ import { chunkText, sendChunked, DiscordStreamingMessage, friendlyToolName, formatCronEmbed, rehydrateStatusEmbed, setSavedStatusEmbed, } from './discord-utils.js';
14
14
  import { DISCORD_TOKEN, DISCORD_OWNER_ID, DISCORD_WATCHED_CHANNELS, MODELS, ASSISTANT_NAME, PKG_DIR, VAULT_DIR, BASE_DIR, DEFAULT_MODEL_TIER, ENABLE_1M_CONTEXT, } from '../config.js';
15
15
  import { findProjectByName, getLinkedProjects } from '../agent/assistant.js';
16
16
  import * as cronParser from 'cron-parser';
@@ -62,6 +62,20 @@ const slashCommands = [
62
62
  new SlashCommandBuilder().setName('help').setDescription('Show all available commands'),
63
63
  ];
64
64
  const botMessageMap = new Map();
65
+ /**
66
+ * Recognize short natural-language stop commands so the user doesn't have
67
+ * to remember the `!stop` slash syntax. Only matches short standalone
68
+ * messages (≤30 chars) so a longer message containing the word "stop" is
69
+ * not misread as an abort.
70
+ */
71
+ function isStopCommand(text) {
72
+ const t = text.trim().toLowerCase().replace(/[.!?,]+$/g, '');
73
+ if (t === '!stop' || t === '/stop')
74
+ return true;
75
+ if (t.length > 30)
76
+ return false;
77
+ return /^(stop|cancel|nevermind|never mind|hold on|wait( (stop|up))?|pause|abort)$/.test(t);
78
+ }
65
79
  function trackBotMessage(messageId, context) {
66
80
  botMessageMap.set(messageId, context);
67
81
  // Evict oldest entries to prevent memory leak
@@ -576,6 +590,11 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
576
590
  await statusEmbedMessage.pin();
577
591
  }
578
592
  catch { /* may already be pinned or lack perms */ }
593
+ // Persist so the next restart edits this message instead of posting another
594
+ const channelId = statusEmbedMessage?.channelId ?? target?.id;
595
+ if (channelId && statusEmbedMessage) {
596
+ setSavedStatusEmbed('clementine', channelId, statusEmbedMessage.id);
597
+ }
579
598
  }
580
599
  }
581
600
  catch (err) {
@@ -599,6 +618,10 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
599
618
  await statusEmbedMessage.pin();
600
619
  }
601
620
  catch { /* non-fatal */ }
621
+ const channelId = statusEmbedMessage?.channelId ?? channel?.id;
622
+ if (channelId && statusEmbedMessage) {
623
+ setSavedStatusEmbed('clementine', channelId, statusEmbedMessage.id);
624
+ }
602
625
  }
603
626
  }
604
627
  catch (err) {
@@ -621,16 +644,19 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
621
644
  logger.error({ err }, 'Failed to register slash commands');
622
645
  }
623
646
  updatePresence();
624
- // Auto-send status embed to owner's DMs on startup
647
+ // Rehydrate + auto-update the owner-DM status embed. If a prior pinned
648
+ // embed is still reachable we edit it in place so restarts don't spam
649
+ // the owner's DM with fresh pinned messages.
625
650
  try {
626
651
  const owner = await client.users.fetch(DISCORD_OWNER_ID, { force: true });
627
652
  const dmChannel = await owner.createDM();
628
653
  cachedDmChannel = dmChannel;
654
+ statusEmbedMessage = await rehydrateStatusEmbed(client, 'clementine');
629
655
  await sendOrUpdateStatusEmbed(dmChannel);
630
- logger.info('Sent startup status embed to owner DMs');
656
+ logger.info({ rehydrated: !!statusEmbedMessage }, 'Status embed ready for owner DM');
631
657
  }
632
658
  catch (err) {
633
- logger.error({ err }, 'Failed to send startup status embed');
659
+ logger.error({ err }, 'Failed to prepare startup status embed');
634
660
  }
635
661
  // Event-driven embed updates — debounced to avoid API spam
636
662
  cronScheduler.onStatusChange(() => {
@@ -1078,8 +1104,12 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
1078
1104
  }
1079
1105
  catch { /* referenced message may be deleted */ }
1080
1106
  }
1081
- // ── !stop — abort active query (bypasses session lock) ────────────
1082
- if (isDm && (text === '!stop' || text === '/stop')) {
1107
+ // ── Stop command — abort active query + any in-flight team tasks ─
1108
+ // Accept the canonical !stop/ /stop AND plain natural-language variants
1109
+ // ("stop", "Stop", "Stop.", "cancel", "wait stop", "nevermind") that
1110
+ // users actually type. Only triggers on short standalone messages so
1111
+ // we don't accidentally abort a longer message that contains "stop".
1112
+ if (isDm && isStopCommand(text)) {
1083
1113
  const stopped = gateway.stopSession(sessionKey);
1084
1114
  await message.reply(stopped ? 'Stopping...' : 'Nothing running to stop.');
1085
1115
  return;
@@ -854,12 +854,12 @@ export class CronScheduler {
854
854
  const startedAt = new Date();
855
855
  try {
856
856
  // Standard cron jobs get a timeout via SDK AbortController (advisor may override)
857
- let response = await this.gateway.handleCronJob(job.name, jobPrompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria);
857
+ let response = await this.gateway.handleCronJob(job.name, jobPrompt, job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug);
858
858
  // alwaysDeliver: retry once if the response is empty/noise
859
859
  if (job.alwaysDeliver && (!response || CronScheduler.isCronNoise(response))) {
860
860
  logger.info({ job: job.name }, 'alwaysDeliver: empty/noise response — retrying once');
861
861
  try {
862
- const retryResponse = await this.gateway.handleCronJob(job.name, jobPrompt + '\n\nYou MUST produce a brief status update. Do NOT return __NOTHING__.', job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria);
862
+ const retryResponse = await this.gateway.handleCronJob(job.name, jobPrompt + '\n\nYou MUST produce a brief status update. Do NOT return __NOTHING__.', job.tier, job.maxTurns, job.model, job.workDir, job.mode, job.maxHours, effectiveTimeoutMs, job.successCriteria, job.agentSlug);
863
863
  if (retryResponse && !CronScheduler.isCronNoise(retryResponse)) {
864
864
  response = retryResponse;
865
865
  }
@@ -1563,7 +1563,10 @@ export class CronScheduler {
1563
1563
  enriched: !!advice.promptEnrichment,
1564
1564
  }, 'Goal work: advisor applied');
1565
1565
  this.gateway.handleCronJob(jobName, enrichedPrompt, 2, effectiveMaxTurns, effectiveModel, undefined, // workDir
1566
- useUnleashed ? 'unleashed' : undefined, useUnleashed ? 1 : undefined).then((result) => {
1566
+ useUnleashed ? 'unleashed' : undefined, useUnleashed ? 1 : undefined, // 1 hour max for unleashed goal work
1567
+ undefined, // timeoutMs (use default)
1568
+ undefined, // successCriteria
1569
+ ownerSlug ?? undefined).then((result) => {
1567
1570
  if (result && !CronScheduler.isCronNoise(result)) {
1568
1571
  this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`, dispatchOpts).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
1569
1572
  }
@@ -1576,7 +1579,13 @@ export class CronScheduler {
1576
1579
  }).catch((err) => {
1577
1580
  // Advisor import failed — fall back to basic execution
1578
1581
  logger.warn({ err, goalId: trigger.goalId }, 'Advisor unavailable — running goal work with defaults');
1579
- this.gateway.handleCronJob(jobName, prompt, 2, trigger.maxTurns ?? 15).then((result) => {
1582
+ this.gateway.handleCronJob(jobName, prompt, 2, trigger.maxTurns ?? 15, undefined, // model
1583
+ undefined, // workDir
1584
+ undefined, // mode
1585
+ undefined, // maxHours
1586
+ undefined, // timeoutMs
1587
+ undefined, // successCriteria
1588
+ ownerSlug ?? undefined).then((result) => {
1580
1589
  if (result && !CronScheduler.isCronNoise(result)) {
1581
1590
  this.dispatcher.send(`🎯 **Goal work: ${goal.title}**\n\n${result.slice(0, 1500)}`, dispatchOpts).catch(err => logger.debug({ err }, 'Failed to send goal work notification'));
1582
1591
  }
@@ -46,6 +46,12 @@ export declare class Gateway {
46
46
  private _saveSeenChannels;
47
47
  /** Mark a channel as seen (owner approved or explicitly always-allowed). */
48
48
  markChannelSeen(channelKey: string): void;
49
+ /**
50
+ * Resolve the agent slug for a session so cross-agent delivery stays in-persona.
51
+ * Prefers the explicit session profile (set by agent bots + cron-scheduler), then
52
+ * falls back to parsing the session-key format.
53
+ */
54
+ private _agentSlugFromSessionKey;
49
55
  /**
50
56
  * Deliver a deep-mode result back to the user.
51
57
  * Routes through the agent's session so it responds conversationally,
@@ -124,8 +130,8 @@ export declare class Gateway {
124
130
  clearSessionProfile(sessionKey: string): void;
125
131
  isSessionBusy(sessionKey: string): boolean;
126
132
  /**
127
- * Abort an in-progress chat query for a session.
128
- * Returns true if there was an active query to abort.
133
+ * Abort an in-progress chat query AND any in-flight delegated team tasks
134
+ * for this session. Returns true if anything was actually aborted.
129
135
  */
130
136
  stopSession(sessionKey: string): boolean;
131
137
  /**
@@ -138,13 +144,13 @@ export declare class Gateway {
138
144
  private acquireSessionLock;
139
145
  handleMessage(sessionKey: string, text: string, onText?: OnTextCallback, model?: string, maxTurns?: number, onToolActivity?: OnToolActivityCallback): Promise<string>;
140
146
  handleHeartbeat(standingInstructions: string, changesSummary?: string, timeContext?: string, dedupContext?: string, profile?: import('../types.js').AgentProfile | null): Promise<string>;
141
- handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[]): Promise<string>;
147
+ handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
142
148
  /**
143
149
  * Process a team message as an autonomous task — same multi-phase execution
144
150
  * as cron unleashed jobs, so agents can work until done instead of being
145
151
  * killed by the 5-minute interactive chat timeout.
146
152
  */
147
- handleTeamTask(fromName: string, fromSlug: string, content: string, profile: import('../types.js').AgentProfile, onText?: (token: string) => void): Promise<string>;
153
+ handleTeamTask(fromName: string, fromSlug: string, content: string, profile: import('../types.js').AgentProfile, onText?: (token: string) => void, abortController?: AbortController): Promise<string>;
148
154
  handlePlan(sessionKey: string, taskDescription: string, onProgress?: (updates: PlanProgressUpdate[]) => Promise<void>, onApproval?: (planSummary: string, steps: PlanStep[]) => Promise<boolean | string>): Promise<string>;
149
155
  handleWorkflow(workflow: WorkflowDefinition, inputs?: Record<string, string>): Promise<string>;
150
156
  /**
@@ -175,6 +175,24 @@ export class Gateway {
175
175
  this._loadSeenChannels().add(channelKey);
176
176
  this._saveSeenChannels();
177
177
  }
178
+ /**
179
+ * Resolve the agent slug for a session so cross-agent delivery stays in-persona.
180
+ * Prefers the explicit session profile (set by agent bots + cron-scheduler), then
181
+ * falls back to parsing the session-key format.
182
+ */
183
+ _agentSlugFromSessionKey(sessionKey) {
184
+ const profile = this.getSessionProfile(sessionKey);
185
+ if (profile && profile !== 'clementine')
186
+ return profile;
187
+ const parts = sessionKey.split(':');
188
+ if (parts[0] !== 'discord')
189
+ return undefined;
190
+ if (parts[1] === 'agent' || parts[1] === 'member-dm')
191
+ return parts[2];
192
+ if ((parts[1] === 'channel' || parts[1] === 'member') && parts.length >= 5)
193
+ return parts[3];
194
+ return undefined;
195
+ }
178
196
  /**
179
197
  * Deliver a deep-mode result back to the user.
180
198
  * Routes through the agent's session so it responds conversationally,
@@ -182,17 +200,19 @@ export class Gateway {
182
200
  * Falls back to pushing rawResult directly if the agent call fails.
183
201
  */
184
202
  async _deliverDeepResult(sessionKey, syntheticPrompt, rawFallback) {
203
+ const agentSlug = this._agentSlugFromSessionKey(sessionKey);
204
+ const ctx = { sessionKey, ...(agentSlug ? { agentSlug } : {}) };
185
205
  try {
186
206
  const agentReply = await this.handleMessage(sessionKey, syntheticPrompt);
187
207
  if (agentReply?.trim()) {
188
- await this._dispatcher?.send(agentReply, { sessionKey });
189
- logger.info({ sessionKey }, 'Deep mode result delivered via agent follow-up + dispatcher');
208
+ await this._dispatcher?.send(agentReply, ctx);
209
+ logger.info({ sessionKey, agentSlug }, 'Deep mode result delivered via agent follow-up + dispatcher');
190
210
  }
191
211
  }
192
212
  catch (err) {
193
213
  logger.warn({ err, sessionKey }, 'Deep mode agent follow-up failed — using raw fallback');
194
214
  if (rawFallback.trim()) {
195
- await this._dispatcher?.send(rawFallback.slice(0, 1500), { sessionKey })
215
+ await this._dispatcher?.send(rawFallback.slice(0, 1500), ctx)
196
216
  .catch(async (e) => {
197
217
  // Both paths failed — surface it instead of swallowing at debug level.
198
218
  logger.warn({ err: e, sessionKey }, 'Deep mode fallback delivery failed — persisting to daily note');
@@ -239,16 +259,30 @@ export class Gateway {
239
259
  // Fire the team task in the background; ack immediately.
240
260
  const ackMessage = `Routing this to **${targetProfile.name}** (${decision.reasoning.toLowerCase()}). I'll post their response back here when done.`;
241
261
  onText?.(ackMessage).catch(() => { });
242
- this.handleTeamTask('Clementine', 'clementine', text, targetProfile)
262
+ // Track this task so "Stop" can abort it along with the chat query.
263
+ const teamAbortController = new AbortController();
264
+ const sess = this.getSession(sessionKey);
265
+ if (!sess.teamTaskControllers)
266
+ sess.teamTaskControllers = new Set();
267
+ sess.teamTaskControllers.add(teamAbortController);
268
+ this.handleTeamTask('Clementine', 'clementine', text, targetProfile, undefined, teamAbortController)
243
269
  .then(response => {
244
270
  if (!response)
245
271
  return;
246
272
  const delivery = `**${targetProfile.name}**: ${response}`;
247
- return this._dispatcher?.send(delivery, { sessionKey });
273
+ return this._dispatcher?.send(delivery, { sessionKey, agentSlug: targetProfile.slug });
248
274
  })
249
275
  .catch(err => {
276
+ if (teamAbortController.signal.aborted) {
277
+ logger.info({ target: decision.targetAgent, sessionKey }, 'Delegated task aborted by user');
278
+ return;
279
+ }
250
280
  logger.warn({ err, target: decision.targetAgent }, 'Delegated task failed');
251
- void this._dispatcher?.send(`**${targetProfile.name}** hit an error handling that: ${String(err).slice(0, 200)}`, { sessionKey });
281
+ void this._dispatcher?.send(`**${targetProfile.name}** hit an error handling that: ${String(err).slice(0, 200)}`, { sessionKey, agentSlug: targetProfile.slug });
282
+ })
283
+ .finally(() => {
284
+ const s = this.sessions.get(sessionKey);
285
+ s?.teamTaskControllers?.delete(teamAbortController);
252
286
  });
253
287
  return { delegated: true, ackMessage };
254
288
  }
@@ -580,17 +614,28 @@ export class Gateway {
580
614
  return this.sessions.get(sessionKey)?.lock !== undefined;
581
615
  }
582
616
  /**
583
- * Abort an in-progress chat query for a session.
584
- * Returns true if there was an active query to abort.
617
+ * Abort an in-progress chat query AND any in-flight delegated team tasks
618
+ * for this session. Returns true if anything was actually aborted.
585
619
  */
586
620
  stopSession(sessionKey) {
587
- const ac = this.sessions.get(sessionKey)?.abortController;
621
+ const s = this.sessions.get(sessionKey);
622
+ let aborted = false;
623
+ const ac = s?.abortController;
588
624
  if (ac && !ac.signal.aborted) {
589
625
  ac.abort();
590
- logger.info({ sessionKey }, 'Session stopped by user');
591
- return true;
626
+ aborted = true;
592
627
  }
593
- return false;
628
+ if (s?.teamTaskControllers?.size) {
629
+ for (const tac of s.teamTaskControllers) {
630
+ if (!tac.signal.aborted)
631
+ tac.abort();
632
+ }
633
+ aborted = true;
634
+ logger.info({ sessionKey, count: s.teamTaskControllers.size }, 'Aborted in-flight team tasks');
635
+ }
636
+ if (aborted)
637
+ logger.info({ sessionKey }, 'Session stopped by user');
638
+ return aborted;
594
639
  }
595
640
  /**
596
641
  * Serialize access to a session. If a query is already in-flight when a new
@@ -964,21 +1009,47 @@ export class Gateway {
964
1009
  }
965
1010
  }
966
1011
  // ── Deep mode detection ─────────────────────────────────────
967
- // Agent proposes background execution for complex tasks
968
- const deepMatch = response?.match(/^\[DEEP_MODE:\s*(.+?)\]\s*/s);
1012
+ // Agent proposes background execution for complex tasks.
1013
+ //
1014
+ // Syntax:
1015
+ // [DEEP_MODE: task description]
1016
+ // [DEEP_MODE(work_dir=/abs/path): task description] ← NEW
1017
+ //
1018
+ // The optional work_dir= parameter runs the unleashed task in a
1019
+ // different project directory. Useful when the task belongs to
1020
+ // a Claude Code project with its own CLAUDE.md / slash commands
1021
+ // (e.g. the proposal-builder project for audit-queue approvals).
1022
+ const deepMatch = response?.match(/^\[DEEP_MODE(?:\(([^)]+)\))?:\s*(.+?)\]\s*/s);
969
1023
  if (deepMatch) {
970
- const taskDesc = deepMatch[1].trim() || text;
971
- const ack = response.replace(/^\[DEEP_MODE:[^\]]*\]\s*/s, '').trim();
1024
+ const paramsStr = deepMatch[1] ?? '';
1025
+ const taskDesc = deepMatch[2].trim() || text;
1026
+ const ack = response.replace(/^\[DEEP_MODE(?:\([^)]*\))?:[^\]]*\]\s*/s, '').trim();
972
1027
  logger.info({ sessionKey, task: taskDesc }, 'Deep mode triggered by agent');
1028
+ // Parse optional work_dir parameter — strict: must be an absolute
1029
+ // path and must exist. Anything else falls back to default.
1030
+ let deepWorkDir;
1031
+ const wdMatch = paramsStr.match(/work_dir=([^,\s]+)/);
1032
+ if (wdMatch) {
1033
+ const candidate = wdMatch[1].trim().replace(/^["']|["']$/g, '');
1034
+ if (path.isAbsolute(candidate) && existsSync(candidate)) {
1035
+ deepWorkDir = candidate;
1036
+ logger.info({ sessionKey, workDir: deepWorkDir }, 'Deep mode using custom work_dir');
1037
+ }
1038
+ else {
1039
+ logger.warn({ sessionKey, candidate }, 'Deep mode work_dir rejected (not absolute or does not exist)');
1040
+ }
1041
+ }
973
1042
  const currentSess = this.getSession(sessionKey);
974
1043
  const jobName = `deep-${Date.now()}`;
975
1044
  currentSess.deepTask = { jobName, taskDesc, startedAt: new Date().toISOString() };
1045
+ const deepAgentSlug = this._agentSlugFromSessionKey(sessionKey);
976
1046
  // Spawn unleashed task in background — don't await
977
1047
  this.assistant.runUnleashedTask(jobName, `${taskDesc}\n\nOriginal request: ${text}`, 2, // tier 2 (Bash/Write/Edit enabled)
978
1048
  undefined, // default maxTurns (75/phase)
979
1049
  undefined, // default model
980
- undefined, // default workDir
981
- 1).then(async (result) => {
1050
+ deepWorkDir, // honors [DEEP_MODE(work_dir=...)] if provided
1051
+ 1, // maxHours
1052
+ deepAgentSlug).then(async (result) => {
982
1053
  logger.info({ sessionKey, jobName, resultLen: result?.length ?? 0 }, 'Deep mode task completed');
983
1054
  if (result && result !== '__NOTHING__') {
984
1055
  this.assistant.injectPendingContext(sessionKey, text, result);
@@ -1005,7 +1076,8 @@ export class Gateway {
1005
1076
  const currentSess = this.getSession(sessionKey);
1006
1077
  const jobName = `deep-${Date.now()}`;
1007
1078
  currentSess.deepTask = { jobName, taskDesc: text.slice(0, 200), startedAt: new Date().toISOString() };
1008
- this.assistant.runUnleashedTask(jobName, `Continue working on this task. The user asked: ${text}\n\nYou already started in a quick session and made ${toolActivityCount} tool calls. Pick up where you left off and complete the work.`, 2, undefined, undefined, undefined, 1).then(async (result) => {
1079
+ const escAgentSlug = this._agentSlugFromSessionKey(sessionKey);
1080
+ this.assistant.runUnleashedTask(jobName, `Continue working on this task. The user asked: ${text}\n\nYou already started in a quick session and made ${toolActivityCount} tool calls. Pick up where you left off and complete the work.`, 2, undefined, undefined, undefined, 1, escAgentSlug).then(async (result) => {
1009
1081
  logger.info({ sessionKey, jobName, resultLen: result?.length ?? 0 }, 'Auto-escalated deep mode completed');
1010
1082
  if (result && result !== '__NOTHING__') {
1011
1083
  this.assistant.injectPendingContext(sessionKey, text, result);
@@ -1059,9 +1131,10 @@ export class Gateway {
1059
1131
  const currentSess = this.getSession(sessionKey);
1060
1132
  const jobName = `deep-${Date.now()}`;
1061
1133
  currentSess.deepTask = { jobName, taskDesc: text.slice(0, 200), startedAt: new Date().toISOString() };
1134
+ const mtAgentSlug = this._agentSlugFromSessionKey(sessionKey);
1062
1135
  // Grab any partial response that was streamed before the error
1063
1136
  const partialResponse = wrappedOnText ? lastStreamedText : '';
1064
- this.assistant.runUnleashedTask(jobName, `Continue working on this task. The user asked: ${text}\n\nYou already started and ran out of turns. Pick up where you left off and complete the work.`, 2, undefined, undefined, undefined, 1).then(async (result) => {
1137
+ this.assistant.runUnleashedTask(jobName, `Continue working on this task. The user asked: ${text}\n\nYou already started and ran out of turns. Pick up where you left off and complete the work.`, 2, undefined, undefined, undefined, 1, mtAgentSlug).then(async (result) => {
1065
1138
  logger.info({ sessionKey, jobName, resultLen: result?.length ?? 0 }, 'Max-turns deep mode completed');
1066
1139
  if (result && result !== '__NOTHING__') {
1067
1140
  this.assistant.injectPendingContext(sessionKey, text, result);
@@ -1134,19 +1207,19 @@ export class Gateway {
1134
1207
  releaseLane();
1135
1208
  }
1136
1209
  }
1137
- async handleCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, mode = 'standard', maxHours, timeoutMs, successCriteria) {
1210
+ async handleCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, mode = 'standard', maxHours, timeoutMs, successCriteria, agentSlug) {
1138
1211
  const releaseLane = await lanes.acquire('cron');
1139
1212
  try {
1140
- logger.info(`Running cron job: ${jobName}${workDir ? ` in ${workDir}` : ''}${mode === 'unleashed' ? ' (unleashed)' : ''}`);
1213
+ logger.info(`Running cron job: ${jobName}${workDir ? ` in ${workDir}` : ''}${mode === 'unleashed' ? ' (unleashed)' : ''}${agentSlug && agentSlug !== 'clementine' ? ` as ${agentSlug}` : ''}`);
1141
1214
  events.emit('cron:start', { jobName, tier, mode, timestamp: Date.now() });
1142
1215
  const cronStart = Date.now();
1143
1216
  try {
1144
1217
  let response;
1145
1218
  if (mode === 'unleashed') {
1146
- response = await this.assistant.runUnleashedTask(jobName, jobPrompt, tier, maxTurns, model, workDir, maxHours);
1219
+ response = await this.assistant.runUnleashedTask(jobName, jobPrompt, tier, maxTurns, model, workDir, maxHours, agentSlug);
1147
1220
  }
1148
1221
  else {
1149
- response = await this.assistant.runCronJob(jobName, jobPrompt, tier, maxTurns, model, workDir, timeoutMs, successCriteria);
1222
+ response = await this.assistant.runCronJob(jobName, jobPrompt, tier, maxTurns, model, workDir, timeoutMs, successCriteria, agentSlug);
1150
1223
  }
1151
1224
  // Re-baseline integrity checksums after cron job (may write to vault)
1152
1225
  scanner.refreshIntegrity();
@@ -1169,11 +1242,11 @@ export class Gateway {
1169
1242
  * as cron unleashed jobs, so agents can work until done instead of being
1170
1243
  * killed by the 5-minute interactive chat timeout.
1171
1244
  */
1172
- async handleTeamTask(fromName, fromSlug, content, profile, onText) {
1245
+ async handleTeamTask(fromName, fromSlug, content, profile, onText, abortController) {
1173
1246
  const releaseLane = await lanes.acquire('cron');
1174
1247
  try {
1175
1248
  logger.info({ fromSlug, toSlug: profile.slug }, 'Running team message as autonomous task');
1176
- const response = await this.assistant.runTeamTask(fromName, fromSlug, content, profile, onText);
1249
+ const response = await this.assistant.runTeamTask(fromName, fromSlug, content, profile, onText, abortController);
1177
1250
  scanner.refreshIntegrity();
1178
1251
  return response;
1179
1252
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.25",
3
+ "version": "1.0.27",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",