clementine-agent 1.0.86 → 1.0.88

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
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Clementine TypeScript — Webhook → action dispatch.
3
+ *
4
+ * External services (Salesforce, GitHub, calendar, email) POST events
5
+ * to /webhook-action/:source. This module turns those events into
6
+ * agentic actions:
7
+ *
8
+ * - `wake_agent` → write wake sentinel; agent ticks within ~3s
9
+ * - `start_background_task` → create a pending task; cron-scheduler picks
10
+ * it up and runs unleashed
11
+ *
12
+ * Configuration: ~/.clementine/webhook-actions.json
13
+ *
14
+ * {
15
+ * "hooks": [
16
+ * {
17
+ * "source": "github",
18
+ * "secretEnv": "GITHUB_WEBHOOK_SECRET",
19
+ * "on": [
20
+ * {
21
+ * "match": { "action": "opened", "pull_request": "*" },
22
+ * "do": "wake_agent",
23
+ * "agent": "ross-the-sdr",
24
+ * "reason": "PR opened — review needed"
25
+ * }
26
+ * ]
27
+ * }
28
+ * ]
29
+ * }
30
+ *
31
+ * Match values: literal strings/numbers/booleans for exact match, or "*"
32
+ * to require the field be present (any value, non-null/undefined). Dot
33
+ * notation supported for nested fields ("payload.user.id"). All conditions
34
+ * in a `match` block must hold (AND).
35
+ *
36
+ * Templating: `prompt` and `reason` strings can interpolate payload
37
+ * fields with `{{ field.path }}`. Missing fields render as empty string.
38
+ *
39
+ * Every dispatched event is logged to ~/.clementine/webhook-actions/log.jsonl
40
+ * (rotated at 1MB / 1000 lines, 30-day retention).
41
+ */
42
+ export type WebhookActionVerb = 'wake_agent' | 'start_background_task';
43
+ export interface WebhookActionRule {
44
+ /** Field/value conditions, all must match. Use "*" for "field present". */
45
+ match?: Record<string, string | number | boolean>;
46
+ do: WebhookActionVerb;
47
+ agent: string;
48
+ /** For wake_agent — short reason annotated on the wake sentinel. */
49
+ reason?: string;
50
+ /** For start_background_task — the prompt template. Supports {{ field.path }}. */
51
+ prompt?: string;
52
+ /** For start_background_task — wall-clock cap. Default 30. */
53
+ maxMinutes?: number;
54
+ }
55
+ export interface WebhookActionSource {
56
+ source: string;
57
+ /** Env var holding the HMAC secret. Required unless `secret` is set inline. */
58
+ secretEnv?: string;
59
+ /** Inline secret (for tests / local-only setups). Prefer secretEnv in prod. */
60
+ secret?: string;
61
+ on: WebhookActionRule[];
62
+ }
63
+ export interface WebhookActionConfig {
64
+ hooks: WebhookActionSource[];
65
+ }
66
+ export interface DispatchResult {
67
+ matched: number;
68
+ dispatched: number;
69
+ errors: string[];
70
+ log: Array<{
71
+ rule: WebhookActionRule;
72
+ ok: boolean;
73
+ message: string;
74
+ }>;
75
+ }
76
+ export declare function loadWebhookActionConfig(opts?: {
77
+ configPath?: string;
78
+ }): WebhookActionConfig;
79
+ export declare function getSourceConfig(source: string, opts?: {
80
+ configPath?: string;
81
+ }): WebhookActionSource | null;
82
+ /** All match conditions hold. "*" means "field is present (non-null/undefined)". */
83
+ export declare function ruleMatches(rule: WebhookActionRule, payload: unknown): boolean;
84
+ /** Replace {{ dot.path }} in a template with payload values. Missing → "". */
85
+ export declare function renderTemplate(template: string, payload: unknown): string;
86
+ /**
87
+ * Match the payload against every rule in the source config and dispatch
88
+ * all matches. Each rule is independent — multiple matches all fire.
89
+ */
90
+ export declare function dispatchWebhookActions(source: string, payload: unknown, opts?: {
91
+ configPath?: string;
92
+ baseDir?: string;
93
+ }): DispatchResult;
94
+ export interface WebhookEventLogEntry {
95
+ timestamp: string;
96
+ source: string;
97
+ verified: boolean;
98
+ matched: number;
99
+ dispatched: number;
100
+ errors: string[];
101
+ payloadPreview: string;
102
+ }
103
+ export declare function logWebhookEvent(entry: WebhookEventLogEntry, opts?: {
104
+ logPath?: string;
105
+ logDir?: string;
106
+ }): void;
107
+ export declare function recentWebhookEvents(limit?: number, opts?: {
108
+ logPath?: string;
109
+ }): WebhookEventLogEntry[];
110
+ export declare const _internals: {
111
+ CONFIG_PATH: string;
112
+ LOG_PATH: string;
113
+ LOG_DIR: string;
114
+ WAKE_DIR: string;
115
+ };
116
+ //# sourceMappingURL=webhook-actions.d.ts.map
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Clementine TypeScript — Webhook → action dispatch.
3
+ *
4
+ * External services (Salesforce, GitHub, calendar, email) POST events
5
+ * to /webhook-action/:source. This module turns those events into
6
+ * agentic actions:
7
+ *
8
+ * - `wake_agent` → write wake sentinel; agent ticks within ~3s
9
+ * - `start_background_task` → create a pending task; cron-scheduler picks
10
+ * it up and runs unleashed
11
+ *
12
+ * Configuration: ~/.clementine/webhook-actions.json
13
+ *
14
+ * {
15
+ * "hooks": [
16
+ * {
17
+ * "source": "github",
18
+ * "secretEnv": "GITHUB_WEBHOOK_SECRET",
19
+ * "on": [
20
+ * {
21
+ * "match": { "action": "opened", "pull_request": "*" },
22
+ * "do": "wake_agent",
23
+ * "agent": "ross-the-sdr",
24
+ * "reason": "PR opened — review needed"
25
+ * }
26
+ * ]
27
+ * }
28
+ * ]
29
+ * }
30
+ *
31
+ * Match values: literal strings/numbers/booleans for exact match, or "*"
32
+ * to require the field be present (any value, non-null/undefined). Dot
33
+ * notation supported for nested fields ("payload.user.id"). All conditions
34
+ * in a `match` block must hold (AND).
35
+ *
36
+ * Templating: `prompt` and `reason` strings can interpolate payload
37
+ * fields with `{{ field.path }}`. Missing fields render as empty string.
38
+ *
39
+ * Every dispatched event is logged to ~/.clementine/webhook-actions/log.jsonl
40
+ * (rotated at 1MB / 1000 lines, 30-day retention).
41
+ */
42
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs';
43
+ import path from 'node:path';
44
+ import { BASE_DIR } from '../config.js';
45
+ import { createBackgroundTask } from './background-tasks.js';
46
+ // ── Storage paths ────────────────────────────────────────────────────
47
+ const CONFIG_PATH = path.join(BASE_DIR, 'webhook-actions.json');
48
+ const LOG_DIR = path.join(BASE_DIR, 'webhook-actions');
49
+ const LOG_PATH = path.join(LOG_DIR, 'log.jsonl');
50
+ const WAKE_DIR = path.join(BASE_DIR, 'heartbeat', 'wake');
51
+ const LOG_MAX_BYTES = 1_000_000;
52
+ const LOG_MAX_LINES = 1000;
53
+ const LOG_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
54
+ // ── Config I/O ───────────────────────────────────────────────────────
55
+ export function loadWebhookActionConfig(opts) {
56
+ const file = opts?.configPath ?? CONFIG_PATH;
57
+ if (!existsSync(file))
58
+ return { hooks: [] };
59
+ try {
60
+ const raw = JSON.parse(readFileSync(file, 'utf-8'));
61
+ if (!Array.isArray(raw.hooks))
62
+ return { hooks: [] };
63
+ return { hooks: raw.hooks };
64
+ }
65
+ catch {
66
+ return { hooks: [] };
67
+ }
68
+ }
69
+ export function getSourceConfig(source, opts) {
70
+ return loadWebhookActionConfig(opts).hooks.find((h) => h.source === source) ?? null;
71
+ }
72
+ // ── Matcher ──────────────────────────────────────────────────────────
73
+ /** Read a dot-path from a JSON-ish object. Returns undefined if any segment is missing. */
74
+ function readPath(obj, dotPath) {
75
+ if (obj == null || typeof obj !== 'object')
76
+ return undefined;
77
+ let cursor = obj;
78
+ for (const segment of dotPath.split('.')) {
79
+ if (cursor == null || typeof cursor !== 'object')
80
+ return undefined;
81
+ cursor = cursor[segment];
82
+ }
83
+ return cursor;
84
+ }
85
+ /** All match conditions hold. "*" means "field is present (non-null/undefined)". */
86
+ export function ruleMatches(rule, payload) {
87
+ const conds = rule.match;
88
+ if (!conds || Object.keys(conds).length === 0)
89
+ return true; // empty match = match-all
90
+ for (const [pathSpec, expected] of Object.entries(conds)) {
91
+ const actual = readPath(payload, pathSpec);
92
+ if (expected === '*') {
93
+ if (actual === undefined || actual === null)
94
+ return false;
95
+ continue;
96
+ }
97
+ // Loose equality: 1 == "1", true == "true". Real users put strings in JSON; loose is friendlier.
98
+ // eslint-disable-next-line eqeqeq
99
+ if (actual == expected)
100
+ continue;
101
+ return false;
102
+ }
103
+ return true;
104
+ }
105
+ /** Replace {{ dot.path }} in a template with payload values. Missing → "". */
106
+ export function renderTemplate(template, payload) {
107
+ return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, dotPath) => {
108
+ const v = readPath(payload, dotPath);
109
+ if (v == null)
110
+ return '';
111
+ if (typeof v === 'object')
112
+ return JSON.stringify(v);
113
+ return String(v);
114
+ });
115
+ }
116
+ function wakeSentinelPath(slug, baseDir) {
117
+ return path.join(baseDir, 'heartbeat', 'wake', `${slug}.json`);
118
+ }
119
+ function dispatchOne(rule, source, payload, env) {
120
+ const baseDir = env.baseDir ?? BASE_DIR;
121
+ if (rule.do === 'wake_agent') {
122
+ try {
123
+ const wakeDir = path.join(baseDir, 'heartbeat', 'wake');
124
+ mkdirSync(wakeDir, { recursive: true });
125
+ const reason = rule.reason ? renderTemplate(rule.reason, payload) : `webhook:${source}`;
126
+ const sentinel = {
127
+ targetSlug: rule.agent,
128
+ fromSlug: `webhook:${source}`,
129
+ reason: reason.slice(0, 200),
130
+ requestedAt: new Date().toISOString(),
131
+ };
132
+ writeFileSync(wakeSentinelPath(rule.agent, baseDir), JSON.stringify(sentinel, null, 2));
133
+ return { ok: true, message: `Woke ${rule.agent} (${reason})` };
134
+ }
135
+ catch (err) {
136
+ return { ok: false, message: `wake_agent failed: ${String(err).slice(0, 200)}` };
137
+ }
138
+ }
139
+ if (rule.do === 'start_background_task') {
140
+ if (!rule.prompt) {
141
+ return { ok: false, message: 'start_background_task: rule has no `prompt` template' };
142
+ }
143
+ try {
144
+ const prompt = renderTemplate(rule.prompt, payload);
145
+ const task = createBackgroundTask({
146
+ fromAgent: rule.agent,
147
+ prompt,
148
+ maxMinutes: rule.maxMinutes ?? 30,
149
+ }, env.baseDir ? { dir: path.join(env.baseDir, 'background-tasks') } : undefined);
150
+ return { ok: true, message: `Queued background task ${task.id} for ${rule.agent}` };
151
+ }
152
+ catch (err) {
153
+ return { ok: false, message: `start_background_task failed: ${String(err).slice(0, 200)}` };
154
+ }
155
+ }
156
+ // Exhaustiveness check — should never hit at runtime if types are honored.
157
+ return { ok: false, message: `Unknown action verb: ${rule.do}` };
158
+ }
159
+ /**
160
+ * Match the payload against every rule in the source config and dispatch
161
+ * all matches. Each rule is independent — multiple matches all fire.
162
+ */
163
+ export function dispatchWebhookActions(source, payload, opts) {
164
+ const cfg = getSourceConfig(source, opts);
165
+ const result = { matched: 0, dispatched: 0, errors: [], log: [] };
166
+ if (!cfg) {
167
+ result.errors.push(`No webhook-action config for source "${source}"`);
168
+ return result;
169
+ }
170
+ for (const rule of cfg.on) {
171
+ if (!ruleMatches(rule, payload))
172
+ continue;
173
+ result.matched++;
174
+ const r = dispatchOne(rule, source, payload, { baseDir: opts?.baseDir });
175
+ result.log.push({ rule, ok: r.ok, message: r.message });
176
+ if (r.ok)
177
+ result.dispatched++;
178
+ else
179
+ result.errors.push(r.message);
180
+ }
181
+ return result;
182
+ }
183
+ function rotateLogIfNeeded(opts) {
184
+ const file = opts?.logPath ?? LOG_PATH;
185
+ try {
186
+ if (!existsSync(file))
187
+ return;
188
+ const { size } = statSync(file);
189
+ if (size <= LOG_MAX_BYTES)
190
+ return;
191
+ const lines = readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean);
192
+ if (lines.length <= LOG_MAX_LINES)
193
+ return;
194
+ const cutoff = Date.now() - LOG_MAX_AGE_MS;
195
+ const kept = [];
196
+ for (const line of lines.slice(-LOG_MAX_LINES)) {
197
+ try {
198
+ const e = JSON.parse(line);
199
+ const ts = new Date(e.timestamp).getTime();
200
+ if (Number.isFinite(ts) && ts >= cutoff)
201
+ kept.push(line);
202
+ }
203
+ catch { /* drop malformed */ }
204
+ }
205
+ writeFileSync(file, kept.join('\n') + (kept.length ? '\n' : ''));
206
+ }
207
+ catch { /* non-fatal */ }
208
+ }
209
+ export function logWebhookEvent(entry, opts) {
210
+ try {
211
+ const dir = opts?.logDir ?? LOG_DIR;
212
+ const file = opts?.logPath ?? LOG_PATH;
213
+ mkdirSync(dir, { recursive: true });
214
+ appendFileSync(file, JSON.stringify(entry) + '\n');
215
+ setImmediate(() => rotateLogIfNeeded(opts));
216
+ }
217
+ catch { /* non-fatal */ }
218
+ }
219
+ export function recentWebhookEvents(limit = 50, opts) {
220
+ try {
221
+ const file = opts?.logPath ?? LOG_PATH;
222
+ if (!existsSync(file))
223
+ return [];
224
+ const lines = readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean);
225
+ const out = [];
226
+ for (const line of lines.slice(-limit).reverse()) {
227
+ try {
228
+ out.push(JSON.parse(line));
229
+ }
230
+ catch { /* skip */ }
231
+ }
232
+ return out;
233
+ }
234
+ catch {
235
+ return [];
236
+ }
237
+ }
238
+ // ── Test-only ────────────────────────────────────────────────────────
239
+ export const _internals = { CONFIG_PATH, LOG_PATH, LOG_DIR, WAKE_DIR };
240
+ //# sourceMappingURL=webhook-actions.js.map
@@ -1364,6 +1364,66 @@ export async function cmdDashboard(opts) {
1364
1364
  diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
1365
1365
  return diff === 0;
1366
1366
  }
