fraim-hub 2.0.237 → 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.
@@ -0,0 +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
+ }
@@ -75,7 +75,7 @@ function resolveDesktopEntry() {
75
75
  }
76
76
  return null;
77
77
  }
78
- function openDesktopWindow(projectPath, preferredPort) {
78
+ function openDesktopWindow(projectPath, preferredPort, runtimeId) {
79
79
  const electronBinary = resolveElectronBinary();
80
80
  const desktopEntry = resolveDesktopEntry();
81
81
  if (!electronBinary || !desktopEntry) {
@@ -84,6 +84,9 @@ function openDesktopWindow(projectPath, preferredPort) {
84
84
  const args = projectPath
85
85
  ? [desktopEntry, '--project-path', projectPath, '--port', String(preferredPort)]
86
86
  : [desktopEntry, '--port', String(preferredPort)];
87
+ if (runtimeId && runtimeId !== 'hub') {
88
+ args.push('--hub-runtime-id', runtimeId);
89
+ }
87
90
  const child = (0, child_process_1.spawn)(electronBinary, args, {
88
91
  detached: true,
89
92
  stdio: 'ignore',
@@ -204,8 +207,8 @@ function fetchRunningHubVersion(port) {
204
207
  req.on('timeout', () => { req.destroy(); resolve(null); });
205
208
  });
206
209
  }
207
- async function reconcileRunningHub(flags) {
208
- const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)());
210
+ async function reconcileRunningHub(flags, runtimeId = 'hub') {
211
+ const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
209
212
  const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
210
213
  const live = !!(running && confirmedVersion && isProcessAlive(running.pid));
211
214
  const effective = live && running ? { ...running, version: confirmedVersion } : null;
@@ -222,7 +225,9 @@ async function reconcileRunningHub(flags) {
222
225
  await waitForPortFree(effective.port);
223
226
  // #921: after killing the registered pid, scan for orphan Hub processes that were
224
227
  // never registered in hub-runtime.json (e.g. older versions started via bare npx).
225
- await scanAndKillOrphanHubs(effective.pid);
228
+ if (runtimeId === 'hub') {
229
+ await scanAndKillOrphanHubs(effective.pid);
230
+ }
226
231
  }
227
232
  else if (decision.action === 'focus-existing' && effective) {
228
233
  console.log(`A Hub (v${effective.version}) is already running - focusing it. Use --restart to replace it.`);
@@ -232,12 +237,13 @@ async function runHub(options) {
232
237
  const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('./server')));
233
238
  const preferredPort = options.port || (0, git_utils_1.getPort)() + 100;
234
239
  const projectPath = options.projectPath ? path_1.default.resolve(options.projectPath) : undefined;
240
+ const runtimeId = options.runtimeId || process.env.FRAIM_HUB_RUNTIME_ID || 'hub';
235
241
  if (options.open) {
236
242
  const wantDesktop = !options.browser;
237
243
  if (wantDesktop) {
238
- await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning });
244
+ await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
239
245
  }
240
- const openedDesktop = wantDesktop && openDesktopWindow(projectPath, preferredPort);
246
+ const openedDesktop = wantDesktop && openDesktopWindow(projectPath, preferredPort, runtimeId);
241
247
  if (!openedDesktop) {
242
248
  const port = await findAvailablePort(preferredPort);
243
249
  const server = new AiHubServer(projectPath ? { projectPath } : {});
@@ -37,6 +37,7 @@ function preferredWindowSize() {
37
37
  function parseArgs(argv) {
38
38
  let projectPath;
39
39
  let preferredPort = 43091;
40
+ let runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub';
40
41
  for (let i = 0; i < argv.length; i += 1) {
41
42
  if (argv[i] === '--project-path' && argv[i + 1]) {
42
43
  projectPath = argv[i + 1];
@@ -46,8 +47,12 @@ function parseArgs(argv) {
46
47
  preferredPort = Number(argv[i + 1]) || preferredPort;
47
48
  i += 1;
48
49
  }
50
+ if (argv[i] === '--hub-runtime-id' && argv[i + 1]) {
51
+ runtimeId = argv[i + 1];
52
+ i += 1;
53
+ }
49
54
  }
50
- return { projectPath, preferredPort };
55
+ return { projectPath, preferredPort, runtimeId };
51
56
  }
52
57
  function applyUserDataOverride() {
53
58
  const userDataDir = process.env.FRAIM_AI_HUB_USER_DATA_DIR;
@@ -115,10 +120,14 @@ function ensureWordSideload(projectPath, httpPort) {
115
120
  // ---------------------------------------------------------------------------
116
121
  // Tray setup
117
122
  // ---------------------------------------------------------------------------
118
- function buildTrayMenu(hubUrl) {
123
+ function displayName(runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub') {
124
+ return process.env.FRAIM_HUB_DISPLAY_NAME || (runtimeId === 'hub2' ? 'FRAIM Hub 2' : 'FRAIM Hub');
125
+ }
126
+ function buildTrayMenu(hubUrl, runtimeId) {
127
+ const name = displayName(runtimeId);
119
128
  return electron_1.Menu.buildFromTemplate([
120
129
  {
121
- label: 'Open FRAIM Hub',
130
+ label: `Open ${name}`,
122
131
  click: () => {
123
132
  if (mainWindow) {
124
133
  mainWindow.show();
@@ -131,12 +140,12 @@ function buildTrayMenu(hubUrl) {
131
140
  },
132
141
  {
133
142
  // #755: surface the running build version so staleness is diagnosable at a glance.
134
- label: 'About FRAIM Hub',
143
+ label: `About ${name}`,
135
144
  click: () => {
136
145
  void electron_1.dialog.showMessageBox({
137
146
  type: 'info',
138
- title: 'About FRAIM Hub',
139
- message: 'FRAIM Hub',
147
+ title: `About ${name}`,
148
+ message: name,
140
149
  detail: `Version ${(0, version_utils_1.getFraimVersion)()}\nIdentity: ~/.fraim/config.json\nLocal server: ${hubUrl}`,
141
150
  buttons: ['OK'],
142
151
  });
@@ -159,7 +168,7 @@ function buildTrayMenu(hubUrl) {
159
168
  },
160
169
  { type: 'separator' },
161
170
  {
162
- label: 'Quit FRAIM',
171
+ label: `Quit ${name}`,
163
172
  click: () => {
164
173
  isQuitting = true;
165
174
  electron_1.app.quit();
@@ -167,10 +176,11 @@ function buildTrayMenu(hubUrl) {
167
176
  },
168
177
  ]);
169
178
  }
170
- function createTray(hubUrl) {
179
+ function createTray(hubUrl, runtimeId) {
180
+ const name = displayName(runtimeId);
171
181
  tray = new electron_1.Tray(resolveTrayIcon());
172
- tray.setToolTip('FRAIM AI Hub');
173
- tray.setContextMenu(buildTrayMenu(hubUrl));
182
+ tray.setToolTip(name);
183
+ tray.setContextMenu(buildTrayMenu(hubUrl, runtimeId));
174
184
  tray.on('double-click', () => {
175
185
  if (mainWindow) {
176
186
  mainWindow.show();
@@ -184,12 +194,12 @@ function createTray(hubUrl) {
184
194
  // ---------------------------------------------------------------------------
185
195
  // BrowserWindow
186
196
  // ---------------------------------------------------------------------------
187
- async function createWindow(url) {
197
+ async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub') {
188
198
  const { width, height } = preferredWindowSize();
189
199
  const isMac = process.platform === 'darwin';
190
200
  const isWin = process.platform === 'win32';
191
201
  mainWindow = new electron_1.BrowserWindow({
192
- title: 'FRAIM AI Hub',
202
+ title: displayName(runtimeId),
193
203
  width,
194
204
  height,
195
205
  minWidth: 1200,
@@ -309,6 +319,7 @@ function stopServerOnce() {
309
319
  // Launch
310
320
  // ---------------------------------------------------------------------------
311
321
  async function launchDesktopShell(options) {
322
+ const runtimeId = options.runtimeId || process.env.FRAIM_HUB_RUNTIME_ID || 'hub';
312
323
  const httpPort = await (0, server_1.findAvailablePort)(options.preferredPort);
313
324
  const httpsPort = await (0, server_1.findAvailablePortExcluding)(43092, new Set([httpPort]));
314
325
  // Generate (or load cached) self-signed cert for HTTPS.
@@ -339,15 +350,15 @@ async function launchDesktopShell(options) {
339
350
  port: httpPort,
340
351
  version: (0, version_utils_1.getFraimVersion)(),
341
352
  startedAt: new Date().toISOString(),
342
- });
353
+ }, runtimeId);
343
354
  }
344
355
  catch (err) {
345
- console.warn('[fraim] could not write hub-runtime.json:', err);
356
+ console.warn('[fraim] could not write hub runtime file:', err);
346
357
  }
347
358
  ensureWordSideload(resolvedProjectPath, httpPort);
348
359
  const hubUrl = `http://127.0.0.1:${httpPort}/ai-hub/`;
349
- createTray(hubUrl);
350
- await createWindow(hubUrl);
360
+ createTray(hubUrl, runtimeId);
361
+ await createWindow(hubUrl, runtimeId);
351
362
  }
352
363
  // ---------------------------------------------------------------------------
353
364
  // Bootstrap
@@ -372,7 +383,7 @@ async function bootstrap() {
372
383
  }
373
384
  });
374
385
  await electron_1.app.whenReady();
375
- electron_1.app.setName('FRAIM Hub');
386
+ electron_1.app.setName(displayName(options.runtimeId));
376
387
  // First-launch housekeeping (idempotent, fast on subsequent runs)
377
388
  ensureLoginItem();
378
389
  configureAutoUpdater();
@@ -388,7 +399,7 @@ async function bootstrap() {
388
399
  // #755: clear the runtime file so a later `fraim hub` doesn't treat a
389
400
  // cleanly-exited instance as live.
390
401
  try {
391
- (0, hub_runtime_file_1.removeHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)());
402
+ (0, hub_runtime_file_1.removeHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), options.runtimeId);
392
403
  }
393
404
  catch { /* best-effort */ }
394
405
  void stopServerOnce();
@@ -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
  }
@@ -13,17 +13,23 @@ const path_1 = __importDefault(require("path"));
13
13
  // `fraim hub` CLI can discover a live instance (pid/port/version) before deciding
14
14
  // whether to replace a stale build. `fraimDir` is injected (never hard-coded to
15
15
  // the real ~/.fraim) so tests isolate via a temp directory.
16
- const RUNTIME_FILE = 'hub-runtime.json';
17
- function hubRuntimeFilePath(fraimDir) {
18
- return path_1.default.join(fraimDir, RUNTIME_FILE);
16
+ const DEFAULT_RUNTIME_ID = 'hub';
17
+ function runtimeFileName(runtimeId = DEFAULT_RUNTIME_ID) {
18
+ if (runtimeId === DEFAULT_RUNTIME_ID)
19
+ return 'hub-runtime.json';
20
+ const safeRuntimeId = runtimeId.toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^-+|-+$/g, '');
21
+ return `${safeRuntimeId || DEFAULT_RUNTIME_ID}-runtime.json`;
19
22
  }
20
- function writeHubRuntimeFile(fraimDir, info) {
23
+ function hubRuntimeFilePath(fraimDir, runtimeId = DEFAULT_RUNTIME_ID) {
24
+ return path_1.default.join(fraimDir, runtimeFileName(runtimeId));
25
+ }
26
+ function writeHubRuntimeFile(fraimDir, info, runtimeId = DEFAULT_RUNTIME_ID) {
21
27
  fs_1.default.mkdirSync(fraimDir, { recursive: true });
22
- fs_1.default.writeFileSync(hubRuntimeFilePath(fraimDir), JSON.stringify(info, null, 2));
28
+ fs_1.default.writeFileSync(hubRuntimeFilePath(fraimDir, runtimeId), JSON.stringify(info, null, 2));
23
29
  }
24
- function readHubRuntimeFile(fraimDir) {
30
+ function readHubRuntimeFile(fraimDir, runtimeId = DEFAULT_RUNTIME_ID) {
25
31
  try {
26
- const parsed = JSON.parse(fs_1.default.readFileSync(hubRuntimeFilePath(fraimDir), 'utf8'));
32
+ const parsed = JSON.parse(fs_1.default.readFileSync(hubRuntimeFilePath(fraimDir, runtimeId), 'utf8'));
27
33
  if (typeof parsed.pid !== 'number' || typeof parsed.port !== 'number' || typeof parsed.version !== 'string') {
28
34
  return null;
29
35
  }
@@ -33,9 +39,9 @@ function readHubRuntimeFile(fraimDir) {
33
39
  return null;
34
40
  }
35
41
  }
36
- function removeHubRuntimeFile(fraimDir) {
42
+ function removeHubRuntimeFile(fraimDir, runtimeId = DEFAULT_RUNTIME_ID) {
37
43
  try {
38
- fs_1.default.rmSync(hubRuntimeFilePath(fraimDir), { force: true });
44
+ fs_1.default.rmSync(hubRuntimeFilePath(fraimDir, runtimeId), { force: true });
39
45
  }
40
46
  catch {
41
47
  /* no-op: absent file is fine */
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HUB2_REMOTE_UI_PUBLIC_KEY_PEM = exports.HUB2_REMOTE_UI_ASSET_ORIGIN = exports.HUB2_REMOTE_UI_RELEASE_ID = exports.HUB2_REMOTE_UI_CHANNEL = void 0;
4
+ exports.hub2RemoteManifestUrl = hub2RemoteManifestUrl;
5
+ exports.hub2TrustedOrigins = hub2TrustedOrigins;
6
+ const remote_hub_gateway_1 = require("./remote-hub-gateway");
7
+ exports.HUB2_REMOTE_UI_CHANNEL = 'hub2';
8
+ exports.HUB2_REMOTE_UI_RELEASE_ID = '2026.07.25.1';
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-----
13
+ `;
14
+ function hub2RemoteManifestUrl(remoteBaseUrl = (0, remote_hub_gateway_1.resolveFraimRemoteUrl)()) {
15
+ const target = new URL('/api/ai-hub/ui/releases/latest', remoteBaseUrl);
16
+ target.searchParams.set('channel', exports.HUB2_REMOTE_UI_CHANNEL);
17
+ return target.toString();
18
+ }
19
+ function hub2TrustedOrigins(remoteBaseUrl = (0, remote_hub_gateway_1.resolveFraimRemoteUrl)()) {
20
+ return Array.from(new Set([new URL(remoteBaseUrl).origin, exports.HUB2_REMOTE_UI_ASSET_ORIGIN])).join(',');
21
+ }
@@ -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);