@tpsdev-ai/flair-mcp 0.20.1 → 0.21.0

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/index.d.ts CHANGED
@@ -14,6 +14,13 @@
14
14
  * - flair_workspace_set — write own WorkspaceState (Office Space coordination)
15
15
  * - flair_orgevent — publish an OrgEvent attributed to self (no forging)
16
16
  *
17
+ * Auto-presence (flair#598): every tool call above triggers a fire-and-forget,
18
+ * rate-limited `POST /Presence` heartbeat for the calling agent (see
19
+ * ./presence.ts + the `heartbeat()` closure in runMcp()) — presence is now a
20
+ * side effect of normal activity instead of the manual `flair presence set`
21
+ * CLI command nobody ran. `bootstrap` additionally seeds the activity/task
22
+ * from its own arguments (session start signal).
23
+ *
17
24
  * Usage:
18
25
  * npx -y @tpsdev-ai/flair-mcp
19
26
  *
package/dist/index.js CHANGED
@@ -14,6 +14,13 @@
14
14
  * - flair_workspace_set — write own WorkspaceState (Office Space coordination)
15
15
  * - flair_orgevent — publish an OrgEvent attributed to self (no forging)
16
16
  *
17
+ * Auto-presence (flair#598): every tool call above triggers a fire-and-forget,
18
+ * rate-limited `POST /Presence` heartbeat for the calling agent (see
19
+ * ./presence.ts + the `heartbeat()` closure in runMcp()) — presence is now a
20
+ * side effect of normal activity instead of the manual `flair presence set`
21
+ * CLI command nobody ran. `bootstrap` additionally seeds the activity/task
22
+ * from its own arguments (session start signal).
23
+ *
17
24
  * Usage:
18
25
  * npx -y @tpsdev-ai/flair-mcp
19
26
  *
@@ -34,6 +41,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
34
41
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
35
42
  import { FlairClient, FlairError } from "@tpsdev-ai/flair-client";
36
43
  import { z } from "zod";
44
+ import { deriveActivity, postPresenceSafe, resolveHeartbeatIntervalMs, resolvePresenceTimeoutMs, shouldSendHeartbeat, } from "./presence.js";
37
45
  // ─── Error helpers ──────────────────────────────────────────────────────────
38
46
  function classifyError(err, flairUrl) {
39
47
  if (err instanceof FlairError) {
@@ -138,6 +146,56 @@ export async function runMcp() {
138
146
  url: process.env.FLAIR_URL,
139
147
  keyPath: process.env.FLAIR_KEY_PATH,
140
148
  });
149
+ // ─── Auto-presence (flair#598) ────────────────────────────────────────────────
150
+ //
151
+ // Presence used to be manual-only (`flair presence set`, which in practice
152
+ // nobody runs) — so the roster was permanently stale and the collision-
153
+ // detection it exists for never worked. This makes presence a SIDE EFFECT of
154
+ // normal agent activity instead: `bootstrap` (session start) always attempts
155
+ // a heartbeat, and every other tool call attempts one too, both routed
156
+ // through the SAME rate-limited, fire-and-forget `heartbeat()` below so
157
+ // repeated bootstrap calls in a short window don't spam any more than
158
+ // repeated tool calls would.
159
+ //
160
+ // A SEPARATE FlairClient instance, not the main `flair` above — its own
161
+ // short `timeoutMs` (resolvePresenceTimeoutMs(), default 3s vs the main
162
+ // client's general-purpose 30s default) means a dead/slow daemon can never
163
+ // make a presence write outlive the tool call it rode in on, independent of
164
+ // whatever timeout the main client is configured with.
165
+ const presenceFlair = new FlairClient({
166
+ agentId,
167
+ url: process.env.FLAIR_URL,
168
+ keyPath: process.env.FLAIR_KEY_PATH,
169
+ timeoutMs: resolvePresenceTimeoutMs(),
170
+ });
171
+ // Rate-limit clock + last-known task, in-process only (no persistence —
172
+ // resets on restart, which is fine; the next tool call re-establishes it).
173
+ // Owned here, not in presence.ts, so the pure rate-limit/body-building logic
174
+ // stays testable without any shared mutable state.
175
+ let lastPresenceSentAt = null;
176
+ // currentTask is the caller's responsibility to preserve (see
177
+ // postPresenceSafe's doc comment): Presence.post() treats an ABSENT
178
+ // currentTask as an explicit clear, so a heartbeat that doesn't know about
179
+ // an in-flight task must keep resending the last one bootstrap() told us
180
+ // about, or it would silently erase it every ~3 minutes.
181
+ let lastKnownTask;
182
+ /**
183
+ * Fire-and-forget, rate-limited presence heartbeat. Safe to call from every
184
+ * tool handler unconditionally — the rate limit + postPresenceSafe's
185
+ * internal catch-everything mean this NEVER throws, NEVER awaits network
186
+ * I/O in the caller's path, and NEVER delays the tool response. The
187
+ * `.catch(() => {})` below is belt-and-suspenders (postPresenceSafe already
188
+ * never rejects) purely so a future change to that function can't
189
+ * accidentally reintroduce an unhandled rejection here.
190
+ */
191
+ function heartbeat(activity = "coding") {
192
+ const now = Date.now();
193
+ if (!shouldSendHeartbeat(now, lastPresenceSentAt, resolveHeartbeatIntervalMs()))
194
+ return;
195
+ lastPresenceSentAt = now; // set synchronously, before the async write, so a burst of
196
+ // tool calls in the same tick can't all slip past the rate limit together
197
+ postPresenceSafe(presenceFlair, activity, lastKnownTask, resolvePresenceTimeoutMs()).catch(() => { });
198
+ }
141
199
  // ─── MCP Server ──────────────────────────────────────────────────────────────
142
200
  const server = new McpServer({
143
201
  name: "flair",
@@ -148,6 +206,7 @@ export async function runMcp() {
148
206
  query: z.string().describe("Search query — natural language, semantic matching"),
149
207
  limit: z.coerce.number().optional().default(5).describe("Max results (default 5)"),
150
208
  }, async ({ query, limit }) => {
209
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
151
210
  try {
152
211
  const results = await flair.memory.search(query, { limit });
153
212
  if (results.length === 0) {
@@ -181,6 +240,7 @@ export async function runMcp() {
181
240
  "private -- never visible to another agent, even one with a memory grant. " +
182
241
  "shared -- visible to the owner and any agent holding a read/search grant."),
183
242
  }, async ({ content, type, durability, tags, visibility }) => {
243
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
184
244
  try {
185
245
  const result = await flair.memory.write(content, {
186
246
  type: type,
@@ -233,6 +293,7 @@ export async function runMcp() {
233
293
  preserveHistory: z.coerce.boolean().optional().default(false)
234
294
  .describe("Write a new supersedes-linked version instead of overwriting in place (default false)"),
235
295
  }, async ({ id, content, preserveHistory }) => {
296
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
236
297
  try {
237
298
  const result = await flair.memory.update(id, content, { preserveHistory });
238
299
  const text = preserveHistory
@@ -250,6 +311,7 @@ export async function runMcp() {
250
311
  server.tool("memory_get", "Retrieve a specific memory by ID.", {
251
312
  id: z.string().describe("Memory ID"),
252
313
  }, async ({ id }) => {
314
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
253
315
  try {
254
316
  const mem = await flair.memory.get(id);
255
317
  if (!mem)
@@ -263,6 +325,7 @@ export async function runMcp() {
263
325
  server.tool("memory_delete", "Delete a memory by ID.", {
264
326
  id: z.string().describe("Memory ID to delete"),
265
327
  }, async ({ id }) => {
328
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
266
329
  try {
267
330
  await flair.memory.delete(id);
268
331
  return { content: [{ type: "text", text: `Memory ${id} deleted.` }] };
@@ -278,6 +341,16 @@ export async function runMcp() {
278
341
  surface: z.string().optional().describe("Surface name (tps-build, tps-review, cli-session) — narrows prediction"),
279
342
  subjects: z.array(z.string()).optional().describe("Entity names to preload context for (e.g., ['flair', 'auth'])"),
280
343
  }, async ({ maxTokens, currentTask, channel, surface, subjects }) => {
344
+ // auto-presence (flair#598) — SESSION START. bootstrap() already receives
345
+ // exactly the payload Presence wants: currentTask is what the agent is
346
+ // about to work on, channel/surface are what deriveActivity() uses to
347
+ // pick something more specific than the "coding" default. Routed through
348
+ // the SAME rate-limited heartbeat() as every other tool, so calling
349
+ // bootstrap twice in quick succession (session resume/compact) doesn't
350
+ // double-send — it's still "sets presence once" per session in practice.
351
+ if (currentTask)
352
+ lastKnownTask = currentTask;
353
+ heartbeat(deriveActivity({ channel, surface }));
281
354
  try {
282
355
  const result = await flair.bootstrap({ maxTokens, currentTask, channel, surface, subjects });
283
356
  if (!result.context) {
@@ -293,6 +366,7 @@ export async function runMcp() {
293
366
  key: z.string().describe("Entry key (e.g., 'role', 'standards', 'project')"),
294
367
  value: z.string().describe("Entry value — personality trait, project context, coding standards, etc."),
295
368
  }, async ({ key, value }) => {
369
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
296
370
  try {
297
371
  await flair.soul.set(key, value);
298
372
  return { content: [{ type: "text", text: `Soul entry '${key}' set.` }] };
@@ -304,6 +378,7 @@ export async function runMcp() {
304
378
  server.tool("soul_get", "Get a personality or project context entry.", {
305
379
  key: z.string().describe("Entry key"),
306
380
  }, async ({ key }) => {
381
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
307
382
  try {
308
383
  const entry = await flair.soul.get(key);
309
384
  if (!entry)
@@ -331,6 +406,7 @@ export async function runMcp() {
331
406
  phase: z.string().optional().describe("Current phase (e.g. design, implement, review)"),
332
407
  summary: z.string().optional().describe("Short summary of current workspace state"),
333
408
  }, async ({ ref, label, provider, task, phase, summary }) => {
409
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
334
410
  try {
335
411
  // No agentId in body — the server attributes from the signed identity.
336
412
  const body = {
@@ -361,6 +437,7 @@ export async function runMcp() {
361
437
  scope: z.string().optional().describe("Scope of the event (e.g. an agent id, repo, or 'org')"),
362
438
  targets: z.array(z.string()).optional().describe("Recipient agent ids"),
363
439
  }, async ({ kind, summary, detail, scope, targets }) => {
440
+ heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
364
441
  try {
365
442
  // No authorId in body — the server attributes from the signed identity.
366
443
  const body = { kind, summary };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Auto-presence — shared, pure/testable logic behind flair#598.
3
+ *
4
+ * Presence (`POST /Presence`, resources/Presence.ts) only ever updated via the
5
+ * manual `flair presence set` CLI command, which in practice nobody runs — so
6
+ * the roster is permanently stale and the active/idle/offline derivation it
7
+ * powers (Office Space collision detection) never means anything. This module
8
+ * makes presence a SIDE EFFECT of normal agent activity instead: every flair-mcp
9
+ * tool call (and the flair-session-start hook) can call `postPresenceSafe()`
10
+ * to refresh the calling agent's own `lastHeartbeatAt`, rate-limited so it
11
+ * doesn't turn into a write storm.
12
+ *
13
+ * Everything here is a pure function or a single fire-and-forget async
14
+ * wrapper — no module-level mutable state lives in this file. The rate-limit
15
+ * clock (`lastSentAt`) and the "last known task" text are owned by each
16
+ * CALLER (flair-mcp/src/index.ts's runMcp() closure, session-start-hook.ts's
17
+ * per-invocation scope) — this file only decides "is it time yet" and "what
18
+ * does the POST body look like", so both call sites share ONE implementation
19
+ * of those decisions without sharing any actual state.
20
+ */
21
+ /** Mirrors VALID_ACTIVITIES in resources/Presence.ts — keep in sync. */
22
+ export type PresenceActivity = "coding" | "reviewing" | "planning" | "idle";
23
+ /**
24
+ * Derive a presence `activity` from the SAME context signals the `bootstrap`
25
+ * tool and the SessionStart hook already receive (channel/surface) — no new
26
+ * inputs, no separate classifier call. Two narrow, name-based overrides for
27
+ * the surfaces that are unambiguous on their own; everything else (including
28
+ * no surface at all, e.g. the session-start hook) falls through to "coding",
29
+ * since an MCP tool call is, definitionally, an agent doing something — never
30
+ * "idle" (idle/offline are purely functions of elapsed time since the last
31
+ * heartbeat; see derivePresenceStatus() in resources/Presence.ts).
32
+ */
33
+ export declare function deriveActivity(ctx?: {
34
+ surface?: string;
35
+ channel?: string;
36
+ }): PresenceActivity;
37
+ /**
38
+ * Pure "is it time to send another heartbeat" check. The caller owns the
39
+ * clock (`lastSentAt`) — this function has no memory of its own, which is
40
+ * what makes it trivially unit-testable (no fake timers, no module reset
41
+ * between tests).
42
+ */
43
+ export declare function shouldSendHeartbeat(now: number, lastSentAt: number | null, minIntervalMs: number): boolean;
44
+ /**
45
+ * Minimum gap between auto-presence POSTs triggered by MCP tool activity.
46
+ *
47
+ * 3 minutes: the issue's proposal asks for "once per few minutes" (2-5min).
48
+ * 3min sits in the middle of that range —
49
+ * - frequent enough that an actively-used agent survives at least one
50
+ * missed beat before crossing the 10-minute OFFLINE threshold
51
+ * (offlineThresholdMs() in resources/Presence.ts, default 600_000ms) —
52
+ * a dropped/delayed write doesn't flip the roster to offline;
53
+ * - infrequent enough that a hot loop (e.g. memory_search called several
54
+ * times a minute while an agent works through a task) doesn't turn every
55
+ * tool call into a presence write — this is the rate limiter's whole job.
56
+ * Not tuned to the 90s ACTIVE threshold (idleThresholdMs(), default 90_000ms)
57
+ * — matching that would mean heartbeating roughly every other tool call,
58
+ * defeating the point of rate limiting. An agent that's genuinely active but
59
+ * calls flair-mcp tools less than once every ~3min will read as "idle" (not
60
+ * "offline") between heartbeats, which is the correct signal: idle already
61
+ * means "present but no recent activity", exactly this case.
62
+ */
63
+ export declare const DEFAULT_HEARTBEAT_INTERVAL_MS: number;
64
+ /** Resolve the heartbeat rate-limit interval from env, clamped to a sane range
65
+ * (same validate-don't-trust-`??` pattern as FLAIR_MCP_PARENT_POLL_MS /
66
+ * FLAIR_HOOK_TIMEOUT_MS elsewhere in this package — an empty-string override
67
+ * must not silently become `0` and defeat the rate limit). */
68
+ export declare function resolveHeartbeatIntervalMs(): number;
69
+ /**
70
+ * Per-write timeout for auto-presence POSTs. Deliberately much shorter than
71
+ * FlairClient's general-purpose default (30s, client.ts DEFAULT_TIMEOUT) —
72
+ * auto-presence is a side effect, never the reason a tool call or a session
73
+ * hook waits. A dead/slow Flair daemon must not hold anything open.
74
+ */
75
+ export declare const DEFAULT_PRESENCE_TIMEOUT_MS = 3000;
76
+ export declare function resolvePresenceTimeoutMs(): number;
77
+ /**
78
+ * Build the POST /Presence body. Exported so tests can pin the exact shape.
79
+ * Never includes agentId — identity is attributed server-side from the
80
+ * Ed25519 signature on the request (Presence.post(), agent-auth.ts), exactly
81
+ * like flair_workspace_set / flair_orgevent in index.ts. currentTask is
82
+ * omitted (not sent as null/empty) when absent, so callers that don't know a
83
+ * task can still heartbeat without explicitly clearing one that was set
84
+ * earlier — see postPresenceSafe()'s doc comment for why that matters.
85
+ */
86
+ export declare function buildPresenceBody(activity: PresenceActivity, currentTask?: string): Record<string, unknown>;
87
+ /** Minimal surface a presence write needs from a Flair client — matches
88
+ * FlairClient.request()'s shape exactly, so the real client satisfies this
89
+ * with zero adapter code, and tests can inject a stub without a live server
90
+ * or a real FlairClient instance. */
91
+ export interface PresencePoster {
92
+ request<T = unknown>(method: string, path: string, body?: unknown): Promise<T>;
93
+ }
94
+ /**
95
+ * POST /Presence and NEVER throw or reject — every failure mode (timeout,
96
+ * network error, 401/403/500 from the server, a malformed response) is
97
+ * caught and swallowed here, in this one place, so every call site gets the
98
+ * guarantee for free instead of re-implementing it. Not awaiting the
99
+ * returned promise is what makes a call site "fire and forget"; the promise
100
+ * itself resolving-never-rejecting is what makes doing that safe (no
101
+ * unhandled rejection even if a caller forgets the `.catch()` belt-and-
102
+ * suspenders).
103
+ *
104
+ * currentTask is intentionally the caller's responsibility, not derived here:
105
+ * Presence.post() (resources/Presence.ts) treats an ABSENT currentTask as an
106
+ * explicit clear — `sanitizeCurrentTask(undefined)` returns `null`, which then
107
+ * overwrites any previously-set task on every merge. That's correct for the
108
+ * CLI (`flair presence set --activity X` with no `--task` IS an explicit "no
109
+ * task" statement) but wrong for an automatic heartbeat, which should never
110
+ * silently erase a task a human or bootstrap() set minutes earlier. Callers
111
+ * that want tasks preserved across heartbeats must track and re-pass the last
112
+ * known task themselves (see index.ts's `lastKnownTask` / session-start-hook's
113
+ * per-invocation scope) — this function just forwards whatever it's given.
114
+ */
115
+ export declare function postPresenceSafe(poster: PresencePoster, activity: PresenceActivity, currentTask: string | undefined, timeoutMs: number): Promise<void>;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Auto-presence — shared, pure/testable logic behind flair#598.
3
+ *
4
+ * Presence (`POST /Presence`, resources/Presence.ts) only ever updated via the
5
+ * manual `flair presence set` CLI command, which in practice nobody runs — so
6
+ * the roster is permanently stale and the active/idle/offline derivation it
7
+ * powers (Office Space collision detection) never means anything. This module
8
+ * makes presence a SIDE EFFECT of normal agent activity instead: every flair-mcp
9
+ * tool call (and the flair-session-start hook) can call `postPresenceSafe()`
10
+ * to refresh the calling agent's own `lastHeartbeatAt`, rate-limited so it
11
+ * doesn't turn into a write storm.
12
+ *
13
+ * Everything here is a pure function or a single fire-and-forget async
14
+ * wrapper — no module-level mutable state lives in this file. The rate-limit
15
+ * clock (`lastSentAt`) and the "last known task" text are owned by each
16
+ * CALLER (flair-mcp/src/index.ts's runMcp() closure, session-start-hook.ts's
17
+ * per-invocation scope) — this file only decides "is it time yet" and "what
18
+ * does the POST body look like", so both call sites share ONE implementation
19
+ * of those decisions without sharing any actual state.
20
+ */
21
+ /**
22
+ * Derive a presence `activity` from the SAME context signals the `bootstrap`
23
+ * tool and the SessionStart hook already receive (channel/surface) — no new
24
+ * inputs, no separate classifier call. Two narrow, name-based overrides for
25
+ * the surfaces that are unambiguous on their own; everything else (including
26
+ * no surface at all, e.g. the session-start hook) falls through to "coding",
27
+ * since an MCP tool call is, definitionally, an agent doing something — never
28
+ * "idle" (idle/offline are purely functions of elapsed time since the last
29
+ * heartbeat; see derivePresenceStatus() in resources/Presence.ts).
30
+ */
31
+ export function deriveActivity(ctx = {}) {
32
+ const surface = (ctx.surface ?? "").toLowerCase();
33
+ if (surface.includes("review"))
34
+ return "reviewing";
35
+ if (surface.includes("plan") || surface.includes("spec") || surface.includes("design"))
36
+ return "planning";
37
+ return "coding";
38
+ }
39
+ // ─── Rate limiting ──────────────────────────────────────────────────────────
40
+ /**
41
+ * Pure "is it time to send another heartbeat" check. The caller owns the
42
+ * clock (`lastSentAt`) — this function has no memory of its own, which is
43
+ * what makes it trivially unit-testable (no fake timers, no module reset
44
+ * between tests).
45
+ */
46
+ export function shouldSendHeartbeat(now, lastSentAt, minIntervalMs) {
47
+ if (lastSentAt == null)
48
+ return true;
49
+ return now - lastSentAt >= minIntervalMs;
50
+ }
51
+ /**
52
+ * Minimum gap between auto-presence POSTs triggered by MCP tool activity.
53
+ *
54
+ * 3 minutes: the issue's proposal asks for "once per few minutes" (2-5min).
55
+ * 3min sits in the middle of that range —
56
+ * - frequent enough that an actively-used agent survives at least one
57
+ * missed beat before crossing the 10-minute OFFLINE threshold
58
+ * (offlineThresholdMs() in resources/Presence.ts, default 600_000ms) —
59
+ * a dropped/delayed write doesn't flip the roster to offline;
60
+ * - infrequent enough that a hot loop (e.g. memory_search called several
61
+ * times a minute while an agent works through a task) doesn't turn every
62
+ * tool call into a presence write — this is the rate limiter's whole job.
63
+ * Not tuned to the 90s ACTIVE threshold (idleThresholdMs(), default 90_000ms)
64
+ * — matching that would mean heartbeating roughly every other tool call,
65
+ * defeating the point of rate limiting. An agent that's genuinely active but
66
+ * calls flair-mcp tools less than once every ~3min will read as "idle" (not
67
+ * "offline") between heartbeats, which is the correct signal: idle already
68
+ * means "present but no recent activity", exactly this case.
69
+ */
70
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 3 * 60 * 1000;
71
+ const HEARTBEAT_INTERVAL_FLOOR_MS = 30_000;
72
+ const HEARTBEAT_INTERVAL_CEILING_MS = 15 * 60 * 1000;
73
+ /** Resolve the heartbeat rate-limit interval from env, clamped to a sane range
74
+ * (same validate-don't-trust-`??` pattern as FLAIR_MCP_PARENT_POLL_MS /
75
+ * FLAIR_HOOK_TIMEOUT_MS elsewhere in this package — an empty-string override
76
+ * must not silently become `0` and defeat the rate limit). */
77
+ export function resolveHeartbeatIntervalMs() {
78
+ const raw = process.env.FLAIR_PRESENCE_HEARTBEAT_MS;
79
+ const parsed = raw != null ? Number(raw) : NaN;
80
+ return Number.isFinite(parsed) && parsed >= HEARTBEAT_INTERVAL_FLOOR_MS && parsed <= HEARTBEAT_INTERVAL_CEILING_MS
81
+ ? parsed
82
+ : DEFAULT_HEARTBEAT_INTERVAL_MS;
83
+ }
84
+ // ─── Timeout ────────────────────────────────────────────────────────────────
85
+ /**
86
+ * Per-write timeout for auto-presence POSTs. Deliberately much shorter than
87
+ * FlairClient's general-purpose default (30s, client.ts DEFAULT_TIMEOUT) —
88
+ * auto-presence is a side effect, never the reason a tool call or a session
89
+ * hook waits. A dead/slow Flair daemon must not hold anything open.
90
+ */
91
+ export const DEFAULT_PRESENCE_TIMEOUT_MS = 3_000;
92
+ const PRESENCE_TIMEOUT_FLOOR_MS = 500;
93
+ const PRESENCE_TIMEOUT_CEILING_MS = 10_000;
94
+ export function resolvePresenceTimeoutMs() {
95
+ const raw = process.env.FLAIR_PRESENCE_TIMEOUT_MS;
96
+ const parsed = raw != null ? Number(raw) : NaN;
97
+ return Number.isFinite(parsed) && parsed >= PRESENCE_TIMEOUT_FLOOR_MS && parsed <= PRESENCE_TIMEOUT_CEILING_MS
98
+ ? parsed
99
+ : DEFAULT_PRESENCE_TIMEOUT_MS;
100
+ }
101
+ /** Race a promise against a timeout. Rejects with a timeout error if exceeded.
102
+ * (Same idiom as session-start-hook.ts's withTimeout — duplicated rather than
103
+ * imported to keep this module dependency-free of that one; it's four lines.) */
104
+ function withTimeout(promise, ms) {
105
+ return new Promise((resolve, reject) => {
106
+ const timer = setTimeout(() => reject(new Error("presence_post_timeout")), ms);
107
+ timer.unref?.();
108
+ promise.then((value) => {
109
+ clearTimeout(timer);
110
+ resolve(value);
111
+ }, (err) => {
112
+ clearTimeout(timer);
113
+ reject(err);
114
+ });
115
+ });
116
+ }
117
+ // ─── POST body ──────────────────────────────────────────────────────────────
118
+ /**
119
+ * Build the POST /Presence body. Exported so tests can pin the exact shape.
120
+ * Never includes agentId — identity is attributed server-side from the
121
+ * Ed25519 signature on the request (Presence.post(), agent-auth.ts), exactly
122
+ * like flair_workspace_set / flair_orgevent in index.ts. currentTask is
123
+ * omitted (not sent as null/empty) when absent, so callers that don't know a
124
+ * task can still heartbeat without explicitly clearing one that was set
125
+ * earlier — see postPresenceSafe()'s doc comment for why that matters.
126
+ */
127
+ export function buildPresenceBody(activity, currentTask) {
128
+ const body = { activity };
129
+ if (currentTask)
130
+ body.currentTask = currentTask;
131
+ return body;
132
+ }
133
+ /**
134
+ * POST /Presence and NEVER throw or reject — every failure mode (timeout,
135
+ * network error, 401/403/500 from the server, a malformed response) is
136
+ * caught and swallowed here, in this one place, so every call site gets the
137
+ * guarantee for free instead of re-implementing it. Not awaiting the
138
+ * returned promise is what makes a call site "fire and forget"; the promise
139
+ * itself resolving-never-rejecting is what makes doing that safe (no
140
+ * unhandled rejection even if a caller forgets the `.catch()` belt-and-
141
+ * suspenders).
142
+ *
143
+ * currentTask is intentionally the caller's responsibility, not derived here:
144
+ * Presence.post() (resources/Presence.ts) treats an ABSENT currentTask as an
145
+ * explicit clear — `sanitizeCurrentTask(undefined)` returns `null`, which then
146
+ * overwrites any previously-set task on every merge. That's correct for the
147
+ * CLI (`flair presence set --activity X` with no `--task` IS an explicit "no
148
+ * task" statement) but wrong for an automatic heartbeat, which should never
149
+ * silently erase a task a human or bootstrap() set minutes earlier. Callers
150
+ * that want tasks preserved across heartbeats must track and re-pass the last
151
+ * known task themselves (see index.ts's `lastKnownTask` / session-start-hook's
152
+ * per-invocation scope) — this function just forwards whatever it's given.
153
+ */
154
+ export async function postPresenceSafe(poster, activity, currentTask, timeoutMs) {
155
+ try {
156
+ await withTimeout(poster.request("POST", "/Presence", buildPresenceBody(activity, currentTask)), timeoutMs);
157
+ }
158
+ catch {
159
+ // Silent by design — see doc comment above. A flair instance that's down
160
+ // or slow must never slow down or break the caller.
161
+ }
162
+ }
@@ -26,12 +26,23 @@
26
26
  * A hard timeout (FLAIR_HOOK_TIMEOUT_MS, default 8s) wraps the bootstrap call
27
27
  * so a stalled Flair daemon can't hang session startup; on timeout we no-op.
28
28
  *
29
+ * AUTO-PRESENCE (flair#598)
30
+ * -------------------------
31
+ * A session starting is the clearest "this agent is alive" signal flair-mcp
32
+ * gets, so this hook also fires a best-effort `POST /Presence` heartbeat
33
+ * alongside bootstrap — see ./presence.ts. It runs CONCURRENTLY with the
34
+ * bootstrap call (not serially after it), reuses the SAME signed request
35
+ * path (Ed25519, no new auth mechanism), and is bounded by its own short
36
+ * timeout so it can never add meaningful latency or turn a working bootstrap
37
+ * into a no-op. Manual `flair presence set` keeps working unchanged.
38
+ *
29
39
  * CONFIG (env, read identically to the MCP server)
30
40
  * ------------------------------------------------
31
41
  * FLAIR_AGENT_ID (required — absent → no-op) agent identity
32
42
  * FLAIR_URL (default http://localhost:19926 via flair-client)
33
43
  * FLAIR_KEY_PATH (default ~/.flair/keys/<agent>.key via flair-client)
34
44
  * FLAIR_HOOK_TIMEOUT_MS (default 8000; clamped 500..30000)
45
+ * FLAIR_PRESENCE_TIMEOUT_MS (default 3000; clamped 500..10000 — see ./presence.ts)
35
46
  *
36
47
  * USAGE — register in ~/.claude/settings.json:
37
48
  * {
@@ -43,8 +54,14 @@
43
54
  * }
44
55
  * }
45
56
  */
46
- /** Minimal surface of FlairClient this hook depends on (eases testing). */
47
- interface BootstrapClient {
57
+ import { type PresencePoster } from "./presence.js";
58
+ /** Minimal surface of FlairClient this hook depends on (eases testing).
59
+ * `request` is optional and structurally matches PresencePoster (presence.ts)
60
+ * — the real FlairClient always has it. When present, this hook also fires a
61
+ * best-effort presence heartbeat (flair#598) alongside bootstrap; when
62
+ * absent (e.g. a lightweight test stub that only implements bootstrap()),
63
+ * the heartbeat is silently skipped — no behavior change for those tests. */
64
+ interface BootstrapClient extends Partial<PresencePoster> {
48
65
  bootstrap(opts: {
49
66
  maxTokens?: number;
50
67
  channel?: string;
@@ -26,12 +26,23 @@
26
26
  * A hard timeout (FLAIR_HOOK_TIMEOUT_MS, default 8s) wraps the bootstrap call
27
27
  * so a stalled Flair daemon can't hang session startup; on timeout we no-op.
28
28
  *
29
+ * AUTO-PRESENCE (flair#598)
30
+ * -------------------------
31
+ * A session starting is the clearest "this agent is alive" signal flair-mcp
32
+ * gets, so this hook also fires a best-effort `POST /Presence` heartbeat
33
+ * alongside bootstrap — see ./presence.ts. It runs CONCURRENTLY with the
34
+ * bootstrap call (not serially after it), reuses the SAME signed request
35
+ * path (Ed25519, no new auth mechanism), and is bounded by its own short
36
+ * timeout so it can never add meaningful latency or turn a working bootstrap
37
+ * into a no-op. Manual `flair presence set` keeps working unchanged.
38
+ *
29
39
  * CONFIG (env, read identically to the MCP server)
30
40
  * ------------------------------------------------
31
41
  * FLAIR_AGENT_ID (required — absent → no-op) agent identity
32
42
  * FLAIR_URL (default http://localhost:19926 via flair-client)
33
43
  * FLAIR_KEY_PATH (default ~/.flair/keys/<agent>.key via flair-client)
34
44
  * FLAIR_HOOK_TIMEOUT_MS (default 8000; clamped 500..30000)
45
+ * FLAIR_PRESENCE_TIMEOUT_MS (default 3000; clamped 500..10000 — see ./presence.ts)
35
46
  *
36
47
  * USAGE — register in ~/.claude/settings.json:
37
48
  * {
@@ -45,6 +56,7 @@
45
56
  */
46
57
  import { FlairClient } from "@tpsdev-ai/flair-client";
47
58
  import { basename } from "node:path";
59
+ import { deriveActivity, postPresenceSafe, resolvePresenceTimeoutMs } from "./presence.js";
48
60
  /** Claude Code SessionStart additionalContext hard limit (chars). */
49
61
  const MAX_CHARS = 10_000;
50
62
  /** Token budget for the bootstrap call — matches the proven prototype. */
@@ -120,9 +132,23 @@ export async function runHook(rawInput, makeClient = defaultClientFactory) {
120
132
  }
121
133
  const cwd = typeof input.cwd === "string" && input.cwd ? input.cwd : process.cwd();
122
134
  const project = basename(cwd) || undefined;
135
+ const client = makeClient(agentId);
136
+ // Auto-presence (flair#598): a session starting is the clearest "this
137
+ // agent is alive" signal available. Fired CONCURRENTLY with the bootstrap
138
+ // call below (not serially after it) and bounded by its own short timeout
139
+ // (resolvePresenceTimeoutMs(), default 3s) — postPresenceSafe() never
140
+ // throws (see presence.ts's fail-open contract), so this can never turn a
141
+ // working bootstrap into a no-op, and awaiting `presenceDone` below can
142
+ // never add meaningful latency beyond what bootstrap already budgets
143
+ // (resolveTimeoutMs(), default 8s). No currentTask here: the SessionStart
144
+ // payload doesn't carry a task description (unlike the MCP `bootstrap`
145
+ // tool call — see index.ts), so this only ever sets `activity`, leaving
146
+ // currentTask exactly whatever it already was.
147
+ const presenceDone = typeof client.request === "function"
148
+ ? postPresenceSafe(client, deriveActivity({ channel: "claude-code" }), undefined, resolvePresenceTimeoutMs())
149
+ : Promise.resolve();
123
150
  let context = "";
124
151
  try {
125
- const client = makeClient(agentId);
126
152
  const res = await withTimeout(Promise.resolve(client.bootstrap({
127
153
  maxTokens: BOOTSTRAP_MAX_TOKENS,
128
154
  channel: "claude-code",
@@ -131,8 +157,10 @@ export async function runHook(rawInput, makeClient = defaultClientFactory) {
131
157
  context = res && res.context ? String(res.context) : "";
132
158
  }
133
159
  catch {
160
+ await presenceDone;
134
161
  return NOOP_OUTPUT; // flair unreachable / auth error / timeout → no-op
135
162
  }
163
+ await presenceDone;
136
164
  if (!context.trim())
137
165
  return NOOP_OUTPUT;
138
166
  if (context.length > MAX_CHARS)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair-mcp",
3
- "version": "0.20.1",
3
+ "version": "0.21.0",
4
4
  "description": "MCP server for Flair — persistent memory for Claude Code, Cursor, and any MCP client.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@modelcontextprotocol/sdk": "1.27.1",
30
- "@tpsdev-ai/flair-client": "0.20.1",
30
+ "@tpsdev-ai/flair-client": "0.21.0",
31
31
  "zod": "4.3.6"
32
32
  },
33
33
  "license": "Apache-2.0",