@tpsdev-ai/flair 0.31.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -269,7 +269,19 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
269
269
  }
270
270
  catch { /* best effort */ }
271
271
  const legacyContent = readFileSync(resolved.plistPath, "utf-8");
272
- const newContent = legacyContent.replace(`<key>Label</key><string>${LEGACY_LAUNCHD_LABEL}</string>`, `<key>Label</key><string>${newLabel}</string>`);
272
+ // Use a function replacer to avoid $-sensitivity in the replacement
273
+ // string (flair#919). String.prototype.replace interprets $&, $', $`
274
+ // etc. in the replacement even when the search value is a plain string.
275
+ const labelSearch = `<key>Label</key><string>${LEGACY_LAUNCHD_LABEL}</string>`;
276
+ const labelReplacement = `<key>Label</key><string>${newLabel}</string>`;
277
+ const newContent = legacyContent.replace(labelSearch, () => labelReplacement);
278
+ // Refuse to propagate a malformed plist: if the Label wasn't found,
279
+ // the plist is not what we expect and migration must not write it.
280
+ if (newContent === legacyContent) {
281
+ throw new Error(`Legacy plist at ${resolved.plistPath} does not contain the expected ` +
282
+ `Label key — it may be malformed or from an unknown Flair version. ` +
283
+ `Remove it manually and re-run 'flair init'.`);
284
+ }
273
285
  writeFileSync(newPlistPath, newContent);
274
286
  try {
275
287
  unlinkSync(resolved.plistPath);
@@ -277,6 +289,58 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
277
289
  catch { /* best effort */ }
278
290
  return { migrated: true, label: newLabel, plistPath: newPlistPath };
279
291
  }
292
+ /**
293
+ * Clean up a pre-flair#693 legacy launchd plist (ai.tpsdev.flair) during
294
+ * init, but ONLY when it belongs to the data dir being initialised.
295
+ *
296
+ * flair#966: the legacy plist is a single global label — init must not
297
+ * unload/delete it unless ROOTPATH proves it serves this data dir.
298
+ *
299
+ * `runLaunchctl` is injected so tests can record/mock without touching
300
+ * real launchd. The caller (init) passes a real execSync wrapper; tests
301
+ * pass a recording stub.
302
+ */
303
+ function cleanupLegacyLaunchdPlist(dataDir, plistDir, runLaunchctl) {
304
+ const legacyPlistPath = launchdPlistPath(LEGACY_LAUNCHD_LABEL, plistDir);
305
+ if (!existsSync(legacyPlistPath))
306
+ return { action: "none" };
307
+ const legacyRootPath = readPlistRootPath(legacyPlistPath);
308
+ const legacyOwnedByUs = legacyRootPath !== null && resolve(legacyRootPath) === resolve(dataDir);
309
+ if (legacyOwnedByUs) {
310
+ let unloadFailed;
311
+ let deleteFailed;
312
+ try {
313
+ runLaunchctl(`launchctl unload "${legacyPlistPath}"`);
314
+ }
315
+ catch (err) {
316
+ unloadFailed = err?.message ?? String(err);
317
+ console.error(`Failed to unload legacy launchd service (${LEGACY_LAUNCHD_LABEL}): ` +
318
+ `${unloadFailed}. ` +
319
+ `The plist at ${legacyPlistPath} may still be loaded — ` +
320
+ `unload it manually with: launchctl unload "${legacyPlistPath}"`);
321
+ }
322
+ try {
323
+ unlinkSync(legacyPlistPath);
324
+ console.log(`Migrated off legacy launchd label (${LEGACY_LAUNCHD_LABEL}) ✓`);
325
+ }
326
+ catch (err) {
327
+ deleteFailed = err?.message ?? String(err);
328
+ console.error(`Failed to remove legacy launchd plist at ${legacyPlistPath}: ` +
329
+ `${deleteFailed}. Remove it manually.`);
330
+ }
331
+ return { action: "unloaded", unloadFailed, deleteFailed };
332
+ }
333
+ if (legacyRootPath !== null) {
334
+ console.log(`Skipped legacy launchd cleanup: the plist at ${legacyPlistPath} ` +
335
+ `belongs to data dir ${resolve(legacyRootPath)}, not ${resolve(dataDir)} — ` +
336
+ `that is a different Flair instance.`);
337
+ return { action: "skipped-foreign", foreignDataDir: resolve(legacyRootPath) };
338
+ }
339
+ console.log(`Skipped legacy launchd cleanup: could not determine which data dir ` +
340
+ `the plist at ${legacyPlistPath} serves. ` +
341
+ `If it is yours, remove it manually with: rm "${legacyPlistPath}"`);
342
+ return { action: "skipped-unknown" };
343
+ }
280
344
  /**
281
345
  * Load + start `dataDir`'s launchd service, migrating off a pre-flair#693
282
346
  * legacy registration FIRST if one is found (migrateLegacyLaunchdLabel
@@ -290,6 +354,13 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
290
354
  */
