@tpsdev-ai/flair 0.48.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.
Files changed (46) hide show
  1. package/README.md +2 -0
  2. package/dist/bridges/runtime/roundtrip.js +91 -2
  3. package/dist/build-info.json +3 -3
  4. package/dist/cli.js +903 -226
  5. package/dist/component-env.js +52 -4
  6. package/dist/deploy.js +20 -3
  7. package/dist/doctor-client.js +105 -32
  8. package/dist/federation/scheduler.js +24 -3
  9. package/dist/hook-install.js +96 -16
  10. package/dist/install/clients.js +318 -9
  11. package/dist/lib/auth-resolve.js +34 -3
  12. package/dist/lib/mcp-enable.js +134 -26
  13. package/dist/lib/scheduler-platform.js +132 -10
  14. package/dist/lib/scratch-owner.js +49 -0
  15. package/dist/rem/scheduler.js +23 -5
  16. package/dist/resources/AgentSeed.js +2 -0
  17. package/dist/resources/Memory.js +24 -5
  18. package/dist/resources/MemoryBootstrap.js +8 -4
  19. package/dist/resources/MemoryFeed.js +3 -0
  20. package/dist/resources/MemoryMaintenance.js +11 -2
  21. package/dist/resources/bm25-index-service.js +257 -0
  22. package/dist/resources/bm25-index.js +631 -0
  23. package/dist/resources/bm25.js +31 -1
  24. package/dist/resources/embeddings-boot.js +45 -3
  25. package/dist/resources/health.js +52 -7
  26. package/dist/resources/mcp-tools.js +1 -0
  27. package/dist/resources/memory-read-scope.js +2 -0
  28. package/dist/resources/search-readiness.js +100 -0
  29. package/dist/resources/semantic-retrieval-core.js +102 -23
  30. package/dist/resources/sort-comparators.js +45 -0
  31. package/dist/src/lib/scheduler-platform.js +132 -10
  32. package/dist/src/rem/scheduler.js +23 -5
  33. package/dist/version-check.js +59 -13
  34. package/docs/auth.md +5 -0
  35. package/docs/claude-code.md +10 -3
  36. package/docs/deepseek-harness.md +1 -1
  37. package/docs/deployment.md +11 -1
  38. package/docs/hosted-on-fabric.md +2 -0
  39. package/docs/integrations.md +78 -5
  40. package/docs/mcp-clients.md +85 -15
  41. package/docs/notes/mcp-oauth-model2.md +31 -13
  42. package/docs/quickstart-fabric.md +1 -1
  43. package/docs/quickstart.md +9 -9
  44. package/docs/standalone-local.md +3 -0
  45. package/docs/troubleshooting.md +25 -0
  46. package/package.json +4 -3
@@ -214,16 +214,51 @@ export function planComponentEnv(existing, publicUrl) {
214
214
  assertNoSecretKeysAdded(existing, text);
215
215
  return { action: "added", text, effectiveValue: publicUrl, notices };
216
216
  }
