clementine-agent 1.18.49 → 1.18.50

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.
@@ -62,18 +62,41 @@ const CRON_FIXER_PROMPT = [
62
62
  '',
63
63
  'Return: a one-paragraph summary of what you applied (or what is blocking apply), per job.',
64
64
  ].join('\n');
65
+ /** Build a routing-signal description for a hired agent.
66
+ * The SDK uses descriptions for auto-routing — they must be imperative
67
+ * ("Use for: ..."), not narrative prose. Otherwise the main agent
68
+ * has nothing to match user phrasings against. */
69
+ function buildHiredAgentDescription(p) {
70
+ const role = p.role ?? p.description ?? `${p.name}, a hired agent`;
71
+ const slug = p.slug;
72
+ const capabilities = (p.routingHints && p.routingHints.length > 0)
73
+ ? p.routingHints.join(', ')
74
+ : (p.description ?? '').slice(0, 200);
75
+ return [
76
+ `Delegate to ${p.name} (${slug}).`,
77
+ capabilities ? `Use for: ${capabilities}.` : '',
78
+ `Role: ${role}.`,
79
+ 'Spawn this subagent when the user names them, asks a question in their domain, or asks Clementine to "have <name> do X".',
80
+ ].filter(Boolean).join(' ');
81
+ }
65
82
  /** Map a hired-agent profile to an AgentDefinition.
66
83
  * Used when Clementine wants to delegate to Ross/Sasha/Nora etc. */
67
84
  function profileToAgentDefinition(p) {
85
+ // Always include `Agent` so the subagent can further fan out, plus
86
+ // core read tools as a baseline. profile.team.allowedTools narrows
87
+ // beyond this when set.
88
+ const baseline = ['Agent', 'Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch', 'TodoWrite'];
89
+ const tools = p.team?.allowedTools?.length
90
+ ? Array.from(new Set(['Agent', ...p.team.allowedTools]))
91
+ : baseline;
68
92
  return {
69
- description: p.description ?? `${p.name} (hired agent: ${p.slug})`,
93
+ description: buildHiredAgentDescription(p),
70
94
  prompt: p.systemPromptBody ?? `You are ${p.name}.`,
71
- // Honor explicit allowlist when present; otherwise inherit from parent.
72
- ...(p.team?.allowedTools?.length ? { tools: p.team.allowedTools } : {}),
95
+ tools,
73
96
  // Hired agents keep their configured model (Sonnet by default).
74
97
  ...(p.model ? { model: p.model } : { model: 'sonnet' }),
75
- // Effort: hired agents do real work, default medium. Caller can override.
76
- effort: 'medium',
98
+ // Effort: hired agents do real work, default medium. Profile may override.
99
+ ...(p.effort ? { effort: p.effort } : { effort: 'medium' }),
77
100
  };
78
101
  }
79
102
  /**
@@ -89,8 +112,19 @@ export function buildAgentMap(opts = {}) {
89
112
  // ── System subagents ────────────────────────────────────────────
90
113
  // Planner: opus, no tools, single turn. Used when the parent agent
91
114
  // sees a multi-step request and wants a decomposition.
115
+ // Description is imperative + matches real user phrasings — the SDK
116
+ // matches against it for auto-routing, so prose doesn't trigger.
92
117
  map['planner'] = {
93
- description: 'Decompose a multi-step user request into atomic, parallel-safe steps. Use for "research these N items", "build a comprehensive X", "for each Y do Z", or any request that obviously involves multiple distinct sub-tasks. Returns a JSON plan; the parent then executes the steps (often by spawning more subagents per step).',
118
+ description: [
119
+ 'Use this subagent BEFORE doing the work whenever the user request',
120
+ 'involves 3 or more items, multiple distinct subtasks, or a phrase',
121
+ 'like "research my top N", "for each X do Y", "look at all of",',
122
+ '"go through every", "do A, B, and C", or any task that would burn',
123
+ 'context if processed serially. The planner returns a JSON plan',
124
+ 'with parallel-safe steps; you then spawn researcher/cron-fixer/',
125
+ 'hired-agent subagents per step. Always prefer this over doing',
126
+ 'multi-item work yourself in the main conversation.',
127
+ ].join(' '),
94
128
  prompt: PLANNER_PROMPT,
95
129
  model: 'opus',
96
130
  tools: [], // pure reasoning, no tools
@@ -98,11 +132,18 @@ export function buildAgentMap(opts = {}) {
98
132
  maxTurns: 1,
99
133
  };
100
134
  // Researcher: haiku, per-item investigation. Cheap fan-out target.
135
+ // No Bash — researcher is read-only fanout, must not mutate state.
101
136
  map['researcher'] = {
102
- description: 'Investigate ONE specific item (one lead, one account, one file, one topic) and return a one-paragraph summary. Use for per-item parallel work spawned by the planner. Cheap and fast.',
137
+ description: [
138
+ 'Use this subagent to investigate ONE specific item — a single',
139
+ 'lead, account, file, web page, or topic — and return a',
140
+ 'one-paragraph summary. Spawn it in PARALLEL via the Agent tool',
141
+ 'with one subagent per item when the planner returns multiple',
142
+ 'research steps. Read-only: never mutates state. Cheap (Haiku).',
143
+ ].join(' '),
103
144
  prompt: RESEARCHER_PROMPT,
104
145
  model: 'haiku',
105
- tools: ['Read', 'Grep', 'Glob', 'Bash', 'WebSearch', 'WebFetch'],
146
+ tools: ['Read', 'Grep', 'Glob', 'WebSearch', 'WebFetch'],
106
147
  effort: 'low',
107
148
  maxTurns: 15,
108
149
  };
@@ -27,13 +27,10 @@ import { AgentManager } from './agent-manager.js';
27
27
  * SDK result; this function is for pre-flight planning only.
28
28
  */
29
29
  export declare function estimateTokens(text: string): number;
30
- export declare function looksLikeContextThrashText(value: unknown): boolean;
31
30
  /** Format a millisecond duration as a human-friendly "X ago" string. */
32
31
  export declare function formatTimeAgo(ms: number): string;
33
- export declare function scrubInternalContextBlocks(text: string): string;
34
32
  export declare function looksLikeOneMillionContextError(value: unknown): boolean;
35
33
  export declare function oneMillionContextRecoveryMessage(): string;
36
- export declare function looksLikeProviderApiErrorResponse(value: unknown): boolean;
37
34
  export declare function looksLikeNoResponseRequested(value: unknown): boolean;
38
35
  /** Autonomous jobs use this sentinel to mean "completed, but do not notify the owner." */
39
36
  export declare function isAutonomousNothingOutput(response: string): boolean;
@@ -58,13 +55,6 @@ export interface ProactiveGoalInput {
58
55
  nextActions?: string[];
59
56
  };
60
57
  }
