fraim-hub 2.0.309 → 2.0.311
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/ai-hub/desktop-main.js +101 -5
- package/dist/src/ai-hub/devtools-window.js +17 -0
- package/dist/src/ai-hub/hosts.js +35 -0
- package/dist/src/ai-hub/hub-app-materializer.js +33 -3
- package/dist/src/ai-hub/hub-main-diagnostics.js +278 -0
- package/dist/src/ai-hub/server.js +35 -3
- package/dist/src/cli/utils/managed-agent-install.js +92 -3
- package/dist/src/first-run/session-service.js +2 -0
- package/package.json +2 -2
- package/public/ai-hub/script.js +241 -95
- package/public/ai-hub/styles.css +26 -0
|
@@ -24,6 +24,8 @@ const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launche
|
|
|
24
24
|
const desktop_auto_updater_1 = require("./desktop-auto-updater");
|
|
25
25
|
const hub_app_materializer_1 = require("./hub-app-materializer");
|
|
26
26
|
const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
|
|
27
|
+
const hub_main_diagnostics_1 = require("./hub-main-diagnostics");
|
|
28
|
+
const devtools_window_1 = require("./devtools-window");
|
|
27
29
|
// Keep installed, running, and user-pinned Windows shortcuts grouped under the
|
|
28
30
|
// stable identity declared in packages/fraim-hub/package.json.
|
|
29
31
|
electron_1.app.setAppUserModelId('ai.fraim.hub');
|
|
@@ -163,7 +165,7 @@ async function resolveLatestHubAppVersion() {
|
|
|
163
165
|
bundledVersion: runningVersion,
|
|
164
166
|
bundledEntryPath: __filename,
|
|
165
167
|
});
|
|
166
|
-
return { upgradable: resolved.version !== runningVersion, latest: resolved.version };
|
|
168
|
+
return { upgradable: resolved.version !== runningVersion, latest: resolved.version, error: resolved.materializationError };
|
|
167
169
|
}
|
|
168
170
|
// Issue #1415: a second-instance activation (the user re-clicking the icon while the Hub is
|
|
169
171
|
// already running) is the common case once login-item autostart + hide-to-tray keep the Hub
|
|
@@ -189,9 +191,12 @@ async function checkForHubAppUpdateOnSecondInstance() {
|
|
|
189
191
|
// response reaches the browser (which uses it to start polling for the new instance) before this
|
|
190
192
|
// process starts tearing down.
|
|
191
193
|
async function restartToLatestNow() {
|
|
192
|
-
const { upgradable, latest } = await resolveLatestHubAppVersion();
|
|
193
|
-
if (!upgradable)
|
|
194
|
-
|
|
194
|
+
const { upgradable, latest, error } = await resolveLatestHubAppVersion();
|
|
195
|
+
if (!upgradable) {
|
|
196
|
+
if (error)
|
|
197
|
+
console.warn(`[fraim] manual restart requested but could not complete: ${error}`);
|
|
198
|
+
return { restarting: false, latest, error };
|
|
199
|
+
}
|
|
195
200
|
console.log(`[fraim] manual restart requested; relaunching to FRAIM Hub ${latest}`);
|
|
196
201
|
setTimeout(() => {
|
|
197
202
|
electron_1.app.relaunch();
|
|
@@ -274,6 +279,14 @@ function shouldConfigureOfficeSideload() {
|
|
|
274
279
|
function displayName(runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub') {
|
|
275
280
|
return process.env.FRAIM_HUB_DISPLAY_NAME || (runtimeId === 'hub2' ? 'FRAIM Hub 2' : 'FRAIM Hub');
|
|
276
281
|
}
|
|
282
|
+
async function openMainWindowDevTools(hubUrl) {
|
|
283
|
+
await (0, devtools_window_1.openHubWindowDevTools)({
|
|
284
|
+
hubUrl,
|
|
285
|
+
getWindow: () => mainWindow,
|
|
286
|
+
createWindow,
|
|
287
|
+
recordOpenRequested: () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.devtools.open_requested'),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
277
290
|
function buildTrayMenu(hubUrl, runtimeId) {
|
|
278
291
|
const name = displayName(runtimeId);
|
|
279
292
|
return electron_1.Menu.buildFromTemplate([
|
|
@@ -289,6 +302,14 @@ function buildTrayMenu(hubUrl, runtimeId) {
|
|
|
289
302
|
}
|
|
290
303
|
},
|
|
291
304
|
},
|
|
305
|
+
{
|
|
306
|
+
label: 'Open DevTools',
|
|
307
|
+
click: () => {
|
|
308
|
+
void openMainWindowDevTools(hubUrl).catch((error) => {
|
|
309
|
+
console.warn('[fraim] could not open Hub DevTools:', error);
|
|
310
|
+
});
|
|
311
|
+
},
|
|
312
|
+
},
|
|
292
313
|
{
|
|
293
314
|
// #755: surface the running build version so staleness is diagnosable at a glance.
|
|
294
315
|
label: `About ${name}`,
|
|
@@ -345,6 +366,36 @@ function createTray(hubUrl, runtimeId) {
|
|
|
345
366
|
// ---------------------------------------------------------------------------
|
|
346
367
|
// BrowserWindow
|
|
347
368
|
// ---------------------------------------------------------------------------
|
|
369
|
+
function buildDesktopDebugSnapshot(runtimeId) {
|
|
370
|
+
const win = mainWindow;
|
|
371
|
+
const windowPresent = Boolean(win);
|
|
372
|
+
const isDestroyed = win ? win.isDestroyed() : null;
|
|
373
|
+
const canReadWindow = Boolean(win && !isDestroyed);
|
|
374
|
+
const safeRead = (reader) => {
|
|
375
|
+
if (!canReadWindow)
|
|
376
|
+
return null;
|
|
377
|
+
try {
|
|
378
|
+
return reader();
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
return {
|
|
385
|
+
runtimeId,
|
|
386
|
+
windowPresent,
|
|
387
|
+
isDestroyed,
|
|
388
|
+
isFocused: safeRead(() => win.isFocused()),
|
|
389
|
+
isVisible: safeRead(() => win.isVisible()),
|
|
390
|
+
isMinimized: safeRead(() => win.isMinimized()),
|
|
391
|
+
bounds: safeRead(() => win.getBounds()),
|
|
392
|
+
title: safeRead(() => win.getTitle()),
|
|
393
|
+
url: safeRead(() => win.webContents.getURL()),
|
|
394
|
+
webContentsDestroyed: win ? safeRead(() => win.webContents.isDestroyed()) : null,
|
|
395
|
+
devToolsOpened: safeRead(() => win.webContents.isDevToolsOpened()),
|
|
396
|
+
gpuFeatureStatus: electron_1.app.getGPUFeatureStatus(),
|
|
397
|
+
};
|
|
398
|
+
}
|
|
348
399
|
async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID || 'hub') {
|
|
349
400
|
const { width, height } = preferredWindowSize();
|
|
350
401
|
const isMac = process.platform === 'darwin';
|
|
@@ -374,6 +425,32 @@ async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID ||
|
|
|
374
425
|
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
|
375
426
|
});
|
|
376
427
|
electron_1.Menu.setApplicationMenu(null);
|
|
428
|
+
mainWindow.on('focus', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.focus'));
|
|
429
|
+
mainWindow.on('blur', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.blur'));
|
|
430
|
+
mainWindow.on('show', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.show'));
|
|
431
|
+
mainWindow.on('hide', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.hide'));
|
|
432
|
+
mainWindow.on('close', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.close', { isQuitting }));
|
|
433
|
+
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
|
434
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.render_process_gone', details);
|
|
435
|
+
});
|
|
436
|
+
mainWindow.webContents.on('unresponsive', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.unresponsive'));
|
|
437
|
+
mainWindow.webContents.on('responsive', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.responsive'));
|
|
438
|
+
mainWindow.webContents.on('did-finish-load', () => {
|
|
439
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.did_finish_load', {
|
|
440
|
+
url: mainWindow?.webContents.getURL() || '',
|
|
441
|
+
title: mainWindow?.getTitle() || '',
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
|
445
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.did_fail_load', {
|
|
446
|
+
errorCode,
|
|
447
|
+
errorDescription,
|
|
448
|
+
validatedURL,
|
|
449
|
+
isMainFrame,
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
mainWindow.webContents.on('devtools-opened', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.devtools_opened'));
|
|
453
|
+
mainWindow.webContents.on('devtools-closed', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('webcontents.devtools_closed'));
|
|
377
454
|
try {
|
|
378
455
|
await mainWindow.webContents.session.clearCache();
|
|
379
456
|
}
|
|
@@ -448,7 +525,10 @@ async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID ||
|
|
|
448
525
|
mainWindow?.hide();
|
|
449
526
|
}
|
|
450
527
|
});
|
|
451
|
-
mainWindow.on('closed', () => {
|
|
528
|
+
mainWindow.on('closed', () => {
|
|
529
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('window.closed');
|
|
530
|
+
mainWindow = null;
|
|
531
|
+
});
|
|
452
532
|
await mainWindow.loadURL(url);
|
|
453
533
|
mainWindow.webContents.setZoomFactor(1);
|
|
454
534
|
}
|
|
@@ -499,6 +579,9 @@ async function launchDesktopShell(options) {
|
|
|
499
579
|
return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0];
|
|
500
580
|
},
|
|
501
581
|
restartToLatest: restartToLatestNow,
|
|
582
|
+
hubMainDiagnostics: {
|
|
583
|
+
snapshot: () => (0, hub_main_diagnostics_1.getHubMainDiagnosticsSnapshot)(buildDesktopDebugSnapshot(runtimeId)),
|
|
584
|
+
},
|
|
502
585
|
});
|
|
503
586
|
await server.start(httpPort);
|
|
504
587
|
const resolvedProjectPath = server.getProjectPath();
|
|
@@ -566,6 +649,16 @@ async function launchDesktopShell(options) {
|
|
|
566
649
|
async function bootstrap() {
|
|
567
650
|
const options = parseArgs(process.argv.slice(2));
|
|
568
651
|
applyUserDataOverride();
|
|
652
|
+
(0, hub_main_diagnostics_1.installHubMainConsoleTee)();
|
|
653
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.startup', {
|
|
654
|
+
runtimeId: options.runtimeId,
|
|
655
|
+
preferredPort: options.preferredPort,
|
|
656
|
+
hasProjectPath: Boolean(options.projectPath),
|
|
657
|
+
});
|
|
658
|
+
electron_1.app.on('child-process-gone', (_event, details) => {
|
|
659
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('app.child_process_gone', details);
|
|
660
|
+
});
|
|
661
|
+
electron_1.app.on('gpu-info-update', () => (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('app.gpu_info_update'));
|
|
569
662
|
// Single-instance lock — if another instance is already running, focus it
|
|
570
663
|
// and exit rather than spawning a second server + window.
|
|
571
664
|
// Skip when FRAIM_AI_HUB_FAKE_HOST=1 (test mode) so Playwright can launch
|
|
@@ -577,6 +670,7 @@ async function bootstrap() {
|
|
|
577
670
|
return;
|
|
578
671
|
}
|
|
579
672
|
electron_1.app.on('second-instance', () => {
|
|
673
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.second_instance');
|
|
580
674
|
void electron_1.app.whenReady().then(checkForRelaunchAttemptUpdate);
|
|
581
675
|
if (mainWindow) {
|
|
582
676
|
mainWindow.show();
|
|
@@ -624,6 +718,7 @@ async function bootstrap() {
|
|
|
624
718
|
// macOS: clicking dock icon re-shows the window. Unlike Windows/Linux, this never spawns a
|
|
625
719
|
// second process (so 'second-instance' never fires here) — it is macOS's equivalent
|
|
626
720
|
// relaunch-attempt event, so it gets the same npm-latest re-check (#1415).
|
|
721
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.activate');
|
|
627
722
|
checkForRelaunchAttemptUpdate();
|
|
628
723
|
if (mainWindow) {
|
|
629
724
|
mainWindow.show();
|
|
@@ -632,6 +727,7 @@ async function bootstrap() {
|
|
|
632
727
|
});
|
|
633
728
|
electron_1.app.on('before-quit', () => {
|
|
634
729
|
isQuitting = true;
|
|
730
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('hub.shutdown');
|
|
635
731
|
// #755: clear the runtime file so a later `fraim hub` doesn't treat a
|
|
636
732
|
// cleanly-exited instance as live.
|
|
637
733
|
try {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.openHubWindowDevTools = openHubWindowDevTools;
|
|
4
|
+
async function openHubWindowDevTools(options) {
|
|
5
|
+
options.recordOpenRequested?.();
|
|
6
|
+
let window = options.getWindow();
|
|
7
|
+
if (!window || window.isDestroyed()) {
|
|
8
|
+
await options.createWindow(options.hubUrl);
|
|
9
|
+
window = options.getWindow();
|
|
10
|
+
}
|
|
11
|
+
if (!window || window.isDestroyed())
|
|
12
|
+
return 'unavailable';
|
|
13
|
+
window.show();
|
|
14
|
+
window.focus();
|
|
15
|
+
window.webContents.openDevTools({ mode: 'detach' });
|
|
16
|
+
return 'opened';
|
|
17
|
+
}
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -630,6 +630,17 @@ function microEventFromToolResult(candidate) {
|
|
|
630
630
|
const text = resultTextFromValue(candidate.result ?? candidate.output ?? candidate.content ?? candidate.message);
|
|
631
631
|
return text ? [{ kind: 'mentor_reply', text }] : undefined;
|
|
632
632
|
}
|
|
633
|
+
function microEventFromNonMentorToolCall(candidate) {
|
|
634
|
+
const rawName = readToolName(candidate);
|
|
635
|
+
const name = typeof rawName === 'string' ? rawName : null;
|
|
636
|
+
if (!name)
|
|
637
|
+
return undefined;
|
|
638
|
+
const rawArgs = candidate.input || candidate.arguments || candidate.parameters;
|
|
639
|
+
const args = normalizeToolArgs(rawArgs);
|
|
640
|
+
const argSummary = args ? JSON.stringify(args) : '';
|
|
641
|
+
const text = argSummary ? `${name}(${argSummary})` : name;
|
|
642
|
+
return { kind: 'tool_call', text };
|
|
643
|
+
}
|
|
633
644
|
function readToolUseId(candidate) {
|
|
634
645
|
return stringValue(candidate.id) || stringValue(candidate.tool_use_id) || stringValue(candidate.toolUseId);
|
|
635
646
|
}
|
|
@@ -685,6 +696,12 @@ function parseMicroEventsSignal(line) {
|
|
|
685
696
|
if (id)
|
|
686
697
|
mentorToolUseIds.push(id);
|
|
687
698
|
}
|
|
699
|
+
else {
|
|
700
|
+
// Non-seekMentoring tool calls: emit so the manager sees all agent activity.
|
|
701
|
+
const nonMentorCall = microEventFromNonMentorToolCall(content);
|
|
702
|
+
if (nonMentorCall)
|
|
703
|
+
events.push(nonMentorCall);
|
|
704
|
+
}
|
|
688
705
|
}
|
|
689
706
|
if (content.type === 'tool_result') {
|
|
690
707
|
const reply = microEventFromToolResult(content);
|
|
@@ -698,6 +715,24 @@ function parseMicroEventsSignal(line) {
|
|
|
698
715
|
mentorToolResults.push({ toolUseId, text });
|
|
699
716
|
}
|
|
700
717
|
}
|
|
718
|
+
// Thinking blocks are suppressed from the main conversation panel but belong
|
|
719
|
+
// in micro-manage, where hidden agent reasoning is the primary signal of interest.
|
|
720
|
+
if (content.type === 'thinking' && typeof content.thinking === 'string' && content.thinking.length > 0) {
|
|
721
|
+
events.push({ kind: 'thinking', text: content.thinking });
|
|
722
|
+
}
|
|
723
|
+
// Opt-out: any content type not already handled above is emitted so new agent
|
|
724
|
+
// capabilities are visible without requiring code changes. Exclusions: types already
|
|
725
|
+
// handled (tool_use, function_call, tool_result, thinking) and 'text' (shown in the
|
|
726
|
+
// main conversation panel, not the micro-manage panel).
|
|
727
|
+
if (content.type !== 'text' &&
|
|
728
|
+
content.type !== 'tool_use' &&
|
|
729
|
+
content.type !== 'function_call' &&
|
|
730
|
+
content.type !== 'tool_result' &&
|
|
731
|
+
content.type !== 'thinking') {
|
|
732
|
+
const typeStr = typeof content.type === 'string' ? content.type : 'unknown';
|
|
733
|
+
if (typeStr)
|
|
734
|
+
events.push({ kind: 'tool_call', text: typeStr });
|
|
735
|
+
}
|
|
701
736
|
}
|
|
702
737
|
return {
|
|
703
738
|
...(events.length > 0 ? { microEvents: events } : {}),
|
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.HUB_APP_ENTRY_RELATIVE = exports.HUB_APP_PACKAGE_NAME = void 0;
|
|
7
|
+
exports.redactCredentialLikeSubstrings = redactCredentialLikeSubstrings;
|
|
7
8
|
exports.resolvePackagedCliScriptPath = resolvePackagedCliScriptPath;
|
|
8
9
|
exports.planHubAppMaterialization = planHubAppMaterialization;
|
|
9
10
|
exports.resolveHubAppCacheDir = resolveHubAppCacheDir;
|
|
@@ -33,6 +34,23 @@ const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
|
|
|
33
34
|
// offline matrix is unit-testable without touching npm, disk, or Electron — the same separation
|
|
34
35
|
// `hub-launch-decision.ts` (#755) and `desktop-auto-updater.ts` (#1387) already established for
|
|
35
36
|
// this exact class of problem.
|
|
37
|
+
// Issue #1627 (security review): the raw materialization failure text below is now surfaced to a
|
|
38
|
+
// new, wider audience than before this issue — a persisted log file and the manual restart-to-
|
|
39
|
+
// latest HTTP response/UI — where previously it was silently discarded. `npm install` output does
|
|
40
|
+
// not normally echo credentials, but a misconfigured corporate registry (a literal token in a
|
|
41
|
+
// `.npmrc` URL, verbose proxy auth errors) is a real enough edge case that this new, wider surface
|
|
42
|
+
// should not forward it as-is. Defense-in-depth only: redact credential-shaped substrings before
|
|
43
|
+
// this text reaches either destination.
|
|
44
|
+
const CREDENTIAL_LIKE_PATTERNS = [
|
|
45
|
+
/:\/\/[^\s/@]+:[^\s/@]+@/g, // scheme://user:pass@ basic-auth-in-URL
|
|
46
|
+
/\b(?:_authToken|authToken|Authorization|api[_-]?key|password)\s*[:=]\s*\S+/gi,
|
|
47
|
+
/\bnpm_[A-Za-z0-9]{20,}\b/g, // npm access-token shape
|
|
48
|
+
/\bBearer\s+[A-Za-z0-9._-]{10,}\b/gi,
|
|
49
|
+
];
|
|
50
|
+
/** Strip anything that looks like a credential from text before it is logged or surfaced. */
|
|
51
|
+
function redactCredentialLikeSubstrings(text) {
|
|
52
|
+
return CREDENTIAL_LIKE_PATTERNS.reduce((acc, pattern) => acc.replace(pattern, '[redacted]'), text);
|
|
53
|
+
}
|
|
36
54
|
exports.HUB_APP_PACKAGE_NAME = 'fraim-hub';
|
|
37
55
|
/** Relative path, from a materialized (or bundled) package root, to the Electron main entry. */
|
|
38
56
|
exports.HUB_APP_ENTRY_RELATIVE = path_1.default.join('dist', 'src', 'ai-hub', 'desktop-main.js');
|
|
@@ -68,12 +86,14 @@ function planHubAppMaterialization(input) {
|
|
|
68
86
|
action: 'use-cached',
|
|
69
87
|
version: newestCached,
|
|
70
88
|
reason: 'npm registry unreachable; reusing the newest complete cached version',
|
|
89
|
+
degraded: false,
|
|
71
90
|
};
|
|
72
91
|
}
|
|
73
92
|
return {
|
|
74
93
|
action: 'use-bundled',
|
|
75
94
|
version: bundledVersion,
|
|
76
95
|
reason: 'npm registry unreachable and no cached version available; falling back to the version bundled with this installer',
|
|
96
|
+
degraded: true,
|
|
77
97
|
};
|
|
78
98
|
}
|
|
79
99
|
if (latestPublishedVersion === bundledVersion) {
|
|
@@ -81,6 +101,7 @@ function planHubAppMaterialization(input) {
|
|
|
81
101
|
action: 'use-bundled',
|
|
82
102
|
version: bundledVersion,
|
|
83
103
|
reason: 'the installed shell already matches the latest published version',
|
|
104
|
+
degraded: false,
|
|
84
105
|
};
|
|
85
106
|
}
|
|
86
107
|
const alreadyMaterialized = cachedVersions.some((entry) => entry.complete && entry.version === latestPublishedVersion);
|
|
@@ -89,12 +110,14 @@ function planHubAppMaterialization(input) {
|
|
|
89
110
|
action: 'use-cached',
|
|
90
111
|
version: latestPublishedVersion,
|
|
91
112
|
reason: 'latest published version is already materialized on disk',
|
|
113
|
+
degraded: false,
|
|
92
114
|
};
|
|
93
115
|
}
|
|
94
116
|
return {
|
|
95
117
|
action: 'materialize',
|
|
96
118
|
version: latestPublishedVersion,
|
|
97
119
|
reason: `latest published version ${latestPublishedVersion} is not yet materialized`,
|
|
120
|
+
degraded: false,
|
|
98
121
|
};
|
|
99
122
|
}
|
|
100
123
|
// ---------------------------------------------------------------------------
|
|
@@ -265,7 +288,11 @@ async function resolveHubAppEntry(options) {
|
|
|
265
288
|
cachedVersions,
|
|
266
289
|
});
|
|
267
290
|
if (plan.action === 'use-bundled') {
|
|
268
|
-
|
|
291
|
+
const materializationError = plan.degraded ? plan.reason : undefined;
|
|
292
|
+
if (materializationError) {
|
|
293
|
+
console.error(`[fraim] FRAIM Hub update check could not complete: ${materializationError}`);
|
|
294
|
+
}
|
|
295
|
+
return { entryPath: options.bundledEntryPath, source: 'bundled', version: plan.version, materializationError };
|
|
269
296
|
}
|
|
270
297
|
if (plan.action === 'use-cached') {
|
|
271
298
|
return {
|
|
@@ -279,7 +306,10 @@ async function resolveHubAppEntry(options) {
|
|
|
279
306
|
const entryPath = await ensureMaterializedHubApp(plan.version, { fraimDir, installPackage: options.installPackage });
|
|
280
307
|
return { entryPath, source: 'cached', version: plan.version };
|
|
281
308
|
}
|
|
282
|
-
catch {
|
|
283
|
-
|
|
309
|
+
catch (error) {
|
|
310
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
311
|
+
const materializationError = redactCredentialLikeSubstrings(rawMessage);
|
|
312
|
+
console.error(`[fraim] failed to materialize FRAIM Hub ${plan.version}; falling back to bundled ${options.bundledVersion}: ${materializationError}`);
|
|
313
|
+
return { entryPath: options.bundledEntryPath, source: 'bundled', version: options.bundledVersion, materializationError };
|
|
284
314
|
}
|
|
285
315
|
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.HubMainDiagnostics = void 0;
|
|
7
|
+
exports.getHubMainDiagnostics = getHubMainDiagnostics;
|
|
8
|
+
exports.resetHubMainDiagnosticsForTest = resetHubMainDiagnosticsForTest;
|
|
9
|
+
exports.recordHubMainDiagnostic = recordHubMainDiagnostic;
|
|
10
|
+
exports.getHubMainDiagnosticsSnapshot = getHubMainDiagnosticsSnapshot;
|
|
11
|
+
exports.installHubMainConsoleTee = installHubMainConsoleTee;
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
14
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
15
|
+
const node_util_1 = __importDefault(require("node:util"));
|
|
16
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
17
|
+
const version_utils_1 = require("../cli/utils/version-utils");
|
|
18
|
+
const DEFAULT_MAX_EVENTS = 100;
|
|
19
|
+
const DEFAULT_MAX_CURRENT_BYTES = 1024 * 1024;
|
|
20
|
+
const DEFAULT_MAX_RETAINED_CHUNKS = 20;
|
|
21
|
+
const DEFAULT_MAX_RETAINED_BYTES = 50 * 1024 * 1024;
|
|
22
|
+
const MAX_STRING_LENGTH = 1000;
|
|
23
|
+
const SENSITIVE_KEY_PATTERN = /token|secret|password|api[-_]?key|credential|authorization/i;
|
|
24
|
+
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
25
|
+
const SENSITIVE_TEXT_PATTERNS = [
|
|
26
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
27
|
+
/\bsk-[A-Za-z0-9]{48,}\b/g,
|
|
28
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/g,
|
|
29
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
30
|
+
/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g,
|
|
31
|
+
/\bsk_live_[A-Za-z0-9]{24,}\b/g,
|
|
32
|
+
/(?:authorization|api[-_]?key|token|secret|password|credential)(["'\s:=]+)([^"',\s]{8,})/gi,
|
|
33
|
+
];
|
|
34
|
+
function resolveDefaultLogDir() {
|
|
35
|
+
try {
|
|
36
|
+
return node_path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'logs');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return node_path_1.default.join(node_os_1.default.homedir(), '.fraim', 'logs');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function redactSensitiveText(value) {
|
|
43
|
+
let redacted = value.replace(EMAIL_PATTERN, '[redacted-email]');
|
|
44
|
+
for (const pattern of SENSITIVE_TEXT_PATTERNS) {
|
|
45
|
+
redacted = redacted.replace(pattern, (match, separator) => {
|
|
46
|
+
if (typeof separator === 'string' && match.includes(separator)) {
|
|
47
|
+
return match.replace(new RegExp(`${separator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`), `${separator}[redacted]`);
|
|
48
|
+
}
|
|
49
|
+
return '[redacted-secret]';
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return redacted;
|
|
53
|
+
}
|
|
54
|
+
function boundedString(value, max = MAX_STRING_LENGTH) {
|
|
55
|
+
const redacted = redactSensitiveText(value);
|
|
56
|
+
return redacted.length > max ? `${redacted.slice(0, max)}...[truncated ${redacted.length - max} chars]` : redacted;
|
|
57
|
+
}
|
|
58
|
+
function safeFileSegment(value) {
|
|
59
|
+
const sanitized = value.replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '');
|
|
60
|
+
return sanitized || 'hub';
|
|
61
|
+
}
|
|
62
|
+
function sanitizeDetail(value, depth = 0) {
|
|
63
|
+
if (value == null || typeof value === 'boolean' || typeof value === 'number')
|
|
64
|
+
return value;
|
|
65
|
+
if (typeof value === 'string')
|
|
66
|
+
return boundedString(value);
|
|
67
|
+
if (value instanceof Error)
|
|
68
|
+
return { name: value.name, message: boundedString(value.message) };
|
|
69
|
+
if (depth >= 4)
|
|
70
|
+
return '[truncated-depth]';
|
|
71
|
+
if (Array.isArray(value))
|
|
72
|
+
return value.slice(0, 20).map((item) => sanitizeDetail(item, depth + 1));
|
|
73
|
+
if (typeof value === 'object') {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [key, item] of Object.entries(value).slice(0, 40)) {
|
|
76
|
+
out[key] = SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : sanitizeDetail(item, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
return boundedString(String(value));
|
|
81
|
+
}
|
|
82
|
+
function toDetails(details) {
|
|
83
|
+
if (!details)
|
|
84
|
+
return undefined;
|
|
85
|
+
const sanitized = sanitizeDetail(details);
|
|
86
|
+
return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)
|
|
87
|
+
? sanitized
|
|
88
|
+
: { value: sanitized };
|
|
89
|
+
}
|
|
90
|
+
function safeStat(filePath) {
|
|
91
|
+
try {
|
|
92
|
+
return node_fs_1.default.statSync(filePath);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function buildVersions() {
|
|
99
|
+
return {
|
|
100
|
+
node: process.versions.node,
|
|
101
|
+
...(process.versions.electron ? { electron: process.versions.electron } : {}),
|
|
102
|
+
...(process.versions.chrome ? { chrome: process.versions.chrome } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
class HubMainDiagnostics {
|
|
106
|
+
constructor(options = {}) {
|
|
107
|
+
this.events = [];
|
|
108
|
+
this.rotationSequence = 0;
|
|
109
|
+
this.lastWriteError = null;
|
|
110
|
+
this.lastRenderProcessGone = null;
|
|
111
|
+
this.lastChildProcessGone = null;
|
|
112
|
+
this.lastUnresponsiveAt = null;
|
|
113
|
+
this.lastResponsiveAt = null;
|
|
114
|
+
this.runtimeId = options.runtimeId || process.env.FRAIM_HUB_RUNTIME_ID || 'hub';
|
|
115
|
+
this.now = options.now || (() => new Date());
|
|
116
|
+
this.maxEvents = options.maxEvents || DEFAULT_MAX_EVENTS;
|
|
117
|
+
this.maxCurrentBytes = options.maxCurrentBytes || DEFAULT_MAX_CURRENT_BYTES;
|
|
118
|
+
this.maxRetainedChunks = options.maxRetainedChunks || DEFAULT_MAX_RETAINED_CHUNKS;
|
|
119
|
+
this.maxRetainedBytes = options.maxRetainedBytes || DEFAULT_MAX_RETAINED_BYTES;
|
|
120
|
+
this.logDir = node_path_1.default.resolve(options.logDir || resolveDefaultLogDir());
|
|
121
|
+
this.logPath = node_path_1.default.join(this.logDir, 'hub-main.log');
|
|
122
|
+
}
|
|
123
|
+
record(type, details) {
|
|
124
|
+
const event = {
|
|
125
|
+
type,
|
|
126
|
+
timestamp: this.now().toISOString(),
|
|
127
|
+
runtimeId: this.runtimeId,
|
|
128
|
+
pid: process.pid,
|
|
129
|
+
appVersion: (0, version_utils_1.getFraimVersion)(),
|
|
130
|
+
...(details ? { details: toDetails(details) } : {}),
|
|
131
|
+
};
|
|
132
|
+
this.events.push(event);
|
|
133
|
+
if (this.events.length > this.maxEvents)
|
|
134
|
+
this.events.splice(0, this.events.length - this.maxEvents);
|
|
135
|
+
if (type === 'webcontents.render_process_gone')
|
|
136
|
+
this.lastRenderProcessGone = event;
|
|
137
|
+
if (type === 'app.child_process_gone')
|
|
138
|
+
this.lastChildProcessGone = event;
|
|
139
|
+
if (type === 'webcontents.unresponsive')
|
|
140
|
+
this.lastUnresponsiveAt = event.timestamp;
|
|
141
|
+
if (type === 'webcontents.responsive')
|
|
142
|
+
this.lastResponsiveAt = event.timestamp;
|
|
143
|
+
this.persist(event);
|
|
144
|
+
return event;
|
|
145
|
+
}
|
|
146
|
+
snapshot(window = null) {
|
|
147
|
+
const current = safeStat(this.logPath);
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
process: {
|
|
151
|
+
pid: process.pid,
|
|
152
|
+
platform: process.platform,
|
|
153
|
+
versions: buildVersions(),
|
|
154
|
+
uptimeSeconds: Math.round(process.uptime()),
|
|
155
|
+
},
|
|
156
|
+
log: {
|
|
157
|
+
path: this.logPath,
|
|
158
|
+
directory: this.logDir,
|
|
159
|
+
exists: Boolean(current),
|
|
160
|
+
bytes: current?.size || 0,
|
|
161
|
+
maxCurrentBytes: this.maxCurrentBytes,
|
|
162
|
+
retainedChunks: this.retainedChunks(),
|
|
163
|
+
lastWriteError: this.lastWriteError,
|
|
164
|
+
},
|
|
165
|
+
window,
|
|
166
|
+
lastEvents: [...this.events],
|
|
167
|
+
lastRenderProcessGone: this.lastRenderProcessGone,
|
|
168
|
+
lastChildProcessGone: this.lastChildProcessGone,
|
|
169
|
+
lastUnresponsiveAt: this.lastUnresponsiveAt,
|
|
170
|
+
lastResponsiveAt: this.lastResponsiveAt,
|
|
171
|
+
capturedAt: this.now().toISOString(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
persist(event) {
|
|
175
|
+
try {
|
|
176
|
+
node_fs_1.default.mkdirSync(this.logDir, { recursive: true });
|
|
177
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
178
|
+
const current = safeStat(this.logPath);
|
|
179
|
+
if (current && current.size > 0 && current.size + Buffer.byteLength(line, 'utf8') > this.maxCurrentBytes) {
|
|
180
|
+
this.rotateCurrentLog();
|
|
181
|
+
}
|
|
182
|
+
node_fs_1.default.appendFileSync(this.logPath, line, 'utf8');
|
|
183
|
+
this.lastWriteError = null;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
this.lastWriteError = error instanceof Error ? boundedString(error.message, 500) : boundedString(String(error), 500);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
rotateCurrentLog() {
|
|
190
|
+
if (!node_fs_1.default.existsSync(this.logPath))
|
|
191
|
+
return;
|
|
192
|
+
const timestamp = this.now().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
193
|
+
const chunkPath = node_path_1.default.join(this.logDir, `hub-main-${timestamp}-${safeFileSegment(this.runtimeId)}-${String(this.rotationSequence).padStart(4, '0')}.log`);
|
|
194
|
+
this.rotationSequence += 1;
|
|
195
|
+
node_fs_1.default.renameSync(this.logPath, chunkPath);
|
|
196
|
+
this.pruneRetainedChunks();
|
|
197
|
+
}
|
|
198
|
+
retainedChunks() {
|
|
199
|
+
try {
|
|
200
|
+
return node_fs_1.default.readdirSync(this.logDir)
|
|
201
|
+
.filter((name) => /^hub-main-.+\.log$/.test(name))
|
|
202
|
+
.map((name) => {
|
|
203
|
+
const filePath = node_path_1.default.join(this.logDir, name);
|
|
204
|
+
const stat = node_fs_1.default.statSync(filePath);
|
|
205
|
+
return { path: filePath, bytes: stat.size, modifiedAt: stat.mtime.toISOString() };
|
|
206
|
+
})
|
|
207
|
+
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
pruneRetainedChunks() {
|
|
214
|
+
const chunks = this.retainedChunks();
|
|
215
|
+
let retainedBytes = 0;
|
|
216
|
+
chunks.forEach((chunk, index) => {
|
|
217
|
+
retainedBytes += chunk.bytes;
|
|
218
|
+
if (index < this.maxRetainedChunks && retainedBytes <= this.maxRetainedBytes)
|
|
219
|
+
return;
|
|
220
|
+
try {
|
|
221
|
+
node_fs_1.default.unlinkSync(chunk.path);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Best effort pruning; endpoint still reports remaining chunks.
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
exports.HubMainDiagnostics = HubMainDiagnostics;
|
|
230
|
+
let singleton = null;
|
|
231
|
+
let consoleTeeInstalled = false;
|
|
232
|
+
let inConsoleTee = false;
|
|
233
|
+
function getHubMainDiagnostics() {
|
|
234
|
+
if (!singleton)
|
|
235
|
+
singleton = new HubMainDiagnostics();
|
|
236
|
+
return singleton;
|
|
237
|
+
}
|
|
238
|
+
function resetHubMainDiagnosticsForTest(options = {}) {
|
|
239
|
+
singleton = new HubMainDiagnostics(options);
|
|
240
|
+
return singleton;
|
|
241
|
+
}
|
|
242
|
+
function recordHubMainDiagnostic(type, details) {
|
|
243
|
+
return getHubMainDiagnostics().record(type, details);
|
|
244
|
+
}
|
|
245
|
+
function getHubMainDiagnosticsSnapshot(window = null) {
|
|
246
|
+
return getHubMainDiagnostics().snapshot(window);
|
|
247
|
+
}
|
|
248
|
+
function installHubMainConsoleTee() {
|
|
249
|
+
if (consoleTeeInstalled)
|
|
250
|
+
return;
|
|
251
|
+
consoleTeeInstalled = true;
|
|
252
|
+
const originals = {
|
|
253
|
+
log: console.log.bind(console),
|
|
254
|
+
warn: console.warn.bind(console),
|
|
255
|
+
error: console.error.bind(console),
|
|
256
|
+
};
|
|
257
|
+
const wrap = (level, type) => {
|
|
258
|
+
return (...args) => {
|
|
259
|
+
originals[level](...args);
|
|
260
|
+
if (inConsoleTee)
|
|
261
|
+
return;
|
|
262
|
+
inConsoleTee = true;
|
|
263
|
+
try {
|
|
264
|
+
getHubMainDiagnostics().record(type, { message: node_util_1.default.format(...args) });
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
originals.warn('[fraim] hub main diagnostic console tee failed:', error);
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
inConsoleTee = false;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
console.log = wrap('log', 'log.info');
|
|
275
|
+
console.warn = wrap('warn', 'log.warn');
|
|
276
|
+
console.error = wrap('error', 'log.error');
|
|
277
|
+
getHubMainDiagnostics().record('hub.log_tee_installed');
|
|
278
|
+
}
|
|
@@ -69,6 +69,7 @@ const manager_turns_1 = require("./manager-turns");
|
|
|
69
69
|
const preferences_1 = require("./preferences");
|
|
70
70
|
const conversation_store_1 = require("./conversation-store");
|
|
71
71
|
const raw_event_log_store_1 = require("./raw-event-log-store");
|
|
72
|
+
const hub_main_diagnostics_1 = require("./hub-main-diagnostics");
|
|
72
73
|
const run_working_directory_1 = require("./run-working-directory");
|
|
73
74
|
const conversation_search_1 = require("./conversation-search");
|
|
74
75
|
const conversation_search_index_1 = require("./conversation-search-index");
|
|
@@ -3005,6 +3006,7 @@ class AiHubServer {
|
|
|
3005
3006
|
userDataDir: process.env.FRAIM_BROWSER_USER_DATA_DIR || undefined,
|
|
3006
3007
|
explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
|
|
3007
3008
|
});
|
|
3009
|
+
this.hubMainDiagnostics = options.hubMainDiagnostics || { snapshot: () => (0, hub_main_diagnostics_1.getHubMainDiagnosticsSnapshot)(null) };
|
|
3008
3010
|
this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
|
|
3009
3011
|
// Issue #701 / #749: the AI Hub is a loopback companion that runs on user machines and
|
|
3010
3012
|
// never touches a database. Persona and manager-team state resolve from the hosted server
|
|
@@ -5814,7 +5816,11 @@ class AiHubServer {
|
|
|
5814
5816
|
(0, hosts_1.createHubEvent)('system', switchEventText),
|
|
5815
5817
|
...(fallbackReason ? [(0, hosts_1.createHubEvent)('system', `Same-host resume fell back to handoff: ${fallbackReason}`)] : []),
|
|
5816
5818
|
],
|
|
5817
|
-
microEvents:
|
|
5819
|
+
microEvents: [
|
|
5820
|
+
...persistedMicroEventsForRun(conversation),
|
|
5821
|
+
createHubMicroEvent('diagnostic', 'system', switchEventText),
|
|
5822
|
+
...(fallbackReason ? [createHubMicroEvent('diagnostic', 'system', `Same-host resume fell back to handoff: ${fallbackReason}`)] : []),
|
|
5823
|
+
],
|
|
5818
5824
|
eventLogRefs: conversation.eventLogRefs || [],
|
|
5819
5825
|
currentPhase: persistedRun?.currentPhase || null,
|
|
5820
5826
|
phaseHistory: persistedRun?.phaseHistory || [],
|
|
@@ -6118,6 +6124,17 @@ class AiHubServer {
|
|
|
6118
6124
|
this.app.get('/api/ai-hub/pid', (_req, res) => {
|
|
6119
6125
|
return res.json({ pid: process.pid });
|
|
6120
6126
|
});
|
|
6127
|
+
this.app.get('/api/ai-hub/debug/window-state', (_req, res) => {
|
|
6128
|
+
try {
|
|
6129
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('debug.window_state_requested');
|
|
6130
|
+
return res.json(this.hubMainDiagnostics.snapshot());
|
|
6131
|
+
}
|
|
6132
|
+
catch (error) {
|
|
6133
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6134
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('debug.window_state_failed', { error: message });
|
|
6135
|
+
return res.status(500).json({ error: message });
|
|
6136
|
+
}
|
|
6137
|
+
});
|
|
6121
6138
|
// Issue #1415/#1379: the update badge's click handler POSTs here instead of just telling the
|
|
6122
6139
|
// user to quit and relaunch by hand. `restartToLatest` (the Electron shell's
|
|
6123
6140
|
// restartToLatestNow) responds before it actually tears the process down, so the client can
|
|
@@ -6481,10 +6498,25 @@ class AiHubServer {
|
|
|
6481
6498
|
return res.status(404).json({ error: 'Configured agent not found or cannot be deleted.' });
|
|
6482
6499
|
return res.json({ ok: true });
|
|
6483
6500
|
});
|
|
6484
|
-
this.app.post('/api/ai-hub/configured-agents/:id/check', (req, res) => {
|
|
6501
|
+
this.app.post('/api/ai-hub/configured-agents/:id/check', async (req, res) => {
|
|
6485
6502
|
if (!this.requireTrustedHubOrigin(req, res))
|
|
6486
6503
|
return;
|
|
6487
|
-
|
|
6504
|
+
// Issue #1618: this is the manager's explicit "check now" action, so it must reflect
|
|
6505
|
+
// reality immediately rather than serving the up-to-5-minute-stale detection cache -
|
|
6506
|
+
// the same reasoning installAgentAndRefreshDetection() already applies after install
|
|
6507
|
+
// (see the comment above that function, server.ts:~2430).
|
|
6508
|
+
(0, hosts_1.invalidateEmployeeDetectionCache)();
|
|
6509
|
+
// Security review (implement-security-review, issue #1618): prefer the non-blocking
|
|
6510
|
+
// parallel probe here for the same reason bootstrapResponse() does (server.ts:~3939) -
|
|
6511
|
+
// invalidating the cache on every click means this route now pays the full re-probe
|
|
6512
|
+
// cost every time instead of at most once per TTL window, so the synchronous form
|
|
6513
|
+
// would let a manager repeatedly clicking Check block the single-threaded Hub event
|
|
6514
|
+
// loop for ~1-3s per click, reintroducing the exact issue-#1010 unresponsiveness this
|
|
6515
|
+
// route previously avoided only by accident (via the cache). Falls back to the sync
|
|
6516
|
+
// form for HostRuntime stubs that do not implement the async variant.
|
|
6517
|
+
const employees = this.hostRuntime.detectEmployeesAsync
|
|
6518
|
+
? await this.hostRuntime.detectEmployeesAsync()
|
|
6519
|
+
: this.hostRuntime.detectEmployees();
|
|
6488
6520
|
const agent = this.configuredAgentsForCurrentMachine(employees).find((entry) => entry.id === req.params.id);
|
|
6489
6521
|
if (!agent)
|
|
6490
6522
|
return res.status(404).json({ error: 'Configured agent not found.' });
|
|
@@ -5,7 +5,94 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.installManagedAgent = installManagedAgent;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
8
9
|
const managed_agent_paths_1 = require("./managed-agent-paths");
|
|
10
|
+
function errorMessage(error) {
|
|
11
|
+
return error instanceof Error ? error.message : 'Unknown error';
|
|
12
|
+
}
|
|
13
|
+
function isStaleNpmInstallError(error) {
|
|
14
|
+
const code = typeof error === 'object' && error !== null && 'code' in error
|
|
15
|
+
? String(error.code || '')
|
|
16
|
+
: '';
|
|
17
|
+
const message = errorMessage(error).toLowerCase();
|
|
18
|
+
return code === 'EEXIST'
|
|
19
|
+
|| message.includes('eexist')
|
|
20
|
+
|| message.includes('already exists')
|
|
21
|
+
|| message.includes('already in use');
|
|
22
|
+
}
|
|
23
|
+
function packagePathSegments(installPackage) {
|
|
24
|
+
let packageName = installPackage.trim();
|
|
25
|
+
if (!packageName)
|
|
26
|
+
return [];
|
|
27
|
+
if (packageName.startsWith('@')) {
|
|
28
|
+
const slashIndex = packageName.indexOf('/');
|
|
29
|
+
if (slashIndex === -1)
|
|
30
|
+
return [];
|
|
31
|
+
const versionAt = packageName.indexOf('@', slashIndex);
|
|
32
|
+
if (versionAt !== -1)
|
|
33
|
+
packageName = packageName.slice(0, versionAt);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const versionAt = packageName.lastIndexOf('@');
|
|
37
|
+
if (versionAt > 0)
|
|
38
|
+
packageName = packageName.slice(0, versionAt);
|
|
39
|
+
}
|
|
40
|
+
const segments = packageName.split('/');
|
|
41
|
+
return segments.every((segment) => segment && segment !== '.' && segment !== '..' && !segment.includes('\\'))
|
|
42
|
+
? segments
|
|
43
|
+
: [];
|
|
44
|
+
}
|
|
45
|
+
function isInsideDirectory(parentDir, candidate) {
|
|
46
|
+
const relative = path_1.default.relative(path_1.default.resolve(parentDir), path_1.default.resolve(candidate));
|
|
47
|
+
return !!relative && !relative.startsWith('..') && !path_1.default.isAbsolute(relative);
|
|
48
|
+
}
|
|
49
|
+
function managedShimNames(command) {
|
|
50
|
+
return process.platform === 'win32'
|
|
51
|
+
? [command, `${command}.cmd`, `${command}.ps1`]
|
|
52
|
+
: [command];
|
|
53
|
+
}
|
|
54
|
+
function cleanupManagedInstallArtifacts(option, prefix) {
|
|
55
|
+
const resolvedPrefix = path_1.default.resolve(prefix);
|
|
56
|
+
const packageSegments = packagePathSegments(option.installPackage);
|
|
57
|
+
const candidates = new Set();
|
|
58
|
+
if (packageSegments.length > 0) {
|
|
59
|
+
candidates.add(path_1.default.join(resolvedPrefix, 'node_modules', ...packageSegments));
|
|
60
|
+
candidates.add(path_1.default.join(resolvedPrefix, 'lib', 'node_modules', ...packageSegments));
|
|
61
|
+
}
|
|
62
|
+
for (const shimName of managedShimNames(option.launchCommand)) {
|
|
63
|
+
candidates.add(path_1.default.join(resolvedPrefix, shimName));
|
|
64
|
+
candidates.add(path_1.default.join(resolvedPrefix, 'bin', shimName));
|
|
65
|
+
}
|
|
66
|
+
const removed = [];
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
if (!isInsideDirectory(resolvedPrefix, candidate) || !fs_1.default.existsSync(candidate))
|
|
69
|
+
continue;
|
|
70
|
+
fs_1.default.rmSync(candidate, { recursive: true, force: true });
|
|
71
|
+
removed.push(candidate);
|
|
72
|
+
}
|
|
73
|
+
return removed;
|
|
74
|
+
}
|
|
75
|
+
async function runInstallWithStaleCleanupRetry(option, prefix, runInstall) {
|
|
76
|
+
try {
|
|
77
|
+
await runInstall();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (!isStaleNpmInstallError(error))
|
|
82
|
+
throw error;
|
|
83
|
+
const removed = cleanupManagedInstallArtifacts(option, prefix);
|
|
84
|
+
try {
|
|
85
|
+
await runInstall();
|
|
86
|
+
}
|
|
87
|
+
catch (retryError) {
|
|
88
|
+
const cleanupSummary = removed.length > 0
|
|
89
|
+
? ` Removed stale managed artifacts: ${removed.join(', ')}.`
|
|
90
|
+
: ' No matching stale managed artifacts were found to remove.';
|
|
91
|
+
throw new Error(`${option.label} install hit a stale npm artifact (${errorMessage(error)}).`
|
|
92
|
+
+ `${cleanupSummary} Retry failed: ${errorMessage(retryError)}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
9
96
|
// Issue #1284/#1285 (Implementation Strategy §2): the exact "standard npm
|
|
10
97
|
// global install, then fall back to FRAIM's managed prefix" sequence used to
|
|
11
98
|
// be duplicated verbatim in `server.ts` (Hub's install-agent route) and
|
|
@@ -17,6 +104,7 @@ const managed_agent_paths_1 = require("./managed-agent-paths");
|
|
|
17
104
|
// check before invoking this — it only covers the install-then-fallback path.
|
|
18
105
|
async function installManagedAgent(option, systemPath, deps) {
|
|
19
106
|
let standardInstallError = null;
|
|
107
|
+
const prefix = (0, managed_agent_paths_1.getPortableNodeBinPath)();
|
|
20
108
|
try {
|
|
21
109
|
await deps.runProcess('npm', ['install', '-g', option.installPackage], {
|
|
22
110
|
PATH: systemPath,
|
|
@@ -40,13 +128,14 @@ async function installManagedAgent(option, systemPath, deps) {
|
|
|
40
128
|
standardInstallError = `${option.label} standard install completed, but the CLI is not runnable from the user PATH.`;
|
|
41
129
|
}
|
|
42
130
|
catch (error) {
|
|
43
|
-
|
|
131
|
+
if (isStaleNpmInstallError(error))
|
|
132
|
+
cleanupManagedInstallArtifacts(option, prefix);
|
|
133
|
+
standardInstallError = errorMessage(error);
|
|
44
134
|
}
|
|
45
135
|
// Defect A fix: co-locate npm-global shims with the node.exe/npm.cmd that
|
|
46
136
|
// actually runs the install, instead of the flat legacy directory.
|
|
47
|
-
const prefix = (0, managed_agent_paths_1.getPortableNodeBinPath)();
|
|
48
137
|
fs_1.default.mkdirSync(prefix, { recursive: true });
|
|
49
|
-
await deps.runProcess('npm', ['install', '-g', option.installPackage], { npm_config_prefix: prefix });
|
|
138
|
+
await runInstallWithStaleCleanupRetry(option, prefix, () => deps.runProcess('npm', ['install', '-g', option.installPackage], { npm_config_prefix: prefix }).then(() => undefined));
|
|
50
139
|
const ver = deps.commandVersion(option.launchCommand, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
|
|
51
140
|
if (!ver) {
|
|
52
141
|
throw new Error(`${option.label} install completed, but the CLI is not runnable from FRAIM's managed PATH. Standard install failure: ${standardInstallError}`);
|
|
@@ -647,6 +647,7 @@ class FirstRunSessionService {
|
|
|
647
647
|
async runNodeRow() {
|
|
648
648
|
const row = this.getRow('node');
|
|
649
649
|
if (this.embeddedDesktop) {
|
|
650
|
+
persistShellPath(this.embeddedDesktop);
|
|
650
651
|
row.status = 'ok';
|
|
651
652
|
row.verb = `${process.version} bundled with FRAIM`;
|
|
652
653
|
this.persist();
|
|
@@ -656,6 +657,7 @@ class FirstRunSessionService {
|
|
|
656
657
|
// If node is missing here something is severely wrong; fall through to error.
|
|
657
658
|
const ver = commandVersion('node');
|
|
658
659
|
if (ver) {
|
|
660
|
+
persistShellPath(this.embeddedDesktop);
|
|
659
661
|
row.status = 'ok';
|
|
660
662
|
row.verb = `${ver} installed`;
|
|
661
663
|
this.persist();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.311",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"author": "Sid Mathur <sid.mathur@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/mathursrus/FRAIM#readme",
|
|
@@ -211,7 +211,7 @@
|
|
|
211
211
|
"electron-updater": "^6.8.9",
|
|
212
212
|
"express": "^5.2.1",
|
|
213
213
|
"extract-zip": "^2.0.1",
|
|
214
|
-
"fraim": "2.0.
|
|
214
|
+
"fraim": "2.0.311",
|
|
215
215
|
"mongodb": "^7.0.0",
|
|
216
216
|
"node-cron": "4.2.1",
|
|
217
217
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -3632,7 +3632,10 @@ function formatMicroAuditKind(event) {
|
|
|
3632
3632
|
if (kind === 'manager_input') return 'manager';
|
|
3633
3633
|
if (kind === 'mentor_call') return 'seekMentoring call';
|
|
3634
3634
|
if (kind === 'mentor_reply') return 'mentor reply';
|
|
3635
|
+
if (kind === 'diagnostic' && event && event.channel === 'system') return 'system';
|
|
3635
3636
|
if (kind === 'diagnostic') return 'diagnostic';
|
|
3637
|
+
if (kind === 'thinking') return 'thinking';
|
|
3638
|
+
if (kind === 'tool_call') return 'tool call';
|
|
3636
3639
|
return event && event.channel ? event.channel : 'system';
|
|
3637
3640
|
}
|
|
3638
3641
|
|
|
@@ -7639,11 +7642,20 @@ function renderCpAgentPicker() {
|
|
|
7639
7642
|
if (!picker) return;
|
|
7640
7643
|
picker.innerHTML = '';
|
|
7641
7644
|
const list = hubConfiguredAgents();
|
|
7645
|
+
const hasAvailable = list.some((e) => e.available !== false && e.enabled !== false);
|
|
7646
|
+
const footer = picker.closest('.cp-employee-footer');
|
|
7647
|
+
if (footer) footer.hidden = !hasAvailable;
|
|
7642
7648
|
const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false && e.enabled !== false);
|
|
7643
7649
|
if (!curOk) {
|
|
7644
7650
|
const firstAvail = list.find((e) => e.available !== false && e.enabled !== false);
|
|
7645
7651
|
state.cpEmployee = firstAvail ? firstAvail.id : null;
|
|
7646
7652
|
}
|
|
7653
|
+
if (!hasAvailable) {
|
|
7654
|
+
renderCpAgentInstallPanel();
|
|
7655
|
+
const startBtn = document.getElementById('cp-start-btn');
|
|
7656
|
+
if (startBtn) startBtn.disabled = true;
|
|
7657
|
+
return;
|
|
7658
|
+
}
|
|
7647
7659
|
for (const e of list) {
|
|
7648
7660
|
const pill = document.createElement('button');
|
|
7649
7661
|
pill.type = 'button';
|
|
@@ -7805,6 +7817,12 @@ function rerunLastJob() {
|
|
|
7805
7817
|
function showRerunToast(msg) {
|
|
7806
7818
|
const t = document.createElement('div');
|
|
7807
7819
|
t.className = 'cp-rerun-toast';
|
|
7820
|
+
// Issue #1627: matches this app's own convention for transient status text (#be-status,
|
|
7821
|
+
// #status-line, etc.) so a screen reader announces the message instead of it being silently
|
|
7822
|
+
// visual-only - relevant beyond the original re-run case now that this toast also carries the
|
|
7823
|
+
// real reason a restart-to-latest attempt failed.
|
|
7824
|
+
t.setAttribute('role', 'status');
|
|
7825
|
+
t.setAttribute('aria-live', 'polite');
|
|
7808
7826
|
t.textContent = msg;
|
|
7809
7827
|
document.body.appendChild(t);
|
|
7810
7828
|
setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 5000);
|
|
@@ -8076,11 +8094,122 @@ function renderCpAgentInstallPanel() {
|
|
|
8076
8094
|
panel.innerHTML = '';
|
|
8077
8095
|
return;
|
|
8078
8096
|
}
|
|
8079
|
-
|
|
8080
|
-
|
|
8081
|
-
|
|
8082
|
-
|
|
8097
|
+
// Keep setup guidance centralized in Manager -> AI Agents.
|
|
8098
|
+
panel.hidden = false;
|
|
8099
|
+
panel.innerHTML = '';
|
|
8100
|
+
|
|
8101
|
+
const heading = document.createElement('div');
|
|
8102
|
+
heading.className = 'install-panel-heading';
|
|
8103
|
+
heading.textContent = 'No AI agent is set up yet';
|
|
8104
|
+
panel.appendChild(heading);
|
|
8105
|
+
|
|
8106
|
+
const intro = document.createElement('p');
|
|
8107
|
+
intro.className = 'install-panel-copy';
|
|
8108
|
+
intro.textContent = 'Install, sign in, and verify a CLI agent in Manager -> AI Agents, then come back here to start.';
|
|
8109
|
+
panel.appendChild(intro);
|
|
8110
|
+
|
|
8111
|
+
const link = document.createElement('button');
|
|
8112
|
+
link.type = 'button';
|
|
8113
|
+
link.className = 'secondary small';
|
|
8114
|
+
link.textContent = 'Go to Manager -> AI Agents';
|
|
8115
|
+
link.setAttribute('data-testid', 'cp-cli-setup-goto-manager');
|
|
8116
|
+
link.addEventListener('click', () => {
|
|
8117
|
+
closePalette();
|
|
8118
|
+
if (typeof tfShowArea === 'function') tfShowArea('manager');
|
|
8119
|
+
const acc = document.getElementById('manager-agents-acc');
|
|
8120
|
+
if (acc) {
|
|
8121
|
+
acc.open = true;
|
|
8122
|
+
acc.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
8123
|
+
}
|
|
8083
8124
|
});
|
|
8125
|
+
panel.appendChild(link);
|
|
8126
|
+
}
|
|
8127
|
+
|
|
8128
|
+
// Issue #1618 (R3): the single shared install/sign-in row builder. Used both by the job
|
|
8129
|
+
// composer's standalone panels (renderAgentInstallPanelInto's loop below) and by the
|
|
8130
|
+
// Manager AI Agents panel's per-card mount (renderConfiguredAgentsPanel), so the two
|
|
8131
|
+
// surfaces share one state machine and cannot drift out of sync. `options.testPrefix`
|
|
8132
|
+
// namespaces testids per mount point (e.g. 'hub-agent' vs 'manager-agent').
|
|
8133
|
+
// PR feedback (#1620): `options.showLabel` (default true) and `options.resetLabel`
|
|
8134
|
+
// (default 'Choose another agent') let a mount point adapt wording/labeling without a
|
|
8135
|
+
// second implementation. `options.buttonClass`/`options.secondaryButtonClass` (default
|
|
8136
|
+
// 'secondary small'/'ghost small', the job composer's own standalone-panel button style)
|
|
8137
|
+
// let a mount point match its own surrounding card/button language instead. The job
|
|
8138
|
+
// composer's 3 mounts pass none of these and are unchanged.
|
|
8139
|
+
function buildAgentInstallRow(emp, options) {
|
|
8140
|
+
const buttonClass = options.buttonClass || 'secondary small';
|
|
8141
|
+
const secondaryButtonClass = options.secondaryButtonClass || 'ghost small';
|
|
8142
|
+
|
|
8143
|
+
const row = document.createElement('div');
|
|
8144
|
+
row.className = 'install-row';
|
|
8145
|
+
row.id = `install-row-${emp.id}`;
|
|
8146
|
+
|
|
8147
|
+
if (options.showLabel !== false) {
|
|
8148
|
+
const label = document.createElement('span');
|
|
8149
|
+
label.className = 'install-label';
|
|
8150
|
+
label.textContent = emp.label;
|
|
8151
|
+
row.appendChild(label);
|
|
8152
|
+
}
|
|
8153
|
+
|
|
8154
|
+
const status = document.createElement('span');
|
|
8155
|
+
status.className = 'install-status';
|
|
8156
|
+
status.id = `install-status-${emp.id}`;
|
|
8157
|
+
status.textContent = agentInstallState[emp.id]?.statusText || '';
|
|
8158
|
+
row.appendChild(status);
|
|
8159
|
+
|
|
8160
|
+
const btn = document.createElement('button');
|
|
8161
|
+
btn.className = buttonClass;
|
|
8162
|
+
btn.id = `install-btn-${emp.id}`;
|
|
8163
|
+
btn.dataset.hubId = emp.id;
|
|
8164
|
+
|
|
8165
|
+
const st = agentInstallState[emp.id] || {};
|
|
8166
|
+
if (!st.phase) {
|
|
8167
|
+
btn.textContent = `Download / Install ${emp.label}`;
|
|
8168
|
+
btn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-install-${emp.id}`);
|
|
8169
|
+
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
8170
|
+
} else if (st.phase === 'installing') {
|
|
8171
|
+
btn.textContent = 'Installing...';
|
|
8172
|
+
btn.disabled = true;
|
|
8173
|
+
} else if (st.phase === 'needs-login') {
|
|
8174
|
+
btn.textContent = 'Sign In';
|
|
8175
|
+
btn.addEventListener('click', () => triggerAgentLogin(emp.id));
|
|
8176
|
+
} else if (st.phase === 'login-triggered') {
|
|
8177
|
+
const checkBtn = document.createElement('button');
|
|
8178
|
+
checkBtn.className = buttonClass;
|
|
8179
|
+
checkBtn.textContent = 'Check if Ready';
|
|
8180
|
+
checkBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-check-${emp.id}`);
|
|
8181
|
+
checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
|
|
8182
|
+
row.appendChild(checkBtn);
|
|
8183
|
+
|
|
8184
|
+
const skipBtn = document.createElement('button');
|
|
8185
|
+
skipBtn.className = secondaryButtonClass;
|
|
8186
|
+
// PR feedback (#1620): "Choose another agent" describes the job composer's picker
|
|
8187
|
+
// context (abandon this one, pick a different employee/tool). The Manager panel has
|
|
8188
|
+
// no picker to return to — clicking this just resets THIS card's install phase — so
|
|
8189
|
+
// that mount passes a clearer label via options.resetLabel.
|
|
8190
|
+
skipBtn.textContent = options.resetLabel || 'Choose another agent';
|
|
8191
|
+
skipBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-reset-${emp.id}`);
|
|
8192
|
+
skipBtn.style.marginLeft = '6px';
|
|
8193
|
+
skipBtn.addEventListener('click', () => {
|
|
8194
|
+
delete agentInstallState[emp.id];
|
|
8195
|
+
renderAgentInstallPanel();
|
|
8196
|
+
renderHubAgentSetupPanel();
|
|
8197
|
+
renderCpAgentInstallPanel();
|
|
8198
|
+
renderConfiguredAgentsPanel();
|
|
8199
|
+
});
|
|
8200
|
+
row.appendChild(skipBtn);
|
|
8201
|
+
return row;
|
|
8202
|
+
} else if (st.phase === 'ready') {
|
|
8203
|
+
btn.textContent = 'Ready';
|
|
8204
|
+
btn.disabled = true;
|
|
8205
|
+
btn.style.color = 'var(--accent)';
|
|
8206
|
+
} else if (st.phase === 'error') {
|
|
8207
|
+
btn.textContent = 'Retry';
|
|
8208
|
+
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
8209
|
+
}
|
|
8210
|
+
|
|
8211
|
+
row.appendChild(btn);
|
|
8212
|
+
return row;
|
|
8084
8213
|
}
|
|
8085
8214
|
|
|
8086
8215
|
function renderAgentInstallPanelInto(panel, options) {
|
|
@@ -8110,70 +8239,7 @@ function renderAgentInstallPanelInto(panel, options) {
|
|
|
8110
8239
|
}
|
|
8111
8240
|
|
|
8112
8241
|
for (const emp of unavailable) {
|
|
8113
|
-
|
|
8114
|
-
row.className = 'install-row';
|
|
8115
|
-
row.id = `install-row-${emp.id}`;
|
|
8116
|
-
|
|
8117
|
-
const label = document.createElement('span');
|
|
8118
|
-
label.className = 'install-label';
|
|
8119
|
-
label.textContent = emp.label;
|
|
8120
|
-
row.appendChild(label);
|
|
8121
|
-
|
|
8122
|
-
const status = document.createElement('span');
|
|
8123
|
-
status.className = 'install-status';
|
|
8124
|
-
status.id = `install-status-${emp.id}`;
|
|
8125
|
-
status.textContent = agentInstallState[emp.id]?.statusText || '';
|
|
8126
|
-
row.appendChild(status);
|
|
8127
|
-
|
|
8128
|
-
const btn = document.createElement('button');
|
|
8129
|
-
btn.className = 'secondary small';
|
|
8130
|
-
btn.id = `install-btn-${emp.id}`;
|
|
8131
|
-
btn.dataset.hubId = emp.id;
|
|
8132
|
-
|
|
8133
|
-
const st = agentInstallState[emp.id] || {};
|
|
8134
|
-
if (!st.phase) {
|
|
8135
|
-
btn.textContent = `Download / Install ${emp.label}`;
|
|
8136
|
-
btn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-install-${emp.id}`);
|
|
8137
|
-
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
8138
|
-
} else if (st.phase === 'installing') {
|
|
8139
|
-
btn.textContent = 'Installing...';
|
|
8140
|
-
btn.disabled = true;
|
|
8141
|
-
} else if (st.phase === 'needs-login') {
|
|
8142
|
-
btn.textContent = 'Sign In';
|
|
8143
|
-
btn.addEventListener('click', () => triggerAgentLogin(emp.id));
|
|
8144
|
-
} else if (st.phase === 'login-triggered') {
|
|
8145
|
-
const checkBtn = document.createElement('button');
|
|
8146
|
-
checkBtn.className = 'secondary small';
|
|
8147
|
-
checkBtn.textContent = 'Check if Ready';
|
|
8148
|
-
checkBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-check-${emp.id}`);
|
|
8149
|
-
checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
|
|
8150
|
-
row.appendChild(checkBtn);
|
|
8151
|
-
|
|
8152
|
-
const skipBtn = document.createElement('button');
|
|
8153
|
-
skipBtn.className = 'ghost small';
|
|
8154
|
-
skipBtn.textContent = 'Choose another agent';
|
|
8155
|
-
skipBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-reset-${emp.id}`);
|
|
8156
|
-
skipBtn.style.marginLeft = '6px';
|
|
8157
|
-
skipBtn.addEventListener('click', () => {
|
|
8158
|
-
delete agentInstallState[emp.id];
|
|
8159
|
-
renderAgentInstallPanel();
|
|
8160
|
-
renderHubAgentSetupPanel();
|
|
8161
|
-
renderCpAgentInstallPanel();
|
|
8162
|
-
});
|
|
8163
|
-
row.appendChild(skipBtn);
|
|
8164
|
-
panel.appendChild(row);
|
|
8165
|
-
continue;
|
|
8166
|
-
} else if (st.phase === 'ready') {
|
|
8167
|
-
btn.textContent = 'Ready';
|
|
8168
|
-
btn.disabled = true;
|
|
8169
|
-
btn.style.color = 'var(--accent)';
|
|
8170
|
-
} else if (st.phase === 'error') {
|
|
8171
|
-
btn.textContent = 'Retry';
|
|
8172
|
-
btn.addEventListener('click', () => startAgentInstall(emp.id));
|
|
8173
|
-
}
|
|
8174
|
-
|
|
8175
|
-
row.appendChild(btn);
|
|
8176
|
-
panel.appendChild(row);
|
|
8242
|
+
panel.appendChild(buildAgentInstallRow(emp, options));
|
|
8177
8243
|
}
|
|
8178
8244
|
}
|
|
8179
8245
|
|
|
@@ -8183,6 +8249,10 @@ function setInstallState(hubId, phase, statusText) {
|
|
|
8183
8249
|
renderHubAgentSetupPanel();
|
|
8184
8250
|
renderCpAgentInstallPanel();
|
|
8185
8251
|
renderCpAgentPicker();
|
|
8252
|
+
// Issue #1618: the Manager AI Agents panel's per-card install row shares this same
|
|
8253
|
+
// agentInstallState, so it must refresh at the same choke point as the job composer's
|
|
8254
|
+
// three panels or it drifts out of sync with them.
|
|
8255
|
+
renderConfiguredAgentsPanel();
|
|
8186
8256
|
}
|
|
8187
8257
|
|
|
8188
8258
|
// Issue #1256 (slice c, AC-C4): backstop above the server's own AGENT_INSTALL_TIMEOUT_MS
|
|
@@ -8254,6 +8324,9 @@ async function refreshEmployees() {
|
|
|
8254
8324
|
renderAgentInstallPanel();
|
|
8255
8325
|
renderHubAgentSetupPanel();
|
|
8256
8326
|
renderCpAgentInstallPanel();
|
|
8327
|
+
// Issue #1618: the Manager AI Agents panel's per-card install row depends on the same
|
|
8328
|
+
// refreshed employee roster (e.g. after "Check if Ready" confirms a host is now available).
|
|
8329
|
+
renderConfiguredAgentsPanel();
|
|
8257
8330
|
} catch { /* best-effort */ }
|
|
8258
8331
|
}
|
|
8259
8332
|
|
|
@@ -9163,6 +9236,26 @@ function renderConfiguredAgentsPanel() {
|
|
|
9163
9236
|
const renderedAvailable = latestCheck ? latestCheck.available !== false : agent.available !== false;
|
|
9164
9237
|
const renderedEnabled = agent.enabled !== false;
|
|
9165
9238
|
const renderedReasons = latestCheck?.reasons || agent.reasons || [];
|
|
9239
|
+
|
|
9240
|
+
// Issue #1618 (R2/R3), moved up (PR feedback #1620): computed before the actions
|
|
9241
|
+
// row so the redundant top-of-card "Check" button can be omitted whenever the
|
|
9242
|
+
// install row itself is about to render — the install row's own state machine
|
|
9243
|
+
// (Download/Install -> Sign In -> Check if Ready) already provides a live-check
|
|
9244
|
+
// path once installed, and "Check" against a not-yet-installed host communicates
|
|
9245
|
+
// nothing the status pill/status pill text does not already say.
|
|
9246
|
+
//
|
|
9247
|
+
// Gated strictly on the underlying host CLI's own readiness (`emp.available ===
|
|
9248
|
+
// false`), never on the configured-agent's `enabled` flag or any other not-ready
|
|
9249
|
+
// reason (PR feedback #1620: an earlier, broader formula also showed the row for a
|
|
9250
|
+
// profile that was merely disabled, or one with a good host but a broken/missing
|
|
9251
|
+
// setup script -- neither of which installing/signing-in the CLI would fix; Edit
|
|
9252
|
+
// is the correct path for both). `renderedAvailable` already prefers a fresh
|
|
9253
|
+
// `latestCheck` result over the possibly-stale roster snapshot, so clicking "Check
|
|
9254
|
+
// if Ready" inside the row hides the row (and restores the top Check button)
|
|
9255
|
+
// immediately once ready.
|
|
9256
|
+
const emp = hubEmployees().find((e) => e.id === agent.baseHostId);
|
|
9257
|
+
const showInstallRow = emp ? emp.available === false : false;
|
|
9258
|
+
|
|
9166
9259
|
const card = document.createElement('div');
|
|
9167
9260
|
card.className = 'configured-agent-card';
|
|
9168
9261
|
card.dataset.testid = 'configured-agent-card';
|
|
@@ -9178,21 +9271,23 @@ function renderConfiguredAgentsPanel() {
|
|
|
9178
9271
|
|
|
9179
9272
|
const actions = document.createElement('div');
|
|
9180
9273
|
actions.className = 'configured-agent-card-actions';
|
|
9181
|
-
|
|
9182
|
-
|
|
9183
|
-
|
|
9184
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9274
|
+
if (!showInstallRow) {
|
|
9275
|
+
const check = document.createElement('button');
|
|
9276
|
+
check.type = 'button';
|
|
9277
|
+
check.className = 'configured-agent-icon-action';
|
|
9278
|
+
check.textContent = 'Check';
|
|
9279
|
+
check.addEventListener('click', async () => {
|
|
9280
|
+
try {
|
|
9281
|
+
const result = await requestJson(`/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}/check`, { method: 'POST' });
|
|
9282
|
+
state.configuredAgentCheckResults[agent.id] = result;
|
|
9283
|
+
showStatus(result.available ? `${agent.label} is ready.` : `${agent.label}: ${(result.reasons || []).join(' ') || 'Check setup.'}`, !result.available);
|
|
9284
|
+
renderConfiguredAgentsPanel();
|
|
9285
|
+
} catch (err) {
|
|
9286
|
+
showStatus(err.message || 'Agent check failed.', true);
|
|
9287
|
+
}
|
|
9288
|
+
});
|
|
9289
|
+
actions.appendChild(check);
|
|
9290
|
+
}
|
|
9196
9291
|
|
|
9197
9292
|
const edit = document.createElement('button');
|
|
9198
9293
|
edit.type = 'button';
|
|
@@ -9240,6 +9335,38 @@ function renderConfiguredAgentsPanel() {
|
|
|
9240
9335
|
card.appendChild(head);
|
|
9241
9336
|
card.appendChild(meta);
|
|
9242
9337
|
card.appendChild(detail);
|
|
9338
|
+
|
|
9339
|
+
// Issue #1618 (R2/R3/R4): a per-card install/sign-in affordance for an agent
|
|
9340
|
+
// that isn't ready, reusing the job composer's shared row builder rather than a
|
|
9341
|
+
// second parallel implementation. `showInstallRow` is computed above (it also
|
|
9342
|
+
// gates the top "Check" button). The buttons are styled with the card's own
|
|
9343
|
+
// `.configured-agent-icon-action` class (PR feedback #1620: the job composer's
|
|
9344
|
+
// `.secondary small`/`.ghost small` classes read as a visually different, duller
|
|
9345
|
+
// button style next to Check/Edit/Delete) instead of the job composer's own
|
|
9346
|
+
// standalone-panel button classes.
|
|
9347
|
+
if (showInstallRow) {
|
|
9348
|
+
const installWrap = document.createElement('div');
|
|
9349
|
+
installWrap.className = 'configured-agent-install-row';
|
|
9350
|
+
installWrap.appendChild(buildAgentInstallRow(
|
|
9351
|
+
emp || { id: agent.baseHostId, label: agent.label, available: false },
|
|
9352
|
+
// PR feedback (#1620): the card's own title already names this configured
|
|
9353
|
+
// agent, so the row's own `emp.label` line (the underlying host's label,
|
|
9354
|
+
// which can legitimately differ from a custom configured-agent label) is
|
|
9355
|
+
// suppressed here to avoid reading as a second, unrelated agent name.
|
|
9356
|
+
// "Choose another agent" is replaced with "Start over" since there is no
|
|
9357
|
+
// agent picker on this panel to return to -- the button only resets this
|
|
9358
|
+
// card's own install phase.
|
|
9359
|
+
{
|
|
9360
|
+
testPrefix: 'manager-agent',
|
|
9361
|
+
showLabel: false,
|
|
9362
|
+
resetLabel: 'Start over',
|
|
9363
|
+
buttonClass: 'configured-agent-icon-action',
|
|
9364
|
+
secondaryButtonClass: 'configured-agent-icon-action',
|
|
9365
|
+
},
|
|
9366
|
+
));
|
|
9367
|
+
card.appendChild(installWrap);
|
|
9368
|
+
}
|
|
9369
|
+
|
|
9243
9370
|
panel.appendChild(card);
|
|
9244
9371
|
}
|
|
9245
9372
|
}
|
|
@@ -16991,23 +17118,42 @@ function tfRestartHubToLatest(event) {
|
|
|
16991
17118
|
badgeEl.textContent = '⬆️ Restarting…';
|
|
16992
17119
|
}
|
|
16993
17120
|
fetch('/api/ai-hub/restart-to-latest', { method: 'POST' })
|
|
16994
|
-
.then((r) =>
|
|
16995
|
-
.then((
|
|
16996
|
-
if (
|
|
16997
|
-
|
|
16998
|
-
|
|
16999
|
-
|
|
17000
|
-
badgeEl.textContent = '⬆️ Updates available';
|
|
17121
|
+
.then((r) => r.json().catch(() => ({})).then((body) => ({ ok: r.ok, body })))
|
|
17122
|
+
.then(({ ok, body }) => {
|
|
17123
|
+
if (!ok) throw new Error((body && body.error) || 'Could not update FRAIM Hub.');
|
|
17124
|
+
if (body && body.restarting) {
|
|
17125
|
+
tfPollForHubRestart(body.latest || null, 0);
|
|
17126
|
+
return;
|
|
17001
17127
|
}
|
|
17128
|
+
// Issue #1627: restarting:false used to be treated as "nothing to do" unconditionally, so a
|
|
17129
|
+
// genuine materialization failure looked identical to "already current" and the badge just
|
|
17130
|
+
// silently reverted with no explanation. An `error` here means the restart was attempted and
|
|
17131
|
+
// failed - show the real reason instead of pretending the click did nothing.
|
|
17132
|
+
tfShowHubRestartFailure(badgeEl, body && body.error);
|
|
17002
17133
|
})
|
|
17003
|
-
.catch(() => {
|
|
17004
|
-
|
|
17005
|
-
badgeEl.disabled = false;
|
|
17006
|
-
badgeEl.textContent = '⬆️ Updates available';
|
|
17007
|
-
}
|
|
17134
|
+
.catch((err) => {
|
|
17135
|
+
tfShowHubRestartFailure(badgeEl, err && err.message);
|
|
17008
17136
|
});
|
|
17009
17137
|
}
|
|
17010
17138
|
|
|
17139
|
+
// Issue #1627: how long the badge shows "Restart failed" before settling back to its steady
|
|
17140
|
+
// "an update is still available, click to try again" state.
|
|
17141
|
+
const HUB_RESTART_FAILURE_DISPLAY_MS = 8000;
|
|
17142
|
+
|
|
17143
|
+
function tfShowHubRestartFailure(badgeEl, reason) {
|
|
17144
|
+
const message = reason
|
|
17145
|
+
? `Could not update FRAIM Hub: ${reason}. Check your network or npm access, then click to try again.`
|
|
17146
|
+
: 'Could not update FRAIM Hub. Check your network or npm access, then click to try again.';
|
|
17147
|
+
showRerunToast(message);
|
|
17148
|
+
if (!badgeEl) return;
|
|
17149
|
+
badgeEl.disabled = false;
|
|
17150
|
+
badgeEl.textContent = '⬆️ Restart failed';
|
|
17151
|
+
badgeEl.title = message;
|
|
17152
|
+
setTimeout(() => {
|
|
17153
|
+
if (badgeEl.textContent === '⬆️ Restart failed') badgeEl.textContent = '⬆️ Updates available';
|
|
17154
|
+
}, HUB_RESTART_FAILURE_DISPLAY_MS);
|
|
17155
|
+
}
|
|
17156
|
+
|
|
17011
17157
|
const HUB_RESTART_POLL_MS = 1000;
|
|
17012
17158
|
const HUB_RESTART_POLL_MAX_ATTEMPTS = 45; // ~45s: generous for a fresh materialize-from-npm relaunch
|
|
17013
17159
|
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -6198,6 +6198,32 @@ img.eh-av { object-fit: cover; background: var(--surface); }
|
|
|
6198
6198
|
overflow-wrap: anywhere;
|
|
6199
6199
|
}
|
|
6200
6200
|
|
|
6201
|
+
/* Issue #1618 (R4): the per-card install/sign-in row is a continuation of the existing
|
|
6202
|
+
card, not a second panel — a dashed top border separates it from Check/Edit/Delete
|
|
6203
|
+
above, matching the mock (docs/feature-specs/mocks/1618-view.html). The shared
|
|
6204
|
+
.install-row/.install-label/.install-status/button.secondary.small/.ghost.small rules
|
|
6205
|
+
above are reused, scoped-overridden only where the card's ~750px width behaves
|
|
6206
|
+
differently than the job composer's ~340px standalone panel (PR #1620 feedback):
|
|
6207
|
+
`.install-status`'s `flex:1` is designed to fill a narrow panel; at card width an
|
|
6208
|
+
empty status span balloons into a large, unstyled-looking blank gap before the
|
|
6209
|
+
action button. Capping it to content width and right-aligning the button instead
|
|
6210
|
+
(matching the Check/Edit/Delete row's own right-aligned actions) keeps the same
|
|
6211
|
+
information, without a wide accidental gap. Only `.configured-agent-install-row`
|
|
6212
|
+
is affected; the 3 job-composer mounts keep their original layout unchanged. */
|
|
6213
|
+
.configured-agent-install-row {
|
|
6214
|
+
margin-top: 8px;
|
|
6215
|
+
padding-top: 8px;
|
|
6216
|
+
border-top: 1px dashed var(--line);
|
|
6217
|
+
}
|
|
6218
|
+
|
|
6219
|
+
.configured-agent-install-row .install-status {
|
|
6220
|
+
flex: 0 1 auto;
|
|
6221
|
+
}
|
|
6222
|
+
|
|
6223
|
+
.configured-agent-install-row .install-row button:first-of-type {
|
|
6224
|
+
margin-left: auto;
|
|
6225
|
+
}
|
|
6226
|
+
|
|
6201
6227
|
.configured-agents-empty {
|
|
6202
6228
|
margin: 0;
|
|
6203
6229
|
color: var(--muted);
|