1367
+ // ── Webhook → action triggers ─────────────────────────────────────
1368
+ // Sibling of /webhook/:slug. Where /webhook/:slug ingests data into
1369
+ // the brain, /webhook-action/:source dispatches agentic actions
1370
+ // (wake an agent, start a background task) based on a YAML-ish config
1371
+ // at ~/.clementine/webhook-actions.json. Same HMAC verification.
1372
+ app.post('/webhook-action/:source', rawBodyParser, async (req, res) => {
1373
+ const sourceParam = req.params.source;
1374
+ const { getSourceConfig, dispatchWebhookActions, logWebhookEvent, } = await import('../agent/webhook-actions.js');
1375
+ const cfg = getSourceConfig(sourceParam);
1376
+ if (!cfg) {
1377
+ res.status(404).json({ error: `No webhook-action config for source "${sourceParam}"` });
1378
+ return;
1379
+ }
1380
+ // Resolve the HMAC secret (env first, inline fallback for local dev).
1381
+ const secret = (cfg.secretEnv ? process.env[cfg.secretEnv] : undefined) ?? cfg.secret ?? '';
1382
+ if (!secret) {
1383
+ res.status(500).json({ error: `Webhook source "${sourceParam}" has no secret configured` });
1384
+ return;
1385
+ }
1386
+ const sig = String(req.headers['x-signature'] ?? req.headers['x-hub-signature-256'] ?? '').trim();
1387
+ const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.from(String(req.body ?? ''));
1388
+ const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
1389
+ const given = sig.startsWith('sha256=') ? sig.slice('sha256='.length) : sig;
1390
+ if (!given || !safeHexEquals(given, expected)) {
1391
+ logWebhookEvent({
1392
+ timestamp: new Date().toISOString(),
1393
+ source: sourceParam,
1394
+ verified: false,
1395
+ matched: 0,
1396
+ dispatched: 0,
1397
+ errors: ['HMAC signature mismatch'],
1398
+ payloadPreview: rawBody.toString('utf-8').slice(0, 200),
1399
+ });
1400
+ res.status(401).json({ error: 'Invalid or missing HMAC signature' });
1401
+ return;
1402
+ }
1403
+ let payload;
1404
+ try {
1405
+ payload = JSON.parse(rawBody.toString('utf-8'));
1406
+ }
1407
+ catch (err) {
1408
+ res.status(400).json({ error: `Body is not JSON: ${err instanceof Error ? err.message : String(err)}` });
1409
+ return;
1410
+ }
1411
+ const result = dispatchWebhookActions(sourceParam, payload);
1412
+ logWebhookEvent({
1413
+ timestamp: new Date().toISOString(),
1414
+ source: sourceParam,
1415
+ verified: true,
1416
+ matched: result.matched,
1417
+ dispatched: result.dispatched,
1418
+ errors: result.errors,
1419
+ payloadPreview: rawBody.toString('utf-8').slice(0, 200),
1420
+ });
1421
+ res.json({
1422
+ matched: result.matched,
1423
+ dispatched: result.dispatched,
1424
+ errors: result.errors,
1425
+ });
1426
+ });
1367
1427
  // Only parse JSON bodies on POST/PUT/PATCH — GET requests don't need body parsing.
