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.
- package/dist/agent/background-tasks.d.ts +56 -0
- package/dist/agent/background-tasks.js +159 -0
- package/dist/agent/webhook-actions.d.ts +116 -0
- package/dist/agent/webhook-actions.js +240 -0
- package/dist/cli/dashboard.js +111 -0
- package/dist/gateway/cron-scheduler.d.ts +11 -0
- package/dist/gateway/cron-scheduler.js +66 -0
- package/dist/index.js +13 -0
- package/dist/tools/background-task-tools.d.ts +16 -0
- package/dist/tools/background-task-tools.js +105 -0
- package/dist/tools/mcp-server.js +2 -0
- package/dist/types.d.ts +24 -0
- package/package.json +1 -1
|
@@ -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
|
package/dist/cli/dashboard.js
CHANGED
|
@@ -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
|
package/dist/tools/mcp-server.js
CHANGED
|
@@ -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
|