@tpsdev-ai/flair 0.50.0 → 0.51.1
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 +15 -4
- package/dist/build-info.json +3 -3
- package/dist/cli.js +630 -199
- package/dist/doctor-client.js +3 -3
- package/dist/install/clients.js +28 -17
- package/dist/lib/daemon-liveness.js +207 -0
- package/dist/lib/doctor-run.js +481 -0
- package/dist/lib/launchd-management.js +7 -26
- package/dist/lib/upgrade-migrations.js +181 -0
- package/dist/resources/Federation.js +42 -20
- package/dist/resources/MemoryArchive.js +94 -0
- package/dist/resources/MemoryBootstrap.js +3 -1
- package/dist/resources/RecordUsage.js +13 -6
- package/dist/resources/SemanticSearch.js +14 -2
- package/dist/resources/federation-classify.js +90 -0
- package/dist/resources/health.js +9 -10
- package/dist/resources/mcp-tools.js +115 -7
- package/dist/resources/record-types.js +3 -1
- package/dist/resources/search-readiness.js +33 -10
- package/dist/resources/semantic-retrieval-core.js +39 -20
- package/dist/resources/usage-ids.js +63 -0
- package/docs/federation.md +11 -0
- package/docs/supply-chain-policy.md +1 -1
- package/docs/upgrade.md +17 -1
- package/package.json +2 -2
- package/schemas/federation.graphql +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Command } from "commander";
|
|
|
3
3
|
import nacl from "tweetnacl";
|
|
4
4
|
import { load as parseYaml } from "js-yaml";
|
|
5
5
|
import * as render from "./render.js";
|
|
6
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, } from "node:fs";
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, openSync, closeSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, constants as fsConstants, } from "node:fs";
|
|
7
7
|
import { homedir, tmpdir } from "node:os";
|
|
8
8
|
import { join, resolve, sep, dirname } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
@@ -32,12 +32,15 @@ import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing
|
|
|
32
32
|
import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
|
|
33
33
|
import { entityFormatHint, parseEntitiesCsv } from "./lib/entity-vocab-cli.js";
|
|
34
34
|
import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
|
|
35
|
-
import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning,
|
|
35
|
+
import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
|
|
36
|
+
import { applyUpgradeHookConsent, catalogIssueDelta, renderCatalogDoctorLines, renderVerifiedSummary, runDoctorChecks, } from "./lib/doctor-run.js";
|
|
37
|
+
import { classifyDaemonState, verifyIdentity, parseProcStatStartTime, procStartTimeToEpochMs, parsePsLstart, parseSidecarJson, } from "./lib/daemon-liveness.js";
|
|
36
38
|
// Value-only static import so `--interval`'s advertised default cannot drift
|
|
37
39
|
// from the one the scheduler actually validates against. The module itself is
|
|
38
40
|
// still loaded lazily at call time (the `await import()`s below) for the
|
|
39
41
|
// functions — this pulls in nothing but node builtins.
|
|
40
42
|
import { DEFAULT_INTERVAL_SECONDS as FEDERATION_SYNC_DEFAULT_INTERVAL } from "./federation/scheduler.js";
|
|
43
|
+
import { applyUpgradeMigrations } from "./lib/upgrade-migrations.js";
|
|
41
44
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
42
45
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
43
46
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -60,14 +63,18 @@ function signBody(body, secretKey) {
|
|
|
60
63
|
const sig = nacl.sign.detached(message, secretKey);
|
|
61
64
|
return Buffer.from(sig).toString("base64url");
|
|
62
65
|
}
|
|
63
|
-
// Per-record principalId (federation-edge-hardening slice 3a
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
66
|
+
// Per-record principalId (federation-edge-hardening slice 3a / flair#1416).
|
|
67
|
+
// Sourced from the write-time provenance stamp (memory-provenance slice 1,
|
|
68
|
+
// Memory.ts's buildProvenance) when present. `provenance` is persisted as
|
|
69
|
+
// a JSON STRING (not an object), so it must be parsed — a raw
|
|
70
|
+
// `row.provenance?.verified?.agentId` would silently always be undefined.
|
|
71
|
+
// Soul/Agent/Relationship rows never carry a provenance stamp today, so
|
|
72
|
+
// this is a no-op for them (those tables are not principal-owning).
|
|
73
|
+
//
|
|
74
|
+
// As of v:2 this value is IN the signed payload. The receiver validates
|
|
75
|
+
// it against data.agentId for Memory (PRINCIPAL_OWNING_TABLES); it is
|
|
76
|
+
// no longer informational-only. Credential.principalId is an unrelated
|
|
77
|
+
// owner field — do not grep that path when changing this one.
|
|
71
78
|
function principalIdFromRow(row) {
|
|
72
79
|
if (typeof row?.provenance !== "string" || row.provenance.length === 0)
|
|
73
80
|
return undefined;
|
|
@@ -3002,7 +3009,7 @@ program
|
|
|
3002
3009
|
.option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
|
|
3003
3010
|
.option("--skip-smoke", "Skip the MCP smoke test")
|
|
3004
3011
|
.option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
|
|
3005
|
-
.option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code
|
|
3012
|
+
.option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code and Codex)")
|
|
3006
3013
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
3007
3014
|
.option("--remote", "When used with --target, init as hub for remote federation")
|
|
3008
3015
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
@@ -3463,6 +3470,11 @@ program
|
|
|
3463
3470
|
console.log(`Starting Harper on port ${httpPort}...`);
|
|
3464
3471
|
const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
|
|
3465
3472
|
proc.unref();
|
|
3473
|
+
// flair#1454: write the identity sidecar immediately after spawn so
|
|
3474
|
+
// `flair stop` and `flair status` can classify this daemon's state
|
|
3475
|
+
// without lsof. Same call as startFlairProcess() uses.
|
|
3476
|
+
if (proc.pid)
|
|
3477
|
+
writeDaemonSidecar(dataDir, proc.pid, httpPort);
|
|
3466
3478
|
}
|
|
3467
3479
|
console.log("Waiting for Harper health check...");
|
|
3468
3480
|
await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
|
|
@@ -3864,6 +3876,18 @@ program
|
|
|
3864
3876
|
}
|
|
3865
3877
|
wiringResults.push({ client: clientId, message: result.message, wired: result.ok });
|
|
3866
3878
|
console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
|
|
3879
|
+
// Codex SessionStart hook (flair#1148 / #1439) — the hook is not
|
|
3880
|
+
// optional on Codex (no CLAUDE.md alternative). Init is the
|
|
3881
|
+
// consent to set up the client, same as the Claude Code hook
|
|
3882
|
+
// applied above. --skip-hook opts out and prints the JSON.
|
|
3883
|
+
if (clientId === "codex" && result.ok) {
|
|
3884
|
+
const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook, hookSettingsPath(homedir(), "codex"));
|
|
3885
|
+
console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
|
|
3886
|
+
if (hookResult.hint) {
|
|
3887
|
+
for (const line of hookResult.hint.split("\n"))
|
|
3888
|
+
console.log(` ${line}`);
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3867
3891
|
}
|
|
3868
3892
|
}
|
|
3869
3893
|
}
|
|
@@ -7030,22 +7054,40 @@ export async function runFederationSyncOnce(opts) {
|
|
|
7030
7054
|
// batch. Closes the hub-relay forgery hole — see
|
|
7031
7055
|
// resources/Federation.ts FederationSync.post's verification gate.
|
|
7032
7056
|
//
|
|
7033
|
-
// CONTRACT — must match
|
|
7034
|
-
// byte-for-byte
|
|
7035
|
-
//
|
|
7036
|
-
//
|
|
7037
|
-
//
|
|
7038
|
-
// field set ever changes, so an old signature fails closed instead of
|
|
7039
|
-
// silently mis-verifying under a new form.
|
|
7057
|
+
// CONTRACT — must match reconstructRecordVerifyBody
|
|
7058
|
+
// (resources/federation-classify.ts) byte-for-byte. canonicalize()
|
|
7059
|
+
// sorts keys, so field ORDER doesn't matter, but the field SET and
|
|
7060
|
+
// values do. `v` versions the canonical form itself: a v:1
|
|
7061
|
+
// signature cannot verify as v:2 (principalId in the field set).
|
|
7040
7062
|
//
|
|
7041
|
-
//
|
|
7042
|
-
// `
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
//
|
|
7046
|
-
// receiver
|
|
7047
|
-
//
|
|
7063
|
+
// v: 2 puts principalId in the signed payload when the row carries
|
|
7064
|
+
// a provenance stamp, and puts `v` on the wire so Phase 1
|
|
7065
|
+
// receivers (`const v = record.v ?? 1`) don't default these
|
|
7066
|
+
// records back to 1. Soul/Agent/Relationship have no stamp and
|
|
7067
|
+
// omit principalId; Memory without a stamp also omits it (the
|
|
7068
|
+
// receiver then skips Memory as principal_mismatch — absent is
|
|
7069
|
+
// not an accept).
|
|
7048
7070
|
const principalId = principalIdFromRow(row);
|
|
7071
|
+
const signedPayload = {
|
|
7072
|
+
v: 2,
|
|
7073
|
+
table,
|
|
7074
|
+
id: row.id,
|
|
7075
|
+
data: row,
|
|
7076
|
+
updatedAt,
|
|
7077
|
+
originatorInstanceId,
|
|
7078
|
+
};
|
|
7079
|
+
if (principalId)
|
|
7080
|
+
signedPayload.principalId = principalId;
|
|
7081
|
+
const signature = signBody(signedPayload, secretKey);
|
|
7082
|
+
const sr = {
|
|
7083
|
+
v: 2,
|
|
7084
|
+
table,
|
|
7085
|
+
id: row.id,
|
|
7086
|
+
data: row,
|
|
7087
|
+
updatedAt,
|
|
7088
|
+
originatorInstanceId,
|
|
7089
|
+
signature,
|
|
7090
|
+
};
|
|
7049
7091
|
if (principalId)
|
|
7050
7092
|
sr.principalId = principalId;
|
|
7051
7093
|
const srBytes = JSON.stringify(sr).length;
|
|
@@ -10276,6 +10318,7 @@ program
|
|
|
10276
10318
|
.option("--project <name>", "Fabric component name for --target", "flair")
|
|
10277
10319
|
.option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
|
|
10278
10320
|
.option("--yes", "Skip the confirmation prompt for --target")
|
|
10321
|
+
.option("--install-hooks", "Consent to installing missing SessionStart hooks (claude-code / Codex) during upgrade. The hook executes at every session start — upgrade will not write it unprompted. Interactive runs prompt; non-interactive runs state the gap and withhold ✅ unless this flag is passed.")
|
|
10279
10322
|
.option("--no-fleet-verify", "Skip the automatic post-upgrade fleet convergence sweep for --target (default: sweep runs — see flair#636)")
|
|
10280
10323
|
// ── flair#878 ─────────────────────────────────────────────────────────────
|
|
10281
10324
|
// These existed on `flair deploy` but stopped at the upgrade boundary, so
|
|
@@ -10941,20 +10984,18 @@ program
|
|
|
10941
10984
|
});
|
|
10942
10985
|
const verdict = decideAfterVerify(verify, previousFlairVersion);
|
|
10943
10986
|
if (verdict.kind === "ok") {
|
|
10944
|
-
// flair#
|
|
10945
|
-
//
|
|
10946
|
-
//
|
|
10947
|
-
//
|
|
10948
|
-
|
|
10949
|
-
|
|
10950
|
-
|
|
10951
|
-
|
|
10952
|
-
|
|
10953
|
-
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
console.log(line);
|
|
10957
|
-
}
|
|
10987
|
+
// flair#1439: the success marker is the doctor runner's verdict, not
|
|
10988
|
+
// a second, narrower notion of "verified". Launchd detach is one
|
|
10989
|
+
// catalog member; the Codex SessionStart hook is another. Adding a
|
|
10990
|
+
// doctor check widens this claim automatically.
|
|
10991
|
+
const run = await doctorRunAfterUpgrade({
|
|
10992
|
+
management,
|
|
10993
|
+
port,
|
|
10994
|
+
installHooksFlag: !!opts.installHooks,
|
|
10995
|
+
fromVersion: previousFlairVersion,
|
|
10996
|
+
toVersion: expectedFlairVersion,
|
|
10997
|
+
});
|
|
10998
|
+
printVerifiedSummary(renderVerifiedSummary(verify.version, run));
|
|
10958
10999
|
return;
|
|
10959
11000
|
}
|
|
10960
11001
|
// flair#741 follow-through: a healthy instance the verifier just couldn't
|
|
@@ -10966,21 +11007,25 @@ program
|
|
|
10966
11007
|
// "print an honest note but roll back anyway" branch that used to sit below
|
|
10967
11008
|
// is gone — that credentials case can no longer reach the rollback path.)
|
|
10968
11009
|
if (verdict.kind === "healthy-unverified") {
|
|
10969
|
-
//
|
|
10970
|
-
//
|
|
10971
|
-
|
|
10972
|
-
|
|
10973
|
-
|
|
10974
|
-
|
|
10975
|
-
:
|
|
11010
|
+
// Same doctor runner as the "ok" branch — an unverified version must
|
|
11011
|
+
// not restore the unqualified ✅ while a catalog member is failing.
|
|
11012
|
+
const run = await doctorRunAfterUpgrade({
|
|
11013
|
+
management,
|
|
11014
|
+
port,
|
|
11015
|
+
installHooksFlag: !!opts.installHooks,
|
|
11016
|
+
fromVersion: previousFlairVersion,
|
|
11017
|
+
toVersion: expectedFlairVersion,
|
|
11018
|
+
});
|
|
11019
|
+
const versionNote = expectedFlairVersion ? ` on @tpsdev-ai/flair@${expectedFlairVersion}` : "";
|
|
11020
|
+
if (run.healthy) {
|
|
11021
|
+
console.log(`✅ upgrade complete: the instance is up and healthy${versionNote}.`);
|
|
11022
|
+
}
|
|
11023
|
+
else {
|
|
11024
|
+
printVerifiedSummary(renderVerifiedSummary(verify.version, run, { authenticated: false }));
|
|
11025
|
+
}
|
|
10976
11026
|
console.log(` The version could not be verified — the checker couldn't authenticate to /HealthDetail (${verdict.reason}).`);
|
|
10977
11027
|
console.log(" The server is confirmed running (public /Health passed); this is a verification gap, not an upgrade failure — nothing was rolled back.");
|
|
10978
11028
|
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.");
|
|
10979
|
-
if (detached) {
|
|
10980
|
-
for (const line of renderDetachedWarning(management, "The instance is NOT running under launchd.")) {
|
|
10981
|
-
console.error(line);
|
|
10982
|
-
}
|
|
10983
|
-
}
|
|
10984
11029
|
return;
|
|
10985
11030
|
}
|
|
10986
11031
|
console.error(`❌ post-restart verification failed: ${verdict.reason}`);
|
|
@@ -10991,6 +11036,240 @@ program
|
|
|
10991
11036
|
}
|
|
10992
11037
|
await rollbackTo(verdict.toVersion, verdict.reason);
|
|
10993
11038
|
});
|
|
11039
|
+
/**
|
|
11040
|
+
* Read a file with O_NOFOLLOW so a symlink planted at the pidfile/sidecar path
|
|
11041
|
+
* cannot redirect the read (flair#1454 decision 6). `readFileSync` has no such
|
|
11042
|
+
* flag, so this opens the fd first and reads from it.
|
|
11043
|
+
*/
|
|
11044
|
+
function readFileNoFollow(path) {
|
|
11045
|
+
let fd;
|
|
11046
|
+
try {
|
|
11047
|
+
fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
|
|
11048
|
+
}
|
|
11049
|
+
catch (err) {
|
|
11050
|
+
if (err?.code === "ENOENT")
|
|
11051
|
+
return { kind: "absent" };
|
|
11052
|
+
if (err?.code === "ELOOP")
|
|
11053
|
+
return { kind: "unreadable", reason: `${path} is a symbolic link` };
|
|
11054
|
+
return { kind: "unreadable", reason: `cannot open ${path}: ${err?.code ?? err?.message}` };
|
|
11055
|
+
}
|
|
11056
|
+
try {
|
|
11057
|
+
return { kind: "present", content: readFileSync(fd, "utf-8") };
|
|
11058
|
+
}
|
|
11059
|
+
catch (err) {
|
|
11060
|
+
return { kind: "unreadable", reason: `cannot read ${path}: ${err?.code ?? err?.message}` };
|
|
11061
|
+
}
|
|
11062
|
+
finally {
|
|
11063
|
+
try {
|
|
11064
|
+
closeSync(fd);
|
|
11065
|
+
}
|
|
11066
|
+
catch { /* already closed */ }
|
|
11067
|
+
}
|
|
11068
|
+
}
|
|
11069
|
+
/**
|
|
11070
|
+
* Refuse to trust a data dir that is a symlink or world-writable (flair#1454
|
|
11071
|
+
* decision 6). Returns a reason, or null when the dir is safe (or absent — a
|
|
11072
|
+
* missing dir is "no data", not "unsafe"; the pidfile read reports absent).
|
|
11073
|
+
*/
|
|
11074
|
+
function checkDataDirSafe(dataDir) {
|
|
11075
|
+
let lst;
|
|
11076
|
+
try {
|
|
11077
|
+
lst = lstatSync(dataDir);
|
|
11078
|
+
}
|
|
11079
|
+
catch {
|
|
11080
|
+
return null;
|
|
11081
|
+
}
|
|
11082
|
+
if (lst.isSymbolicLink()) {
|
|
11083
|
+
return `data directory ${dataDir} is a symbolic link — refusing to trust its pidfile`;
|
|
11084
|
+
}
|
|
11085
|
+
let st;
|
|
11086
|
+
try {
|
|
11087
|
+
st = statSync(dataDir);
|
|
11088
|
+
}
|
|
11089
|
+
catch {
|
|
11090
|
+
return null;
|
|
11091
|
+
}
|
|
11092
|
+
if (st.mode & 0o002) {
|
|
11093
|
+
return `data directory ${dataDir} is world-writable — refusing to trust its pidfile`;
|
|
11094
|
+
}
|
|
11095
|
+
return null;
|
|
11096
|
+
}
|
|
11097
|
+
/** Read `hdb.pid` (O_NOFOLLOW) into a `PidfileRead`. */
|
|
11098
|
+
function readPidfile(dataDir) {
|
|
11099
|
+
const r = readFileNoFollow(join(dataDir, "hdb.pid"));
|
|
11100
|
+
if (r.kind !== "present")
|
|
11101
|
+
return r;
|
|
11102
|
+
const n = Number(r.content.trim());
|
|
11103
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
11104
|
+
return { kind: "unreadable", reason: `${join(dataDir, "hdb.pid")} does not contain a valid pid` };
|
|
11105
|
+
}
|
|
11106
|
+
return { kind: "present", pid: n };
|
|
11107
|
+
}
|
|
11108
|
+
/** Read `flair-daemon.json` (O_NOFOLLOW) into a `SidecarRead`. */
|
|
11109
|
+
function readSidecar(dataDir) {
|
|
11110
|
+
const r = readFileNoFollow(join(dataDir, "flair-daemon.json"));
|
|
11111
|
+
if (r.kind !== "present")
|
|
11112
|
+
return r;
|
|
11113
|
+
const parsed = parseSidecarJson(r.content);
|
|
11114
|
+
if (parsed === null) {
|
|
11115
|
+
return { kind: "unreadable", reason: `${join(dataDir, "flair-daemon.json")} is malformed` };
|
|
11116
|
+
}
|
|
11117
|
+
return { kind: "present", ...parsed };
|
|
11118
|
+
}
|
|
11119
|
+
/** `kill(pid, 0)` as a three-way: alive / gone (ESRCH) / eperm (another user's). */
|
|
11120
|
+
function probePidLiveness(pid) {
|
|
11121
|
+
try {
|
|
11122
|
+
process.kill(pid, 0);
|
|
11123
|
+
return { kind: "alive" };
|
|
11124
|
+
}
|
|
11125
|
+
catch (err) {
|
|
11126
|
+
if (err?.code === "ESRCH")
|
|
11127
|
+
return { kind: "gone" };
|
|
11128
|
+
if (err?.code === "EPERM")
|
|
11129
|
+
return { kind: "eperm" };
|
|
11130
|
+
return { kind: "gone" };
|
|
11131
|
+
}
|
|
11132
|
+
}
|
|
11133
|
+
/**
|
|
11134
|
+
* The live process's start time in epoch ms, or null when it cannot be read.
|
|
11135
|
+
* Linux reads `/proc/<pid>/stat` field 22 (starttime in clock ticks) plus
|
|
11136
|
+
* `/proc/uptime`; macOS shells out to `ps -o lstart=`. A null answer degrades
|
|
11137
|
+
* to "identity unverified" — it never decides a verdict toward the destructive
|
|
11138
|
+
* branch (flair#1454 decision 4).
|
|
11139
|
+
*/
|
|
11140
|
+
function readProcessStartTimeMs(pid) {
|
|
11141
|
+
if (process.platform === "linux") {
|
|
11142
|
+
try {
|
|
11143
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
|
|
11144
|
+
const starttime = parseProcStatStartTime(stat);
|
|
11145
|
+
if (starttime === null)
|
|
11146
|
+
return null;
|
|
11147
|
+
const uptimeRaw = readFileSync("/proc/uptime", "utf-8").trim().split(/\s+/)[0];
|
|
11148
|
+
const uptime = Number(uptimeRaw);
|
|
11149
|
+
if (!Number.isFinite(uptime))
|
|
11150
|
+
return null;
|
|
11151
|
+
return procStartTimeToEpochMs(starttime, uptime, Date.now());
|
|
11152
|
+
}
|
|
11153
|
+
catch {
|
|
11154
|
+
return null;
|
|
11155
|
+
}
|
|
11156
|
+
}
|
|
11157
|
+
if (process.platform === "darwin") {
|
|
11158
|
+
try {
|
|
11159
|
+
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
11160
|
+
encoding: "utf-8",
|
|
11161
|
+
env: { ...process.env, LC_ALL: "C" },
|
|
11162
|
+
timeout: 2000,
|
|
11163
|
+
});
|
|
11164
|
+
return parsePsLstart(out);
|
|
11165
|
+
}
|
|
11166
|
+
catch {
|
|
11167
|
+
return null;
|
|
11168
|
+
}
|
|
11169
|
+
}
|
|
11170
|
+
return null;
|
|
11171
|
+
}
|
|
11172
|
+
/** The health probe, three-way: ok / refused (ECONNREFUSED) / unreachable. */
|
|
11173
|
+
async function probeHealth(port) {
|
|
11174
|
+
try {
|
|
11175
|
+
await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(2000) });
|
|
11176
|
+
return { kind: "ok" };
|
|
11177
|
+
}
|
|
11178
|
+
catch (err) {
|
|
11179
|
+
// Node's undici fetch reports ECONNREFUSED on `err.cause.code`; Bun reports
|
|
11180
|
+
// `ConnectionRefused` on `err.code`. Both mean "nothing is listening".
|
|
11181
|
+
const code = err?.cause?.code ?? err?.code;
|
|
11182
|
+
if (code === "ECONNREFUSED" || code === "ConnectionRefused") {
|
|
11183
|
+
return { kind: "refused" };
|
|
11184
|
+
}
|
|
11185
|
+
return { kind: "unreachable" };
|
|
11186
|
+
}
|
|
11187
|
+
}
|
|
11188
|
+
/** Gather every piece of evidence the classifier needs, in one place. */
|
|
11189
|
+
async function gatherDaemonEvidence(port, dataDir) {
|
|
11190
|
+
const dataDirUnsafe = checkDataDirSafe(dataDir);
|
|
11191
|
+
const pidfile = readPidfile(dataDir);
|
|
11192
|
+
const pidLiveness = pidfile.kind === "present" ? probePidLiveness(pidfile.pid) : null;
|
|
11193
|
+
let sidecar = readSidecar(dataDir);
|
|
11194
|
+
// Probe health first so the self-heal gate below can use it without a
|
|
11195
|
+
// second round-trip. Also consumed at the end for the classifier.
|
|
11196
|
+
const health = await probeHealth(port);
|
|
11197
|
+
// flair#1454 self-heal: a daemon started by a pre-sidecar version of flair
|
|
11198
|
+
// (upgrade-across-#1454) has a live pid in hdb.pid but no flair-daemon.json.
|
|
11199
|
+
// Without this path, classifyDaemonState returns DISAGREEMENT and `flair stop`
|
|
11200
|
+
// refuses — breaking the upgrade flow for every existing user.
|
|
11201
|
+
//
|
|
11202
|
+
// SECURITY: the real guard is /Health, NOT the ±2s start-time check.
|
|
11203
|
+
// The ±2s check is circular in the self-heal path: we write the sidecar
|
|
11204
|
+
// with the live process's OWN start time, then verifyIdentity reads the
|
|
11205
|
+
// same process — it matches by construction for ANY live pid, including a
|
|
11206
|
+
// recycled pid belonging to an unrelated process. Requiring /Health OK is
|
|
11207
|
+
// the correct proof: only the flair daemon responds 200 at
|
|
11208
|
+
// http://127.0.0.1:<port>/Health. An unrelated recycled pid does not.
|
|
11209
|
+
//
|
|
11210
|
+
// Therefore: self-heal is gated on health.kind === "ok". A live pid that
|
|
11211
|
+
// does NOT serve /Health — a recycled pid, a wedged pre-#1454 daemon that
|
|
11212
|
+
// can no longer respond — is left as DISAGREEMENT (refuse to signal).
|
|
11213
|
+
// Never WEDGED, never SIGTERM, on an unverified pid.
|
|
11214
|
+
//
|
|
11215
|
+
// The write uses the same O_NOFOLLOW / 0600 / atomic-rename posture as every
|
|
11216
|
+
// other sidecar write. We skip self-heal when the dataDir is unsafe
|
|
11217
|
+
// (symlink / world-writable) — the check has already happened above.
|
|
11218
|
+
if (sidecar.kind === "absent" &&
|
|
11219
|
+
dataDirUnsafe === null &&
|
|
11220
|
+
pidfile.kind === "present" &&
|
|
11221
|
+
pidLiveness?.kind === "alive" &&
|
|
11222
|
+
health.kind === "ok" // ← the real proof: only flair serves this
|
|
11223
|
+
) {
|
|
11224
|
+
const pid = pidfile.pid;
|
|
11225
|
+
const startTimeMs = readProcessStartTimeMs(pid);
|
|
11226
|
+
if (startTimeMs !== null) {
|
|
11227
|
+
try {
|
|
11228
|
+
writeDaemonSidecar(dataDir, pid, port, startTimeMs);
|
|
11229
|
+
// Re-read: now that the sidecar exists, classify through the normal path.
|
|
11230
|
+
sidecar = readSidecar(dataDir);
|
|
11231
|
+
}
|
|
11232
|
+
catch {
|
|
11233
|
+
// Self-heal is best-effort. If the write fails (e.g. read-only dataDir),
|
|
11234
|
+
// we proceed with sidecar === absent and fall through to DISAGREEMENT
|
|
11235
|
+
// — the same outcome as before the self-heal path, so no regression.
|
|
11236
|
+
}
|
|
11237
|
+
}
|
|
11238
|
+
}
|
|
11239
|
+
const identity = verifyIdentity({
|
|
11240
|
+
pidfilePid: pidfile.kind === "present" ? pidfile.pid : null,
|
|
11241
|
+
sidecar,
|
|
11242
|
+
readStartTime: readProcessStartTimeMs,
|
|
11243
|
+
});
|
|
11244
|
+
return { dataDirUnsafe, pidfile, pidLiveness, identity, health };
|
|
11245
|
+
}
|
|
11246
|
+
/**
|
|
11247
|
+
* Write the identity sidecar atomically (temp + rename) at spawn time or
|
|
11248
|
+
* during self-heal (flair#1454 decision 3). `pid` is the spawned process's
|
|
11249
|
+
* pid — the same number Harper writes to `hdb.pid`, since Harper runs
|
|
11250
|
+
* in-process. `startTimeMs` defaults to `Date.now()` for a fresh spawn.
|
|
11251
|
+
*
|
|
11252
|
+
* Self-heal callers pass the live process's actual start time (from
|
|
11253
|
+
* readProcessStartTimeMs) so the sidecar records an accurate epoch, not a
|
|
11254
|
+
* wall-clock approximation. Note: in the self-heal path the ±2s start-time
|
|
11255
|
+
* check in verifyIdentity is NOT what prevents recycled-pid adoption —
|
|
11256
|
+
* that guard is the /Health probe that the self-heal caller already required
|
|
11257
|
+
* before reaching this point. The start time is recorded faithfully for
|
|
11258
|
+
* forward compatibility and audit, not as a security gate here.
|
|
11259
|
+
*/
|
|
11260
|
+
function writeDaemonSidecar(dataDir, pid, port, startTimeMs = Date.now()) {
|
|
11261
|
+
const sidecar = { pid, startTimeMs, port, flairVersion: __pkgVersion };
|
|
11262
|
+
const tmpPath = join(dataDir, `.flair-daemon.json.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
11263
|
+
// Write with mode 0600 so the tmp file is never world-readable (flair#1454
|
|
11264
|
+
// decision 6 — same posture as admin-pass and key material).
|
|
11265
|
+
writeFileSync(tmpPath, JSON.stringify(sidecar, null, 2) + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
11266
|
+
const finalPath = join(dataDir, "flair-daemon.json");
|
|
11267
|
+
renameSync(tmpPath, finalPath);
|
|
11268
|
+
// Re-assert 0600 after the rename: rename preserves the tmp permissions but
|
|
11269
|
+
// a pre-existing file at the destination retains its original mode on some
|
|
11270
|
+
// kernels. An explicit chmod is the only guarantee (flair#1454 decision 6).
|
|
11271
|
+
chmodSync(finalPath, 0o600);
|
|
11272
|
+
}
|
|
10994
11273
|
// ─── flair stop ───────────────────────────────────────────────────────────────
|
|
10995
11274
|
program
|
|
10996
11275
|
.command("stop")
|
|
@@ -11016,49 +11295,55 @@ program
|
|
|
11016
11295
|
}
|
|
11017
11296
|
}
|
|
11018
11297
|
}
|
|
11019
|
-
//
|
|
11020
|
-
//
|
|
11021
|
-
//
|
|
11022
|
-
//
|
|
11023
|
-
//
|
|
11024
|
-
//
|
|
11025
|
-
|
|
11026
|
-
|
|
11027
|
-
|
|
11028
|
-
|
|
11029
|
-
|
|
11030
|
-
|
|
11031
|
-
|
|
11032
|
-
|
|
11033
|
-
|
|
11034
|
-
|
|
11035
|
-
|
|
11036
|
-
|
|
11037
|
-
process.exit(1);
|
|
11298
|
+
// Non-launchd: the five-state liveness machine (flair#1454). The old
|
|
11299
|
+
// decision tree (launchd -> lsof -> "not running") is REPLACED, not
|
|
11300
|
+
// patched: `lsof` absence used to render as a definite "not running", and
|
|
11301
|
+
// the pidfile was only consulted to attribute port-derived PIDs. Now the
|
|
11302
|
+
// pidfile + identity sidecar are the primary evidence, and the health
|
|
11303
|
+
// probe is a cross-check — never the verdict.
|
|
11304
|
+
const dataDir = defaultDataDir();
|
|
11305
|
+
const evidence = await gatherDaemonEvidence(port, dataDir);
|
|
11306
|
+
const state = classifyDaemonState(evidence, { port, dataDir });
|
|
11307
|
+
switch (state.state) {
|
|
11308
|
+
case "RUNNING":
|
|
11309
|
+
case "WEDGED": {
|
|
11310
|
+
// Identity is already proven for both of these — killing a wedged
|
|
11311
|
+
// daemon is recovery, not a recycled-PID gamble.
|
|
11312
|
+
const pid = state.pid;
|
|
11313
|
+
const label = state.state === "WEDGED" ? "wedged daemon" : "daemon";
|
|
11314
|
+
try {
|
|
11315
|
+
process.kill(pid, "SIGTERM");
|
|
11038
11316
|
}
|
|
11039
|
-
|
|
11040
|
-
|
|
11041
|
-
|
|
11042
|
-
|
|
11043
|
-
|
|
11044
|
-
|
|
11317
|
+
catch (err) {
|
|
11318
|
+
if (err?.code !== "ESRCH") {
|
|
11319
|
+
console.error(`❌ failed to signal pid ${pid}: ${err?.code ?? err?.message}`);
|
|
11320
|
+
process.exit(1);
|
|
11321
|
+
}
|
|
11322
|
+
}
|
|
11323
|
+
await waitForProcessExit(pid, STARTUP_TIMEOUT_MS);
|
|
11324
|
+
const after = await probeHealth(port);
|
|
11325
|
+
if (after.kind === "refused") {
|
|
11326
|
+
console.log(`✅ Flair stopped (${label}, pid ${pid})`);
|
|
11045
11327
|
}
|
|
11046
11328
|
else {
|
|
11047
|
-
|
|
11048
|
-
try {
|
|
11049
|
-
process.kill(pid, "SIGTERM");
|
|
11050
|
-
}
|
|
11051
|
-
catch { /* already gone */ }
|
|
11052
|
-
}
|
|
11053
|
-
console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
|
|
11329
|
+
console.log(`✅ Flair stopped (${label}, pid ${pid}; port ${port} may still be releasing)`);
|
|
11054
11330
|
}
|
|
11331
|
+
return;
|
|
11055
11332
|
}
|
|
11056
|
-
|
|
11333
|
+
case "NOT_RUNNING":
|
|
11057
11334
|
console.log("Flair is not running.");
|
|
11058
|
-
|
|
11059
|
-
|
|
11060
|
-
|
|
11061
|
-
|
|
11335
|
+
return;
|
|
11336
|
+
case "DISAGREEMENT":
|
|
11337
|
+
console.error(`⚠️ ${state.detail}`);
|
|
11338
|
+
console.error(` Not stopping — the evidence conflicts.`);
|
|
11339
|
+
console.error(` pidfile: ${join(dataDir, "hdb.pid")}`);
|
|
11340
|
+
console.error(` port: ${port}`);
|
|
11341
|
+
console.error(` To inspect: flair doctor`);
|
|
11342
|
+
process.exit(1);
|
|
11343
|
+
case "UNKNOWN":
|
|
11344
|
+
console.error(`⚠️ ${state.detail}`);
|
|
11345
|
+
console.error(` Not stopping — could not determine whether Flair is running.`);
|
|
11346
|
+
process.exit(1);
|
|
11062
11347
|
}
|
|
11063
11348
|
});
|
|
11064
11349
|
// ─── flair start ──────────────────────────────────────────────────────────────
|
|
@@ -11068,16 +11353,35 @@ program
|
|
|
11068
11353
|
.option("--port <port>", "Harper HTTP port")
|
|
11069
11354
|
.action(async (opts) => {
|
|
11070
11355
|
const port = resolveHttpPort(opts);
|
|
11071
|
-
// Check if already running
|
|
11072
|
-
try {
|
|
11073
|
-
const res = await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(2000) });
|
|
11074
|
-
if (res.status > 0) {
|
|
11075
|
-
console.log(`Flair is already running on port ${port}.`);
|
|
11076
|
-
return;
|
|
11077
|
-
}
|
|
11078
|
-
}
|
|
11079
|
-
catch { /* not running — good */ }
|
|
11080
11356
|
const dataDir = defaultDataDir();
|
|
11357
|
+
// Already-running check via the five-state liveness machine (flair#1454).
|
|
11358
|
+
// The old check was a bare `fetch /Health` that treated "got a response"
|
|
11359
|
+
// as "already running" and exited 0 — half of #1454. Now the machine
|
|
11360
|
+
// classifies, and every non-NOT_RUNNING state refuses with a non-zero exit.
|
|
11361
|
+
const evidence = await gatherDaemonEvidence(port, dataDir);
|
|
11362
|
+
const state = classifyDaemonState(evidence, { port, dataDir });
|
|
11363
|
+
switch (state.state) {
|
|
11364
|
+
case "NOT_RUNNING":
|
|
11365
|
+
break; // proceed to boot
|
|
11366
|
+
case "RUNNING":
|
|
11367
|
+
console.error(`Flair is already running on port ${port} (pid ${state.pid}).`);
|
|
11368
|
+
process.exit(1);
|
|
11369
|
+
case "WEDGED":
|
|
11370
|
+
console.error(`⚠️ A wedged Flair daemon (pid ${state.pid}) is holding port ${port}.`);
|
|
11371
|
+
console.error(` Run 'flair stop' first — never start over a live pid.`);
|
|
11372
|
+
process.exit(1);
|
|
11373
|
+
case "DISAGREEMENT":
|
|
11374
|
+
console.error(`⚠️ ${state.detail}`);
|
|
11375
|
+
console.error(` Refusing to start — the evidence conflicts.`);
|
|
11376
|
+
console.error(` pidfile: ${join(dataDir, "hdb.pid")}`);
|
|
11377
|
+
console.error(` port: ${port}`);
|
|
11378
|
+
console.error(` To inspect: flair doctor`);
|
|
11379
|
+
process.exit(1);
|
|
11380
|
+
case "UNKNOWN":
|
|
11381
|
+
console.error(`⚠️ ${state.detail}`);
|
|
11382
|
+
console.error(` Refusing to start — could not determine whether Flair is running.`);
|
|
11383
|
+
process.exit(1);
|
|
11384
|
+
}
|
|
11081
11385
|
if (!existsSync(dataDir)) {
|
|
11082
11386
|
console.error("❌ No Flair data directory found. Run 'flair init' first.");
|
|
11083
11387
|
process.exit(1);
|
|
@@ -11155,6 +11459,12 @@ program
|
|
|
11155
11459
|
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
11156
11460
|
});
|
|
11157
11461
|
proc.unref();
|
|
11462
|
+
// Write the identity sidecar immediately after spawn (flair#1454 decision
|
|
11463
|
+
// 3) — BEFORE waitForHealth, so startTimeMs stays within the ±2s tolerance
|
|
11464
|
+
// of the process's real start time. `proc.pid` is the pid Harper writes to
|
|
11465
|
+
// hdb.pid, since Harper runs in-process.
|
|
11466
|
+
if (proc.pid)
|
|
11467
|
+
writeDaemonSidecar(dataDir, proc.pid, port);
|
|
11158
11468
|
try {
|
|
11159
11469
|
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
11160
11470
|
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
|
|
@@ -11217,51 +11527,6 @@ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
|
|
|
11217
11527
|
`Re-run with --data-dir ${resolve(declared)} to act on that one, or run ` +
|
|
11218
11528
|
`'flair init --data-dir ${resolve(dataDir)}' to register a service for this one.`);
|
|
11219
11529
|
}
|
|
11220
|
-
/**
|
|
11221
|
-
* Refuse a port-based SIGTERM that cannot be attributed to `dataDir`
|
|
11222
|
-
* (flair#902).
|
|
11223
|
-
*
|
|
11224
|
-
* The port fallback below identifies its target by port number and nothing
|
|
11225
|
-
* else, so `--data-dir <scratch>` with a port that scratch instance does not
|
|
11226
|
-
* serve signals whichever instance DOES serve it. That is the whole of this
|
|
11227
|
-
* bug on Linux, where there is no launchd path at all.
|
|
11228
|
-
*
|
|
11229
|
-
* Scoped deliberately to a non-default data dir. For the default install the
|
|
11230
|
-
* port genuinely is that instance's port by every convention in this CLI
|
|
11231
|
-
* (`writeConfig`/`readPortFromConfig`), and the only evidence available here
|
|
11232
|
-
* — `<dataDir>/hdb.pid` vs the listening PIDs — is not something we can
|
|
11233
|
-
* require without risking a false refusal on a working install whose PID file
|
|
11234
|
-
* is missing or whose listener is a worker. So the default path keeps today's
|
|
11235
|
-
* behavior exactly, and the residual gap is stated rather than papered over:
|
|
11236
|
-
* a default-dir port stop is still unattributed. The new refusal can only
|
|
11237
|
-
* fire for a caller that explicitly named another data dir — the case that is
|
|
11238
|
-
* wrong today whenever the port does not match.
|
|
11239
|
-
*/
|
|
11240
|
-
function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
|
|
11241
|
-
// (flair#915) Apply the attribution check for ALL data directories, not
|
|
11242
|
-
// just non-default ones. The default-dir bypass was the residual gap that
|
|
11243
|
-
// #910 left behind — it allowed an unattributed SIGTERM on the default
|
|
11244
|
-
// install's port. The old concern (false refusal when hdb.pid is missing)
|
|
11245
|
-
// is actually the RIGHT behavior: no PID file means we cannot attribute the
|
|
11246
|
-
// listener, so we refuse. That is safer than killing the wrong process.
|
|
11247
|
-
const expected = readHarperPid(dataDir);
|
|
11248
|
-
// No PID file — Harper is not (or was not) running in this directory.
|
|
11249
|
-
// The port is stale or held by something else; refuse to SIGTERM it.
|
|
11250
|
-
if (expected === null) {
|
|
11251
|
-
throw new Error(`refusing to stop the process listening on port ${port}: no hdb.pid under `
|
|
11252
|
-
+ `${resolve(dataDir)}, so that is not a running instance. `
|
|
11253
|
-
+ `Stopping by port alone would signal a process we cannot attribute. `
|
|
11254
|
-
+ `If it is not Flair, stop it manually.`);
|
|
11255
|
-
}
|
|
11256
|
-
// PID file exists — the PID on the port must be Harper.
|
|
11257
|
-
if (listeningPids.includes(expected))
|
|
11258
|
-
return;
|
|
11259
|
-
throw new Error(`refusing to stop the process listening on port ${port}: its recorded PID ${expected} `
|
|
11260
|
-
+ `is not the process listening on ${port}. `
|
|
11261
|
-
+ `Stopping by port alone would signal a different instance. `
|
|
11262
|
-
+ `Pass --port with the port ${resolve(dataDir)} actually serves, `
|
|
11263
|
-
+ `or stop the process manually.`);
|
|
11264
|
-
}
|
|
11265
11530
|
// ─── "is it still under launchd?" (flair#1022) ─────────────────────────────
|
|
11266
11531
|
//
|
|
11267
11532
|
// The pure logic lives in src/lib/launchd-management.ts; these two adapters
|
|
@@ -11299,6 +11564,117 @@ function resolveInstanceServingPid(dataDir, port) {
|
|
|
11299
11564
|
listeningPids,
|
|
11300
11565
|
});
|
|
11301
11566
|
}
|
|
11567
|
+
/** Agent signing-key ids under `keysDir` — node-scoped federation keys excluded. */
|
|
11568
|
+
function collectKeyAgentIds(keysDir) {
|
|
11569
|
+
if (!existsSync(keysDir))
|
|
11570
|
+
return [];
|
|
11571
|
+
try {
|
|
11572
|
+
const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
|
|
11573
|
+
const { agentKeyIds } = partitionKeyIds(keyFiles.map((f) => f.replace(/\.key$/, "")), keysDir);
|
|
11574
|
+
return agentKeyIds;
|
|
11575
|
+
}
|
|
11576
|
+
catch {
|
|
11577
|
+
return [];
|
|
11578
|
+
}
|
|
11579
|
+
}
|
|
11580
|
+
async function confirmYes(question) {
|
|
11581
|
+
if (!process.stdin.isTTY)
|
|
11582
|
+
return false;
|
|
11583
|
+
const { createInterface } = await import("node:readline");
|
|
11584
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
11585
|
+
const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
|
|
11586
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
11587
|
+
}
|
|
11588
|
+
/**
|
|
11589
|
+
* flair#1439 — run the enumerable doctor catalog after upgrade's instance
|
|
11590
|
+
* probe, and offer a consented SessionStart-hook install when that check
|
|
11591
|
+
* fails. Silent writes are refused: `--install-hooks` or an interactive
|
|
11592
|
+
* yes is the only consent. The consent→write composition lives in
|
|
11593
|
+
* applyUpgradeHookConsent so tests can drive the path that actually
|
|
11594
|
+
* writes (or does not write) the hook file.
|
|
11595
|
+
*
|
|
11596
|
+
* Before the catalog run, `applyUpgradeMigrations` fires any version-keyed
|
|
11597
|
+
* migrations that are pending for the fromVersion→toVersion pair. These do
|
|
11598
|
+
* NOT require `--install-hooks` because the user already consented to the
|
|
11599
|
+
* affected integration when they ran `flair init` — the migration just
|
|
11600
|
+
* applies a new artifact that the old init could not have written.
|
|
11601
|
+
*/
|
|
11602
|
+
async function doctorRunAfterUpgrade(args) {
|
|
11603
|
+
const homeDir = homedir();
|
|
11604
|
+
const keysDir = defaultKeysDir();
|
|
11605
|
+
const detectedClientIds = detectClients().filter((c) => c.detected).map((c) => c.id);
|
|
11606
|
+
// ── Version-keyed upgrade migrations (flair#1439) ─────────────────────────
|
|
11607
|
+
// Apply any pending migrations BEFORE the doctor catalog run so that the
|
|
11608
|
+
// catalog sees the post-migration state (e.g. the hook is present, so the
|
|
11609
|
+
// session-start-hook check passes and the upgrade prints ✅ verified: healthy).
|
|
11610
|
+
const migCtx = {
|
|
11611
|
+
homeDir,
|
|
11612
|
+
port: args.port,
|
|
11613
|
+
detectedClientIds,
|
|
11614
|
+
};
|
|
11615
|
+
const migrations = applyUpgradeMigrations(args.fromVersion, args.toVersion, migCtx);
|
|
11616
|
+
for (const { results } of migrations.applied) {
|
|
11617
|
+
for (const r of results) {
|
|
11618
|
+
if (r.wrote) {
|
|
11619
|
+
console.log(` ✓ ${r.message}`);
|
|
11620
|
+
}
|
|
11621
|
+
else if (!r.ok) {
|
|
11622
|
+
console.error(` • ${r.message}`);
|
|
11623
|
+
}
|
|
11624
|
+
// Silently skip no-op (ok, !wrote) — nothing changed, nothing to say.
|
|
11625
|
+
}
|
|
11626
|
+
}
|
|
11627
|
+
if (!migrations.allOk) {
|
|
11628
|
+
// A migration reported a non-fatal issue (each is logged above). Say so
|
|
11629
|
+
// plainly rather than let the upcoming ✅ verified summary imply the upgrade
|
|
11630
|
+
// finished cleanly — the doctor catalog below reflects the real state.
|
|
11631
|
+
console.error(" • one or more upgrade migrations did not complete cleanly — the doctor check below shows the current state.");
|
|
11632
|
+
}
|
|
11633
|
+
const ctx = {
|
|
11634
|
+
homeDir,
|
|
11635
|
+
cwd: process.cwd(),
|
|
11636
|
+
detectedClientIds,
|
|
11637
|
+
launchd: args.management,
|
|
11638
|
+
keysDir,
|
|
11639
|
+
keyAgentIds: collectKeyAgentIds(keysDir),
|
|
11640
|
+
};
|
|
11641
|
+
const run = runDoctorChecks(ctx);
|
|
11642
|
+
const apply = (promptAccepted) => applyUpgradeHookConsent({
|
|
11643
|
+
homeDir,
|
|
11644
|
+
ctx,
|
|
11645
|
+
run,
|
|
11646
|
+
installHooksFlag: args.installHooksFlag,
|
|
11647
|
+
interactive: !!process.stdin.isTTY,
|
|
11648
|
+
promptAccepted,
|
|
11649
|
+
port: args.port,
|
|
11650
|
+
});
|
|
11651
|
+
let outcome = apply();
|
|
11652
|
+
if (outcome.consent === "prompt" && outcome.prompt) {
|
|
11653
|
+
console.log("");
|
|
11654
|
+
for (const line of outcome.prompt.preamble)
|
|
11655
|
+
console.log(` ${line}`);
|
|
11656
|
+
outcome = apply(await confirmYes(outcome.prompt.question));
|
|
11657
|
+
}
|
|
11658
|
+
if (outcome.consent === "install") {
|
|
11659
|
+
for (const w of outcome.writes) {
|
|
11660
|
+
console.log(` ${w.ok ? "✓" : "•"} ${w.message}`);
|
|
11661
|
+
}
|
|
11662
|
+
}
|
|
11663
|
+
if (outcome.consent === "skip-noninteractive") {
|
|
11664
|
+
for (const line of outcome.messages) {
|
|
11665
|
+
console.error(` ${line}`);
|
|
11666
|
+
}
|
|
11667
|
+
}
|
|
11668
|
+
return outcome.run;
|
|
11669
|
+
}
|
|
11670
|
+
function printVerifiedSummary(summary) {
|
|
11671
|
+
for (const line of summary.lines) {
|
|
11672
|
+
if (summary.degraded)
|
|
11673
|
+
console.error(line);
|
|
11674
|
+
else
|
|
11675
|
+
console.log(line);
|
|
11676
|
+
}
|
|
11677
|
+
}
|
|
11302
11678
|
/**
|
|
11303
11679
|
* Observe whether `dataDir`'s instance is running under launchd right now.
|
|
11304
11680
|
*
|
|
@@ -11343,13 +11719,13 @@ function observeLaunchdManagement(dataDir, port) {
|
|
|
11343
11719
|
* data dir does, via `resolveLaunchdLabel`.
|
|
11344
11720
|
*
|
|
11345
11721
|
* Idempotent-ish: stopping an already-stopped instance is a harmless no-op
|
|
11346
|
-
* on both paths (launchctl stop on an unloaded/idle service, or
|
|
11347
|
-
*
|
|
11722
|
+
* on both paths (launchctl stop on an unloaded/idle service, or a
|
|
11723
|
+
* NOT_RUNNING classification from the liveness machine).
|
|
11348
11724
|
*
|
|
11349
11725
|
* Throws when the resolved target provably belongs to a different instance
|
|
11350
|
-
* — see assertLaunchdServiceOwnedBy /
|
|
11351
|
-
* already treat a failed stop as fatal, which
|
|
11352
|
-
* quiescing the wrong install.
|
|
11726
|
+
* — see assertLaunchdServiceOwnedBy, or a DISAGREEMENT/UNKNOWN verdict from
|
|
11727
|
+
* the liveness machine. Callers already treat a failed stop as fatal, which
|
|
11728
|
+
* is the point: refusing beats quiescing the wrong install.
|
|
11353
11729
|
*/
|
|
11354
11730
|
async function stopFlairProcess(port, dataDir) {
|
|
11355
11731
|
if (process.platform === "darwin") {
|
|
@@ -11421,43 +11797,42 @@ async function stopFlairProcess(port, dataDir) {
|
|
|
11421
11797
|
}
|
|
11422
11798
|
}
|
|
11423
11799
|
}
|
|
11424
|
-
// Port-based stop (Linux, or macOS fallback when no launchd plist)
|
|
11800
|
+
// Port-based stop (Linux, or macOS fallback when no launchd plist) — the
|
|
11801
|
+
// five-state liveness machine (flair#1454). The old lsof-based tree is
|
|
11802
|
+
// REPLACED, not patched: `lsof` absence used to render as "not running", and
|
|
11803
|
+
// the pidfile was only consulted to attribute port-derived PIDs. Now the
|
|
11804
|
+
// pidfile + identity sidecar are the primary evidence, and the health probe
|
|
11805
|
+
// is a cross-check — never the verdict.
|
|
11425
11806
|
console.log("Stopping...");
|
|
11426
|
-
const
|
|
11427
|
-
|
|
11428
|
-
|
|
11429
|
-
|
|
11430
|
-
|
|
11431
|
-
|
|
11432
|
-
|
|
11433
|
-
|
|
11434
|
-
|
|
11435
|
-
|
|
11436
|
-
|
|
11437
|
-
|
|
11438
|
-
|
|
11439
|
-
|
|
11440
|
-
|
|
11441
|
-
|
|
11442
|
-
|
|
11443
|
-
|
|
11444
|
-
|
|
11445
|
-
|
|
11446
|
-
|
|
11447
|
-
}
|
|
11448
|
-
catch { }
|
|
11449
|
-
}
|
|
11450
|
-
// flair#905 / lrf5: wait for every signalled process to actually exit.
|
|
11451
|
-
// A blind 2-second sleep is not a guarantee — Harper may be flushing
|
|
11452
|
-
// RocksDB WAL/MANIFEST, and the next start will fail with a locked data
|
|
11453
|
-
// directory if the old process hasn't released it yet. The launchd path
|
|
11454
|
-
// above already does this via waitForProcessExit; the port-based path
|
|
11455
|
-
// must match that guarantee.
|
|
11456
|
-
for (const target of targets) {
|
|
11457
|
-
try {
|
|
11458
|
-
await waitForProcessExit(target, STARTUP_TIMEOUT_MS);
|
|
11807
|
+
const evidence = await gatherDaemonEvidence(port, dataDir);
|
|
11808
|
+
const state = classifyDaemonState(evidence, { port, dataDir });
|
|
11809
|
+
switch (state.state) {
|
|
11810
|
+
case "RUNNING":
|
|
11811
|
+
case "WEDGED": {
|
|
11812
|
+
// Identity is already proven for both — killing a wedged daemon is
|
|
11813
|
+
// recovery, not a recycled-PID gamble.
|
|
11814
|
+
const pid = state.pid;
|
|
11815
|
+
try {
|
|
11816
|
+
process.kill(pid, "SIGTERM");
|
|
11817
|
+
}
|
|
11818
|
+
catch { /* already gone */ }
|
|
11819
|
+
// flair#905 / lrf5: wait for the signalled process to actually exit. A
|
|
11820
|
+
// blind sleep is not a guarantee — Harper may be flushing RocksDB
|
|
11821
|
+
// WAL/MANIFEST, and the next start fails with a locked data directory if
|
|
11822
|
+
// the old process hasn't released it yet.
|
|
11823
|
+
try {
|
|
11824
|
+
await waitForProcessExit(pid, STARTUP_TIMEOUT_MS);
|
|
11825
|
+
}
|
|
11826
|
+
catch { /* best-effort — the next start will surface the real problem */ }
|
|
11827
|
+
return;
|
|
11459
11828
|
}
|
|
11460
|
-
|
|
11829
|
+
case "NOT_RUNNING":
|
|
11830
|
+
return; // idempotent no-op
|
|
11831
|
+
case "DISAGREEMENT":
|
|
11832
|
+
case "UNKNOWN":
|
|
11833
|
+
// Deliberately outside any catch: a refusal must reach the caller, not
|
|
11834
|
+
// be swallowed as "not running" and reported as a successful stop.
|
|
11835
|
+
throw new Error(`refusing to stop: ${state.detail}`);
|
|
11461
11836
|
}
|
|
11462
11837
|
}
|
|
11463
11838
|
/**
|
|
@@ -11586,6 +11961,10 @@ async function startFlairProcess(port, dataDir) {
|
|
|
11586
11961
|
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
11587
11962
|
});
|
|
11588
11963
|
proc.unref();
|
|
11964
|
+
// Identity sidecar immediately after spawn (flair#1454 decision 3), before
|
|
11965
|
+
// waitForHealth so startTimeMs stays within the ±2s tolerance.
|
|
11966
|
+
if (proc.pid)
|
|
11967
|
+
writeDaemonSidecar(dataDir, proc.pid, port);
|
|
11589
11968
|
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
11590
11969
|
readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
|
|
11591
11970
|
stampEngineVersionIfRunning(dataDir); // flair#1047: stamp the store with the engine version
|
|
@@ -13057,6 +13436,21 @@ program
|
|
|
13057
13436
|
return /^y(es)?$/i.test(answer.trim());
|
|
13058
13437
|
}
|
|
13059
13438
|
const detectedClients = detectClients().filter((c) => c.detected);
|
|
13439
|
+
// flair#1439 — install-health (MCP, FLAIR_URL, CLAUDE.md, SessionStart
|
|
13440
|
+
// hook, verified-read plan, keys classification, launchd) is the same
|
|
13441
|
+
// catalog upgrade asserts. Adding a check to DOCTOR_CHECK_IDS widens
|
|
13442
|
+
// both. Extra doctor UX (pi, --fix, execution probe, continuity,
|
|
13443
|
+
// agent registration) stays below and does not redefine those checks.
|
|
13444
|
+
const doctorCtx = {
|
|
13445
|
+
homeDir: homedir(),
|
|
13446
|
+
cwd: process.cwd(),
|
|
13447
|
+
detectedClientIds: detectedClients.map((c) => c.id),
|
|
13448
|
+
launchd: observeLaunchdManagement(defaultDataDir(), effectivePort),
|
|
13449
|
+
keysDir,
|
|
13450
|
+
keyAgentIds,
|
|
13451
|
+
agentFlag: typeof opts.agent === "string" ? opts.agent : undefined,
|
|
13452
|
+
};
|
|
13453
|
+
const catalogBefore = runDoctorChecks(doctorCtx);
|
|
13060
13454
|
if (detectedClients.length === 0) {
|
|
13061
13455
|
console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
|
|
13062
13456
|
}
|
|
@@ -13291,7 +13685,6 @@ program
|
|
|
13291
13685
|
wireCursor(wireEnv);
|
|
13292
13686
|
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
13293
13687
|
if (wireResult.ok) {
|
|
13294
|
-
fixed++;
|
|
13295
13688
|
if (client.id === "claude-code")
|
|
13296
13689
|
claudeCodeAgentId = fixAgentId;
|
|
13297
13690
|
if (client.id === "codex")
|
|
@@ -13311,7 +13704,6 @@ program
|
|
|
13311
13704
|
const agentHint = knownAgentId ? "" : fixCommandAgentHint(keyAgentIds);
|
|
13312
13705
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix${agentHint} ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
|
|
13313
13706
|
}
|
|
13314
|
-
issues++;
|
|
13315
13707
|
continue;
|
|
13316
13708
|
}
|
|
13317
13709
|
console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
|
|
@@ -13369,15 +13761,12 @@ program
|
|
|
13369
13761
|
else {
|
|
13370
13762
|
const fixRes = fixClaudeMdBootstrap(process.cwd());
|
|
13371
13763
|
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
13372
|
-
if (fixRes.ok)
|
|
13373
|
-
fixed++;
|
|
13374
13764
|
}
|
|
13375
13765
|
}
|
|
13376
13766
|
}
|
|
13377
13767
|
else {
|
|
13378
13768
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the mcp__flair__bootstrap line to ./CLAUDE.md)")}`);
|
|
13379
13769
|
}
|
|
13380
|
-
issues++;
|
|
13381
13770
|
}
|
|
13382
13771
|
// flair#1007: presence was never the problem — the failing entry was
|
|
13383
13772
|
// perfectly well-formed. inspectSessionStartHook() additionally RUNS
|
|
@@ -13446,8 +13835,6 @@ program
|
|
|
13446
13835
|
else {
|
|
13447
13836
|
const upgrade = upgradeSessionStartHookCommand(homedir());
|
|
13448
13837
|
console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
|
|
13449
|
-
if (upgrade.ok && upgrade.changed)
|
|
13450
|
-
fixed++;
|
|
13451
13838
|
}
|
|
13452
13839
|
}
|
|
13453
13840
|
}
|
|
@@ -13458,7 +13845,6 @@ program
|
|
|
13458
13845
|
else {
|
|
13459
13846
|
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`);
|
|
13460
13847
|
}
|
|
13461
|
-
issues++;
|
|
13462
13848
|
}
|
|
13463
13849
|
}
|
|
13464
13850
|
else {
|
|
@@ -13476,15 +13862,12 @@ program
|
|
|
13476
13862
|
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
13477
13863
|
const fixRes = fixSessionStartHook(homedir(), fixAgentId);
|
|
13478
13864
|
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
13479
|
-
if (fixRes.ok)
|
|
13480
|
-
fixed++;
|
|
13481
13865
|
}
|
|
13482
13866
|
}
|
|
13483
13867
|
}
|
|
13484
13868
|
else {
|
|
13485
13869
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
|
|
13486
13870
|
}
|
|
13487
|
-
issues++;
|
|
13488
13871
|
}
|
|
13489
13872
|
// flair#1257 slice 2 — continuity capture pair (the check-5 twin of
|
|
13490
13873
|
// the SessionStart check above: installed / absent / stale-form).
|
|
@@ -13584,8 +13967,6 @@ program
|
|
|
13584
13967
|
else {
|
|
13585
13968
|
const upgrade = upgradeSessionStartHookCommand(homedir(), hook.path);
|
|
13586
13969
|
console.log(` ${upgrade.ok ? render.icons.ok : render.icons.warn} ${upgrade.message}`);
|
|
13587
|
-
if (upgrade.ok && upgrade.changed)
|
|
13588
|
-
fixed++;
|
|
13589
13970
|
}
|
|
13590
13971
|
}
|
|
13591
13972
|
}
|
|
@@ -13596,7 +13977,6 @@ program
|
|
|
13596
13977
|
else {
|
|
13597
13978
|
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 --harness codex`);
|
|
13598
13979
|
}
|
|
13599
|
-
issues++;
|
|
13600
13980
|
}
|
|
13601
13981
|
}
|
|
13602
13982
|
else {
|
|
@@ -13614,18 +13994,27 @@ program
|
|
|
13614
13994
|
const fixAgentId = resolveHookAgentId({ agent: opts.agent }, homedir(), "codex");
|
|
13615
13995
|
const fixRes = fixSessionStartHook(homedir(), fixAgentId, hook.path);
|
|
13616
13996
|
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
13617
|
-
if (fixRes.ok)
|
|
13618
|
-
fixed++;
|
|
13619
13997
|
}
|
|
13620
13998
|
}
|
|
13621
13999
|
}
|
|
13622
14000
|
else {
|
|
13623
14001
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install --harness codex`);
|
|
13624
14002
|
}
|
|
13625
|
-
issues++;
|
|
13626
14003
|
}
|
|
13627
14004
|
}
|
|
13628
14005
|
}
|
|
14006
|
+
// Catalog is the install-health verdict — count fail/unrun here, not
|
|
14007
|
+
// via a second issues++ on MCP / CLAUDE.md / SessionStart hook above.
|
|
14008
|
+
// --fix that cleared a catalog member shows up in the found→fixed delta.
|
|
14009
|
+
const catalogAfter = autoFix ? runDoctorChecks(doctorCtx) : catalogBefore;
|
|
14010
|
+
const catalogDelta = catalogIssueDelta(catalogBefore, catalogAfter);
|
|
14011
|
+
issues += catalogDelta.found;
|
|
14012
|
+
if (autoFix)
|
|
14013
|
+
fixed += catalogDelta.fixed;
|
|
14014
|
+
console.log(`\n ${render.wrap(render.c.bold, "Install health")}`);
|
|
14015
|
+
for (const row of renderCatalogDoctorLines(catalogAfter)) {
|
|
14016
|
+
console.log(` ${render.icons[row.icon]} ${row.line}`);
|
|
14017
|
+
}
|
|
13629
14018
|
// 7a. Resolve which agent identities the two verified-read sections below
|
|
13630
14019
|
// (Fleet presence, Migrations) iterate (flair#722). Previously both
|
|
13631
14020
|
// sections required --agent explicitly; doctor already enumerates every
|
|
@@ -15393,6 +15782,7 @@ memory.command("search [query]")
|
|
|
15393
15782
|
.option("--q <query>", "search query (alias for positional arg)")
|
|
15394
15783
|
.option("--limit <n>", "Max results", "5")
|
|
15395
15784
|
.option("--tag <tag>")
|
|
15785
|
+
.option("--include-archived", "Include basemented (archived) memories in results (default: excluded)")
|
|
15396
15786
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
15397
15787
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15398
15788
|
.option("--port <port>", "Harper HTTP port")
|
|
@@ -15410,10 +15800,51 @@ memory.command("search [query]")
|
|
|
15410
15800
|
const body = { agentId, q, limit: parseInt(opts.limit, 10) || 5 };
|
|
15411
15801
|
if (opts.tag)
|
|
15412
15802
|
body.tag = opts.tag;
|
|
15803
|
+
if (opts.includeArchived)
|
|
15804
|
+
body.includeArchived = true;
|
|
15413
15805
|
const baseUrl = resolveBaseUrl(opts);
|
|
15414
15806
|
const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
15415
15807
|
console.log(JSON.stringify(res, null, 2));
|
|
15416
15808
|
});
|
|
15809
|
+
// ─── flair memory basement / restore ────────────────────────────────────────
|
|
15810
|
+
// flair#1472 Deliverable A — the user-facing archive action. `basement` sends a
|
|
15811
|
+
// memory to the basement (archived=true + stamps archivedAt); `restore`
|
|
15812
|
+
// un-basements it (clears archived/archivedAt/archivedBy). Both are GLOBAL and
|
|
15813
|
+
// deliberate: restore un-retires the memory for EVERY session, not a
|
|
15814
|
+
// session-local view (per-session reuse is drawers, Deliverable B, which does
|
|
15815
|
+
// not exist yet). Scoped to the caller's own memories (own-lane write).
|
|
15816
|
+
memory.command("basement <id>")
|
|
15817
|
+
.description("Send a memory to the basement (archive it). Removes it from bootstrap + default search; still retrievable via `memory search --include-archived`. GLOBAL and deliberate — scoped to your own memories.")
|
|
15818
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
15819
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
15820
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15821
|
+
.option("--port <port>", "Harper HTTP port")
|
|
15822
|
+
.action(async (id, opts) => {
|
|
15823
|
+
const agentId = resolveSigningAgentId(opts, "memory basement");
|
|
15824
|
+
if (!agentId) {
|
|
15825
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15826
|
+
process.exit(2);
|
|
15827
|
+
}
|
|
15828
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
15829
|
+
const res = await api("POST", "/MemoryArchive", { id, action: "basement" }, { baseUrl, agentId });
|
|
15830
|
+
console.log(JSON.stringify(res, null, 2));
|
|
15831
|
+
});
|
|
15832
|
+
memory.command("restore <id>")
|
|
15833
|
+
.description("Restore a basemented (archived) memory. Clears archived/archivedAt/archivedBy. GLOBAL and deliberate — this un-retires the memory for EVERY session, not a session-local view (per-session reuse is drawers, which do not exist yet). Scoped to your own memories.")
|
|
15834
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
15835
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
15836
|
+
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
15837
|
+
.option("--port <port>", "Harper HTTP port")
|
|
15838
|
+
.action(async (id, opts) => {
|
|
15839
|
+
const agentId = resolveSigningAgentId(opts, "memory restore");
|
|
15840
|
+
if (!agentId) {
|
|
15841
|
+
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
15842
|
+
process.exit(2);
|
|
15843
|
+
}
|
|
15844
|
+
const baseUrl = resolveBaseUrl(opts);
|
|
15845
|
+
const res = await api("POST", "/MemoryArchive", { id, action: "restore" }, { baseUrl, agentId });
|
|
15846
|
+
console.log(JSON.stringify(res, null, 2));
|
|
15847
|
+
});
|
|
15417
15848
|
memory.command("list")
|
|
15418
15849
|
.description("List an agent's memories (optionally filtered by --tag or embedding-backfill triage)")
|
|
15419
15850
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|