61
- /**
62
- * Build the compact "active goals" block that gets injected when no goal
63
- * keyword matches the user's prompt. Pure so it can be tested without the
64
- * full Assistant/vault setup.
65
- */
66
- export declare function buildActiveGoalsBlock(goals: ProactiveGoalInput[], agentSlug?: string | null, maxEntries?: number): string;
67
- export declare function chunkReferencedInResponse(chunkContent: string, responseLower: string): boolean;
68
58
  export declare class PersonalAssistant {
69
59
  static readonly MAX_SESSION_EXCHANGES = 40;
70
60
  private sessions;
@@ -169,10 +169,6 @@ export function estimateTokens(text) {
169
169
  return 0;
170
170
  return Math.ceil(text.length / 3.3);
171
171
  }
172
- export function looksLikeContextThrashText(value) {
173
- const text = String(value ?? '');
174
- return /autocompact\s+is\s+thrashing|context\s+refilled\s+to\s+the\s+limit|refilled\s+to\s+the\s+limit\s+within/i.test(text);
175
- }
176
172
  /**
177
173
  * Strip lone Unicode surrogates (U+D800–U+DFFF) from a string so it can be
178
174
  * safely serialized to JSON. Lone surrogates are valid in JS strings but
@@ -233,25 +229,12 @@ export function formatTimeAgo(ms) {
233
229
  function capContextBlock(text, maxChars) {
234
230
  return capOutput(String(text ?? ''), maxChars);
235
231
  }
236
- export function scrubInternalContextBlocks(text) {
237
- return text
238
- .replace(/\[Context governance:[^\]]*\][\s\S]*?\[\/Context governance:[^\]]*\]\s*/gi, '')
239
- .replace(/\[Active working set\][\s\S]*?\[\/Active working set\]\s*/gi, '')
240
- .replace(/\[Recent proactive notification context\][\s\S]*?\[\/Recent proactive notification context\]\s*/gi, '')
241
- .trim();
242
- }
243
232
  export function looksLikeOneMillionContextError(value) {
244
233
  return looksLikeClaudeOneMillionContextError(value);
245
234
  }
246
235
  export function oneMillionContextRecoveryMessage() {
247
236
  return "Claude rejected 1M context for this account. I've switched Clementine to persistent 200K recovery mode and reset the session. Restart Clementine once so every background worker starts with the same safe setting.";
248
237
  }
249
- export function looksLikeProviderApiErrorResponse(value) {
250
- const text = String(value ?? '').trim();
251
- return /^api error:/i.test(text)
252
- || /^error:\s*api error:/i.test(text)
253
- || looksLikeOneMillionContextError(text);
254
- }
255
238
  export function looksLikeNoResponseRequested(value) {
256
239
  const text = String(value ?? '').trim();
257
240
  return /^no response requested\.?$/i.test(text);
@@ -711,72 +694,6 @@ export function removeProject(projectPath) {
711
694
  _projectsMetaCacheTime = 0; // invalidate cache
712
695
  return true;
713
696
  }
714
- // ── Retrieval Outcome Heuristic ─────────────────────────────────────
715
- /**
716
- * Decide whether a retrieved memory chunk shows up in the assistant's
717
- * response. We key on distinctive tokens (multi-letter capitalized words,
718
- * numbers of 2+ digits) that are unlikely to appear in the response unless
719
- * the chunk's content actually influenced what was said.
720
- *
721
- * Intentionally a cheap local heuristic — no LLM call. False positives are
722
- * tolerable since the outcome score is bounded and averaged over many
723
- * observations.
724
- */
725
- const OUTCOME_STOPWORDS = new Set([
726
- 'there', 'these', 'those', 'their', 'where', 'which', 'while',
727
- 'would', 'could', 'should', 'about', 'being', 'after', 'before',
728
- 'again', 'against', 'because',
729
- ]);
730
- /**
731
- * Build the compact "active goals" block that gets injected when no goal
732
- * keyword matches the user's prompt. Pure so it can be tested without the
733
- * full Assistant/vault setup.
734
- */
735
- export function buildActiveGoalsBlock(goals, agentSlug, maxEntries = 6) {
736
- if (goals.length === 0)
737
- return '';
738
- const filtered = goals.filter(({ goal }) => {
739
- if (!agentSlug)
740
- return true;
741
- return goal.owner === agentSlug || goal.owner === 'clementine';
742
- });
743
- if (filtered.length === 0)
744
- return '';
745
- const rank = { high: 0, medium: 1, low: 2 };
746
- const sorted = [...filtered].sort((a, b) => {
747
- const ra = rank[a.goal.priority ?? 'medium'] ?? 1;
748
- const rb = rank[b.goal.priority ?? 'medium'] ?? 1;
749
- return ra - rb;
750
- });
751
- const top = sorted.slice(0, maxEntries);
752
- const lines = top.map(({ goal }) => {
753
- const next = goal.nextActions?.[0];
754
- const nextBit = next ? ` → ${String(next).slice(0, 80)}` : '';
755
- return `- [${goal.priority ?? 'medium'}] ${goal.title}${nextBit}`;
756
- });
757
- return `\n\n## Active Goals (background context)\n${lines.join('\n')}\n`;
758
- }
759
- export function chunkReferencedInResponse(chunkContent, responseLower) {
760
- if (!chunkContent || !responseLower)
761
- return false;
762
- const distinctive = new Set();
763
- const capMatches = chunkContent.match(/\b[A-Z][a-zA-Z]{3,}\b/g) ?? [];
764
- for (const m of capMatches) {
765
- const lower = m.toLowerCase();
766
- if (!OUTCOME_STOPWORDS.has(lower))
767
- distinctive.add(lower);
768
- }
769
- const numMatches = chunkContent.match(/\b\d{2,}\b/g) ?? [];
770
- for (const m of numMatches)
771
- distinctive.add(m);
772
- if (distinctive.size === 0)
773
- return false;
774
- for (const tok of distinctive) {
775
- if (responseLower.includes(tok))
776
- return true;
777
- }
778
- return false;
779
- }
780
697
  // ── PersonalAssistant ───────────────────────────────────────────────
781
698
  export class PersonalAssistant {
782
699
  static MAX_SESSION_EXCHANGES = MAX_SESSION_EXCHANGES;
@@ -347,12 +347,11 @@ export async function classifyRoute(userMessage, agents, gateway) {
347
347
  3, // maxTurns — classifier doesn't need tools
348
348
  'haiku', // cheap
349
349
  undefined, // workDir
350
- 'standard', // mode
350
+ 'standard', // mode (display only)
351
351
  undefined, // maxHours
352
352
  undefined, // timeoutMs
353
353
  undefined, // successCriteria
354
- undefined, // agentSlug
355
- { disableAllTools: true });
354
+ undefined);
356
355
  }
