fraim-hub 2.0.310 → 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.
@@ -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
- return { restarting: false, latest };
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', () => { mainWindow = null; });
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
+ }
@@ -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
- return { entryPath: options.bundledEntryPath, source: 'bundled', version: plan.version };
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
- return { entryPath: options.bundledEntryPath, source: 'bundled', version: options.bundledVersion };
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
@@ -6122,6 +6124,17 @@ class AiHubServer {
6122
6124
  this.app.get('/api/ai-hub/pid', (_req, res) => {
6123
6125
  return res.json({ pid: process.pid });
6124
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
+ });
6125
6138
  // Issue #1415/#1379: the update badge's click handler POSTs here instead of just telling the
6126
6139
  // user to quit and relaunch by hand. `restartToLatest` (the Electron shell's
6127
6140
  // restartToLatestNow) responds before it actually tears the process down, so the client can
@@ -6485,10 +6498,25 @@ class AiHubServer {
6485
6498
  return res.status(404).json({ error: 'Configured agent not found or cannot be deleted.' });
6486
6499
  return res.json({ ok: true });
6487
6500
  });
6488
- 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) => {
6489
6502
  if (!this.requireTrustedHubOrigin(req, res))
6490
6503
  return;
6491
- const employees = this.hostRuntime.detectEmployees();
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();
6492
6520
  const agent = this.configuredAgentsForCurrentMachine(employees).find((entry) => entry.id === req.params.id);
6493
6521
  if (!agent)
6494
6522
  return res.status(404).json({ error: 'Configured agent not found.' });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.310",
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.310",
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",
@@ -7642,11 +7642,20 @@ function renderCpAgentPicker() {
7642
7642
  if (!picker) return;
7643
7643
  picker.innerHTML = '';
7644
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;
7645
7648
  const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false && e.enabled !== false);
7646
7649
  if (!curOk) {
7647
7650
  const firstAvail = list.find((e) => e.available !== false && e.enabled !== false);
7648
7651
  state.cpEmployee = firstAvail ? firstAvail.id : null;
7649
7652
  }
7653
+ if (!hasAvailable) {
7654
+ renderCpAgentInstallPanel();
7655
+ const startBtn = document.getElementById('cp-start-btn');
7656
+ if (startBtn) startBtn.disabled = true;
7657
+ return;
7658
+ }
7650
7659
  for (const e of list) {
7651
7660
  const pill = document.createElement('button');
7652
7661
  pill.type = 'button';
@@ -7808,6 +7817,12 @@ function rerunLastJob() {
7808
7817
  function showRerunToast(msg) {
7809
7818
  const t = document.createElement('div');
7810
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');
7811
7826
  t.textContent = msg;
7812
7827
  document.body.appendChild(t);
7813
7828
  setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 5000);
@@ -8079,11 +8094,122 @@ function renderCpAgentInstallPanel() {
8079
8094
  panel.innerHTML = '';
8080
8095
  return;
8081
8096
  }
8082
- renderAgentInstallPanelInto(panel, {
8083
- heading: 'Set up a CLI agent before starting',
8084
- intro: 'Install, sign in, and verify one Hub agent to enable Start.',
8085
- testPrefix: 'cp-agent',
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
+ }
8086
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;
8087
8213
  }
8088
8214
 
8089
8215
  function renderAgentInstallPanelInto(panel, options) {
@@ -8113,70 +8239,7 @@ function renderAgentInstallPanelInto(panel, options) {
8113
8239
  }
8114
8240
 
8115
8241
  for (const emp of unavailable) {
8116
- const row = document.createElement('div');
8117
- row.className = 'install-row';
8118
- row.id = `install-row-${emp.id}`;
8119
-
8120
- const label = document.createElement('span');
8121
- label.className = 'install-label';
8122
- label.textContent = emp.label;
8123
- row.appendChild(label);
8124
-
8125
- const status = document.createElement('span');
8126
- status.className = 'install-status';
8127
- status.id = `install-status-${emp.id}`;
8128
- status.textContent = agentInstallState[emp.id]?.statusText || '';
8129
- row.appendChild(status);
8130
-
8131
- const btn = document.createElement('button');
8132
- btn.className = 'secondary small';
8133
- btn.id = `install-btn-${emp.id}`;
8134
- btn.dataset.hubId = emp.id;
8135
-
8136
- const st = agentInstallState[emp.id] || {};
8137
- if (!st.phase) {
8138
- btn.textContent = `Download / Install ${emp.label}`;
8139
- btn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-install-${emp.id}`);
8140
- btn.addEventListener('click', () => startAgentInstall(emp.id));
8141
- } else if (st.phase === 'installing') {
8142
- btn.textContent = 'Installing...';
8143
- btn.disabled = true;
8144
- } else if (st.phase === 'needs-login') {
8145
- btn.textContent = 'Sign In';
8146
- btn.addEventListener('click', () => triggerAgentLogin(emp.id));
8147
- } else if (st.phase === 'login-triggered') {
8148
- const checkBtn = document.createElement('button');
8149
- checkBtn.className = 'secondary small';
8150
- checkBtn.textContent = 'Check if Ready';
8151
- checkBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-check-${emp.id}`);
8152
- checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
8153
- row.appendChild(checkBtn);
8154
-
8155
- const skipBtn = document.createElement('button');
8156
- skipBtn.className = 'ghost small';
8157
- skipBtn.textContent = 'Choose another agent';
8158
- skipBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-reset-${emp.id}`);
8159
- skipBtn.style.marginLeft = '6px';
8160
- skipBtn.addEventListener('click', () => {
8161
- delete agentInstallState[emp.id];
8162
- renderAgentInstallPanel();
8163
- renderHubAgentSetupPanel();
8164
- renderCpAgentInstallPanel();
8165
- });
8166
- row.appendChild(skipBtn);
8167
- panel.appendChild(row);
8168
- continue;
8169
- } else if (st.phase === 'ready') {
8170
- btn.textContent = 'Ready';
8171
- btn.disabled = true;
8172
- btn.style.color = 'var(--accent)';
8173
- } else if (st.phase === 'error') {
8174
- btn.textContent = 'Retry';
8175
- btn.addEventListener('click', () => startAgentInstall(emp.id));
8176
- }
8177
-
8178
- row.appendChild(btn);
8179
- panel.appendChild(row);
8242
+ panel.appendChild(buildAgentInstallRow(emp, options));
8180
8243
  }
8181
8244
  }
8182
8245
 
@@ -8186,6 +8249,10 @@ function setInstallState(hubId, phase, statusText) {
8186
8249
  renderHubAgentSetupPanel();
8187
8250
  renderCpAgentInstallPanel();
8188
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();
8189
8256
  }
8190
8257
 
8191
8258
  // Issue #1256 (slice c, AC-C4): backstop above the server's own AGENT_INSTALL_TIMEOUT_MS
@@ -8257,6 +8324,9 @@ async function refreshEmployees() {
8257
8324
  renderAgentInstallPanel();
8258
8325
  renderHubAgentSetupPanel();
8259
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();
8260
8330
  } catch { /* best-effort */ }
8261
8331
  }
8262
8332
 
@@ -9166,6 +9236,26 @@ function renderConfiguredAgentsPanel() {
9166
9236
  const renderedAvailable = latestCheck ? latestCheck.available !== false : agent.available !== false;
9167
9237
  const renderedEnabled = agent.enabled !== false;
9168
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
+
9169
9259
  const card = document.createElement('div');
9170
9260
  card.className = 'configured-agent-card';
9171
9261
  card.dataset.testid = 'configured-agent-card';
@@ -9181,21 +9271,23 @@ function renderConfiguredAgentsPanel() {
9181
9271
 
9182
9272
  const actions = document.createElement('div');
9183
9273
  actions.className = 'configured-agent-card-actions';
9184
- const check = document.createElement('button');
9185
- check.type = 'button';
9186
- check.className = 'configured-agent-icon-action';
9187
- check.textContent = 'Check';
9188
- check.addEventListener('click', async () => {
9189
- try {
9190
- const result = await requestJson(`/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}/check`, { method: 'POST' });
9191
- state.configuredAgentCheckResults[agent.id] = result;
9192
- showStatus(result.available ? `${agent.label} is ready.` : `${agent.label}: ${(result.reasons || []).join(' ') || 'Check setup.'}`, !result.available);
9193
- renderConfiguredAgentsPanel();
9194
- } catch (err) {
9195
- showStatus(err.message || 'Agent check failed.', true);
9196
- }
9197
- });
9198
- actions.appendChild(check);
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
+ }
9199
9291
 
9200
9292
  const edit = document.createElement('button');
9201
9293
  edit.type = 'button';
@@ -9243,6 +9335,38 @@ function renderConfiguredAgentsPanel() {
9243
9335
  card.appendChild(head);
9244
9336
  card.appendChild(meta);
9245
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
+
9246
9370
  panel.appendChild(card);
9247
9371
  }
9248
9372
  }
@@ -16994,23 +17118,42 @@ function tfRestartHubToLatest(event) {
16994
17118
  badgeEl.textContent = '⬆️ Restarting…';
16995
17119
  }
16996
17120
  fetch('/api/ai-hub/restart-to-latest', { method: 'POST' })
16997
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
16998
- .then((info) => {
16999
- if (info && info.restarting) {
17000
- tfPollForHubRestart(info.latest || null, 0);
17001
- } else if (badgeEl) {
17002
- badgeEl.disabled = false;
17003
- 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;
17004
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);
17005
17133
  })
17006
- .catch(() => {
17007
- if (badgeEl) {
17008
- badgeEl.disabled = false;
17009
- badgeEl.textContent = '⬆️ Updates available';
17010
- }
17134
+ .catch((err) => {
17135
+ tfShowHubRestartFailure(badgeEl, err && err.message);
17011
17136
  });
17012
17137
  }
17013
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
+
17014
17157
  const HUB_RESTART_POLL_MS = 1000;
17015
17158
  const HUB_RESTART_POLL_MAX_ATTEMPTS = 45; // ~45s: generous for a fresh materialize-from-npm relaunch
17016
17159
 
@@ -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);