fraim-hub 2.0.237 → 2.0.238

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();
@@ -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
+ }
@@ -66,6 +66,8 @@ const user_config_1 = require("../cli/utils/user-config");
66
66
  const org_publish_1 = require("../cli/utils/org-publish");
67
67
  const version_utils_1 = require("../cli/utils/version-utils");
68
68
  const hub_latest_version_1 = require("./hub-latest-version");
69
+ const ui_runtime_1 = require("./ui-runtime");
70
+ const ui_cache_1 = require("./ui-cache");
69
71
  const semver = __importStar(require("semver"));
70
72
  const BOOTSTRAP_PERSONA_FIRST_PAINT_BUDGET_MS = 250;
71
73
  let personaHiringModule;
@@ -1571,6 +1573,12 @@ class AiHubServer {
1571
1573
  this.folderPicker = options.folderPicker ?? pickProjectPath;
1572
1574
  this.httpsPort = options.httpsPort;
1573
1575
  this.certBundle = options.certBundle;
1576
+ this.hubUiCacheDir = options.hubUiCacheDir || process.env.FRAIM_HUB_UI_CACHE_DIR || (0, ui_cache_1.defaultHubUiCacheDir)();
1577
+ this.hubUiPublicKeyPem = options.hubUiPublicKeyPem || process.env.FRAIM_HUB_UI_PUBLIC_KEY;
1578
+ this.hubUiManifestUrl = options.hubUiManifestUrl || process.env.FRAIM_HUB_UI_MANIFEST_URL;
1579
+ this.hubUiTrustedOrigins = options.hubUiTrustedOrigins || parseHubUiTrustedOrigins();
1580
+ this.hubUiRemoteEnabled = options.hubUiRemoteEnabled;
1581
+ this.hubUiAllowInsecureLoopback = options.hubUiAllowInsecureLoopback || process.env.NODE_ENV === 'test';
1574
1582
  this.managedBrowser = options.managedBrowser || new managed_browser_1.ManagedBrowser({
1575
1583
  channel: process.env.FRAIM_BROWSER_CHANNEL || 'auto',
1576
1584
  port: process.env.FRAIM_BROWSER_PORT ? Number(process.env.FRAIM_BROWSER_PORT) : undefined,
@@ -1626,6 +1634,39 @@ class AiHubServer {
1626
1634
  next();
1627
1635
  });
1628
1636
  this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
1637
+ this.app.use('/_fraim-hub-ui', (req, res, next) => {
1638
+ try {
1639
+ const pathname = new URL(req.url, 'http://localhost').pathname;
1640
+ const parts = pathname.split('/').filter(Boolean).map((part) => decodeURIComponent(part));
1641
+ const [releaseId, ...assetParts] = parts;
1642
+ const assetPath = assetParts.join('/');
1643
+ (0, ui_cache_1.assertSafeHubUiAssetPath)(releaseId);
1644
+ (0, ui_cache_1.assertSafeHubUiAssetPath)(assetPath);
1645
+ const releaseDir = (0, ui_cache_1.hubUiReleaseDir)(this.hubUiCacheDir, releaseId);
1646
+ const filePath = path_1.default.join(releaseDir, assetPath);
1647
+ if (!filePath.startsWith(releaseDir + path_1.default.sep) && filePath !== releaseDir) {
1648
+ res.status(403).end();
1649
+ return;
1650
+ }
1651
+ const contentTypes = {
1652
+ '.html': 'text/html; charset=utf-8',
1653
+ '.css': 'text/css; charset=utf-8',
1654
+ '.js': 'application/javascript; charset=utf-8',
1655
+ };
1656
+ fs_1.default.readFile(filePath, (err, data) => {
1657
+ if (err) {
1658
+ next();
1659
+ return;
1660
+ }
1661
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
1662
+ res.setHeader('Content-Type', contentTypes[path_1.default.extname(filePath)] || 'application/octet-stream');
1663
+ res.end(data);
1664
+ });
1665
+ }
1666
+ catch {
1667
+ res.status(403).end();
1668
+ }
1669
+ });
1629
1670
  // Issue #489: Serve the Word task pane assets at /word-taskpane/*.
1630
1671
  // Office JS appends ?_host_Info=Word$Win32$... to every request — we must
1631
1672
  // strip the query string before resolving the file path, otherwise every
@@ -1704,6 +1745,17 @@ class AiHubServer {
1704
1745
  next();
1705
1746
  });
1706
1747
  }
