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.
- package/bin/fraim-hub-2.js +12 -0
- package/dist/src/ai-hub/cli.js +12 -6
- package/dist/src/ai-hub/desktop-main.js +29 -18
- package/dist/src/ai-hub/hosts.js +262 -11
- package/dist/src/ai-hub/hub-runtime-file.js +15 -9
- package/dist/src/ai-hub/hub2-remote-config.js +21 -0
- package/dist/src/ai-hub/preferences.js +30 -1
- package/dist/src/ai-hub/server.js +570 -2
- package/dist/src/ai-hub/ui-cache.js +96 -0
- package/dist/src/ai-hub/ui-manifest-client.js +93 -0
- package/dist/src/ai-hub/ui-runtime.js +175 -0
- package/dist/src/cli/fraim-hub-2.js +40 -0
- package/dist/src/cli/fraim-hub.js +11 -6
- package/dist/src/config/persona-capability-bundles.js +2 -2
- package/package.json +6 -3
- package/public/ai-hub/index.html +2 -1
- package/public/ai-hub/remote-ui-loader.js +63 -0
- package/public/ai-hub/script.js +224 -107
- package/public/ai-hub/styles.css +42 -19
|
@@ -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
|
+
}
|
package/dist/src/ai-hub/cli.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
143
|
+
label: `About ${name}`,
|
|
135
144
|
click: () => {
|
|
136
145
|
void electron_1.dialog.showMessageBox({
|
|
137
146
|
type: 'info',
|
|
138
|
-
title:
|
|
139
|
-
message:
|
|
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:
|
|
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(
|
|
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:
|
|
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
|
|
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(
|
|
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();
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
745
|
-
|
|
746
|
-
return
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
|
17
|
-
function
|
|
18
|
-
|
|
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
|
|
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
|
-
|
|
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);
|