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.
@@ -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
+ }
@@ -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
- 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
@@ -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: persistedMicroEventsForRun(conversation),
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
- 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();
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
- standardInstallError = error instanceof Error ? error.message : 'Unknown error';
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.309",
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.309",
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",
@@ -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
- renderAgentInstallPanelInto(panel, {
8080
- heading: 'Set up a CLI agent before starting',
8081
- intro: 'Install, sign in, and verify one Hub agent to enable Start.',
8082
- 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
+ }
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
- const row = document.createElement('div');
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
- const check = document.createElement('button');
9182
- check.type = 'button';
9183
- check.className = 'configured-agent-icon-action';
9184
- check.textContent = 'Check';
9185
- check.addEventListener('click', async () => {
9186
- try {
9187
- const result = await requestJson(`/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}/check`, { method: 'POST' });
9188
- state.configuredAgentCheckResults[agent.id] = result;
9189
- showStatus(result.available ? `${agent.label} is ready.` : `${agent.label}: ${(result.reasons || []).join(' ') || 'Check setup.'}`, !result.available);
9190
- renderConfiguredAgentsPanel();
9191
- } catch (err) {
9192
- showStatus(err.message || 'Agent check failed.', true);
9193
- }
9194
- });
9195
- 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
+ }
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) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
16995
- .then((info) => {
16996
- if (info && info.restarting) {
16997
- tfPollForHubRestart(info.latest || null, 0);
16998
- } else if (badgeEl) {
16999
- badgeEl.disabled = false;
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
- if (badgeEl) {
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
 
@@ -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);