@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.
@@ -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) ────
@@ -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, optionally widened by a declared detect() override
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 it: an `npm install -g` links the package's bin
93
- * into the prefix's bin directory, which is on PATH by construction (it is where
94
- * `npm` itself is found from). A client whose binary is NOT on PATH is a client
95
- * the user cannot launch, and wiring an MCP config for it is at best a no-op.
96
- * `flair init --client <name>` still wires a client explicitly, bypassing
97
- * detection entirely, so an exotic install is never locked out.
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 MCP config live" — used by
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
- * Additive only: does not change existing wire/detect behavior.
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 (`bin` on PATH) applied uniformly — a
762
- * client added to ALL_CLIENTS is detected by declaring its executable, with no
763
- * per-client branch here to forget to extend. A client may widen that with a
764
- * declared `detect` override (still a pure fs check pi adds its settings
765
- * file as a second signal, flair#1342); the override lives on the registry
766
- * entry, so this function stays branch-free.
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 ? client.detect() : detectBin(client.bin),
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
+ }