@tpsdev-ai/flair 0.33.0 → 0.34.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/cli.js CHANGED
@@ -6,11 +6,12 @@ import * as render from "./render.js";
6
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, } from "node:fs";
7
7
  import { homedir, tmpdir } from "node:os";
8
8
  import { join, resolve, sep, dirname } from "node:path";
9
- import { spawn, execFileSync } from "node:child_process";
9
+ import { spawn, execFileSync, spawnSync, execSync } from "node:child_process";
10
10
  import { createHash, randomUUID, randomBytes } from "node:crypto";
11
11
  import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
12
12
  import { keystore } from "./keystore.js";
13
- import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
13
+ import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl, resolveDeployPublicUrl } from "./deploy.js";
14
+ import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, assertNoSecretKeysAdded, describePublicUrlFinding, planComponentEnv, readEnvValue, } from "./component-env.js";
14
15
  import { fabricUpgrade } from "./fabric-upgrade.js";
15
16
  import { checkVersion, formatVersionNudge, primeVersionCheckCache, FLAIR_PKG_NAME } from "./version-check.js";
16
17
  import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache } from "./version-handshake.js";
@@ -21,11 +22,12 @@ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemi
21
22
  import { flairCliVersion, mcpServerSpec, unpinnedSpecWarning } from "./lib/mcp-spec.js";
22
23
  import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
23
24
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
24
- import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, inferSoleAgentId, fixCommandAgentHint, describeAgentGateFinding, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
25
+ import { readClientMcpBlock, checkClaudeMdBootstrap, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, inferSoleAgentId, fixCommandAgentHint, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
25
26
  import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
26
- import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
27
+ import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
27
28
  import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
28
29
  import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
30
+ import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, renderVerifiedSummary, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
29
31
  // Value-only static import so `--interval`'s advertised default cannot drift
30
32
  // from the one the scheduler actually validates against. The module itself is
31
33
  // still loaded lazily at call time (the `await import()`s below) for the
@@ -1427,7 +1429,7 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
1427
1429
  catch { /* keysDir missing */ }
1428
1430
  }
1429
1431
  if (!agentId) {
1430
- return { state: "skipped", detail: "no agent id or key found" };
1432
+ return { state: "skipped", reason: "no-agent", detail: "no agent id or key found" };
1431
1433
  }
1432
1434
  // Find the signing key. Prefer the standard locations (resolveKeyPath), but
1433
1435
  // fall back to the keysDir we were handed — `flair init` keys live there and
@@ -1439,7 +1441,7 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
1439
1441
  keyPath = candidate;
1440
1442
  }
1441
1443
  if (!keyPath) {
1442
- return { state: "skipped", detail: `no private key for agent '${agentId}'` };
1444
+ return { state: "skipped", reason: "no-key", detail: `no private key for agent '${agentId}'` };
1443
1445
  }
1444
1446
  // Distinctive content vs. a PARAPHRASE query with deliberately ZERO shared
1445
1447
  // content words. If the search recovers the memory it can ONLY be by meaning.
@@ -1459,7 +1461,7 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
1459
1461
  });
1460
1462
  if (!writeRes.ok && writeRes.status !== 204) {
1461
1463
  const text = await writeRes.text().catch(() => "");
1462
- return { state: "skipped", detail: `could not write probe memory: HTTP ${writeRes.status} ${text.slice(0, 80)}` };
1464
+ return { state: "skipped", reason: "probe-failed", detail: `could not write probe memory: HTTP ${writeRes.status} ${text.slice(0, 80)}` };
1463
1465
  }
1464
1466
  stored = true;
1465
1467
  // Allow the HNSW index to catch up before searching.
@@ -1472,7 +1474,7 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
1472
1474
  });
1473
1475
  if (!searchRes.ok) {
1474
1476
  const text = await searchRes.text().catch(() => "");
1475
- return { state: "skipped", detail: `SemanticSearch failed: HTTP ${searchRes.status} ${text.slice(0, 80)}` };
1477
+ return { state: "skipped", reason: "probe-failed", detail: `SemanticSearch failed: HTTP ${searchRes.status} ${text.slice(0, 80)}` };
1476
1478
  }
1477
1479
  const data = await searchRes.json();
1478
1480
  // The server sets _warning ONLY when getMode() === "none" — i.e. the
@@ -1498,8 +1500,15 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
1498
1500
  return { state: "ok", score };
1499
1501
  }
1500
1502
  catch (err) {
1503
+ // flair#1023: distinguish "your key will not load" from "the probe
1504
+ // request failed". The former is raised before any request leaves the
1505
+ // process, so it is never evidence about the instance — and "pass
1506
+ // --agent" cannot fix it.
1507
+ if (err instanceof KeyLoadError) {
1508
+ return { state: "skipped", reason: "key-load", detail: err.message };
1509
+ }
1501
1510
  const message = err instanceof Error ? err.message : String(err);
1502
- return { state: "skipped", detail: `probe error: ${message.slice(0, 100)}` };
1511
+ return { state: "skipped", reason: "probe-failed", detail: `probe error: ${message.slice(0, 100)}` };
1503
1512
  }
