fraim-hub 2.0.238 → 2.0.239

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.
@@ -1,12 +1,12 @@
1
- #!/usr/bin/env node
2
- try {
3
- const { createFraimHub2Program } = require('../dist/src/cli/fraim-hub-2.js');
4
- createFraimHub2Program().parseAsync(process.argv).catch((error) => {
5
- console.error(error instanceof Error ? error.message : String(error));
6
- process.exit(1);
7
- });
8
- } catch (error) {
9
- console.error('Unable to start FRAIM Hub 2. Run npm install -g fraim-hub again to refresh the package.');
10
- console.error(error instanceof Error ? error.message : String(error));
11
- process.exit(1);
12
- }
1
+ #!/usr/bin/env node
2
+ try {
3
+ const { createFraimHub2Program } = require('../dist/src/cli/fraim-hub-2.js');
4
+ createFraimHub2Program().parseAsync(process.argv).catch((error) => {
5
+ console.error(error instanceof Error ? error.message : String(error));
6
+ process.exit(1);
7
+ });
8
+ } catch (error) {
9
+ console.error('Unable to start FRAIM Hub 2. Run npm install -g fraim-hub again to refresh the package.');
10
+ console.error(error instanceof Error ? error.message : String(error));
11
+ process.exit(1);
12
+ }
@@ -8,6 +8,12 @@ exports.parseSeekMentoringSignal = parseSeekMentoringSignal;
8
8
  exports.parseFraimJobLoadSignal = parseFraimJobLoadSignal;
9
9
  exports.parseUsageSignal = parseUsageSignal;
10
10
  exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
11
+ exports.__setAgentAvailabilityPathForTests = __setAgentAvailabilityPathForTests;
12
+ exports.invalidateEmployeeDetectionCache = invalidateEmployeeDetectionCache;
13
+ exports.__setEmployeeDetectionTtlForTests = __setEmployeeDetectionTtlForTests;
14
+ exports.__getEmployeeProbeRoundsForTests = __getEmployeeProbeRoundsForTests;
15
+ exports.__resetEmployeeProbeRoundsForTests = __resetEmployeeProbeRoundsForTests;
16
+ exports.detectEmployeesAsync = detectEmployeesAsync;
11
17
  exports.detectEmployees = detectEmployees;
12
18
  exports.prepareCodexBrowserHome = prepareCodexBrowserHome;
13
19
  exports.sharedBrowserHostConfig = sharedBrowserHostConfig;
@@ -724,14 +730,246 @@ function resolveHostInvocation(plan) {
724
730
  args: ['/d', '/s', '/c', [command, ...args.map(escapeWindowsArg)].join(' ')],
725
731
  };
726
732
  }
733
+ // Single source for the probe environment, shared by the sync and async probes so the two
734
+ // cannot drift on how the managed-agent bin directories are put on PATH.
735
+ const versionProbeEnv = () => ({
736
+ ...process.env,
737
+ PATH: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH),
738
+ });
727
739
  const availableByVersionProbe = (command) => {
728
740
  const invocation = resolveHostInvocation({ command, args: ['--version'] });
729
741
  const result = (0, child_process_1.spawnSync)(invocation.command, invocation.args, {
730
742
  encoding: 'utf8',
731
- env: { ...process.env, PATH: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH) },
743
+ env: versionProbeEnv(),
732
744
  });
733
745
  return result.status === 0;
734
746
  };