1748
+ this.app.get('/api/ai-hub/bridge', (_req, res) => {
1749
+ res.json(this.hubUiBridgeInfo());
1750
+ });
1751
+ this.app.get('/api/ai-hub/ui-runtime', async (_req, res) => {
1752
+ try {
1753
+ res.json(await this.resolveHubUiRuntimeResponse());
1754
+ }
1755
+ catch (error) {
1756
+ res.status(500).json({ error: error instanceof Error ? error.message : String(error) });
1757
+ }
1758
+ });
1707
1759
  this.registerRoutes();
1708
1760
  }
1709
1761
  getApp() {
@@ -1793,6 +1845,45 @@ class AiHubServer {
1793
1845
  this.managedBrowser.stop();
1794
1846
  }
1795
1847
  getHttpsPort() { return this.httpsPort; }
1848
+ isHubUiRemoteEnabled() {
1849
+ if (this.hubUiRemoteEnabled !== undefined)
1850
+ return this.hubUiRemoteEnabled;
1851
+ return process.env.FRAIM_HUB_REMOTE_UI === '1';
1852
+ }
1853
+ hubUiBridgeInfo() {
1854
+ return (0, ui_runtime_1.createAiHubBridgeInfo)({
1855
+ bridgeVersion: (0, version_utils_1.getFraimVersion)(),
1856
+ remoteBaseUrl: (0, remote_hub_gateway_1.resolveFraimRemoteUrl)(),
1857
+ remoteUiEnabled: this.isHubUiRemoteEnabled(),
1858
+ });
1859
+ }
1860
+ resolveHubUiManifestUrl() {
1861
+ if (this.hubUiManifestUrl)
1862
+ return this.hubUiManifestUrl;
1863
+ if (!this.isHubUiRemoteEnabled())
1864
+ return undefined;
1865
+ return `${(0, remote_hub_gateway_1.resolveFraimRemoteUrl)()}/api/ai-hub/ui/releases/latest`;
1866
+ }
1867
+ resolveHubUiTrustedOrigins(manifestUrl) {
1868
+ if (this.hubUiTrustedOrigins?.length)
1869
+ return this.hubUiTrustedOrigins;
1870
+ if (!manifestUrl)
1871
+ return [];
1872
+ return [new URL(manifestUrl).origin];
1873
+ }
1874
+ resolveHubUiRuntimeResponse() {
1875
+ const bridge = this.hubUiBridgeInfo();
1876
+ const manifestUrl = this.resolveHubUiManifestUrl();
1877
+ return (0, ui_runtime_1.resolveHubUiRuntime)({
1878
+ bridge,
1879
+ cacheDir: this.hubUiCacheDir,
1880
+ remoteEnabled: this.isHubUiRemoteEnabled(),
1881
+ manifestUrl,
1882
+ trustedOrigins: this.resolveHubUiTrustedOrigins(manifestUrl),
1883
+ publicKeyPem: this.hubUiPublicKeyPem,
1884
+ allowInsecureLoopback: this.hubUiAllowInsecureLoopback,
1885
+ });
1886
+ }
1796
1887
  knownProjects(projectPath, extras = [], options = {}) {
1797
1888
  // #866 R2: guard against path.resolve('') === cwd. When there is no active
1798
1889
  // project, keep the path empty so no invocation-directory project is injected.
@@ -1848,18 +1939,12 @@ class AiHubServer {
1848
1939
  console.warn('[ai-hub] manager-team lookup failed:', err?.message || err);
1849
1940
  return [];
1850
1941
  });
1851
- const personaProjectionPromise = this.computePersonas(resolvedApiKey, managerTeamPromise, normalizedProjectPath);
1852
- const fallbackPersonaProjection = this.fallbackPersonaProjection(resolvedApiKey, normalizedProjectPath);
1942
+ const personaProjectionPromise = this.computePersonas(resolvedApiKey, managerTeamPromise);
1943
+ const fallbackPersonaProjection = this.fallbackPersonaProjection(resolvedApiKey);
1853
1944
  const personaProjection = await this.withFirstPaintBudget(personaProjectionPromise, fallbackPersonaProjection, 'persona projection');
1854
- // Issue #1005: identity not persona contents — is what distinguishes a first-paint
1855
- // placeholder from an answer. withFirstPaintBudget resolves to this exact object only
1856
- // on timeout; computePersonas returns a fresh `{ ...fallbackProjection, userKey }` even
1857
- // when the authority definitively locks everything (401 / feature-off). So a real
1858
- // sign-out is still `resolved`, and only a budget miss is `unresolved`.
1859
- const personasResolved = personaProjection !== fallbackPersonaProjection;
1860
- const managerTeam = personasResolved
1861
- ? await this.withFirstPaintBudget(managerTeamPromise, [], 'manager team')
1862
- : [];
1945
+ const managerTeam = personaProjection === fallbackPersonaProjection
1946
+ ? []
1947
+ : await this.withFirstPaintBudget(managerTeamPromise, [], 'manager team');
1863
1948
  const { personas, subscriptionActive, workspaceId, userKey } = personaProjection;
1864
1949
  void personaProjectionPromise.catch(() => undefined);
1865
1950
  void managerTeamPromise.catch(() => undefined);
@@ -1894,9 +1979,6 @@ class AiHubServer {
1894
1979
  employees,
1895
1980
  configuredAgents,
1896
1981
  personas,
1897
- // Issue #1005: lets the client tell a resolved projection from the first-paint
1898
- // placeholder, so a late unresolved bootstrap cannot wipe a resolved roster.
1899
- personasResolved,
1900
1982
  subscriptionActive,
1901
1983
  activeRun,
1902
1984
  projects,
@@ -1969,7 +2051,7 @@ class AiHubServer {
1969
2051
  },
1970
2052
  };
1971
2053
  }