291
355
  function ensureLaunchdServiceLoaded(dataDir, runLaunchctl, launchAgentsDir = defaultLaunchAgentsDir()) {
292
356
  const migration = migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir);
357
+ // Unload first so a rewritten plist is re-read (flair#872).
358
+ // launchd caches the environment of an already-loaded job; load
359
+ // alone does not pick up changes to the plist on disk.
360
+ try {
361
+ runLaunchctl(`launchctl unload "${migration.plistPath}"`);
362
+ }
363
+ catch { /* not loaded, etc. — best effort */ }
293
364
  try {
294
365
  runLaunchctl(`launchctl load "${migration.plistPath}"`);
295
366
  }
@@ -2933,25 +3004,20 @@ program
2933
3004
  const plistDir = defaultLaunchAgentsDir();
2934
3005
  mkdirSync(plistDir, { recursive: true });
2935
3006
  const plistPath = launchdPlistPath(label, plistDir);
2936
- // flair#693 migration: a pre-flair#693 install registered under
3007
+ // flair#693 + flair#966: a pre-flair#693 install registered under
2937
3008
  // the bare LEGACY_LAUNCHD_LABEL. init always writes fresh plist
2938
3009
  // content below (it has the current ports/creds in hand), so
2939
3010
  // migration here is just "clean up the old registration" —
2940
3011
  // unload + remove it BEFORE writing the new one, so re-running
2941
3012
  // init never leaves two services behind for this data dir.
2942
- const legacyPlistPath = launchdPlistPath(LEGACY_LAUNCHD_LABEL, plistDir);
2943
- if (existsSync(legacyPlistPath)) {
2944
- try {
2945
- const { execSync } = await import("node:child_process");
2946
- execSync(`launchctl unload "${legacyPlistPath}"`, { stdio: "pipe" });
2947
- }
2948
- catch { /* best effort */ }
2949
- try {
2950
- unlinkSync(legacyPlistPath);
2951
- }
2952
- catch { /* best effort */ }
2953
- console.log(`Migrated off legacy launchd label (${LEGACY_LAUNCHD_LABEL}) ✓`);
2954
- }
3013
+ //
3014
+ // flair#966: the legacy plist is NOT scoped to this data dir —
3015
+ // it is a single global label. cleanupLegacyLaunchdPlist reads
3016
+ // ROOTPATH to establish ownership before touching it.
3017
+ cleanupLegacyLaunchdPlist(dataDir, plistDir, (cmd) => {
3018
+ const { execSync } = require("node:child_process");
3019
+ execSync(cmd, { stdio: "pipe" });
3020
+ });
2955
3021
  const opsSocket = join(dataDir, "operations-server");
2956
3022
  // authorizeLocal: false (flair#654) — same posture as the initial spawn
2957
3023
  // above; the launchd-managed process must not diverge from it.
@@ -9618,17 +9684,38 @@ program
9618
9684
  // PID — see parseListeningPids (flair#800/flair#905): this used to SIGTERM
9619
9685
  // every process holding ANY socket on the port, so `flair stop` could kill
9620
9686
  // itself (leaving Flair running) or kill an unrelated client of it.
