fraim-hub 2.0.283 → 2.0.284

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.
@@ -2156,7 +2156,8 @@ class FakeHostRuntime {
2156
2156
  }
2157
2157
  startRun(hostId, _projectPath, message, handlers, _sessionId) {
2158
2158
  this.lastStartMessage = message;
2159
- return this.fakeProcess(hostId, this.fakeEmployeeReply('start', message), handlers);
2159
+ const reply = this.startReply ?? this.fakeEmployeeReply('start', message);
2160
+ return this.fakeProcess(hostId, reply, handlers);
2160
2161
  }
2161
2162
  continueRun(hostId, _projectPath, sessionId, message, handlers) {
2162
2163
  this.lastContinueMessage = message;
@@ -9,9 +9,16 @@ exports.refreshLatestPublishedVersionInBackground = refreshLatestPublishedVersio
9
9
  exports.__resetLatestVersionCache = __resetLatestVersionCache;
10
10
  const https_1 = __importDefault(require("https"));
11
11
  // Issue #755: best-effort "latest published fraim version" for the Hub's
12
- // update-available nudge. Cached in-process (1h TTL) and never throws — returns
12
+ // update-available nudge. Cached in-process (5m TTL) and never throws — returns
13
13
  // null when offline or the registry is unreachable, so the nudge simply hides.
14
- const TTL_MS = 60 * 60 * 1000;
14
+ // Issue #1379: TTL_MS was 1 hour, so a real npm publish could take up to an hour to
15
+ // ever show up in a running Hub server's own cache, confirmed live (one process still
16
+ // reporting a stale "latest" long after a newer version had been on the registry for
17
+ // several minutes, while a freshly-started process reported the correct one). The
18
+ // client only calls GET /api/ai-hub/version on page load, bootstrap refresh, and
19
+ // persona-identity retry (not a fast poll loop), so 5 minutes cuts worst-case
20
+ // staleness by 12x with no meaningful increase in registry request volume.
21
+ const TTL_MS = 5 * 60 * 1000;
15
22
  const FAILED_RETRY_MS = 5 * 60 * 1000;
16
23
  const REGISTRY_URL = 'https://registry.npmjs.org/fraim/latest';
17
24
  let cache = { value: null, at: 0 };
@@ -18,6 +18,7 @@ exports.writeMacosAppBundle = writeMacosAppBundle;
18
18
  // `fraim-hub install` from the npm package.
19
19
  const fs_1 = __importDefault(require("fs"));
20
20
  const path_1 = __importDefault(require("path"));
21
+ const windows_shim_path_search_1 = require("../core/utils/windows-shim-path-search");
21
22
  /**
22
23
  * What the launcher runs.
23
24
  *
@@ -94,13 +95,16 @@ function renderWindowsLauncher(nodeDir) {
94
95
  'rem re-running `npx fraim-hub@latest install` recreates it.',
95
96
  'setlocal',
96
97
  `set "PATH=%PATH%;${batchEscape(nodeDir)}"`,
97
- 'where npx >nul 2>nul',
98
- 'if errorlevel 1 (',
98
+ // Issue #1375: `where npx` depends on `where.exe` (C:\Windows\System32), which is
99
+ // unresolvable when the launching process's PATH omits System32. The PATH-search below
100
+ // needs no external binary and resolves an absolute path we can invoke directly.
101
+ ...(0, windows_shim_path_search_1.windowsPathSearchLines)('NPX_CMD', 'npx'),
102
+ 'if not defined NPX_CMD (',
99
103
  ` echo ${MISSING_NODE_MESSAGE}`,
100
104
  ' pause',
101
105
  ' exit /b 1',
102
106
  ')',
103
- `call npx ${NPX_ARGS} %*`,
107
+ `call "%NPX_CMD%" ${NPX_ARGS} %*`,
104
108
  '',
105
109
  ].join('\r\n');
106
110
  }
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
39
+ exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HubConnectorStatusStore = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
40
  exports.configureFraimForHubAgent = configureFraimForHubAgent;
41
41
  exports.hubCommandVersion = hubCommandVersion;
42
42
  exports.buildOpenFileInvocation = buildOpenFileInvocation;
@@ -48,6 +48,7 @@ const crypto_1 = require("crypto");
48
48
  const child_process_1 = require("child_process");
49
49
  const https_1 = __importDefault(require("https"));
50
50
  const types_1 = require("../first-run/types");
51
+ const local_provider_registry_1 = require("../cli/providers/local-provider-registry");
51
52
  const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
52
53
  const brand_store_1 = require("../core/brand-store");
53
54
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
@@ -604,6 +605,227 @@ class HostConfigStore {
604
605
  }
605
606
  }
606
607
  exports.HostConfigStore = HostConfigStore;
608
+ class HubConnectorStatusStore {
609
+ constructor(filePath) {
610
+ this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-connectors.json');
611
+ }
612
+ load() {
613
+ try {
614
+ if (!fs_1.default.existsSync(this.filePath))
615
+ return [];
616
+ const parsed = JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
617
+ if (!Array.isArray(parsed))
618
+ return [];
619
+ return parsed.map((entry) => normalizeStoredConnectorStatus(entry)).filter(Boolean);
620
+ }
621
+ catch {
622
+ return [];
623
+ }
624
+ }
625
+ saveStatus(status) {
626
+ const list = this.load();
627
+ const next = list.filter((entry) => entry.id !== status.id);
628
+ next.push(status);
629
+ fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
630
+ fs_1.default.writeFileSync(this.filePath, JSON.stringify(next.sort((a, b) => a.id.localeCompare(b.id)), null, 2));
631
+ return status;
632
+ }
633
+ }
634
+ exports.HubConnectorStatusStore = HubConnectorStatusStore;
635
+ function normalizeConnectorId(raw) {
636
+ if (typeof raw !== 'string')
637
+ return null;
638
+ const id = raw.trim().toLowerCase();
639
+ return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(id) ? id : null;
640
+ }
641
+ function connectorReadiness(configured, authenticated) {
642
+ if (!configured)
643
+ return 'missing_config';
644
+ if (!authenticated)
645
+ return 'needs_auth';
646
+ return 'ready';
647
+ }
648
+ function normalizeStoredConnectorStatus(raw) {
649
+ if (!raw || typeof raw !== 'object')
650
+ return null;
651
+ const value = raw;
652
+ const id = normalizeConnectorId(value.id);
653
+ if (!id)
654
+ return null;
655
+ const configured = Boolean(value.configured);
656
+ const authenticated = Boolean(value.authenticated);
657
+ const label = typeof value.label === 'string' && value.label.trim() ? value.label.trim() : id;
658
+ const transport = value.transport === 'stdio' || value.transport === 'http' ? value.transport : undefined;
659
+ const authMode = value.authMode === 'none' ||
660
+ value.authMode === 'env' ||
661
+ value.authMode === 'oauth' ||
662
+ value.authMode === 'api-token' ||
663
+ value.authMode === 'host-managed' ||
664
+ value.authMode === 'unknown'
665
+ ? value.authMode
666
+ : undefined;
667
+ return {
668
+ id,
669
+ label,
670
+ configured,
671
+ authenticated,
672
+ readiness: connectorReadiness(configured, authenticated),
673
+ source: 'configured',
674
+ ...(transport ? { transport } : {}),
675
+ ...(authMode ? { authMode } : {}),
676
+ ...(typeof value.detail === 'string' && value.detail.trim() ? { detail: value.detail.trim() } : {}),
677
+ ...(typeof value.updatedAt === 'string' && value.updatedAt.trim() ? { updatedAt: value.updatedAt.trim() } : {}),
678
+ };
679
+ }
680
+ function hasSecretLikeField(raw) {
681
+ if (!raw || typeof raw !== 'object')
682
+ return false;
683
+ if (Array.isArray(raw))
684
+ return raw.some((entry) => hasSecretLikeField(entry));
685
+ return Object.entries(raw).some(([key, value]) => /token|secret|password|apikey|api_key|credential/i.test(key) || hasSecretLikeField(value));
686
+ }
687
+ function readFraimApiKeyConfigured() {
688
+ if (process.env.FRAIM_API_KEY && process.env.FRAIM_API_KEY.trim())
689
+ return true;
690
+ try {
691
+ const configPath = path_1.default.join(os_1.default.homedir(), '.fraim', 'config.json');
692
+ if (!fs_1.default.existsSync(configPath))
693
+ return false;
694
+ const parsed = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
695
+ return typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0;
696
+ }
697
+ catch {
698
+ return false;
699
+ }
700
+ }
701
+ function providerConnectorStatus(provider) {
702
+ if (!provider.mcpServer)
703
+ return null;
704
+ const mcpServer = provider.mcpServer;
705
+ const needsAuth = Boolean(mcpServer.authHeaderTemplate || mcpServer.envTemplate);
706
+ return {
707
+ id: provider.id,
708
+ label: provider.displayName,
709
+ configured: false,
710
+ authenticated: false,
711
+ readiness: 'missing_config',
712
+ source: 'derived',
713
+ transport: mcpServer.type,
714
+ authMode: needsAuth ? 'unknown' : 'host-managed',
715
+ detail: 'External provider readiness is supplied by an agent-guided setup on this Hub machine, then recorded with non-secret connector status.',
716
+ };
717
+ }
718
+ function genericConnectorStatus(id) {
719
+ const provider = (0, local_provider_registry_1.getAllLocalProviders)().find((entry) => entry.id === id);
720
+ const providerStatus = provider ? providerConnectorStatus(provider) : null;
721
+ if (providerStatus)
722
+ return providerStatus;
723
+ return {
724
+ id,
725
+ label: id,
726
+ configured: false,
727
+ authenticated: false,
728
+ readiness: 'missing_config',
729
+ source: 'derived',
730
+ authMode: 'unknown',
731
+ detail: 'No connector status has been configured for this machine.',
732
+ };
733
+ }
734
+ function buildStoredConnectorStatusFromDerived(status) {
735
+ return normalizeStoredConnectorStatus({
736
+ ...status,
737
+ source: 'configured',
738
+ updatedAt: new Date().toISOString(),
739
+ });
740
+ }
741
+ function connectorSetupResponseForStatus(status, setupStatus, message, action) {
742
+ return {
743
+ connectorId: status.id,
744
+ status: setupStatus,
745
+ connector: status,
746
+ ...(action ? { action } : {}),
747
+ checkUrl: `/api/ai-hub/connectors/${status.id}/setup/check`,
748
+ message,
749
+ };
750
+ }
751
+ function buildDerivedConnectorStatuses() {
752
+ const fraimReady = readFraimApiKeyConfigured();
753
+ const base = [
754
+ {
755
+ id: 'fraim',
756
+ label: 'FRAIM',
757
+ configured: fraimReady,
758
+ authenticated: fraimReady,
759
+ readiness: connectorReadiness(fraimReady, fraimReady),
760
+ source: 'derived',
761
+ transport: 'stdio',
762
+ authMode: 'api-token',
763
+ detail: fraimReady ? 'FRAIM credential is available on this machine.' : 'Run fraim setup on this machine.',
764
+ },
765
+ {
766
+ id: 'git',
767
+ label: 'Git',
768
+ configured: true,
769
+ authenticated: true,
770
+ readiness: 'ready',
771
+ source: 'derived',
772
+ transport: 'stdio',
773
+ authMode: 'none',
774
+ detail: 'Base local Git MCP server is available in the generated FRAIM MCP set.',
775
+ },
776
+ {
777
+ id: 'playwright',
778
+ label: 'Playwright',
779
+ configured: true,
780
+ authenticated: true,
781
+ readiness: 'ready',
782
+ source: 'derived',
783
+ transport: 'stdio',
784
+ authMode: 'none',
785
+ detail: 'Base local Playwright MCP server is available in the generated FRAIM MCP set.',
786
+ },
787
+ ];
788
+ const providers = (0, local_provider_registry_1.getAllLocalProviders)()
789
+ .map((provider) => providerConnectorStatus(provider))
790
+ .filter(Boolean);
791
+ return [...base, ...providers];
792
+ }
793
+ function mergeConnectorStatuses(stored) {
794
+ const merged = new Map();
795
+ for (const status of buildDerivedConnectorStatuses())
796
+ merged.set(status.id, status);
797
+ for (const status of stored) {
798
+ const derived = merged.get(status.id);
799
+ if (derived?.readiness === 'ready' && status.readiness !== 'ready')
800
+ continue;
801
+ merged.set(status.id, status);
802
+ }
803
+ return [...merged.values()].sort((a, b) => a.id.localeCompare(b.id));
804
+ }
805
+ function missingRequiredConnectors(required, connectors) {
806
+ if (!Array.isArray(required) || required.length === 0)
807
+ return [];
808
+ const byId = new Map(connectors.map((connector) => [connector.id, connector]));
809
+ const missing = [];
810
+ for (const raw of required) {
811
+ const id = normalizeConnectorId(raw);
812
+ if (!id)
813
+ continue;
814
+ const connector = byId.get(id) || {
815
+ id,
816
+ label: id,
817
+ configured: false,
818
+ authenticated: false,
819
+ readiness: 'missing_config',
820
+ source: 'derived',
821
+ authMode: 'unknown',
822
+ detail: 'No connector status has been configured for this machine.',
823
+ };
824
+ if (connector.readiness !== 'ready')
825
+ missing.push(connector);
826
+ }
827
+ return missing;
828
+ }
607
829
  async function pingHost(host) {
608
830
  const start = Date.now();
609
831
  try {
@@ -1530,6 +1752,31 @@ function hubCommandVersion(command, extraBinDirs, basePath) {
1530
1752
  const raw = (result.stdout || result.stderr || '').trim();
1531
1753
  return raw || null;
1532
1754
  }
1755
+ function hubRunSync(command, args, env) {
1756
+ const resolvedCommand = (0, command_resolution_1.getSystemCommandPath)(command, process.env.PATH) || command;
1757
+ const executable = process.platform === 'win32' ? 'cmd.exe' : resolvedCommand;
1758
+ const realArgs = process.platform === 'win32'
1759
+ ? ['/d', '/s', '/c', [resolvedCommand, ...args].map(hosts_1.escapeWindowsArg).join(' ')]
1760
+ : args;
1761
+ const childEnv = { ...process.env };
1762
+ for (const [key, value] of Object.entries(env || {})) {
1763
+ if (value === undefined)
1764
+ delete childEnv[key];
1765
+ else
1766
+ childEnv[key] = value;
1767
+ }
1768
+ const result = (0, child_process_1.spawnSync)(executable, realArgs, {
1769
+ encoding: 'utf8',
1770
+ timeout: 10_000,
1771
+ env: childEnv,
1772
+ });
1773
+ return {
1774
+ status: result.status,
1775
+ stdout: result.stdout || '',
1776
+ stderr: result.stderr || '',
1777
+ ...(result.error ? { error: result.error } : {}),
1778
+ };
1779
+ }
1533
1780
  function hubRunProcess(command, args, env) {
1534
1781
  if (process.env.NODE_ENV === 'test' && command === 'npm' && process.env.FRAIM_TEST_HUB_NPM_ERROR) {
1535
1782
  return Promise.reject(new Error(process.env.FRAIM_TEST_HUB_NPM_ERROR));
@@ -2200,6 +2447,7 @@ class AiHubServer {
2200
2447
  this.deploymentStoreProvided = Boolean(options.deploymentStore);
2201
2448
  this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
2202
2449
  this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
2450
+ this.connectorStatusStore = options.connectorStatusStore ?? new HubConnectorStatusStore();
2203
2451
  this.restartRecoveryPolicy = new restart_recovery_policy_1.RestartRecoveryPolicy({ machineLevelJobIds: MACHINE_LEVEL_JOB_IDS });
2204
2452
  this.app.use(express_1.default.json({ limit: '10mb' }));
2205
2453
  // CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
@@ -5446,6 +5694,16 @@ class AiHubServer {
5446
5694
  if (!instructions && !legacyMessage) {
5447
5695
  throw new Error('Coach your employee before starting the run.');
5448
5696
  }
5697
+ const missingConnectors = missingRequiredConnectors(req.body.requiredConnectors, mergeConnectorStatuses(this.connectorStatusStore.load()));
5698
+ if (missingConnectors.length > 0) {
5699
+ return res.status(409).json({
5700
+ error: 'Remote Hub connector preflight failed.',
5701
+ code: 'missing_remote_connectors',
5702
+ missingConnectors,
5703
+ capabilitiesUrl: '/api/ai-hub/capabilities',
5704
+ setupHint: 'Configure the connector on this Hub machine through the agent-guided setup workflow, then update non-secret connector status and retry.',
5705
+ });
5706
+ }
5449
5707
  const employees = this.hostRuntime.detectEmployees();
5450
5708
  const { hostId, agent: configuredAgent, launchContext } = this.resolveLaunchAgent(req.body.configuredAgentId, requestedHostId, employees);
5451
5709
  const prepared = instructions
@@ -6551,6 +6809,120 @@ class AiHubServer {
6551
6809
  });
6552
6810
  // ─── End Issue #945 ───────────────────────────────────────────────────────
6553
6811
  // GET /api/ai-hub/hosts — list registered remote hosts with health status.
6812
+ this.app.get('/api/ai-hub/capabilities', (_req, res) => {
6813
+ const employees = this.hostRuntime.detectEmployees();
6814
+ const response = {
6815
+ host: {
6816
+ id: 'local',
6817
+ label: os_1.default.hostname(),
6818
+ url: this.hubBase,
6819
+ },
6820
+ generatedAt: new Date().toISOString(),
6821
+ agents: this.configuredAgentsForCurrentMachine(employees).map((agent) => (0, configured_agents_1.projectConfiguredAgent)(agent, employees)),
6822
+ connectors: mergeConnectorStatuses(this.connectorStatusStore.load()),
6823
+ };
6824
+ return res.json(response);
6825
+ });
6826
+ this.app.put('/api/ai-hub/connectors/:id/status', (req, res) => {
6827
+ if (!this.requireTrustedHubOrigin(req, res))
6828
+ return;
6829
+ const id = normalizeConnectorId(req.params.id);
6830
+ if (!id)
6831
+ return res.status(400).json({ error: 'Invalid connector id.' });
6832
+ if (hasSecretLikeField(req.body)) {
6833
+ console.warn('[ai-hub] connector status rejected secret-shaped payload', { connectorId: id });
6834
+ return res.status(400).json({ error: 'Connector status must not include tokens, passwords, API keys, or credentials.' });
6835
+ }
6836
+ const configured = Boolean(req.body?.configured);
6837
+ const authenticated = Boolean(req.body?.authenticated);
6838
+ const candidate = normalizeStoredConnectorStatus({
6839
+ id,
6840
+ label: typeof req.body?.label === 'string' ? req.body.label : id,
6841
+ configured,
6842
+ authenticated,
6843
+ transport: req.body?.transport,
6844
+ authMode: req.body?.authMode,
6845
+ detail: req.body?.detail,
6846
+ updatedAt: new Date().toISOString(),
6847
+ });
6848
+ if (!candidate)
6849
+ return res.status(400).json({ error: 'Invalid connector status.' });
6850
+ const saved = this.connectorStatusStore.saveStatus(candidate);
6851
+ console.log('[ai-hub] connector status updated', {
6852
+ connectorId: saved.id,
6853
+ readiness: saved.readiness,
6854
+ configured: saved.configured,
6855
+ authenticated: saved.authenticated,
6856
+ });
6857
+ return res.json(saved);
6858
+ });
6859
+ this.app.post('/api/ai-hub/connectors/:id/setup/start', (req, res) => {
6860
+ if (!this.requireTrustedHubOrigin(req, res))
6861
+ return;
6862
+ const id = normalizeConnectorId(req.params.id);
6863
+ if (!id)
6864
+ return res.status(400).json({ error: 'Invalid connector id.' });
6865
+ if (hasSecretLikeField(req.body)) {
6866
+ console.warn('[ai-hub] connector setup rejected secret-shaped payload', { connectorId: id, action: 'start' });
6867
+ return res.status(400).json({ error: 'Connector setup must not include tokens, passwords, API keys, or credentials.' });
6868
+ }
6869
+ const current = mergeConnectorStatuses(this.connectorStatusStore.load()).find((status) => status.id === id) || genericConnectorStatus(id);
6870
+ if (current.readiness === 'ready') {
6871
+ const saved = this.connectorStatusStore.saveStatus(buildStoredConnectorStatusFromDerived(current));
6872
+ console.log('[ai-hub] connector setup status updated', {
6873
+ connectorId: saved.id,
6874
+ action: 'start',
6875
+ setupStatus: 'verified',
6876
+ readiness: saved.readiness,
6877
+ });
6878
+ return res.json(connectorSetupResponseForStatus(saved, 'verified', `${saved.label} is verified on this Hub machine.`));
6879
+ }
6880
+ const saved = this.connectorStatusStore.saveStatus(buildStoredConnectorStatusFromDerived(current));
6881
+ console.log('[ai-hub] connector setup status updated', {
6882
+ connectorId: saved.id,
6883
+ action: 'start',
6884
+ setupStatus: 'action_required',
6885
+ readiness: saved.readiness,
6886
+ });
6887
+ return res.json(connectorSetupResponseForStatus(saved, 'action_required', `${saved.label} must be configured on this Hub machine before the remote run can start.`, {
6888
+ type: 'manual',
6889
+ label: `Configure ${saved.label} on the remote Hub machine`,
6890
+ detail: 'Use the appropriate provider or MCP setup flow for the selected remote machine. Do not assume a CLI dependency from the Hub API; after verification, record non-secret readiness with PUT /api/ai-hub/connectors/{id}/status.',
6891
+ }));
6892
+ });
6893
+ this.app.post('/api/ai-hub/connectors/:id/setup/check', (req, res) => {
6894
+ if (!this.requireTrustedHubOrigin(req, res))
6895
+ return;
6896
+ const id = normalizeConnectorId(req.params.id);
6897
+ if (!id)
6898
+ return res.status(400).json({ error: 'Invalid connector id.' });
6899
+ if (hasSecretLikeField(req.body)) {
6900
+ console.warn('[ai-hub] connector setup rejected secret-shaped payload', { connectorId: id, action: 'check' });
6901
+ return res.status(400).json({ error: 'Connector setup check must not include tokens, passwords, API keys, or credentials.' });
6902
+ }
6903
+ const current = mergeConnectorStatuses(this.connectorStatusStore.load()).find((status) => status.id === id) || genericConnectorStatus(id);
6904
+ const saved = this.connectorStatusStore.saveStatus(buildStoredConnectorStatusFromDerived(current));
6905
+ if (saved.readiness === 'ready') {
6906
+ console.log('[ai-hub] connector setup status updated', {
6907
+ connectorId: saved.id,
6908
+ action: 'check',
6909
+ setupStatus: 'verified',
6910
+ readiness: saved.readiness,
6911
+ });
6912
+ return res.json(connectorSetupResponseForStatus(saved, 'verified', `${saved.label} is verified on this Hub machine.`));
6913
+ }
6914
+ console.log('[ai-hub] connector setup status updated', {
6915
+ connectorId: saved.id,
6916
+ action: 'check',
6917
+ setupStatus: 'action_required',
6918
+ readiness: saved.readiness,
6919
+ });
6920
+ return res.json(connectorSetupResponseForStatus(saved, 'action_required', `${saved.label} is not ready on this Hub machine.`, {
6921
+ type: 'manual',
6922
+ label: `Verify ${saved.label} on the remote Hub machine`,
6923
+ detail: 'The agent-guided setup must verify the provider on the remote machine and then PUT a non-secret ready status. The Hub does not infer provider-specific auth state.',
6924
+ }));
6925
+ });
6554
6926
  this.app.get('/api/ai-hub/hosts', async (_req, res) => {
6555
6927
  const hosts = this.hostConfigStore.load();
6556
6928
  const healthResults = await Promise.allSettled(hosts.map((h) => pingHost(h)));
@@ -14,6 +14,7 @@ exports.ensureFraimMcpLatestLauncher = ensureFraimMcpLatestLauncher;
14
14
  const fs_1 = __importDefault(require("fs"));
15
15
  const os_1 = __importDefault(require("os"));
16
16
  const path_1 = __importDefault(require("path"));
17
+ const windows_shim_path_search_1 = require("../../core/utils/windows-shim-path-search");
17
18
  const LAUNCHER_VERSION = 1;
18
19
  const PACKAGED_RUNTIME_VERSION = 1;
19
20
  const launcherSource = `#!/usr/bin/env node
@@ -194,9 +195,9 @@ function packagedFraimShimSource(command, runtime) {
194
195
  const windowsFraimMcpShimSource = `@echo off
195
196
  setlocal
196
197
  for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
197
- where node >nul 2>nul
198
- if not errorlevel 1 (
199
- node "%FRAIM_USER_DIR_VALUE%\\bin\\fraim-mcp-latest.js" %*
198
+ ${(0, windows_shim_path_search_1.windowsPathSearchLines)('NODE_EXE', 'node').join('\n')}
199
+ if defined NODE_EXE (
200
+ "%NODE_EXE%" "%FRAIM_USER_DIR_VALUE%\\bin\\fraim-mcp-latest.js" %*
200
201
  exit /b %ERRORLEVEL%
201
202
  )
202
203
  if exist "%FRAIM_USER_DIR_VALUE%\\node\\node.exe" (
@@ -217,9 +218,9 @@ exit /b 1
217
218
  const windowsFraimNpxShimSource = `@echo off
218
219
  setlocal
219
220
  for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
220
- where npx >nul 2>nul
221
- if not errorlevel 1 (
222
- npx %*
221
+ ${(0, windows_shim_path_search_1.windowsPathSearchLines)('NPX_CMD', 'npx').join('\n')}
222
+ if defined NPX_CMD (
223
+ "%NPX_CMD%" %*
223
224
  exit /b %ERRORLEVEL%
224
225
  )
225
226
  if exist "%FRAIM_USER_DIR_VALUE%\\node\\npx.cmd" (
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ /**
3
+ * Shared Windows batch-script primitive for resolving an executable on `PATH` without
4
+ * depending on `where.exe`.
5
+ *
6
+ * Issue #1375: every generated `.cmd` launcher in this repo used to probe for `node`/`npx`
7
+ * with `where <name> >nul 2>nul`. `where.exe` itself lives in `C:\Windows\System32`, so on a
8
+ * launching process whose `PATH` omits `System32` — the normal case for a shell started from
9
+ * the VS Code integrated Git Bash terminal — the probe is unresolvable and falsely reports the
10
+ * target missing even when it is on `PATH`. cmd.exe's own command dispatch resolves external
11
+ * commands strictly through `PATH` (not the OS's default `CreateProcess` search order, which
12
+ * would fall back to the system directory automatically), so this is a real, reproducible gap,
13
+ * not a theoretical one.
14
+ *
15
+ * `for %%X in (<name>) do set "VAR=%%~$PATH:X"` is cmd.exe's native argument-expansion PATH
16
+ * search. It needs no external binary — the direct analogue of a POSIX shell's `command -v`
17
+ * builtin — and resolves to an absolute path the caller can invoke directly. Unlike `where`, it
18
+ * needs an exact filename (no `%PATHEXT%` extension search of its own), so this module tries
19
+ * `.exe` then `.cmd` in turn to match `where`'s original extension-agnostic behavior.
20
+ *
21
+ * Every `.cmd` generator in this repo that needs to resolve node/npx/git on Windows should call
22
+ * this instead of hand-rolling its own `where` probe: three independent reimplementations of this
23
+ * exact check (`src/cli/mcp/fraim-mcp-latest-launcher.ts`, `src/ai-hub/hub-launcher.ts`, and
24
+ * `scripts/installer/fraim-install-win.template.cmd`) is exactly how the `where`-based bug shipped
25
+ * on Windows only while the POSIX shim's single `command -v` call stayed correct.
26
+ *
27
+ * `scripts/installer/fraim-install-win.template.cmd` is a static text file, not generated from
28
+ * this module (it is read verbatim and two tokens substituted, not built from TypeScript), so it
29
+ * cannot import this function. It re-implements the identical pattern as literal batch text —
30
+ * keep both in sync when this logic changes.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.windowsPathSearchLines = windowsPathSearchLines;
34
+ /**
35
+ * Extensions tried, in order, when resolving a bare command name — mirrors the two extensions
36
+ * every tool this repo probes for actually ships as: `node.exe`/`git.exe`/`winget.exe` are native
37
+ * PE binaries, `npx.cmd` is npm's batch shim. `%%~$PATH:X` needs an exact filename (unlike `where`,
38
+ * it does not consult `%PATHEXT%` itself), so trying each extension in turn is what restores
39
+ * `where`'s original extension-agnostic behavior.
40
+ */
41
+ const WINDOWS_EXECUTABLE_EXTENSIONS = ['.exe', '.cmd'];
42
+ /**
43
+ * Returns the batch-script lines that resolve `targetBaseName` (no extension) on `PATH` into
44
+ * `varName`, trying each of `WINDOWS_EXECUTABLE_EXTENSIONS` in turn, or leave `varName` undefined
45
+ * if none match. Caller decides line joining (`\n` vs `\r\n`) and what to do with the result —
46
+ * typically `if defined <varName> ( "%<varName>%" ... )`.
47
+ *
48
+ * @param varName Batch variable to receive the resolved absolute path, e.g. `NODE_EXE`.
49
+ * @param targetBaseName Command name without extension, e.g. `node`, `npx`, `git`, `winget`.
50
+ */
51
+ function windowsPathSearchLines(varName, targetBaseName) {
52
+ return [
53
+ `set "${varName}="`,
54
+ ...WINDOWS_EXECUTABLE_EXTENSIONS.map((ext) => `if not defined ${varName} for %%X in (${targetBaseName}${ext}) do set "${varName}=%%~$PATH:X"`),
55
+ ];
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.283",
3
+ "version": "2.0.284",
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",
@@ -75,6 +75,7 @@
75
75
  "dist/src/core/utils/ports.js",
76
76
  "dist/src/core/utils/project-fraim-paths.js",
77
77
  "dist/src/core/utils/setup-preferences.js",
78
+ "dist/src/core/utils/windows-shim-path-search.js",
78
79
  "dist/src/db/payment-repository.js",
79
80
  "dist/src/fraim/db-service.js",
80
81
  "dist/src/first-run/",
@@ -209,7 +210,7 @@
209
210
  "electron-updater": "^6.8.9",
210
211
  "express": "^5.2.1",
211
212
  "extract-zip": "^2.0.1",
212
- "fraim": "2.0.283",
213
+ "fraim": "2.0.284",
213
214
  "mongodb": "^7.0.0",
214
215
  "node-cron": "4.2.1",
215
216
  "node-edge-tts": "^1.2.10",
@@ -121,7 +121,7 @@
121
121
 
122
122
  function emptyContext() {
123
123
  var meta = docMeta();
124
- return { docUrl: meta.docUrl, docTitle: meta.docTitle, selection: '', hasSelection: true, bodyPreview: '', wordCount: 0, comments: [] };
124
+ return { docUrl: meta.docUrl, docTitle: meta.docTitle, selection: '', hasSelection: true, bodyPreview: '', wordCount: 0, comments: [], hostApp: 'Excel' };
125
125
  }
126
126
 
127
127
  function readFullContext(cb) {
@@ -150,6 +150,7 @@
150
150
  bodyPreview: body.slice(0, 800),
151
151
  wordCount: body ? body.split(/\s+/).filter(Boolean).length : 0,
152
152
  comments: commentList,
153
+ hostApp: 'Excel',
153
154
  });
154
155
  });