1972
- if (existing.jobId && existing.jobId !== '__freeform__' && existing.jobId !== options.jobId) {
2054
+ if (existing.jobId && existing.jobId !== options.jobId) {
1973
2055
  console.warn('[ai-hub] hub.conversation_continuity.rejected', {
1974
2056
  conversationId,
1975
2057
  existingJobId: existing.jobId,
@@ -1987,15 +2069,6 @@ class AiHubServer {
1987
2069
  }
1988
2070
  return { ok: true, continuityDecision: 'same_continuity' };
1989
2071
  }
1990
- resolveImplicitResumeConversation(options) {
1991
- const candidates = this.conversationStore.loadProject(options.projectPath).conversations.filter((conversation) => {
1992
- if (conversation.jobId !== options.jobId)
1993
- return false;
1994
- const existingAgentId = this.configuredAgentIdForConversation(conversation, options.employees);
1995
- return existingAgentId === options.requestedAgent.id;
1996
- });
1997
- return candidates.length === 1 ? candidates[0] : null;
1998
- }
1999
2072
  isTrustedHubOrigin(req) {
2000
2073
  const origin = req.get('origin');
2001
2074
  if (!origin)
@@ -2606,15 +2679,9 @@ class AiHubServer {
2606
2679
  const message = (invocationForm ?? (0, manager_turns_1.buildSameJobContinueMessage)(userText)) + (0, manager_turns_1.buildCommunicationStyleNote)();
2607
2680
  return { message, display };
2608
2681
  }
2609
- async computePersonas(apiKey, managerTeamPromise,
2610
- // Issue #1005: the project whose custom employees belong in this projection. Custom
2611
- // employees are per-project (`fraim/personalized-employee/employees/`), so reading
2612
- // them from `this.projectPath` (the directory the Hub launched from) leaked one
2613
- // project's employees into every other project's roster. Defaults to the launch
2614
- // project only so callers that genuinely have no project context keep working.
2615
- projectPath = this.projectPath) {
2682
+ async computePersonas(apiKey, managerTeamPromise) {
2616
2683
  const allBundles = listHubPersonaBundles();
2617
- const fallbackProjection = this.fallbackPersonaProjection(apiKey, projectPath);
2684
+ const fallbackProjection = this.fallbackPersonaProjection(apiKey);
2618
2685
  // Issue #701: persona state comes from the hosted server (GET /api/personas/me)
2619
2686
  // via the user's API key — never a local MongoDB connection. Issue #925: resolve
2620
2687
  // it in a transport-aware way so a transient outage does not collapse to the same
@@ -2631,7 +2698,7 @@ class AiHubServer {
2631
2698
  // (feature-off, legacy bypass, per-entitlement gating) lives solely in
2632
2699
  // persona-entitlement-service.resolvePersonaAccessStatuses — the Hub does not
2633
2700
  // re-derive it.
2634
- const customPersonas = (0, custom_employees_1.readCustomEmployees)(projectPath).map(custom_employees_1.buildCustomEmployeePersona);
2701
+ const customPersonas = (0, custom_employees_1.readCustomEmployees)(this.projectPath).map(custom_employees_1.buildCustomEmployeePersona);
2635
2702
  if (!state) {
2636
2703
  return { ...fallbackProjection, userKey };
2637
2704
  }
@@ -2670,9 +2737,7 @@ class AiHubServer {
2670
2737
  return { ...fallbackProjection, userKey };
2671
2738
  }
2672
2739
  }
2673
- fallbackPersonaProjection(apiKey,
2674
- // Issue #1005: see computePersonas — custom employees are per-project.
2675
- projectPath = this.projectPath) {
2740
+ fallbackPersonaProjection(apiKey) {
2676
2741
  const fallbackPersonas = listHubPersonaBundles().map((bundle) => ({
2677
2742
  key: bundle.personaKey,
2678
2743
  displayName: bundle.catalogMetadata.displayName,
@@ -2685,7 +2750,7 @@ class AiHubServer {
2685
2750
  seatsInUse: 0,
2686
2751
  origin: 'catalog',
2687
2752
  }));
2688
- const customPersonas = (0, custom_employees_1.readCustomEmployees)(projectPath).map(custom_employees_1.buildCustomEmployeePersona);
2753
+ const customPersonas = (0, custom_employees_1.readCustomEmployees)(this.projectPath).map(custom_employees_1.buildCustomEmployeePersona);
2689
2754
  return {
2690
2755
  personas: [...fallbackPersonas, ...customPersonas],
2691
2756
  subscriptionActive: false,
@@ -2853,9 +2918,7 @@ class AiHubServer {
2853
2918
  console.warn('[ai-hub] manager-team lookup failed:', err?.message || err);
2854
2919
  return [];
2855
2920
  });
2856
- // Issue #1005: pass the requested project so custom employees are scoped to it,
2857
- // not to the directory the Hub launched from.
2858
- const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey, managerTeamPromise, projectPath);
2921
+ const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey, managerTeamPromise);
2859
2922
  const managerTeam = await managerTeamPromise;
2860
2923
  const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath, { includeRegistry: true })
2861
2924
  .filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
@@ -3371,7 +3434,15 @@ class AiHubServer {
3371
3434
  const version = (0, version_utils_1.getFraimVersion)();
3372
3435
  const latest = await (0, hub_latest_version_1.getLatestPublishedVersion)();
3373
3436
  const updateAvailable = !!(latest && semver.valid(version) && semver.valid(latest) && semver.gt(latest, version));
3374
- return res.json({ version, latest, updateAvailable });
3437
+ const uiRuntime = await this.resolveHubUiRuntimeResponse();
3438
+ return res.json({
3439
+ version,
3440
+ latest,
3441
+ updateAvailable,
3442
+ uiReleaseId: uiRuntime.releaseId,
3443
+ uiMode: uiRuntime.mode,
3444
+ uiUpdateAvailable: uiRuntime.mode === 'remote-cache',
3445
+ });
3375
3446
  });
3376
3447
  // #921: expose the server's pid so the orphan scanner in cli.ts can identify and
3377
3448
  // kill Hub processes that are not registered in hub-runtime.json.
@@ -4089,18 +4160,13 @@ class AiHubServer {
4089
4160
  throw new Error('A jobId is required to resume.');
4090
4161
  if (!instructions)
4091
4162
  throw new Error('Provide an instruction to continue.');
4092
- const employees = this.hostRuntime.detectEmployees();
4093
- const { hostId, agent: configuredAgent, launchContext } = this.resolveLaunchAgent(body.configuredAgentId, requestedHostId, employees);
4094
- const requestedConversationId = typeof body.conversationId === 'string' && body.conversationId.trim()
4163
+ const { hostId, agent: configuredAgent, launchContext } = this.resolveLaunchAgent(body.configuredAgentId, requestedHostId);
4164
+ const conversationId = typeof body.conversationId === 'string' && body.conversationId.trim()
4095
4165
  ? body.conversationId.trim()
4096
4166
  : undefined;
4097
- const inferredConversation = requestedConversationId
4098
- ? null
4099
- : this.resolveImplicitResumeConversation({ projectPath, jobId, requestedAgent: configuredAgent, employees });
4100
- const conversationId = requestedConversationId || inferredConversation?.id;
4101
- const persistedConversation = requestedConversationId
4102
- ? this.conversationStore.loadProject(projectPath).conversations.find((entry) => entry.id === requestedConversationId)
4103
- : inferredConversation ?? undefined;
4167
+ const persistedConversation = conversationId
4168
+ ? this.conversationStore.loadProject(projectPath).conversations.find((entry) => entry.id === conversationId)
4169
+ : undefined;
4104
4170
  const persistedRun = readPersistedRunProjection(persistedConversation);
4105
4171
  const now = new Date().toISOString();
4106
4172
  const run = {
@@ -4111,16 +4177,12 @@ class AiHubServer {
4111
4177
  jobId, hostId, configuredAgentId: configuredAgent.id, configuredAgentLabel: configuredAgent.label, baseHostId: configuredAgent.baseHostId, projectPath, status: 'running', sessionId,
4112
4178
  createdAt: now, updatedAt: now, messages: [],
4113
4179
  events: [(0, hosts_1.createHubEvent)('system', `Resuming ${configuredAgent.label} (${hostId}) session ${sessionId} in ${projectPath}`)],
4114
- // Only carry phase state forward when the prior run was interrupted mid-job
4115
- // (status !== 'completed'). A completed conversation is a finished run;
4116
- // resuming the session starts a fresh job, so the tracker must be blank.
4117
- currentPhase: persistedConversation?.status !== 'completed' ? (persistedRun?.currentPhase || null) : null,
4118
- phaseHistory: persistedConversation?.status !== 'completed' ? (persistedRun?.phaseHistory || []) : [],
4180
+ currentPhase: persistedRun?.currentPhase || null,
4181
+ phaseHistory: persistedRun?.phaseHistory || [],
4119
4182
  totals: persistedRun?.totals || emptyTotals(),
4120
4183
  lastStatusChangeAt: now,
4121
- runDiscriminant: persistedConversation?.status !== 'completed' ? (persistedRun?.runDiscriminant || undefined) : undefined,
4184
+ runDiscriminant: persistedRun?.runDiscriminant || undefined,
4122
4185
  personaKey: getHubPersonaForJob(jobId),
4123
- continuityDecision: conversationId ? 'same_continuity' : 'new_conversation',
4124
4186
  };
4125
4187
  // Continue-turn message (FRAIM invocation for the job + instructions) plus
4126
4188
  // the shared-browser note so the resumed agent knows about it.
@@ -5090,6 +5152,13 @@ function resolveAiHubPublicDir() {
5090
5152
  }
5091
5153
  throw new Error('Could not locate public/ai-hub assets.');
5092
5154
  }
5155
+ function parseHubUiTrustedOrigins() {
5156
+ const raw = process.env.FRAIM_HUB_UI_TRUSTED_ORIGINS;
5157
+ if (!raw)
5158
+ return undefined;
5159
+ const origins = raw.split(',').map((part) => part.trim()).filter(Boolean);
5160
+ return origins.length ? origins : undefined;
5161
+ }
5093
5162
  // Issue #489: Resolve the word taskpane static assets directory.
5094
5163
  // Returns null (not throws) when the directory does not exist so the Hub
5095
5164
  // can start without the Word add-in assets present (e.g., older installs).