357
356
  catch (err) {
358
357
  logger.warn({ err }, 'Route classifier call failed');
@@ -0,0 +1,17 @@
1
+ import type { AgentProfile } from '../types.js';
2
+ export interface BuildChatContextOptions {
3
+ /** Active hired-agent profile, when set. The agent-specific MEMORY.md
4
+ * in `agents/<slug>/MEMORY.md` is preferred over the global one. */
5
+ profile?: AgentProfile | null;
6
+ /** Optional caller-supplied systemPromptBody to append after the
7
+ * vault context block (used by hired-agent profiles). */
8
+ profileAppend?: string;
9
+ }
10
+ /**
11
+ * Build the system-prompt append string for chat invocations.
12
+ *
13
+ * Returns an empty string when none of the source files exist — the
14
+ * SDK then runs with just the bare `claude_code` preset.
15
+ */
16
+ export declare function buildChatSystemAppend(opts?: BuildChatContextOptions): string;
17
+ //# sourceMappingURL=run-agent-context.d.ts.map
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Build the vault context block that gets appended to the SDK's
3
+ * `claude_code` system prompt preset for chat sessions.
4
+ *
5
+ * The legacy chat path (`assistant.ts:buildSystemPrompt`) injected
6
+ * SOUL.md (personality), MEMORY.md (long-term memory), AGENTS.md
7
+ * (team awareness), and the agent-specific working-memory file. Without
8
+ * those, the canonical chat path loses the personality, preferences,
9
+ * and team-roster knowledge that distinguish Clementine from a generic
10
+ * SDK agent.
11
+ *
12
+ * Canonical pattern: SDK accepts `systemPrompt: { type: 'preset',
13
+ * preset: 'claude_code', append: <string> }`. We append a single
14
+ * concatenated context block — no wrappers, no recursive prompts.
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { SOUL_FILE, AGENTS_FILE, MEMORY_FILE, AGENTS_DIR } from '../config.js';
19
+ const SOUL_MAX_CHARS = 6_000;
20
+ const MEMORY_MAX_CHARS = 8_000;
21
+ const AGENTS_MAX_CHARS = 4_000;
22
+ const PROFILE_MEMORY_MAX_CHARS = 6_000;
23
+ function readFileSafe(p) {
24
+ try {
25
+ return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
26
+ }
27
+ catch {
28
+ return '';
29
+ }
30
+ }
31
+ function trimTo(text, max) {
32
+ if (!text)
33
+ return '';
34
+ return text.length <= max ? text : text.slice(0, max - 3) + '...';
35
+ }
36
+ /**
37
+ * Build the system-prompt append string for chat invocations.
38
+ *
39
+ * Returns an empty string when none of the source files exist — the
40
+ * SDK then runs with just the bare `claude_code` preset.
41
+ */
42
+ export function buildChatSystemAppend(opts = {}) {
43
+ const blocks = [];
44
+ // 1. Soul (personality + voice)
45
+ const soul = readFileSafe(SOUL_FILE);
46
+ if (soul.trim()) {
47
+ blocks.push(`## Identity & Voice\n${trimTo(soul, SOUL_MAX_CHARS)}`);
48
+ }
49
+ // 2. Long-term memory — agent-specific file overrides the global one.
50
+ const profileMemoryPath = opts.profile?.slug
51
+ ? path.join(AGENTS_DIR, opts.profile.slug, 'MEMORY.md')
52
+ : null;
53
+ let memory = '';
54
+ if (profileMemoryPath && fs.existsSync(profileMemoryPath)) {
55
+ memory = readFileSafe(profileMemoryPath);
56
+ if (memory.trim()) {
57
+ blocks.push(`## Long-Term Memory (${opts.profile?.name ?? opts.profile?.slug})\n${trimTo(memory, PROFILE_MEMORY_MAX_CHARS)}`);
58
+ }
59
+ }
60
+ else {
61
+ memory = readFileSafe(MEMORY_FILE);
62
+ if (memory.trim()) {
63
+ blocks.push(`## Long-Term Memory\n${trimTo(memory, MEMORY_MAX_CHARS)}`);
64
+ }
65
+ }
66
+ // 3. Team roster (only when not running AS a hired agent — Sasha
67
+ // doesn't need to be told who Sasha is).
68
+ if (!opts.profile) {
69
+ const agentsRoster = readFileSafe(AGENTS_FILE);
70
+ if (agentsRoster.trim()) {
71
+ blocks.push(`## Team Roster\n${trimTo(agentsRoster, AGENTS_MAX_CHARS)}`);
72
+ }
73
+ }
74
+ // 4. Profile system prompt body (e.g. Sasha's role description).
75
+ if (opts.profileAppend?.trim()) {
76
+ blocks.push(opts.profileAppend);
77
+ }
78
+ return blocks.join('\n\n');
79
+ }
80
+ //# sourceMappingURL=run-agent-context.js.map
@@ -301,12 +301,23 @@ export async function runAgentCron(opts) {
301
301
  abortSignal: opts.abortSignal,
302
302
  extraMcpServers: mcp.servers,
303
303
  });
304
+ // Mirror the run into transcripts so future chat recall can see it.
305
+ // Legacy runCronJob did this with role='cron'; canonical needs the
306
+ // same so memory queries (`what did Sasha do this morning?`) work.
307
+ const deliverable = result.text ?? '';
308
+ if (opts.memoryStore && deliverable.trim()) {
309
+ try {
310
+ opts.memoryStore.saveTurn(`cron:${opts.jobName}`, 'cron', deliverable, opts.model ?? '');
311
+ }
312
+ catch (err) {
313
+ logger.debug({ err, job: opts.jobName }, 'runAgentCron: transcript mirror failed (non-fatal)');
314
+ }
315
+ }
304
316
  // ── Post-task hooks: reflection + skill extraction ────────────────
305
317
  // Both fire-and-forget — never block the cron deliverable on these.
306
318
  // They are the same passes the legacy runCronJob fires; without them
307
319
  // the new path would lose the success-grading + procedural-memory
308
320
  // growth that makes Clementine self-improving.
309
- const deliverable = result.text ?? '';
310
321
  if (opts.postTaskHooks && deliverable && deliverable.trim() !== '__NOTHING__') {
311
322
  const durationMs = Date.now() - startedAt;
312
323
  opts.postTaskHooks
@@ -65,8 +65,9 @@ export async function runAgentHeartbeat(opts) {
65
65
  profile: opts.profile?.slug,
66
66
  promptChars: prompt.length,
67
67
  }, 'runAgentHeartbeat: dispatching to runAgent (no tools)');