217
+ /**
218
+ * True when `envPath` is inside a `node_modules` tree (any platform separator).
219
+ *
220
+ * A `.env` there is not a durable location: it does not exist on a stock npm
221
+ * install and `npm upgrade` / `flair upgrade` wipes the package directory
222
+ * (flair#1313). Doctor and deploy must never name that path as the fix.
223
+ *
224
+ * Heuristic, not a guarantee: a path segment equal to `node_modules` is treated
225
+ * as the npm tree. A durable deploy root that happened to use that as a
226
+ * directory name (e.g. `/opt/my-node_modules-app/flair/.env`) would be
227
+ * misclassified. That is not a real deployment shape.
228
+ */
229
+ export function isNodeModulesEnvPath(envPath) {
230
+ // Path-segment match, not a substring of a filename — still a heuristic.
231
+ return envPath.split(/[\\/]/).includes("node_modules");
232
+ }
233
+ /**
234
+ * The location `publicUrlRemedy` names when the component path is inside
235
+ * `node_modules`. Three durable channels, matching what the deploy actually
236
+ * reads: the process environment that starts Harper (CLI / launchd / systemd),
237
+ * or the component `.env` on a Fabric/server deploy (`loadEnv` in config.yaml).
238
+ */
239
+ export const DURABLE_PUBLIC_URL_LOCATION = "the Flair process environment (launchd EnvironmentVariables, systemd Environment=, " +
240
+ "or export before flair start/restart) or, on a Fabric/server deploy, the component " +
241
+ `${COMPONENT_ENV_FILENAME} that Harper's loadEnv plugin reads — never a ${COMPONENT_ENV_FILENAME} ` +
242
+ "inside node_modules (that path does not exist by default and is wiped on every upgrade)";
217
243
  /**
218
244
  * The remedy string for a missing/loopback `FLAIR_PUBLIC_URL`. One definition so
219
245
  * `flair deploy` and `flair doctor` cannot drift into naming different files.
220
246
  *
221
- * It names all three things an operator needs: the FILE, the KEY, and the fact that
222
- * the file is only read because config.yaml declares Harper's `loadEnv` plugin —
223
- * without which the file is present and inert, which is what made flair#1000 hard
224
- * to see.
247
+ * When `envPath` is a durable component location (the deploy root, a server
248
+ * component dir), it names the FILE, the KEY, and the fact that the file is
249
+ * only read because config.yaml declares Harper's `loadEnv` plugin without
250
+ * which the file is present and inert, which is what made flair#1000 hard to
251
+ * see.
252
+ *
253
+ * When `envPath` is inside `node_modules` (a global `npm install -g` package
254
+ * dir), naming that file would send the operator to a path that does not exist
255
+ * by default and is destroyed on every upgrade (flair#1313). The remedy then
256
+ * names the durable process-environment / server-component channels instead.
225
257
  */
226
258
  export function publicUrlRemedy(envPath, exampleUrl = "https://flair.example.com") {
259
+ if (isNodeModulesEnvPath(envPath)) {
260
+ return `set ${PUBLIC_URL_KEY}=${exampleUrl} in ${DURABLE_PUBLIC_URL_LOCATION}, then restart the instance`;
261
+ }
227
262
  return (`set ${PUBLIC_URL_KEY}=${exampleUrl} in ${envPath} (Harper reads a component's ` +
228
263
  `${COMPONENT_ENV_FILENAME} only because flair's config.yaml declares the loadEnv plugin, ` +
229
264
  `above jsResource), then restart the instance`);
@@ -257,6 +292,19 @@ export function describePublicUrlFinding(input) {
257
292
  };
258
293
  }