1504
1513
  finally {
1505
1514
  // Best-effort cleanup of the ephemeral probe memory.
@@ -1545,6 +1554,11 @@ export async function probeFlairReachable(url, timeoutMs = 2000) {
1545
1554
  * any other status, or a network error/timeout -> "unreachable" (could not
1546
1555
  * verify one way or the other — e.g. a bare 401/403/500 doesn't tell us
1547
1556
  * whether the agent exists, so we don't claim NOT registered on those)
1557
+ * the key file exists but will not load -> "key-unreadable" (flair#1023 —
1558
+ * signing happens strictly before the request, so authFetch can only
1559
+ * raise KeyLoadError while the instance is still untouched. This USED to
1560
+ * land in the catch below and be reported as "instance unreachable",
1561
+ * which doctor printed directly beneath its own "Harper responding" tick)
1548
1562
  * no local key found for agentId (checked resolveKeyPath, then keysDir) -> "no-key"
1549
1563
  * (can't sign the request at all — distinct from "unreachable" so the
1550
1564
  * caller can print an accurate reason)
@@ -1591,6 +1605,13 @@ export async function checkAgentRegistered(baseUrl, agentId, keysDir) {
1591
1605
  return { state: "unreachable", detail: `HTTP ${res.status} ${text.slice(0, 80)}` };
1592
1606
  }
1593
1607
  catch (err) {
1608
+ // flair#1023: a key that will not load is NOT a reachability fact. It is
1609
+ // raised before the request is sent, so reporting it as "unreachable"
1610
+ // sends the operator to firewalls and ports for a problem that is on
1611
+ // their own disk.
1612
+ if (err instanceof KeyLoadError) {
1613
+ return { state: "key-unreadable", detail: err.message };
1614
+ }
1594
1615
  const message = err instanceof Error ? err.message : String(err);
1595
1616
  return { state: "unreachable", detail: `instance unreachable: ${message.slice(0, 100)}` };
1596
1617
  }
@@ -1599,6 +1620,21 @@ export async function checkAgentRegistered(baseUrl, agentId, keysDir) {
1599
1620
  // Used during restart to confirm the old Harper process actually exited before
1600
1621
  // we start polling /Health — otherwise the still-shutting-down old process can
1601
1622
  // answer and we'd declare restart success while a gap is still ahead.
1623
+ /**
1624
+ * Is `pid` a process that exists right now? Signal 0 performs the permission
1625
+ * and existence checks without delivering anything (flair#1022) — a `hdb.pid`
1626
+ * left behind by a process that is gone is not evidence about a running
1627
+ * instance, and treating it as such produces confident wrong answers.
1628
+ */
1629
+ function isProcessAlive(pid) {
1630
+ try {
1631
+ process.kill(pid, 0);
1632
+ return true;
1633
+ }
1634
+ catch {
1635
+ return false;
1636
+ }
1637
+ }
1602
1638
  async function waitForProcessExit(pid, timeoutMs) {
1603
1639
  const deadline = Date.now() + timeoutMs;
1604
1640
  while (Date.now() < deadline) {
@@ -1751,7 +1787,35 @@ export async function callOpsApi(opsUrl, body, user, pass) {
1751
1787
  }
1752
1788
  return res.json();
1753
1789
  }
1754
- export async function buildDeployTarball(projectRoot, flairAdminPass) {
1790
+ /**
1791
+ * Build the component tarball `flair init --remote` uploads via the ops API.
1792
+ *
1793
+ * ── What was wrong here (flair#1005 item 2) ─────────────────────────────────
1794
+ * This function wrote a `.env` into its temp directory and then packed an
1795
+ * EXPLICIT entries list that did not contain it, so the file was discarded with
1796
+ * the temp directory on every call. It had been that way since the writer landed:
1797
+ * a writer whose output nothing consumed. `.env` is now in the list, which is the
1798
+ * whole of the fix — and `init-remote-ops.test.ts` asserts the entry is present
1799
+ * in a real tarball, because "the file was written" was never evidence of
1800
+ * anything.
1801
+ *
1802
+ * ── Why `publicUrl` replaced the password parameter (flair#1011) ────────────
1803
+ * The discarded file assigned `HDB_ADMIN_PASSWORD` and `FLAIR_ADMIN_PASSWORD`.
1804
+ * Shipping it as-is would have made a latent hazard live, for two independent
1805
+ * reasons: Harper composes its own configuration before a component's `.env`
1806
+ * loads, so `HDB_ADMIN_PASSWORD` set this way is a credential Harper is
1807
+ * structurally unable to honour while flair reads it — two sources, one name,
1808
+ * nothing comparing them; and the payload is ingested into Harper's
1809
+ * `hdb_deployment` record, which is replicated to every node and retained for
1810
+ * rollback, so anything in it is persisted cluster-wide.
1811
+ *
1812
+ * The parameter is REMOVED rather than validated. A caller cannot pass a password
1813
+ * to a function that has nowhere to put one, and no future edit can reintroduce
1814
+ * one without also reintroducing the parameter. The admin credential still
1815
+ * reaches the instance the way it always actually did — `add_user`/`alter_user`
1816
+ * over the ops API in `provisionFabric`.
1817
+ */
1818
+ export async function buildDeployTarball(projectRoot, publicUrl) {
1755
1819
  const tmpDir = mkdtempSync(join(tmpdir(), "flair-deploy-"));
1756
1820
  try {
1757
1821
  // Copy deployment files into temp directory
@@ -1765,13 +1829,22 @@ export async function buildDeployTarball(projectRoot, flairAdminPass) {
1765
1829
  cpSync(src, dst, { recursive: true });
1766
1830
  }
1767
1831
  }
1768
- // Write .env with 600 permissions
1769
- const envContent = [
1770
- `HDB_ADMIN_PASSWORD=${flairAdminPass}`,
1771
- `FLAIR_ADMIN_PASSWORD=${flairAdminPass}`,
1772
- "",
1773
- ].join("\n");
1774
- writeFileSync(join(tmpDir, ".env"), envContent, { mode: 0o600 });
1832
+ // The component's environment. Harper reads this file only because
1833
+ // config.yaml declares its `loadEnv` plugin (flair#1010) — without that
1834
+ // declaration the file is present and inert, which is what made flair#1000
1835
+ // hard to see. An existing `.env` in the project root is merged, never
1836
+ // replaced; planComponentEnv keeps an operator's own value for the key.
1837
+ const existingEnvPath = join(projectRoot, COMPONENT_ENV_FILENAME);
1838
+ const existingEnv = existsSync(existingEnvPath) ? readFileSync(existingEnvPath, "utf8") : null;
1839
+ const plan = planComponentEnv(existingEnv, publicUrl);
1840
+ for (const notice of plan.notices)
1841
+ console.warn(`⚠ flair init --remote: ${notice}`);
1842
+ const envText = plan.text ?? existingEnv;
1843
+ if (envText !== null) {
1844
+ assertNoSecretKeysAdded(existingEnv, envText);
1845
+ writeFileSync(join(tmpDir, COMPONENT_ENV_FILENAME), envText, { mode: 0o600 });
1846
+ entries.push(COMPONENT_ENV_FILENAME);
1847
+ }
1775
1848
  // Build compressed tarball
1776
1849
  const tarballPath = join(tmpDir, "deploy.tar.gz");
1777
1850
  await tarCreate({ gzip: true, cwd: tmpDir, file: tarballPath, portable: true }, entries);
@@ -1809,9 +1882,12 @@ export async function waitForFlairRestart(targetUrl, maxWaitMs = 30_000) {
1809
1882
  }
1810
1883
  export async function provisionFabric(target, opsTarget, clusterAdminUser, clusterAdminPass, flairAdminPass) {
1811
1884
  const projectRoot = process.cwd();
1812
- // 1. Build and deploy component tarball
1885
+ // 1. Build and deploy component tarball. `target` is the served URL this
1886
+ // function verifies against in step 2 — the same value the component must
1887
+ // advertise in OAuth/A2A discovery, so it is what FLAIR_PUBLIC_URL is set from
1888
+ // (flair#1005). A loopback target supplies nothing: see resolveDeployPublicUrl.
1813
1889
  console.log("Building deploy tarball...");
1814
- const { tarballB64 } = await buildDeployTarball(projectRoot, flairAdminPass);
1890
+ const { tarballB64 } = await buildDeployTarball(projectRoot, resolveDeployPublicUrl(target));
1815
1891
  console.log("Deploying via ops API...");
1816
1892
  await callOpsApi(opsTarget, {
1817
1893
  operation: "deploy_component",
@@ -3961,7 +4037,13 @@ export async function classifyKeysDir(keysDir, baseUrl) {
3961
4037
  entries: [],
3962
4038
  };
3963
4039
  }
3964
- const decision = classifyKeyFile(c.agentId, true, { state: reg.state, detail: reg.detail }, baseUrl);
4040
+ // flair#1023 added "key-unreadable". It cannot occur here this key's
4041
+ // seed already parsed via isValidPrivateKeySeedFile above — but is
4042
+ // handled explicitly rather than folded into the else: a key that will
4043
+ // not load means exactly what prune already calls "invalid".
4044
+ const decision = reg.state === "key-unreadable"
4045
+ ? classifyKeyFile(c.agentId, false, null, baseUrl)
4046
+ : classifyKeyFile(c.agentId, true, { state: reg.state, detail: reg.detail }, baseUrl);
3965
4047
  entries.push({ name: c.name, class: decision.class, reason: decision.reason, agentId: c.agentId });
3966
4048
  }
3967
4049
  return { aborted: false, entries };
@@ -4162,6 +4244,14 @@ hook
4162
4244
  console.log(` ${status.correctShape ? render.icons.ok : render.icons.warn} wired${status.correctShape ? "" : " (unexpected shape — was it hand-edited?)"}`);
4163
4245
  console.log(` ${render.wrap(render.c.dim, "Agent:")} ${status.agentId ?? render.wrap(render.c.dim, "(unknown — could not parse command)")}`);
4164
4246
  console.log(` ${render.wrap(render.c.dim, "Flair URL:")} ${status.flairUrl ?? render.wrap(render.c.dim, "(unknown — could not parse command)")}`);
4247
+ // flair#1007 — whether a command that stopped resolving would fail quietly
4248
+ // or print an error on every session start.
4249
+ if (status.silenced) {
4250
+ console.log(` ${render.wrap(render.c.dim, "On failure:")} silent (exit 0, no output)`);
4251
+ }
4252
+ else {
4253
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`flair hook install\` to adopt the silent form`);
4254
+ }
4165
4255
  console.log("");
4166
4256
  });
4167
4257
  // ─── flair mcp ───────────────────────────────────────────────────────────────
@@ -9611,8 +9701,28 @@ program
9611
9701
  // The delegated `flair restart` printed its own success line; don't say it twice.
9612
9702
  if (!restartWasDelegated)
9613
9703
  console.log("✅ Flair restarted");
9704
+ // flair#1022 — the headline defect. The restart above is allowed to fall
9705
+ // back off launchd to a plain detached spawn, and SHOULD be: a running
9706
+ // instance beats a down one. What was missing is that the fallback changes
9707
+ // whether anything brings this instance back after a reboot, and the
9708
+ // verification below made no claim about it. `healthy, authenticated,
9709
+ // running <new version>` was every word true of an instance that had just
9710
+ // been orphaned.
9711
+ //
9712
+ // Observed here rather than reported by the restart, because
9713
+ // `restartAfterUpgrade` may have delegated to the newly installed CLI in a
9714
+ // CHILD PROCESS (flair#905) — no in-process flag crosses that boundary.
9715
+ // Asking launchd is the one form of this check that is correct on both
9716
+ // paths.
9717
+ const management = observeLaunchdManagement(upgradeDataDir, port);
9718
+ const detached = isDetached(management);
9614
9719
  if (!shouldVerify) {
9615
9720
  console.log(" (--no-verify: skipping post-restart verification)");
9721
+ if (detached) {
9722
+ for (const line of renderDetachedWarning(management, "Flair is running, but NOT under launchd.")) {
9723
+ console.error(line);
9724
+ }
9725
+ }
9616
9726
  return;
9617
9727
  }
9618
9728
  console.log("\nVerifying...");
@@ -9629,7 +9739,20 @@ program
9629
9739
  });
9630
9740
  const verdict = decideAfterVerify(verify, previousFlairVersion);
9631
9741
  if (verdict.kind === "ok") {
9632
- console.log(`✅ verified: healthy, authenticated${verify.version ? `, running ${verify.version}` : ""}`);
9742
+ // flair#1022: the verified facts are unchanged and still stated — the
9743
+ // upgrade did land. What changes is the MARKER and the claim around it.
9744
+ // A run that ended up outside its process manager has not fully
9745
+ // succeeded, so it does not get a ✅, and the line names the property
9746
+ // that is wrong rather than only the ones that are right. The choice
9747
+ // lives in renderVerifiedSummary so it is testable without performing an
9748
+ // upgrade — no CI lane runs this darwin path.
9749
+ const summary = renderVerifiedSummary(verify.version, management);
9750
+ for (const line of summary.lines) {
9751
+ if (summary.degraded)
9752
+ console.error(line);
9753
+ else
9754
+ console.log(line);
9755
+ }
9633
9756
  return;
9634
9757
  }
9635
9758
  // flair#741 follow-through: a healthy instance the verifier just couldn't
@@ -9641,10 +9764,21 @@ program
9641
9764
  // "print an honest note but roll back anyway" branch that used to sit below
9642
9765
  // is gone — that credentials case can no longer reach the rollback path.)
9643
9766
  if (verdict.kind === "healthy-unverified") {
9644
- console.log(`✅ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}.`);
9767
+ // flair#1022: same rule as the "ok" branch above the is withheld
9768
+ // when the run left the instance outside launchd, and the reason is
9769
+ // named. This branch already qualifies the version claim; the process
9770
+ // manager is a second, independent qualification.
9771
+ console.log(detached
9772
+ ? `⚠️ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}, but NOT under launchd.`
9773
+ : `✅ upgrade complete: the instance is up and healthy${expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : ""}.`);
9645
9774
  console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
9646
9775
  console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
9647
9776
  console.log(" To enable full post-upgrade verification: set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key.");
9777
+ if (detached) {
9778
+ for (const line of renderDetachedWarning(management, "The instance is NOT running under launchd.")) {
9779
+ console.error(line);
9780
+ }
9781
+ }
9648
9782
  return;
9649
9783
  }
9650
9784
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
@@ -9755,6 +9889,13 @@ program
9755
9889
  const { plistPath } = resolveLaunchdLabel(dataDir);
9756
9890
  if (existsSync(plistPath)) {
9757
9891
  try {
9892
+ // flair#1022, same pre-flight as startFlairProcess: launchctl exits 0
9893
+ // for a job it cannot exec, so a stale plist is only ever observable
9894
+ // as a startup timeout unless the paths are checked first.
9895
+ const stalePlist = diagnoseLaunchdPlistPaths(plistPath);
9896
+ if (stalePlist) {
9897
+ throw new Error(`${stalePlist.message} Fix it with: ${stalePlist.remedy.join(" && ")}`);
9898
+ }
9758
9899
  const { execSync } = await import("node:child_process");
9759
9900
  const { label, migrated } = ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
9760
9901
  if (migrated)
@@ -9904,6 +10045,71 @@ function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
9904
10045
  + `Pass --port with the port ${resolve(dataDir)} actually serves, `
9905
10046
  + `or stop the process manually.`);
9906
10047
  }
10048
+ // ─── "is it still under launchd?" (flair#1022) ─────────────────────────────
10049
+ //
10050
+ // The pure logic lives in src/lib/launchd-management.ts; these two adapters
10051
+ // are the only places that talk to real launchd or the real filesystem, so a
10052
+ // test can exercise every branch above without either.
10053
+ /** `launchctl list <label>`, capped so an unreachable launchd cannot hang the CLI. */
10054
+ const realLaunchctlLister = (label) => {
10055
+ const res = spawnSync("launchctl", ["list", label], {
10056
+ encoding: "utf-8",
10057
+ timeout: LAUNCHCTL_QUERY_TIMEOUT_MS,
10058
+ });
10059
+ return { code: res.status, stdout: res.stdout ?? "" };
10060
+ };
10061
+ /**
10062
+ * Which process is actually serving `dataDir` — Harper's own `hdb.pid` first,
10063
+ * then the listener on `port`.
10064
+ *
10065
+ * `hdb.pid` is written by the Harper process itself on every boot regardless
10066
+ * of who spawned it, which is exactly the property this needs: it is the same
10067
+ * number on the launchd path and on the direct-spawn fallback, so comparing it
10068
+ * against launchd's reported PID is a real comparison rather than a proxy.
10069
+ * The port listener is the backstop for an install whose PID file is missing;
10070
+ * `null` (neither available) is handled by the caller as "no evidence", never
10071
+ * as "detached".
10072
+ */
10073
+ function resolveInstanceServingPid(dataDir, port) {
10074
+ let listeningPids = [];
10075
+ try {
10076
+ listeningPids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
10077
+ }
10078
+ catch { /* lsof unavailable — the PID file may still answer */ }
10079
+ return pickInstancePid({
10080
+ pidFilePid: readHarperPid(dataDir),
10081
+ isAlive: isProcessAlive,
10082
+ listeningPids,
10083
+ });
10084
+ }
10085
+ /**
10086
+ * Observe whether `dataDir`'s instance is running under launchd right now.
10087
+ *
10088
+ * Called AFTER a restart completes, by both `flair restart` and `flair
10089
+ * upgrade` — see the module header for why this is an observation rather than
10090
+ * a flag carried out of `startFlairProcess` (the upgrade's restart may happen
10091
+ * in a child process, so no in-process flag survives).
10092
+ */
10093
+ function observeLaunchdManagement(dataDir, port) {
10094
+ // Answered without touching the filesystem or lsof off darwin — this runs on
10095
+ // the success path of every restart and upgrade, including Linux's, where
10096
+ // there is no launchd to have fallen back from.
10097
+ if (process.platform !== "darwin") {
10098
+ return { state: "not-applicable", detail: `${process.platform} does not use launchd` };
10099
+ }
10100
+ const { label, plistPath } = resolveLaunchdLabel(dataDir);
10101
+ if (!existsSync(plistPath)) {
10102
+ return { state: "no-service", detail: `no launchd service is registered for this instance (${plistPath})` };
10103
+ }
10104
+ return assessLaunchdManagement({
10105
+ platform: process.platform,
10106
+ label,
10107
+ plistPath,
10108
+ instancePid: resolveInstanceServingPid(dataDir, port),
10109
+ plistExists: existsSync,
10110
+ list: realLaunchctlLister,
10111
+ });
10112
+ }
9907
10113
  /**
9908
10114
  * Stop the local Flair (Harper) process — launchd `stop` on darwin when a
9909
10115
  * plist is present (falling back on failure), otherwise a manual SIGTERM by
@@ -9947,12 +10153,48 @@ async function stopFlairProcess(port, dataDir) {
9947
10153
  // can race against the still-shutting-down old process and return
9948
10154
  // success before the new one comes up.
9949
10155
  const oldPid = readHarperPid(dataDir);
10156
+ // flair#1022: ask launchd whether the process we are about to wait on
10157
+ // is even its job's, BEFORE unloading. When the instance is already
10158
+ // running outside launchd — the state a previous fallback leaves
10159
+ // behind, and the state a stale plist guarantees — the unload has
10160
+ // nothing to signal, so waiting on `oldPid` burns the FULL startup
10161
+ // budget and then reports the meaningless
10162
+ // "Process <pid> did not exit within 60000ms". That was the first of
10163
+ // the reported incident's two 60-second hangs. The unload still runs
10164
+ // (a loaded-but-broken job must not be left able to respawn); only the
10165
+ // wait is skipped, and the fallback is entered immediately with a
10166
+ // reason that names the real condition.
10167
+ //
10168
+ // Gated on a LIVE recorded PID, and that gate is load-bearing: with no
10169
+ // running process there is nothing to wait for and nothing to
10170
+ // reattribute, and `stopFlairProcess` is documented as a harmless
10171
+ // no-op when the instance is already stopped. Without the gate, an
10172
+ // already-stopped instance takes the port fallback, which refuses when
10173
+ // it cannot attribute a listener (flair#915) — turning an idempotent
10174
+ // stop into a failed restart. Caught by the flair#902/#914 suites.
10175
+ //
10176
+ // Asked BEFORE the unload, because after it launchd no longer knows
10177
+ // the label at all and every answer would be "detached".
10178
+ const managed = oldPid !== null && isProcessAlive(oldPid)
10179
+ ? assessLaunchdManagement({
10180
+ platform: process.platform,
10181
+ label,
10182
+ plistPath,
10183
+ instancePid: oldPid,
10184
+ plistExists: existsSync,
10185
+ list: realLaunchctlLister,
10186
+ })
10187
+ : null;
9950
10188
  // unload stops the job AND prevents KeepAlive from respawning it.
9951
10189
  // launchctl stop alone is insufficient for a KeepAlive job (flair#874).
9952
10190
  try {
9953
10191
  execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
9954
10192
  }
9955
10193
  catch { }
10194
+ if (managed && isDetached(managed)) {
10195
+ throw new Error(`launchd is not running this instance — ${managed.detail}`
10196
+ + `${managed.remedy?.length ? ` Fix it with: ${managed.remedy.join(" && ")}` : ""}`);
10197
+ }
9956
10198
  if (oldPid)
9957
10199
  await waitForProcessExit(oldPid, STARTUP_TIMEOUT_MS);
9958
10200
  return;
@@ -10014,6 +10256,21 @@ async function startFlairProcess(port, dataDir) {
10014
10256
  // success.
10015
10257
  assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "start");
10016
10258
  try {
10259
+ // flair#1022: launchd will not tell us it cannot exec the job.
10260
+ // `launchctl load` and `launchctl start` BOTH exit 0 for a plist whose
10261
+ // ProgramArguments[0] does not exist (measured, see the module header),
10262
+ // so the only way this loop learns anything is by waiting the full
10263
+ // startup budget for a port that will never open — the reported
10264
+ // incident's second 60-second hang, ending in "did not respond within
10265
+ // 60000ms (120 attempts)", an error about a port that says nothing
10266
+ // about the cause. The paths in the plist are absolute and checkable
10267
+ // with an existsSync, so check them first and turn a two-minute silence
10268
+ // into an immediate, named diagnosis. Still falls back — a running
10269
+ // instance beats a down one — just without the wait or the mystery.
10270
+ const stalePlist = diagnoseLaunchdPlistPaths(plistPath);
10271
+ if (stalePlist) {
10272
+ throw new Error(`${stalePlist.message} Fix it with: ${stalePlist.remedy.join(" && ")}`);
10273
+ }
10017
10274
  const { execSync } = await import("node:child_process");
10018
10275
  ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
10019
10276
  await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
@@ -10190,6 +10447,18 @@ program
10190
10447
  // and saying so here is what keeps that true when someone adds one.
10191
10448
  try {
10192
10449
  await restartFlair(port, defaultDataDir());
10450
+ // flair#1022: a restart that fell back off launchd left the instance
10451
+ // running but unmanaged, and "✅ Flair restarted" was true of both
10452
+ // outcomes. Ask launchd what it is actually running now — an
10453
+ // observation, not a flag out of the restart, so it is right even when
10454
+ // the detachment predates this command.
10455
+ const managed = observeLaunchdManagement(defaultDataDir(), port);
10456
+ if (isDetached(managed)) {
10457
+ for (const line of renderDetachedWarning(managed, "Flair restarted, but it is NOT running under launchd.")) {
10458
+ console.error(line);
10459
+ }
10460
+ return;
10461
+ }
10193
10462
  console.log("✅ Flair restarted");
10194
10463
  }
10195
10464
  catch (err) {
@@ -11229,6 +11498,54 @@ program
11229
11498
  }
11230
11499
  }
11231
11500
  catch { /* best-effort — a stat failure shouldn't fail doctor */ }
11501
+ // 3d. The URL this instance tells the world to use (flair#1005, flair#1000).
11502
+ //
11503
+ // Asks the instance for its OWN discovery document rather than inferring
11504
+ // anything from config: /OAuthMetadata's `issuer` is the exact field that was
11505
+ // wrong in flair#1000, and it is the only thing that proves what a client
11506
+ // will actually be handed. describePublicUrlFinding (src/component-env.ts) is
11507
+ // pure decision logic, unit-tested, and documents in its own header why the
11508
+ // detectable condition is DRIFT rather than "unset on a public instance" —
11509
+ // doctor reaches this instance over loopback and cannot observe whether it is
11510
+ // also reachable at a public address.
11511
+ if (harperResponding) {
11512
+ let advertisedIssuer = null;
11513
+ try {
11514
+ const res = await fetch(`${baseUrl}/OAuthMetadata`, { signal: AbortSignal.timeout(5000) });
11515
+ if (res.ok) {
11516
+ const doc = (await res.json());
11517
+ if (typeof doc?.issuer === "string" && doc.issuer !== "")
11518
+ advertisedIssuer = doc.issuer;
11519
+ }
11520
+ }
11521
+ catch { /* unreachable/unparseable → null → the finding is skipped, not passed */ }
11522
+ // The component directory for a local install is the flair package itself:
11523
+ // `flair start` spawns `harper run .` with cwd = flairPackageDir().
11524
+ const componentEnvPath = join(flairPackageDir(), COMPONENT_ENV_FILENAME);
11525
+ let componentEnvValue = null;
11526
+ try {
11527
+ if (existsSync(componentEnvPath)) {
11528
+ componentEnvValue = readEnvValue(readFileSync(componentEnvPath, "utf-8"), PUBLIC_URL_KEY);
11529
+ }
11530
+ }
11531
+ catch { /* unreadable → treat as absent */ }
11532
+ const finding = describePublicUrlFinding({
11533
+ advertisedIssuer,
11534
+ componentEnvValue,
11535
+ processEnvValue: process.env.FLAIR_PUBLIC_URL ?? null,
11536
+ componentEnvPath,
11537
+ });
11538
+ if (finding) {
11539
+ const icon = finding.icon === "ok" ? render.icons.ok
11540
+ : finding.icon === "warn" ? render.icons.warn
11541
+ : render.icons.error;
11542
+ console.log(` ${icon} ${finding.message}`);
11543
+ if (finding.fixHint)
11544
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${finding.fixHint}`);
11545
+ if (finding.isIssue)
11546
+ issues++;
11547
+ }
11548
+ }
11232
11549
  // 4. Embeddings check — REAL semantic round-trip (only if Harper is responding).
11233
11550
  //
11234
11551
  // The dead-simple `{ q: "test" }` probe used to pass even when embeddings were
@@ -11253,13 +11570,21 @@ program
11253
11570
  console.log(` ${render.wrap(render.c.dim, "See:")} docs/troubleshooting.md ${render.wrap(render.c.dim, "→ \"Semantic search DEGRADED\"")}`);
11254
11571
  issues++;
11255
11572
  break;
11256
- case "skipped":
11257
- // Could not run the round-trip (no agent / no key). Don't claim
11258
- // all-clear — surface that the check was skipped, but don't count it
11259
- // as a hard issue since the user may simply not have an agent yet.
11573
+ case "skipped": {
11574
+ // Could not run the round-trip. Don't claim all-clear — surface that
11575
+ // the check was skipped, but don't count it as a hard issue since
11576
+ // the user may simply not have an agent yet.
11577
+ //
11578
+ // flair#1023: the remedy is chosen from the classified reason
11579
+ // (embeddingsSkipRemedy, src/doctor-client.ts) instead of being
11580
+ // printed unconditionally. A key that will not decode gets no
11581
+ // "pass --agent" advice, because following it changes nothing.
11260
11582
  console.log(` ${render.icons.warn} Embeddings: not verified ${render.wrap(render.c.dim, `(${semanticStatus.detail})`)}`);
11261
- console.log(` ${render.wrap(render.c.dim, "Pass --agent <id> (or set FLAIR_AGENT_ID) so doctor can run a real semantic round-trip.")}`);
11583
+ const remedy = embeddingsSkipRemedy(semanticStatus.reason);
11584
+ if (remedy)
11585
+ console.log(` ${render.wrap(render.c.dim, remedy)}`);
11262
11586
  break;
11587
+ }
11263
11588
  }
11264
11589
  }
11265
11590
  // 5. Stale PID file (skip if already reported in port check)
@@ -11418,7 +11743,11 @@ program
11418
11743
  issues++;
11419
11744
  }
11420
11745
  else {
11421
- console.log(` ${render.icons.warn} could not verify agent registration ${render.wrap(render.c.dim, `(${reg.detail})`)}`);
11746
+ // flair#1023: `reachable` was just established two lines above, so
11747
+ // reuse the same self-inconsistency guard the agent gates use
11748
+ // rather than echoing a detail that may claim the opposite.
11749
+ const finding = describeAgentGateFinding(block.agentId, reg.state, reg.detail, { instanceReachable: reachable });
11750
+ console.log(` ${render.icons.warn} ${finding?.message ?? `could not verify agent registration (${reg.detail})`}`);
11422
11751
  }
11423
11752
  }
11424
11753
  // Claude-Code-specific: CLAUDE.md + SessionStart hook. Only Claude Code
@@ -11452,9 +11781,75 @@ program
11452
11781
  }
11453
11782
  issues++;
11454
11783
  }
11455
- const hook = checkSessionStartHook(homedir());
11784
+ // flair#1007: presence was never the problem — the failing entry was
11785
+ // perfectly well-formed. inspectSessionStartHook() additionally RUNS
11786
+ // the registered command (bounded, side-effect-free via
11787
+ // FLAIR_HOOK_PROBE) so doctor can tell "wired" from "wired and still
11788
+ // works", and reports the shell-level silencing separately so an
11789
+ // already-installed loud hook can be upgraded rather than only
11790
+ // diagnosed.
11791
+ const hook = inspectSessionStartHook(homedir());
11456
11792
  if (hook.present) {
11457
- console.log(` ${render.icons.ok} SessionStart hook: flair-session-start wired in ${render.wrap(render.c.dim, hook.path)}`);
11793
+ if (hook.execution === "broken") {
11794
+ // Reported in full, with the remedy — but NOT counted as an issue,
11795
+ // so it never flips doctor's exit code on its own. This is a
11796
+ // verification of the environment at the moment doctor runs (a
11797
+ // cold `npx` cache, an offline machine, a slow registry), exactly
11798
+ // like the FLAIR_URL reachability and agent-registration
11799
+ // verifications above, which are warnings for the same reason. A
11800
+ // fresh, correct install on a machine that simply has not fetched
11801
+ // the adapter yet must not be told it is broken in the exit code.
11802
+ // The unsilenced finding below IS counted: that one is a fact
11803
+ // about the file, true regardless of the environment.
11804
+ console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
11805
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
11806
+ console.log(` ${render.wrap(render.c.dim, "If this persists, the Node runtime probably changed and the globally")}`);
11807
+ console.log(` ${render.wrap(render.c.dim, "installed @tpsdev-ai/flair-mcp no longer resolves for it.")}`);
11808
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} npm install -g @tpsdev-ai/flair-mcp ${render.wrap(render.c.dim, "(reinstall for the runtime you use now)")}`);
11809
+ console.log(` ${render.wrap(render.c.dim, "Or, if you no longer want ambient memory:")} flair hook uninstall`);
11810
+ }
11811
+ else if (hook.execution === "unknown") {
11812
+ console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but could not be verified ${render.wrap(render.c.dim, `(${hook.detail ?? "no detail"})`)}`);
11813
+ }
11814
+ else if (!hook.ours) {
11815
+ console.log(` ${render.icons.ok} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "(custom command — not verified, not modified)")}`);
11816
+ }
11817
+ else {
11818
+ console.log(` ${render.icons.ok} SessionStart hook: flair-session-start wired in ${render.wrap(render.c.dim, hook.path)} ${render.wrap(render.c.dim, "and still runs")}`);
11819
+ }
11820
+ // Independent of whether it runs today: would it stay quiet if it
11821
+ // stopped? Only offered as a repair when the command is the exact
11822
+ // string Flair itself wrote — a hand-edited or pinned hook is the
11823
+ // user's, and doctor reports on it rather than rewriting it.
11824
+ if (!hook.silenced && hook.ours) {
11825
+ console.log(` ${render.icons.warn} SessionStart hook: a failure would print an error on every session (this command predates the silent-failure fix)`);
11826
+ if (hook.upgradable) {
11827
+ if (autoFix) {
11828
+ if (dryRun) {
11829
+ console.log(` ${render.wrap(render.c.dim, "Would rewrite the hook command in")} ${hook.path}`);
11830
+ }
11831
+ else {
11832
+ const proceed = await confirmFix(` Rewrite the Flair SessionStart hook in ${hook.path} so failures stay silent? [y/N] `);
11833
+ if (!proceed) {
11834
+ console.log(` Skipped.`);
11835
+ }
11836
+ else {
11837
+ const upgrade = upgradeSessionStartHookCommand(homedir());
11838
+ console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
11839
+ if (upgrade.ok && upgrade.changed)
11840
+ fixed++;
11841
+ }
11842
+ }
11843
+ }
11844
+ else {
11845
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites the hook command in place — same agent, same instance)")}`);
11846
+ }
11847
+ }
11848
+ else {
11849
+ console.log(` ${render.wrap(render.c.dim, "This hook was hand-edited, so Flair will not rewrite it. To adopt the current form:")} flair hook install`);
11850
+ }
11851
+ issues++;
11852
+ }
11458
11853
  }
