@tpsdev-ai/flair 0.45.0 → 0.47.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/config.yaml +35 -2
- package/dist/build-info.json +6 -0
- package/dist/cli.js +847 -168
- package/dist/doctor-client.js +358 -11
- package/dist/federation/scheduler.js +114 -9
- package/dist/hook-install.js +150 -1
- package/dist/install/global-bin-path.js +234 -0
- package/dist/lib/entity-vocab-cli.js +113 -0
- package/dist/lib/mcp-enable.js +71 -21
- package/dist/lib/scheduler-platform.js +363 -1
- package/dist/postinstall.cjs +88 -0
- package/dist/rem/runner.js +177 -10
- package/dist/rem/scheduler.js +126 -20
- package/dist/resources/AttentionQuery.js +5 -3
- package/dist/resources/AutoPromoteCandidates.js +18 -12
- package/dist/resources/Federation.js +49 -5
- package/dist/resources/Memory.js +36 -2
- package/dist/resources/MemoryBootstrap.js +118 -7
- package/dist/resources/MemoryMaintenance.js +8 -2
- package/dist/resources/MemoryReflect.js +70 -5
- package/dist/resources/auto-promote-lib.js +46 -0
- package/dist/resources/build-info.js +50 -0
- package/dist/resources/entity-vocab.js +25 -1
- package/dist/resources/health.js +25 -5
- package/dist/resources/mcp-oauth-flag.js +20 -0
- package/dist/resources/mcp-oauth.js +6 -1
- package/dist/resources/mcp-tools.js +53 -3
- package/dist/resources/memory-reflect-lib.js +201 -4
- package/dist/src/lib/scheduler-platform.js +363 -1
- package/dist/src/rem/scheduler.js +126 -20
- package/docs/deepseek-harness.md +110 -0
- package/docs/entity-vocabulary.md +15 -0
- package/docs/integrations.md +1 -0
- package/docs/mcp-clients.md +4 -0
- package/docs/notes/mcp-oauth-model2.md +52 -3
- package/package.json +5 -4
- package/schemas/memory.graphql +12 -0
- package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
package/dist/doctor-client.js
CHANGED
|
@@ -154,6 +154,272 @@ export function parseLegacySessionStartHookCommand(command) {
|
|
|
154
154
|
export function isFlairHookCommand(command) {
|
|
155
155
|
return typeof command === "string" && command.includes("@tpsdev-ai/flair-mcp") && command.includes(SESSION_START_HOOK_MARKER);
|
|
156
156
|
}
|
|
157
|
+
// ── the continuity capture hooks (flair#1257 slice 2) ──────────────────────
|
|
158
|
+
//
|
|
159
|
+
// Continuity's write side is a pair of Claude Code hook entries — PostToolUse
|
|
160
|
+
// (mutating tools only, via the matcher below) and Stop — both running the
|
|
161
|
+
// SAME `flair-continuity-capture` binary shipped by @tpsdev-ai/flair-mcp.
|
|
162
|
+
// Same ONE-builder discipline as the SessionStart command above (#1007):
|
|
163
|
+
// every path that writes these entries (`flair doctor --fix`, `flair hook
|
|
164
|
+
// install --continuity` in src/hook-install.ts) goes through
|
|
165
|
+
// buildContinuityCaptureHookCommand, so the invocation's failure behaviour is
|
|
166
|
+
// defined and tested in one place.
|
|
167
|
+
//
|
|
168
|
+
// Unlike the SessionStart command, this one captures NOTHING to re-emit: a
|
|
169
|
+
// PostToolUse/Stop hook's stdout is harness-interpreted surface and the
|
|
170
|
+
// capture binary never has anything to say to it, so the wrapper discards
|
|
171
|
+
// BOTH streams and absorbs failure (`>/dev/null 2>/dev/null || true`). The
|
|
172
|
+
// same #1007 reasoning applies: if the npx resolution breaks, the silence has
|
|
173
|
+
// to be a property of the command string, because the binary's own fail-open
|
|
174
|
+
// guarantee is behind the door that stopped opening. hookCommandIsSilenced()
|
|
175
|
+
// recognizes this shape unchanged.
|
|
176
|
+
//
|
|
177
|
+
// INSTALLING THESE HOOKS IS THE OPT-IN. There is no env flag: an agent whose
|
|
178
|
+
// settings.json carries the pair journals; one that doesn't, doesn't. Doctor
|
|
179
|
+
// therefore reports "absent" as "not enabled" — informational, never a pass,
|
|
180
|
+
// never a failure (see checkContinuityCaptureHooks / cli.ts's rendering).
|
|
181
|
+
/** The exact substring identifying a Flair continuity-capture hook command. */
|
|
182
|
+
export const CONTINUITY_CAPTURE_HOOK_MARKER = "flair-continuity-capture";
|
|
183
|
+
/**
|
|
184
|
+
* The PostToolUse matcher written alongside our hook entry — the EXACT
|
|
185
|
+
* mutating-tool allowlist the capture binary enforces internally
|
|
186
|
+
* (packages/flair-mcp/src/continuity.ts's MUTATING_TOOLS: Write/Edit/
|
|
187
|
+
* NotebookEdit mutate file/cell state, Bash can mutate anything; read-only
|
|
188
|
+
* tools are world-recoverable and journal nothing). The matcher is an
|
|
189
|
+
* EFFICIENCY (no process spawn for a Read), not the control — the binary's
|
|
190
|
+
* own allowlist is the control and fires regardless of who spawns it.
|
|
191
|
+
*/
|
|
192
|
+
export const CONTINUITY_POST_TOOL_USE_MATCHER = "Write|Edit|NotebookEdit|Bash";
|
|
193
|
+
/**
|
|
194
|
+
* Build the exact `command` string registered for BOTH continuity hook events
|
|
195
|
+
* (PostToolUse and Stop run the same binary; the payload's hook_event_name
|
|
196
|
+
* tells it which fired). Same strict value allow-list as the SessionStart
|
|
197
|
+
* builder — throws rather than emitting a quoted approximation.
|
|
198
|
+
*/
|
|
199
|
+
export function buildContinuityCaptureHookCommand(agentId, flairUrl) {
|
|
200
|
+
if (!isHookCommandValueSafe(agentId)) {
|
|
201
|
+
throw new Error(`agent id '${agentId}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
|
|
202
|
+
}
|
|
203
|
+
if (flairUrl != null && flairUrl !== "" && !isHookCommandValueSafe(flairUrl)) {
|
|
204
|
+
throw new Error(`Flair URL '${flairUrl}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
|
|
205
|
+
}
|
|
206
|
+
const env = flairUrl ? `FLAIR_AGENT_ID=${agentId} FLAIR_URL=${flairUrl}` : `FLAIR_AGENT_ID=${agentId}`;
|
|
207
|
+
const invocation = `${env} npx -y -p @tpsdev-ai/flair-mcp ${CONTINUITY_CAPTURE_HOOK_MARKER}`;
|
|
208
|
+
return `sh -c '${invocation} >/dev/null 2>/dev/null || true'`;
|
|
209
|
+
}
|
|
210
|
+
/** Does this command invoke the Flair continuity-capture binary at all? */
|
|
211
|
+
export function isFlairContinuityCommand(command) {
|
|
212
|
+
return (typeof command === "string" &&
|
|
213
|
+
command.includes("@tpsdev-ai/flair-mcp") &&
|
|
214
|
+
command.includes(CONTINUITY_CAPTURE_HOOK_MARKER));
|
|
215
|
+
}
|
|
216
|
+
/** The two hook events continuity registers under. */
|
|
217
|
+
export const CONTINUITY_HOOK_EVENTS = ["PostToolUse", "Stop"];
|
|
218
|
+
function findContinuityEntry(config, event) {
|
|
219
|
+
const groups = config?.hooks?.[event];
|
|
220
|
+
if (!Array.isArray(groups))
|
|
221
|
+
return null;
|
|
222
|
+
for (let gi = 0; gi < groups.length; gi++) {
|
|
223
|
+
const hooks = groups[gi]?.hooks;
|
|
224
|
+
if (!Array.isArray(hooks))
|
|
225
|
+
continue;
|
|
226
|
+
for (let hi = 0; hi < hooks.length; hi++) {
|
|
227
|
+
if (typeof hooks[hi]?.command === "string" && hooks[hi].command.includes(CONTINUITY_CAPTURE_HOOK_MARKER)) {
|
|
228
|
+
return { group: groups[gi], hookIndex: hi, groupIndex: gi };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
function continuityEventReport(config, event) {
|
|
235
|
+
const found = findContinuityEntry(config, event);
|
|
236
|
+
if (!found)
|
|
237
|
+
return { present: false, currentForm: false };
|
|
238
|
+
const hook = found.group.hooks[found.hookIndex];
|
|
239
|
+
const command = typeof hook?.command === "string" ? hook.command : "";
|
|
240
|
+
const matcher = typeof found.group?.matcher === "string" ? found.group.matcher : undefined;
|
|
241
|
+
const shapeOk = hook?.type === "command" &&
|
|
242
|
+
command.includes(`npx -y -p @tpsdev-ai/flair-mcp ${CONTINUITY_CAPTURE_HOOK_MARKER}`) &&
|
|
243
|
+
hookCommandIsSilenced(command);
|
|
244
|
+
const matcherOk = event !== "PostToolUse" || matcher === CONTINUITY_POST_TOOL_USE_MATCHER;
|
|
245
|
+
return { present: true, command, matcher, currentForm: shapeOk && matcherOk };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Doctor's check-5 twin of checkSessionStartHook for the continuity pair —
|
|
249
|
+
* pure fs read, no probe. A missing or unparseable settings.json reads as
|
|
250
|
+
* "absent" (not enabled), matching checkSessionStartHook's tolerance.
|
|
251
|
+
*/
|
|
252
|
+
export function checkContinuityCaptureHooks(homeDir) {
|
|
253
|
+
const path = join(homeDir, ".claude", "settings.json");
|
|
254
|
+
let config = {};
|
|
255
|
+
const raw = readTextFile(path);
|
|
256
|
+
if (raw && raw.trim()) {
|
|
257
|
+
try {
|
|
258
|
+
config = JSON.parse(raw);
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
config = {};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const postToolUse = continuityEventReport(config, "PostToolUse");
|
|
265
|
+
const stop = continuityEventReport(config, "Stop");
|
|
266
|
+
let state;
|
|
267
|
+
if (!postToolUse.present && !stop.present)
|
|
268
|
+
state = "absent";
|
|
269
|
+
else if (!postToolUse.present || !stop.present)
|
|
270
|
+
state = "partial";
|
|
271
|
+
else if (postToolUse.currentForm && stop.currentForm)
|
|
272
|
+
state = "installed";
|
|
273
|
+
else
|
|
274
|
+
state = "stale";
|
|
275
|
+
return { path, postToolUse, stop, state };
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Pure merge of the continuity pair into a parsed settings object — the ONE
|
|
279
|
+
* mutation core both write paths (`flair doctor --fix` via
|
|
280
|
+
* fixContinuityCaptureHooks below, `flair hook install --continuity` via
|
|
281
|
+
* src/hook-install.ts) share. Idempotent: re-running with unchanged inputs is
|
|
282
|
+
* a structural no-op. Only OUR entries are ever touched — sibling hooks,
|
|
283
|
+
* groups and keys are preserved byte-identical; a group we don't own keeps
|
|
284
|
+
* its matcher (our binary's internal allowlist still filters — the matcher is
|
|
285
|
+
* an efficiency, not the control).
|
|
286
|
+
*/
|
|
287
|
+
export function computeContinuityHookInstall(config, agentId, flairUrl) {
|
|
288
|
+
const command = buildContinuityCaptureHookCommand(agentId, flairUrl);
|
|
289
|
+
const newConfig = JSON.parse(JSON.stringify(config ?? {}));
|
|
290
|
+
const actions = { PostToolUse: "noop", Stop: "noop" };
|
|
291
|
+
let changed = false;
|
|
292
|
+
newConfig.hooks = newConfig.hooks && typeof newConfig.hooks === "object" && !Array.isArray(newConfig.hooks) ? newConfig.hooks : {};
|
|
293
|
+
for (const event of CONTINUITY_HOOK_EVENTS) {
|
|
294
|
+
const existing = findContinuityEntry(newConfig, event);
|
|
295
|
+
if (existing) {
|
|
296
|
+
const hook = existing.group.hooks[existing.hookIndex];
|
|
297
|
+
const soleOwner = existing.group.hooks.length === 1;
|
|
298
|
+
const wantMatcher = event === "PostToolUse" && soleOwner;
|
|
299
|
+
const matcherCurrent = !wantMatcher || existing.group.matcher === CONTINUITY_POST_TOOL_USE_MATCHER;
|
|
300
|
+
if (hook.command === command && hook.type === "command" && matcherCurrent)
|
|
301
|
+
continue;
|
|
302
|
+
existing.group.hooks[existing.hookIndex] = { type: "command", command };
|
|
303
|
+
if (wantMatcher)
|
|
304
|
+
existing.group.matcher = CONTINUITY_POST_TOOL_USE_MATCHER;
|
|
305
|
+
actions[event] = "update";
|
|
306
|
+
changed = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
newConfig.hooks[event] = Array.isArray(newConfig.hooks[event]) ? newConfig.hooks[event] : [];
|
|
310
|
+
const group = { hooks: [{ type: "command", command }] };
|
|
311
|
+
if (event === "PostToolUse")
|
|
312
|
+
group.matcher = CONTINUITY_POST_TOOL_USE_MATCHER;
|
|
313
|
+
newConfig.hooks[event].push(group);
|
|
314
|
+
actions[event] = "add";
|
|
315
|
+
changed = true;
|
|
316
|
+
}
|
|
317
|
+
return { changed, actions, newConfig };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Pure removal of the continuity pair — deletes ONLY our entries (marker
|
|
321
|
+
* substring match, exactly how install finds them), then prunes any group /
|
|
322
|
+
* event array / `hooks` key left empty by that removal. Never touches
|
|
323
|
+
* anything else.
|
|
324
|
+
*/
|
|
325
|
+
export function computeContinuityHookRemoval(config) {
|
|
326
|
+
const newConfig = JSON.parse(JSON.stringify(config ?? {}));
|
|
327
|
+
const actions = { PostToolUse: "noop", Stop: "noop" };
|
|
328
|
+
let changed = false;
|
|
329
|
+
for (const event of CONTINUITY_HOOK_EVENTS) {
|
|
330
|
+
const existing = findContinuityEntry(newConfig, event);
|
|
331
|
+
if (!existing)
|
|
332
|
+
continue;
|
|
333
|
+
existing.group.hooks.splice(existing.hookIndex, 1);
|
|
334
|
+
if (existing.group.hooks.length === 0) {
|
|
335
|
+
newConfig.hooks[event].splice(existing.groupIndex, 1);
|
|
336
|
+
}
|
|
337
|
+
if (newConfig.hooks[event].length === 0) {
|
|
338
|
+
delete newConfig.hooks[event];
|
|
339
|
+
}
|
|
340
|
+
actions[event] = "remove";
|
|
341
|
+
changed = true;
|
|
342
|
+
}
|
|
343
|
+
if (changed && newConfig.hooks && typeof newConfig.hooks === "object" && Object.keys(newConfig.hooks).length === 0) {
|
|
344
|
+
delete newConfig.hooks;
|
|
345
|
+
}
|
|
346
|
+
return { changed, actions, newConfig };
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* `flair doctor --fix` write path: register (or repair to current form) the
|
|
350
|
+
* continuity pair in ~/.claude/settings.json. Merge-safe read-parse-write,
|
|
351
|
+
* mirroring fixSessionStartHook — creates the file if absent, refuses on a
|
|
352
|
+
* file it cannot parse.
|
|
353
|
+
*/
|
|
354
|
+
export function fixContinuityCaptureHooks(homeDir, agentId, flairUrl) {
|
|
355
|
+
const path = join(homeDir, ".claude", "settings.json");
|
|
356
|
+
if (!agentId) {
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
path,
|
|
360
|
+
changed: false,
|
|
361
|
+
message: "no agent id known — pass --agent <id> (or set FLAIR_AGENT_ID) so doctor knows which agent to wire the continuity hooks to",
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (!isHookCommandValueSafe(agentId)) {
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
path,
|
|
368
|
+
changed: false,
|
|
369
|
+
message: `agent id '${agentId}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
if (flairUrl != null && flairUrl !== "" && !isHookCommandValueSafe(flairUrl)) {
|
|
373
|
+
return {
|
|
374
|
+
ok: false,
|
|
375
|
+
path,
|
|
376
|
+
changed: false,
|
|
377
|
+
message: `Flair URL '${flairUrl}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
let config = {};
|
|
382
|
+
const raw = readTextFile(path);
|
|
383
|
+
if (raw && raw.trim())
|
|
384
|
+
config = JSON.parse(raw);
|
|
385
|
+
const { changed, newConfig } = computeContinuityHookInstall(config, agentId, flairUrl);
|
|
386
|
+
if (!changed) {
|
|
387
|
+
return { ok: true, path, changed: false, message: `continuity capture hooks already current in ${path}` };
|
|
388
|
+
}
|
|
389
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
390
|
+
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
|
|
391
|
+
return { ok: true, path, changed: true, message: `wired the continuity capture hooks (PostToolUse + Stop) in ${path} (agent '${agentId}')` };
|
|
392
|
+
}
|
|
393
|
+
catch (err) {
|
|
394
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
395
|
+
return { ok: false, path, changed: false, message: `could not write ${path}: ${reason}` };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Symmetric removal path (`flair doctor --fix` when disabling / `flair hook
|
|
400
|
+
* uninstall --continuity`). A no-op when nothing is wired — never creates a
|
|
401
|
+
* file that didn't exist, refuses on a file it cannot parse.
|
|
402
|
+
*/
|
|
403
|
+
export function removeContinuityCaptureHooks(homeDir) {
|
|
404
|
+
const path = join(homeDir, ".claude", "settings.json");
|
|
405
|
+
const raw = readTextFile(path);
|
|
406
|
+
if (!raw || !raw.trim()) {
|
|
407
|
+
return { ok: true, path, changed: false, message: `no ${path} — continuity capture hooks are not enabled` };
|
|
408
|
+
}
|
|
409
|
+
try {
|
|
410
|
+
const config = JSON.parse(raw);
|
|
411
|
+
const { changed, newConfig } = computeContinuityHookRemoval(config);
|
|
412
|
+
if (!changed) {
|
|
413
|
+
return { ok: true, path, changed: false, message: `no continuity capture hooks found in ${path} — nothing to remove` };
|
|
414
|
+
}
|
|
415
|
+
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
|
|
416
|
+
return { ok: true, path, changed: true, message: `removed the continuity capture hooks (PostToolUse + Stop) from ${path}` };
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
420
|
+
return { ok: false, path, changed: false, message: `could not update ${path}: ${reason}` };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
157
423
|
// ── shared helpers ──────────────────────────────────────────────────────────
|
|
158
424
|
/**
|
|
159
425
|
* Run `fn` with process.env.HOME temporarily pointed at `homeDir`, then
|
|
@@ -188,12 +454,40 @@ function readTextFile(path) {
|
|
|
188
454
|
return null;
|
|
189
455
|
}
|
|
190
456
|
}
|
|
457
|
+
// ── check 1: MCP server block present + configured ─────────────────────────
|
|
458
|
+
/**
|
|
459
|
+
* flair-client's own DEFAULT_URL (packages/flair-client/src/client.ts:
|
|
460
|
+
* `this.url = config.url ?? readEnvOrUnset("FLAIR_URL") ?? DEFAULT_URL`).
|
|
461
|
+
* Duplicated here as a value rather than imported — flair-client does not
|
|
462
|
+
* export it, and this module stays dependency-light by the same convention as
|
|
463
|
+
* the AgentGateState type duplication below. A unit test
|
|
464
|
+
* (doctor-client-native-shapes.test.ts) asserts this literal matches
|
|
465
|
+
* flair-client's source, so the two cannot drift silently.
|
|
466
|
+
*/
|
|
467
|
+
export const FLAIR_CLIENT_DEFAULT_URL = "http://localhost:19926";
|
|
468
|
+
/**
|
|
469
|
+
* The URL the wired flair-mcp process will actually connect to: the block's
|
|
470
|
+
* FLAIR_URL when set, else flair-client's built-in default. `defaulted` tells
|
|
471
|
+
* the caller which of the two it got, so doctor's output can say so
|
|
472
|
+
* (flair#1287 — a defaulted URL is still a probe-able, working URL).
|
|
473
|
+
*/
|
|
474
|
+
export function effectiveFlairUrl(block) {
|
|
475
|
+
return block.flairUrl ? { url: block.flairUrl, defaulted: false } : { url: FLAIR_CLIENT_DEFAULT_URL, defaulted: true };
|
|
476
|
+
}
|
|
191
477
|
/**
|
|
192
478
|
* Read the Flair MCP server block from `clientId`'s config file. `present`
|
|
193
|
-
* is true
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
479
|
+
* is true when the block exists AND FLAIR_AGENT_ID is set (non-empty).
|
|
480
|
+
*
|
|
481
|
+
* FLAIR_URL is deliberately NOT required (flair#1287): flair-client treats it
|
|
482
|
+
* as optional and falls back to FLAIR_CLIENT_DEFAULT_URL, and the documented
|
|
483
|
+
* `claude mcp add` command (docs/mcp-clients.md) sets only FLAIR_AGENT_ID —
|
|
484
|
+
* so a URL-less block is a WORKING setup that doctor used to false-negative
|
|
485
|
+
* as "no Flair MCP server configured". Doctor's requirement now matches
|
|
486
|
+
* flair-client's actual contract: agent id required (flair-mcp refuses to
|
|
487
|
+
* start without one — "(none — required)" in docs), URL optional
|
|
488
|
+
* (`urlDefaulted` reports the fallback so the output can distinguish it).
|
|
489
|
+
* agentId/flairUrl are still returned when partially found so callers can use
|
|
490
|
+
* whatever is known.
|
|
197
491
|
*/
|
|
198
492
|
export function readClientMcpBlock(clientId, homeDir) {
|
|
199
493
|
const configPath = withHome(homeDir, () => clientConfigPath(clientId));
|
|
@@ -210,7 +504,11 @@ function readJsonFlairBlock(configPath) {
|
|
|
210
504
|
return { present: false, configPath };
|
|
211
505
|
const agentId = typeof flair.env?.FLAIR_AGENT_ID === "string" && flair.env.FLAIR_AGENT_ID ? flair.env.FLAIR_AGENT_ID : undefined;
|
|
212
506
|
const flairUrl = typeof flair.env?.FLAIR_URL === "string" && flair.env.FLAIR_URL ? flair.env.FLAIR_URL : undefined;
|
|
213
|
-
|
|
507
|
+
// FLAIR_URL optional — see readClientMcpBlock's doc (flair#1287). Any
|
|
508
|
+
// extra fields the client's own tooling writes (e.g. `claude mcp add`'s
|
|
509
|
+
// `type: "stdio"`) are irrelevant to presence and deliberately ignored.
|
|
510
|
+
const present = !!agentId;
|
|
511
|
+
return { present, configPath, agentId, flairUrl, urlDefaulted: present && !flairUrl };
|
|
214
512
|
}
|
|
215
513
|
catch {
|
|
216
514
|
// Malformed JSON — treat as "not present", never throw.
|
|
@@ -241,7 +539,55 @@ function readCodexFlairBlock(configPath) {
|
|
|
241
539
|
if (!raw)
|
|
242
540
|
return { present: false, configPath };
|
|
243
541
|
const scanned = scanCodexFlairBlock(raw);
|
|
244
|
-
return { present: scanned.present, configPath, agentId: scanned.agentId, flairUrl: scanned.flairUrl };
|
|
542
|
+
return { present: scanned.present, configPath, agentId: scanned.agentId, flairUrl: scanned.flairUrl, urlDefaulted: scanned.urlDefaulted };
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* The two env keys the codex scanner ever looks for, each with LITERAL
|
|
546
|
+
* regexes for both TOML shapes a real Codex config carries:
|
|
547
|
+
*
|
|
548
|
+
* `line` — the `[mcp_servers.flair.env]` sub-table form (`FLAIR_AGENT_ID
|
|
549
|
+
* = "..."` on its own line): what `codex mcp add` serializes
|
|
550
|
+
* (toml_edit Table via table_from_pairs, openai/codex
|
|
551
|
+
* codex-rs config/edit/document_helpers.rs), what Codex's own
|
|
552
|
+
* config docs show, and what our tomlSnippet() writes;
|
|
553
|
+
* `inline` — the inline table (`env = { "FLAIR_AGENT_ID" = "..." }`, bare
|
|
554
|
+
* or quoted keys): valid Codex TOML that `codex mcp add` itself
|
|
555
|
+
* PRESERVES when merging into a hand-written inline entry
|
|
556
|
+
* (merge_inline_table, same file). The old line-anchored regex
|
|
557
|
+
* silently missed this shape — the flair#1287 defect class (a
|
|
558
|
+
* client-accepted config our detector rejects) in TOML form.
|
|
559
|
+
*
|
|
560
|
+
* Spelled out as regex LITERALS per key rather than built via `new RegExp`
|
|
561
|
+
* with the key interpolated: the key set is closed (these two), and literal
|
|
562
|
+
* patterns keep the scanner off the non-literal-regexp SAST surface entirely
|
|
563
|
+
* — there is nothing dynamic for an injected pattern to ride in on. The
|
|
564
|
+
* `keyof` parameter type makes a third key a compile error here, not a
|
|
565
|
+
* silently unmatched scan.
|
|
566
|
+
*/
|
|
567
|
+
const CODEX_ENV_PATTERNS = {
|
|
568
|
+
FLAIR_AGENT_ID: {
|
|
569
|
+
line: /^\s*FLAIR_AGENT_ID\s*=\s*"([^"]*)"/m,
|
|
570
|
+
inline: /"?FLAIR_AGENT_ID"?\s*=\s*"([^"]*)"/,
|
|
571
|
+
},
|
|
572
|
+
FLAIR_URL: {
|
|
573
|
+
line: /^\s*FLAIR_URL\s*=\s*"([^"]*)"/m,
|
|
574
|
+
inline: /"?FLAIR_URL"?\s*=\s*"([^"]*)"/,
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
/** Pull one env value out of the `[mcp_servers.flair]` block text — see
|
|
578
|
+
* CODEX_ENV_PATTERNS for the two shapes each key is matched against. */
|
|
579
|
+
function scanCodexEnvValue(block, key) {
|
|
580
|
+
const patterns = CODEX_ENV_PATTERNS[key];
|
|
581
|
+
const lineMatch = block.match(patterns.line);
|
|
582
|
+
if (lineMatch?.[1])
|
|
583
|
+
return lineMatch[1];
|
|
584
|
+
const inlineEnv = block.match(/^\s*env\s*=\s*\{([^}]*)\}/m);
|
|
585
|
+
if (inlineEnv) {
|
|
586
|
+
const inlineMatch = inlineEnv[1].match(patterns.inline);
|
|
587
|
+
if (inlineMatch?.[1])
|
|
588
|
+
return inlineMatch[1];
|
|
589
|
+
}
|
|
590
|
+
return undefined;
|
|
245
591
|
}
|
|
246
592
|
function scanCodexFlairBlock(raw) {
|
|
247
593
|
const startMatch = raw.match(/^\[mcp_servers\.flair\]\s*$/m);
|
|
@@ -257,11 +603,12 @@ function scanCodexFlairBlock(raw) {
|
|
|
257
603
|
blockLines.push(lines[i]);
|
|
258
604
|
}
|
|
259
605
|
const block = blockLines.join("\n");
|
|
260
|
-
const
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
606
|
+
const agentId = scanCodexEnvValue(block, "FLAIR_AGENT_ID");
|
|
607
|
+
const flairUrl = scanCodexEnvValue(block, "FLAIR_URL");
|
|
608
|
+
// FLAIR_URL optional — same contract as readJsonFlairBlock (flair#1287);
|
|
609
|
+
// docs/mcp-clients.md's own Codex snippet sets only FLAIR_AGENT_ID.
|
|
610
|
+
const present = !!agentId;
|
|
611
|
+
return { present, agentId, flairUrl, urlDefaulted: present && !flairUrl };
|
|
265
612
|
}
|
|
266
613
|
// ── check 2: FLAIR_URL to use when (re-)wiring a client (flair#727) ────────
|
|
267
614
|
/**
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
* `flair federation sync --admin-pass-file`, which reads it through
|
|
58
58
|
* readAdminPassFileSecure() and refuses a file that is not owner-only.
|
|
59
59
|
*/
|
|
60
|
-
import { existsSync, chmodSync, rmSync, readFileSync } from "node:fs";
|
|
60
|
+
import { existsSync, chmodSync, rmSync, readFileSync, mkdirSync } from "node:fs";
|
|
61
61
|
import { resolve, dirname } from "node:path";
|
|
62
62
|
import { homedir } from "node:os";
|
|
63
63
|
import { fileURLToPath } from "node:url";
|
|
64
64
|
import { escapeXml } from "../lib/xml-escape.js";
|
|
65
|
-
import { detectPlatform as detectPlatformFor, spawnReport, readTemplate, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
|
|
65
|
+
import { detectPlatform as detectPlatformFor, spawnReport, readTemplate, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, verifyFirstRun, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
|
|
66
66
|
export const LAUNCHD_LABEL = "dev.flair.federation.sync";
|
|
67
67
|
export const SYSTEMD_TIMER_UNIT = "flair-federation-sync.timer";
|
|
68
68
|
export const SYSTEMD_SERVICE_UNIT = "flair-federation-sync.service";
|
|
@@ -132,7 +132,7 @@ export function validateInterval(intervalSeconds) {
|
|
|
132
132
|
`For sub-minute latency use \`flair federation watch --interval <s>\` in a foreground session instead.`);
|
|
133
133
|
}
|
|
134
134
|
}
|
|
135
|
-
function buildSubstitutions(opts, shimPath, flairBin) {
|
|
135
|
+
function buildSubstitutions(opts, shimPath, flairBin, nodeBin) {
|
|
136
136
|
validateInterval(opts.intervalSeconds);
|
|
137
137
|
const adminPassFile = opts.adminPassFile ?? "";
|
|
138
138
|
if (adminPassFile && !existsSync(adminPassFile)) {
|
|
@@ -141,6 +141,7 @@ function buildSubstitutions(opts, shimPath, flairBin) {
|
|
|
141
141
|
}
|
|
142
142
|
return {
|
|
143
143
|
FLAIR_BIN: flairBin,
|
|
144
|
+
NODE_BIN: nodeBin,
|
|
144
145
|
SHIM_PATH: shimPath,
|
|
145
146
|
HOME: opts.homeOverride ?? homedir(),
|
|
146
147
|
INTERVAL_SECONDS: String(opts.intervalSeconds),
|
|
@@ -165,9 +166,29 @@ function launchdDomain() {
|
|
|
165
166
|
export function enableScheduler(opts) {
|
|
166
167
|
const plat = detectPlatform(opts.platformOverride);
|
|
167
168
|
const flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
|
|
169
|
+
const nodeBin = resolveNodeBin(opts.nodeBin);
|
|
168
170
|
const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
169
171
|
const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
|
|
170
|
-
const subs = buildSubstitutions(opts, shimPath, flairBin);
|
|
172
|
+
const subs = buildSubstitutions(opts, shimPath, flairBin, nodeBin);
|
|
173
|
+
// 0. Create the log directory the unit files point stdout/stderr at.
|
|
174
|
+
// Nothing else ever creates it — launchd kills a job whose StandardOutPath
|
|
175
|
+
// directory is missing (spawn error 209) and systemd fails the unit (#1231).
|
|
176
|
+
//
|
|
177
|
+
// Mode 0700 is load-bearing, NOT cosmetic: this directory also receives
|
|
178
|
+
// REM's nightly log, which carries distillation CANDIDATE CONTENT — actual
|
|
179
|
+
// memory text, not just sync counts and errors. Relaxing it to 0755 (e.g.
|
|
180
|
+
// "for shared debugging") would expose memory content to every local user.
|
|
181
|
+
const logsDir = resolve(subs.HOME, ".flair", "logs");
|
|
182
|
+
try {
|
|
183
|
+
mkdirSync(logsDir, { recursive: true, mode: 0o700 });
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
throw new Error(`could not create the scheduler log directory ${logsDir}: ${err?.message ?? err}. ` +
|
|
187
|
+
`The service manager writes the job's stdout/stderr there; without it the first run dies ` +
|
|
188
|
+
`before producing any output. Fix whatever blocks creating that directory, then re-run ` +
|
|
189
|
+
`\`flair federation sync enable\`.`);
|
|
190
|
+
}
|
|
191
|
+
const stderrLogPath = resolve(logsDir, "federation-sync.stderr.log");
|
|
171
192
|
// 1. Deploy the shim (always — both platforms invoke it).
|
|
172
193
|
const shimContents = renderTemplate(readTemplate(templateRoot, "bin/flair-federation-sync.sh.tmpl"), subs);
|
|
173
194
|
writeFileWithDir(shimPath, shimContents, 0o700);
|
|
@@ -179,14 +200,28 @@ export function enableScheduler(opts) {
|
|
|
179
200
|
writeFileWithDir(plistPath, plistContents, 0o600);
|
|
180
201
|
const loadCommand = ["launchctl", "bootstrap", launchdDomain(), plistPath];
|
|
181
202
|
let loadResult;
|
|
203
|
+
let firstRun;
|
|
182
204
|
if (!opts.skipLoad) {
|
|
183
205
|
// Bootout first in case a prior install left the job loaded — this is
|
|
184
206
|
// what makes re-running enable (e.g. to change --interval) idempotent
|
|
185
207
|
// rather than a "service already loaded" failure.
|
|
186
208
|
spawnReport(["launchctl", "bootout", launchdDomain(), plistPath]);
|
|
187
209
|
loadResult = spawnReport(loadCommand);
|
|
210
|
+
if (loadResult.code === 0) {
|
|
211
|
+
// Ordering gate (#1231): verify the first run ONLY after the load
|
|
212
|
+
// exited 0. A load failure is its own failure mode with its own
|
|
213
|
+
// remedy — kickstarting on top of it would blur which actor failed.
|
|
214
|
+
firstRun = verifyFirstRun({
|
|
215
|
+
plat,
|
|
216
|
+
darwinTarget: `${launchdDomain()}/${LAUNCHD_LABEL}`,
|
|
217
|
+
stderrLogPath,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
188
220
|
}
|
|
189
|
-
return {
|
|
221
|
+
return {
|
|
222
|
+
platform: plat, shimPath, schedulerPath: plistPath, intervalSeconds: opts.intervalSeconds,
|
|
223
|
+
loadCommand, loadResult, firstRunVerified: firstRun?.verified === true, firstRun,
|
|
224
|
+
};
|
|
190
225
|
}
|
|
191
226
|
// Linux: systemd user units.
|
|
192
227
|
const timerPath = opts.systemdTimerOverride ?? SYSTEMD_TIMER_PATH;
|
|
@@ -197,15 +232,24 @@ export function enableScheduler(opts) {
|
|
|
197
232
|
writeFileWithDir(timerPath, timerContents, 0o600);
|
|
198
233
|
const loadCommand = ["systemctl", "--user", "enable", "--now", SYSTEMD_TIMER_UNIT];
|
|
199
234
|
let loadResult;
|
|
235
|
+
let firstRun;
|
|
200
236
|
if (!opts.skipLoad) {
|
|
201
237
|
spawnReport(["systemctl", "--user", "daemon-reload"]);
|
|
202
238
|
// Restart so a changed --interval takes effect on re-enable; `enable
|
|
203
239
|
// --now` alone leaves an already-running timer on its old schedule.
|
|
204
240
|
loadResult = spawnReport(loadCommand);
|
|
205
|
-
if (loadResult.code === 0)
|
|
241
|
+
if (loadResult.code === 0) {
|
|
206
242
|
spawnReport(["systemctl", "--user", "restart", SYSTEMD_TIMER_UNIT]);
|
|
243
|
+
// Ordering gate (#1231): only after the load exited 0. Starts the
|
|
244
|
+
// SERVICE unit directly (oneshot ⇒ blocks until the run exits) rather
|
|
245
|
+
// than waiting out the timer.
|
|
246
|
+
firstRun = verifyFirstRun({ plat, linuxServiceUnit: SYSTEMD_SERVICE_UNIT, stderrLogPath });
|
|
247
|
+
}
|
|
207
248
|
}
|
|
208
|
-
return {
|
|
249
|
+
return {
|
|
250
|
+
platform: plat, shimPath, schedulerPath: timerPath, intervalSeconds: opts.intervalSeconds,
|
|
251
|
+
loadCommand, loadResult, firstRunVerified: firstRun?.verified === true, firstRun,
|
|
252
|
+
};
|
|
209
253
|
}
|
|
210
254
|
/** Removes the scheduler entry. Peer records and sync history are untouched. */
|
|
211
255
|
export function disableScheduler(opts = {}) {
|
|
@@ -432,6 +476,14 @@ export function assessDriver(input) {
|
|
|
432
476
|
* success-vs-failure decision (flair#850: never print a success headline
|
|
433
477
|
* before activation is known to have succeeded), extracted from the CLI
|
|
434
478
|
* action so it is unit-testable without spawning launchctl/systemctl.
|
|
479
|
+
*
|
|
480
|
+
* flair#1231 deepened the #850 rule by one layer: activation exiting 0 proves
|
|
481
|
+
* the service manager ACCEPTED the job, not that the job can run — a stripped
|
|
482
|
+
* exec bit and a missing log directory both passed activation and killed the
|
|
483
|
+
* first real run invisibly. So the ✅ headline is now additionally gated on
|
|
484
|
+
* `firstRunVerified`: success may not be claimed until the thing the operator
|
|
485
|
+
* asked for — a sync run through the service manager — has been observed to
|
|
486
|
+
* happen once.
|
|
435
487
|
*/
|
|
436
488
|
export function formatEnableReport(r, input) {
|
|
437
489
|
const activationFailed = !!r.loadResult && r.loadResult.code !== 0;
|
|
@@ -459,6 +511,58 @@ export function formatEnableReport(r, input) {
|
|
|
459
511
|
lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair federation sync status`);
|
|
460
512
|
return { lines, ok: false };
|
|
461
513
|
}
|
|
514
|
+
if (!r.firstRunVerified) {
|
|
515
|
+
const fr = r.firstRun;
|
|
516
|
+
const headline = fr?.outcome === "run-failed"
|
|
517
|
+
? `⚠️ Federation sync driver installed but the first run FAILED (${describeExitCode(fr.exitCode)})`
|
|
518
|
+
: fr?.outcome === "timeout"
|
|
519
|
+
? `⚠️ Federation sync driver installed but the first run did not complete within ${Math.round(fr.budgetMs / 1000)}s — cannot confirm it works`
|
|
520
|
+
: fr?.outcome === "manager-unavailable"
|
|
521
|
+
? `⚠️ Federation sync driver installed but the service manager is unreachable — cannot verify the first run`
|
|
522
|
+
: fr?.outcome === "start-failed"
|
|
523
|
+
? `⚠️ Federation sync driver installed but the first run could not be started`
|
|
524
|
+
: `⚠️ Federation sync driver installed but the first run was never verified`;
|
|
525
|
+
const lines = [
|
|
526
|
+
headline,
|
|
527
|
+
` Interval: every ${r.intervalSeconds}s`,
|
|
528
|
+
` Scheduler: ${r.schedulerPath}`,
|
|
529
|
+
` Shim: ${r.shimPath}`,
|
|
530
|
+
credLine,
|
|
531
|
+
];
|
|
532
|
+
if (input.target)
|
|
533
|
+
lines.push(` Target: ${input.target}`);
|
|
534
|
+
if (r.loadResult)
|
|
535
|
+
lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
|
|
536
|
+
if (fr) {
|
|
537
|
+
lines.push(` First run: ${fr.detail}`);
|
|
538
|
+
if (fr.stderrTail) {
|
|
539
|
+
lines.push(` Log tail (${fr.logPath}):`);
|
|
540
|
+
for (const l of fr.stderrTail.split("\n"))
|
|
541
|
+
lines.push(` ${l}`);
|
|
542
|
+
}
|
|
543
|
+
else if (fr.logEmpty) {
|
|
544
|
+
lines.push(` Log file ${fr.logPath} exists but is EMPTY — the run died before writing anything.`);
|
|
545
|
+
}
|
|
546
|
+
else {
|
|
547
|
+
lines.push(` No log file at ${fr.logPath}.`);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
lines.push("");
|
|
551
|
+
if (fr?.outcome === "timeout") {
|
|
552
|
+
lines.push(` The run may legitimately still be going. Check the log above and \`flair federation status\`;`);
|
|
553
|
+
lines.push(` nothing has been CONFIRMED to sync yet.`);
|
|
554
|
+
}
|
|
555
|
+
else if (fr?.outcome === "manager-unavailable") {
|
|
556
|
+
lines.push(` The driver files are installed, but launchctl/systemctl could not be consulted, so whether`);
|
|
557
|
+
lines.push(` sync runs is UNKNOWN. Fix the service manager for this session, then re-run \`flair federation sync enable\`.`);
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
lines.push(` Nothing has synced. Fix the cause above, then re-run \`flair federation sync enable\`.`);
|
|
561
|
+
}
|
|
562
|
+
lines.push("");
|
|
563
|
+
lines.push(` Check anytime with: flair federation sync status`);
|
|
564
|
+
return { lines, ok: false };
|
|
565
|
+
}
|
|
462
566
|
const lines = [
|
|
463
567
|
`✅ Federation sync driver enabled (${r.platform})`,
|
|
464
568
|
` Interval: every ${r.intervalSeconds}s`,
|
|
@@ -470,9 +574,10 @@ export function formatEnableReport(r, input) {
|
|
|
470
574
|
lines.push(` Target: ${input.target}`);
|
|
471
575
|
if (r.loadResult)
|
|
472
576
|
lines.push(` Load: ${r.loadCommand.join(" ")} → ok`);
|
|
577
|
+
lines.push(` First run: completed through the service manager, exit 0`);
|
|
473
578
|
lines.push("");
|
|
474
|
-
lines.push(`
|
|
475
|
-
lines.push(`which
|
|
579
|
+
lines.push(`Confirm anytime with \`flair federation status\`,`);
|
|
580
|
+
lines.push(`which reports whether anything is actually driving sync.`);
|
|
476
581
|
lines.push(`Disable with \`flair federation sync disable\`.`);
|
|
477
582
|
return { lines, ok: true };
|
|
478
583
|
}
|