68
- return runAgent(prompt, {
69
- sessionKey: `heartbeat:${opts.profile?.slug ?? 'clementine'}`,
68
+ const sessionKey = `heartbeat:${opts.profile?.slug ?? 'clementine'}`;
69
+ const result = await runAgent(prompt, {
70
+ sessionKey,
70
71
  source: 'heartbeat',
71
72
  profile: opts.profile,
72
73
  memoryStore: opts.memoryStore,
@@ -80,5 +81,17 @@ export async function runAgentHeartbeat(opts) {
80
81
  allowedTools: [],
81
82
  abortSignal: opts.abortSignal,
82
83
  });
84
+ // Mirror the heartbeat into transcripts so dedup + recall work.
85
+ // Skip pure __NOTHING__ outputs since they carry no information.
86
+ const text = result.text?.trim() ?? '';
87
+ if (opts.memoryStore && text && text !== '__NOTHING__') {
88
+ try {
89
+ opts.memoryStore.saveTurn(sessionKey, 'heartbeat', text, opts.model ?? MODELS.haiku);
90
+ }
91
+ catch {
92
+ /* non-fatal */
93
+ }
94
+ }
95
+ return result;
83
96
  }
84
97
  //# sourceMappingURL=run-agent-heartbeat.js.map
@@ -49,8 +49,9 @@ export async function runAgentTeamTask(opts) {
49
49
  droppedComposio: mcp.droppedComposio,
50
50
  promptChars: builtPrompt.length,
51
51
  }, 'runAgentTeamTask: dispatching to runAgent');
52
+ const sessionKey = `team-task:${opts.fromSlug}->${opts.profile.slug}`;
52
53
  const result = await runAgent(builtPrompt, {
53
- sessionKey: `team-task:${opts.fromSlug}->${opts.profile.slug}`,
54
+ sessionKey,
54
55
  source: 'team-task',
55
56
  profile: opts.profile,
56
57
  agentManager: opts.agentManager,
@@ -62,6 +63,19 @@ export async function runAgentTeamTask(opts) {
62
63
  abortSignal: opts.abortSignal,
63
64
  extraMcpServers: mcp.servers,
64
65
  });
66
+ // Mirror the inbound message + outbound response into transcripts so
67
+ // future recall sees who-asked-whom and what got done.
68
+ if (opts.memoryStore) {
69
+ try {
70
+ opts.memoryStore.saveTurn(sessionKey, `team-from:${opts.fromSlug}`, opts.content, '');
71
+ if (result.text?.trim()) {
72
+ opts.memoryStore.saveTurn(sessionKey, `team-to:${opts.profile.slug}`, result.text, opts.model ?? '');
73
+ }
74
+ }
75
+ catch {
76
+ /* non-fatal */
77
+ }
78
+ }
65
79
  return {
66
80
  ...result,
67
81
  builtPrompt,
@@ -77,6 +77,12 @@ export interface RunAgentOptions {
77
77
  url?: string;
78
78
  headers?: Record<string, string>;
79
79
  }>;
80
+ /** String appended to the SDK's `claude_code` system-prompt preset.
81
+ * Caller-built so chat callers can inject vault context (SOUL.md,
82
+ * MEMORY.md, AGENTS.md) while autonomous callers (cron/heartbeat/
83
+ * team-task) keep the prompt small. When unset, falls back to
84
+ * profile.systemPromptBody (legacy single-source behavior). */
85
+ systemPromptAppend?: string;
80
86
  }
81
87
  export interface RunAgentResult {
82
88
  /** Final text response from the agent. */
@@ -111,14 +111,28 @@ export async function runAgent(prompt, opts) {
111
111
  const effectivePrompt = opts.forceSubagent && agents[opts.forceSubagent]
112
112
  ? `Use the ${opts.forceSubagent} agent to handle this request:\n\n${prompt}`
113
113
  : prompt;
114
- // Compose system prompt. When a hired-agent profile is active, that
115
- // becomes the main agent's identity append to the claude_code preset.
116
- const profileAppend = opts.profile?.systemPromptBody
117
- ? opts.profile.systemPromptBody
118
- : undefined;
119
- // Allowed tools. Default to core + Clementine MCP. Per-subagent tool
120
- // restrictions live on each AgentDefinition.tools field.
121
- const allowedTools = opts.allowedTools ?? CORE_TOOLS_FOR_AGENT_PARENT;
114
+ // Compose system-prompt append. The caller has already merged any
115
+ // vault context (SOUL.md, MEMORY.md, AGENTS.md) and profile body
116
+ // into a single string when needed; otherwise we fall back to the
117
+ // profile body alone for autonomous paths.
118
+ const profileAppend = opts.systemPromptAppend?.trim()
119
+ ? opts.systemPromptAppend
120
+ : opts.profile?.systemPromptBody?.trim()
121
+ ? opts.profile.systemPromptBody
122
+ : undefined;
123
+ // Allowed tools at the main-agent level.
124
+ // 1. Caller-provided opts.allowedTools wins (e.g. heartbeat passes []).
125
+ // 2. When a hired-agent profile is the main agent and it has a
126
+ // team.allowedTools allowlist, use it (with `Agent` always
127
+ // included so subagent delegation still works).
128
+ // 3. Otherwise the core set. Per-subagent tool restrictions live
129
+ // on each AgentDefinition.tools field, not here.
130
+ const profileMainAllow = opts.profile?.team?.allowedTools?.length
131
+ ? Array.from(new Set(['Agent', ...opts.profile.team.allowedTools]))
132
+ : null;
133
+ const allowedTools = opts.allowedTools
134
+ ?? profileMainAllow
135
+ ?? CORE_TOOLS_FOR_AGENT_PARENT;
122
136
  // Wire the Clementine MCP server so the agent can reach memory/cron/
123
137
  // broken-job tools. Without this, the cron-fixer subagent's `tools`
124
138
  // list references mcp__clementine-tools__* that don't exist in the
@@ -141,6 +155,20 @@ export async function runAgent(prompt, opts) {
141
155
  },
142
156
  ...(opts.extraMcpServers ?? {}),
143
157
  };
158
+ // Bridge an external AbortSignal to a real AbortController the SDK
159
+ // can act on. The SDK calls .abort() internally on budget/turn caps,
160
+ // so we cannot pass a fake { signal } object — it must be a real
161
+ // controller. When the caller's signal fires we propagate.
162
+ let sdkAbortController;
163
+ if (opts.abortSignal) {
164
+ sdkAbortController = new AbortController();
165
+ if (opts.abortSignal.aborted) {
166
+ sdkAbortController.abort();
167
+ }
168
+ else {
169
+ opts.abortSignal.addEventListener('abort', () => sdkAbortController.abort(), { once: true });
170
+ }
171
+ }
144
172
  // Apply 1M-context env normalization (existing infra)
145
173
  const sdkOptionsRaw = {
146
174
  systemPrompt: profileAppend
@@ -153,6 +181,10 @@ export async function runAgent(prompt, opts) {
153
181
  mcpServers: mcpServers,
154
182
  allowedTools,
155
183
  permissionMode: 'bypassPermissions',
184
+ // SDK spec requires this companion flag whenever permissionMode is
185
+ // 'bypassPermissions'. Without it, autonomous runs can silently
186
+ // hang waiting for permission prompts.
187
+ allowDangerouslySkipPermissions: true,
156
188
  cwd: BASE_DIR,
157
189
  env: subprocessEnv,
158
190
  maxBudgetUsd,
@@ -160,7 +192,7 @@ export async function runAgent(prompt, opts) {
160
192
  ...(opts.maxTurns ? { maxTurns: opts.maxTurns } : {}),
161
193
  ...(opts.model ? { model: opts.model } : {}),
162
194
  ...(opts.resumeSessionId ? { resume: opts.resumeSessionId } : {}),
163
- ...(opts.abortSignal ? { abortController: { signal: opts.abortSignal } } : {}),
195
+ ...(sdkAbortController ? { abortController: sdkAbortController } : {}),
164
196
  };
165
197
  const sdkOptions = normalizeClaudeSdkOptionsForOneMillionContext(sdkOptionsRaw);
166
198
  logger.info({
@@ -541,7 +541,12 @@ export async function diagnoseBrokenJob(broken, gateway) {
541
541
  rawResponse = await gateway.handleCronJob(`diagnose:${broken.jobName}`, prompt, 1, // tier 1 — cheap
542
542
  5, // maxTurns — diagnosis doesn't need tools typically
543
543
  'haiku', // model — keep cost negligible
544
- undefined, 'standard', undefined, undefined, undefined, undefined, { disableAllTools: true });
544
+ undefined, // workDir
545
+ 'standard', // mode (display only)
546
+ undefined, // maxHours
547
+ undefined, // timeoutMs
548
+ undefined, // successCriteria
549
+ undefined);
545
550
  }
546
551
  catch (err) {
547
552
  logger.warn({ err, job: broken.jobName }, 'Diagnostic LLM call failed');
@@ -294,7 +294,12 @@ export class HeartbeatScheduler {
294
294
  // LLM callback for summarization/principle extraction
295
295
  const llmCall = async (prompt) => {
296
296
  const cronCall = buildConsolidationCronCall(prompt);
297
- const result = await this.gateway.handleCronJob(cronCall.jobName, cronCall.jobPrompt, cronCall.tier, cronCall.maxTurns, cronCall.model, undefined, 'standard', undefined, undefined, undefined, undefined, cronCall.opts);
297
+ const result = await this.gateway.handleCronJob(cronCall.jobName, cronCall.jobPrompt, cronCall.tier, cronCall.maxTurns, cronCall.model, undefined, // workDir
298
+ 'standard', // mode (display only)
299
+ undefined, // maxHours
300
+ undefined, // timeoutMs
301
+ undefined, // successCriteria
302
+ undefined);
298
303
  return result || '';
299
304
  };
300
305
  const result = await runConsolidation(store, llmCall);
@@ -978,7 +983,12 @@ export class HeartbeatScheduler {
978
983
  let response = null;
979
984
  try {
980
985
  const cronCall = buildInsightCheckCronCall(prompt);
981
- response = await this.gateway.handleCronJob(cronCall.jobName, cronCall.jobPrompt, cronCall.tier, cronCall.maxTurns, cronCall.model, undefined, 'standard', undefined, undefined, undefined, undefined, cronCall.opts);
986
+ response = await this.gateway.handleCronJob(cronCall.jobName, cronCall.jobPrompt, cronCall.tier, cronCall.maxTurns, cronCall.model, undefined, // workDir
987
+ 'standard', // mode (display only)
988
+ undefined, // maxHours
989
+ undefined, // timeoutMs
990
+ undefined, // successCriteria
991
+ undefined);
982
992
  this.runLog.append({
983
993
  jobName: 'insight-check',
984
994
  startedAt: icStartedAt.toISOString(),
@@ -136,12 +136,11 @@ export async function gradeRun(entry, gateway, jobPrompt) {
136
136
  raw = await gateway.handleCronJob(`grade:${entry.jobName}`, prompt, 1, // tier 1
137
137
  3, // maxTurns — tight
138
138
  'haiku', undefined, // workDir
139
- 'standard', // mode
139
+ 'standard', // mode (display only)
140
140
  undefined, // maxHours
141
141
  undefined, // timeoutMs
142
142
  undefined, // successCriteria
143
- undefined, // agentSlug
144
- { disableAllTools: true });
143
+ undefined);
145
144
  }
146
145
  catch (err) {
147
146
  logger.warn({ err, jobName: entry.jobName }, 'Outcome grader LLM call failed');
@@ -167,9 +167,10 @@ export declare class Gateway {
167
167
  handleMessage(sessionKey: string, text: string, onText?: OnTextCallback, model?: string, maxTurns?: number, onToolActivity?: OnToolActivityCallback, onProgress?: OnProgressCallback): Promise<string>;
168
168
  private _handleMessageInner;
169
169
  handleHeartbeat(standingInstructions: string, changesSummary?: string, timeContext?: string, dedupContext?: string, profile?: import('../types.js').AgentProfile | null): Promise<string>;
170
- handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, _mode?: 'standard' | 'unleashed', _maxHours?: number, _timeoutMs?: number, successCriteria?: string[], agentSlug?: string, _opts?: {
171
- disableAllTools?: boolean;
172
- }): Promise<string>;
170
+ handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string,
171
+ /** Accepted for back-compat; canonical SDK path executes every job
172
+ * identically. Affects only UI display + budget heuristics elsewhere. */
173
+ _mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
173
174
  /**
174
175
  * Process a team message as an autonomous task — same multi-phase execution
175
176
  * as cron unleashed jobs, so agents can work until done instead of being
@@ -16,7 +16,6 @@ import { lanes } from './lanes.js';
16
16
  import { AgentManager } from '../agent/agent-manager.js';
17
17
  import { TeamRouter } from '../agent/team-router.js';
18
18
  import { TeamBus } from '../agent/team-bus.js';
19
- import { events } from '../events/bus.js';
20
19
  import { listBackgroundTasks, loadBackgroundTask, markFailed } from '../agent/background-tasks.js';
21
20
  import { applyAssistantExperienceUpdate, detectApprovalReply, detectLocalTurn } from '../agent/local-turn.js';
22
21
  import { buildApprovalFollowupPrompt, detectActionExpectation } from '../agent/action-enforcer.js';
@@ -1497,7 +1496,6 @@ export class Gateway {
1497
1496
  logger.info({ sessionKey, laneWaitMs }, 'Chat lane wait was non-trivial');
1498
1497
  }
1499
1498
  logger.info(`Message from ${sessionKey}: ${text.slice(0, 100)}...`);
1500
- events.emit('message:received', { sessionKey, text, timestamp: Date.now() });
1501
1499
  // ── Register provenance on first interaction ────────────────
1502
1500
  this.ensureProvenance(sessionKey);
1503
1501
  // ── Pre-flight injection scan ───────────────────────────────
@@ -1780,10 +1778,32 @@ export class Gateway {
1780
1778
  // runAgent() owns chat. No legacy fallback — errors propagate
1781
1779
  // to the catch block below for honest classification.
1782
1780
  const { runAgent } = await import('../agent/run-agent.js');
1781
+ const { buildExtraMcpForRunAgent } = await import('../agent/run-agent-mcp.js');
1782
+ const { buildChatSystemAppend } = await import('../agent/run-agent-context.js');
1783
+ // Wire Composio + external MCP servers (Outlook, Gmail,
1784
+ // Salesforce, etc) so chat can reach the same tools the
1785
+ // legacy chat path did. Profile allowlists override the
1786
+ // bundle router when set.
1787
+ const chatMcp = await buildExtraMcpForRunAgent({
1788
+ scopeText: chatPrompt,
1789
+ profile: resolvedProfile,
1790
+ });
1791
+ // Inject vault context (SOUL.md / MEMORY.md / AGENTS.md +
1792
+ // optional profile body) into the system-prompt append so
1793
+ // the agent has personality + long-term memory + team
1794
+ // awareness. Profile-specific MEMORY.md takes precedence
1795
+ // over the global one when a hired agent is active.
1796
+ const chatSystemAppend = buildChatSystemAppend({
1797
+ profile: resolvedProfile,
1798
+ profileAppend: resolvedProfile?.systemPromptBody,
1799
+ });
1783
1800
  logger.info({
1784
1801
  sessionKey: effectiveSessionKey,
1785
1802
  profile: resolvedProfile?.slug,
1786
1803
  path: 'runagent_chat',
1804
+ composioConnected: chatMcp.composioConnected.length,
1805
+ externalConnected: chatMcp.externalConnected.length,
1806
+ systemAppendChars: chatSystemAppend.length,
1787
1807
  }, 'Routing chat through runAgent');
1788
1808
  const runAgentResult = await runAgent(chatPrompt, {
1789
1809
  sessionKey: effectiveSessionKey,
@@ -1793,6 +1813,8 @@ export class Gateway {
1793
1813
  memoryStore: this.assistant.getMemoryStore?.() ?? null,
1794
1814
  ...(effectiveModel ? { model: effectiveModel } : {}),
1795
1815
  ...(maxTurns ? { maxTurns } : {}),
1816
+ ...(chatSystemAppend ? { systemPromptAppend: chatSystemAppend } : {}),
1817
+ extraMcpServers: chatMcp.servers,
1796
1818
  onText: wrappedOnText,
1797
1819
  onToolActivity: ({ tool, input }) => {
1798
1820
  toolActivityCount++;
@@ -1888,7 +1910,6 @@ export class Gateway {
1888
1910
  return '__NOTHING__';
1889
1911
  }
1890
1912
  logger.info({ agent }, 'Running heartbeat...');
1891
- events.emit('heartbeat:start', { agent, timestamp: Date.now() });
1892
1913
  const hbStart = Date.now();
1893
1914
  try {
1894
1915
  const { runAgentHeartbeat } = await import('../agent/run-agent-heartbeat.js');
@@ -1902,21 +1923,16 @@ export class Gateway {
1902
1923
  memoryStore: this.assistant.getMemoryStore?.() ?? null,
1903
1924
  });
1904
1925
  scanner.refreshIntegrity();
1905
- events.emit('heartbeat:complete', {
1906
- agent,
1907
- durationMs: Date.now() - hbStart,
1908
- responseLength: result.text?.length ?? 0,
1909
- });
1910
1926
  logger.info({
1911
1927
  agent,
1912
1928
  cost: Number(result.totalCostUsd.toFixed(4)),
1913
1929
  numTurns: result.numTurns,
1914
1930
  durationMs: Date.now() - hbStart,
1931
+ responseLen: result.text?.length ?? 0,
1915
1932
  }, 'runAgentHeartbeat: heartbeat complete');
1916
1933
  return result.text;
1917
1934
  }
1918
1935
  catch (err) {
1919
- events.emit('heartbeat:error', { agent, error: String(err) });
1920
1936
  logger.error({ err }, 'Heartbeat error');
1921
1937
  return `Heartbeat error: ${err}`;
1922
1938
  }
@@ -1925,18 +1941,34 @@ export class Gateway {
1925
1941
  releaseLane();
1926
1942
  }
1927
1943
  }
1928
- async handleCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir, _mode = 'standard', _maxHours, _timeoutMs, successCriteria, agentSlug, _opts) {
1944
+ async handleCronJob(jobName, jobPrompt, tier = 1, maxTurns, model, workDir,
1945
+ /** Accepted for back-compat; canonical SDK path executes every job
1946
+ * identically. Affects only UI display + budget heuristics elsewhere. */
1947
+ _mode, maxHours, timeoutMs, successCriteria, agentSlug) {
1929
1948
  const releaseLane = await lanes.acquire('cron');
1949
+ // Build a wall-clock abort timer from maxHours / timeoutMs.
1950
+ // Whichever is shorter wins. Defaults to 1h if neither is set.
1951
+ const wallMs = (() => {
1952
+ const fromHours = maxHours && maxHours > 0 ? maxHours * 3600 * 1000 : null;
1953
+ const fromMs = timeoutMs && timeoutMs > 0 ? timeoutMs : null;
1954
+ if (fromHours && fromMs)
1955
+ return Math.min(fromHours, fromMs);
1956
+ return fromHours ?? fromMs ?? 60 * 60 * 1000;
1957
+ })();
1958
+ const cronAc = new AbortController();
1959
+ const cronTimer = setTimeout(() => {
1960
+ cronAc.abort();
1961
+ logger.warn({ jobName, wallMs }, 'Cron job hit wall-clock cap — aborting');
1962
+ }, wallMs);
1930
1963
  try {
1931
1964
  logger.info(`Running cron job: ${jobName}${workDir ? ` in ${workDir}` : ''}${agentSlug && agentSlug !== 'clementine' ? ` as ${agentSlug}` : ''}`);
1932
- events.emit('cron:start', { jobName, tier, mode: 'runagent', timestamp: Date.now() });
1933
1965
  const cronStart = Date.now();
1934
1966
  try {
1935
1967
  const { runAgentCron } = await import('../agent/run-agent-cron.js');
1936
1968
  const profile = agentSlug && agentSlug !== 'clementine'
1937
1969
  ? this.getAgentManager().get(agentSlug) ?? null
1938
1970
  : null;
1939
- logger.info({ jobName, agentSlug, tier, path: 'runagent_cron' }, 'Routing cron through runAgentCron');
1971
+ logger.info({ jobName, agentSlug, tier, wallMs, path: 'runagent_cron' }, 'Routing cron through runAgentCron');
1940
1972
  const cronResult = await runAgentCron({
1941
1973
  jobName,
1942
1974
  jobPrompt,
@@ -1948,15 +1980,10 @@ export class Gateway {
1948
1980
  successCriteria,
1949
1981
  model,
1950
1982
  workDir,
1983
+ abortSignal: cronAc.signal,
1951
1984
  postTaskHooks: this.assistant,
1952
1985
  });
1953
1986
  scanner.refreshIntegrity();
1954
- events.emit('cron:complete', {
1955
- jobName,
1956
- mode: 'runagent',
1957
- durationMs: Date.now() - cronStart,
1958
- responseLength: cronResult.text?.length ?? 0,
1959
- });
1960
1987
  logger.info({
1961
1988
  jobName,
1962
1989
  cost: Number(cronResult.totalCostUsd.toFixed(4)),
@@ -1968,16 +1995,16 @@ export class Gateway {
1968
1995
  return cronResult.text;
1969
1996
  }
1970
1997
  catch (err) {
1971
- events.emit('cron:error', { jobName, mode: 'runagent', error: String(err) });
1972
1998
  logger.error({ err, jobName }, `Cron job error: ${jobName}`);
1973
1999
  throw err;
1974
2000
  }
1975
2001
  }
1976
2002
  finally {
2003
+ clearTimeout(cronTimer);
1977
2004
  releaseLane();
1978
2005
  }
1979
2006
  }
1980
- // ── Team task execution (unleashed for team messages) ──────────────
2007
+ // ── Team task execution ──────────────────────────────────────────────
1981
2008
  /**
1982
2009
  * Process a team message as an autonomous task — same multi-phase execution
1983
2010
  * as cron unleashed jobs, so agents can work until done instead of being
@@ -992,7 +992,6 @@ export function registerAdminTools(server) {
992
992
  const schedule = String(job.schedule ?? '');
993
993
  const prompt = String(job.prompt ?? '');
994
994
  const enabled = job.enabled !== false;
995
- const mode = job.mode === 'unleashed' ? 'unleashed' : 'standard';
996
995
  const workDir = job.work_dir ? String(job.work_dir) : null;
997
996
  const humanSchedule = describeCronSchedule(schedule);
998
997
  const nextRun = enabled ? getNextRun(schedule) : null;
@@ -1016,7 +1015,7 @@ export function registerAdminTools(server) {
1016
1015
  }
1017
1016
  }
1018
1017
  const status = enabled ? 'enabled' : 'disabled';
1019
- lines.push(`**${name}** [${status}] ${mode === 'unleashed' ? '[unleashed] ' : ''}` +
1018
+ lines.push(`**${name}** [${status}] ` +
1020
1019
  `\n Schedule: ${humanSchedule} (\`${schedule}\`)` +
1021
1020
  (nextRun ? `\n Next run: ${nextRun}` : '') +
1022
1021
  (lastRunInfo ? `\n ${lastRunInfo}` : '') +
@@ -1026,47 +1025,20 @@ export function registerAdminTools(server) {
1026
1025
  return textResult(lines.join('\n\n'));
1027
1026
  });
1028
1027
  // ── Add Cron Job ────────────────────────────────────────────────────────
1029
- server.tool('add_cron_job', 'Add a new scheduled cron job. Validates the schedule expression and writes to CRON.md. The daemon auto-reloads on file change. Use mode "unleashed" for multi-step tasks (browser automation, batch processing, multi-contact workflows) they need more turns than standard mode provides. Auto-escalates to unleashed when complex patterns are detected.', {
1028
+ server.tool('add_cron_job', 'Add a new scheduled cron job. Validates the schedule expression and writes to CRON.md. The daemon auto-reloads on file change. The canonical SDK path runs every job through runAgentCron there is no separate "unleashed" mode anymore; the SDK handles compaction + multi-turn work natively up to maxBudgetUsd.', {
1030
1029
  name: z.string().describe('Job name (unique identifier)'),
1031
1030
  schedule: z.string().describe('Cron expression (e.g., "0 9 * * 1" for Monday 9 AM)'),
1032
1031
  prompt: z.string().describe('The prompt/instruction for the assistant to execute'),
1033
- tier: z.number().optional().default(1).describe('Security tier (1=auto, 2=logged, 3=approval)'),
1032
+ tier: z.number().optional().default(1).describe('Security tier (1=auto, 2=logged, 3=approval). Tier 2+ also raises the per-run budget cap.'),
1034
1033
  enabled: z.boolean().optional().default(true).describe('Whether the job is enabled'),
1035
1034
  work_dir: z.string().optional().describe('Project directory to run in (agent gets access to project tools, CLAUDE.md, files)'),
1036
- mode: z.enum(['standard', 'unleashed']).optional().default('standard').describe('standard = normal cron, unleashed = long-running phased execution with checkpointing'),
1037
- max_hours: z.number().optional().describe('Max hours for unleashed mode (default 6). Ignored for standard mode.'),
1038
- }, async ({ name: jobName, schedule, prompt, tier, enabled, work_dir, mode: rawMode, max_hours: rawMaxHours }) => {
1039
- let mode = rawMode;
1040
- let max_hours = rawMaxHours;
1035
+ max_hours: z.number().optional().describe('Wall-clock cap in hours. Defaults to 1h. Run aborts via AbortSignal when exceeded.'),
1036
+ }, async ({ name: jobName, schedule, prompt, tier, enabled, work_dir, max_hours }) => {
1041
1037
  // Validate cron expression
1042
1038
  const cronMod = await import('node-cron');
1043
1039
  if (!cronMod.default.validate(schedule)) {
1044
1040
  return textResult(`Invalid cron expression: "${schedule}". Examples: "0 9 * * 1" (Mon 9 AM), "*/30 * * * *" (every 30 min).`);
1045
1041
  }
1046
- // Auto-escalate to unleashed when the job clearly needs it.
1047
- // Tier 2 jobs with complex prompts (browser automation, multi-contact workflows,
1048
- // multi-step sequences) will exhaust standard turn limits silently.
1049
- if (mode !== 'unleashed' && tier >= 2) {
1050
- const complexSignals = [
1051
- /\bfor each\b.*\bcontact\b/i,
1052
- /\bfor each\b.*\bprospect\b/i,
1053
- /\bfor each\b.*\baccount\b/i,
1054
- /\bfor each\b.*\blead\b/i,
1055
- /\bfor each\b.*\bprofile\b/i,
1056
- /\bplaywright\b/i,
1057
- /\bkernel\s+browsers?\b/i,
1058
- /\bbrowser\b.*\bautomati/i,
1059
- /\bstep\s+\d+\b.*\bstep\s+\d+\b/is,
1060
- ];
1061
- const isComplex = complexSignals.some(p => p.test(prompt))
1062
- || prompt.length > 2000;
1063
- if (isComplex) {
1064
- mode = 'unleashed';
1065
- if (!max_hours)
1066
- max_hours = 1;
1067
- logger.info({ jobName }, 'Auto-escalated to unleashed mode (complex prompt detected)');
1068
- }
1069
- }
1070
1042
  // Read existing CRON.md or create empty structure
1071
1043
  const matterMod = await import('gray-matter');
1072
1044
  let parsed;
@@ -1097,11 +1069,8 @@ export function registerAdminTools(server) {
1097
1069
  };
1098
1070
  if (work_dir)
1099
1071
  newJob.work_dir = work_dir;
1100
- if (mode === 'unleashed') {
1101
- newJob.mode = 'unleashed';
1102
- if (max_hours)
1103
- newJob.max_hours = max_hours;
1104
- }
1072
+ if (max_hours)
1073
+ newJob.max_hours = max_hours;
1105
1074
  jobs.push(newJob);
1106
1075
  parsed.data.jobs = jobs;
1107
1076
  // Write back preserving body content — validate first to prevent daemon crash
@@ -1113,7 +1082,7 @@ export function registerAdminTools(server) {
1113
1082
  return textResult(`Failed to add job "${jobName}": generated YAML is invalid. Error: ${yamlErr}`);
1114
1083
  }
1115
1084
  writeFileSync(CRON_FILE, output);
1116
- logger.info({ jobName, schedule, tier, mode, work_dir }, 'Added cron job via MCP tool');
1085
+ logger.info({ jobName, schedule, tier, work_dir, max_hours }, 'Added cron job via MCP tool');
1117
1086
  // Read-back verification: confirm the job was persisted correctly
1118
1087
  let verified = false;
1119
1088
  try {
@@ -1134,10 +1103,8 @@ export function registerAdminTools(server) {
1134
1103
  ];
1135
1104
  if (work_dir)
1136
1105
  details.push(` Project: ${work_dir}`);
1137
- if (mode === 'unleashed') {
1138
- const escalated = rawMode !== 'unleashed' ? ' (auto-escalated complex prompt detected)' : '';
1139
- details.push(` Mode: unleashed (max ${max_hours ?? 6} hours)${escalated}`);
1140
- }
1106
+ if (max_hours)
1107
+ details.push(` Wall-clock cap: ${max_hours}h`);
1141
1108
  const verifyMsg = verified
1142
1109
  ? 'Verified: job persisted to CRON.md and will be picked up by the daemon.'
1143
1110
  : 'WARNING: Could not verify the job was written correctly. Check CRON.md manually.';
package/dist/types.d.ts CHANGED
@@ -196,6 +196,25 @@ export interface AgentProfile {
196
196
  start: number;
197
197
  end: number;
198
198
  };
199
+ /**
200
+ * Short imperative routing hints used to build this agent's
201
+ * AgentDefinition.description for SDK auto-routing. Each entry is a
202
+ * capability phrase the main agent might match against user input
203
+ * (e.g., "outbound prospect emails", "content calendar drafting").
204
+ * Free-form strings, comma-joined when assembled. Optional.
205
+ */
206
+ routingHints?: string[];
207
+ /**
208
+ * Short label describing the role (e.g., "SDR", "CMO"). Used in the
209
+ * routing description when present.
210
+ */
211
+ role?: string;
212
+ /**
213
+ * SDK reasoning effort tier when this profile runs as a subagent.
214
+ * Defaults to 'medium' if unset. Low = Haiku-style cheap fanout,
215
+ * High = deep reasoning, Max = max effort.
216
+ */
217
+ effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max';
199
218
  }
200
219
  export type AgentStatus = 'active' | 'paused' | 'error' | 'terminated';
201
220
  export interface HeartbeatReportedTopic {
@@ -299,7 +318,12 @@ export interface CronJobDefinition {
299
318
  maxTurns?: number;
300
319
  model?: string;
301
320
  workDir?: string;
321
+ /** Display/intent hint — 'unleashed' jobs are typically long autonomous
322
+ * tasks. The canonical SDK path runs every job through runAgentCron
323
+ * identically; this field affects only UI badges + budget heuristics. */
302
324
  mode?: 'standard' | 'unleashed';
325
+ /** Wall-clock cap in hours. Defaults to 1h. Triggers an AbortSignal
326
+ * on the runAgentCron call when exceeded. */
303
327
  maxHours?: number;
304
328
  maxRetries?: number;
305
329
  after?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.18.49",
3
+ "version": "1.18.50",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,43 +0,0 @@
1
- /**
2
- * Clementine TypeScript — Event Bus.
3
- *
4
- * Typed pub/sub system for decoupling gateway lifecycle events from consumers.
5
- * Plugins, logging, metrics, and UI can subscribe without modifying core code.
6
- *
7
- * Events are fire-and-forget (async handlers don't block the emitter).
8
- * "before" events return a boolean — false cancels the operation.
9
- */
10
- export type EventHandler<T = unknown> = (payload: T) => void | Promise<void>;
11
- export type BeforeHandler<T = unknown> = (payload: T) => boolean | Promise<boolean>;
12
- declare class EventBus {
13
- private listeners;
14
- private beforeHandlers;
15
- /**
16
- * Subscribe to an event. Handler is called asynchronously (fire-and-forget).
17
- * Returns an unsubscribe function.
18
- */
19
- on<T = unknown>(event: string, handler: EventHandler<T>): () => void;
20
- /** Subscribe to an event, but only fire once. */
21
- once<T = unknown>(event: string, handler: EventHandler<T>): () => void;
22
- /**
23
- * Register a "before" handler that can cancel an operation.
24
- * If any before handler returns false, the operation is cancelled.
25
- */
26
- before<T = unknown>(event: string, handler: BeforeHandler<T>): () => void;
27
- /**
28
- * Emit an event asynchronously. Handlers run in parallel, errors are logged but don't propagate.
29
- */
30
- emit<T = unknown>(event: string, payload: T): void;
31
- /**
32
- * Run "before" handlers sequentially. Returns true if all pass, false if any cancels.
33
- */
34
- emitBefore<T = unknown>(event: string, payload: T): Promise<boolean>;
35
- /** Remove all listeners for an event, or all events if no event specified. */
36
- clear(event?: string): void;
37
- /** Get count of listeners for an event (useful for debugging). */
38
- listenerCount(event: string): number;
39
- }
40
- /** Singleton event bus — shared across the entire process. */
41
- export declare const events: EventBus;
42
- export {};
43
- //# sourceMappingURL=bus.d.ts.map
@@ -1,136 +0,0 @@
1
- /**
2
- * Clementine TypeScript — Event Bus.
3
- *
4
- * Typed pub/sub system for decoupling gateway lifecycle events from consumers.
5
- * Plugins, logging, metrics, and UI can subscribe without modifying core code.
6
- *
7
- * Events are fire-and-forget (async handlers don't block the emitter).
8
- * "before" events return a boolean — false cancels the operation.
9
- */
10
- import pino from 'pino';
11
- const logger = pino({ name: 'clementine.events' });
12
- class EventBus {
13
- listeners = new Map();
14
- beforeHandlers = new Map();
15
- /**
16
- * Subscribe to an event. Handler is called asynchronously (fire-and-forget).
17
- * Returns an unsubscribe function.
18
- */
19
- on(event, handler) {
20
- const subs = this.listeners.get(event) ?? [];
21
- const sub = { handler: handler, once: false };
22
- subs.push(sub);
23
- this.listeners.set(event, subs);
24
- return () => {
25
- const list = this.listeners.get(event);
26
- if (list) {
27
- const idx = list.indexOf(sub);
28
- if (idx !== -1)
29
- list.splice(idx, 1);
30
- }
31
- };
32
- }
33
- /** Subscribe to an event, but only fire once. */
34
- once(event, handler) {
35
- const subs = this.listeners.get(event) ?? [];
36
- const sub = { handler: handler, once: true };
37
- subs.push(sub);
38
- this.listeners.set(event, subs);
39
- return () => {
40
- const list = this.listeners.get(event);
41
- if (list) {
42
- const idx = list.indexOf(sub);
43
- if (idx !== -1)
44
- list.splice(idx, 1);
45
- }
46
- };
47
- }
48
- /**
49
- * Register a "before" handler that can cancel an operation.
50
- * If any before handler returns false, the operation is cancelled.
51
- */
52
- before(event, handler) {
53
- const handlers = this.beforeHandlers.get(event) ?? [];
54
- const entry = { handler: handler, once: false };
55
- handlers.push(entry);
56
- this.beforeHandlers.set(event, handlers);
57
- return () => {
58
- const list = this.beforeHandlers.get(event);
59
- if (list) {
60
- const idx = list.findIndex(e => e === entry);
61
- if (idx !== -1)
62
- list.splice(idx, 1);
63
- }
64
- };
65
- }
66
- /**
67
- * Emit an event asynchronously. Handlers run in parallel, errors are logged but don't propagate.
68
- */
69
- emit(event, payload) {
70
- const subs = this.listeners.get(event);
71
- if (!subs || subs.length === 0)
72
- return;
73
- // Snapshot handlers and remove once-listeners
74
- const handlers = [...subs];
75
- for (let i = subs.length - 1; i >= 0; i--) {
76
- if (subs[i].once)
77
- subs.splice(i, 1);
78
- }
79
- for (const sub of handlers) {
80
- try {
81
- const result = sub.handler(payload);
82
- if (result instanceof Promise) {
83
- result.catch(err => logger.warn({ err, event }, 'Event handler error'));
84
- }
85
- }
86
- catch (err) {
87
- logger.warn({ err, event }, 'Event handler error (sync)');
88
- }
89
- }
90
- }
91
- /**
92
- * Run "before" handlers sequentially. Returns true if all pass, false if any cancels.
93
- */
94
- async emitBefore(event, payload) {
95
- const handlers = this.beforeHandlers.get(event);
96
- if (!handlers || handlers.length === 0)
97
- return true;
98
- for (const entry of [...handlers]) {
99
- try {
100
- const result = entry.handler(payload);
101
- const allowed = result instanceof Promise ? await result : result;
102
- if (!allowed) {
103
- logger.info({ event }, 'Operation cancelled by before handler');
104
- return false;
105
- }
106
- }
107
- catch (err) {
108
- logger.warn({ err, event }, 'Before handler error — allowing operation');
109
- }
110
- if (entry.once) {
111
- const idx = handlers.indexOf(entry);
112
- if (idx !== -1)
113
- handlers.splice(idx, 1);
114
- }
115
- }
116
- return true;
117
- }
118
- /** Remove all listeners for an event, or all events if no event specified. */
119
- clear(event) {
120
- if (event) {
121
- this.listeners.delete(event);
122
- this.beforeHandlers.delete(event);
123
- }
124
- else {
125
- this.listeners.clear();
126
- this.beforeHandlers.clear();
127
- }
128
- }
129
- /** Get count of listeners for an event (useful for debugging). */
130
- listenerCount(event) {
131
- return (this.listeners.get(event)?.length ?? 0) + (this.beforeHandlers.get(event)?.length ?? 0);
132
- }
133
- }
134
- /** Singleton event bus — shared across the entire process. */
135
- export const events = new EventBus();
136
- //# sourceMappingURL=bus.js.map