1368
1428
  // Registered AFTER the webhook route so /webhook/* keeps its raw body.
1369
1429
  app.use((req, res, next) => {
@@ -1978,6 +2038,57 @@ export async function cmdDashboard(opts) {
1978
2038
  app.get('/api/agent-heartbeats', (_req, res) => {
1979
2039
  res.json(getAgentHeartbeats());
1980
2040
  });
2041
+ app.get('/api/webhook-actions', async (_req, res) => {
2042
+ try {
2043
+ const { loadWebhookActionConfig, recentWebhookEvents } = await import('../agent/webhook-actions.js');
2044
+ const cfg = loadWebhookActionConfig();
2045
+ // Don't leak secrets — strip secret/secretEnv from the config response
2046
+ const sanitized = {
2047
+ hooks: cfg.hooks.map((h) => ({
2048
+ source: h.source,
2049
+ hasSecret: Boolean(h.secret) || Boolean(h.secretEnv),
2050
+ secretEnv: h.secretEnv ?? null,
2051
+ rules: h.on.length,
2052
+ on: h.on.map((r) => ({
2053
+ do: r.do,
2054
+ agent: r.agent,
2055
+ match: r.match ?? {},
2056
+ reason: r.reason,
2057
+ promptHead: r.prompt ? r.prompt.slice(0, 120) : undefined,
2058
+ maxMinutes: r.maxMinutes,
2059
+ })),
2060
+ })),
2061
+ };
2062
+ res.json({
2063
+ config: sanitized,
2064
+ recent: recentWebhookEvents(50),
2065
+ });
2066
+ }
2067
+ catch (err) {
2068
+ res.status(500).json({ error: String(err).slice(0, 200) });
2069
+ }
2070
+ });
2071
+ app.get('/api/background-tasks', async (_req, res) => {
2072
+ try {
2073
+ const { listBackgroundTasks } = await import('../agent/background-tasks.js');
2074
+ const tasks = listBackgroundTasks();
2075
+ const now = Date.now();
2076
+ // Add derived fields convenient for UI use
2077
+ const out = tasks.map((t) => {
2078
+ const startedMs = t.startedAt ? new Date(t.startedAt).getTime() : 0;
2079
+ const completedMs = t.completedAt ? new Date(t.completedAt).getTime() : 0;
2080
+ return {
2081
+ ...t,
2082
+ runningForMs: t.status === 'running' && startedMs > 0 ? now - startedMs : null,
2083
+ totalDurationMs: startedMs > 0 && completedMs > 0 ? completedMs - startedMs : null,
2084
+ };
2085
+ });
2086
+ res.json(out);
2087
+ }
2088
+ catch (err) {
2089
+ res.status(500).json({ error: String(err).slice(0, 200) });
2090
+ }
2091
+ });
1981
2092
  app.get('/api/heartbeat/agent/:slug', (req, res) => {
1982
2093
  const slug = req.params.slug;
1983
2094
  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.86",
3
+ "version": "1.0.88",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",