155
156
  }).catch(function() { cb(emptyContext()); });
@@ -198,20 +199,42 @@
198
199
 
199
200
  // ── Write-back (R4) ─────────────────────────────────────────────────────────
200
201
  // Never overwrites the cell that was selected when the job started: if that
201
- // selection was meaningful (anything but the sheet's bare A1 default),
202
- // write into the cell immediately adjacent to it (same row, next column).
203
- // Otherwise append a new row at the end of the used range. Either way, add
204
- // a cell comment noting the write for audit/undo-by-review (R4's own
205
- // recommended default) and rely on Excel's native Ctrl+Z for reject.
202
+ // selection was meaningful (anything but the sheet's bare A1 default) AND the
203
+ // output is plain text (not a table), write into the cell immediately adjacent
204
+ // to it (same row, next column). For markdown-table output, or when only a
205
+ // default A1 was selected, append a new block at the end of the used range.
206
+ // Either way, add a cell comment on the first written cell for audit/undo-by-
207
+ // review (R4) and rely on Excel's native Ctrl+Z for the reject path.
208
+
209
+ // Parse a markdown table into a 2D string array. Returns null when the text
210
+ // contains no recognisable table. Separator rows (| --- |) are skipped.
211
+ function parseMarkdownTable(text) {
212
+ var rows = [];
213
+ var lines = String(text || '').split('\n');
214
+ for (var i = 0; i < lines.length; i++) {
215
+ var line = lines[i].trim();
216
+ if (!line.startsWith('|')) continue;
217
+ var cells = line.replace(/^\||\|$/g, '').split('|');
218
+ var isSep = cells.every(function(c) { return /^\s*:?-+:?\s*$/.test(c); });
219
+ if (isSep) continue;
220
+ rows.push(cells.map(function(c) { return c.trim(); }));
221
+ }
222
+ return rows.length >= 1 ? rows : null;
223
+ }
224
+
206
225
  function writeBackNonDestructive(text, jobLabel, cb) {
207
226
  if (typeof Excel === 'undefined' || !Excel.run) { cb(false, 'Excel API 1.10+ not available'); return; }
208
227
  var info = lastSelectionInfo;
209
228
  var meaningful = !!(info && !isDefaultA1(info));
210
229
  var note = 'Written by FRAIM' + (jobLabel ? ' (' + jobLabel + ')' : '') + ' - ' + new Date().toISOString();
230
+ var tableData = parseMarkdownTable(text);
211
231
  try {
212
232
  Excel.run(function(ctx) {
213
233
  var sheet = ctx.workbook.worksheets.getActiveWorksheet();
214
- if (meaningful) {
234
+ // Plain-text adjacent write only when the selection was meaningful AND
235
+ // the output is not a multi-row table (tables always land at end of range
236
+ // because they span rows and would clobber data if placed mid-sheet).
237
+ if (meaningful && !tableData) {
215
238
  var addr = String(info.address).split('!').pop();
216
239
  var selRange = sheet.getRange(addr);
217
240
  var target = selRange.getOffsetRange(0, 1);
@@ -222,10 +245,23 @@
222
245
  var used = sheet.getUsedRangeOrNullObject();
223
246
  used.load('rowCount,rowIndex,isNullObject');
224
247
  return ctx.sync().then(function() {
225
- var nextRow = used.isNullObject ? 0 : (used.rowIndex + used.rowCount);
226
- var target = sheet.getRangeByIndexes(nextRow, 0, 1, 1);
227
- target.values = [[text]];
228
- sheet.comments.add(target, note);
248
+ var nextRow = used.isNullObject ? 1 : (used.rowIndex + used.rowCount);
249
+ if (tableData) {
250
+ var numRows = tableData.length;
251
+ var numCols = tableData.reduce(function(mx, r) { return Math.max(mx, r.length); }, 0);
252
+ var padded = tableData.map(function(row) {
253
+ var r = row.slice(0, numCols);
254
+ while (r.length < numCols) r.push('');
255
+ return r;
256
+ });
257
+ var tableRange = sheet.getRangeByIndexes(nextRow, 0, numRows, numCols);
258
+ tableRange.values = padded;
259
+ sheet.comments.add(sheet.getRangeByIndexes(nextRow, 0, 1, 1), note);
260
+ } else {
261
+ var target = sheet.getRangeByIndexes(nextRow, 0, 1, 1);
262
+ target.values = [[text]];
263
+ sheet.comments.add(target, note);
264
+ }
229
265
  return ctx.sync();
230
266
  });
231
267
  }).then(function() { cb(true); }).catch(function(e) { cb(false, (e && e.message) || String(e)); });
@@ -50,6 +50,10 @@
50
50
  <div class="nav-right">
51
51
  <!-- #744: FRAIM co-mark (co-branding, not white-label) shown when a brand is set. -->
52
52
  <span class="hub-cobrand" id="hub-cobrand" hidden></span>
53
+ <!-- #1379: persistent top-bar update indicator, driven by tfPopulateVersionInfo() alongside
54
+ the #am-update account-menu nudge below. Hidden until an update is actually available;
55
+ the `title` attribute (set in JS) carries the version + action-to-take hover text. -->
56
+ <button class="hub-update-badge" id="hub-update-badge" type="button" hidden>⬆️ Updates available</button>
53
57
  <button class="avatar-btn" id="avatar-btn" type="button" title="Account &amp; settings">SM</button>
54
58
  <div id="account-menu" class="account-menu">
55
59
  <div class="am-header">
@@ -576,6 +576,13 @@ function buildWordContextBlock(wc) {
576
576
  const lines = wc.comments.slice(0, 5).map(c => `- ${c.author || 'Author'}: ${c.text}`).join('\n');
577
577
  parts.push(`Document comments (${wc.comments.length}):\n${lines}`);
578
578
  }
579
+ if (wc.hostApp === 'Excel') {
580
+ parts.push(
581
+ 'Host: Excel add-in. Your final text response is written automatically into this workbook.\n' +
582
+ 'For structured output (tables, schedules, amortization calculators, grids): format your response as a Markdown table (| Header | Header | / | value | value |) so each cell is written to the correct row and column in the sheet.\n' +
583
+ 'Do NOT use Python, openpyxl, or any file-creation tool to produce a standalone .xlsx file — all output goes directly into the currently open workbook.'
584
+ );
585
+ }
579
586
  if (parts.length === 0) return '';
580
587
  return `[Word Document Context]\n${parts.join('\n\n')}`;
581
588
  }
@@ -15861,6 +15868,11 @@ function tfWireShell() {
15861
15868
  }
15862
15869
  const avatar = document.getElementById('avatar-btn');
15863
15870
  if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
15871
+ // #1379: clicking the top-bar badge opens the same account menu that already carries
15872
+ // the full update-available detail, rather than duplicating that content into a
15873
+ // second popover.
15874
+ const updateBadge = document.getElementById('hub-update-badge');
15875
+ if (updateBadge) updateBadge.addEventListener('click', tfToggleAccountMenu);
15864
15876
  tfWireThemeToggle();
15865
15877
  const accountItem = document.getElementById('am-account');
15866
15878
  if (accountItem) accountItem.addEventListener('click', (e) => {
@@ -16080,24 +16092,36 @@ function tfPopulateAccountMenu() {
16080
16092
  // #755: show the running Hub build version and, when a newer version is published,
16081
16093
  // an update-available nudge. Running version is cheap (from bootstrap); the latest
16082
16094
  // check hits the server's cached /api/ai-hub/version (best-effort, hidden on failure).
16095
+ // #1379: the account-menu nudge above is only visible once the manager opens that menu,
16096
+ // so a persistent top-bar indicator (#hub-update-badge, in .nav-right before #avatar-btn)
16097
+ // surfaces the same info without requiring the click. Both surfaces are driven from the
16098
+ // same response and use the same copy; #am-update is kept as-is (not removed) per #1379's
16099
+ // own "implementer's call, as long as the information isn't lost."
16083
16100
  function tfPopulateVersionInfo() {
16084
16101
  const versionEl = document.getElementById('am-version');
16085
16102
  const running = (state.bootstrap && state.bootstrap.version) || '';
16086
16103
  if (versionEl) versionEl.textContent = running ? `FRAIM Hub v${running}` : '';
16087
16104
  const updateEl = document.getElementById('am-update');
16088
16105
  const updateSub = document.getElementById('am-update-sub');
16089
- if (!updateEl) return;
16106
+ const badgeEl = document.getElementById('hub-update-badge');
16107
+ if (!updateEl && !badgeEl) return;
16090
16108
  fetch('/api/ai-hub/version')
16091
16109
  .then((r) => (r.ok ? r.json() : null))
16092
16110
  .then((info) => {
16093
16111
  if (info && info.updateAvailable && info.latest) {
16094
- if (updateSub) updateSub.textContent = `v${info.latest} is available. Quit the Hub (tray → Quit) and relaunch to update.`;
16095
- updateEl.hidden = false;
16112
+ const actionText = `v${info.latest} is available. Quit the Hub (tray → Quit) and relaunch to update.`;
16113
+ if (updateSub) updateSub.textContent = actionText;
16114
+ if (updateEl) updateEl.hidden = false;
16115
+ if (badgeEl) {
16116
+ badgeEl.title = actionText;
16117
+ badgeEl.hidden = false;
16118
+ }
16096
16119
  } else {
16097
- updateEl.hidden = true;
16120
+ if (updateEl) updateEl.hidden = true;
16121
+ if (badgeEl) badgeEl.hidden = true;
16098
16122
  }
16099
16123
  })
16100
- .catch(() => { /* offline / best-effort: leave the nudge hidden */ });
16124
+ .catch(() => { /* offline / best-effort: leave both surfaces hidden */ });
16101
16125
  }
16102
16126
 
16103
16127
  function tfRequestedConnectedSurface() {
@@ -4419,6 +4419,22 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4419
4419
  }
4420
4420
  .avatar-btn:hover { opacity: .85; box-shadow: 0 2px 8px rgba(0,0,0,.22); }
4421
4421
 
4422
+ /* Issue #1379: persistent top-bar update indicator, sits in .nav-right just before
4423
+ .avatar-btn. Pill sizing/radius matches the established .run-state-pill convention;
4424
+ accent-colored (not warn) since a new version is a nudge, not a problem. */
4425
+ .hub-update-badge {
4426
+ display: inline-flex; align-items: center; gap: 5px;
4427
+ padding: 7px 12px; border-radius: 999px;
4428
+ font-size: 11px; font-weight: 700; letter-spacing: .02em; white-space: nowrap;
4429
+ background: var(--accent); color: #fff; border: none;
4430
+ cursor: pointer; flex-shrink: 0; margin-left: 8px;
4431
+ box-shadow: 0 1px 4px rgba(0,0,0,.18);
4432
+ transition: opacity .12s, box-shadow .12s;
4433
+ }
4434
+ .hub-update-badge:hover { opacity: .85; box-shadow: 0 2px 8px rgba(0,0,0,.22); }
4435
+ .hub-update-badge:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
4436
+ .hub-update-badge[hidden] { display: none; }
4437
+
4422
4438
  /* Suppress the old Hub's header and rail when inside the workspace-conv */
4423
4439
  .workspace-conv .header { display: none !important; }
4424
4440
  .workspace-conv .rail { display: none !important; }
@@ -5400,6 +5416,12 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5400
5416
  .hub-brand { padding: 0 2px 0 10px; }
5401
5417
  .hub-brand-divider { margin: 0 4px 0 8px; }
5402
5418
  .hub-tab { padding: 10px 12px; }
5419
+ /* #1379: the badge's own text ("Updates available") is exactly what forced the same
5420
+ horizontal-scroll problem this block already exists to prevent - measured at 375px:
5421
+ 95px of overflow with the badge shown, none without it. Same treatment as the
5422
+ co-mark above: the update is still reachable via #avatar-btn -> #am-update at this
5423
+ width, just not duplicated in the top bar where there's no room for it. */
5424
+ .hub-update-badge { display: none; }
5403
5425
  }
5404
5426
 
5405
5427
  /* Brand editor (Company tab → Brand accordion). */