259
294
  if (componentEnvValue !== null && !isLoopbackUrl(componentEnvValue)) {
295
+ if (isNodeModulesEnvPath(componentEnvPath)) {
296
+ // The value is in an upgrade-wiped location. Naming that path — even to
297
+ // say "confirm loadEnv" — would send the operator back into node_modules
298
+ // (flair#1313). Move the value to a durable channel.
299
+ return {
300
+ isIssue: true,
301
+ icon: "error",
302
+ message: `${PUBLIC_URL_KEY} is set in a ${COMPONENT_ENV_FILENAME} inside the npm package ` +
303
+ `directory but discovery still advertises ${advertisedIssuer} — that file is ` +
304
+ `wiped on every upgrade and is not a durable location`,
305
+ fixHint: publicUrlRemedy(componentEnvPath, componentEnvValue),
306
+ };
307
+ }
260
308
  return {
261
309
  isIssue: true,
262
310
  icon: "error",
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
- const names = declared
635
- .map((f) => String(f).replace(/^\.\//, "").replace(/\/+$/, ""))
636
- .filter((f) => f !== "" && !f.includes("*") && !f.startsWith("!"));
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
  /**
@@ -22,8 +22,8 @@
22
22
  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
- import { ALL_CLIENTS, clientConfigPath } from "./install/clients.js";
26
- import { FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
25
+ import { ALL_CLIENTS, clientConfigPath, piSettingsPath, resolvePiExtensionPath, scanPiSettings, } from "./install/clients.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`: it resolves
46
- // a package binary through whatever Node runtime the user's shell happens to
47
- // expose. Under a Node version manager, globally installed packages are
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 one harness we support today.
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
- const invocation = `${env} npx -y -p @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`;
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()) {
@@ -610,6 +625,51 @@ function scanCodexFlairBlock(raw) {
610
625
  const present = !!agentId;
611
626
  return { present, agentId, flairUrl, urlDefaulted: present && !flairUrl };
612
627
  }
628
+ export function checkPiFlairWiring(homeDir, cwd) {
629
+ const userPath = withHome(homeDir, () => piSettingsPath());
630
+ const files = [
631
+ // pi resolves user-scope relative paths against the agent dir (the
632
+ // settings file's own directory), project-scope against the project dir.
633
+ { path: userPath, baseDir: dirname(userPath) },
634
+ ];
635
+ if (cwd)
636
+ files.push({ path: join(cwd, ".pi", "settings.json"), baseDir: cwd });
637
+ const report = {
638
+ settingsPath: userPath,
639
+ checked: [],
640
+ wired: false,
641
+ wiredVia: null,
642
+ pinnedVersion: null,
643
+ misconfigured: [],
644
+ };
645
+ for (const file of files) {
646
+ const raw = readTextFile(file.path);
647
+ report.checked.push({ path: file.path, exists: raw !== null });
648
+ const scan = scanPiSettings(raw);
649
+ for (const entry of scan.misconfiguredNpmUnderExtensions) {
650
+ report.misconfigured.push({ path: file.path, entry });
651
+ }
652
+ if (report.wired)
653
+ continue; // first wiring found wins; keep collecting traps
654
+ if (scan.packagesSpec) {
655
+ report.wired = true;
656
+ report.wiredVia = "packages";
657
+ report.wiredIn = file.path;
658
+ report.spec = scan.packagesSpec;
659
+ report.pinnedVersion = scan.pinnedVersion;
660
+ continue;
661
+ }
662
+ if (scan.extensionFilePaths.length > 0) {
663
+ const entry = scan.extensionFilePaths[0];
664
+ report.wired = true;
665
+ report.wiredVia = "extension-path";
666
+ report.wiredIn = file.path;
667
+ report.spec = entry;
668
+ report.extensionPathExists = existsSync(resolvePiExtensionPath(entry, homeDir, file.baseDir));
669
+ }
670
+ }
671
+ return report;
672
+ }
613
673
  // ── check 2: FLAIR_URL to use when (re-)wiring a client (flair#727) ────────
614
674
  /**
615
675
  * Pick the FLAIR_URL to feed a wire() call when `doctor --fix` re-wires a
@@ -683,12 +743,14 @@ export function fixClaudeMdBootstrap(cwd) {
683
743
  }
684
744
  }
685
745
  /**
686
- * Pass when ~/.claude/settings.json exists, parses as JSON, and ANY hook
746
+ * Pass when the harness settings file exists, parses as JSON, and ANY hook
687
747
  * command anywhere under hooks.SessionStart[*].hooks[*].command contains the
688
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.
689
751
  */
690
- export function checkSessionStartHook(homeDir) {
691
- const path = join(homeDir, ".claude", "settings.json");
752
+ export function checkSessionStartHook(homeDir, settingsPath) {
753
+ const path = settingsPath ?? join(homeDir, ".claude", "settings.json");
692
754
  const raw = readTextFile(path);
693
755
  if (!raw || !raw.trim())
694
756
  return { present: false, path };
@@ -727,10 +789,9 @@ export function checkSessionStartHook(homeDir) {
727
789
  * command. Returns the version when the spec is written
728
790
  * `@tpsdev-ai/flair-mcp@<ver>`; null for a bare/unpinned spec.
729
791
  *
730
- * The SessionStart hook is deliberately unpinned (`npx -y -p
731
- * @tpsdev-ai/flair-mcp`, buildSessionStartHookCommand above), so a hook
732
- * establishes that flair-mcp is wired but never carries a version — the pin
733
- * 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.
734
795
  */
735
796
  export function extractFlairMcpPin(text) {
736
797
  if (typeof text !== "string")
@@ -755,27 +816,39 @@ export function detectWiredFlairMcp(homeDir) {
755
816
  pinnedVersion = pin;
756
817
  }
757
818
  };
758
- // 1. The SessionStart hook (claude-code). Establishes wiring; unpinned by design.
759
- const hook = checkSessionStartHook(homeDir);
760
- if (hook.present && isFlairHookCommand(hook.command ?? ""))
761
- note(hook.command);
762
- // 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).
763
821
  for (const client of ALL_CLIENTS) {
764
822
  const configPath = withHome(homeDir, () => clientConfigPath(client.id));
765
823
  note(readTextFile(configPath));
766
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
+ }
767
840
  return { wired, pinnedVersion };
768
841
  }
769
842
  /**
770
- * Merge-safe insert of a Flair SessionStart hook group into
771
- * ~/.claude/settings.json — creates the file/array if absent, preserves any
772
- * other existing hooks/keys (read-parse-merge-write, mirroring wireJsonMcp's
773
- * merge safety in src/install/clients.ts; never a blind overwrite). Dedupes:
774
- * a no-op (ok:true) if a matching hook is already present, so it's safe to
775
- * 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.
776
849
  */
777
- export function fixSessionStartHook(homeDir, agentId) {
778
- const path = join(homeDir, ".claude", "settings.json");
850
+ export function fixSessionStartHook(homeDir, agentId, settingsPath) {
851
+ const path = settingsPath ?? join(homeDir, ".claude", "settings.json");
779
852
  if (!agentId) {
780
853
  return {
781
854
  ok: false,
@@ -908,7 +981,7 @@ export function classifyHookProbe(outcome) {
908
981
  * (or null via `{ probe: false }`) to keep a caller hermetic.
909
982
  */
910
983
  export function inspectSessionStartHook(homeDir, opts = {}) {
911
- const found = checkSessionStartHook(homeDir);
984
+ const found = checkSessionStartHook(homeDir, opts.settingsPath);
912
985
  if (!found.present || !found.command) {
913
986
  return { path: found.path, present: false, ours: false, silenced: false, upgradable: false, execution: null };
914
987
  }
@@ -950,8 +1023,8 @@ export function classifyHookReadiness(report) {
950
1023
  * decision to un-wire ambient memory, and `flair hook uninstall` is the
951
1024
  * command for that when the user does decide.
952
1025
  */
953
- export function upgradeSessionStartHookCommand(homeDir) {
954
- const path = join(homeDir, ".claude", "settings.json");
1026
+ export function upgradeSessionStartHookCommand(homeDir, settingsPath) {
1027
+ const path = settingsPath ?? join(homeDir, ".claude", "settings.json");
955
1028
  try {
956
1029
  const raw = readTextFile(path);
957
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 flairBin = opts.flairBin ?? process.argv[1] ?? "flair";
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 remedy = describeLoadFailureFor(r.platform, lr, "flair federation sync enable");
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. */
@@ -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
- /** v1 supports exactly one harness. The flag/type exist so a second harness
60
- * is an additive registry entry, not a rewrite (Kern's #719 verdict: "a
61
- * switch statement... is fine until we have 3+ harnesses"). */
62
- export const SUPPORTED_HARNESSES = ["claude-code"];
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
@@ -94,14 +124,62 @@ export function hookBackupPath(settingsPath) {
94
124
  export function buildHookCommand(agentId, flairUrl) {
95
125
  return buildSessionStartHookCommand(agentId, flairUrl);
96
126
  }
127
+ /**
128
+ * Peel the installer `sh -c '...'` wrapper (and its `out=$(...)` capture)
129
+ * so env assignments can be read from the inner invocation. Leaves a bare
130
+ * command (legacy pre-#1007, hand-rolled) unchanged. Never throws.
131
+ */
132
+ function unwrapInstallerHookCommand(command) {
133
+ const shc = command.match(/^sh\s+-c\s+(['"])([\s\S]*)\1\s*$/);
134
+ const body = shc ? shc[2] : command;
135
+ // SessionStart installer: `out=$(<invocation> 2>/dev/null) && printf ...`
136
+ const captured = body.match(/^out=\$\((.*)\)\s*&&/);
137
+ return captured ? captured[1] : body;
138
+ }
139
+ /** Env values the installer interpolates are allow-listed (see
140
+ * isHookCommandValueSafe). Stop before whitespace or the shell
141
+ * metacharacters the `$(...)` wrapper can leave adjacent to a value. */
142
+ const HOOK_ENV_VALUE_RE = /[^\s'"$();|&<>]+/;
97
143
  /** Best-effort recovery of the agentId/flairUrl a previously-wired hook
98
- * command carries — used by `flair hook status`. Pure string scan, never
99
- * throws on an unexpected shape. */
144
+ * command carries — used by `flair hook status`. Understands the
145
+ * installer-written `sh -c` wrapper (`flair init`, `flair hook install`,
146
+ * docs/mcp-clients.md) as well as a bare invocation. Pure string scan,
147
+ * never throws on an unexpected shape. A missing FLAIR_URL is not a
148
+ * parse failure: `flair init` / doctor's minimal shape omit it on
149
+ * purpose (the hook then uses flair-client's localhost default). */
100
150
  export function parseHookCommandEnv(command) {
101
- const agentMatch = command.match(/FLAIR_AGENT_ID=(\S+)/);
102
- const urlMatch = command.match(/FLAIR_URL=(\S+)/);
151
+ const source = unwrapInstallerHookCommand(command);
152
+ const agentMatch = source.match(new RegExp(`FLAIR_AGENT_ID=(${HOOK_ENV_VALUE_RE.source})`));
153
+ const urlMatch = source.match(new RegExp(`FLAIR_URL=(${HOOK_ENV_VALUE_RE.source})`));
103
154
  return { agentId: agentMatch?.[1], flairUrl: urlMatch?.[1] };
104
155
  }
156
+ /** Printed by `flair hook status` only when the command is wired but its
157
+ * agent/URL really could not be recovered — never for the installer
158
+ * `sh -c` form that simply omits FLAIR_URL (flair#1325). */
159
+ export const HOOK_STATUS_UNPARSED = "(unknown — could not parse command)";
160
+ /** Agent / Flair URL lines `flair hook status` prints under a wired hook.
161
+ * Recovered values are shown. The installer-no-URL omit (flair#1325) is
162
+ * allowed ONLY when agentId was parsed — that is the real `flair init`
163
+ * shape (`FLAIR_AGENT_ID` set, `FLAIR_URL` omitted). correctShape alone
164
+ * is not enough: it is an npx-invocation check and a wired correct-shape
165
+ * command with no env assignments must still show the unknown lines,
166
+ * not a silent all-clear. */
167
+ export function hookStatusIdentityLines(status) {
168
+ const lines = [];
169
+ if (status.agentId) {
170
+ lines.push({ label: "Agent", value: status.agentId });
171
+ }
172
+ else {
173
+ lines.push({ label: "Agent", value: HOOK_STATUS_UNPARSED });
174
+ }
175
+ if (status.flairUrl) {
176
+ lines.push({ label: "Flair URL", value: status.flairUrl });
177
+ }
178
+ else if (!status.agentId) {
179
+ lines.push({ label: "Flair URL", value: HOOK_STATUS_UNPARSED });
180
+ }
181
+ return lines;
182
+ }
105
183
  function makeHookGroup(command) {
106
184
  return { hooks: [{ type: "command", command }] };
107
185
  }
@@ -344,7 +422,7 @@ export function hookStatus(homeDir, harness) {
344
422
  }
345
423
  const hookEntry = config.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex];
346
424
  const command = typeof hookEntry?.command === "string" ? hookEntry.command : "";
347
- const correctShape = hookEntry?.type === "command" && command.includes(`npx -y -p @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`);
425
+ const correctShape = hookEntry?.type === "command" && isSessionStartHookInvocation(command);
348
426
  const env = parseHookCommandEnv(command);
349
427
  return {
350
428
  harness, path, wired: true, correctShape,
@@ -377,6 +455,13 @@ export function installContinuityHooks(opts) {
377
455
  const { homeDir, harness, agentId, flairUrl } = opts;
378
456
  const dryRun = !!opts.dryRun;
379
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
+ }
380
465
  for (const [label, value] of [["agent id", agentId], ["Flair URL", flairUrl]]) {
381
466
  if (!isHookCommandValueSafe(value)) {
382
467
  return {
@@ -494,10 +579,5 @@ export function uninstallContinuityHooks(opts) {
494
579
  /** Read-only continuity status for `flair hook status` — the same report
495
580
  * doctor's check consumes, resolved through the harness's settings path. */
496
581
  export function continuityHookStatus(homeDir, harness) {
497
- // hookSettingsPath and checkContinuityCaptureHooks both resolve
498
- // ~/.claude/settings.json from homeDir; asserting through the harness
499
- // registry keeps a future second harness from silently reading the wrong
500
- // file.
501
- void hookSettingsPath(homeDir, harness);
502
- return checkContinuityCaptureHooks(homeDir);
582
+ return checkContinuityCaptureHooks(homeDir, hookSettingsPath(homeDir, harness));
503
583
  }