clementine-agent 1.0.80 → 1.0.82

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/README.md CHANGED
@@ -556,7 +556,7 @@ Both paths trigger an automatic daemon restart when the new agent is detected.
556
556
 
557
557
  ### Agent configuration
558
558
 
559
- Each agent is defined by a YAML frontmatter file in `~/.clementine/agents/<slug>/agent.md`:
559
+ Each agent is defined by a YAML frontmatter file in `~/.clementine/vault/00-System/agents/<slug>/agent.md`:
560
560
 
561
561
  | Field | Description |
562
562
  |-------|-------------|
@@ -585,6 +585,26 @@ The dashboard's "The Office" page shows each agent as an animated desk station w
585
585
  - Channel assignment, model badge, project badge, tool count
586
586
  - Edit and "Let Go" (delete) actions
587
587
 
588
+ ### Per-agent heartbeats
589
+
590
+ Each specialist (Ross / Sasha / your hires) gets their own autonomous heartbeat scheduler alongside Clementine's. The cycle:
591
+
592
+ 1. **Cheap tick** every 30 min: load the agent's state, hash three signals (pending delegated tasks, latest goal update, latest cron run). If unchanged → silent tick, no LLM call, no cost.
593
+ 2. **LLM tick** when a signal *changes* between ticks (a delegated task arrived, a goal moved, a cron deliverable to review): the scheduler invokes `assistant.heartbeat()` with the agent's profile. Output flows through their dedicated Discord bot to their channel.
594
+ 3. **Self-adjusting cadence**: agents end their LLM-tick output with `[NEXT_CHECK: Xm]` to set when to check in next (5–720 min). Clamped at the bounds. Default 30m if omitted.
595
+
596
+ State per agent at `~/.clementine/heartbeat/agents/<slug>/state.json`. Live observability via:
597
+
598
+ ```bash
599
+ curl -H "X-Token: $(cat ~/.clementine/.dashboard-token)" \
600
+ http://localhost:3030/api/agent-heartbeats | jq
601
+ ```
602
+
603
+ Routing rules — Clementine remains the master delegator:
604
+ - Inbox triage runs as Clementine, but she'll hand off via `team_message` when an item clearly belongs to a specialist (she's allowed to guess).
605
+ - Daily-plan goal-priorities owned by a specialist now fire goal-triggers (which run as the owner) instead of queueing as Clementine's work.
606
+ - Goal advancement triggers route to `goal.owner` automatically.
607
+
588
608
  ---
589
609
 
590
610
  ## Scheduled tasks & cron jobs
@@ -1049,6 +1049,55 @@ function getHeartbeat() {
1049
1049
  return {};
1050
1050
  }
1051
1051
  }
1052
+ /**
1053
+ * Read per-agent heartbeat states from disk (one state.json per agent
1054
+ * under ~/.clementine/heartbeat/agents/<slug>/). Returns an array sorted
1055
+ * by next-due-soonest, with relative-time strings convenient for UI use.
1056
+ */
1057
+ function getAgentHeartbeats() {
1058
+ const agentsRoot = path.join(BASE_DIR, 'heartbeat', 'agents');
1059
+ if (!existsSync(agentsRoot))
1060
+ return [];
1061
+ const out = [];
1062
+ let dirs = [];
1063
+ try {
1064
+ dirs = readdirSync(agentsRoot, { withFileTypes: true })
1065
+ .filter((d) => d.isDirectory())
1066
+ .map((d) => d.name);
1067
+ }
1068
+ catch {
1069
+ return [];
1070
+ }
1071
+ const now = Date.now();
1072
+ for (const slug of dirs) {
1073
+ const stateFile = path.join(agentsRoot, slug, 'state.json');
1074
+ if (!existsSync(stateFile))
1075
+ continue;
1076
+ try {
1077
+ const state = JSON.parse(readFileSync(stateFile, 'utf-8'));
1078
+ const lastTickMs = state.lastTickAt ? new Date(String(state.lastTickAt)).getTime() : 0;
1079
+ const nextCheckMs = state.nextCheckAt ? new Date(String(state.nextCheckAt)).getTime() : 0;
1080
+ out.push({
1081
+ slug,
1082
+ lastTickAt: state.lastTickAt ?? null,
1083
+ nextCheckAt: state.nextCheckAt ?? null,
1084
+ silentTickCount: Number(state.silentTickCount ?? 0),
1085
+ fingerprint: state.fingerprint ?? '',
1086
+ lastSignalSummary: state.lastSignalSummary ?? null,
1087
+ lastTickAgoMs: lastTickMs ? now - lastTickMs : null,
1088
+ nextCheckInMs: nextCheckMs ? nextCheckMs - now : null,
1089
+ isDue: nextCheckMs > 0 && nextCheckMs <= now,
1090
+ });
1091
+ }
1092
+ catch { /* skip malformed */ }
1093
+ }
1094
+ out.sort((a, b) => {
1095
+ const ai = typeof a.nextCheckInMs === 'number' ? a.nextCheckInMs : Number.MAX_SAFE_INTEGER;
1096
+ const bi = typeof b.nextCheckInMs === 'number' ? b.nextCheckInMs : Number.MAX_SAFE_INTEGER;
1097
+ return ai - bi;
1098
+ });
1099
+ return out;
1100
+ }
1052
1101
  async function getMemory() {
1053
1102
  const memoryFile = path.join(VAULT_DIR, '00-System', 'MEMORY.md');
1054
1103
  let content = '';
@@ -1925,6 +1974,9 @@ export async function cmdDashboard(opts) {
1925
1974
  app.get('/api/heartbeat', (_req, res) => {
1926
1975
  res.json(getHeartbeat());
1927
1976
  });
1977
+ app.get('/api/agent-heartbeats', (_req, res) => {
1978
+ res.json(getAgentHeartbeats());
1979
+ });
1928
1980
  app.get('/api/heartbeat/agent/:slug', (req, res) => {
1929
1981
  const slug = req.params.slug;
1930
1982
  const state = getHeartbeat();
@@ -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 whose
29
- * nextCheckAt has come due. Runs serially to avoid races on shared
30
- * state (goals dir, cron runs dir).
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 whose
82
- * nextCheckAt has come due. Runs serially to avoid races on shared
83
- * state (goals dir, cron runs dir).
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
  }
@@ -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
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.80",
3
+ "version": "1.0.82",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",