747
+ // Issue #1010: the async counterpart of availableByVersionProbe. Same semantics (exit 0
748
+ // from `<agent> --version` means the CLI is installed AND actually runs), but non-blocking
749
+ // so N agents can be probed concurrently without freezing the event loop.
750
+ // Upper bound on a single probe. Bug-bash finding: `spawn`/`spawnSync` here are otherwise
751
+ // unbounded, so one wedged CLI would stall detection forever - and because the first-paint
752
+ // path awaits detection, that would turn the slow first paint this issue is about into an
753
+ // indefinite one. The worst probe observed on a healthy machine is 1419ms (gemini), so 10s
754
+ // is far above any legitimate `--version`. The tradeoff is deliberate: a CLI that cannot
755
+ // print its version within 10s is reported unavailable rather than allowed to hang the Hub.
756
+ // It is recoverable from the UI via the existing per-agent "Check" action.
757
+ const VERSION_PROBE_TIMEOUT_MS = 10_000;
758
+ const availableByVersionProbeAsync = (command) => new Promise((resolve) => {
759
+ const invocation = resolveHostInvocation({ command, args: ['--version'] });
760
+ let settled = false;
761
+ let timer;
762
+ let child;
763
+ const finish = (value) => {
764
+ if (settled)
765
+ return;
766
+ settled = true;
767
+ if (timer)
768
+ clearTimeout(timer);
769
+ resolve(value);
770
+ };
771
+ try {
772
+ child = (0, child_process_1.spawn)(invocation.command, invocation.args, {
773
+ env: versionProbeEnv(),
774
+ stdio: 'ignore',
775
+ });
776
+ timer = setTimeout(() => {
777
+ console.warn(`[ai-hub] agent version probe timed out after ${VERSION_PROBE_TIMEOUT_MS}ms: ${command}`);
778
+ try {
779
+ child?.kill();
780
+ }
781
+ catch { /* already gone */ }
782
+ finish(false);
783
+ }, VERSION_PROBE_TIMEOUT_MS);
784
+ // Do not hold the process open just for a probe.
785
+ timer.unref?.();
786
+ child.on('error', () => finish(false));
787
+ child.on('close', (code) => finish(code === 0));
788
+ }
789
+ catch {
790
+ finish(false);
791
+ }
792
+ });
793
+ // ─── Issue #1010: employee-detection cache ───────────────────────────────────
794
+ //
795
+ // detectEmployees() used to run one blocking spawnSync per agent on EVERY call, and it is
796
+ // called from bootstrapResponse and several routes. Measured 2715ms per call versus 53ms
797
+ // for all of the Hub's own local discovery in the same handler (51x), with gemini (1419ms)
798
+ // and copilot (939ms) dominating. Worse than the latency: spawnSync blocks Node's event
799
+ // loop, so the Hub answered NOTHING while probing. A trivial 3ms endpoint measured 3043ms
800
+ // when it happened to land during a bootstrap.
801
+ //
802
+ // "Which CLIs are installed on this machine" does not change between requests, so it is
803
+ // cached. The TTL is a safety net for a CLI installed outside the Hub; installs performed
804
+ // THROUGH the Hub invalidate explicitly (see the install-agent route) so they appear at once.
805
+ const EMPLOYEE_DETECTION_TTL_MS = 5 * 60 * 1000;
806
+ let employeeDetectionTtlMs = EMPLOYEE_DETECTION_TTL_MS;
807
+ let cachedEmployees = null;
808
+ let cachedEmployeesAtMs = 0;
809
+ let inFlightDetection = null;
810
+ function cachedEmployeesIfFresh() {
811
+ if (!cachedEmployees)
812
+ return null;
813
+ if (Date.now() - cachedEmployeesAtMs > employeeDetectionTtlMs)
814
+ return null;
815
+ return cachedEmployees;
816
+ }
817
+ // ─── Persisted last-known availability ───────────────────────────────────────
818
+ //
819
+ // An in-memory cache alone does not make the FIRST paint fast: after a Hub restart the
820
+ // cache is empty, so the first bootstrap pays the full probe. Measured, the parallel probe
821
+ // takes a consistent ~2.2s, and a headless browser hitting a freshly started server beat
822
+ // startup priming and paid 2529ms - i.e. whether first paint is fast came down to a race
823
+ // between Electron boot and priming. That is not a guarantee.
824
+ //
825
+ // Agent availability changes rarely (installing a CLI is a deliberate act), so the last
826
+ // known answer is persisted and served immediately on the next start. Startup priming then
827
+ // force-refreshes in the background.
828
+ //
829
+ // Tradeoff, deliberate: immediately after uninstalling a CLI outside the Hub, the first
830
+ // paint can briefly show it as available until the background refresh lands (~2.2s) or the
831
+ // TTL lapses. That is recoverable and self-correcting, and is preferable to freezing every
832
+ // cold start for 2.2s. Installs performed THROUGH the Hub invalidate explicitly.
833
+ const AGENT_AVAILABILITY_FILE = 'hub-agent-availability.json';
834
+ // Overridable so tests do not mutate the developer's real ~/.fraim state. These guards run
835
+ // in the smoke suite now, and invalidation DELETES this file, so pointing them at a temp
836
+ // path keeps a frequent test run from repeatedly clearing real agent-availability data.
837
+ let agentAvailabilityPathOverride = null;
838
+ /** Test seam only. Pass null to restore the real user-level path. */
839
+ function __setAgentAvailabilityPathForTests(filePath) {
840
+ agentAvailabilityPathOverride = filePath;
841
+ }
842
+ function agentAvailabilityFilePath() {
843
+ if (agentAvailabilityPathOverride)
844
+ return agentAvailabilityPathOverride;
845
+ // Resolved lazily and defensively: this module is imported by CLI paths that must not
846
+ // fail to load just because a home directory is unusual.
847
+ return path_1.default.join(os_1.default.homedir(), '.fraim', AGENT_AVAILABILITY_FILE);
848
+ }
849
+ function loadPersistedEmployees() {
850
+ try {
851
+ const raw = fs_1.default.readFileSync(agentAvailabilityFilePath(), 'utf8');
852
+ const parsed = JSON.parse(raw);
853
+ if (!Array.isArray(parsed.employees) || parsed.employees.length === 0)
854
+ return null;
855
+ // Only accept entries that still match the current known agent ids, so a stale file
856
+ // from an older build cannot introduce unknown ids into the roster.
857
+ const knownIds = new Set(Object.keys(EMPLOYEE_LABELS));
858
+ const employees = parsed.employees.filter((e) => e && typeof e.id === 'string' && knownIds.has(e.id));
859
+ if (employees.length === 0)
860
+ return null;
861
+ const detectedAtMs = parsed.detectedAt ? Date.parse(parsed.detectedAt) : NaN;
862
+ return { employees, detectedAtMs: Number.isFinite(detectedAtMs) ? detectedAtMs : 0 };
863
+ }
864
+ catch {
865
+ return null; // absent or malformed is fine; we simply probe instead
866
+ }
867
+ }
868
+ /**
869
+ * Adopt a persisted entry into the in-memory cache, preserving its ORIGINAL age.
870
+ *
871
+ * Critically this does NOT stamp `Date.now()`. Doing so made a stale file look permanently
872
+ * fresh, which silently killed the TTL: `detectEmployees()` expired the in-memory entry,
873
+ * immediately re-read the same stale file, and never re-probed - so a CLI installed outside
874
+ * the Hub was never picked up. Caught by asserting on the persisted `detectedAt` stamp; a
875
+ * timing-based assertion had masked it.
876
+ *
877
+ * When the adopted entry is already past the TTL it is still returned (first paint stays
878
+ * fast) but a background refresh is kicked off so the next read is correct.
879
+ */
880
+ function adoptPersistedEmployees(persisted) {
881
+ cachedEmployees = persisted.employees;
882
+ cachedEmployeesAtMs = persisted.detectedAtMs;
883
+ if (Date.now() - persisted.detectedAtMs > employeeDetectionTtlMs) {
884
+ void detectEmployeesAsync({ force: true }).catch(() => undefined);
885
+ }
886
+ return persisted.employees;
887
+ }
888
+ function persistEmployees(employees) {
889
+ try {
890
+ const file = agentAvailabilityFilePath();
891
+ fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
892
+ fs_1.default.writeFileSync(file, JSON.stringify({ employees, detectedAt: new Date().toISOString() }, null, 2), 'utf8');
893
+ }
894
+ catch {
895
+ // Best effort only: failing to persist must never break detection.
896
+ }
897
+ }
898
+ function storeDetectedEmployees(employees) {
899
+ cachedEmployees = employees;
900
+ cachedEmployeesAtMs = Date.now();
901
+ persistEmployees(employees);
902
+ return employees;
903
+ }
904
+ /**
905
+ * Drop the cached agent-availability result so the next detection re-probes.
906
+ * Call after anything that changes what is installed (for example the Hub's own
907
+ * install-agent flow) so a newly installed CLI shows up without waiting for the TTL.
908
+ */
909
+ function invalidateEmployeeDetectionCache() {
910
+ cachedEmployees = null;
911
+ cachedEmployeesAtMs = 0;
912
+ inFlightDetection = null;
913
+ try {
914
+ fs_1.default.rmSync(agentAvailabilityFilePath(), { force: true });
915
+ }
916
+ catch { /* best effort */ }
917
+ }
918
+ /** Test seam only. Mirrors __resetLatestVersionCache in hub-latest-version.ts. */
919
+ function __setEmployeeDetectionTtlForTests(ttlMs) {
920
+ employeeDetectionTtlMs = ttlMs ?? EMPLOYEE_DETECTION_TTL_MS;
921
+ }
922
+ // Counts detection rounds that actually executed CLI probes, as opposed to being served
923
+ // from the in-memory cache or the persisted file. This is the perf signal worth asserting
924
+ // on, because it is ENVIRONMENT-INDEPENDENT: how expensive a probe happens to be varies
925
+ // wildly (2715ms on a machine with the CLIs installed, ~44ms on CI where none are), but
926
+ // "how many times did we pay for it" does not. Absolute millisecond thresholds silently
927
+ // pass on unfixed code wherever probes are cheap; this counter cannot.
928
+ let employeeProbeRounds = 0;
929
+ /** Test seam only: number of detection rounds that really probed. */
930
+ function __getEmployeeProbeRoundsForTests() {
931
+ return employeeProbeRounds;
932
+ }
933
+ /** Test seam only. */
934
+ function __resetEmployeeProbeRoundsForTests() {
935
+ employeeProbeRounds = 0;
936
+ }
937
+ function buildEmployeeStatus(id, available) {
938
+ return {
939
+ id,
940
+ label: EMPLOYEE_LABELS[id],
941
+ available,
942
+ detail: available ? 'Installed and ready on this machine.' : 'CLI not detected on this machine.',
943
+ supportsRaw: supportsDirectPath(id),
944
+ };
945
+ }
946
+ /**
947
+ * Non-blocking employee detection. Probes every agent CONCURRENTLY, so the cost is the
948
+ * slowest single probe rather than the sum of all of them, and the event loop stays free
949
+ * for other requests. Result populates the same cache detectEmployees() reads.
950
+ *
951
+ * Concurrent callers share one in-flight probe rather than each starting their own.
952
+ */
953
+ async function detectEmployeesAsync(options = {}) {
954
+ const fresh = cachedEmployeesIfFresh();
955
+ if (fresh)
956
+ return fresh;
957
+ if (!options.force) {
958
+ // Fast path for the first request after a restart: serve last-known immediately rather
959
+ // than making first paint wait ~2.2s. Startup priming passes force:true to refresh.
960
+ const persisted = loadPersistedEmployees();
961
+ if (persisted)
962
+ return adoptPersistedEmployees(persisted);
963
+ }
964
+ if (inFlightDetection)
965
+ return inFlightDetection;
966
+ const ids = Object.keys(EMPLOYEE_LABELS);
967
+ employeeProbeRounds += 1;
968
+ inFlightDetection = Promise.all(ids.map(async (id) => buildEmployeeStatus(id, await availableByVersionProbeAsync(agentBinaryName(id)))))
969
+ .then((employees) => storeDetectedEmployees(employees))
970
+ .finally(() => { inFlightDetection = null; });
971
+ return inFlightDetection;
972
+ }
735
973
  // Resolve the binary name for each agent tool.