9687
+ //
9688
+ // Attribution guard (flair#915): the port is not an identity. Refuse to
9689
+ // SIGTERM a PID that cannot be attributed to this instance.
9621
9690
  try {
9622
9691
  const { execSync } = await import("node:child_process");
9623
9692
  const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
9624
9693
  if (pids.length > 0) {
9625
- for (const pid of pids) {
9626
- try {
9627
- process.kill(pid, "SIGTERM");
9694
+ const dataDir = defaultDataDir();
9695
+ const harperPid = readHarperPid(dataDir);
9696
+ if (harperPid !== null && !pids.includes(harperPid)) {
9697
+ console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
9698
+ + `do not match this Flair instance (PID ${harperPid}). `
9699
+ + `Not stopping — cannot attribute the process to this instance. `
9700
+ + `Stop the process manually if it is not Flair.`);
9701
+ process.exit(1);
9702
+ }
9703
+ else if (harperPid === null) {
9704
+ console.error(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
9705
+ + `but no PID file in data directory — not a running Flair instance. `
9706
+ + `Not stopping — cannot attribute the process to this instance. `
9707
+ + `Stop the process manually if it is not Flair.`);
9708
+ process.exit(1);
9709
+ }
9710
+ else {
9711
+ for (const pid of pids) {
9712
+ try {
9713
+ process.kill(pid, "SIGTERM");
9714
+ }
9715
+ catch { /* already gone */ }
9628
9716
  }
9629
- catch { /* already gone */ }
9717
+ console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
9630
9718
  }
9631
- console.log(`✅ Flair stopped (killed PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")})`);
9632
9719
  }
9633
9720
  else {
9634
9721
  console.log("Flair is not running.");
@@ -9741,20 +9828,28 @@ program
9741
9828
  * Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD. Only the
9742
9829
  * extracted ROOTPATH path ever reaches a message.
9743
9830
  */
9744
- export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9745
- let declared = null;
9831
+ /**
9832
+ * Read the ROOTPATH declared in a launchd plist, or null if it cannot be
9833
+ * determined (file missing, unreadable, or no ROOTPATH key).
9834
+ *
9835
+ * The plist stores this XML-escaped (buildLaunchdPlist), so the returned
9836
+ * value is decoded through unescapeXml before being returned — a data dir
9837
+ * containing `&` is on disk as `&amp;` and this returns the literal `&`.
9838
+ *
9839
+ * Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD.
9840
+ */
9841
+ export function readPlistRootPath(plistPath) {
9746
9842
  try {
9747
9843
  const raw = readFileSync(plistPath, "utf-8");
9748
9844
  const m = raw.match(/<key>ROOTPATH<\/key>\s*<string>([^<]*)<\/string>/);
9749
- // The plist stores this XML-escaped (buildLaunchdPlist), so a data dir
9750
- // containing `&` is on disk as `&amp;`. Decode before comparing, or the
9751
- // path would never equal itself and this guard would refuse a legitimate
9752
- // stop/start on any instance whose path contains an escaped character.
9753
- declared = m ? unescapeXml(m[1]) : null;
9845
+ return m ? unescapeXml(m[1]) : null;
9754
9846
  }
9755
9847
  catch {
9756
- return; // unreadable — no evidence, don't block
9848
+ return null;
9757
9849
  }
9850
+ }
9851
+ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9852
+ const declared = readPlistRootPath(plistPath);
9758
9853
  if (declared === null)
9759
9854
  return;
9760
9855
  if (resolve(declared) === resolve(dataDir))
@@ -9785,17 +9880,29 @@ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9785
9880
  * wrong today whenever the port does not match.
9786
9881
  */
9787
9882
  function assertPortInstanceOwnedBy(port, dataDir, listeningPids) {
9788
- if (resolve(dataDir) === defaultDataDir())
9789
- return;
9883
+ // (flair#915) Apply the attribution check for ALL data directories, not
9884
+ // just non-default ones. The default-dir bypass was the residual gap that
9885
+ // #910 left behind — it allowed an unattributed SIGTERM on the default
9886
+ // install's port. The old concern (false refusal when hdb.pid is missing)
9887
+ // is actually the RIGHT behavior: no PID file means we cannot attribute the
9888
+ // listener, so we refuse. That is safer than killing the wrong process.
9790
9889
  const expected = readHarperPid(dataDir);
9791
- if (expected !== null && listeningPids.includes(expected))
9890
+ // No PID file — Harper is not (or was not) running in this directory.
9891
+ // The port is stale or held by something else; refuse to SIGTERM it.
9892
+ if (expected === null) {
9893
+ throw new Error(`refusing to stop the process listening on port ${port}: no hdb.pid under `
9894
+ + `${resolve(dataDir)}, so that is not a running instance. `
9895
+ + `Stopping by port alone would signal a process we cannot attribute. `
9896
+ + `If it is not Flair, stop it manually.`);
9897
+ }
9898
+ // PID file exists — the PID on the port must be Harper.
9899
+ if (listeningPids.includes(expected))
9792
9900
  return;
9793
- const why = expected === null
9794
- ? `no hdb.pid under that directory, so it does not look like a running instance`
9795
- : `its recorded PID ${expected} is not the process listening on ${port}`;
9796
- throw new Error(`refusing to stop the process listening on port ${port}: it cannot be attributed to ${resolve(dataDir)} (${why}). ` +
9797
- `Stopping by port alone would signal a different Flair instance. ` +
9798
- `Pass --port with the port ${resolve(dataDir)} actually serves, or omit --data-dir to operate on the default install.`);
9901
+ throw new Error(`refusing to stop the process listening on port ${port}: its recorded PID ${expected} `
9902
+ + `is not the process listening on ${port}. `
9903
+ + `Stopping by port alone would signal a different instance. `
9904
+ + `Pass --port with the port ${resolve(dataDir)} actually serves, `
9905
+ + `or stop the process manually.`);
9799
9906
  }
9800
9907
  /**
9801
9908
  * Stop the local Flair (Harper) process — launchd `stop` on darwin when a
@@ -9835,18 +9942,15 @@ async function stopFlairProcess(port, dataDir) {
9835
9942
  assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "stop");
9836
9943
  try {
9837
9944
  const { execSync } = await import("node:child_process");
9838
- // Ensure the service is loaded (init writes the plist but doesn't load it)
9839
- try {
9840
- execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
9841
- }
9842
- catch { }
9843
- // Capture the current PID *before* stopping so callers that
9945
+ // Capture the current PID *before* unloading so callers that
9844
9946
  // immediately restart can verify exit. Without this, waitForHealth
9845
9947
  // can race against the still-shutting-down old process and return
9846
- // success before KeepAlive brings the new one up.
9948
+ // success before the new one comes up.
9847
9949
  const oldPid = readHarperPid(dataDir);
9950
+ // unload stops the job AND prevents KeepAlive from respawning it.
9951
+ // launchctl stop alone is insufficient for a KeepAlive job (flair#874).
9848
9952
  try {
9849
- execSync(`launchctl stop ${label}`, { stdio: "pipe" });
9953
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
9850
9954
  }
9851
9955
  catch { }
9852
9956
  if (oldPid)
@@ -10100,7 +10204,11 @@ program
10100
10204
  .option("--purge", "Also remove data and keys (destructive)")
10101
10205
  .action(async (opts) => {
10102
10206
  const platform = process.platform;
10103
- const port = readPortFromConfig() ?? DEFAULT_PORT;
10207
+ // Use the unified resolver: Harper's config > per-user config > default.
10208
+ // A default of 19926 that is "present but wrong" beats the actual port
10209
+ // Harper is serving on (flair#819). resolveHttpPort reads Harper's own
10210
+ // config in the data directory, which is authoritative.
10211
+ const port = resolveHttpPort({}, "address");
10104
10212
  // Stop first: remove launchd service(s) on macOS, then kill by port on
10105
10213
  // all platforms. Removes BOTH the new instance-scoped plist and a
10106
10214
  // pre-flair#693 legacy plist if present — uninstall's job is to purge
@@ -10127,51 +10235,88 @@ program
10127
10235
  // Kill any process still on the port (covers direct-start, no-service, or
10128
10236
  // failed unload). Listening sockets only, never our own PID — see
10129
10237
  // parseListeningPids (flair#800/flair#905).
10238
+ //
10239
+ // Guard (flair#917): refuse to SIGTERM a PID that cannot be attributed to
10240
+ // this Flair instance. A port is not an identity — something else can hold
10241
+ // it. Killing the wrong PID and then purging data is the whole bug.
10242
+ let refusedKill = false;
10130
10243
  try {
10131
10244
  const { execSync } = await import("node:child_process");
10132
10245
  const pids = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
10133
10246
  if (pids.length > 0) {
10134
- for (const pid of pids) {
10135
- try {
10136
- process.kill(pid, "SIGTERM");
10247
+ // Verify ownership before killing: the PID must match this instance's
10248
+ // recorded PID (hdb.pid). If no PID file exists, Harper is already
10249
+ // stopped and the port is stale — safe to skip.
10250
+ const dataDir = defaultDataDir();
10251
+ const harperPid = readHarperPid(dataDir);
10252
+ if (harperPid !== null) {
10253
+ // PID file exists — the PID on the port must be Harper or we refuse.
10254
+ if (!pids.includes(harperPid)) {
10255
+ console.log(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
10256
+ + `do not match this Flair instance (PID ${harperPid}). `
10257
+ + `Not killing — cannot attribute the process to this instance. `
10258
+ + `Stop the process manually if it is not Flair.`);
10259
+ refusedKill = true;
10260
+ }
10261
+ else {
10262
+ for (const pid of pids) {
10263
+ try {
10264
+ process.kill(pid, "SIGTERM");
10265
+ }
10266
+ catch { }
10267
+ }
10268
+ await new Promise(r => setTimeout(r, 2000));
10269
+ console.log("✅ Flair process stopped");
10137
10270
  }
10138
- catch { }
10139
10271
  }
10140
- // Wait for process to release file handles (RocksDB)
10141
- await new Promise(r => setTimeout(r, 2000));
10142
- console.log("✅ Flair process stopped");
10272
+ else {
10273
+ // No PID file — Harper is not (or was not) running here.
10274
+ // The port may be stale or held by something else; don't risk killing it.
10275
+ console.log(`⚠️ Process(es) on port ${port} (PID${pids.length > 1 ? "s" : ""}: ${pids.join(", ")}) `
10276
+ + `but no PID file in data directory — not a running Flair instance. `
10277
+ + `Not killing — stop the process manually if it is not Flair.`);
10278
+ refusedKill = true;
10279
+ }
10143
10280
  }
10144
10281
  }
10145
10282
  catch { /* not running */ }
10146
- // Remove config
10147
- const cfgPath = configPath();
10148
- if (existsSync(cfgPath)) {
10149
- const { unlinkSync } = await import("node:fs");
10150
- unlinkSync(cfgPath);
10151
- console.log("✅ Config removed");
10283
+ // Always remove per-user config on uninstall.
10284
+ {
10285
+ const cfgPath = configPath();
10286
+ if (existsSync(cfgPath)) {
10287
+ const { unlinkSync } = await import("node:fs");
10288
+ unlinkSync(cfgPath);
10289
+ console.log("✅ Config removed");
10290
+ }
10152
10291
  }
10153
10292
  if (opts.purge) {
10154
- const { rmSync } = await import("node:fs");
10155
- const dataDir = defaultDataDir();
10156
- const keysDir = defaultKeysDir();
10157
- const flairDir = join(homedir(), ".flair");
10158
- if (existsSync(dataDir)) {
10159
- rmSync(dataDir, { recursive: true, force: true });
10160
- console.log("✅ Data removed: " + dataDir);
10293
+ if (refusedKill) {
10294
+ console.log("\n⚠️ Skipping purge: could not attribute the process on port — data preserved.");
10295
+ console.log("Stop the process manually, then re-run: flair uninstall --purge");
10161
10296
  }
10162
- if (existsSync(keysDir)) {
10163
- rmSync(keysDir, { recursive: true, force: true });
10164
- console.log("✅ Keys removed: " + keysDir);
10165
- }
10166
- // Remove .flair dir if empty
10167
- try {
10168
- const { readdirSync, rmdirSync } = await import("node:fs");
10169
- if (existsSync(flairDir) && readdirSync(flairDir).length === 0) {
10170
- rmdirSync(flairDir);
10297
+ else {
10298
+ const { rmSync } = await import("node:fs");
10299
+ const dataDir = defaultDataDir();
10300
+ const keysDir = defaultKeysDir();
10301
+ const flairDir = join(homedir(), ".flair");
10302
+ if (existsSync(dataDir)) {
10303
+ rmSync(dataDir, { recursive: true, force: true });
10304
+ console.log("✅ Data removed: " + dataDir);
10305
+ }
10306
+ if (existsSync(keysDir)) {
10307
+ rmSync(keysDir, { recursive: true, force: true });
10308
+ console.log("✅ Keys removed: " + keysDir);
10309
+ }
10310
+ // Remove .flair dir if empty
10311
+ try {
10312
+ const { readdirSync, rmdirSync } = await import("node:fs");
10313
+ if (existsSync(flairDir) && readdirSync(flairDir).length === 0) {
10314
+ rmdirSync(flairDir);
10315
+ }
10171
10316
  }
10317
+ catch { /* non-empty, that's fine */ }
10318
+ console.log("\n🗑️ Flair fully purged");
10172
10319
  }
10173
- catch { /* non-empty, that's fine */ }
10174
- console.log("\n🗑️ Flair fully purged");
10175
10320
  }
10176
10321
  else {
10177
10322
  console.log("\nData and keys preserved at ~/.flair/");
@@ -10821,17 +10966,24 @@ program
10821
10966
  else if (versionCheckResult.latest) {
10822
10967
  console.log(` ${render.icons.ok} flair ${__pkgVersion} is current`);
10823
10968
  }
10824
- // Helper: try to reach Harper on a given port
10969
+ // Helper: try to reach Harper on a given port.
10970
+ // Must return true ONLY when Harper's /Health endpoint returns 200 OK.
10971
+ // A generic HTTP status > 0 (flair#862) would accept 404 from a Node
10972
+ // inspector on 9229 or any other service — "present but wrong" beats
10973
+ // "absent but correct".
10825
10974
  async function probePort(p) {
10826
10975
  try {
10827
10976
  const res = await fetch(`http://127.0.0.1:${p}/Health`, { signal: AbortSignal.timeout(3000) });
10828
- return res.status > 0;
10977
+ return res.ok; // 200-299 only — /Health returns { ok: true } on 200
10829
10978
  }
10830
10979
  catch {
10831
10980
  return false;
10832
10981
  }
10833
10982
  }
10834
- // Helper: discover what port a Harper PID is listening on
10983
+ // Helper: discover what port a Harper PID is listening on.
10984
+ // Scans ALL listening ports for this PID and returns the first one that
10985
+ // responds to /Health with 200 OK. This avoids picking a debug port (9229)
10986
+ // or any non-Flair listener that happens to share the process (flair#862).
10835
10987
  async function discoverPortFromPid(pid) {
10836
10988
  // Defense-in-depth: caller already validates, but re-check here
10837
10989
  if (!/^\d+$/.test(pid))
@@ -10839,9 +10991,16 @@ program
10839
10991
  try {
10840
10992
  const { execSync } = await import("node:child_process");
10841
10993
  const out = execSync(`lsof -aPi -p ${pid} -sTCP:LISTEN -Fn 2>/dev/null || true`, { encoding: "utf-8" });
10842
- const match = out.match(/:(\d+)$/m);
10843
- if (match)
10844
- return Number(match[1]);
10994
+ // Extract all ports from lsof -Fn output (lines like "n127.0.0.1:PORT")
10995
+ const ports = [...out.matchAll(/n(?:\S+):(\d+)/g)].map(m => Number(m[1]));
10996
+ if (ports.length === 0)
10997
+ return null;
10998
+ // Try each port until one responds to /Health with 200 OK
10999
+ for (const port of ports) {
11000
+ if (await probePort(port))
11001
+ return port;
11002
+ }
11003
+ return null; // No port responded to /Health
10845
11004
  }
10846
11005
  catch { /* ignore */ }
10847
11006
  return null;
@@ -12627,7 +12786,7 @@ memory.command("add [content]")
12627
12786
  .description("Write a new memory row for an agent (content via positional arg or --content)")
12628
12787
  .requiredOption("--agent <id>")
12629
12788
  .option("--content <text>", "memory content (alias for positional arg)")
12630
- .option("--durability <d>", "standard").option("--tags <csv>")
12789
+ .option("--durability <d>", "permanent|persistent|standard|ephemeral (default standard). Also decides the default visibility when --visibility is omitted: permanent/persistent -> shared, standard/ephemeral -> private").option("--tags <csv>")
12631
12790
  .option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
12632
12791
  .option("--subject <text>", "one-line title / entity this memory is about")
12633
12792
  .option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
@@ -12648,8 +12807,21 @@ memory.command("add [content]")
12648
12807
  body.summary = opts.summary;
12649
12808
  if (opts.subject)
12650
12809
  body.subject = opts.subject;
12651
- if (opts.visibility)
12652
- body.visibility = String(opts.visibility).trim();
12810
+ // flair#991: reject an unrecognized --visibility instead of writing it.
12811
+ // `visibility` is a free-form String server-side and the read scope asks
12812
+ // isPrivateVisibility() — an exact match on the literal "private" — so
12813
+ // ANY other string, `--visibility prvate` included, persists a row the
12814
+ // user believes is owner-only and that every agent on the instance can
12815
+ // in fact read. A typo must never widen who can read a memory.
12816
+ if (opts.visibility) {
12817
+ const visibility = String(opts.visibility).trim();
12818
+ if (visibility !== "private" && visibility !== "shared") {
12819
+ console.error(`error: --visibility must be 'private' or 'shared' (got: ${visibility})`);
12820
+ console.error(" omit it to use the durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private");
12821
+ process.exit(1);
12822
+ }
12823
+ body.visibility = visibility;
12824
+ }
12653
12825
  if (opts.derivedFrom) {
12654
12826
  body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
12655
12827
  }
@@ -13027,6 +13199,59 @@ function parseRelativeOrIso(input) {
13027
13199
  const multMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 };
13028
13200
  return new Date(Date.now() - n * (multMs[unit] ?? 0)).toISOString();
13029
13201
  }
13202
+ export function searchScoringFormula(scoring) {
13203
+ return scoring === "composite"
13204
+ ? "semantic × durability-weight × recency-decay × usage-boost"
13205
+ : "cosine similarity only";
13206
+ }
13207
+ export function buildSearchExplain(record, scoring, now = Date.now()) {
13208
+ const score = typeof record?._score === "number" ? record._score : undefined;
13209
+ const rawScore = typeof record?._rawScore === "number" ? record._rawScore : undefined;
13210
+ // composite mode: server sends both (_rawScore = pre-composite semantic).
13211
+ // raw mode: server sends only _score, and that IS the raw score.
13212
+ const raw = scoring === "composite" ? rawScore : score;
13213
+ const composite = scoring === "composite" ? score : undefined;
13214
+ let ageDays;
13215
+ if (record?.createdAt) {
13216
+ const created = new Date(String(record.createdAt)).getTime();
13217
+ if (Number.isFinite(created))
13218
+ ageDays = Math.max(0, Math.floor((now - created) / 86_400_000));
13219
+ }
13220
+ const explain = {
13221
+ scoring,
13222
+ formula: searchScoringFormula(scoring),
13223
+ durability: record?.durability ?? "standard",
13224
+ usageCount: typeof record?.usageCount === "number" ? record.usageCount : 0,
13225
+ };
13226
+ if (typeof raw === "number")
13227
+ explain.raw = raw;
13228
+ if (typeof composite === "number")
13229
+ explain.composite = composite;
13230
+ if (typeof ageDays === "number")
13231
+ explain.ageDays = ageDays;
13232
+ return explain;
13233
+ }
13234
+ // Human one-liner for a hit's breakdown. Scoring terms come from the shared
13235
+ // builder; the trailing tags/subject/supersedes are record context that json
13236
+ // mode already carries at top level, so they're appended here only.
13237
+ export function formatSearchExplain(explain, record) {
13238
+ const parts = [];
13239
+ if (typeof explain.raw === "number")
13240
+ parts.push(`raw=${explain.raw.toFixed(3)}`);
13241
+ if (typeof explain.composite === "number")
13242
+ parts.push(`composite=${explain.composite.toFixed(3)}`);
13243
+ parts.push(`durability=${explain.durability}`);
13244
+ if (typeof explain.ageDays === "number")
13245
+ parts.push(`age=${explain.ageDays}d`);
13246
+ parts.push(`usage=${explain.usageCount}`);
13247
+ if (Array.isArray(record?.tags) && record.tags.length > 0)
13248
+ parts.push(`tags=[${record.tags.join(",")}]`);
13249
+ if (record?.subject)
13250
+ parts.push(`subject=${record.subject}`);
13251
+ if (record?.supersedes)
13252
+ parts.push(`supersedes=${record.supersedes}`);
13253
+ return parts.join(" · ");
13254
+ }
13030
13255
  program
13031
13256
  .command("search <query>")
13032
13257
  .description("Search memories by meaning (shortcut for memory search) — filterable, with --explain ranking")
@@ -13049,7 +13274,7 @@ program
13049
13274
  .option("--durability <level>", "Filter to permanent|persistent|standard|ephemeral (client-side)")
13050
13275
  .option("--source <name>", "Filter by source/agentId (client-side)")
13051
13276
  // Output modes
13052
- .option("--explain", "Show score breakdown (composite, raw, durability, age, retrieval) per hit")
13277
+ .option("--explain", "Show score breakdown (raw, composite, durability, age, usage) per hit — also added to --json output as _explain")
13053
13278
  .option("--json", "Output raw JSON array")
13054
13279
  .action(async (query, opts) => {
13055
13280
  try {
@@ -13109,8 +13334,17 @@ program
13109
13334
  results = results.filter((r) => allowed.has(r._source ?? r.agentId ?? ""));
13110
13335
  }
13111
13336
  const mode = render.resolveOutputMode(opts);
13337
+ const scoringMode = payload.scoring === "composite" ? "composite" : "raw";
13112
13338
  if (mode === "json") {
13113
- console.log(render.asJSON(results));
13339
+ // flair#992: --explain must be honoured here, not silently dropped.
13340
+ // This branch is what every non-TTY caller lands in. The breakdown
13341
+ // rides ALONG the json contract as an opt-in `_explain` key — present
13342
+ // only when the caller typed --explain, so default output is unchanged
13343
+ // — rather than switching output mode behind the caller's back.
13344
+ const out = opts.explain
13345
+ ? results.map((r) => ({ ...r, _explain: buildSearchExplain(r, scoringMode) }))
13346
+ : results;
13347
+ console.log(render.asJSON(out));
13114
13348
  return;
13115
13349
  }
13116
13350
  if (results.length === 0) {
@@ -13162,30 +13396,15 @@ program
13162
13396
  if (meta)
13163
13397
  console.log(` ${render.wrap(render.c.dim, "(")} ${meta} ${render.wrap(render.c.dim, ")")}`);
13164
13398
  if (opts.explain) {
13165
- const parts = [];
13166
- if (typeof r._rawScore === "number")
13167
- parts.push(`raw=${r._rawScore.toFixed(3)}`);
13168
- if (typeof r._score === "number")
13169
- parts.push(`composite=${r._score.toFixed(3)}`);
13170
- if (typeof r.retrievalCount === "number" && r.retrievalCount > 0)
13171
- parts.push(`retrievals=${r.retrievalCount}`);
13172
- if (r.tags && Array.isArray(r.tags) && r.tags.length > 0)
13173
- parts.push(`tags=[${r.tags.join(",")}]`);
13174
- if (r.subject)
13175
- parts.push(`subject=${r.subject}`);
13176
- if (r.supersedes)
13177
- parts.push(`supersedes=${r.supersedes}`);
13178
- if (parts.length > 0) {
13179
- console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, parts.join(" · "))}`);
13399
+ const line = formatSearchExplain(buildSearchExplain(r, scoringMode), r);
13400
+ if (line) {
13401
+ console.log(` ${render.wrap(render.c.gray, "└─")} ${render.wrap(render.c.dim, line)}`);
13180
13402
  }
13181
13403
  }
13182
13404
  console.log();
13183
13405
  }
13184
13406
  if (opts.explain) {
13185
- const formula = payload.scoring === "composite"
13186
- ? "semantic × durability-weight × recency-decay × retrieval-boost"
13187
- : "cosine similarity only";
13188
- console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, payload.scoring)} ${render.wrap(render.c.dim, `(${formula})`)}`);
13407
+ console.log(`${render.wrap(render.c.dim, "Scoring:")} ${render.wrap(render.c.bold, scoringMode)} ${render.wrap(render.c.dim, `(${searchScoringFormula(scoringMode)})`)}`);
13189
13408
  }
13190
13409
  }
13191
13410
  catch (err) {
@@ -13333,7 +13552,7 @@ soul.command("set")
13333
13552
  .requiredOption("--agent <id>")
13334
13553
  .requiredOption("--key <key>")
13335
13554
  .requiredOption("--value <value>")
13336
- .option("--durability <d>", "permanent")
13555
+ .option("--durability <d>", "permanent|persistent|standard|ephemeral (default permanent — soul entries are identity, not working memory)")
13337
13556
  .option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
13338
13557
  .action(async (opts) => {
13339
13558
  // PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
@@ -14068,6 +14287,19 @@ program
14068
14287
  console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
14069
14288
  process.exit(1);
14070
14289
  }
14290
+ // flair#968: `flair backup > file.json` captures the progress report, not
14291
+ // the archive (which goes to --output, defaulting to ~/.flair/backups/...).
14292
+ // The result was exit 0 and a plausible-looking file of a few hundred bytes —
14293
+ // a false success immediately before a destructive upgrade.
14294
+ //
14295
+ // When stdout is not a TTY, route progress output to stderr. The archive
14296
+ // still goes to --output / the default path. This makes `flair backup >
14297
+ // file.json` produce an EMPTY file — unmistakably not a valid archive —
14298
+ // while leaving default-path callers (schedulers, cron) completely
14299
+ // unaffected.
14300
+ const log = process.stdout.isTTY
14301
+ ? console.log.bind(console)
14302
+ : console.error.bind(console);
14071
14303
  const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
14072
14304
  async function adminGet(path) {
14073
14305
  const res = await fetch(`${baseUrl}${path}`, {
@@ -14080,11 +14312,11 @@ program
14080
14312
  }
14081
14313
  return res.json();
14082
14314
  }
14083
- console.log("Fetching agents...");
14315
+ log("Fetching agents...");
14084
14316
  const allAgents = await adminGet("/Agent/");
14085
14317
  const filterIds = opts.agents ? opts.agents.split(",").map((s) => s.trim()) : null;
14086
14318
  const agents = filterIds ? allAgents.filter((a) => filterIds.includes(a.id)) : allAgents;
14087
- console.log(`Fetching memories for ${agents.length} agent(s)...`);
14319
+ log(`Fetching memories for ${agents.length} agent(s)...`);
14088
14320
  const memories = [];
14089
14321
  for (const agent of agents) {
14090
14322
  try {
@@ -14096,7 +14328,7 @@ program
14096
14328
  console.warn(` Warning: could not fetch memories for ${agent.id}: ${err.message}`);
14097
14329
  }
14098
14330
  }
14099
- console.log("Fetching souls...");
14331
+ log("Fetching souls...");
14100
14332
  const souls = [];
14101
14333
  for (const agent of agents) {
14102
14334
  try {
@@ -14124,11 +14356,11 @@ program
14124
14356
  const tmp = outputPath + ".tmp";
14125
14357
  writeFileSync(tmp, JSON.stringify(backup, null, 2) + "\n", "utf-8");
14126
14358
  renameSync(tmp, outputPath);
14127
- console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
14128
- console.log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
14129
- console.log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
14130
- console.log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
14131
- console.log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
14359
+ log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
14360
+ log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
14361
+ log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
14362
+ log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
14363
+ log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
14132
14364
  });
14133
14365
  // ─── flair restore ────────────────────────────────────────────────────────────
14134
14366
  program
@@ -15137,4 +15369,4 @@ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBi
15137
15369
  // Harper's own config — the per-instance port record (flair#914)
15138
15370
  harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
15139
15371
  // launchd label (flair#693)
15140
- LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };
15372
+ LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };