@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/doctor-client.js
CHANGED
|
@@ -1117,8 +1117,8 @@ function sessionStartHookHint(agentId, path) {
|
|
|
1117
1117
|
* after the hook is present (whether from a prior call or already there)
|
|
1118
1118
|
* reports ok:true, applied:false — safe to call on every `flair init`.
|
|
1119
1119
|
*/
|
|
1120
|
-
export function applyOrReportSessionStartHook(homeDir, agentId, skip) {
|
|
1121
|
-
const existing = checkSessionStartHook(homeDir);
|
|
1120
|
+
export function applyOrReportSessionStartHook(homeDir, agentId, skip, settingsPath) {
|
|
1121
|
+
const existing = checkSessionStartHook(homeDir, settingsPath);
|
|
1122
1122
|
if (existing.present) {
|
|
1123
1123
|
return { applied: false, ok: true, message: `SessionStart hook already wired in ${existing.path}` };
|
|
1124
1124
|
}
|
|
@@ -1126,7 +1126,7 @@ export function applyOrReportSessionStartHook(homeDir, agentId, skip) {
|
|
|
1126
1126
|
if (skip) {
|
|
1127
1127
|
return { applied: false, ok: false, message: "SessionStart hook skipped (--skip-hook)", hint };
|
|
1128
1128
|
}
|
|
1129
|
-
const fix = fixSessionStartHook(homeDir, agentId);
|
|
1129
|
+
const fix = fixSessionStartHook(homeDir, agentId, settingsPath ?? existing.path);
|
|
1130
1130
|
return { applied: fix.ok, ok: fix.ok, message: fix.message, hint: fix.ok ? undefined : hint };
|
|
1131
1131
|
}
|
|
1132
1132
|
// ── check 5: per-agent iteration for verified-read sections (flair#722) ────
|
package/dist/install/clients.js
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// @tpsdev-ai/flair-mcp); pi is a native-extension host (kind:
|
|
6
6
|
// "native-extension" — wired via pi's own settings.json `packages` key,
|
|
7
7
|
// flair#1342). Each client has:
|
|
8
|
-
// - detection: `bin` on PATH
|
|
8
|
+
// - detection: `bin` on PATH OR the client's known config path exists
|
|
9
|
+
// (flair#1417 — GUI-only Cursor, PATH-quirky installs). A declared
|
|
10
|
+
// detect() override remains the exception (pi).
|
|
9
11
|
// - wire(env): { ok: boolean; message: string }
|
|
10
12
|
//
|
|
11
13
|
// Wiring contract (FIX 4 — onboarding dogfood round 1):
|
|
@@ -89,12 +91,13 @@ function binInPath(name) {
|
|
|
89
91
|
* elsewhere — the same defect already fixed for `flair upgrade`'s presence
|
|
90
92
|
* probes (see "Upgrade presence probes" in src/cli.ts).
|
|
91
93
|
*
|
|
92
|
-
* Nothing is lost by dropping
|
|
93
|
-
* into the prefix's bin directory, which is on PATH by
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
94
|
+
* Nothing is lost by dropping the npm-list fallback: an `npm install -g` links
|
|
95
|
+
* the package's bin into the prefix's bin directory, which is on PATH by
|
|
96
|
+
* construction. Detection still asks PATH first. A second signal is the
|
|
97
|
+
* client's known config path (detectClients, flair#1417): a GUI install whose
|
|
98
|
+
* shell command is opt-in (Cursor) is still present when its config file
|
|
99
|
+
* exists. `flair init --client <name>` still wires a client explicitly,
|
|
100
|
+
* bypassing detection entirely, so an exotic install is never locked out.
|
|
98
101
|
*/
|
|
99
102
|
function detectBin(bin) {
|
|
100
103
|
try {
|
|
@@ -560,10 +563,12 @@ function antigravityConfigPath() {
|
|
|
560
563
|
return join(resolveHome(), ".gemini", "config", "mcp_config.json");
|
|
561
564
|
}
|
|
562
565
|
/**
|
|
563
|
-
* Single dispatcher for "where does this client's
|
|
566
|
+
* Single dispatcher for "where does this client's config live" — used by
|
|
564
567
|
* `flair doctor`'s client-integration checks (flair#588) to read the config
|
|
565
|
-
* without duplicating the per-client path logic that already lives here
|
|
566
|
-
*
|
|
568
|
+
* without duplicating the per-client path logic that already lives here,
|
|
569
|
+
* and by detectClients() as the config-path presence signal (flair#1417).
|
|
570
|
+
* Every current ClientId has a path; a future id must extend this switch
|
|
571
|
+
* or stay binary-only via a `detect` override — do not invent a path.
|
|
567
572
|
*/
|
|
568
573
|
export function clientConfigPath(id) {
|
|
569
574
|
switch (id) {
|
|
@@ -758,17 +763,23 @@ export function renderWiringSummary(results, opts = {}) {
|
|
|
758
763
|
return lines;
|
|
759
764
|
}
|
|
760
765
|
/**
|
|
761
|
-
* Detect every known client. One rule
|
|
762
|
-
* client
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
766
|
+
* Detect every known client. One rule applied uniformly: `bin` on PATH OR
|
|
767
|
+
* the client's known config path exists (flair#1417). A client added to
|
|
768
|
+
* ALL_CLIENTS is detected by declaring its executable; clientConfigPath()
|
|
769
|
+
* already names every current id, so the config-path fallback is not a
|
|
770
|
+
* per-client branch to forget. A declared `detect` override remains the
|
|
771
|
+
* exception (still a pure fs check — pi, flair#1342). Detection never
|
|
772
|
+
* spawns a subprocess (flair#946).
|
|
773
|
+
*
|
|
774
|
+
* A future client with no config path helper stays binary-only until it
|
|
775
|
+
* grows one here or a `detect` override — do not invent a path.
|
|
767
776
|
*/
|
|
768
777
|
export function detectClients() {
|
|
769
778
|
return ALL_CLIENTS.map((client) => ({
|
|
770
779
|
...client,
|
|
771
|
-
detected: client.detect
|
|
780
|
+
detected: client.detect
|
|
781
|
+
? client.detect()
|
|
782
|
+
: detectBin(client.bin) || existsSync(clientConfigPath(client.id)),
|
|
772
783
|
}));
|
|
773
784
|
}
|
|
774
785
|
export function wireClaudeCode(env) {
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemon-liveness.ts — the five-state liveness machine for a local Flair
|
|
3
|
+
* (Harper) daemon (flair#1454).
|
|
4
|
+
*
|
|
5
|
+
* The defect this replaces: `flair stop` decided "not running" from a single
|
|
6
|
+
* `lsof -ti :<port>` whose `catch` converted "lsof unavailable" into "nothing
|
|
7
|
+
* is listening" — absence rendered as a definite negative. `flair start` then
|
|
8
|
+
* "succeeded" over the live daemon it had just failed to see, and a
|
|
9
|
+
* `stop; start` wrapper (a systemd `Type=forking` unit's `ExecStop`, say)
|
|
10
|
+
* produced a second daemon while the first kept running.
|
|
11
|
+
*
|
|
12
|
+
* The fix is a classifier with FIVE states, not a boolean:
|
|
13
|
+
*
|
|
14
|
+
* RUNNING identity-verified pid alive + health 200
|
|
15
|
+
* NOT_RUNNING no pidfile, or verified dead + port refused
|
|
16
|
+
* WEDGED identity-VERIFIED pid alive + not serving -> stop ACTS
|
|
17
|
+
* DISAGREEMENT evidence conflicts, identity NOT verified -> stop REFUSES
|
|
18
|
+
* UNKNOWN insufficient evidence to classify -> stop REFUSES
|
|
19
|
+
*
|
|
20
|
+
* THE INVARIANT (the whole design): WEDGED is reachable ONLY when the pidfile
|
|
21
|
+
* identity has been VERIFIED. The same physical evidence with an unverified
|
|
22
|
+
* identity lands in DISAGREEMENT. This is encoded structurally — the classifier
|
|
23
|
+
* takes the identity-check RESULT as an input, and there is no code path to
|
|
24
|
+
* WEDGED that bypasses it. Killing a wedged daemon is recovery only when we
|
|
25
|
+
* have proven the pid is ours; without that proof it is a recycled-PID gamble
|
|
26
|
+
* and the machine refuses.
|
|
27
|
+
*
|
|
28
|
+
* Identity is carried by a sidecar (`<dataDir>/flair-daemon.json`) that flair
|
|
29
|
+
* writes at spawn — `{ pid, startTimeMs, port, flairVersion }` — because
|
|
30
|
+
* `hdb.pid` is written by the Harper process itself and flair does not own its
|
|
31
|
+
* format. Verification = hdb.pid's pid matches the sidecar pid AND the live
|
|
32
|
+
* process's start time matches the sidecar's startTimeMs (±2s).
|
|
33
|
+
*
|
|
34
|
+
* This module is PURE: it classifies and parses, and never touches the
|
|
35
|
+
* filesystem, the network, or a process. The adapters that do (O_NOFOLLOW
|
|
36
|
+
* reads, `kill(pid, 0)`, the health probe, the start-time readers) live in
|
|
37
|
+
* `src/cli.ts`, so every branch here is unit-testable without a daemon.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* Classify the gathered evidence into one of the five states.
|
|
41
|
+
*
|
|
42
|
+
* The WEDGED gate is the first branch and the only one that can return WEDGED:
|
|
43
|
+
* it requires `identity.kind === "verified"` AND `pidLiveness.kind === "alive"`.
|
|
44
|
+
* Every other "alive" pid — unverified identity, or EPERM — is DISAGREEMENT.
|
|
45
|
+
*/
|
|
46
|
+
export function classifyDaemonState(ev, ctx) {
|
|
47
|
+
if (ev.dataDirUnsafe !== null) {
|
|
48
|
+
return { state: "UNKNOWN", detail: ev.dataDirUnsafe };
|
|
49
|
+
}
|
|
50
|
+
if (ev.pidfile.kind === "unreadable") {
|
|
51
|
+
return { state: "UNKNOWN", detail: ev.pidfile.reason };
|
|
52
|
+
}
|
|
53
|
+
const pid = ev.pidfile.kind === "present" ? ev.pidfile.pid : null;
|
|
54
|
+
const liveness = ev.pidLiveness;
|
|
55
|
+
// THE INVARIANT: WEDGED is reachable only through a VERIFIED identity.
|
|
56
|
+
if (ev.identity.kind === "verified" && liveness?.kind === "alive") {
|
|
57
|
+
if (ev.health.kind === "ok") {
|
|
58
|
+
return { state: "RUNNING", pid: ev.identity.pid };
|
|
59
|
+
}
|
|
60
|
+
return { state: "WEDGED", pid: ev.identity.pid };
|
|
61
|
+
}
|
|
62
|
+
// No live pid recorded (absent, or the recorded pid is gone).
|
|
63
|
+
if (pid === null || liveness?.kind === "gone") {
|
|
64
|
+
if (ev.health.kind === "refused") {
|
|
65
|
+
return { state: "NOT_RUNNING" };
|
|
66
|
+
}
|
|
67
|
+
if (ev.health.kind === "ok") {
|
|
68
|
+
return {
|
|
69
|
+
state: "DISAGREEMENT",
|
|
70
|
+
detail: pid === null
|
|
71
|
+
? `a process is serving port ${ctx.port}, but no pid is recorded under ${ctx.dataDir}`
|
|
72
|
+
: `a process is serving port ${ctx.port}, but the recorded pid ${pid} is not alive`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
state: "UNKNOWN",
|
|
77
|
+
detail: `could not determine whether Flair is running: ` +
|
|
78
|
+
`${pid === null ? "no pid is recorded" : `recorded pid ${pid} is not alive`} ` +
|
|
79
|
+
`and the health check on port ${ctx.port} did not respond`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// EPERM — the recorded pid exists but belongs to another user.
|
|
83
|
+
if (liveness?.kind === "eperm") {
|
|
84
|
+
return {
|
|
85
|
+
state: "DISAGREEMENT",
|
|
86
|
+
detail: `the recorded pid ${pid} exists but belongs to another user — refusing to act on it`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Alive, but identity NOT verified — the WEDGED shape without the proof.
|
|
90
|
+
if (liveness?.kind === "alive") {
|
|
91
|
+
const reason = ev.identity.kind === "unverified" ? ev.identity.reason : "no identity sidecar";
|
|
92
|
+
return {
|
|
93
|
+
state: "DISAGREEMENT",
|
|
94
|
+
detail: `the recorded pid ${pid} is alive, but its identity could not be verified (${reason}) — refusing to act on it`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return { state: "UNKNOWN", detail: "could not determine whether Flair is running" };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Verify the pidfile identity against the sidecar. Pure — `readStartTime` is
|
|
101
|
+
* injected so the live-process read (the only non-deterministic part) stays in
|
|
102
|
+
* the adapter layer.
|
|
103
|
+
*
|
|
104
|
+
* Verified requires ALL of: a pidfile pid, a readable sidecar, matching pids,
|
|
105
|
+
* a readable live start time, and a start time within `toleranceMs` (±2s).
|
|
106
|
+
* Any shortfall is `unverified` (or `none` when there is nothing to check).
|
|
107
|
+
*/
|
|
108
|
+
export function verifyIdentity(input) {
|
|
109
|
+
const tolerance = input.toleranceMs ?? 2000;
|
|
110
|
+
if (input.pidfilePid === null) {
|
|
111
|
+
return { kind: "none" };
|
|
112
|
+
}
|
|
113
|
+
if (input.sidecar.kind === "absent") {
|
|
114
|
+
return { kind: "none" };
|
|
115
|
+
}
|
|
116
|
+
if (input.sidecar.kind === "unreadable") {
|
|
117
|
+
return { kind: "unverified", reason: input.sidecar.reason };
|
|
118
|
+
}
|
|
119
|
+
if (input.pidfilePid !== input.sidecar.pid) {
|
|
120
|
+
return {
|
|
121
|
+
kind: "unverified",
|
|
122
|
+
reason: `hdb.pid names pid ${input.pidfilePid} but the sidecar records pid ${input.sidecar.pid}`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const actual = input.readStartTime(input.pidfilePid);
|
|
126
|
+
if (actual === null) {
|
|
127
|
+
return { kind: "unverified", reason: `could not read the start time of pid ${input.pidfilePid}` };
|
|
128
|
+
}
|
|
129
|
+
if (!isStartTimeMatch(actual, input.sidecar.startTimeMs, tolerance)) {
|
|
130
|
+
return {
|
|
131
|
+
kind: "unverified",
|
|
132
|
+
reason: `pid ${input.pidfilePid} started at ${actual}ms but the sidecar records ${input.sidecar.startTimeMs}ms`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return { kind: "verified", pid: input.pidfilePid };
|
|
136
|
+
}
|
|
137
|
+
/** `|actual - recorded| <= tolerance`. */
|
|
138
|
+
export function isStartTimeMatch(actualMs, recordedMs, toleranceMs = 2000) {
|
|
139
|
+
return Math.abs(actualMs - recordedMs) <= toleranceMs;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parse `/proc/<pid>/stat` field 22 (starttime, in clock ticks).
|
|
143
|
+
*
|
|
144
|
+
* Field 2 (comm) is parenthesised and may itself contain spaces and `)`
|
|
145
|
+
* characters, so the split is on the LAST `)` — not the first space, which is
|
|
146
|
+
* the classic bug that mangles any process whose comm has a space in it.
|
|
147
|
+
* Returns the raw tick count; the ticks→epoch conversion (which needs boot
|
|
148
|
+
* time and CLK_TCK) lives in the adapter.
|
|
149
|
+
*/
|
|
150
|
+
export function parseProcStatStartTime(stat) {
|
|
151
|
+
const closeParen = stat.lastIndexOf(")");
|
|
152
|
+
if (closeParen < 0)
|
|
153
|
+
return null;
|
|
154
|
+
const rest = stat.slice(closeParen + 1).trim().split(/\s+/);
|
|
155
|
+
// rest[0] is field 3 (state); field 22 (starttime) is therefore rest[19].
|
|
156
|
+
const starttime = Number(rest[19]);
|
|
157
|
+
return Number.isFinite(starttime) ? starttime : null;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Convert a `/proc/<pid>/stat` starttime (clock ticks since boot) to epoch ms,
|
|
161
|
+
* given the system uptime in seconds and the current wall clock. Pure so the
|
|
162
|
+
* arithmetic is testable without a live process.
|
|
163
|
+
*/
|
|
164
|
+
export function procStartTimeToEpochMs(starttimeTicks, uptimeSeconds, nowMs, clkTck = 100) {
|
|
165
|
+
const bootTimeMs = nowMs - uptimeSeconds * 1000;
|
|
166
|
+
return bootTimeMs + (starttimeTicks / clkTck) * 1000;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Parse `ps -o lstart= -p <pid>` output ("Sat Aug 29 15:03:22 2026") to epoch
|
|
170
|
+
* ms. Returns null when the output is empty or unparseable — the caller treats
|
|
171
|
+
* that as "identity unverified", never as a verdict toward the destructive
|
|
172
|
+
* branch.
|
|
173
|
+
*/
|
|
174
|
+
export function parsePsLstart(output) {
|
|
175
|
+
const trimmed = output.trim();
|
|
176
|
+
if (!trimmed)
|
|
177
|
+
return null;
|
|
178
|
+
const ms = Date.parse(trimmed);
|
|
179
|
+
return Number.isFinite(ms) ? ms : null;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Parse the sidecar JSON. Returns null on malformed content or a missing /
|
|
183
|
+
* non-positive pid/startTimeMs/port — the caller reports that as "unreadable".
|
|
184
|
+
*/
|
|
185
|
+
export function parseSidecarJson(content) {
|
|
186
|
+
let obj;
|
|
187
|
+
try {
|
|
188
|
+
obj = JSON.parse(content);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
if (typeof obj !== "object" || obj === null)
|
|
194
|
+
return null;
|
|
195
|
+
const rec = obj;
|
|
196
|
+
const pid = Number(rec.pid);
|
|
197
|
+
const startTimeMs = Number(rec.startTimeMs);
|
|
198
|
+
const port = Number(rec.port);
|
|
199
|
+
const flairVersion = typeof rec.flairVersion === "string" ? rec.flairVersion : "";
|
|
200
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
201
|
+
return null;
|
|
202
|
+
if (!Number.isFinite(startTimeMs))
|
|
203
|
+
return null;
|
|
204
|
+
if (!Number.isInteger(port) || port <= 0)
|
|
205
|
+
return null;
|
|
206
|
+
return { pid, startTimeMs, port, flairVersion };
|
|
207
|
+
}
|