@tpsdev-ai/flair 0.49.0 → 0.50.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/bridges/runtime/roundtrip.js +91 -2
- package/dist/build-info.json +3 -3
- package/dist/cli.js +383 -110
- package/dist/deploy.js +20 -3
- package/dist/doctor-client.js +59 -31
- package/dist/federation/scheduler.js +24 -3
- package/dist/hook-install.js +45 -13
- package/dist/lib/scheduler-platform.js +132 -10
- package/dist/lib/scratch-owner.js +49 -0
- package/dist/rem/scheduler.js +23 -5
- package/dist/resources/MemoryBootstrap.js +8 -4
- package/dist/resources/health.js +52 -7
- package/dist/resources/mcp-tools.js +1 -0
- package/dist/resources/search-readiness.js +100 -0
- package/dist/resources/semantic-retrieval-core.js +9 -1
- package/dist/resources/sort-comparators.js +45 -0
- package/dist/src/lib/scheduler-platform.js +132 -10
- package/dist/src/rem/scheduler.js +23 -5
- package/docs/auth.md +5 -0
- package/docs/deepseek-harness.md +1 -1
- package/docs/hosted-on-fabric.md +2 -0
- package/docs/integrations.md +53 -1
- package/docs/mcp-clients.md +67 -15
- package/docs/quickstart-fabric.md +1 -1
- package/docs/troubleshooting.md +25 -0
- package/package.json +2 -2
package/dist/deploy.js
CHANGED
|
@@ -631,9 +631,26 @@ export function publishedEntryNames(packageRoot) {
|
|
|
631
631
|
}
|
|
632
632
|
// `files` entries are npm patterns; the ones flair uses are plain top-level
|
|
633
633
|
// names, optionally trailing-slashed ("dist/"). Normalise to the entry name.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
634
|
+
//
|
|
635
|
+
// Honourable iff the entry is a plain top-level name. A blacklist of `*` and
|
|
636
|
+
// `!` lets `?`, `[]`, `{}` (and anything else minimatch understands) through
|
|
637
|
+
// as a literal that matches no real file — the same silent drop this fix
|
|
638
|
+
// exists to prevent (Sherlock on #1398). Whitelist: there is no
|
|
639
|
+
// differently-malformed form that slips through.
|
|
640
|
+
const names = [];
|
|
641
|
+
for (const raw of declared) {
|
|
642
|
+
const f = String(raw).replace(/^\.\//, "").replace(/\/+$/, "");
|
|
643
|
+
// Empty after normalize (`""`, `./`, `/`) is a listed entry we cannot
|
|
644
|
+
// honour as a plain top-level name. Skipping it left hasDeclaredFiles
|
|
645
|
+
// true and shipped only the always-includes — the same silent omit
|
|
646
|
+
// (Bugbot on #1398).
|
|
647
|
+
if (f === "." || f === ".." || !/^[a-zA-Z0-9._-]+$/.test(f)) {
|
|
648
|
+
throw new Error(`Cannot honour files entry ${JSON.stringify(String(raw))}: the deploy payload filter supports plain ` +
|
|
649
|
+
`top-level entries only. Either simplify the entry, or extend publishedEntryNames() ` +
|
|
650
|
+
`to match npm pack semantics.`);
|
|
651
|
+
}
|
|
652
|
+
names.push(f);
|
|
653
|
+
}
|
|
637
654
|
return new Set([...names, ...always]);
|
|
638
655
|
}
|
|
639
656
|
/**
|
package/dist/doctor-client.js
CHANGED
|
@@ -23,7 +23,7 @@ import { spawnSync } from "node:child_process";
|
|
|
23
23
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
24
|
import { dirname, join } from "node:path";
|
|
25
25
|
import { ALL_CLIENTS, clientConfigPath, piSettingsPath, resolvePiExtensionPath, scanPiSettings, } from "./install/clients.js";
|
|
26
|
-
import { FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
|
|
26
|
+
import { FLAIR_MCP_PACKAGE, mcpServerSpec } from "./lib/mcp-spec.js";
|
|
27
27
|
// The exact substring `flair init` writes into CLAUDE.md (src/cli.ts, the
|
|
28
28
|
// `init` action) and that the doctor check + fix both key off of.
|
|
29
29
|
export const CLAUDE_MD_BOOTSTRAP_MARKER = "mcp__flair__bootstrap";
|
|
@@ -42,9 +42,10 @@ export const SESSION_START_HOOK_MARKER = "flair-session-start";
|
|
|
42
42
|
//
|
|
43
43
|
// WHY THE INVOCATION IS WRAPPED
|
|
44
44
|
// -----------------------------
|
|
45
|
-
// The hook runs `npx -y -p @tpsdev-ai/flair-mcp flair-session-start
|
|
46
|
-
//
|
|
47
|
-
//
|
|
45
|
+
// The hook runs `npx -y -p @tpsdev-ai/flair-mcp@<version> flair-session-start`
|
|
46
|
+
// (same mcpServerSpec() pin as `flair init`'s user-local MCP client configs —
|
|
47
|
+
// flair#1143). It resolves a package binary through whatever Node runtime the
|
|
48
|
+
// user's shell happens to expose. Under a Node version manager, globally installed packages are
|
|
48
49
|
// per-runtime-version, so a routine and entirely unrelated runtime upgrade
|
|
49
50
|
// orphans that binary. The command then stops resolving and the harness
|
|
50
51
|
// reports a hook error on EVERY session, indefinitely, in wording that names
|
|
@@ -64,7 +65,7 @@ export const SESSION_START_HOOK_MARKER = "flair-session-start";
|
|
|
64
65
|
// spawns with `shell: true` and never consults $SHELL (verified against the
|
|
65
66
|
// 2.1.220 bundle; the settings schema's claim that `"shell": "bash"` uses your
|
|
66
67
|
// $SHELL is not what the code does on POSIX). So a bare POSIX fragment would
|
|
67
|
-
// in fact be enough for the
|
|
68
|
+
// in fact be enough for the JSON-command harnesses we support today.
|
|
68
69
|
//
|
|
69
70
|
// It is still wrapped, because this string is not private to that harness:
|
|
70
71
|
// SUPPORTED_HARNESSES (src/hook-install.ts) is a registry meant to grow, and
|
|
@@ -118,9 +119,23 @@ export function buildSessionStartHookCommand(agentId, flairUrl) {
|
|
|
118
119
|
throw new Error(`Flair URL '${flairUrl}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
|
|
119
120
|
}
|
|
120
121
|
const env = flairUrl ? `FLAIR_AGENT_ID=${agentId} FLAIR_URL=${flairUrl}` : `FLAIR_AGENT_ID=${agentId}`;
|
|
121
|
-
|
|
122
|
+
// Same pin as user-local MCP client configs (mcpServerSpec / flair#907).
|
|
123
|
+
// Public plugin mcp.json stays unpinned (flair#1308) — that is a scraped
|
|
124
|
+
// listing, not a machine we just wired.
|
|
125
|
+
const invocation = `${env} npx -y -p ${mcpServerSpec()} ${SESSION_START_HOOK_MARKER}`;
|
|
122
126
|
return `sh -c 'out=$(${invocation} 2>/dev/null) && printf %s "$out" || true'`;
|
|
123
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* The `npx -y -p` invocation that runs `flair-session-start` — pinned
|
|
130
|
+
* (`@tpsdev-ai/flair-mcp@<ver>`) or the pre-#1143 unpinned form. Used by
|
|
131
|
+
* `flair hook status` so a freshly-pinned hook is not reported as
|
|
132
|
+
* hand-edited, and an older unpinned hook is not reported as broken.
|
|
133
|
+
* Rejects the pre-#1166 form (no `-p`), which runs the MCP shim.
|
|
134
|
+
*/
|
|
135
|
+
export const SESSION_START_HOOK_INVOCATION_RE = /npx -y -p @tpsdev-ai\/flair-mcp(?:@[^\s"']+)? flair-session-start/;
|
|
136
|
+
export function isSessionStartHookInvocation(command) {
|
|
137
|
+
return typeof command === "string" && SESSION_START_HOOK_INVOCATION_RE.test(command);
|
|
138
|
+
}
|
|
124
139
|
/**
|
|
125
140
|
* Does this command absorb a failure instead of surfacing it? Checked as two
|
|
126
141
|
* independent PROPERTIES (stderr discarded, non-zero exit absorbed) rather
|
|
@@ -249,8 +264,8 @@ function continuityEventReport(config, event) {
|
|
|
249
264
|
* pure fs read, no probe. A missing or unparseable settings.json reads as
|
|
250
265
|
* "absent" (not enabled), matching checkSessionStartHook's tolerance.
|
|
251
266
|
*/
|
|
252
|
-
export function checkContinuityCaptureHooks(homeDir) {
|
|
253
|
-
const path = join(homeDir, ".claude", "settings.json");
|
|
267
|
+
export function checkContinuityCaptureHooks(homeDir, settingsPath) {
|
|
268
|
+
const path = settingsPath ?? join(homeDir, ".claude", "settings.json");
|
|
254
269
|
let config = {};
|
|
255
270
|
const raw = readTextFile(path);
|
|
256
271
|
if (raw && raw.trim()) {
|
|
@@ -728,12 +743,14 @@ export function fixClaudeMdBootstrap(cwd) {
|
|
|
728
743
|
}
|
|
729
744
|
}
|
|
730
745
|
/**
|
|
731
|
-
* Pass when
|
|
746
|
+
* Pass when the harness settings file exists, parses as JSON, and ANY hook
|
|
732
747
|
* command anywhere under hooks.SessionStart[*].hooks[*].command contains the
|
|
733
748
|
* flair-session-start marker (see docs/mcp-clients.md for the exact shape).
|
|
749
|
+
* `settingsPath` defaults to ~/.claude/settings.json; pass the Codex
|
|
750
|
+
* `~/.codex/hooks.json` path (or any other hookSettingsPath) for that harness.
|
|
734
751
|
*/
|
|
735
|
-
export function checkSessionStartHook(homeDir) {
|
|
736
|
-
const path = join(homeDir, ".claude", "settings.json");
|
|
752
|
+
export function checkSessionStartHook(homeDir, settingsPath) {
|
|
753
|
+
const path = settingsPath ?? join(homeDir, ".claude", "settings.json");
|
|
737
754
|
const raw = readTextFile(path);
|
|
738
755
|
if (!raw || !raw.trim())
|
|
739
756
|
return { present: false, path };
|
|
@@ -772,10 +789,9 @@ export function checkSessionStartHook(homeDir) {
|
|
|
772
789
|
* command. Returns the version when the spec is written
|
|
773
790
|
* `@tpsdev-ai/flair-mcp@<ver>`; null for a bare/unpinned spec.
|
|
774
791
|
*
|
|
775
|
-
*
|
|
776
|
-
*
|
|
777
|
-
* establishes that flair-mcp is wired but
|
|
778
|
-
* comes from the client MCP config.
|
|
792
|
+
* SessionStart hooks written since flair#1143 carry the same pin as a
|
|
793
|
+
* client MCP config (`mcpServerSpec()`). A pre-#1143 unpinned hook still
|
|
794
|
+
* establishes that flair-mcp is wired but contributes no version.
|
|
779
795
|
*/
|
|
780
796
|
export function extractFlairMcpPin(text) {
|
|
781
797
|
if (typeof text !== "string")
|
|
@@ -800,27 +816,39 @@ export function detectWiredFlairMcp(homeDir) {
|
|
|
800
816
|
pinnedVersion = pin;
|
|
801
817
|
}
|
|
802
818
|
};
|
|
803
|
-
// 1.
|
|
804
|
-
|
|
805
|
-
if (hook.present && isFlairHookCommand(hook.command ?? ""))
|
|
806
|
-
note(hook.command);
|
|
807
|
-
// 2. Every known client's MCP config — a wired flair block carries the spec.
|
|
819
|
+
// 1. Every known client's MCP config — a wired flair block carries the spec.
|
|
820
|
+
// Scanned first so a client pin wins (see module note above).
|
|
808
821
|
for (const client of ALL_CLIENTS) {
|
|
809
822
|
const configPath = withHome(homeDir, () => clientConfigPath(client.id));
|
|
810
823
|
note(readTextFile(configPath));
|
|
811
824
|
}
|
|
825
|
+
// 2. SessionStart hooks (claude-code settings.json + Codex hooks.json).
|
|
826
|
+
// Establish wiring; may carry a pin, but NEVER override a client pin
|
|
827
|
+
// already taken above — `note()` only sets pinnedVersion when it is still
|
|
828
|
+
// unset, so THIS ORDERING IS THE PRECEDENCE RULE, not decoration. The
|
|
829
|
+
// Codex path is additive (flair#1148); it must not reorder these two steps.
|
|
830
|
+
// Paths match hookSettingsPath in src/hook-install.ts — listed here to
|
|
831
|
+
// avoid a cycle (hook-install already imports this module).
|
|
832
|
+
for (const hookPath of [
|
|
833
|
+
join(homeDir, ".claude", "settings.json"),
|
|
834
|
+
join(homeDir, ".codex", "hooks.json"),
|
|
835
|
+
]) {
|
|
836
|
+
const hook = checkSessionStartHook(homeDir, hookPath);
|
|
837
|
+
if (hook.present && isFlairHookCommand(hook.command ?? ""))
|
|
838
|
+
note(hook.command);
|
|
839
|
+
}
|
|
812
840
|
return { wired, pinnedVersion };
|
|
813
841
|
}
|
|
814
842
|
/**
|
|
815
|
-
* Merge-safe insert of a Flair SessionStart hook group into
|
|
816
|
-
* ~/.claude/settings.json — creates the file/array
|
|
817
|
-
* other existing hooks/keys (read-parse-merge-write,
|
|
818
|
-
* merge safety in src/install/clients.ts; never a
|
|
819
|
-
* a no-op (ok:true) if a matching hook is already
|
|
820
|
-
* call twice.
|
|
843
|
+
* Merge-safe insert of a Flair SessionStart hook group into the harness
|
|
844
|
+
* settings file (default ~/.claude/settings.json) — creates the file/array
|
|
845
|
+
* if absent, preserves any other existing hooks/keys (read-parse-merge-write,
|
|
846
|
+
* mirroring wireJsonMcp's merge safety in src/install/clients.ts; never a
|
|
847
|
+
* blind overwrite). Dedupes: a no-op (ok:true) if a matching hook is already
|
|
848
|
+
* present, so it's safe to call twice.
|
|
821
849
|
*/
|
|
822
|
-
export function fixSessionStartHook(homeDir, agentId) {
|
|
823
|
-
const path = join(homeDir, ".claude", "settings.json");
|
|
850
|
+
export function fixSessionStartHook(homeDir, agentId, settingsPath) {
|
|
851
|
+
const path = settingsPath ?? join(homeDir, ".claude", "settings.json");
|
|
824
852
|
if (!agentId) {
|
|
825
853
|
return {
|
|
826
854
|
ok: false,
|
|
@@ -953,7 +981,7 @@ export function classifyHookProbe(outcome) {
|
|
|
953
981
|
* (or null via `{ probe: false }`) to keep a caller hermetic.
|
|
954
982
|
*/
|
|
955
983
|
export function inspectSessionStartHook(homeDir, opts = {}) {
|
|
956
|
-
const found = checkSessionStartHook(homeDir);
|
|
984
|
+
const found = checkSessionStartHook(homeDir, opts.settingsPath);
|
|
957
985
|
if (!found.present || !found.command) {
|
|
958
986
|
return { path: found.path, present: false, ours: false, silenced: false, upgradable: false, execution: null };
|
|
959
987
|
}
|
|
@@ -995,8 +1023,8 @@ export function classifyHookReadiness(report) {
|
|
|
995
1023
|
* decision to un-wire ambient memory, and `flair hook uninstall` is the
|
|
996
1024
|
* command for that when the user does decide.
|
|
997
1025
|
*/
|
|
998
|
-
export function upgradeSessionStartHookCommand(homeDir) {
|
|
999
|
-
const path = join(homeDir, ".claude", "settings.json");
|
|
1026
|
+
export function upgradeSessionStartHookCommand(homeDir, settingsPath) {
|
|
1027
|
+
const path = settingsPath ?? join(homeDir, ".claude", "settings.json");
|
|
1000
1028
|
try {
|
|
1001
1029
|
const raw = readTextFile(path);
|
|
1002
1030
|
if (!raw || !raw.trim())
|
|
@@ -62,7 +62,7 @@ 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, describeExitCode, resolveNodeBin, verifyFirstRun, STATUS_CHECK_TIMEOUT_MS, } from "../lib/scheduler-platform.js";
|
|
65
|
+
import { detectPlatform as detectPlatformFor, spawnReport, readTemplate, renderTemplateWith, writeFileWithDir, interpretActiveResult, describeLoadFailure as describeLoadFailureFor, describeExitCode, resolveNodeBin, resolveFlairBin, formatFlairBinWarning, verifyFirstRun, probeUserLingerEnabled, 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";
|
|
@@ -165,7 +165,8 @@ function launchdDomain() {
|
|
|
165
165
|
*/
|
|
166
166
|
export function enableScheduler(opts) {
|
|
167
167
|
const plat = detectPlatform(opts.platformOverride);
|
|
168
|
-
const
|
|
168
|
+
const resolvedFlair = resolveFlairBin(opts.flairBin);
|
|
169
|
+
const flairBin = resolvedFlair.path;
|
|
169
170
|
const nodeBin = resolveNodeBin(opts.nodeBin);
|
|
170
171
|
const shimPath = opts.shimPathOverride ?? SHIM_PATH_DEFAULT;
|
|
171
172
|
const templateRoot = opts.templateRootOverride ?? defaultTemplateRoot();
|
|
@@ -221,6 +222,7 @@ export function enableScheduler(opts) {
|
|
|
221
222
|
return {
|
|
222
223
|
platform: plat, shimPath, schedulerPath: plistPath, intervalSeconds: opts.intervalSeconds,
|
|
223
224
|
loadCommand, loadResult, firstRunVerified: firstRun?.verified === true, firstRun,
|
|
225
|
+
flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
|
|
224
226
|
};
|
|
225
227
|
}
|
|
226
228
|
// Linux: systemd user units.
|
|
@@ -249,6 +251,7 @@ export function enableScheduler(opts) {
|
|
|
249
251
|
return {
|
|
250
252
|
platform: plat, shimPath, schedulerPath: timerPath, intervalSeconds: opts.intervalSeconds,
|
|
251
253
|
loadCommand, loadResult, firstRunVerified: firstRun?.verified === true, firstRun,
|
|
254
|
+
flairBin, flairBinCanonical: resolvedFlair.canonical, flairBinPublic: resolvedFlair.publicBin,
|
|
252
255
|
};
|
|
253
256
|
}
|
|
254
257
|
/** Removes the scheduler entry. Peer records and sync history are untouched. */
|
|
@@ -485,6 +488,15 @@ export function assessDriver(input) {
|
|
|
485
488
|
* asked for — a sync run through the service manager — has been observed to
|
|
486
489
|
* happen once.
|
|
487
490
|
*/
|
|
491
|
+
function appendFlairBinWarning(lines, r) {
|
|
492
|
+
if (r.flairBinCanonical !== false || !r.flairBin)
|
|
493
|
+
return;
|
|
494
|
+
const warning = formatFlairBinWarning(r.flairBin, r.flairBinPublic ?? null, "flair federation sync enable");
|
|
495
|
+
if (warning.length === 0)
|
|
496
|
+
return;
|
|
497
|
+
lines.push("");
|
|
498
|
+
lines.push(...warning);
|
|
499
|
+
}
|
|
488
500
|
export function formatEnableReport(r, input) {
|
|
489
501
|
const activationFailed = !!r.loadResult && r.loadResult.code !== 0;
|
|
490
502
|
const credLine = input.adminPassFile
|
|
@@ -504,11 +516,18 @@ export function formatEnableReport(r, input) {
|
|
|
504
516
|
lines.push(` Activation: ${r.loadCommand.join(" ")} → code ${lr.code}`);
|
|
505
517
|
if (lr.stderr)
|
|
506
518
|
lines.push(` stderr: ${lr.stderr.trim()}`);
|
|
507
|
-
const
|
|
519
|
+
const lingerEnabled = input.lingerEnabled !== undefined
|
|
520
|
+
? input.lingerEnabled
|
|
521
|
+
: (r.platform === "linux" ? (input.probeLinger ?? probeUserLingerEnabled)() : undefined);
|
|
522
|
+
const remedy = describeLoadFailureFor(r.platform, lr, "flair federation sync enable", {
|
|
523
|
+
lingerEnabled,
|
|
524
|
+
env: input.env,
|
|
525
|
+
});
|
|
508
526
|
lines.push("");
|
|
509
527
|
lines.push(remedy ? ` ${remedy}` : ` Re-run the activation command above manually to see the full diagnostic.`);
|
|
510
528
|
lines.push("");
|
|
511
529
|
lines.push(` Nothing is scheduled until activation succeeds. Check anytime with: flair federation sync status`);
|
|
530
|
+
appendFlairBinWarning(lines, r);
|
|
512
531
|
return { lines, ok: false };
|
|
513
532
|
}
|
|
514
533
|
if (!r.firstRunVerified) {
|
|
@@ -561,6 +580,7 @@ export function formatEnableReport(r, input) {
|
|
|
561
580
|
}
|
|
562
581
|
lines.push("");
|
|
563
582
|
lines.push(` Check anytime with: flair federation sync status`);
|
|
583
|
+
appendFlairBinWarning(lines, r);
|
|
564
584
|
return { lines, ok: false };
|
|
565
585
|
}
|
|
566
586
|
const lines = [
|
|
@@ -579,6 +599,7 @@ export function formatEnableReport(r, input) {
|
|
|
579
599
|
lines.push(`Confirm anytime with \`flair federation status\`,`);
|
|
580
600
|
lines.push(`which reports whether anything is actually driving sync.`);
|
|
581
601
|
lines.push(`Disable with \`flair federation sync disable\`.`);
|
|
602
|
+
appendFlairBinWarning(lines, r);
|
|
582
603
|
return { lines, ok: true };
|
|
583
604
|
}
|
|
584
605
|
/** Formats the `flair federation sync status` report. */
|
package/dist/hook-install.js
CHANGED
|
@@ -54,12 +54,13 @@
|
|
|
54
54
|
// reuses bootstrap's own maxTokens machinery.
|
|
55
55
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
56
56
|
import { dirname, join } from "node:path";
|
|
57
|
-
import { SESSION_START_HOOK_MARKER, buildSessionStartHookCommand, buildContinuityCaptureHookCommand, checkContinuityCaptureHooks, computeContinuityHookInstall, computeContinuityHookRemoval, hookCommandIsSilenced, isHookCommandValueSafe, } from "./doctor-client.js";
|
|
57
|
+
import { SESSION_START_HOOK_MARKER, buildSessionStartHookCommand, buildContinuityCaptureHookCommand, checkContinuityCaptureHooks, computeContinuityHookInstall, computeContinuityHookRemoval, hookCommandIsSilenced, isHookCommandValueSafe, isSessionStartHookInvocation, readClientMcpBlock, } from "./doctor-client.js";
|
|
58
58
|
// ── harness registry ────────────────────────────────────────────────────────
|
|
59
|
-
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
59
|
+
/** SessionStart hook harnesses. A second harness is an additive registry
|
|
60
|
+
* entry, not a rewrite (Kern's #719 verdict: "a switch statement... is
|
|
61
|
+
* fine until we have 3+ harnesses"). Codex writes the same JSON hook
|
|
62
|
+
* schema Claude Code uses, into `~/.codex/hooks.json` (flair#1148). */
|
|
63
|
+
export const SUPPORTED_HARNESSES = ["claude-code", "codex"];
|
|
63
64
|
export function isSupportedHarness(value) {
|
|
64
65
|
return SUPPORTED_HARNESSES.includes(value);
|
|
65
66
|
}
|
|
@@ -70,8 +71,37 @@ export function hookSettingsPath(homeDir, harness) {
|
|
|
70
71
|
switch (harness) {
|
|
71
72
|
case "claude-code":
|
|
72
73
|
return join(homeDir, ".claude", "settings.json");
|
|
74
|
+
case "codex":
|
|
75
|
+
return join(homeDir, ".codex", "hooks.json");
|
|
73
76
|
}
|
|
74
77
|
}
|
|
78
|
+
/** Continuity capture (PostToolUse + Stop) is Claude Code only. The matcher
|
|
79
|
+
* is Claude tool names; writing it into another harness looks enabled and
|
|
80
|
+
* never journals (flair#1148 Bugbot). SessionStart stays per-harness. */
|
|
81
|
+
export function harnessSupportsContinuity(harness) {
|
|
82
|
+
return harness === "claude-code";
|
|
83
|
+
}
|
|
84
|
+
/** Status/doctor hint for `flair hook install`. Claude Code stays the bare
|
|
85
|
+
* default; every other harness is named so the hint cannot silently write
|
|
86
|
+
* the wrong file (flair#1148 Bugbot). */
|
|
87
|
+
export function hookInstallHint(harness, extraFlags = "") {
|
|
88
|
+
const parts = ["flair hook install"];
|
|
89
|
+
if (extraFlags)
|
|
90
|
+
parts.push(extraFlags);
|
|
91
|
+
if (harness !== "claude-code")
|
|
92
|
+
parts.push(`--harness ${harness}`);
|
|
93
|
+
return parts.join(" ");
|
|
94
|
+
}
|
|
95
|
+
/** Agent id for a hook install: flag, env, this harness's MCP block, then
|
|
96
|
+
* Claude Code's block as a last resort (same agent is often shared). */
|
|
97
|
+
export function resolveHookAgentId(opts, homeDir, harness) {
|
|
98
|
+
return (opts.agent ||
|
|
99
|
+
opts.agentId ||
|
|
100
|
+
process.env.FLAIR_AGENT_ID ||
|
|
101
|
+
readClientMcpBlock(harness, homeDir).agentId ||
|
|
102
|
+
(harness !== "claude-code" ? readClientMcpBlock("claude-code", homeDir).agentId : undefined) ||
|
|
103
|
+
undefined);
|
|
104
|
+
}
|
|
75
105
|
/** Backup path convention: a single sibling `<path>.bak`, overwritten on
|
|
76
106
|
* every mutating run — recovery insurance for the mutation that's about to
|
|
77
107
|
* happen, not a version history. Exported so tests assert against the same
|
|
@@ -131,7 +161,7 @@ export const HOOK_STATUS_UNPARSED = "(unknown — could not parse command)";
|
|
|
131
161
|
* Recovered values are shown. The installer-no-URL omit (flair#1325) is
|
|
132
162
|
* allowed ONLY when agentId was parsed — that is the real `flair init`
|
|
133
163
|
* shape (`FLAIR_AGENT_ID` set, `FLAIR_URL` omitted). correctShape alone
|
|
134
|
-
* is not enough: it is an npx-
|
|
164
|
+
* is not enough: it is an npx-invocation check and a wired correct-shape
|
|
135
165
|
* command with no env assignments must still show the unknown lines,
|
|
136
166
|
* not a silent all-clear. */
|
|
137
167
|
export function hookStatusIdentityLines(status) {
|
|
@@ -392,7 +422,7 @@ export function hookStatus(homeDir, harness) {
|
|
|
392
422
|
}
|
|
393
423
|
const hookEntry = config.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex];
|
|
394
424
|
const command = typeof hookEntry?.command === "string" ? hookEntry.command : "";
|
|
395
|
-
const correctShape = hookEntry?.type === "command" && command
|
|
425
|
+
const correctShape = hookEntry?.type === "command" && isSessionStartHookInvocation(command);
|
|
396
426
|
const env = parseHookCommandEnv(command);
|
|
397
427
|
return {
|
|
398
428
|
harness, path, wired: true, correctShape,
|
|
@@ -425,6 +455,13 @@ export function installContinuityHooks(opts) {
|
|
|
425
455
|
const { homeDir, harness, agentId, flairUrl } = opts;
|
|
426
456
|
const dryRun = !!opts.dryRun;
|
|
427
457
|
const path = hookSettingsPath(homeDir, harness);
|
|
458
|
+
if (!harnessSupportsContinuity(harness)) {
|
|
459
|
+
return {
|
|
460
|
+
ok: false, path, harness, dryRun,
|
|
461
|
+
message: `continuity capture is Claude Code only — ${harness} has no PostToolUse/Stop matcher Flair can journal (SessionStart is still ${hookInstallHint(harness)})`,
|
|
462
|
+
backupPath: null, actions: null,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
428
465
|
for (const [label, value] of [["agent id", agentId], ["Flair URL", flairUrl]]) {
|
|
429
466
|
if (!isHookCommandValueSafe(value)) {
|
|
430
467
|
return {
|
|
@@ -542,10 +579,5 @@ export function uninstallContinuityHooks(opts) {
|
|
|
542
579
|
/** Read-only continuity status for `flair hook status` — the same report
|
|
543
580
|
* doctor's check consumes, resolved through the harness's settings path. */
|
|
544
581
|
export function continuityHookStatus(homeDir, harness) {
|
|
545
|
-
|
|
546
|
-
// ~/.claude/settings.json from homeDir; asserting through the harness
|
|
547
|
-
// registry keeps a future second harness from silently reading the wrong
|
|
548
|
-
// file.
|
|
549
|
-
void hookSettingsPath(homeDir, harness);
|
|
550
|
-
return checkContinuityCaptureHooks(homeDir);
|
|
582
|
+
return checkContinuityCaptureHooks(homeDir, hookSettingsPath(homeDir, harness));
|
|
551
583
|
}
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* in `verifyFirstRun()`: success may not be claimed until the thing the
|
|
21
21
|
* operator asked for has been observed to happen once.
|
|
22
22
|
*/
|
|
23
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
24
|
-
import { resolve, dirname, isAbsolute } from "node:path";
|
|
25
|
-
import { platform } from "node:os";
|
|
23
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, realpathSync } from "node:fs";
|
|
24
|
+
import { resolve, dirname, isAbsolute, basename } from "node:path";
|
|
25
|
+
import { platform, userInfo } from "node:os";
|
|
26
26
|
import { spawnSync } from "node:child_process";
|
|
27
27
|
/**
|
|
28
28
|
* 30s ceiling on launchctl/systemctl invocations so a hung service manager
|
|
@@ -90,20 +90,71 @@ export function interpretActiveResult(plat, code, stdout, stderr) {
|
|
|
90
90
|
return null; // spawn itself failed — inconclusive
|
|
91
91
|
return false; // covers the no-bus case: empty stdout, nonzero/failed exit
|
|
92
92
|
}
|
|
93
|
+
/** True when this session already has the env `systemctl --user` needs. */
|
|
94
|
+
export function sessionHasUserBusEnv(env = process.env) {
|
|
95
|
+
return Boolean(env.XDG_RUNTIME_DIR?.trim() && env.DBUS_SESSION_BUS_ADDRESS?.trim());
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Reads whether lingering is already enabled for the current user.
|
|
99
|
+
* `loginctl show-user … Linger=yes` is the official answer; the stamp file
|
|
100
|
+
* `loginctl enable-linger` creates is the fallback when loginctl is missing
|
|
101
|
+
* or inconclusive. A failed probe is `null`, never linger-off — inventing
|
|
102
|
+
* linger-off would repeat the linger remedy after it already ran (#1107).
|
|
103
|
+
*/
|
|
104
|
+
export function probeUserLingerEnabled(opts = {}) {
|
|
105
|
+
const run = opts.run ?? spawnReport;
|
|
106
|
+
const lingerStampExists = opts.lingerStampExists ?? ((u) => existsSync(`/var/lib/systemd/linger/${u}`));
|
|
107
|
+
let user = "";
|
|
108
|
+
try {
|
|
109
|
+
user = userInfo().username;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
user = process.env.USER || process.env.LOGNAME || "";
|
|
113
|
+
}
|
|
114
|
+
if (!user)
|
|
115
|
+
return null;
|
|
116
|
+
const r = run(["loginctl", "show-user", user, "--property=Linger"], STATUS_CHECK_TIMEOUT_MS);
|
|
117
|
+
const m = /^Linger=(yes|no)\s*$/m.exec(r.stdout ?? "");
|
|
118
|
+
if (m)
|
|
119
|
+
return m[1] === "yes";
|
|
120
|
+
if (lingerStampExists(user))
|
|
121
|
+
return true;
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
93
124
|
/**
|
|
94
|
-
* Human remedy text for a failed scheduler-load attempt (flair#850).
|
|
95
|
-
* the
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
125
|
+
* Human remedy text for a failed scheduler-load attempt (flair#850, #1107).
|
|
126
|
+
* Covers the traced "no systemd user session bus" failure, which blocks
|
|
127
|
+
* `systemctl --user` entirely in ssh-without-lingering, container, and CI
|
|
128
|
+
* contexts. Two cases that used to share one remedy:
|
|
129
|
+
* (a) lingering genuinely off — print `loginctl enable-linger`
|
|
130
|
+
* (b) linger already on, this session has no user-bus env — print the
|
|
131
|
+
* `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` export lines
|
|
132
|
+
* Repeating (a) after the operator has applied it is the #1107 lie.
|
|
133
|
+
* Returns null when the failure doesn't match a known pattern — the caller
|
|
134
|
+
* already prints the raw stderr, so the operator still has something to go on.
|
|
100
135
|
*
|
|
101
136
|
* `enableCommand` is the caller's own enable invocation, named in the remedy
|
|
102
137
|
* so the operator is told to re-run the command they actually ran.
|
|
103
138
|
*/
|
|
104
|
-
export function describeLoadFailure(plat, loadResult, enableCommand) {
|
|
139
|
+
export function describeLoadFailure(plat, loadResult, enableCommand, session) {
|
|
105
140
|
const stderr = loadResult.stderr || "";
|
|
106
141
|
if (plat === "linux" && /failed to connect to bus/i.test(stderr)) {
|
|
142
|
+
if (session?.lingerEnabled === true) {
|
|
143
|
+
const env = session.env ?? process.env;
|
|
144
|
+
if (!sessionHasUserBusEnv(env)) {
|
|
145
|
+
return ("No systemd user session bus is available in this session. Lingering is already enabled — " +
|
|
146
|
+
"do not re-run `loginctl enable-linger`. The remaining gap is this session's user-bus environment. " +
|
|
147
|
+
"Export:\n" +
|
|
148
|
+
" export XDG_RUNTIME_DIR=/run/user/$(id -u)\n" +
|
|
149
|
+
" export DBUS_SESSION_BUS_ADDRESS=unix:path=$XDG_RUNTIME_DIR/bus\n" +
|
|
150
|
+
` then re-run \`${enableCommand}\`.`);
|
|
151
|
+
}
|
|
152
|
+
return ("No systemd user session bus is available in this session. Lingering is already enabled and " +
|
|
153
|
+
"this session already has XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS — " +
|
|
154
|
+
"do not re-run `loginctl enable-linger` or re-export those variables. " +
|
|
155
|
+
"Check that `$XDG_RUNTIME_DIR/bus` exists (the systemd --user instance may not be running), " +
|
|
156
|
+
`then re-run \`${enableCommand}\`.`);
|
|
157
|
+
}
|
|
107
158
|
return ("No systemd user session bus is available in this session (common over ssh without lingering, " +
|
|
108
159
|
"in containers, or under CI). Fix: enable lingering for this user — `loginctl enable-linger <user>` " +
|
|
109
160
|
`— then re-run \`${enableCommand}\`.`);
|
|
@@ -172,6 +223,77 @@ export function resolveNodeBin(explicit) {
|
|
|
172
223
|
"enable time — refusing to install a shim that would resolve `node` from the service manager's PATH " +
|
|
173
224
|
"at run time. Install node (or put it on PATH for this shell) and re-run enable.");
|
|
174
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* Resolves the path enable will bake as FLAIR_BIN, and whether that path is
|
|
228
|
+
* the stable public `flair` entry (flair#1279).
|
|
229
|
+
*
|
|
230
|
+
* Resolution order:
|
|
231
|
+
* 1. `explicit` — caller/test override. Relatives are resolved against cwd.
|
|
232
|
+
* 2. `hooks.argv1` / `process.argv[1]` — whatever launched enable.
|
|
233
|
+
* 3. The public `flair` on PATH, only when (1) and (2) are empty.
|
|
234
|
+
* Nothing absolute resolvable ⇒ throw. A bare `"flair"` is not an exec
|
|
235
|
+
* target under #1231's `exec <node> <script>` form (`node flair` looks in
|
|
236
|
+
* cwd, not PATH).
|
|
237
|
+
*/
|
|
238
|
+
export function resolveFlairBin(explicit, hooks) {
|
|
239
|
+
const publicBin = hooks && "publicBin" in hooks ? (hooks.publicBin ?? null) : lookupPublicFlairBin();
|
|
240
|
+
const captured = explicit ?? hooks?.argv1 ?? process.argv[1];
|
|
241
|
+
let path;
|
|
242
|
+
if (typeof captured === "string" && captured.length > 0) {
|
|
243
|
+
path = isAbsolute(captured) ? captured : resolve(captured);
|
|
244
|
+
}
|
|
245
|
+
else if (publicBin) {
|
|
246
|
+
path = publicBin;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
throw new Error("unable to resolve an absolute path to the flair CLI (process.argv[1] was empty and `command -v flair` " +
|
|
250
|
+
"found nothing). The scheduler shim bakes this path in at enable time — refusing to install a shim " +
|
|
251
|
+
"whose exec target is unknown. Re-run enable via the `flair` command.");
|
|
252
|
+
}
|
|
253
|
+
return { path, publicBin, canonical: isCanonicalFlairBin(path, publicBin) };
|
|
254
|
+
}
|
|
255
|
+
/** True when `baked` is the public `flair` entry, not a working-tree capture. */
|
|
256
|
+
export function isCanonicalFlairBin(baked, publicBin) {
|
|
257
|
+
if (basename(baked) === "flair")
|
|
258
|
+
return true;
|
|
259
|
+
if (publicBin && pathsReferToSameFile(baked, publicBin))
|
|
260
|
+
return true;
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The enable-report lines for a non-canonical FLAIR_BIN. Empty when the
|
|
265
|
+
* baked path is the public entry — callers should not print a warning then.
|
|
266
|
+
*/
|
|
267
|
+
export function formatFlairBinWarning(baked, publicBin, enableCommand) {
|
|
268
|
+
if (isCanonicalFlairBin(baked, publicBin))
|
|
269
|
+
return [];
|
|
270
|
+
const lines = [
|
|
271
|
+
`⚠️ FLAIR_BIN is ${baked} — that is the process that ran enable, not a stable public entry.`,
|
|
272
|
+
` A later blue/green directory swap, or deleting this working tree, will strand the scheduler unit.`,
|
|
273
|
+
];
|
|
274
|
+
if (publicBin) {
|
|
275
|
+
lines.push(` Public \`flair\` on PATH: ${publicBin}. Re-run \`${enableCommand}\` as the \`flair\` command to bake that path instead.`);
|
|
276
|
+
}
|
|
277
|
+
else {
|
|
278
|
+
lines.push(` No \`flair\` on PATH. Re-run \`${enableCommand}\` via the installed \`flair\` command (or a stable symlink) so the baked path survives a tree swap.`);
|
|
279
|
+
}
|
|
280
|
+
return lines;
|
|
281
|
+
}
|
|
282
|
+
function lookupPublicFlairBin() {
|
|
283
|
+
const r = spawnReport(["/bin/sh", "-c", "command -v flair"], STATUS_CHECK_TIMEOUT_MS);
|
|
284
|
+
const found = r.stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
285
|
+
if (r.code === 0 && found && isAbsolute(found) && existsSync(found))
|
|
286
|
+
return found;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
function pathsReferToSameFile(a, b) {
|
|
290
|
+
try {
|
|
291
|
+
return realpathSync(a) === realpathSync(b);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return resolve(a) === resolve(b);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
175
297
|
// ─── first-run verification (flair#1231) ────────────────────────────────────
|
|
176
298
|
// A load/bootstrap command exiting 0 proves the service manager accepted the
|
|
177
299
|
// job — not that the job can run. The only vantage that exercises the real
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner stamp for harness scratch directories (flair#1032).
|
|
3
|
+
*
|
|
4
|
+
* Directory mtime is not a liveness signal: on Linux, appending to files
|
|
5
|
+
* inside subdirectories does not update the parent. The stamp records the
|
|
6
|
+
* creating process; a sweep may delete a tree only when that process is gone
|
|
7
|
+
* (and, for Harper trees, when `hdb.pid` is gone too).
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
export const SCRATCH_OWNER_FILE = ".flair-scratch-owner";
|
|
12
|
+
export function writeScratchOwnerStamp(dir, pid = process.pid) {
|
|
13
|
+
writeFileSync(join(dir, SCRATCH_OWNER_FILE), `${pid}\n`, { encoding: "utf-8" });
|
|
14
|
+
}
|
|
15
|
+
export function isPidAlive(pid) {
|
|
16
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
17
|
+
return false;
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function readPidFile(path) {
|
|
27
|
+
try {
|
|
28
|
+
const pid = Number(readFileSync(path, "utf-8").trim());
|
|
29
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function readScratchOwnerPid(dir) {
|
|
36
|
+
return readPidFile(join(dir, SCRATCH_OWNER_FILE));
|
|
37
|
+
}
|
|
38
|
+
export function hasScratchOwnerStamp(dir) {
|
|
39
|
+
return existsSync(join(dir, SCRATCH_OWNER_FILE));
|
|
40
|
+
}
|
|
41
|
+
/** True when the creating process is still alive. Unreadable stamp → not live. */
|
|
42
|
+
export function scratchOwnerIsLive(dir) {
|
|
43
|
+
const pid = readScratchOwnerPid(dir);
|
|
44
|
+
return pid !== null && isPidAlive(pid);
|
|
45
|
+
}
|
|
46
|
+
export function hdbPidIsLive(dir) {
|
|
47
|
+
const pid = readPidFile(join(dir, "hdb.pid"));
|
|
48
|
+
return pid !== null && isPidAlive(pid);
|
|
49
|
+
}
|