11459
11854
  else {
11460
11855
  console.log(` ${render.icons.error} SessionStart hook: not found in ${render.wrap(render.c.dim, hook.path)}`);
@@ -11511,7 +11906,10 @@ program
11511
11906
  for (const id of verifiedReadAgentIds) {
11512
11907
  const reg = await checkAgentRegistered(baseUrl, id, defaultKeysDir());
11513
11908
  agentGates.push({ id, state: reg.state, detail: reg.detail });
11514
- const finding = describeAgentGateFinding(id, reg.state, reg.detail);
11909
+ // harperResponding is necessarily true here (verifiedReadAgentIds is
11910
+ // empty otherwise), so an "unreachable" verdict from this loop is
11911
+ // always a self-contradiction — flair#1023. Hand the guard the fact.
11912
+ const finding = describeAgentGateFinding(id, reg.state, reg.detail, { instanceReachable: harperResponding });
11515
11913
  if (finding?.isIssue)
11516
11914
  issues++;
11517
11915
  }
@@ -11522,7 +11920,7 @@ program
11522
11920
  // agent and moves on to the next (failure isolation).
11523
11921
  function renderAgentGateHeader(gate) {
11524
11922
  console.log(` ${render.wrap(render.c.dim, `Agent: ${gate.id}`)}`);
11525
- const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail);
11923
+ const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail, { instanceReachable: harperResponding });
11526
11924
  if (!finding)
11527
11925
  return true;
11528
11926
  const icon = finding.icon === "error" ? render.icons.error : render.icons.warn;
@@ -11710,7 +12108,7 @@ program
11710
12108
  console.log(` ${render.icons.info} Pass --agent <id> (with a matching key in ~/.flair/keys) to see migration state — requires a verified read, same as Fleet presence above.`);
11711
12109
  }
11712
12110
  else {
11713
- const passedGates = agentGates.filter((g) => describeAgentGateFinding(g.id, g.state, g.detail) === null);
12111
+ const passedGates = agentGates.filter((g) => describeAgentGateFinding(g.id, g.state, g.detail, { instanceReachable: harperResponding }) === null);
11714
12112
  for (const gate of passedGates) {
11715
12113
  renderAgentGateHeader(gate);
11716
12114
  const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
@@ -15369,4 +15767,6 @@ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBi
15369
15767
  // Harper's own config — the per-instance port record (flair#914)
15370
15768
  harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
15371
15769
  // launchd label (flair#693)
15372
- LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };
15770
+ LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded,
15771
+ // launchd management observation (flair#1022)
15772
+ observeLaunchdManagement, resolveInstanceServingPid, };