clementine-agent 1.0.85 → 1.0.87

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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Clementine TypeScript — Background task persistence + lifecycle helpers.
3
+ *
4
+ * A "background task" is an unleashed multi-turn job that an agent kicks
5
+ * off via the `start_background_task` MCP tool. Persistence is one JSON
6
+ * file per task at ~/.clementine/background-tasks/<id>.json. The file is
7
+ * the source of truth — the MCP tool writes the initial pending state,
8
+ * the daemon picks it up, runs it, and updates the same file as the
9
+ * lifecycle progresses.
10
+ *
11
+ * Process boundary: the MCP tool runs in an SDK subprocess, so it can't
12
+ * call the gateway directly. It writes a pending file; the daemon's
13
+ * cron-scheduler tick picks up pending tasks within ~3 seconds.
14
+ *
15
+ * Restart safety: on daemon startup, any task left in 'running' is
16
+ * aborted (its process is gone). P6b can add resumability; for now,
17
+ * fail-fast is clearer than silently re-running a task that may have
18
+ * already partially completed.
19
+ */
20
+ import type { BackgroundTask } from '../types.js';
21
+ export declare const BACKGROUND_TASK_DIR: string;
22
+ export interface BackgroundTaskOptions {
23
+ /** Override storage directory for tests. Defaults to BASE_DIR/background-tasks/. */
24
+ dir?: string;
25
+ }
26
+ /**
27
+ * Create a new pending task on disk and return it. Caller (the MCP tool)
28
+ * doesn't await execution — the daemon picks the task up asynchronously.
29
+ */
30
+ export declare function createBackgroundTask(input: {
31
+ fromAgent: string;
32
+ prompt: string;
33
+ maxMinutes: number;
34
+ }, opts?: BackgroundTaskOptions): BackgroundTask;
35
+ /** Load a task by id, or null if not found / malformed. */
36
+ export declare function loadBackgroundTask(id: string, opts?: BackgroundTaskOptions): BackgroundTask | null;
37
+ /** List tasks with optional status / agent filters, newest first. */
38
+ export declare function listBackgroundTasks(filter?: {
39
+ status?: BackgroundTask['status'];
40
+ fromAgent?: string;
41
+ }, opts?: BackgroundTaskOptions): BackgroundTask[];
42
+ /** Transition a task to 'running' — daemon picked it up. */
43
+ export declare function markRunning(id: string, opts?: BackgroundTaskOptions): BackgroundTask | null;
44
+ /** Transition to 'done' with final result. */
45
+ export declare function markDone(id: string, result: string, deliverableNote?: string, opts?: BackgroundTaskOptions): BackgroundTask | null;
46
+ /** Transition to 'failed' or 'aborted' with error message. */
47
+ export declare function markFailed(id: string, error: string, reason?: 'failed' | 'aborted', opts?: BackgroundTaskOptions): BackgroundTask | null;
48
+ /**
49
+ * Daemon-restart hygiene: any task still in 'running' must be from a
50
+ * prior daemon process. Mark them aborted so the lifecycle is honest.
51
+ * Returns the count of tasks aborted.
52
+ */
53
+ export declare function abortStaleRunningTasks(opts?: BackgroundTaskOptions): number;
54
+ /** Test-only: delete a task file. Production code never deletes — history matters. */
55
+ export declare function _deleteBackgroundTask(id: string, opts?: BackgroundTaskOptions): void;
56
+ //# sourceMappingURL=background-tasks.d.ts.map
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Clementine TypeScript — Background task persistence + lifecycle helpers.
3
+ *
4
+ * A "background task" is an unleashed multi-turn job that an agent kicks
5
+ * off via the `start_background_task` MCP tool. Persistence is one JSON
6
+ * file per task at ~/.clementine/background-tasks/<id>.json. The file is
7
+ * the source of truth — the MCP tool writes the initial pending state,
8
+ * the daemon picks it up, runs it, and updates the same file as the
9
+ * lifecycle progresses.
10
+ *
11
+ * Process boundary: the MCP tool runs in an SDK subprocess, so it can't
12
+ * call the gateway directly. It writes a pending file; the daemon's
13
+ * cron-scheduler tick picks up pending tasks within ~3 seconds.
14
+ *
15
+ * Restart safety: on daemon startup, any task left in 'running' is
16
+ * aborted (its process is gone). P6b can add resumability; for now,
17
+ * fail-fast is clearer than silently re-running a task that may have
18
+ * already partially completed.
19
+ */
20
+ import { randomBytes } from 'node:crypto';
21
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
22
+ import path from 'node:path';
23
+ import { BASE_DIR } from '../config.js';
24
+ const DEFAULT_DIR = path.join(BASE_DIR, 'background-tasks');
25
+ const RESULT_TRUNCATE_BYTES = 3000;
26
+ export const BACKGROUND_TASK_DIR = DEFAULT_DIR;
27
+ function dirFor(opts) {
28
+ return opts?.dir ?? DEFAULT_DIR;
29
+ }
30
+ function makeId(now = new Date()) {
31
+ // Sortable-by-time prefix + 6 hex chars of randomness
32
+ return `bg-${now.getTime().toString(36)}-${randomBytes(3).toString('hex')}`;
33
+ }
34
+ function pathFor(id, opts) {
35
+ return path.join(dirFor(opts), `${id}.json`);
36
+ }
37
+ function safeWrite(file, task) {
38
+ mkdirSync(path.dirname(file), { recursive: true });
39
+ // Truncate result so a runaway task can't blow the file size
40
+ const slim = task.result && task.result.length > RESULT_TRUNCATE_BYTES
41
+ ? { ...task, result: task.result.slice(0, RESULT_TRUNCATE_BYTES) + '\n...[truncated]' }
42
+ : task;
43
+ writeFileSync(file, JSON.stringify(slim, null, 2));
44
+ }
45
+ /**
46
+ * Create a new pending task on disk and return it. Caller (the MCP tool)
47
+ * doesn't await execution — the daemon picks the task up asynchronously.
48
+ */
49
+ export function createBackgroundTask(input, opts) {
50
+ const now = new Date();
51
+ const task = {
52
+ id: makeId(now),
53
+ fromAgent: input.fromAgent,
54
+ prompt: input.prompt,
55
+ maxMinutes: Math.max(1, Math.min(240, Math.floor(input.maxMinutes))), // 1m–4h
56
+ status: 'pending',
57
+ createdAt: now.toISOString(),
58
+ };
59
+ safeWrite(pathFor(task.id, opts), task);
60
+ return task;
61
+ }
62
+ /** Load a task by id, or null if not found / malformed. */
63
+ export function loadBackgroundTask(id, opts) {
64
+ try {
65
+ const file = pathFor(id, opts);
66
+ if (!existsSync(file))
67
+ return null;
68
+ return JSON.parse(readFileSync(file, 'utf-8'));
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ /** List tasks with optional status / agent filters, newest first. */
75
+ export function listBackgroundTasks(filter = {}, opts) {
76
+ const dir = dirFor(opts);
77
+ if (!existsSync(dir))
78
+ return [];
79
+ const out = [];
80
+ let files;
81
+ try {
82
+ files = readdirSync(dir).filter((f) => f.endsWith('.json'));
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ for (const file of files) {
88
+ try {
89
+ const task = JSON.parse(readFileSync(path.join(dir, file), 'utf-8'));
90
+ if (filter.status && task.status !== filter.status)
91
+ continue;
92
+ if (filter.fromAgent && task.fromAgent !== filter.fromAgent)
93
+ continue;
94
+ out.push(task);
95
+ }
96
+ catch { /* skip malformed */ }
97
+ }
98
+ // Newest first by createdAt; falls back to id (which is timestamp-prefixed)
99
+ out.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
100
+ return out;
101
+ }
102
+ /** Transition a task to 'running' — daemon picked it up. */
103
+ export function markRunning(id, opts) {
104
+ const task = loadBackgroundTask(id, opts);
105
+ if (!task)
106
+ return null;
107
+ task.status = 'running';
108
+ task.startedAt = new Date().toISOString();
109
+ safeWrite(pathFor(id, opts), task);
110
+ return task;
111
+ }
112
+ /** Transition to 'done' with final result. */
113
+ export function markDone(id, result, deliverableNote, opts) {
114
+ const task = loadBackgroundTask(id, opts);
115
+ if (!task)
116
+ return null;
117
+ task.status = 'done';
118
+ task.completedAt = new Date().toISOString();
119
+ task.result = result;
120
+ if (deliverableNote)
121
+ task.deliverableNote = deliverableNote;
122
+ safeWrite(pathFor(id, opts), task);
123
+ return task;
124
+ }
125
+ /** Transition to 'failed' or 'aborted' with error message. */
126
+ export function markFailed(id, error, reason = 'failed', opts) {
127
+ const task = loadBackgroundTask(id, opts);
128
+ if (!task)
129
+ return null;
130
+ task.status = reason;
131
+ task.completedAt = new Date().toISOString();
132
+ task.error = error.slice(0, 1000);
133
+ safeWrite(pathFor(id, opts), task);
134
+ return task;
135
+ }
136
+ /**
137
+ * Daemon-restart hygiene: any task still in 'running' must be from a
138
+ * prior daemon process. Mark them aborted so the lifecycle is honest.
139
+ * Returns the count of tasks aborted.
140
+ */
141
+ export function abortStaleRunningTasks(opts) {
142
+ const stuck = listBackgroundTasks({ status: 'running' }, opts);
143
+ let aborted = 0;
144
+ for (const t of stuck) {
145
+ markFailed(t.id, 'daemon restarted while task was in flight', 'aborted', opts);
146
+ aborted++;
147
+ }
148
+ return aborted;
149
+ }
150
+ /** Test-only: delete a task file. Production code never deletes — history matters. */
151
+ export function _deleteBackgroundTask(id, opts) {
152
+ try {
153
+ const file = pathFor(id, opts);
154
+ if (existsSync(file))
155
+ unlinkSync(file);
156
+ }
157
+ catch { /* ignore */ }
158
+ }
159
+ //# sourceMappingURL=background-tasks.js.map
@@ -23,6 +23,8 @@ export interface RouteDecision {
23
23
  confidence: number;
24
24
  reasoning: string;
25
25
  }
26
+ /** Test-only: reset the cache between runs. */
27
+ export declare function _resetRouteCache(): void;
26
28
  export declare function isDirectImperative(userMessage: string): {
27
29
  match: boolean;
28
30
  pattern?: string;
@@ -18,6 +18,49 @@
18
18
  */
19
19
  import pino from 'pino';
20
20
  const logger = pino({ name: 'clementine.route-classifier' });
21
+ // ── LRU cache for repeated messages ──────────────────────────────────
22
+ // Same text + same available-agents set → same decision. Skips the
23
+ // Haiku LLM call entirely on cache hit (saves 1-2 seconds per repeat).
24
+ // Bounded by both size and TTL so stale rosters can't cause wrong routes.
25
+ const ROUTE_CACHE_MAX = 100;
26
+ const ROUTE_CACHE_TTL_MS = 5 * 60 * 1000;
27
+ // Insertion-ordered Map = LRU when we delete-and-reinsert on hit.
28
+ const routeCache = new Map();
29
+ function cacheKey(text, agents) {
30
+ // Trim + normalize whitespace so trailing-newline variations of the
31
+ // same message hit the same cache entry.
32
+ const normText = text.replace(/\s+/g, ' ').trim();
33
+ // Sort slugs so order doesn't matter; include count to invalidate on
34
+ // hire/fire (the agents array changes shape).
35
+ const slugFingerprint = agents.map(a => a.slug).sort().join(',');
36
+ return `${slugFingerprint}::${normText}`;
37
+ }
38
+ function cacheGet(key, now) {
39
+ const entry = routeCache.get(key);
40
+ if (!entry)
41
+ return null;
42
+ if (entry.expiresAt <= now) {
43
+ routeCache.delete(key);
44
+ return null;
45
+ }
46
+ // LRU touch: remove + re-insert to move to end of insertion order
47
+ routeCache.delete(key);
48
+ routeCache.set(key, entry);
49
+ return entry;
50
+ }
51
+ function cachePut(key, decision, now) {
52
+ routeCache.set(key, { decision, expiresAt: now + ROUTE_CACHE_TTL_MS });
53
+ while (routeCache.size > ROUTE_CACHE_MAX) {
54
+ const oldest = routeCache.keys().next().value;
55
+ if (oldest === undefined)
56
+ break;
57
+ routeCache.delete(oldest);
58
+ }
59
+ }
60
+ /** Test-only: reset the cache between runs. */
61
+ export function _resetRouteCache() {
62
+ routeCache.clear();
63
+ }
21
64
  /**
22
65
  * Direct-imperative guardrail.
23
66
  *
@@ -229,6 +272,15 @@ export async function classifyRoute(userMessage, agents, gateway) {
229
272
  logger.debug({ trigger: 'question-opener' }, 'Routing skipped — question-opener');
230
273
  return null;
231
274
  }
275
+ // Cache hit short-circuit — same message + same roster as a recent
276
+ // call gets the same decision without firing the Haiku classifier.
277
+ const now = Date.now();
278
+ const key = cacheKey(userMessage, agents);
279
+ const hit = cacheGet(key, now);
280
+ if (hit) {
281
+ logger.debug({ trigger: 'cache-hit', cachedAgent: hit.decision?.targetAgent ?? 'clementine' }, 'Route classifier cache hit');
282
+ return hit.decision;
283
+ }
232
284
  // LLM classifier for everything else.
233
285
  const prompt = buildPrompt(userMessage, agents);
234
286
  let raw;
@@ -239,6 +291,7 @@ export async function classifyRoute(userMessage, agents, gateway) {
239
291
  }
240
292
  catch (err) {
241
293
  logger.warn({ err }, 'Route classifier call failed');
294
+ // Don't cache failures — next call should retry the LLM.
242
295
  return null;
243
296
  }
244
297
  const decision = parseResponse(raw);
@@ -254,6 +307,7 @@ export async function classifyRoute(userMessage, agents, gateway) {
254
307
  decision.targetAgent = 'clementine';
255
308
  decision.confidence = Math.min(decision.confidence, 0.3);
256
309
  }
310
+ cachePut(key, decision, now);
257
311
  return decision;
258
312
  }
259
313
  //# sourceMappingURL=route-classifier.js.map
@@ -1978,6 +1978,27 @@ export async function cmdDashboard(opts) {
1978
1978
  app.get('/api/agent-heartbeats', (_req, res) => {
1979
1979
  res.json(getAgentHeartbeats());
1980
1980
  });
1981
+ app.get('/api/background-tasks', async (_req, res) => {
1982
+ try {
1983
+ const { listBackgroundTasks } = await import('../agent/background-tasks.js');
1984
+ const tasks = listBackgroundTasks();
1985
+ const now = Date.now();
1986
+ // Add derived fields convenient for UI use
1987
+ const out = tasks.map((t) => {
1988
+ const startedMs = t.startedAt ? new Date(t.startedAt).getTime() : 0;
1989
+ const completedMs = t.completedAt ? new Date(t.completedAt).getTime() : 0;
1990
+ return {
1991
+ ...t,
1992
+ runningForMs: t.status === 'running' && startedMs > 0 ? now - startedMs : null,
1993
+ totalDurationMs: startedMs > 0 && completedMs > 0 ? completedMs - startedMs : null,
1994
+ };
1995
+ });
1996
+ res.json(out);
1997
+ }
1998
+ catch (err) {
1999
+ res.status(500).json({ error: String(err).slice(0, 200) });
2000
+ }
2001
+ });
1981
2002
  app.get('/api/heartbeat/agent/:slug', (req, res) => {
1982
2003
  const slug = req.params.slug;
1983
2004
  const state = getHeartbeat();
@@ -150,6 +150,17 @@ export declare class CronScheduler {
150
150
  private unwatchWorkflowDir;
151
151
  /** Watch the triggers directory for MCP-initiated job runs and goal work sessions. */
152
152
  private watchTriggers;
153
+ /**
154
+ * Pick up pending background tasks and run them via the unleashed
155
+ * cron path. Each task gets the originating agent's profile and
156
+ * Discord channel for the completion notification.
157
+ *
158
+ * Concurrency: a task moves from 'pending' to 'running' synchronously
159
+ * before the long-running work starts, so a second tick won't pick up
160
+ * the same task twice. Failures are logged + persisted; we never throw
161
+ * out of the trigger interval.
162
+ */
163
+ private processBackgroundTasks;
153
164
  /** Process any pending trigger files and run the corresponding jobs. */
154
165
  private processTriggers;
155
166
  /** Process any pending goal work trigger files. Routes through the execution advisor. */
@@ -18,6 +18,7 @@ import { scanner } from '../security/scanner.js';
18
18
  import { parseAllWorkflows as parseAllWorkflowsSync } from '../agent/workflow-runner.js';
19
19
  import { SelfImproveLoop } from '../agent/self-improve.js';
20
20
  import { logAuditJsonl } from '../agent/hooks.js';
21
+ import { listBackgroundTasks, markDone as markBgTaskDone, markFailed as markBgTaskFailed, markRunning as markBgTaskRunning, } from '../agent/background-tasks.js';
21
22
  import { outcomeStatusFromGoalDisposition, recentDecisions, recordDecisionOutcome, } from '../agent/proactive-ledger.js';
22
23
  const logger = pino({ name: 'clementine.cron' });
23
24
  /** Default timeout for standard cron jobs (10 minutes). */
@@ -1501,8 +1502,73 @@ export class CronScheduler {
1501
1502
  this.triggerTimer = setInterval(() => {
1502
1503
  this.processTriggers();
1503
1504
  this.processGoalTriggers();
1505
+ this.processBackgroundTasks();
1504
1506
  }, 3000);
1505
1507
  }
1508
+ /**
1509
+ * Pick up pending background tasks and run them via the unleashed
1510
+ * cron path. Each task gets the originating agent's profile and
1511
+ * Discord channel for the completion notification.
1512
+ *
1513
+ * Concurrency: a task moves from 'pending' to 'running' synchronously
1514
+ * before the long-running work starts, so a second tick won't pick up
1515
+ * the same task twice. Failures are logged + persisted; we never throw
1516
+ * out of the trigger interval.
1517
+ */
1518
+ processBackgroundTasks() {
1519
+ let pending;
1520
+ try {
1521
+ pending = listBackgroundTasks({ status: 'pending' });
1522
+ }
1523
+ catch {
1524
+ return;
1525
+ }
1526
+ if (pending.length === 0)
1527
+ return;
1528
+ for (const task of pending) {
1529
+ // Move to 'running' synchronously so the next tick (3s away) won't
1530
+ // re-pick. Even if the work below errors, the state is honest.
1531
+ const started = markBgTaskRunning(task.id);
1532
+ if (!started)
1533
+ continue;
1534
+ logger.info({ id: task.id, fromAgent: task.fromAgent, maxMinutes: task.maxMinutes }, 'Background task picked up');
1535
+ // Don't await — fire-and-forget. The 3s tick continues to scan.
1536
+ const jobName = `bg:${task.id}`;
1537
+ const maxHours = Math.max(0.05, task.maxMinutes / 60);
1538
+ this.gateway.handleCronJob(jobName, task.prompt, 2, // tier 2 (Bash/Write/Edit available)
1539
+ undefined, // default maxTurns
1540
+ undefined, // default model
1541
+ undefined, // default workDir
1542
+ 'unleashed', // long-running mode
1543
+ maxHours, undefined, // timeoutMs (maxHours covers it)
1544
+ undefined, // successCriteria
1545
+ task.fromAgent).then((result) => {
1546
+ try {
1547
+ markBgTaskDone(task.id, result ?? '(no output)');
1548
+ }
1549
+ catch (err) {
1550
+ logger.warn({ err, id: task.id }, 'Failed to mark background task done');
1551
+ }
1552
+ // Dispatch the deliverable to the originating agent's channel.
1553
+ const deliveryHead = `**Background task ${task.id} done** — ${task.prompt.slice(0, 100).replace(/\s+/g, ' ')}${task.prompt.length > 100 ? '...' : ''}\n\n`;
1554
+ const body = (result ?? '').slice(0, 1500);
1555
+ this.dispatcher
1556
+ .send(deliveryHead + body, { agentSlug: task.fromAgent !== 'clementine' ? task.fromAgent : undefined })
1557
+ .catch((err) => logger.debug({ err, id: task.id }, 'Failed to dispatch background task result'));
1558
+ }).catch((err) => {
1559
+ const errStr = String(err).slice(0, 500);
1560
+ try {
1561
+ markBgTaskFailed(task.id, errStr, 'failed');
1562
+ }
1563
+ catch (saveErr) {
1564
+ logger.warn({ err: saveErr, id: task.id }, 'Failed to mark background task failed');
1565
+ }
1566
+ this.dispatcher
1567
+ .send(`**Background task ${task.id} failed** — ${errStr.slice(0, 200)}`, { agentSlug: task.fromAgent !== 'clementine' ? task.fromAgent : undefined })
1568
+ .catch(() => { });
1569
+ });
1570
+ }
1571
+ }
1506
1572
  /** Process any pending trigger files and run the corresponding jobs. */
1507
1573
  processTriggers() {
1508
1574
  if (!existsSync(this.triggerDir))
package/dist/index.js CHANGED
@@ -762,6 +762,19 @@ async function asyncMain() {
762
762
  heartbeat.start();
763
763
  cronScheduler.start();
764
764
  agentHeartbeats.start();
765
+ // Background-task hygiene: any task left in 'running' is from a prior
766
+ // process. Mark them aborted so the lifecycle is honest. (P6b will add
767
+ // resumability; for now fail-fast is clearer than silently re-running.)
768
+ try {
769
+ const { abortStaleRunningTasks } = await import('./agent/background-tasks.js');
770
+ const aborted = abortStaleRunningTasks();
771
+ if (aborted > 0) {
772
+ logger.info({ count: aborted }, 'Aborted stale running background tasks from prior daemon');
773
+ }
774
+ }
775
+ catch (err) {
776
+ logger.warn({ err }, 'Background task hygiene check failed — non-fatal');
777
+ }
765
778
  const timerInterval = startTimerChecker(dispatcher, gateway);
766
779
  // Start brain ingest scheduler (polls registered REST sources on their cron)
767
780
  try {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Clementine TypeScript — Background task MCP tools.
3
+ *
4
+ * `start_background_task` lets an agent kick off a long-running job
5
+ * (research, multi-page extraction, batch outreach) without blocking
6
+ * the conversation. The agent gets a task id immediately and is
7
+ * notified in their channel when the work completes.
8
+ *
9
+ * Internally: the tool writes a pending task file. The daemon's
10
+ * cron-scheduler tick picks it up within ~3 seconds, runs it via
11
+ * runUnleashedTask with the agent's profile, then dispatches the
12
+ * result to the agent's Discord channel.
13
+ */
14
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
+ export declare function registerBackgroundTaskTools(server: McpServer): void;
16
+ //# sourceMappingURL=background-task-tools.d.ts.map
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Clementine TypeScript — Background task MCP tools.
3
+ *
4
+ * `start_background_task` lets an agent kick off a long-running job
5
+ * (research, multi-page extraction, batch outreach) without blocking
6
+ * the conversation. The agent gets a task id immediately and is
7
+ * notified in their channel when the work completes.
8
+ *
9
+ * Internally: the tool writes a pending task file. The daemon's
10
+ * cron-scheduler tick picks it up within ~3 seconds, runs it via
11
+ * runUnleashedTask with the agent's profile, then dispatches the
12
+ * result to the agent's Discord channel.
13
+ */
14
+ import { z } from 'zod';
15
+ import { createBackgroundTask, listBackgroundTasks, loadBackgroundTask, } from '../agent/background-tasks.js';
16
+ import { ACTIVE_AGENT_SLUG, logger, textResult } from './shared.js';
17
+ const DEFAULT_MAX_MINUTES = 30;
18
+ export function registerBackgroundTaskTools(server) {
19
+ server.tool('start_background_task', 'Kick off a long-running autonomous task in the background. Use when the work would burn the chat context (deep research, multi-page extraction, batch processing) or take longer than a chat turn. Returns a task id immediately. The daemon picks the task up within seconds, runs it with your profile + tools, and posts the deliverable to your Discord channel when done.', {
20
+ prompt: z.string().describe('The full task description — be specific about what you want produced. Use the same level of detail you would give a teammate.'),
21
+ max_minutes: z.number().optional().describe(`Hard wall-clock cap on the task. Default ${DEFAULT_MAX_MINUTES} min. Range 1–240. Use longer caps for sustained research.`),
22
+ }, async ({ prompt, max_minutes }) => {
23
+ const fromAgent = ACTIVE_AGENT_SLUG || 'clementine';
24
+ const trimmed = (prompt ?? '').trim();
25
+ if (!trimmed) {
26
+ return textResult('start_background_task: prompt is required.');
27
+ }
28
+ const cap = typeof max_minutes === 'number' ? max_minutes : DEFAULT_MAX_MINUTES;
29
+ const task = createBackgroundTask({
30
+ fromAgent,
31
+ prompt: trimmed,
32
+ maxMinutes: cap,
33
+ });
34
+ logger.info({ id: task.id, fromAgent, maxMinutes: task.maxMinutes }, 'Background task queued');
35
+ return textResult(`Queued **${task.id}** (max ${task.maxMinutes} min). The daemon will pick it up within a few seconds and run it in the background. You'll get a notification in your channel when the deliverable lands. Use \`get_background_task\` to check status.`);
36
+ });
37
+ server.tool('get_background_task', 'Check the status of a background task. Returns its lifecycle state (pending|running|done|failed|aborted), how long it has been running, and the result/error if terminal.', {
38
+ task_id: z.string().describe('Task id returned by start_background_task (e.g., "bg-abc123-def4")'),
39
+ }, async ({ task_id }) => {
40
+ const task = loadBackgroundTask(task_id);
41
+ if (!task) {
42
+ return textResult(`get_background_task: no task found with id "${task_id}".`);
43
+ }
44
+ const lines = [];
45
+ lines.push(`**${task.id}** — ${task.status}`);
46
+ lines.push(`From: ${task.fromAgent}`);
47
+ lines.push(`Created: ${task.createdAt}`);
48
+ if (task.startedAt)
49
+ lines.push(`Started: ${task.startedAt}`);
50
+ if (task.completedAt)
51
+ lines.push(`Completed: ${task.completedAt}`);
52
+ lines.push(`Max minutes: ${task.maxMinutes}`);
53
+ lines.push('');
54
+ lines.push(`Prompt: ${task.prompt.slice(0, 300)}${task.prompt.length > 300 ? '...' : ''}`);
55
+ if (task.status === 'running' && task.startedAt) {
56
+ const elapsedMin = Math.round((Date.now() - new Date(task.startedAt).getTime()) / 60000);
57
+ lines.push('');
58
+ lines.push(`Running for ${elapsedMin}m / ${task.maxMinutes}m cap.`);
59
+ }
60
+ if (task.error) {
61
+ lines.push('');
62
+ lines.push(`Error: ${task.error}`);
63
+ }
64
+ if (task.result) {
65
+ lines.push('');
66
+ lines.push(`Result:\n${task.result}`);
67
+ }
68
+ if (task.deliverableNote) {
69
+ lines.push('');
70
+ lines.push(`Deliverable: ${task.deliverableNote}`);
71
+ }
72
+ return textResult(lines.join('\n'));
73
+ });
74
+ server.tool('list_background_tasks', 'List background tasks, optionally filtered by status or originating agent. Newest first. Use to see what work is in flight or completed recently.', {
75
+ status: z
76
+ .enum(['pending', 'running', 'done', 'failed', 'aborted'])
77
+ .optional()
78
+ .describe('Filter by lifecycle status'),
79
+ from_agent: z.string().optional().describe('Filter by originating agent slug'),
80
+ limit: z.number().optional().describe('Max number to return (default 20, max 100)'),
81
+ }, async ({ status, from_agent, limit }) => {
82
+ const filter = {};
83
+ if (status)
84
+ filter.status = status;
85
+ if (from_agent)
86
+ filter.fromAgent = from_agent;
87
+ const all = listBackgroundTasks(filter);
88
+ const cap = Math.max(1, Math.min(100, typeof limit === 'number' ? limit : 20));
89
+ const tasks = all.slice(0, cap);
90
+ if (tasks.length === 0) {
91
+ const filterDesc = [
92
+ status ? `status=${status}` : '',
93
+ from_agent ? `from_agent=${from_agent}` : '',
94
+ ].filter(Boolean).join(', ');
95
+ return textResult(`No background tasks found${filterDesc ? ` (${filterDesc})` : ''}.`);
96
+ }
97
+ const lines = [`## Background tasks (${tasks.length}${all.length > tasks.length ? ` of ${all.length}` : ''})`];
98
+ for (const t of tasks) {
99
+ const promptHead = t.prompt.replace(/\s+/g, ' ').slice(0, 80);
100
+ lines.push(`- **${t.id}** [${t.status}] ${t.fromAgent}: ${promptHead}${t.prompt.length > 80 ? '...' : ''}`);
101
+ }
102
+ return textResult(lines.join('\n'));
103
+ });
104
+ }
105
+ //# sourceMappingURL=background-task-tools.js.map
@@ -27,6 +27,7 @@ import { registerSessionTools } from './session-tools.js';
27
27
  import { registerArtifactTools } from './artifact-tools.js';
28
28
  import { registerBrainTools } from './brain-tools.js';
29
29
  import { registerAgentHeartbeatTools } from './agent-heartbeat-tools.js';
30
+ import { registerBackgroundTaskTools } from './background-task-tools.js';
30
31
  // ── Server ──────────────────────────────────────────────────────────────
31
32
  const serverName = (env['ASSISTANT_NAME'] ?? 'Clementine').toLowerCase() + '-tools';
32
33
  const server = new McpServer({ name: serverName, version: '1.0.0' });
@@ -41,6 +42,7 @@ registerSessionTools(server);
41
42
  registerArtifactTools(server);
42
43
  registerBrainTools(server);
43
44
  registerAgentHeartbeatTools(server);
45
+ registerBackgroundTaskTools(server);
44
46
  // ── Main ────────────────────────────────────────────────────────────────
45
47
  async function main() {
46
48
  // Initialize memory store and run full sync on startup
package/dist/types.d.ts CHANGED
@@ -239,6 +239,30 @@ export interface HeartbeatWorkItem {
239
239
  error?: string;
240
240
  agentSlug?: string;
241
241
  }
242
+ /**
243
+ * Long-running autonomous task an agent kicks off via the
244
+ * `start_background_task` MCP tool. The task runs in the daemon as an
245
+ * unleashed cron-style job with the requesting agent's profile, then
246
+ * notifies that agent's Discord channel on completion.
247
+ *
248
+ * Lifecycle: pending → running → (done | failed | aborted)
249
+ *
250
+ * Persisted as ~/.clementine/background-tasks/<id>.json. The file is
251
+ * the source of truth; status is updated in place as the task progresses.
252
+ */
253
+ export interface BackgroundTask {
254
+ id: string;
255
+ fromAgent: string;
256
+ prompt: string;
257
+ maxMinutes: number;
258
+ status: 'pending' | 'running' | 'done' | 'failed' | 'aborted';
259
+ createdAt: string;
260
+ startedAt?: string;
261
+ completedAt?: string;
262
+ result?: string;
263
+ error?: string;
264
+ deliverableNote?: string;
265
+ }
242
266
  /**
243
267
  * State for one specialist agent's heartbeat scheduler. Persisted at
244
268
  * ~/.clementine/heartbeat/agents/<slug>/state.json. Manager reads
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.85",
3
+ "version": "1.0.87",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",