@tpsdev-ai/flair 0.32.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/README.md +64 -64
- package/SECURITY.md +7 -0
- package/config.yaml +34 -0
- package/dist/cli.js +659 -111
- package/dist/component-env.js +286 -0
- package/dist/deploy.js +190 -3
- package/dist/doctor-client.js +357 -7
- package/dist/hook-install.js +39 -9
- package/dist/lib/auth-resolve.js +85 -2
- package/dist/lib/launchd-management.js +328 -0
- package/dist/lib/mcp-enable.js +19 -0
- package/dist/resources/AdminInstance.js +20 -2
- package/dist/resources/Memory.js +24 -2
- package/dist/resources/OAuth.js +41 -25
- package/dist/resources/auth-middleware.js +26 -0
- package/dist/resources/dcr-gate.js +194 -0
- package/dist/resources/in-process-api.js +5 -1
- package/dist/resources/mcp-handler.js +91 -4
- package/dist/resources/mcp-oauth.js +89 -7
- package/dist/resources/mcp-tools.js +40 -0
- package/dist/resources/oauth-discovery.js +242 -0
- package/dist/resources/oauth-wellknown.js +111 -0
- package/dist/resources/rate-limit.js +400 -0
- package/docs/auth.md +122 -5
- package/docs/deploying-on-fabric.md +35 -2
- package/docs/deployment.md +1 -1
- package/docs/embedding-in-a-harper-app.md +6 -1
- package/docs/hosted-on-fabric.md +1 -1
- package/docs/mcp-clients.md +28 -4
- package/docs/quickstart.md +29 -4
- package/docs/the-team.md +8 -4
- package/docs/troubleshooting.md +37 -1
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
|
@@ -9684,17 +9818,38 @@ program
|
|
|
9684
9818
|
// PID — see parseListeningPids (flair#800/flair#905): this used to SIGTERM
|
|
9685
9819
|
// every process holding ANY socket on the port, so `flair stop` could kill
|
|
9686
9820
|
// itself (leaving Flair running) or kill an unrelated client of it.
|
|
9821
|
+
//
|
|
9822
|
+
// Attribution guard (flair#915): the port is not an identity. Refuse to
|
|
9823
|
+
// SIGTERM a PID that cannot be attributed to this instance.
|
|
9687
9824
|
try {
|
|
9688
9825
|
const { execSync } = await import("node:child_process");
|
|
9689
9826
|
const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
|
|
9690
9827
|
if (pids.length > 0) {
|
|
9691
|
-
|
|
9692
|
-
|
|
9693
|
-
|
|
9828
|
+
const dataDir = defaultDataDir();
|
|
9829
|
+
const harperPid = readHarperPid(dataDir);
|
|
9830
|
+
if (harperPid !== null && !pids.includes(harperPid)) {
|
|
9831
|
+
console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
|
|
9832
|
+
+ `do not match this Flair instance (PID ${harperPid}). `
|
|
9833
|
+
+ `Not stopping — cannot attribute the process to this instance. `
|
|
9834
|
+
+ `Stop the process manually if it is not Flair.`);
|
|
9835
|
+
process.exit(1);
|
|
9836
|
+
}
|
|
9837
|
+
else if (harperPid === null) {
|
|
9838
|
+
console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
|
|
9839
|
+
+ `but no PID file in data directory — not a running Flair instance. `
|
|
9840
|
+
+ `Not stopping — cannot attribute the process to this instance. `
|
|
9841
|
+
+ `Stop the process manually if it is not Flair.`);
|
|
9842
|
+
process.exit(1);
|
|
9843
|
+
}
|
|
9844
|
+
else {
|
|
9845
|
+
for (const pid of pids) {
|
|
9846
|
+
try {
|
|
9847
|
+
process.kill(pid, "SIGTERM");
|
|
9848
|
+
}
|
|
9849
|
+
catch { /* already gone */ }
|
|
9694
9850
|
}
|
|
9695
|
-
|
|
9851
|
+
console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
|
|
9696
9852
|
}
|
|
9697
|
-
console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
|
|
9698
9853
|
}
|
|
9699
9854
|
else {
|
|
9700
9855
|
console.log("Flair is not running.");
|
|
@@ -9734,6 +9889,13 @@ program
|
|
|
9734
9889
|
const { plistPath } = resolveLaunchdLabel(dataDir);
|
|
9735
9890
|
if (existsSync(plistPath)) {
|
|
9736
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
|
+
}
|
|
9737
9899
|
const { execSync } = await import("node:child_process");
|
|
9738
9900
|
const { label, migrated } = ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
|
|
9739
9901
|
if (migrated)
|
|
@@ -9859,17 +10021,94 @@ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
|
|
|
9859
10021
|
* wrong today whenever the port does not match.
|
|
9860
10022
|
*/
|
|
9861
10023
|
function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
|
|
9862
|
-
|
|
9863
|
-
|
|
10024
|
+
// (flair#915) Apply the attribution check for ALL data directories, not
|
|
10025
|
+
// just non-default ones. The default-dir bypass was the residual gap that
|
|
10026
|
+
// #910 left behind — it allowed an unattributed SIGTERM on the default
|
|
10027
|
+
// install's port. The old concern (false refusal when hdb.pid is missing)
|
|
10028
|
+
// is actually the RIGHT behavior: no PID file means we cannot attribute the
|
|
10029
|
+
// listener, so we refuse. That is safer than killing the wrong process.
|
|
9864
10030
|
const expected = readHarperPid(dataDir);
|
|
9865
|
-
|
|
10031
|
+
// No PID file — Harper is not (or was not) running in this directory.
|
|
10032
|
+
// The port is stale or held by something else; refuse to SIGTERM it.
|
|
10033
|
+
if (expected === null) {
|
|
10034
|
+
throw new Error(`refusing to stop the process listening on port ${port}: no hdb.pid under `
|
|
10035
|
+
+ `${resolve(dataDir)}, so that is not a running instance. `
|
|
10036
|
+
+ `Stopping by port alone would signal a process we cannot attribute. `
|
|
10037
|
+
+ `If it is not Flair, stop it manually.`);
|
|
10038
|
+
}
|
|
10039
|
+
// PID file exists — the PID on the port must be Harper.
|
|
10040
|
+
if (listeningPids.includes(expected))
|
|
9866
10041
|
return;
|
|
9867
|
-
|
|
9868
|
-
|
|
9869
|
-
|
|
9870
|
-
|
|
9871
|
-
`
|
|
9872
|
-
|
|
10042
|
+
throw new Error(`refusing to stop the process listening on port ${port}: its recorded PID ${expected} `
|
|
10043
|
+
+ `is not the process listening on ${port}. `
|
|
10044
|
+
+ `Stopping by port alone would signal a different instance. `
|
|
10045
|
+
+ `Pass --port with the port ${resolve(dataDir)} actually serves, `
|
|
10046
|
+
+ `or stop the process manually.`);
|
|
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
|
+
});
|
|
9873
10112
|
}
|
|
9874
10113
|
/**
|
|
9875
10114
|
* Stop the local Flair (Harper) process — launchd `stop` on darwin when a
|
|
@@ -9914,12 +10153,48 @@ async function stopFlairProcess(port, dataDir) {
|
|
|
9914
10153
|
// can race against the still-shutting-down old process and return
|
|
9915
10154
|
// success before the new one comes up.
|
|
9916
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;
|
|
9917
10188
|
// unload stops the job AND prevents KeepAlive from respawning it.
|
|
9918
10189
|
// launchctl stop alone is insufficient for a KeepAlive job (flair#874).
|
|
9919
10190
|
try {
|
|
9920
10191
|
execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
|
|
9921
10192
|
}
|
|
9922
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
|
+
}
|
|
9923
10198
|
if (oldPid)
|
|
9924
10199
|
await waitForProcessExit(oldPid, STARTUP_TIMEOUT_MS);
|
|
9925
10200
|
return;
|
|
@@ -9981,6 +10256,21 @@ async function startFlairProcess(port, dataDir) {
|
|
|
9981
10256
|
// success.
|
|
9982
10257
|
assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "start");
|
|
9983
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
|
+
}
|
|
9984
10274
|
const { execSync } = await import("node:child_process");
|
|
9985
10275
|
ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
|
|
9986
10276
|
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
@@ -10157,6 +10447,18 @@ program
|
|
|
10157
10447
|
// and saying so here is what keeps that true when someone adds one.
|
|
10158
10448
|
try {
|
|
10159
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
|
+
}
|
|
10160
10462
|
console.log("✅ Flair restarted");
|
|
10161
10463
|
}
|
|
10162
10464
|
catch (err) {
|
|
@@ -10171,7 +10473,11 @@ program
|
|
|
10171
10473
|
.option("--purge", "Also remove data and keys (destructive)")
|
|
10172
10474
|
.action(async (opts) => {
|
|
10173
10475
|
const platform = process.platform;
|
|
10174
|
-
|
|
10476
|
+
// Use the unified resolver: Harper's config > per-user config > default.
|
|
10477
|
+
// A default of 19926 that is "present but wrong" beats the actual port
|
|
10478
|
+
// Harper is serving on (flair#819). resolveHttpPort reads Harper's own
|
|
10479
|
+
// config in the data directory, which is authoritative.
|
|
10480
|
+
const port = resolveHttpPort({}, "address");
|
|
10175
10481
|
// Stop first: remove launchd service(s) on macOS, then kill by port on
|
|
10176
10482
|
// all platforms. Removes BOTH the new instance-scoped plist and a
|
|
10177
10483
|
// pre-flair#693 legacy plist if present — uninstall's job is to purge
|
|
@@ -10198,51 +10504,88 @@ program
|
|
|
10198
10504
|
// Kill any process still on the port (covers direct-start, no-service, or
|
|
10199
10505
|
// failed unload). Listening sockets only, never our own PID — see
|
|
10200
10506
|
// parseListeningPids (flair#800/flair#905).
|
|
10507
|
+
//
|
|
10508
|
+
// Guard (flair#917): refuse to SIGTERM a PID that cannot be attributed to
|
|
10509
|
+
// this Flair instance. A port is not an identity — something else can hold
|
|
10510
|
+
// it. Killing the wrong PID and then purging data is the whole bug.
|
|
10511
|
+
let refusedKill = false;
|
|
10201
10512
|
try {
|
|
10202
10513
|
const { execSync } = await import("node:child_process");
|
|
10203
10514
|
const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
|
|
10204
10515
|
if (pids.length > 0) {
|
|
10205
|
-
|
|
10206
|
-
|
|
10207
|
-
|
|
10516
|
+
// Verify ownership before killing: the PID must match this instance's
|
|
10517
|
+
// recorded PID (hdb.pid). If no PID file exists, Harper is already
|
|
10518
|
+
// stopped and the port is stale — safe to skip.
|
|
10519
|
+
const dataDir = defaultDataDir();
|
|
10520
|
+
const harperPid = readHarperPid(dataDir);
|
|
10521
|
+
if (harperPid !== null) {
|
|
10522
|
+
// PID file exists — the PID on the port must be Harper or we refuse.
|
|
10523
|
+
if (!pids.includes(harperPid)) {
|
|
10524
|
+
console.log(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
|
|
10525
|
+
+ `do not match this Flair instance (PID ${harperPid}). `
|
|
10526
|
+
+ `Not killing — cannot attribute the process to this instance. `
|
|
10527
|
+
+ `Stop the process manually if it is not Flair.`);
|
|
10528
|
+
refusedKill = true;
|
|
10208
10529
|
}
|
|
10209
|
-
|
|
10530
|
+
else {
|
|
10531
|
+
for (const pid of pids) {
|
|
10532
|
+
try {
|
|
10533
|
+
process.kill(pid, "SIGTERM");
|
|
10534
|
+
}
|
|
10535
|
+
catch { }
|
|
10536
|
+
}
|
|
10537
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
10538
|
+
console.log("✅ Flair process stopped");
|
|
10539
|
+
}
|
|
10540
|
+
}
|
|
10541
|
+
else {
|
|
10542
|
+
// No PID file — Harper is not (or was not) running here.
|
|
10543
|
+
// The port may be stale or held by something else; don't risk killing it.
|
|
10544
|
+
console.log(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
|
|
10545
|
+
+ `but no PID file in data directory — not a running Flair instance. `
|
|
10546
|
+
+ `Not killing — stop the process manually if it is not Flair.`);
|
|
10547
|
+
refusedKill = true;
|
|
10210
10548
|
}
|
|
10211
|
-
// Wait for process to release file handles (RocksDB)
|
|
10212
|
-
await new Promise(r => setTimeout(r, 2000));
|
|
10213
|
-
console.log("✅ Flair process stopped");
|
|
10214
10549
|
}
|
|
10215
10550
|
}
|
|
10216
10551
|
catch { /* not running */ }
|
|
10217
|
-
//
|
|
10218
|
-
|
|
10219
|
-
|
|
10220
|
-
|
|
10221
|
-
|
|
10222
|
-
|
|
10552
|
+
// Always remove per-user config on uninstall.
|
|
10553
|
+
{
|
|
10554
|
+
const cfgPath = configPath();
|
|
10555
|
+
if (existsSync(cfgPath)) {
|
|
10556
|
+
const { unlinkSync } = await import("node:fs");
|
|
10557
|
+
unlinkSync(cfgPath);
|
|
10558
|
+
console.log("✅ Config removed");
|
|
10559
|
+
}
|
|
10223
10560
|
}
|
|
10224
10561
|
if (opts.purge) {
|
|
10225
|
-
|
|
10226
|
-
|
|
10227
|
-
|
|
10228
|
-
const flairDir = join(homedir(), ".flair");
|
|
10229
|
-
if (existsSync(dataDir)) {
|
|
10230
|
-
rmSync(dataDir, { recursive: true, force: true });
|
|
10231
|
-
console.log("✅ Data removed: " + dataDir);
|
|
10232
|
-
}
|
|
10233
|
-
if (existsSync(keysDir)) {
|
|
10234
|
-
rmSync(keysDir, { recursive: true, force: true });
|
|
10235
|
-
console.log("✅ Keys removed: " + keysDir);
|
|
10562
|
+
if (refusedKill) {
|
|
10563
|
+
console.log("\n⚠️ Skipping purge: could not attribute the process on port — data preserved.");
|
|
10564
|
+
console.log("Stop the process manually, then re-run: flair uninstall --purge");
|
|
10236
10565
|
}
|
|
10237
|
-
|
|
10238
|
-
|
|
10239
|
-
const
|
|
10240
|
-
|
|
10241
|
-
|
|
10566
|
+
else {
|
|
10567
|
+
const { rmSync } = await import("node:fs");
|
|
10568
|
+
const dataDir = defaultDataDir();
|
|
10569
|
+
const keysDir = defaultKeysDir();
|
|
10570
|
+
const flairDir = join(homedir(), ".flair");
|
|
10571
|
+
if (existsSync(dataDir)) {
|
|
10572
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
10573
|
+
console.log("✅ Data removed: " + dataDir);
|
|
10574
|
+
}
|
|
10575
|
+
if (existsSync(keysDir)) {
|
|
10576
|
+
rmSync(keysDir, { recursive: true, force: true });
|
|
10577
|
+
console.log("✅ Keys removed: " + keysDir);
|
|
10242
10578
|
}
|
|
10579
|
+
// Remove .flair dir if empty
|
|
10580
|
+
try {
|
|
10581
|
+
const { readdirSync, rmdirSync } = await import("node:fs");
|
|
10582
|
+
if (existsSync(flairDir) && readdirSync(flairDir).length === 0) {
|
|
10583
|
+
rmdirSync(flairDir);
|
|
10584
|
+
}
|
|
10585
|
+
}
|
|
10586
|
+
catch { /* non-empty, that's fine */ }
|
|
10587
|
+
console.log("\n🗑️ Flair fully purged");
|
|
10243
10588
|
}
|
|
10244
|
-
catch { /* non-empty, that's fine */ }
|
|
10245
|
-
console.log("\n🗑️ Flair fully purged");
|
|
10246
10589
|
}
|
|
10247
10590
|
else {
|
|
10248
10591
|
console.log("\nData and keys preserved at ~/.flair/");
|
|
@@ -10892,17 +11235,24 @@ program
|
|
|
10892
11235
|
else if (versionCheckResult.latest) {
|
|
10893
11236
|
console.log(` ${render.icons.ok} flair ${__pkgVersion} is current`);
|
|
10894
11237
|
}
|
|
10895
|
-
// Helper: try to reach Harper on a given port
|
|
11238
|
+
// Helper: try to reach Harper on a given port.
|
|
11239
|
+
// Must return true ONLY when Harper's /Health endpoint returns 200 OK.
|
|
11240
|
+
// A generic HTTP status > 0 (flair#862) would accept 404 from a Node
|
|
11241
|
+
// inspector on 9229 or any other service — "present but wrong" beats
|
|
11242
|
+
// "absent but correct".
|
|
10896
11243
|
async function probePort(p) {
|
|
10897
11244
|
try {
|
|
10898
11245
|
const res = await fetch(`http://127.0.0.1:${p}/Health`, { signal: AbortSignal.timeout(3000) });
|
|
10899
|
-
return res.
|
|
11246
|
+
return res.ok; // 200-299 only — /Health returns { ok: true } on 200
|
|
10900
11247
|
}
|
|
10901
11248
|
catch {
|
|
10902
11249
|
return false;
|
|
10903
11250
|
}
|
|
10904
11251
|
}
|
|
10905
|
-
// Helper: discover what port a Harper PID is listening on
|
|
11252
|
+
// Helper: discover what port a Harper PID is listening on.
|
|
11253
|
+
// Scans ALL listening ports for this PID and returns the first one that
|
|
11254
|
+
// responds to /Health with 200 OK. This avoids picking a debug port (9229)
|
|
11255
|
+
// or any non-Flair listener that happens to share the process (flair#862).
|
|
10906
11256
|
async function discoverPortFromPid(pid) {
|
|
10907
11257
|
// Defense-in-depth: caller already validates, but re-check here
|
|
10908
11258
|
if (!/^\d+$/.test(pid))
|
|
@@ -10910,9 +11260,16 @@ program
|
|
|
10910
11260
|
try {
|
|
10911
11261
|
const { execSync } = await import("node:child_process");
|
|
10912
11262
|
const out = execSync(`lsof -aPi -p ${pid} -sTCP:LISTEN -Fn 2>/dev/null || true`, { encoding: "utf-8" });
|
|
10913
|
-
|
|
10914
|
-
|
|
10915
|
-
|
|
11263
|
+
// Extract all ports from lsof -Fn output (lines like "n127.0.0.1:PORT")
|
|
11264
|
+
const ports = [...out.matchAll(/n(?:\S+):(\d+)/g)].map(m => Number(m[1]));
|
|
11265
|
+
if (ports.length === 0)
|
|
11266
|
+
return null;
|
|
11267
|
+
// Try each port until one responds to /Health with 200 OK
|
|
11268
|
+
for (const port of ports) {
|
|
11269
|
+
if (await probePort(port))
|
|
11270
|
+
return port;
|
|
11271
|
+
}
|
|
11272
|
+
return null; // No port responded to /Health
|
|
10916
11273
|
}
|
|
10917
11274
|
catch { /* ignore */ }
|
|
10918
11275
|
return null;
|
|
@@ -11141,6 +11498,54 @@ program
|
|
|
11141
11498
|
}
|
|
11142
11499
|
}
|
|
11143
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
|
+
}
|
|
11144
11549
|
// 4. Embeddings check — REAL semantic round-trip (only if Harper is responding).
|
|
11145
11550
|
//
|
|
11146
11551
|
// The dead-simple `{ q: "test" }` probe used to pass even when embeddings were
|
|
@@ -11165,13 +11570,21 @@ program
|
|
|
11165
11570
|
console.log(` ${render.wrap(render.c.dim, "See:")} docs/troubleshooting.md ${render.wrap(render.c.dim, "→ \"Semantic search DEGRADED\"")}`);
|
|
11166
11571
|
issues++;
|
|
11167
11572
|
break;
|
|
11168
|
-
case "skipped":
|
|
11169
|
-
// Could not run the round-trip
|
|
11170
|
-
//
|
|
11171
|
-
//
|
|
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.
|
|
11172
11582
|
console.log(` ${render.icons.warn} Embeddings: not verified ${render.wrap(render.c.dim, `(${semanticStatus.detail})`)}`);
|
|
11173
|
-
|
|
11583
|
+
const remedy = embeddingsSkipRemedy(semanticStatus.reason);
|
|
11584
|
+
if (remedy)
|
|
11585
|
+
console.log(` ${render.wrap(render.c.dim, remedy)}`);
|
|
11174
11586
|
break;
|
|
11587
|
+
}
|
|
11175
11588
|
}
|
|
11176
11589
|
}
|
|
11177
11590
|
// 5. Stale PID file (skip if already reported in port check)
|
|
@@ -11330,7 +11743,11 @@ program
|
|
|
11330
11743
|
issues++;
|
|
11331
11744
|
}
|
|
11332
11745
|
else {
|
|
11333
|
-
|
|
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})`}`);
|
|
11334
11751
|
}
|
|
11335
11752
|
}
|
|
11336
11753
|
// Claude-Code-specific: CLAUDE.md + SessionStart hook. Only Claude Code
|
|
@@ -11364,9 +11781,75 @@ program
|
|
|
11364
11781
|
}
|
|
11365
11782
|
issues++;
|
|
11366
11783
|
}
|
|
11367
|
-
|
|
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());
|
|
11368
11792
|
if (hook.present) {
|
|
11369
|
-
|
|
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
|
+
}
|
|
11370
11853
|
}
|
|
11371
11854
|
else {
|
|
11372
11855
|
console.log(` ${render.icons.error} SessionStart hook: not found in ${render.wrap(render.c.dim, hook.path)}`);
|
|
@@ -11423,7 +11906,10 @@ program
|
|
|
11423
11906
|
for (const id of verifiedReadAgentIds) {
|
|
11424
11907
|
const reg = await checkAgentRegistered(baseUrl, id, defaultKeysDir());
|
|
11425
11908
|
agentGates.push({ id, state: reg.state, detail: reg.detail });
|
|
11426
|
-
|
|
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 });
|
|
11427
11913
|
if (finding?.isIssue)
|
|
11428
11914
|
issues++;
|
|
11429
11915
|
}
|
|
@@ -11434,7 +11920,7 @@ program
|
|
|
11434
11920
|
// agent and moves on to the next (failure isolation).
|
|
11435
11921
|
function renderAgentGateHeader(gate) {
|
|
11436
11922
|
console.log(` ${render.wrap(render.c.dim, `Agent: ${gate.id}`)}`);
|
|
11437
|
-
const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail);
|
|
11923
|
+
const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail, { instanceReachable: harperResponding });
|
|
11438
11924
|
if (!finding)
|
|
11439
11925
|
return true;
|
|
11440
11926
|
const icon = finding.icon === "error" ? render.icons.error : render.icons.warn;
|
|
@@ -11622,7 +12108,7 @@ program
|
|
|
11622
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.`);
|
|
11623
12109
|
}
|
|
11624
12110
|
else {
|
|
11625
|
-
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);
|
|
11626
12112
|
for (const gate of passedGates) {
|
|
11627
12113
|
renderAgentGateHeader(gate);
|
|
11628
12114
|
const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
|
|
@@ -12698,7 +13184,7 @@ memory.command("add [content]")
|
|
|
12698
13184
|
.description("Write a new memory row for an agent (content via positional arg or --content)")
|
|
12699
13185
|
.requiredOption("--agent <id>")
|
|
12700
13186
|
.option("--content <text>", "memory content (alias for positional arg)")
|
|
12701
|
-
.option("--durability <d>", "standard").option("--tags <csv>")
|
|
13187
|
+
.option("--durability <d>", "permanent|persistent|standard|ephemeral (default standard). Also decides the default visibility when --visibility is omitted: permanent/persistent -> shared, standard/ephemeral -> private").option("--tags <csv>")
|
|
12702
13188
|
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
|
|
12703
13189
|
.option("--subject <text>", "one-line title / entity this memory is about")
|
|
12704
13190
|
.option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
|
|
@@ -12719,8 +13205,21 @@ memory.command("add [content]")
|
|
|
12719
13205
|
body.summary = opts.summary;
|
|
12720
13206
|
if (opts.subject)
|
|
12721
13207
|
body.subject = opts.subject;
|
|
12722
|
-
|
|
12723
|
-
|
|
13208
|
+
// flair#991: reject an unrecognized --visibility instead of writing it.
|
|
13209
|
+
// `visibility` is a free-form String server-side and the read scope asks
|
|
13210
|
+
// isPrivateVisibility() — an exact match on the literal "private" — so
|
|
13211
|
+
// ANY other string, `--visibility prvate` included, persists a row the
|
|
13212
|
+
// user believes is owner-only and that every agent on the instance can
|
|
13213
|
+
// in fact read. A typo must never widen who can read a memory.
|
|
13214
|
+
if (opts.visibility) {
|
|
13215
|
+
const visibility = String(opts.visibility).trim();
|
|
13216
|
+
if (visibility !== "private" && visibility !== "shared") {
|
|
13217
|
+
console.error(`error: --visibility must be 'private' or 'shared' (got: ${visibility})`);
|
|
13218
|
+
console.error(" omit it to use the durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private");
|
|
13219
|
+
process.exit(1);
|
|
13220
|
+
}
|
|
13221
|
+
body.visibility = visibility;
|
|
13222
|
+
}
|
|
12724
13223
|
if (opts.derivedFrom) {
|
|
12725
13224
|
body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
|
|
12726
13225
|
}
|
|
@@ -13098,6 +13597,59 @@ function parseRelativeOrIso(input) {
|
|
|
13098
13597
|
const multMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
|
|
13099
13598
|
return new Date(Date.now() - n * (multMs[unit] ?? 0)).toISOString();
|
|
13100
13599
|
}
|
|
13600
|
+
export function searchScoringFormula(scoring) {
|
|
13601
|
+
return scoring === "composite"
|
|
13602
|
+
? "semantic × durability-weight × recency-decay × usage-boost"
|
|
13603
|
+
: "cosine similarity only";
|
|
13604
|
+
}
|
|
13605
|
+
export function buildSearchExplain(record, scoring, now = Date.now()) {
|
|
13606
|
+
const score = typeof record?._score === "number" ? record._score : undefined;
|
|
13607
|
+
const rawScore = typeof record?._rawScore === "number" ? record._rawScore : undefined;
|
|
13608
|
+
// composite mode: server sends both (_rawScore = pre-composite semantic).
|
|
13609
|
+
// raw mode: server sends only _score, and that IS the raw score.
|
|
13610
|
+
const raw = scoring === "composite" ? rawScore : score;
|
|
13611
|
+
const composite = scoring === "composite" ? score : undefined;
|
|
13612
|
+
let ageDays;
|
|
13613
|
+
if (record?.createdAt) {
|
|
13614
|
+
const created = new Date(String(record.createdAt)).getTime();
|
|
13615
|
+
if (Number.isFinite(created))
|
|
13616
|
+
ageDays = Math.max(0, Math.floor((now - created) / 86_400_000));
|
|
13617
|
+
}
|
|
13618
|
+
const explain = {
|
|
13619
|
+
scoring,
|
|
13620
|
+
formula: searchScoringFormula(scoring),
|
|
13621
|
+
durability: record?.durability ?? "standard",
|
|
13622
|
+
usageCount: typeof record?.usageCount === "number" ? record.usageCount : 0,
|
|
13623
|
+
};
|
|
13624
|
+
if (typeof raw === "number")
|
|
13625
|
+
explain.raw = raw;
|
|
13626
|
+
if (typeof composite === "number")
|
|
13627
|
+
explain.composite = composite;
|
|
13628
|
+
if (typeof ageDays === "number")
|
|
13629
|
+
explain.ageDays = ageDays;
|
|
13630
|
+
return explain;
|
|
13631
|
+
}
|
|
13632
|
+
// Human one-liner for a hit's breakdown. Scoring terms come from the shared
|
|
13633
|
+
// builder; the trailing tags/subject/supersedes are record context that json
|
|
13634
|
+
// mode already carries at top level, so they're appended here only.
|
|
13635
|
+
export function formatSearchExplain(explain, record) {
|
|
13636
|
+
const parts = [];
|
|
13637
|
+
if (typeof explain.raw === "number")
|
|
13638
|
+
parts.push(`raw=${explain.raw.toFixed(3)}`);
|
|
13639
|
+
if (typeof explain.composite === "number")
|
|
13640
|
+
parts.push(`composite=${explain.composite.toFixed(3)}`);
|
|
13641
|
+
parts.push(`durability=${explain.durability}`);
|
|
13642
|
+
if (typeof explain.ageDays === "number")
|
|
13643
|
+
parts.push(`age=${explain.ageDays}d`);
|
|
13644
|
+
parts.push(`usage=${explain.usageCount}`);
|
|
13645
|
+
if (Array.isArray(record?.tags) && record.tags.length > 0)
|
|
13646
|
+
parts.push(`tags=[${record.tags.join(",")}]`);
|
|
13647
|
+
if (record?.subject)
|
|
13648
|
+
parts.push(`subject=${record.subject}`);
|
|
13649
|
+
if (record?.supersedes)
|
|
13650
|
+
parts.push(`supersedes=${record.supersedes}`);
|
|
13651
|
+
return parts.join(" · ");
|
|
13652
|
+
}
|
|
13101
13653
|
program
|
|
13102
13654
|
.command("search <query>")
|
|
13103
13655
|
.description("Search memories by meaning (shortcut for memory search) — filterable, with --explain ranking")
|
|
@@ -13120,7 +13672,7 @@ program
|
|
|
13120
13672
|
.option("--durability <level>", "Filter to permanent|persistent|standard|ephemeral (client-side)")
|
|
13121
13673
|
.option("--source <name>", "Filter by source/agentId (client-side)")
|
|
13122
13674
|
// Output modes
|
|
13123
|
-
.option("--explain", "Show score breakdown (
|
|
13675
|
+
.option("--explain", "Show score breakdown (raw, composite, durability, age, usage) per hit — also added to --json output as _explain")
|
|
13124
13676
|
.option("--json", "Output raw JSON array")
|
|
13125
13677
|
.action(async (query, opts) => {
|
|
13126
13678
|
try {
|
|
@@ -13180,8 +13732,17 @@ program
|
|
|
13180
13732
|
results = results.filter((r) => allowed.has(r._source ?? r.agentId ?? ""));
|
|
13181
13733
|
}
|
|
13182
13734
|
const mode = render.resolveOutputMode(opts);
|
|
13735
|
+
const scoringMode = payload.scoring === "composite" ? "composite" : "raw";
|
|
13183
13736
|
if (mode === "json") {
|
|
13184
|
-
|
|
13737
|
+
// flair#992: --explain must be honoured here, not silently dropped.
|
|
13738
|
+
// This branch is what every non-TTY caller lands in. The breakdown
|
|
13739
|
+
// rides ALONG the json contract as an opt-in `_explain` key — present
|
|
13740
|
+
// only when the caller typed --explain, so default output is unchanged
|
|
13741
|
+
// — rather than switching output mode behind the caller's back.
|
|
13742
|
+
const out = opts.explain
|
|
13743
|
+
? results.map((r) => ({ ...r, _explain: buildSearchExplain(r, scoringMode) }))
|
|
13744
|
+
: results;
|
|
13745
|
+
console.log(render.asJSON(out));
|
|
13185
13746
|
return;
|
|
13186
13747
|
}
|
|
13187
13748
|
if (results.length === 0) {
|
|
@@ -13233,30 +13794,15 @@ program
|
|
|
13233
13794
|
if (meta)
|
|
13234
13795
|
console.log(` ${render.wrap(render.c.dim, "(")} ${meta} ${render.wrap(render.c.dim, ")")}`);
|
|
13235
13796
|
if (opts.explain) {
|
|
13236
|
-
const
|
|
13237
|
-
if (
|
|
13238
|
-
|
|
13239
|
-
if (typeof r._score === "number")
|
|
13240
|
-
parts.push(`composite=${r._score.toFixed(3)}`);
|
|
13241
|
-
if (typeof r.retrievalCount === "number" && r.retrievalCount > 0)
|
|
13242
|
-
parts.push(`retrievals=${r.retrievalCount}`);
|
|
13243
|
-
if (r.tags && Array.isArray(r.tags) && r.tags.length > 0)
|
|
13244
|
-
parts.push(`tags=[${r.tags.join(",")}]`);
|
|
13245
|
-
if (r.subject)
|
|
13246
|
-
parts.push(`subject=${r.subject}`);
|
|
13247
|
-
if (r.supersedes)
|
|
13248
|
-
parts.push(`supersedes=${r.supersedes}`);
|
|
13249
|
-
if (parts.length > 0) {
|
|
13250
|
-
console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, parts.join(" · "))}`);
|
|
13797
|
+
const line = formatSearchExplain(buildSearchExplain(r, scoringMode), r);
|
|
13798
|
+
if (line) {
|
|
13799
|
+
console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, line)}`);
|
|
13251
13800
|
}
|
|
13252
13801
|
}
|
|
13253
13802
|
console.log();
|
|
13254
13803
|
}
|
|
13255
13804
|
if (opts.explain) {
|
|
13256
|
-
|
|
13257
|
-
? "semantic × durability-weight × recency-decay × retrieval-boost"
|
|
13258
|
-
: "cosine similarity only";
|
|
13259
|
-
console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, payload.scoring)} ${render.wrap(render.c.dim, `(${formula})`)}`);
|
|
13805
|
+
console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, scoringMode)} ${render.wrap(render.c.dim, `(${searchScoringFormula(scoringMode)})`)}`);
|
|
13260
13806
|
}
|
|
13261
13807
|
}
|
|
13262
13808
|
catch (err) {
|
|
@@ -13404,7 +13950,7 @@ soul.command("set")
|
|
|
13404
13950
|
.requiredOption("--agent <id>")
|
|
13405
13951
|
.requiredOption("--key <key>")
|
|
13406
13952
|
.requiredOption("--value <value>")
|
|
13407
|
-
.option("--durability <d>", "permanent")
|
|
13953
|
+
.option("--durability <d>", "permanent|persistent|standard|ephemeral (default permanent — soul entries are identity, not working memory)")
|
|
13408
13954
|
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
13409
13955
|
.action(async (opts) => {
|
|
13410
13956
|
// PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
|
|
@@ -15221,4 +15767,6 @@ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBi
|
|
|
15221
15767
|
// Harper's own config — the per-instance port record (flair#914)
|
|
15222
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,
|
|
15223
15769
|
// launchd label (flair#693)
|
|
15224
|
-
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, };
|