clementine-agent 1.0.81 → 1.0.83
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/channels/discord.js +1 -1
- package/dist/gateway/agent-heartbeat-manager.d.ts +26 -3
- package/dist/gateway/agent-heartbeat-manager.js +191 -6
- package/dist/gateway/agent-heartbeat-scheduler.d.ts +7 -0
- package/dist/gateway/agent-heartbeat-scheduler.js +15 -0
- package/dist/gateway/router.d.ts +2 -2
- package/dist/gateway/router.js +30 -3
- package/dist/tools/agent-heartbeat-tools.d.ts +18 -0
- package/dist/tools/agent-heartbeat-tools.js +71 -0
- package/dist/tools/mcp-server.js +2 -0
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
package/dist/channels/discord.js
CHANGED
|
@@ -1122,7 +1122,7 @@ export async function startDiscord(gateway, heartbeat, cronScheduler, dispatcher
|
|
|
1122
1122
|
const streamer = new DiscordStreamingMessage(message.channel);
|
|
1123
1123
|
await streamer.start();
|
|
1124
1124
|
try {
|
|
1125
|
-
const response = await gateway.handleMessage(sessionKey, effectiveText, (t) => streamer.update(t), oneOffModel, oneOffMaxTurns, (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); return Promise.resolve(); });
|
|
1125
|
+
const response = await gateway.handleMessage(sessionKey, effectiveText, (t) => streamer.update(t), oneOffModel, oneOffMaxTurns, (toolName, toolInput) => { streamer.setToolStatus(friendlyToolName(toolName, toolInput)); return Promise.resolve(); }, (status) => { streamer.setToolStatus(status); return Promise.resolve(); });
|
|
1126
1126
|
await streamer.finalize(response);
|
|
1127
1127
|
updatePresence(sessionKey);
|
|
1128
1128
|
// Track bot message for feedback reactions
|
|
@@ -19,15 +19,38 @@ export declare class AgentHeartbeatManager {
|
|
|
19
19
|
private timer;
|
|
20
20
|
private running;
|
|
21
21
|
private ticking;
|
|
22
|
+
private readonly perAgentWatchers;
|
|
23
|
+
private goalTriggerWatcher;
|
|
24
|
+
private wakeDirWatcher;
|
|
25
|
+
private readonly pendingWakes;
|
|
22
26
|
constructor(agentManager: AgentManager, gateway?: AgentHeartbeatGateway);
|
|
23
27
|
start(): void;
|
|
24
28
|
stop(): void;
|
|
29
|
+
/**
|
|
30
|
+
* Set up fs.watch on the directories that signal real work for an agent:
|
|
31
|
+
*
|
|
32
|
+
* - per-agent tasks dir (delegated tasks land here)
|
|
33
|
+
* - goal-triggers dir (any goal trigger; we route to the owner)
|
|
34
|
+
* - wake-sentinels dir (explicit wake_agent calls)
|
|
35
|
+
*
|
|
36
|
+
* On a relevant change, schedule a debounced wake for the matching
|
|
37
|
+
* scheduler. Failures here are non-fatal — polling still works.
|
|
38
|
+
*/
|
|
39
|
+
private setupWatchers;
|
|
40
|
+
/** Watch a single agent's tasks directory. Idempotent. */
|
|
41
|
+
private watchAgentTasks;
|
|
42
|
+
private teardownWatchers;
|
|
43
|
+
/** Debounced wake — coalesce a burst of events into one markDue call. */
|
|
44
|
+
private scheduleWake;
|
|
45
|
+
/** Goal trigger landed — wake the owning agent. Non-Clementine owners only. */
|
|
46
|
+
private handleGoalTriggerEvent;
|
|
25
47
|
/** Add/remove schedulers to match the current AgentManager listing. */
|
|
26
48
|
private reconcile;
|
|
27
49
|
/**
|
|
28
|
-
* One outer-loop tick. Reconcile the registry, then fire agents
|
|
29
|
-
*
|
|
30
|
-
*
|
|
50
|
+
* One outer-loop tick. Reconcile the registry, then fire all due agents
|
|
51
|
+
* concurrently. Each agent's tick is isolated by profile + idempotency-keyed
|
|
52
|
+
* filesystem writes, so parallel execution is safe — and 3+ specialists no
|
|
53
|
+
* longer queue behind each other.
|
|
31
54
|
*/
|
|
32
55
|
private outerTick;
|
|
33
56
|
/** Diagnostic helper for the dashboard / CLI. */
|
|
@@ -10,10 +10,20 @@
|
|
|
10
10
|
* out. Per-agent failures are caught so one buggy agent can't crash the
|
|
11
11
|
* daemon or stall others.
|
|
12
12
|
*/
|
|
13
|
+
import { existsSync, mkdirSync, watch } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
13
15
|
import pino from 'pino';
|
|
16
|
+
import { AGENTS_DIR, BASE_DIR } from '../config.js';
|
|
17
|
+
import { listAllGoals } from '../tools/shared.js';
|
|
14
18
|
import { AgentHeartbeatScheduler } from './agent-heartbeat-scheduler.js';
|
|
15
19
|
const logger = pino({ name: 'clementine.agent-heartbeat-manager' });
|
|
16
20
|
const OUTER_TICK_MS = 60_000;
|
|
21
|
+
/**
|
|
22
|
+
* After a watched event fires, wait this long before actually waking the
|
|
23
|
+
* agent. Coalesces filesystem storms (a burst of file writes from one
|
|
24
|
+
* action shouldn't trigger N wake-ups).
|
|
25
|
+
*/
|
|
26
|
+
const WAKE_DEBOUNCE_MS = 3_000;
|
|
17
27
|
export class AgentHeartbeatManager {
|
|
18
28
|
agentManager;
|
|
19
29
|
gateway;
|
|
@@ -21,6 +31,12 @@ export class AgentHeartbeatManager {
|
|
|
21
31
|
timer = null;
|
|
22
32
|
running = false;
|
|
23
33
|
ticking = false;
|
|
34
|
+
// Per-directory fs.watch handles, indexed by slug for cleanup
|
|
35
|
+
perAgentWatchers = new Map();
|
|
36
|
+
goalTriggerWatcher = null;
|
|
37
|
+
wakeDirWatcher = null;
|
|
38
|
+
// Debounce wake-ups per slug so a burst of file events fires one tick.
|
|
39
|
+
pendingWakes = new Map();
|
|
24
40
|
constructor(agentManager, gateway) {
|
|
25
41
|
this.agentManager = agentManager;
|
|
26
42
|
this.gateway = gateway ?? null;
|
|
@@ -30,6 +46,7 @@ export class AgentHeartbeatManager {
|
|
|
30
46
|
return;
|
|
31
47
|
this.running = true;
|
|
32
48
|
this.reconcile();
|
|
49
|
+
this.setupWatchers();
|
|
33
50
|
// Run an immediate tick so schedulers boot up without a 60s delay.
|
|
34
51
|
this.outerTick().catch((err) => logger.error({ err }, 'Initial agent heartbeat tick failed'));
|
|
35
52
|
this.timer = setInterval(() => {
|
|
@@ -45,9 +62,155 @@ export class AgentHeartbeatManager {
|
|
|
45
62
|
clearInterval(this.timer);
|
|
46
63
|
this.timer = null;
|
|
47
64
|
}
|
|
65
|
+
this.teardownWatchers();
|
|
48
66
|
this.schedulers.clear();
|
|
49
67
|
logger.info('Agent heartbeat manager stopped');
|
|
50
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Set up fs.watch on the directories that signal real work for an agent:
|
|
71
|
+
*
|
|
72
|
+
* - per-agent tasks dir (delegated tasks land here)
|
|
73
|
+
* - goal-triggers dir (any goal trigger; we route to the owner)
|
|
74
|
+
* - wake-sentinels dir (explicit wake_agent calls)
|
|
75
|
+
*
|
|
76
|
+
* On a relevant change, schedule a debounced wake for the matching
|
|
77
|
+
* scheduler. Failures here are non-fatal — polling still works.
|
|
78
|
+
*/
|
|
79
|
+
setupWatchers() {
|
|
80
|
+
// Per-agent task dirs
|
|
81
|
+
for (const [slug] of this.schedulers) {
|
|
82
|
+
this.watchAgentTasks(slug);
|
|
83
|
+
}
|
|
84
|
+
// Goal-triggers (one trigger per goal; we resolve owner → slug at fire time)
|
|
85
|
+
const triggerDir = path.join(BASE_DIR, 'cron', 'goal-triggers');
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(triggerDir, { recursive: true });
|
|
88
|
+
this.goalTriggerWatcher = watch(triggerDir, (eventType, filename) => {
|
|
89
|
+
if (eventType !== 'rename' || !filename || !filename.endsWith('.trigger.json'))
|
|
90
|
+
return;
|
|
91
|
+
// Trigger filenames are idempotencyKey-based — we don't have a slug here,
|
|
92
|
+
// so wake any agent whose goal might match. Cheap enough: reconcile with
|
|
93
|
+
// listAllGoals once and wake the affected owners.
|
|
94
|
+
this.handleGoalTriggerEvent(filename);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
logger.warn({ err, triggerDir }, 'Failed to watch goal-triggers — falling back to polling');
|
|
99
|
+
}
|
|
100
|
+
// Wake sentinels (F3): wake_agent tool writes BASE_DIR/heartbeat/wake/<slug>.json
|
|
101
|
+
const wakeDir = path.join(BASE_DIR, 'heartbeat', 'wake');
|
|
102
|
+
try {
|
|
103
|
+
mkdirSync(wakeDir, { recursive: true });
|
|
104
|
+
this.wakeDirWatcher = watch(wakeDir, (eventType, filename) => {
|
|
105
|
+
if (eventType !== 'rename' || !filename)
|
|
106
|
+
return;
|
|
107
|
+
if (!filename.endsWith('.json'))
|
|
108
|
+
return;
|
|
109
|
+
const slug = filename.replace(/\.json$/, '');
|
|
110
|
+
// Consume the sentinel + wake the agent
|
|
111
|
+
try {
|
|
112
|
+
// Use unlinkSync via require to keep top-level import surface tight
|
|
113
|
+
// (already imported existsSync — we use it before unlink to be safe)
|
|
114
|
+
const sentinelPath = path.join(wakeDir, filename);
|
|
115
|
+
if (existsSync(sentinelPath)) {
|
|
116
|
+
// best-effort: import unlinkSync inline
|
|
117
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
118
|
+
const fs = require('node:fs');
|
|
119
|
+
try {
|
|
120
|
+
fs.unlinkSync(sentinelPath);
|
|
121
|
+
}
|
|
122
|
+
catch { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch { /* non-fatal */ }
|
|
126
|
+
this.scheduleWake(slug, 'wake-sentinel');
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
logger.warn({ err, wakeDir }, 'Failed to watch wake-sentinels — wake_agent tool will be slower');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Watch a single agent's tasks directory. Idempotent. */
|
|
134
|
+
watchAgentTasks(slug) {
|
|
135
|
+
if (this.perAgentWatchers.has(slug))
|
|
136
|
+
return;
|
|
137
|
+
const tasksDir = path.join(AGENTS_DIR, slug, 'tasks');
|
|
138
|
+
try {
|
|
139
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
140
|
+
const watcher = watch(tasksDir, (eventType, filename) => {
|
|
141
|
+
if (!filename || !filename.endsWith('.json'))
|
|
142
|
+
return;
|
|
143
|
+
// Both 'rename' (create/delete) and 'change' can indicate new work
|
|
144
|
+
this.scheduleWake(slug, `task-${eventType}:${filename}`);
|
|
145
|
+
});
|
|
146
|
+
this.perAgentWatchers.set(slug, watcher);
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
logger.debug({ err, slug, tasksDir }, 'Could not watch agent tasks dir — will rely on polling');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
teardownWatchers() {
|
|
153
|
+
for (const [, w] of this.perAgentWatchers) {
|
|
154
|
+
try {
|
|
155
|
+
w.close();
|
|
156
|
+
}
|
|
157
|
+
catch { /* ignore */ }
|
|
158
|
+
}
|
|
159
|
+
this.perAgentWatchers.clear();
|
|
160
|
+
try {
|
|
161
|
+
this.goalTriggerWatcher?.close();
|
|
162
|
+
}
|
|
163
|
+
catch { /* ignore */ }
|
|
164
|
+
this.goalTriggerWatcher = null;
|
|
165
|
+
try {
|
|
166
|
+
this.wakeDirWatcher?.close();
|
|
167
|
+
}
|
|
168
|
+
catch { /* ignore */ }
|
|
169
|
+
this.wakeDirWatcher = null;
|
|
170
|
+
for (const [, t] of this.pendingWakes)
|
|
171
|
+
clearTimeout(t);
|
|
172
|
+
this.pendingWakes.clear();
|
|
173
|
+
}
|
|
174
|
+
/** Debounced wake — coalesce a burst of events into one markDue call. */
|
|
175
|
+
scheduleWake(slug, reason) {
|
|
176
|
+
const existing = this.pendingWakes.get(slug);
|
|
177
|
+
if (existing)
|
|
178
|
+
clearTimeout(existing);
|
|
179
|
+
const t = setTimeout(() => {
|
|
180
|
+
this.pendingWakes.delete(slug);
|
|
181
|
+
const scheduler = this.schedulers.get(slug);
|
|
182
|
+
if (!scheduler)
|
|
183
|
+
return;
|
|
184
|
+
scheduler.markDue();
|
|
185
|
+
logger.info({ slug, reason }, 'Agent heartbeat: woken by event');
|
|
186
|
+
// Don't await — let the next outerTick (within ≤60s, or instant when
|
|
187
|
+
// we trigger one ourselves) actually run the tick.
|
|
188
|
+
this.outerTick().catch((err) => logger.warn({ err, slug }, 'Triggered tick after wake failed'));
|
|
189
|
+
}, WAKE_DEBOUNCE_MS);
|
|
190
|
+
this.pendingWakes.set(slug, t);
|
|
191
|
+
}
|
|
192
|
+
/** Goal trigger landed — wake the owning agent. Non-Clementine owners only. */
|
|
193
|
+
handleGoalTriggerEvent(filename) {
|
|
194
|
+
try {
|
|
195
|
+
// We don't yet know which goal id the trigger references without reading
|
|
196
|
+
// the file (idempotencyKey-named). Read it, find the owner, wake them.
|
|
197
|
+
const triggerPath = path.join(BASE_DIR, 'cron', 'goal-triggers', filename);
|
|
198
|
+
if (!existsSync(triggerPath))
|
|
199
|
+
return; // file was already consumed by cron-scheduler
|
|
200
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
201
|
+
const fs = require('node:fs');
|
|
202
|
+
const trigger = JSON.parse(fs.readFileSync(triggerPath, 'utf-8'));
|
|
203
|
+
if (!trigger.goalId)
|
|
204
|
+
return;
|
|
205
|
+
const lookup = listAllGoals().find((g) => g.goal && g.goal.id === trigger.goalId);
|
|
206
|
+
if (!lookup || !lookup.owner || lookup.owner === 'clementine')
|
|
207
|
+
return;
|
|
208
|
+
this.scheduleWake(lookup.owner, `goal-trigger:${trigger.goalId}`);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
logger.debug({ err, filename }, 'Failed to handle goal-trigger event — non-fatal');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
51
214
|
/** Add/remove schedulers to match the current AgentManager listing. */
|
|
52
215
|
reconcile() {
|
|
53
216
|
let active = [];
|
|
@@ -66,6 +229,9 @@ export class AgentHeartbeatManager {
|
|
|
66
229
|
for (const slug of active) {
|
|
67
230
|
if (!this.schedulers.has(slug)) {
|
|
68
231
|
this.schedulers.set(slug, new AgentHeartbeatScheduler(slug, this.agentManager, this.gateway ? { gateway: this.gateway } : {}));
|
|
232
|
+
// Start watching this agent's tasks dir if we're already running
|
|
233
|
+
if (this.running)
|
|
234
|
+
this.watchAgentTasks(slug);
|
|
69
235
|
logger.info({ slug }, 'Agent heartbeat: registered scheduler');
|
|
70
236
|
}
|
|
71
237
|
}
|
|
@@ -73,14 +239,28 @@ export class AgentHeartbeatManager {
|
|
|
73
239
|
for (const slug of [...this.schedulers.keys()]) {
|
|
74
240
|
if (!activeSet.has(slug)) {
|
|
75
241
|
this.schedulers.delete(slug);
|
|
242
|
+
const watcher = this.perAgentWatchers.get(slug);
|
|
243
|
+
if (watcher) {
|
|
244
|
+
try {
|
|
245
|
+
watcher.close();
|
|
246
|
+
}
|
|
247
|
+
catch { /* ignore */ }
|
|
248
|
+
this.perAgentWatchers.delete(slug);
|
|
249
|
+
}
|
|
250
|
+
const pending = this.pendingWakes.get(slug);
|
|
251
|
+
if (pending) {
|
|
252
|
+
clearTimeout(pending);
|
|
253
|
+
this.pendingWakes.delete(slug);
|
|
254
|
+
}
|
|
76
255
|
logger.info({ slug }, 'Agent heartbeat: deregistered scheduler');
|
|
77
256
|
}
|
|
78
257
|
}
|
|
79
258
|
}
|
|
80
259
|
/**
|
|
81
|
-
* One outer-loop tick. Reconcile the registry, then fire agents
|
|
82
|
-
*
|
|
83
|
-
*
|
|
260
|
+
* One outer-loop tick. Reconcile the registry, then fire all due agents
|
|
261
|
+
* concurrently. Each agent's tick is isolated by profile + idempotency-keyed
|
|
262
|
+
* filesystem writes, so parallel execution is safe — and 3+ specialists no
|
|
263
|
+
* longer queue behind each other.
|
|
84
264
|
*/
|
|
85
265
|
async outerTick(now = new Date()) {
|
|
86
266
|
if (this.ticking)
|
|
@@ -88,16 +268,21 @@ export class AgentHeartbeatManager {
|
|
|
88
268
|
this.ticking = true;
|
|
89
269
|
try {
|
|
90
270
|
this.reconcile();
|
|
271
|
+
const due = [];
|
|
91
272
|
for (const [slug, scheduler] of this.schedulers) {
|
|
273
|
+
if (scheduler.isDue(now))
|
|
274
|
+
due.push({ slug, scheduler });
|
|
275
|
+
}
|
|
276
|
+
if (due.length === 0)
|
|
277
|
+
return;
|
|
278
|
+
await Promise.all(due.map(async ({ slug, scheduler }) => {
|
|
92
279
|
try {
|
|
93
|
-
if (!scheduler.isDue(now))
|
|
94
|
-
continue;
|
|
95
280
|
await scheduler.tick(now);
|
|
96
281
|
}
|
|
97
282
|
catch (err) {
|
|
98
283
|
logger.warn({ err, slug }, 'Agent heartbeat tick failed — continuing');
|
|
99
284
|
}
|
|
100
|
-
}
|
|
285
|
+
}));
|
|
101
286
|
}
|
|
102
287
|
finally {
|
|
103
288
|
this.ticking = false;
|
|
@@ -73,6 +73,13 @@ export declare class AgentHeartbeatScheduler {
|
|
|
73
73
|
};
|
|
74
74
|
/** Schedule the next check explicitly. Clamped to [MIN, MAX] minutes. */
|
|
75
75
|
setNextCheckIn(minutes: number, now?: Date): void;
|
|
76
|
+
/**
|
|
77
|
+
* Mark the agent as due for a tick right now. Used for event-driven wake-ups
|
|
78
|
+
* (a delegated task arrives, an explicit wake_agent call) — bypasses the
|
|
79
|
+
* MIN_INTERVAL_MIN clamp that setNextCheckIn applies, since this is "wake
|
|
80
|
+
* now" not "set future cadence."
|
|
81
|
+
*/
|
|
82
|
+
markDue(now?: Date): void;
|
|
76
83
|
getSlug(): string;
|
|
77
84
|
}
|
|
78
85
|
//# sourceMappingURL=agent-heartbeat-scheduler.d.ts.map
|
|
@@ -286,6 +286,21 @@ export class AgentHeartbeatScheduler {
|
|
|
286
286
|
};
|
|
287
287
|
this.saveState(state);
|
|
288
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Mark the agent as due for a tick right now. Used for event-driven wake-ups
|
|
291
|
+
* (a delegated task arrives, an explicit wake_agent call) — bypasses the
|
|
292
|
+
* MIN_INTERVAL_MIN clamp that setNextCheckIn applies, since this is "wake
|
|
293
|
+
* now" not "set future cadence."
|
|
294
|
+
*/
|
|
295
|
+
markDue(now = new Date()) {
|
|
296
|
+
const prior = this.loadState();
|
|
297
|
+
const state = {
|
|
298
|
+
...prior,
|
|
299
|
+
slug: this.slug,
|
|
300
|
+
nextCheckAt: now.toISOString(),
|
|
301
|
+
};
|
|
302
|
+
this.saveState(state);
|
|
303
|
+
}
|
|
289
304
|
getSlug() {
|
|
290
305
|
return this.slug;
|
|
291
306
|
}
|
package/dist/gateway/router.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Manages per-user/channel sessions for conversation continuity.
|
|
6
6
|
*/
|
|
7
7
|
import { PersonalAssistant, type ProjectMeta } from '../agent/assistant.js';
|
|
8
|
-
import type { OnTextCallback, OnToolActivityCallback, PlanProgressUpdate, PlanStep, SelfImproveConfig, SelfImproveExperiment, SessionProvenance, TeamMessage, VerboseLevel, WorkflowDefinition } from '../types.js';
|
|
8
|
+
import type { OnProgressCallback, OnTextCallback, OnToolActivityCallback, PlanProgressUpdate, PlanStep, SelfImproveConfig, SelfImproveExperiment, SessionProvenance, TeamMessage, VerboseLevel, WorkflowDefinition } from '../types.js';
|
|
9
9
|
import { AgentManager } from '../agent/agent-manager.js';
|
|
10
10
|
import { TeamRouter } from '../agent/team-router.js';
|
|
11
11
|
import { TeamBus } from '../agent/team-bus.js';
|
|
@@ -149,7 +149,7 @@ export declare class Gateway {
|
|
|
149
149
|
* or correct the agent mid-response instead of queuing behind a long query.
|
|
150
150
|
*/
|
|
151
151
|
private acquireSessionLock;
|
|
152
|
-
handleMessage(sessionKey: string, text: string, onText?: OnTextCallback, model?: string, maxTurns?: number, onToolActivity?: OnToolActivityCallback): Promise<string>;
|
|
152
|
+
handleMessage(sessionKey: string, text: string, onText?: OnTextCallback, model?: string, maxTurns?: number, onToolActivity?: OnToolActivityCallback, onProgress?: OnProgressCallback): Promise<string>;
|
|
153
153
|
private _handleMessageInner;
|
|
154
154
|
handleHeartbeat(standingInstructions: string, changesSummary?: string, timeContext?: string, dedupContext?: string, profile?: import('../types.js').AgentProfile | null): Promise<string>;
|
|
155
155
|
handleCronJob(jobName: string, jobPrompt: string, tier?: number, maxTurns?: number, model?: string, workDir?: string, mode?: 'standard' | 'unleashed', maxHours?: number, timeoutMs?: number, successCriteria?: string[], agentSlug?: string): Promise<string>;
|
package/dist/gateway/router.js
CHANGED
|
@@ -691,7 +691,7 @@ export class Gateway {
|
|
|
691
691
|
};
|
|
692
692
|
}
|
|
693
693
|
// ── Message handling ────────────────────────────────────────────────
|
|
694
|
-
async handleMessage(sessionKey, text, onText, model, maxTurns, onToolActivity) {
|
|
694
|
+
async handleMessage(sessionKey, text, onText, model, maxTurns, onToolActivity, onProgress) {
|
|
695
695
|
if (this.draining) {
|
|
696
696
|
return "I'm restarting momentarily — your message will be processed after I'm back online.";
|
|
697
697
|
}
|
|
@@ -713,7 +713,7 @@ export class Gateway {
|
|
|
713
713
|
text_len: text.length,
|
|
714
714
|
});
|
|
715
715
|
try {
|
|
716
|
-
const result = await this._handleMessageInner(sessionKey, text, onText, model, maxTurns, onToolActivity);
|
|
716
|
+
const result = await this._handleMessageInner(sessionKey, text, onText, model, maxTurns, onToolActivity, onProgress);
|
|
717
717
|
logAuditJsonl({
|
|
718
718
|
event_type: 'message_completed',
|
|
719
719
|
duration_ms: Date.now() - traceStart,
|
|
@@ -731,7 +731,7 @@ export class Gateway {
|
|
|
731
731
|
}
|
|
732
732
|
});
|
|
733
733
|
}
|
|
734
|
-
async _handleMessageInner(sessionKey, text, onText, model, maxTurns, onToolActivity) {
|
|
734
|
+
async _handleMessageInner(sessionKey, text, onText, model, maxTurns, onToolActivity, onProgress) {
|
|
735
735
|
// ── Auth circuit breaker — stop spamming error messages ────────
|
|
736
736
|
if (this.authCircuitOpen) {
|
|
737
737
|
if (!this.shouldProbeAuth()) {
|
|
@@ -743,10 +743,31 @@ export class Gateway {
|
|
|
743
743
|
// Allow this one message through as a probe to see if auth recovered
|
|
744
744
|
logger.info({ sessionKey }, 'Auth circuit open — allowing probe message');
|
|
745
745
|
}
|
|
746
|
+
// Show "queued" status if either lane or session lock is contended,
|
|
747
|
+
// so the user doesn't stare at "thinking..." for up to 60s while a
|
|
748
|
+
// previous message is still processing.
|
|
749
|
+
const laneWaitStart = Date.now();
|
|
750
|
+
let queuedStatusShown = false;
|
|
751
|
+
const queuedTimer = onProgress
|
|
752
|
+
? setTimeout(() => {
|
|
753
|
+
queuedStatusShown = true;
|
|
754
|
+
onProgress('waiting for previous message to finish...').catch(() => { });
|
|
755
|
+
}, 750)
|
|
756
|
+
: null;
|
|
746
757
|
const releaseLane = await lanes.acquire('chat');
|
|
758
|
+
if (queuedTimer)
|
|
759
|
+
clearTimeout(queuedTimer);
|
|
747
760
|
try {
|
|
748
761
|
const release = await this.acquireSessionLock(sessionKey);
|
|
749
762
|
try {
|
|
763
|
+
if (queuedStatusShown && onProgress) {
|
|
764
|
+
// Lane was busy — clear the wait notice now that we're moving
|
|
765
|
+
await onProgress('thinking...').catch(() => { });
|
|
766
|
+
}
|
|
767
|
+
const laneWaitMs = Date.now() - laneWaitStart;
|
|
768
|
+
if (laneWaitMs > 1000) {
|
|
769
|
+
logger.info({ sessionKey, laneWaitMs }, 'Chat lane wait was non-trivial');
|
|
770
|
+
}
|
|
750
771
|
logger.info(`Message from ${sessionKey}: ${text.slice(0, 100)}...`);
|
|
751
772
|
events.emit('message:received', { sessionKey, text, timestamp: Date.now() });
|
|
752
773
|
// ── Register provenance on first interaction ────────────────
|
|
@@ -849,6 +870,9 @@ export class Gateway {
|
|
|
849
870
|
|| text.startsWith('[Approval:')
|
|
850
871
|
|| text.startsWith('[Reaction:')
|
|
851
872
|
|| text.startsWith('[System:');
|
|
873
|
+
if (!isInternalMsg && !sess?.profile && !text.startsWith('!') && !isStructuredWorkflowMsg && onProgress) {
|
|
874
|
+
await onProgress('checking if a teammate should handle this...').catch(() => { });
|
|
875
|
+
}
|
|
852
876
|
const routingResult = !isInternalMsg && !sess?.profile && !text.startsWith('!') && !isStructuredWorkflowMsg
|
|
853
877
|
? await this._maybeRouteToSpecialist(sessionKey, text, onText)
|
|
854
878
|
: null;
|
|
@@ -1058,6 +1082,9 @@ export class Gateway {
|
|
|
1058
1082
|
// Primary guardrail is cost budget (maxBudgetUsd in buildOptions).
|
|
1059
1083
|
// Wall clock (CHAT_MAX_WALL_MS) and StallGuard are safety nets.
|
|
1060
1084
|
events.emit('query:start', { sessionKey, model: effectiveModel, maxTurns: maxTurns, timestamp: Date.now() });
|
|
1085
|
+
if (onProgress) {
|
|
1086
|
+
await onProgress('thinking...').catch(() => { });
|
|
1087
|
+
}
|
|
1061
1088
|
const queryStartMs = Date.now();
|
|
1062
1089
|
const [response] = await Promise.race([
|
|
1063
1090
|
this.assistant.chat(chatPrompt, effectiveSessionKey, { onText: wrappedOnText, onToolActivity: wrappedOnToolActivity, model: effectiveModel, maxTurns: maxTurns, securityAnnotation, projectOverride, profile: resolvedProfile, verboseLevel, abortController: chatAc }),
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Agent Heartbeat MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* `wake_agent` lets an agent (typically Clementine, but anyone permitted
|
|
5
|
+
* via `canMessage`) wake another agent's heartbeat right now instead of
|
|
6
|
+
* waiting for their poll cycle. Useful when you've just delegated work
|
|
7
|
+
* and want them to react in seconds, not minutes.
|
|
8
|
+
*
|
|
9
|
+
* Implementation: writes a sentinel file at
|
|
10
|
+
* ~/.clementine/heartbeat/wake/<slug>.json
|
|
11
|
+
* which AgentHeartbeatManager watches via fs.watch (set up in start()).
|
|
12
|
+
* On detection it consumes the sentinel and calls scheduler.markDue(),
|
|
13
|
+
* which makes the agent due on the next outerTick (≤60s, usually within
|
|
14
|
+
* the WAKE_DEBOUNCE_MS window).
|
|
15
|
+
*/
|
|
16
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
|
+
export declare function registerAgentHeartbeatTools(server: McpServer): void;
|
|
18
|
+
//# sourceMappingURL=agent-heartbeat-tools.d.ts.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clementine TypeScript — Agent Heartbeat MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* `wake_agent` lets an agent (typically Clementine, but anyone permitted
|
|
5
|
+
* via `canMessage`) wake another agent's heartbeat right now instead of
|
|
6
|
+
* waiting for their poll cycle. Useful when you've just delegated work
|
|
7
|
+
* and want them to react in seconds, not minutes.
|
|
8
|
+
*
|
|
9
|
+
* Implementation: writes a sentinel file at
|
|
10
|
+
* ~/.clementine/heartbeat/wake/<slug>.json
|
|
11
|
+
* which AgentHeartbeatManager watches via fs.watch (set up in start()).
|
|
12
|
+
* On detection it consumes the sentinel and calls scheduler.markDue(),
|
|
13
|
+
* which makes the agent due on the next outerTick (≤60s, usually within
|
|
14
|
+
* the WAKE_DEBOUNCE_MS window).
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
import { ACTIVE_AGENT_SLUG, AGENTS_DIR, BASE_DIR, logger, textResult } from './shared.js';
|
|
20
|
+
const WAKE_DIR = path.join(BASE_DIR, 'heartbeat', 'wake');
|
|
21
|
+
function isKnownAgent(slug) {
|
|
22
|
+
// A known agent is one with vault/00-System/agents/<slug>/agent.md present.
|
|
23
|
+
return existsSync(path.join(AGENTS_DIR, slug, 'agent.md'));
|
|
24
|
+
}
|
|
25
|
+
function listKnownAgentSlugs() {
|
|
26
|
+
if (!existsSync(AGENTS_DIR))
|
|
27
|
+
return [];
|
|
28
|
+
try {
|
|
29
|
+
return readdirSync(AGENTS_DIR, { withFileTypes: true })
|
|
30
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('_'))
|
|
31
|
+
.filter((d) => existsSync(path.join(AGENTS_DIR, d.name, 'agent.md')))
|
|
32
|
+
.map((d) => d.name);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function registerAgentHeartbeatTools(server) {
|
|
39
|
+
server.tool('wake_agent', 'Wake an agent\'s heartbeat right now instead of waiting for their next poll cycle. Use after delegating urgent work or when an external signal needs immediate attention. The target agent will tick within ~3 seconds (debounced) and decide what to do.', {
|
|
40
|
+
slug: z.string().describe('Slug of the agent to wake (e.g., "ross-the-sdr")'),
|
|
41
|
+
reason: z.string().optional().describe('One-line reason for the wake — appears in the agent\'s next tick context'),
|
|
42
|
+
}, async ({ slug, reason }) => {
|
|
43
|
+
const callerSlug = ACTIVE_AGENT_SLUG || 'clementine';
|
|
44
|
+
if (!slug || typeof slug !== 'string') {
|
|
45
|
+
return textResult('wake_agent: slug is required.');
|
|
46
|
+
}
|
|
47
|
+
if (slug === callerSlug) {
|
|
48
|
+
return textResult(`wake_agent: cannot wake yourself (${slug}). You're already awake.`);
|
|
49
|
+
}
|
|
50
|
+
if (!isKnownAgent(slug)) {
|
|
51
|
+
const known = listKnownAgentSlugs();
|
|
52
|
+
return textResult(`wake_agent: unknown agent "${slug}". Known agents: ${known.length > 0 ? known.join(', ') : '(none)'}.`);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(WAKE_DIR, { recursive: true });
|
|
56
|
+
const sentinel = {
|
|
57
|
+
targetSlug: slug,
|
|
58
|
+
fromSlug: callerSlug,
|
|
59
|
+
reason: reason ?? '',
|
|
60
|
+
requestedAt: new Date().toISOString(),
|
|
61
|
+
};
|
|
62
|
+
writeFileSync(path.join(WAKE_DIR, `${slug}.json`), JSON.stringify(sentinel, null, 2));
|
|
63
|
+
logger.info({ from: callerSlug, to: slug, reason: reason ?? '' }, 'wake_agent: sentinel written');
|
|
64
|
+
return textResult(`Woke ${slug}. They'll tick within ~3 seconds.${reason ? ` (Reason: ${reason})` : ''}`);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return textResult(`wake_agent: failed to write sentinel — ${String(err).slice(0, 200)}`);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=agent-heartbeat-tools.js.map
|
package/dist/tools/mcp-server.js
CHANGED
|
@@ -26,6 +26,7 @@ import { registerTeamTools } from './team-tools.js';
|
|
|
26
26
|
import { registerSessionTools } from './session-tools.js';
|
|
27
27
|
import { registerArtifactTools } from './artifact-tools.js';
|
|
28
28
|
import { registerBrainTools } from './brain-tools.js';
|
|
29
|
+
import { registerAgentHeartbeatTools } from './agent-heartbeat-tools.js';
|
|
29
30
|
// ── Server ──────────────────────────────────────────────────────────────
|
|
30
31
|
const serverName = (env['ASSISTANT_NAME'] ?? 'Clementine').toLowerCase() + '-tools';
|
|
31
32
|
const server = new McpServer({ name: serverName, version: '1.0.0' });
|
|
@@ -39,6 +40,7 @@ registerTeamTools(server);
|
|
|
39
40
|
registerSessionTools(server);
|
|
40
41
|
registerArtifactTools(server);
|
|
41
42
|
registerBrainTools(server);
|
|
43
|
+
registerAgentHeartbeatTools(server);
|
|
42
44
|
// ── Main ────────────────────────────────────────────────────────────────
|
|
43
45
|
async function main() {
|
|
44
46
|
// Initialize memory store and run full sync on startup
|
package/dist/types.d.ts
CHANGED
|
@@ -103,6 +103,13 @@ export interface ChannelCapabilities {
|
|
|
103
103
|
export declare const DEFAULT_CHANNEL_CAPABILITIES: ChannelCapabilities;
|
|
104
104
|
export type OnTextCallback = (text: string) => Promise<void>;
|
|
105
105
|
export type OnToolActivityCallback = (toolName: string, toolInput: Record<string, unknown>) => Promise<void>;
|
|
106
|
+
/**
|
|
107
|
+
* Pre-query progress callback. Fired at stage transitions BEFORE the SDK
|
|
108
|
+
* query starts (routing, complexity classification, lock waits, etc.) so
|
|
109
|
+
* the user sees the indicator change instead of staring at "thinking..."
|
|
110
|
+
* for several seconds.
|
|
111
|
+
*/
|
|
112
|
+
export type OnProgressCallback = (status: string) => Promise<void>;
|
|
106
113
|
export interface NotificationContext {
|
|
107
114
|
agentSlug?: string;
|
|
108
115
|
/** When set, the dispatcher routes the message back to the channel that owns this session. */
|