736
974
  function agentBinaryName(id) {
737
975
  if (id === 'copilot')
@@ -740,17 +978,26 @@ function agentBinaryName(id) {
740
978
  return AGY_BINARY;
741
979
  return executableName(id);
742
980
  }
981
+ /**
982
+ * Synchronous employee detection, served from the Issue #1010 cache when it is fresh.
983
+ *
984
+ * The blocking probe is retained as the cache-miss fallback rather than removed, so this
985
+ * function's contract is unchanged for every existing caller: it still returns a resolved
986
+ * answer, never a placeholder, and needs no "availability unknown" state in the UI. In
987
+ * practice the miss is rare because the server primes the cache asynchronously at startup
988
+ * (see AiHubServer.start), so requests read a warm cache instead of paying ~2.7s.
989
+ */
743
990
  function detectEmployees() {
744
- return Object.keys(EMPLOYEE_LABELS).map((id) => {
745
- const available = availableByVersionProbe(agentBinaryName(id));
746
- return {
747
- id,
748
- label: EMPLOYEE_LABELS[id],
749
- available,
750
- detail: available ? 'Installed and ready on this machine.' : 'CLI not detected on this machine.',
751
- supportsRaw: supportsDirectPath(id),
752
- };
753
- });
991
+ const fresh = cachedEmployeesIfFresh();
992
+ if (fresh)
993
+ return fresh;
994
+ // Prefer last-known over a blocking probe: ~0ms instead of ~2.7s of frozen event loop.
995
+ const persisted = loadPersistedEmployees();
996
+ if (persisted)
997
+ return adoptPersistedEmployees(persisted);
998
+ employeeProbeRounds += 1;
999
+ const employees = Object.keys(EMPLOYEE_LABELS).map((id) => buildEmployeeStatus(id, availableByVersionProbe(agentBinaryName(id))));
1000
+ return storeDetectedEmployees(employees);
754
1001
  }
755
1002
  function parseFraimInvocation(message) {
756
1003
  const trimmed = message.trim();
@@ -1476,6 +1723,10 @@ class CliHostRuntime {
1476
1723
  detectEmployees() {
1477
1724
  return detectEmployees();
1478
1725
  }
1726
+ // Issue #1010: probes all agents concurrently without blocking the event loop.
1727
+ detectEmployeesAsync() {
1728
+ return detectEmployeesAsync();
1729
+ }
1479
1730
  startRun(hostId, projectPath, message, handlers, sessionId, launchContext) {
1480
1731
  return spawnHostProcess(hostId, (0, configured_agents_1.decorateHostPlanWithConfiguredAgent)(buildStartPlan(hostId, message, sessionId), launchContext?.agent, launchContext?.env), projectPath, handlers);
1481
1732
  }
@@ -7,9 +7,9 @@ const remote_hub_gateway_1 = require("./remote-hub-gateway");
7
7
  exports.HUB2_REMOTE_UI_CHANNEL = 'hub2';
8
8
  exports.HUB2_REMOTE_UI_RELEASE_ID = '2026.07.25.1';
9
9
  exports.HUB2_REMOTE_UI_ASSET_ORIGIN = 'https://fraim.wellnessatwork.me';
10
- exports.HUB2_REMOTE_UI_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
11
- MCowBQYDK2VwAyEAn53Ks3CQSqzflw4/6KIJbKmZeWy94V5H7LYlWhMOePo=
12
- -----END PUBLIC KEY-----
10
+ exports.HUB2_REMOTE_UI_PUBLIC_KEY_PEM = `-----BEGIN PUBLIC KEY-----
11
+ MCowBQYDK2VwAyEAn53Ks3CQSqzflw4/6KIJbKmZeWy94V5H7LYlWhMOePo=
12
+ -----END PUBLIC KEY-----
13
13
  `;
14
14
  function hub2RemoteManifestUrl(remoteBaseUrl = (0, remote_hub_gateway_1.resolveFraimRemoteUrl)()) {
15
15
  const target = new URL('/api/ai-hub/ui/releases/latest', remoteBaseUrl);
@@ -134,6 +134,27 @@ function normalizeAiHubProjectList(projects, currentProjectPath, options = {}) {
134
134
  add(normalizeProjectEntry(project));
135
135
  return withUniqueProjectIds(Array.from(byPath.values()));
136
136
  }
137
+ // Issue #1024: tombstones live in a separate file so a wholesale replacement of
138
+ // ai-hub-state.json (backup restore, cross-machine sync) can never drop project
139
+ // deletions that happened after the snapshot was taken.
140
+ function tombstoneFilePath(stateFilePath) {
141
+ return stateFilePath.replace(/\.json$/i, '-tombstones.json');
142
+ }
143
+ function readTombstoneFile(stateFilePath) {
144
+ const tombFile = tombstoneFilePath(stateFilePath);
145
+ try {
146
+ const raw = JSON.parse(fs_1.default.readFileSync(tombFile, 'utf8'));
147
+ return normalizeRemovedProjectPaths(raw);
148
+ }
149
+ catch {
150
+ return [];
151
+ }
152
+ }
153
+ function writeTombstoneFile(stateFilePath, paths) {
154
+ const tombFile = tombstoneFilePath(stateFilePath);
155
+ fs_1.default.mkdirSync(path_1.default.dirname(tombFile), { recursive: true });
156
+ fs_1.default.writeFileSync(tombFile, JSON.stringify(normalizeRemovedProjectPaths(paths), null, 2));
157
+ }
137
158
  class AiHubPreferencesStore {
138
159
  constructor(stateFilePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-state.json')) {
139
160
  this.stateFilePath = stateFilePath;
@@ -144,7 +165,13 @@ class AiHubPreferencesStore {
144
165
  }
145
166
  try {
146
167
  const raw = JSON.parse(fs_1.default.readFileSync(this.stateFilePath, 'utf8'));
147
- const removedProjectPaths = normalizeRemovedProjectPaths(raw.removedProjectPaths);
168
+ // Issue #1024: merge tombstones from ai-hub-state.json with those in the
169
+ // separate tombstone file so a wholesale restore of the state file can never
170
+ // drop project deletions that happened after the snapshot was taken.
171
+ const removedProjectPaths = normalizeRemovedProjectPaths([
172
+ ...(Array.isArray(raw.removedProjectPaths) ? raw.removedProjectPaths : []),
173
+ ...readTombstoneFile(this.stateFilePath),
174
+ ]);
148
175
  return {
149
176
  projectPath: raw.projectPath || projectPath,
150
177
  employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex' || raw.employeeId === 'gemini' || raw.employeeId === 'copilot' || raw.employeeId === 'antigravity') ? raw.employeeId : DEFAULT_EMPLOYEE,
@@ -168,6 +195,8 @@ class AiHubPreferencesStore {
168
195
  save(preferences) {
169
196
  fs_1.default.mkdirSync(path_1.default.dirname(this.stateFilePath), { recursive: true });
170
197
  fs_1.default.writeFileSync(this.stateFilePath, JSON.stringify(preferences, null, 2));
198
+ // Issue #1024: keep the tombstone file in sync so load() always sees the union.
199
+ writeTombstoneFile(this.stateFilePath, preferences.removedProjectPaths || []);
171
200
  }
172
201
  saveProjects(projectPath, projects, options = {}) {
173
202
  const normalizedProjectPath = normalizeProjectPath(projectPath);