fraim-hub 2.0.271 → 2.0.273
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/fraim-hub-2.js +1 -1
- package/dist/src/ai-hub/conversation-store.js +7 -1
- package/dist/src/ai-hub/hub-install.js +57 -23
- package/dist/src/ai-hub/hub-launcher.js +184 -0
- package/dist/src/ai-hub/preferences.js +4 -1
- package/dist/src/ai-hub/server.js +61 -12
- package/dist/src/cli/commands/add-ide.js +10 -131
- package/dist/src/cli/mcp/ide-formats.js +1 -1
- package/dist/src/cli/mcp/mcp-server-registry.js +3 -3
- package/dist/src/cli/providers/local-provider-registry.js +4 -4
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/learning-usage-store.js +2 -2
- package/package.json +2 -2
- package/public/ai-hub/script.js +113 -23
package/bin/fraim-hub-2.js
CHANGED
|
@@ -43,6 +43,9 @@ function conversationScopeKey(scope, projectPath) {
|
|
|
43
43
|
return exports.MANAGER_SCOPE_KEY;
|
|
44
44
|
if (scope === 'company')
|
|
45
45
|
return exports.COMPANY_SCOPE_KEY;
|
|
46
|
+
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
|
|
47
|
+
throw new Error('conversationScopeKey: empty project path');
|
|
48
|
+
}
|
|
46
49
|
return normalizeConversationKey(projectPath);
|
|
47
50
|
}
|
|
48
51
|
const emptyProjectState = () => ({
|
|
@@ -67,7 +70,10 @@ function normalizeConversationKey(key) {
|
|
|
67
70
|
if (typeof key === 'string' && (key === exports.MANAGER_SCOPE_KEY || key === exports.COMPANY_SCOPE_KEY)) {
|
|
68
71
|
return key;
|
|
69
72
|
}
|
|
70
|
-
|
|
73
|
+
if (typeof key !== 'string' || key.trim().length === 0) {
|
|
74
|
+
throw new Error('normalizeConversationKey: empty project path');
|
|
75
|
+
}
|
|
76
|
+
return path_1.default.resolve(key);
|
|
71
77
|
}
|
|
72
78
|
function normalizeProjectPath(projectPath) {
|
|
73
79
|
return normalizeConversationKey(projectPath);
|
|
@@ -10,33 +10,34 @@ exports.getOsShortcutPaths = getOsShortcutPaths;
|
|
|
10
10
|
exports.writeMacosLaunchAgent = writeMacosLaunchAgent;
|
|
11
11
|
exports.writeLinuxDesktopEntry = writeLinuxDesktopEntry;
|
|
12
12
|
exports.runHubInstall = runHubInstall;
|
|
13
|
-
// #921: `fraim
|
|
13
|
+
// #921/#1179: `fraim-hub install` — register the FRAIM Hub as an OS application so users
|
|
14
14
|
// can launch it without a terminal or npx command.
|
|
15
15
|
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
16
|
+
// #921 shipped this as a downloader: it fetched a packaged Electron app from the repository's
|
|
17
|
+
// latest GitHub Release and pointed the shortcut at it. That never worked. The lookup is
|
|
18
|
+
// unauthenticated and `mathursrus/FRAIM` is private (GitHub answers 404, not 403), and no release
|
|
19
|
+
// has ever been published, so every run ended in `GitHub release lookup failed with HTTP 404`.
|
|
20
|
+
//
|
|
21
|
+
// #1179 replaces the download with a generated launcher that runs the Hub through
|
|
22
|
+
// `npx fraim-hub@latest` (see `./hub-launcher`). That needs no release feed and no signing
|
|
23
|
+
// infrastructure, and it matches the shape of the macOS installer that already ships:
|
|
24
|
+
// `scripts/build-macos-installer.sh` builds a `pkgbuild --nopayload` package whose entire payload
|
|
25
|
+
// is a script that runs `npx -y fraim@latest first-run`.
|
|
18
26
|
const commander_1 = require("commander");
|
|
19
27
|
const fs_1 = __importDefault(require("fs"));
|
|
20
28
|
const path_1 = __importDefault(require("path"));
|
|
21
29
|
const os_1 = __importDefault(require("os"));
|
|
22
30
|
const child_process_1 = require("child_process");
|
|
23
31
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
24
|
-
const
|
|
32
|
+
const hub_launcher_1 = require("./hub-launcher");
|
|
25
33
|
// Stable install directory under ~/.fraim/bin/fraim-hub-electron/
|
|
26
34
|
function resolveHubInstallDir() {
|
|
27
35
|
return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'bin', 'fraim-hub-electron');
|
|
28
36
|
}
|
|
29
|
-
// The
|
|
30
|
-
//
|
|
37
|
+
// The file OS shortcuts point at: the generated launcher, `fraim-hub.cmd` on Windows and
|
|
38
|
+
// `fraim-hub` elsewhere. Before #1179 this named a downloaded Electron binary that never arrived.
|
|
31
39
|
function resolveShortcutTarget() {
|
|
32
|
-
|
|
33
|
-
if (process.platform === 'win32') {
|
|
34
|
-
return path_1.default.join(installDir, 'fraim-hub.exe');
|
|
35
|
-
}
|
|
36
|
-
if (process.platform === 'darwin') {
|
|
37
|
-
return path_1.default.join(installDir, 'FRAIM Hub.app', 'Contents', 'MacOS', 'FRAIM Hub');
|
|
38
|
-
}
|
|
39
|
-
return path_1.default.join(installDir, 'fraim-hub');
|
|
40
|
+
return (0, hub_launcher_1.resolveLauncherPath)(resolveHubInstallDir());
|
|
40
41
|
}
|
|
41
42
|
// Platform-correct locations for OS launcher entries.
|
|
42
43
|
function getOsShortcutPaths(home) {
|
|
@@ -48,7 +49,10 @@ function getOsShortcutPaths(home) {
|
|
|
48
49
|
}
|
|
49
50
|
if (process.platform === 'darwin') {
|
|
50
51
|
return {
|
|
52
|
+
// The LaunchAgent starts the Hub headless at login; the .app is what Launchpad and Finder
|
|
53
|
+
// show. Neither substitutes for the other, so both are installed.
|
|
51
54
|
launchAgent: path_1.default.join(home, 'Library', 'LaunchAgents', 'ai.fraim.hub.plist'),
|
|
55
|
+
appBundle: path_1.default.join(home, 'Applications', 'FRAIM Hub.app'),
|
|
52
56
|
};
|
|
53
57
|
}
|
|
54
58
|
return {
|
|
@@ -81,11 +85,21 @@ function writeMacosLaunchAgent(plistPath, target) {
|
|
|
81
85
|
fs_1.default.mkdirSync(path_1.default.dirname(plistPath), { recursive: true });
|
|
82
86
|
fs_1.default.writeFileSync(plistPath, content, 'utf8');
|
|
83
87
|
}
|
|
84
|
-
function writeLinuxDesktopEntry(desktopPath, target) {
|
|
88
|
+
function writeLinuxDesktopEntry(desktopPath, target, workingDir = os_1.default.homedir()) {
|
|
89
|
+
// No `--no-open` here. A .desktop entry is something the user clicks, and `--no-open` would start
|
|
90
|
+
// the server without ever showing the Hub. Only the macOS LaunchAgent, which runs at login with
|
|
91
|
+
// no click behind it, wants the headless form.
|
|
92
|
+
//
|
|
93
|
+
// `Path=` is not decoration. #646 lost a whole release to the same class of bug on macOS: the
|
|
94
|
+
// installer ran its bootstrap from a temp directory that no longer existed, so `npx`'s
|
|
95
|
+
// `process.cwd()` threw `ENOENT: uv_cwd` and first-run never launched (fixed in d680d2a6 by
|
|
96
|
+
// cd-ing to the user's home). A launcher started by a desktop environment inherits whatever
|
|
97
|
+
// working directory that environment had, so name one that is guaranteed to exist.
|
|
85
98
|
const content = `[Desktop Entry]
|
|
86
99
|
Name=FRAIM Hub
|
|
87
100
|
Comment=FRAIM AI Workforce Hub
|
|
88
|
-
Exec=${target}
|
|
101
|
+
Exec=${target}
|
|
102
|
+
Path=${workingDir}
|
|
89
103
|
Icon=fraim-hub
|
|
90
104
|
Terminal=false
|
|
91
105
|
Type=Application
|
|
@@ -99,13 +113,26 @@ StartupNotify=true
|
|
|
99
113
|
}
|
|
100
114
|
catch { /* non-fatal */ }
|
|
101
115
|
}
|
|
102
|
-
function writeWindowsShortcutScript(lnkPath, target) {
|
|
116
|
+
function writeWindowsShortcutScript(lnkPath, target, workingDir = os_1.default.homedir()) {
|
|
103
117
|
// PowerShell WScript.Shell creates a real .lnk shortcut.
|
|
118
|
+
//
|
|
119
|
+
// WindowStyle 7 (minimized), not 1 (normal): the target is a .cmd, so Windows creates a console
|
|
120
|
+
// host for the length of the launch. `src/ai-hub/cli.ts` detaches and unrefs the Electron child,
|
|
121
|
+
// so npx returns once the Hub is ready and that console closes on its own; minimized keeps it
|
|
122
|
+
// from taking focus in front of the Hub window while it is up. Hiding it outright would need a
|
|
123
|
+
// wscript/VBS shim, which this repo has no precedent for.
|
|
124
|
+
//
|
|
125
|
+
// WorkingDirectory is set rather than left empty. #646 lost a release to the same class of bug:
|
|
126
|
+
// the macOS installer ran its bootstrap from a temp directory that no longer existed, `npx`'s
|
|
127
|
+
// `process.cwd()` threw `ENOENT: uv_cwd`, and first-run never launched (fixed in d680d2a6 by
|
|
128
|
+
// cd-ing to the user's home). A shortcut with no WorkingDirectory hands npx whatever directory
|
|
129
|
+
// the shell that launched it happened to be in.
|
|
104
130
|
const ps = [
|
|
105
131
|
'$ws = New-Object -ComObject WScript.Shell',
|
|
106
132
|
`$sc = $ws.CreateShortcut('${lnkPath.replace(/'/g, "''")}')`,
|
|
107
133
|
`$sc.TargetPath = '${target.replace(/'/g, "''")}'`,
|
|
108
|
-
|
|
134
|
+
`$sc.WorkingDirectory = '${workingDir.replace(/'/g, "''")}'`,
|
|
135
|
+
"$sc.WindowStyle = 7",
|
|
109
136
|
'$sc.Save()',
|
|
110
137
|
].join('; ');
|
|
111
138
|
try {
|
|
@@ -113,8 +140,10 @@ function writeWindowsShortcutScript(lnkPath, target) {
|
|
|
113
140
|
}
|
|
114
141
|
catch {
|
|
115
142
|
// PowerShell unavailable (e.g. minimal CI image) — write a plain .cmd launcher as fallback.
|
|
143
|
+
// Same working-directory guarantee the .lnk gets above, for the same #646 reason.
|
|
116
144
|
const safeTarget = target.replace(/"/g, '""');
|
|
117
|
-
const
|
|
145
|
+
const safeWorkingDir = workingDir.replace(/"/g, '""');
|
|
146
|
+
const cmd = `@echo off\r\ncd /d "${safeWorkingDir}"\r\nstart "" "${safeTarget}"\r\n`;
|
|
118
147
|
const cmdPath = lnkPath.replace(/\.lnk$/i, '.cmd');
|
|
119
148
|
fs_1.default.mkdirSync(path_1.default.dirname(cmdPath), { recursive: true });
|
|
120
149
|
fs_1.default.writeFileSync(cmdPath, cmd, 'utf8');
|
|
@@ -127,14 +156,18 @@ async function runHubInstall() {
|
|
|
127
156
|
const home = os_1.default.homedir();
|
|
128
157
|
const shortcuts = getOsShortcutPaths(home);
|
|
129
158
|
fs_1.default.mkdirSync(installDir, { recursive: true });
|
|
130
|
-
|
|
131
|
-
console.log(`
|
|
159
|
+
(0, hub_launcher_1.writeHubLauncher)({ installDir });
|
|
160
|
+
console.log(` Launcher: ${target}`);
|
|
132
161
|
if (process.platform === 'win32' && shortcuts.startMenu) {
|
|
133
162
|
fs_1.default.mkdirSync(path_1.default.dirname(shortcuts.startMenu), { recursive: true });
|
|
134
|
-
writeWindowsShortcutScript(shortcuts.startMenu, target);
|
|
163
|
+
writeWindowsShortcutScript(shortcuts.startMenu, target, home);
|
|
135
164
|
console.log(` Start Menu shortcut: ${shortcuts.startMenu}`);
|
|
136
165
|
}
|
|
137
166
|
else if (process.platform === 'darwin' && shortcuts.launchAgent) {
|
|
167
|
+
if (shortcuts.appBundle) {
|
|
168
|
+
(0, hub_launcher_1.writeMacosAppBundle)(shortcuts.appBundle, target);
|
|
169
|
+
console.log(` Application: ${shortcuts.appBundle}`);
|
|
170
|
+
}
|
|
138
171
|
writeMacosLaunchAgent(shortcuts.launchAgent, target);
|
|
139
172
|
// Load the agent so it is active immediately without a logout/login.
|
|
140
173
|
try {
|
|
@@ -144,7 +177,7 @@ async function runHubInstall() {
|
|
|
144
177
|
console.log(` LaunchAgent: ${shortcuts.launchAgent}`);
|
|
145
178
|
}
|
|
146
179
|
else if (shortcuts.desktopEntry) {
|
|
147
|
-
writeLinuxDesktopEntry(shortcuts.desktopEntry, target);
|
|
180
|
+
writeLinuxDesktopEntry(shortcuts.desktopEntry, target, home);
|
|
148
181
|
// Notify the desktop environment to refresh its app database.
|
|
149
182
|
try {
|
|
150
183
|
(0, child_process_1.execFileSync)('update-desktop-database', [path_1.default.dirname(shortcuts.desktopEntry)], { stdio: 'ignore' });
|
|
@@ -155,6 +188,7 @@ async function runHubInstall() {
|
|
|
155
188
|
console.log('');
|
|
156
189
|
console.log('FRAIM Hub is registered as an OS application.');
|
|
157
190
|
console.log('Launch it from your Start Menu / Launchpad / application launcher.');
|
|
191
|
+
console.log('Each launch resolves the latest published fraim-hub, so there is nothing to reinstall.');
|
|
158
192
|
}
|
|
159
193
|
exports.hubInstallCommand = new commander_1.Command('install')
|
|
160
194
|
.description('Register FRAIM Hub as an OS application (Start Menu / Launchpad entry)')
|
|
@@ -0,0 +1,184 @@
|
|
|
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.resolveLauncherPath = resolveLauncherPath;
|
|
7
|
+
exports.writeHubLauncher = writeHubLauncher;
|
|
8
|
+
exports.writeMacosAppBundle = writeMacosAppBundle;
|
|
9
|
+
// #1179: the launcher an OS shortcut points at.
|
|
10
|
+
//
|
|
11
|
+
// `fraim-hub install` used to download a packaged Electron app from
|
|
12
|
+
// `https://api.github.com/repos/mathursrus/FRAIM/releases/latest`. That call is unauthenticated and
|
|
13
|
+
// `mathursrus/FRAIM` is private, so GitHub answered 404 rather than 403; and no release has ever
|
|
14
|
+
// been published, because `.github/workflows/fraim-hub-release.yml` fires only on `release:
|
|
15
|
+
// published`. The command therefore failed for every user and never produced anything launchable.
|
|
16
|
+
//
|
|
17
|
+
// The Hub does not need a packaged binary to be double-clickable. `npx fraim-hub@latest` already
|
|
18
|
+
// resolves a shared Electron dist from `~/.fraim/bin/electron/<version>/` (#1112) and detaches the
|
|
19
|
+
// desktop child (`src/ai-hub/cli.ts`), and the shipped macOS installer is the same shape: a
|
|
20
|
+
// `pkgbuild --nopayload` package whose whole job is to run `npx -y fraim@latest first-run`. So the
|
|
21
|
+
// shortcut target is a small generated script that runs the Hub through npx.
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const path_1 = __importDefault(require("path"));
|
|
24
|
+
/**
|
|
25
|
+
* What the launcher runs.
|
|
26
|
+
*
|
|
27
|
+
* `@latest` and not a pinned version, because an npx-launched Hub has no self-update path:
|
|
28
|
+
* `configureAutoUpdater()` in `desktop-main.ts` returns immediately unless `app.isPackaged` (never
|
|
29
|
+
* true under npx), it reads from the same absent release feed, and `/api/ai-hub/version` only
|
|
30
|
+
* reports `updateAvailable` without acting on it. Resolving `@latest` at launch is the update
|
|
31
|
+
* mechanism; pinning here would strand users on the version they installed.
|
|
32
|
+
*/
|
|
33
|
+
const HUB_LAUNCH_SPEC = 'fraim-hub@latest';
|
|
34
|
+
/**
|
|
35
|
+
* No `--restart`: it replaces a running Hub unconditionally, so double-clicking the icon while the
|
|
36
|
+
* Hub is up would kill it and pay a cold relaunch (~20s in #1110). The default already replaces
|
|
37
|
+
* only an older or stale instance and re-focuses a current one (`src/cli/fraim-hub.ts`).
|
|
38
|
+
*
|
|
39
|
+
* No `--no-open` either: that belongs to the background LaunchAgent, not to a clicked app entry.
|
|
40
|
+
*/
|
|
41
|
+
const NPX_ARGS = `-y ${HUB_LAUNCH_SPEC}`;
|
|
42
|
+
/**
|
|
43
|
+
* Shown when no `npx` can be resolved. Worth a real sentence because the failure surface is a
|
|
44
|
+
* double-clicked icon with no terminal behind it, where the alternative is cmd's
|
|
45
|
+
* `'npx' is not recognized` flashing past.
|
|
46
|
+
*
|
|
47
|
+
* Emitted by a batch `echo`, so this string must stay free of `& | < > ^`, which cmd interprets
|
|
48
|
+
* rather than prints.
|
|
49
|
+
*/
|
|
50
|
+
const MISSING_NODE_MESSAGE = 'FRAIM Hub needs Node.js. Install it from https://nodejs.org/ and open FRAIM Hub again.';
|
|
51
|
+
/**
|
|
52
|
+
* Quote a value for a POSIX shell script.
|
|
53
|
+
*
|
|
54
|
+
* The launcher interpolates filesystem paths into a script it then executes. Inside double quotes a
|
|
55
|
+
* shell still expands `$(...)`, backticks, and `$VAR`, so a path carrying any of those would be
|
|
56
|
+
* evaluated rather than used. Single quotes suppress all of it, and `'\''` is the standard way to
|
|
57
|
+
* carry a literal single quote through.
|
|
58
|
+
*
|
|
59
|
+
* These paths derive from `process.execPath` and `getUserFraimDirPath()` (which honours
|
|
60
|
+
* `FRAIM_USER_DIR`), so an attacker who controls them can already run code as this user. This is
|
|
61
|
+
* not a privilege boundary; it is a write-boundary guard kept next to the construction it protects,
|
|
62
|
+
* so a future caller passing a less trustworthy path does not turn this into one.
|
|
63
|
+
*/
|
|
64
|
+
function posixSingleQuote(value) {
|
|
65
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Escape a value for literal use inside a batch file.
|
|
69
|
+
*
|
|
70
|
+
* `%` is the variable sigil, and a Windows path may legally contain it, so `C:\%TEMP%\node` would
|
|
71
|
+
* expand at parse time instead of naming the directory. In a batch file `%%` yields a literal `%`.
|
|
72
|
+
* `"` needs no handling: it is not a legal character in a Windows path.
|
|
73
|
+
*/
|
|
74
|
+
function batchEscape(value) {
|
|
75
|
+
return value.replace(/%/g, '%%');
|
|
76
|
+
}
|
|
77
|
+
/** `fraim-hub.cmd` on Windows, `fraim-hub` elsewhere. */
|
|
78
|
+
function launcherFileName(platform = process.platform) {
|
|
79
|
+
return platform === 'win32' ? 'fraim-hub.cmd' : 'fraim-hub';
|
|
80
|
+
}
|
|
81
|
+
function resolveLauncherPath(installDir, platform = process.platform) {
|
|
82
|
+
return path_1.default.join(installDir, launcherFileName(platform));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The Node directory recorded into the launcher is *appended* to PATH, never prepended.
|
|
86
|
+
*
|
|
87
|
+
* Appended, because prepending would pin every future launch to whichever Node ran the install and
|
|
88
|
+
* shadow a later system upgrade indefinitely. A system `npx` should keep winning; this directory is
|
|
89
|
+
* the fallback for the case that made recording it necessary at all: a GUI shortcut inherits no
|
|
90
|
+
* shell PATH, and the portable-Node bootstrap in `scripts/installer/fraim-install-win.template.cmd`
|
|
91
|
+
* puts Node under `~/.fraim/node/...` where the system PATH never points.
|
|
92
|
+
*/
|
|
93
|
+
function renderWindowsLauncher(nodeDir) {
|
|
94
|
+
return [
|
|
95
|
+
'@echo off',
|
|
96
|
+
'rem FRAIM Hub launcher - generated by `fraim-hub install` (issue #1179). Safe to delete;',
|
|
97
|
+
'rem re-running `npx fraim-hub@latest install` recreates it.',
|
|
98
|
+
'setlocal',
|
|
99
|
+
`set "PATH=%PATH%;${batchEscape(nodeDir)}"`,
|
|
100
|
+
'where npx >nul 2>nul',
|
|
101
|
+
'if errorlevel 1 (',
|
|
102
|
+
` echo ${MISSING_NODE_MESSAGE}`,
|
|
103
|
+
' pause',
|
|
104
|
+
' exit /b 1',
|
|
105
|
+
')',
|
|
106
|
+
`call npx ${NPX_ARGS} %*`,
|
|
107
|
+
'',
|
|
108
|
+
].join('\r\n');
|
|
109
|
+
}
|
|
110
|
+
function renderPosixLauncher(nodeDir) {
|
|
111
|
+
return [
|
|
112
|
+
'#!/bin/sh',
|
|
113
|
+
'# FRAIM Hub launcher - generated by `fraim-hub install` (issue #1179). Safe to delete;',
|
|
114
|
+
'# re-running `npx fraim-hub@latest install` recreates it.',
|
|
115
|
+
`FRAIM_NODE_DIR=${posixSingleQuote(nodeDir)}`,
|
|
116
|
+
'PATH="$PATH:$FRAIM_NODE_DIR"',
|
|
117
|
+
'export PATH',
|
|
118
|
+
'if ! command -v npx >/dev/null 2>&1; then',
|
|
119
|
+
` echo "${MISSING_NODE_MESSAGE}" >&2`,
|
|
120
|
+
' exit 1',
|
|
121
|
+
'fi',
|
|
122
|
+
`exec npx ${NPX_ARGS} "$@"`,
|
|
123
|
+
'',
|
|
124
|
+
].join('\n');
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Write the launcher and return its path. Idempotent: the contents depend only on the platform and
|
|
128
|
+
* the Node directory, so a repeat install rewrites the same bytes.
|
|
129
|
+
*/
|
|
130
|
+
function writeHubLauncher(options) {
|
|
131
|
+
const platform = options.platform ?? process.platform;
|
|
132
|
+
const nodeDir = options.nodeDir ?? path_1.default.dirname(process.execPath);
|
|
133
|
+
const launcherPath = resolveLauncherPath(options.installDir, platform);
|
|
134
|
+
fs_1.default.mkdirSync(options.installDir, { recursive: true });
|
|
135
|
+
fs_1.default.writeFileSync(launcherPath, platform === 'win32' ? renderWindowsLauncher(nodeDir) : renderPosixLauncher(nodeDir), 'utf8');
|
|
136
|
+
if (platform !== 'win32') {
|
|
137
|
+
fs_1.default.chmodSync(launcherPath, 0o755);
|
|
138
|
+
}
|
|
139
|
+
return launcherPath;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* A minimal `.app` around the launcher, so macOS has something to show in Launchpad and Finder.
|
|
143
|
+
*
|
|
144
|
+
* The LaunchAgent `runHubInstall` already writes is a login item, not an application: it starts the
|
|
145
|
+
* Hub headless at login and produces no clickable entry. The two are complementary and both are
|
|
146
|
+
* installed.
|
|
147
|
+
*
|
|
148
|
+
* `CFBundleIdentifier` is deliberately `ai.fraim.hub.launcher`, not the `ai.fraim.hub` that
|
|
149
|
+
* `packages/fraim-hub/package.json` declares as electron-builder's `appId`. If a packaged Hub is
|
|
150
|
+
* ever installed alongside this one, two bundles sharing an identifier would leave LaunchServices
|
|
151
|
+
* to guess which is which.
|
|
152
|
+
*/
|
|
153
|
+
function writeMacosAppBundle(appDir, launcherPath) {
|
|
154
|
+
const executableName = 'FRAIM Hub';
|
|
155
|
+
const macosDir = path_1.default.join(appDir, 'Contents', 'MacOS');
|
|
156
|
+
fs_1.default.mkdirSync(macosDir, { recursive: true });
|
|
157
|
+
const infoPlist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
158
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
159
|
+
<plist version="1.0">
|
|
160
|
+
<dict>
|
|
161
|
+
<key>CFBundleName</key>
|
|
162
|
+
<string>FRAIM Hub</string>
|
|
163
|
+
<key>CFBundleDisplayName</key>
|
|
164
|
+
<string>FRAIM Hub</string>
|
|
165
|
+
<key>CFBundleIdentifier</key>
|
|
166
|
+
<string>ai.fraim.hub.launcher</string>
|
|
167
|
+
<key>CFBundleExecutable</key>
|
|
168
|
+
<string>${executableName}</string>
|
|
169
|
+
<key>CFBundlePackageType</key>
|
|
170
|
+
<string>APPL</string>
|
|
171
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
172
|
+
<string>6.0</string>
|
|
173
|
+
<key>CFBundleVersion</key>
|
|
174
|
+
<string>1</string>
|
|
175
|
+
<key>CFBundleShortVersionString</key>
|
|
176
|
+
<string>1.0</string>
|
|
177
|
+
</dict>
|
|
178
|
+
</plist>
|
|
179
|
+
`;
|
|
180
|
+
fs_1.default.writeFileSync(path_1.default.join(appDir, 'Contents', 'Info.plist'), infoPlist, 'utf8');
|
|
181
|
+
const executablePath = path_1.default.join(macosDir, executableName);
|
|
182
|
+
fs_1.default.writeFileSync(executablePath, `#!/bin/sh\nexec ${posixSingleQuote(launcherPath)} "$@"\n`, 'utf8');
|
|
183
|
+
fs_1.default.chmodSync(executablePath, 0o755);
|
|
184
|
+
}
|
|
@@ -22,7 +22,10 @@ const defaultPreferences = (projectPath) => ({
|
|
|
22
22
|
removedProjectPaths: [],
|
|
23
23
|
});
|
|
24
24
|
function normalizeProjectPath(projectPath) {
|
|
25
|
-
|
|
25
|
+
if (typeof projectPath !== 'string' || projectPath.trim().length === 0) {
|
|
26
|
+
throw new Error('normalizeProjectPath: empty project path');
|
|
27
|
+
}
|
|
28
|
+
return path_1.default.resolve(projectPath);
|
|
26
29
|
}
|
|
27
30
|
function canonicalProjectPath(projectPath) {
|
|
28
31
|
const normalized = normalizeProjectPath(projectPath);
|
|
@@ -1468,6 +1468,8 @@ const HUB_TO_FIRST_RUN_ID = {
|
|
|
1468
1468
|
codex: 'codex',
|
|
1469
1469
|
gemini: 'gemini-cli',
|
|
1470
1470
|
copilot: 'copilot-cli',
|
|
1471
|
+
// Issue #1205: antigravity (agy) has no npm package; wire it so the install route can handle it
|
|
1472
|
+
antigravity: 'antigravity-cli',
|
|
1471
1473
|
};
|
|
1472
1474
|
function hubAgentOption(hubId) {
|
|
1473
1475
|
const frId = HUB_TO_FIRST_RUN_ID[hubId];
|
|
@@ -1483,7 +1485,7 @@ function hubAgentOption(hubId) {
|
|
|
1483
1485
|
* `runAddIDE` reads the FRAIM key from ~/.fraim/config.json (written by setup/first-run) and
|
|
1484
1486
|
* `process.exit(1)`s when it is missing. We guard on that here via the exported `loadGlobalConfig`
|
|
1485
1487
|
* so a missing key degrades to a logged no-op instead of terminating the long-running Hub server.
|
|
1486
|
-
*
|
|
1488
|
+
* Base IDE setup does not collect third-party provider credentials.
|
|
1487
1489
|
*/
|
|
1488
1490
|
async function configureFraimForHubAgent(hubId) {
|
|
1489
1491
|
const frId = HUB_TO_FIRST_RUN_ID[hubId];
|
|
@@ -1497,7 +1499,7 @@ async function configureFraimForHubAgent(hubId) {
|
|
|
1497
1499
|
}
|
|
1498
1500
|
// `frId` (e.g. 'claude-code', 'codex', 'gemini-cli', 'copilot-cli') is a valid add-ide
|
|
1499
1501
|
// `--ide` name/alias; runAddIDE resolves and configures it even when not yet detected.
|
|
1500
|
-
await runAddIDE({ ide: frId
|
|
1502
|
+
await runAddIDE({ ide: frId });
|
|
1501
1503
|
return { configured: true, ideName: frId };
|
|
1502
1504
|
}
|
|
1503
1505
|
catch (e) {
|
|
@@ -1698,7 +1700,13 @@ function isPathInsideDirectory(candidatePath, parentPath) {
|
|
|
1698
1700
|
function isLikelyTemporaryProjectPath(projectPath) {
|
|
1699
1701
|
if (!projectPath)
|
|
1700
1702
|
return false;
|
|
1701
|
-
|
|
1703
|
+
if (isPathInsideDirectory(projectPath, os_1.default.tmpdir()))
|
|
1704
|
+
return true;
|
|
1705
|
+
const resolved = path_1.default.resolve(projectPath);
|
|
1706
|
+
const home = os_1.default.homedir();
|
|
1707
|
+
const commonLaunchDirectories = [home, path_1.default.join(home, 'Desktop'), path_1.default.join(home, 'Downloads')];
|
|
1708
|
+
return commonLaunchDirectories.some((candidate) => sameDirectoryPath(resolved, candidate))
|
|
1709
|
+
&& !directoryExists(path_1.default.join(resolved, 'fraim'));
|
|
1702
1710
|
}
|
|
1703
1711
|
function isRelatedTemporaryProjectPath(projectPath, activeProjectPath) {
|
|
1704
1712
|
if (!isLikelyTemporaryProjectPath(projectPath))
|
|
@@ -2430,7 +2438,7 @@ class AiHubServer {
|
|
|
2430
2438
|
const managerTeam = personasResolved
|
|
2431
2439
|
? await this.withFirstPaintBudget(managerTeamPromise, [], 'manager team')
|
|
2432
2440
|
: [];
|
|
2433
|
-
const { personas, subscriptionActive, workspaceId, userKey } = personaProjection;
|
|
2441
|
+
const { personas, subscriptionActive, workspaceId, userKey, identityAuthoritative } = personaProjection;
|
|
2434
2442
|
void personaProjectionPromise.catch(() => undefined);
|
|
2435
2443
|
void managerTeamPromise.catch(() => undefined);
|
|
2436
2444
|
const resolvedUserEmail = userKey ?? null;
|
|
@@ -2478,6 +2486,10 @@ class AiHubServer {
|
|
|
2478
2486
|
// apiKey used for personas above), or null when not connected — so the
|
|
2479
2487
|
// profile card and personas can never disagree about who's signed in.
|
|
2480
2488
|
userEmail: resolvedUserEmail,
|
|
2489
|
+
// Issue #1185: lets the client tell a definitive "signed out" from "we could not
|
|
2490
|
+
// reach the authority yet", so it neither latches the placeholder nor keeps showing
|
|
2491
|
+
// a signed-out user's stale identity.
|
|
2492
|
+
identityAuthoritative,
|
|
2481
2493
|
// #744: the org cobrand identity (name/color/logo) from the org context
|
|
2482
2494
|
// storage, or null when unset so the Hub falls back to FRAIM identity.
|
|
2483
2495
|
orgBrand: (0, learning_context_builder_1.readOrgBrand)(normalizedProjectPath),
|
|
@@ -2759,6 +2771,13 @@ class AiHubServer {
|
|
|
2759
2771
|
return record;
|
|
2760
2772
|
}
|
|
2761
2773
|
persistRunConversationNow(run, activeId) {
|
|
2774
|
+
if ((run.scope ?? 'project') === 'project' && !run.projectPath.trim()) {
|
|
2775
|
+
console.warn('[ai-hub] skipped project-scoped conversation write with no project path', {
|
|
2776
|
+
runId: run.id,
|
|
2777
|
+
conversationId: run.conversationId || run.id,
|
|
2778
|
+
});
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2762
2781
|
try {
|
|
2763
2782
|
// Issue #708: route the record to its scope bucket (manager/company runs get a
|
|
2764
2783
|
// project-independent home); project runs continue to key by project path.
|
|
@@ -3839,6 +3858,12 @@ class AiHubServer {
|
|
|
3839
3858
|
// blip does not sign the user out; a definitive 401/no-key (reachable, null state)
|
|
3840
3859
|
// yields null with no local-guess fallback (#750 contract).
|
|
3841
3860
|
const userKey = this.resolveResilientUserKey(apiKey, state, reachable);
|
|
3861
|
+
// Issue #1185: tell the client WHY the identity is what it is. A resolved identity is
|
|
3862
|
+
// always authoritative. A null one is authoritative only when the authority actually
|
|
3863
|
+
// answered — no key at all, or a reachable authority returning no state (401 /
|
|
3864
|
+
// feature-off). When the authority could not be reached and nothing was cached, "not
|
|
3865
|
+
// connected" is a guess, and the client must neither latch it nor sign the user out.
|
|
3866
|
+
const identityAuthoritative = userKey !== null || !apiKey || reachable;
|
|
3842
3867
|
// A null state means the Hub makes its ONLY self-owned access decision — render the
|
|
3843
3868
|
// locked "not-signed-in" fallback. When a state IS returned, its per-persona
|
|
3844
3869
|
// `status` is authoritative and rendered verbatim: the hired/locked decision
|
|
@@ -3847,7 +3872,7 @@ class AiHubServer {
|
|
|
3847
3872
|
// re-derive it.
|
|
3848
3873
|
const customPersonas = (0, custom_employees_1.readCustomEmployees)(projectPath).map(custom_employees_1.buildCustomEmployeePersona);
|
|
3849
3874
|
if (!state) {
|
|
3850
|
-
return { ...fallbackProjection, userKey };
|
|
3875
|
+
return { ...fallbackProjection, userKey, identityAuthoritative };
|
|
3851
3876
|
}
|
|
3852
3877
|
try {
|
|
3853
3878
|
const verdictByKey = new Map((state.personas || []).map((p) => [p.personaKey, p]));
|
|
@@ -3876,12 +3901,12 @@ class AiHubServer {
|
|
|
3876
3901
|
});
|
|
3877
3902
|
// Issue #945: union custom employees — they are always 'hired' and carry no
|
|
3878
3903
|
// entitlement. Custom personas are appended so catalog order is unchanged.
|
|
3879
|
-
return { personas: [...personas, ...customPersonas], subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey };
|
|
3904
|
+
return { personas: [...personas, ...customPersonas], subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey, identityAuthoritative };
|
|
3880
3905
|
}
|
|
3881
3906
|
catch (err) {
|
|
3882
3907
|
console.error('[ai-hub] persona lookup failed:', err);
|
|
3883
3908
|
// Keep the (already-resolved) identity even if per-persona accounting fails.
|
|
3884
|
-
return { ...fallbackProjection, userKey };
|
|
3909
|
+
return { ...fallbackProjection, userKey, identityAuthoritative };
|
|
3885
3910
|
}
|
|
3886
3911
|
}
|
|
3887
3912
|
fallbackPersonaProjection(apiKey,
|
|
@@ -3900,11 +3925,16 @@ class AiHubServer {
|
|
|
3900
3925
|
origin: 'catalog',
|
|
3901
3926
|
}));
|
|
3902
3927
|
const customPersonas = (0, custom_employees_1.readCustomEmployees)(projectPath).map(custom_employees_1.buildCustomEmployeePersona);
|
|
3928
|
+
const userKey = apiKey ? this.lastResolvedIdentityByApiKey.get(apiKey) ?? null : null;
|
|
3903
3929
|
return {
|
|
3904
3930
|
personas: [...fallbackPersonas, ...customPersonas],
|
|
3905
3931
|
subscriptionActive: false,
|
|
3906
3932
|
workspaceId: null,
|
|
3907
|
-
userKey
|
|
3933
|
+
userKey,
|
|
3934
|
+
// Issue #1185: this projection is the first-paint placeholder. A null identity here
|
|
3935
|
+
// means "the budget ran out before we knew", never "signed out". A cached identity
|
|
3936
|
+
// IS authoritative — it came from a real prior resolve for this exact key.
|
|
3937
|
+
identityAuthoritative: userKey !== null,
|
|
3908
3938
|
};
|
|
3909
3939
|
}
|
|
3910
3940
|
async withFirstPaintBudget(promise, fallback, label) {
|
|
@@ -4071,7 +4101,7 @@ class AiHubServer {
|
|
|
4071
4101
|
});
|
|
4072
4102
|
// Issue #1005: pass the requested project so custom employees are scoped to it,
|
|
4073
4103
|
// not to the directory the Hub launched from.
|
|
4074
|
-
const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey, managerTeamPromise, projectPath);
|
|
4104
|
+
const { personas, subscriptionActive, workspaceId, userKey, identityAuthoritative } = await this.computePersonas(apiKey, managerTeamPromise, projectPath);
|
|
4075
4105
|
const managerTeam = await managerTeamPromise;
|
|
4076
4106
|
const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath, { includeRegistry: true })
|
|
4077
4107
|
.filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
|
|
@@ -4081,6 +4111,7 @@ class AiHubServer {
|
|
|
4081
4111
|
subscriptionActive,
|
|
4082
4112
|
workspaceId,
|
|
4083
4113
|
userEmail: userKey ?? null,
|
|
4114
|
+
identityAuthoritative,
|
|
4084
4115
|
managerTeam,
|
|
4085
4116
|
firstRun: this.computeFirstRun(projectPath, jobCount, personas),
|
|
4086
4117
|
});
|
|
@@ -4186,14 +4217,22 @@ class AiHubServer {
|
|
|
4186
4217
|
const scope = scopeParam(req.query.scope);
|
|
4187
4218
|
const key = scope
|
|
4188
4219
|
? (0, conversation_store_1.conversationScopeKey)(scope, '')
|
|
4189
|
-
: (typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
4220
|
+
: (typeof req.query.projectPath === 'string' && req.query.projectPath.trim().length > 0
|
|
4190
4221
|
? path_1.default.resolve(req.query.projectPath)
|
|
4191
4222
|
: this.defaultProjectPath());
|
|
4223
|
+
const conversationId = typeof req.query.conversationId === 'string' ? req.query.conversationId : '';
|
|
4224
|
+
if (!scope && !key) {
|
|
4225
|
+
if (conversationId)
|
|
4226
|
+
return res.status(404).json({ error: 'conversation not found' });
|
|
4227
|
+
if (req.query.headersOnly === '1' || req.query.headersOnly === 'true') {
|
|
4228
|
+
return res.json({ projectPath: '', scope: 'project', headersOnly: true, conversations: [], source: 'disk' });
|
|
4229
|
+
}
|
|
4230
|
+
return res.json({ projectPath: '', scope: 'project', activeId: null, conversations: [], source: 'disk' });
|
|
4231
|
+
}
|
|
4192
4232
|
// Issue #820: lazy read surfaces so the UI never has to parse the whole store.
|
|
4193
4233
|
// ?conversationId=<id> -> one full body (lazy load on open).
|
|
4194
4234
|
// ?headersOnly=1 -> newest-first headers, no bodies (fast list / first paint).
|
|
4195
4235
|
// (default) -> full project (backwards compatible).
|
|
4196
|
-
const conversationId = typeof req.query.conversationId === 'string' ? req.query.conversationId : '';
|
|
4197
4236
|
if (conversationId) {
|
|
4198
4237
|
const conversation = this.conversationStore.loadConversation(key, conversationId);
|
|
4199
4238
|
if (!conversation)
|
|
@@ -4902,6 +4941,16 @@ class AiHubServer {
|
|
|
4902
4941
|
fraimConfigured: mcp.configured,
|
|
4903
4942
|
});
|
|
4904
4943
|
}
|
|
4944
|
+
// Issue #1205: agents with no npm package (e.g. antigravity/agy) must be installed manually.
|
|
4945
|
+
if (!option.installPackage) {
|
|
4946
|
+
return res.json({
|
|
4947
|
+
ok: true,
|
|
4948
|
+
manualInstall: true,
|
|
4949
|
+
installUrl: option.installUrl || '',
|
|
4950
|
+
message: `Install ${option.label} from the vendor site, then click "Download / Install" again to activate it.`,
|
|
4951
|
+
loginHint: `Once installed, click "Check if Ready" to verify ${option.label} is on your PATH.`,
|
|
4952
|
+
});
|
|
4953
|
+
}
|
|
4905
4954
|
let standardInstallError = null;
|
|
4906
4955
|
try {
|
|
4907
4956
|
await hubRunProcess('npm', ['install', '-g', option.installPackage], {
|
|
@@ -6388,6 +6437,7 @@ class AiHubServer {
|
|
|
6388
6437
|
// Pre-register before startRun so synchronous onEvent calls (e.g. FakeHostRuntime)
|
|
6389
6438
|
// can call runRegistry.update without "Run not found" throws.
|
|
6390
6439
|
this.runRegistry.create(run, {});
|
|
6440
|
+
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
|
|
6391
6441
|
const child = this.hostRuntime.startRun(hostId, deployment.projectPath, instructions, {
|
|
6392
6442
|
onEvent: (event, channel) => {
|
|
6393
6443
|
this.runRegistry.update(run.id, (current) => {
|
|
@@ -6424,7 +6474,6 @@ class AiHubServer {
|
|
|
6424
6474
|
},
|
|
6425
6475
|
}, startSessionSeedForHost(hostId, run.id), launchContext);
|
|
6426
6476
|
this.runRegistry.create(run, child);
|
|
6427
|
-
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
|
|
6428
6477
|
return run;
|
|
6429
6478
|
}
|
|
6430
6479
|
// ─── End Issue #578 helpers ───────────────────────────────────────────────
|
|
@@ -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.
|
|
39
|
+
exports.loadGlobalConfig = exports.addIDECommand = exports.runAddIDE = void 0;
|
|
40
40
|
const commander_1 = require("commander");
|
|
41
41
|
const chalk_1 = __importDefault(require("chalk"));
|
|
42
42
|
const prompts_1 = __importDefault(require("prompts"));
|
|
@@ -48,9 +48,6 @@ const mcp_config_generator_1 = require("../setup/mcp-config-generator");
|
|
|
48
48
|
const claude_code_telemetry_1 = require("../setup/claude-code-telemetry");
|
|
49
49
|
const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
50
50
|
const mcp_server_registry_1 = require("../mcp/mcp-server-registry");
|
|
51
|
-
const get_provider_client_1 = require("../api/get-provider-client");
|
|
52
|
-
const provider_prompts_1 = require("../setup/provider-prompts");
|
|
53
|
-
const provider_registry_1 = require("../providers/provider-registry");
|
|
54
51
|
const user_config_1 = require("../utils/user-config");
|
|
55
52
|
const resolveGlobalConfigPath = () => {
|
|
56
53
|
const primary = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'config.json');
|
|
@@ -84,41 +81,9 @@ const loadGlobalConfig = async () => {
|
|
|
84
81
|
const config = JSON.parse(fs_1.default.readFileSync(globalConfigPath, 'utf8'));
|
|
85
82
|
if (!config.apiKey)
|
|
86
83
|
return null;
|
|
87
|
-
// Support both old and new token format
|
|
88
|
-
const tokens = config.tokens || {};
|
|
89
|
-
// Backward compatibility: map old format to new
|
|
90
|
-
// Only try to fetch provider IDs if we have a valid FRAIM key
|
|
91
|
-
if (config.apiKey) {
|
|
92
|
-
try {
|
|
93
|
-
const client = (0, get_provider_client_1.getProviderClient)();
|
|
94
|
-
const providerIds = await client.getAllProviderIds();
|
|
95
|
-
for (const id of providerIds) {
|
|
96
|
-
const oldKey = `${id}Token`;
|
|
97
|
-
if (config[oldKey] && !tokens[id]) {
|
|
98
|
-
tokens[id] = config[oldKey];
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
catch (e) {
|
|
103
|
-
// If provider client fails (network error, invalid key, etc.),
|
|
104
|
-
// skip backward compatibility mapping and use local fallback
|
|
105
|
-
// This is fine - the config will still work with the new format
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
// Load all provider configs
|
|
109
|
-
const providerConfigs = {};
|
|
110
|
-
// New format: providerConfigs object
|
|
111
|
-
if (config.providerConfigs) {
|
|
112
|
-
Object.entries(config.providerConfigs).forEach(([key, value]) => {
|
|
113
|
-
const providerId = key.replace('Config', '');
|
|
114
|
-
providerConfigs[providerId] = value;
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
84
|
return {
|
|
118
85
|
fraimKey: config.apiKey,
|
|
119
|
-
tokens,
|
|
120
86
|
mode: config.mode,
|
|
121
|
-
providerConfigs
|
|
122
87
|
};
|
|
123
88
|
}
|
|
124
89
|
catch (e) {
|
|
@@ -126,65 +91,7 @@ const loadGlobalConfig = async () => {
|
|
|
126
91
|
}
|
|
127
92
|
};
|
|
128
93
|
exports.loadGlobalConfig = loadGlobalConfig;
|
|
129
|
-
const
|
|
130
|
-
const client = (0, get_provider_client_1.getProviderClient)();
|
|
131
|
-
const provider = await client.getProvider(providerId);
|
|
132
|
-
if (!provider)
|
|
133
|
-
return '';
|
|
134
|
-
if (isOptional) {
|
|
135
|
-
console.log(chalk_1.default.yellow(`\n🔑 ${provider.displayName} token (optional for conversational mode)`));
|
|
136
|
-
console.log(chalk_1.default.gray(`${provider.displayName} token enables ${provider.displayName}-specific MCP features.\n`));
|
|
137
|
-
const wantsToken = await (0, prompts_1.default)({
|
|
138
|
-
type: 'confirm',
|
|
139
|
-
name: 'addToken',
|
|
140
|
-
message: `Do you want to add a ${provider.displayName} token?`,
|
|
141
|
-
initial: false
|
|
142
|
-
});
|
|
143
|
-
if (!wantsToken.addToken) {
|
|
144
|
-
console.log(chalk_1.default.blue(`ℹ️ Skipping ${provider.displayName} token - ${provider.displayName} MCP server will not be configured`));
|
|
145
|
-
return '';
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
else {
|
|
149
|
-
console.log(chalk_1.default.yellow(`\n🔑 ${provider.displayName} token needed for MCP configuration`));
|
|
150
|
-
console.log(chalk_1.default.gray(`This is required for ${provider.displayName} MCP servers to function properly.\n`));
|
|
151
|
-
}
|
|
152
|
-
try {
|
|
153
|
-
return await (0, provider_prompts_1.promptForProviderToken)(client, providerId);
|
|
154
|
-
}
|
|
155
|
-
catch (error) {
|
|
156
|
-
if (isOptional) {
|
|
157
|
-
console.log(chalk_1.default.blue(`ℹ️ No ${provider.displayName} token provided - ${provider.displayName} MCP server will not be configured`));
|
|
158
|
-
return '';
|
|
159
|
-
}
|
|
160
|
-
console.log(chalk_1.default.red(`${provider.displayName} token is required. Exiting.`));
|
|
161
|
-
process.exit(1);
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
const saveProviderTokenToConfig = async (providerId, token) => {
|
|
165
|
-
const globalConfigPath = resolveGlobalConfigPath();
|
|
166
|
-
if (fs_1.default.existsSync(globalConfigPath)) {
|
|
167
|
-
try {
|
|
168
|
-
const config = JSON.parse(fs_1.default.readFileSync(globalConfigPath, 'utf8'));
|
|
169
|
-
// Use new tokens structure
|
|
170
|
-
if (!config.tokens) {
|
|
171
|
-
config.tokens = {};
|
|
172
|
-
}
|
|
173
|
-
config.tokens[providerId] = token;
|
|
174
|
-
fs_1.default.writeFileSync(globalConfigPath, JSON.stringify(config, null, 2));
|
|
175
|
-
const { getProvider } = await Promise.resolve().then(() => __importStar(require('../providers/provider-registry')));
|
|
176
|
-
const provider = await getProvider(providerId);
|
|
177
|
-
console.log(chalk_1.default.green(`✅ ${provider?.displayName || providerId} token saved to global config`));
|
|
178
|
-
}
|
|
179
|
-
catch (e) {
|
|
180
|
-
const { getProvider } = await Promise.resolve().then(() => __importStar(require('../providers/provider-registry')));
|
|
181
|
-
const provider = await getProvider(providerId);
|
|
182
|
-
console.log(chalk_1.default.yellow(`⚠️ Could not save ${provider?.displayName || providerId} token to config`));
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
};
|
|
186
|
-
exports.saveProviderTokenToConfig = saveProviderTokenToConfig;
|
|
187
|
-
const configureIDEMCP = async (ide, fraimKey, tokens, providerConfigs) => {
|
|
94
|
+
const configureIDEMCP = async (ide, fraimKey) => {
|
|
188
95
|
const configPath = (0, ide_detector_1.expandPath)(ide.configPath);
|
|
189
96
|
console.log(chalk_1.default.blue(`🔧 Configuring ${ide.name}...`));
|
|
190
97
|
// Create backup if config exists
|
|
@@ -219,13 +126,10 @@ const configureIDEMCP = async (ide, fraimKey, tokens, providerConfigs) => {
|
|
|
219
126
|
existingTomlContent = fs_1.default.readFileSync(configPath, 'utf8');
|
|
220
127
|
console.log(chalk_1.default.gray(` 📋 Found existing TOML config`));
|
|
221
128
|
}
|
|
222
|
-
const newTomlContent = await (0, mcp_config_generator_1.generateMCPConfig)(ide.configType, fraimKey,
|
|
129
|
+
const newTomlContent = await (0, mcp_config_generator_1.generateMCPConfig)(ide.configType, fraimKey, {});
|
|
223
130
|
const { getAllMCPServerIds } = await Promise.resolve().then(() => __importStar(require('../mcp/mcp-server-registry')));
|
|
224
131
|
const baseServerIds = getAllMCPServerIds();
|
|
225
|
-
|
|
226
|
-
const providerServerIds = Object.keys(tokens).filter(id => tokens[id]);
|
|
227
|
-
const serversToAdd = [...baseServerIds, ...providerServerIds];
|
|
228
|
-
const mergeResult = (0, mcp_config_generator_1.mergeTomlMCPServers)(existingTomlContent, newTomlContent, serversToAdd);
|
|
132
|
+
const mergeResult = (0, mcp_config_generator_1.mergeTomlMCPServers)(existingTomlContent, newTomlContent, baseServerIds);
|
|
229
133
|
fs_1.default.writeFileSync(configPath, mergeResult.content);
|
|
230
134
|
mergeResult.addedServers.forEach(server => {
|
|
231
135
|
console.log(chalk_1.default.green(` ✅ Added ${server} MCP server`));
|
|
@@ -239,14 +143,14 @@ const configureIDEMCP = async (ide, fraimKey, tokens, providerConfigs) => {
|
|
|
239
143
|
}
|
|
240
144
|
else {
|
|
241
145
|
// For JSON configs - intelligent merging
|
|
242
|
-
const newConfig = await (0, mcp_config_generator_1.generateMCPConfig)(ide.configType, fraimKey,
|
|
146
|
+
const newConfig = await (0, mcp_config_generator_1.generateMCPConfig)(ide.configType, fraimKey, {});
|
|
243
147
|
const newMCPServers = newConfig[serversKey] || newConfig.mcpServers || {};
|
|
244
148
|
// Merge MCP servers intelligently
|
|
245
149
|
const mergedMCPServers = { ...existingMCPServers };
|
|
246
150
|
const addedServers = [];
|
|
247
151
|
const updatedServers = [];
|
|
248
152
|
const skippedServers = [];
|
|
249
|
-
const alwaysUpdateServers = new Set(['fraim'
|
|
153
|
+
const alwaysUpdateServers = new Set(['fraim']);
|
|
250
154
|
for (const [serverName, serverConfig] of Object.entries(newMCPServers)) {
|
|
251
155
|
if (!existingMCPServers[serverName]) {
|
|
252
156
|
mergedMCPServers[serverName] = serverConfig;
|
|
@@ -302,7 +206,7 @@ const listSupportedIDEs = () => {
|
|
|
302
206
|
console.log(chalk_1.default.yellow(' Gemini aliases: gemini, gemini-cli, gemini cli'));
|
|
303
207
|
console.log(chalk_1.default.yellow(' GitHub Copilot CLI aliases: copilot, copilot-cli, github copilot cli'));
|
|
304
208
|
};
|
|
305
|
-
const promptForIDESelection = async (availableIDEs
|
|
209
|
+
const promptForIDESelection = async (availableIDEs) => {
|
|
306
210
|
console.log(chalk_1.default.green(`✅ Found ${availableIDEs.length} IDEs that can be configured:\n`));
|
|
307
211
|
availableIDEs.forEach((ide, index) => {
|
|
308
212
|
const configExists = fs_1.default.existsSync((0, ide_detector_1.expandPath)(ide.configPath));
|
|
@@ -315,14 +219,6 @@ const promptForIDESelection = async (availableIDEs, tokens) => {
|
|
|
315
219
|
for (const server of mcp_server_registry_1.BASE_MCP_SERVERS) {
|
|
316
220
|
console.log(chalk_1.default.gray(` • ${server.id} (${server.description})`));
|
|
317
221
|
}
|
|
318
|
-
// Show provider servers (only if tokens exist)
|
|
319
|
-
const allProviders = await (0, provider_registry_1.getAllProviders)();
|
|
320
|
-
for (const provider of allProviders) {
|
|
321
|
-
const hasToken = tokens?.[provider.id];
|
|
322
|
-
if (hasToken && provider.mcpServer) {
|
|
323
|
-
console.log(chalk_1.default.gray(` - ${provider.id} (${provider.description})`));
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
222
|
const response = await (0, prompts_1.default)({
|
|
327
223
|
type: 'text',
|
|
328
224
|
name: 'selection',
|
|
@@ -360,25 +256,8 @@ const runAddIDE = async (options) => {
|
|
|
360
256
|
console.log(chalk_1.default.yellow('💡 Please run "fraim setup" first to configure your FRAIM keys.'));
|
|
361
257
|
process.exit(1);
|
|
362
258
|
}
|
|
363
|
-
const platformTokens = globalConfig.tokens || {};
|
|
364
259
|
const isConversationalMode = globalConfig.mode === 'conversational';
|
|
365
|
-
|
|
366
|
-
const allProviderIds = await (0, provider_registry_1.getAllProviderIds)();
|
|
367
|
-
const hasAnyToken = allProviderIds.some(id => platformTokens[id]);
|
|
368
|
-
if (!hasAnyToken && !isConversationalMode && !options.skipTokenPrompts) {
|
|
369
|
-
console.log(chalk_1.default.yellow('⚠️ No provider tokens found in configuration.'));
|
|
370
|
-
// Prompt for first integrated provider as default
|
|
371
|
-
const integratedProviders = await (0, provider_registry_1.getProvidersWithCapability)('integrated');
|
|
372
|
-
const defaultProviderId = integratedProviders[0]?.id;
|
|
373
|
-
if (defaultProviderId) {
|
|
374
|
-
const token = await promptForProviderTokenIfNeeded(defaultProviderId, false);
|
|
375
|
-
if (token) {
|
|
376
|
-
await saveProviderTokenToConfig(defaultProviderId, token);
|
|
377
|
-
platformTokens[defaultProviderId] = token;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
if (isConversationalMode && !hasAnyToken) {
|
|
260
|
+
if (isConversationalMode) {
|
|
382
261
|
console.log(chalk_1.default.blue('ℹ️ Conversational mode: Configuring MCP without platform integration\n'));
|
|
383
262
|
}
|
|
384
263
|
else {
|
|
@@ -415,7 +294,7 @@ const runAddIDE = async (options) => {
|
|
|
415
294
|
}
|
|
416
295
|
else {
|
|
417
296
|
// Interactive selection
|
|
418
|
-
idesToConfigure = await promptForIDESelection(detectedIDEs
|
|
297
|
+
idesToConfigure = await promptForIDESelection(detectedIDEs);
|
|
419
298
|
}
|
|
420
299
|
}
|
|
421
300
|
if (idesToConfigure.length === 0) {
|
|
@@ -429,7 +308,7 @@ const runAddIDE = async (options) => {
|
|
|
429
308
|
};
|
|
430
309
|
for (const ide of idesToConfigure) {
|
|
431
310
|
try {
|
|
432
|
-
await configureIDEMCP(ide, globalConfig.fraimKey
|
|
311
|
+
await configureIDEMCP(ide, globalConfig.fraimKey);
|
|
433
312
|
results.successful.push(ide.name);
|
|
434
313
|
(0, user_config_1.addInstalledIde)((0, ide_detector_1.getAdapterConfigType)(ide));
|
|
435
314
|
}
|
|
@@ -257,7 +257,7 @@ class CodexFormat {
|
|
|
257
257
|
const escapedUrl = this.escapeToml(server.url);
|
|
258
258
|
sections.push(`[mcp_servers.${key}]`);
|
|
259
259
|
sections.push(`url = "${escapedUrl}"`);
|
|
260
|
-
//
|
|
260
|
+
// URL-only provider metadata serializes without a synthesized Authorization header.
|
|
261
261
|
if (authHeader) {
|
|
262
262
|
sections.push(`http_headers = { Authorization = "${this.escapeToml(authHeader)}" }`);
|
|
263
263
|
}
|
|
@@ -81,9 +81,9 @@ async function buildProviderMCPServer(providerId, token, config) {
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
84
|
+
* Serialize an HTTP MCP server from explicit provider metadata.
|
|
85
|
+
* A missing authHeaderTemplate produces a URL-only shape; it does not establish
|
|
86
|
+
* authentication compatibility for any agent host.
|
|
87
87
|
*/
|
|
88
88
|
function buildHTTPServer(mcpConfig, token) {
|
|
89
89
|
if (!mcpConfig.url) {
|
|
@@ -22,7 +22,7 @@ const LOCAL_PROVIDERS = [
|
|
|
22
22
|
description: 'GitHub repository and issue management',
|
|
23
23
|
capabilities: ['code', 'issues', 'integrated'],
|
|
24
24
|
docsUrl: 'https://docs.github.com',
|
|
25
|
-
setupInstructions: '
|
|
25
|
+
setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current GitHub and host guidance',
|
|
26
26
|
hasAdditionalConfig: false,
|
|
27
27
|
mcpServer: {
|
|
28
28
|
type: 'http',
|
|
@@ -35,7 +35,7 @@ const LOCAL_PROVIDERS = [
|
|
|
35
35
|
description: 'GitLab repository and issue management',
|
|
36
36
|
capabilities: ['code', 'issues', 'integrated'],
|
|
37
37
|
docsUrl: 'https://docs.gitlab.com',
|
|
38
|
-
setupInstructions: '
|
|
38
|
+
setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current GitLab and host guidance',
|
|
39
39
|
hasAdditionalConfig: false,
|
|
40
40
|
mcpServer: {
|
|
41
41
|
type: 'http',
|
|
@@ -49,7 +49,7 @@ const LOCAL_PROVIDERS = [
|
|
|
49
49
|
description: 'Azure DevOps repository and issue management',
|
|
50
50
|
capabilities: ['code', 'issues', 'integrated'],
|
|
51
51
|
docsUrl: 'https://docs.microsoft.com/azure/devops',
|
|
52
|
-
setupInstructions: '
|
|
52
|
+
setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current Azure DevOps and host guidance',
|
|
53
53
|
hasAdditionalConfig: true,
|
|
54
54
|
mcpServer: {
|
|
55
55
|
type: 'stdio',
|
|
@@ -66,7 +66,7 @@ const LOCAL_PROVIDERS = [
|
|
|
66
66
|
description: 'Jira issue tracking and project management',
|
|
67
67
|
capabilities: ['issues'],
|
|
68
68
|
docsUrl: 'https://support.atlassian.com/jira',
|
|
69
|
-
setupInstructions: '
|
|
69
|
+
setupInstructions: 'Ask your agent to use the FRAIM connect-mcp skill and follow current Jira and host guidance',
|
|
70
70
|
hasAdditionalConfig: true,
|
|
71
71
|
mcpServer: {
|
|
72
72
|
type: 'stdio',
|
|
@@ -64,8 +64,8 @@ exports.FIRST_RUN_AGENT_OPTIONS = [
|
|
|
64
64
|
detectAliases: ['agy', 'antigravity', 'antigravity-cli'],
|
|
65
65
|
loginCommand: 'agy auth login',
|
|
66
66
|
launchCommand: 'agy',
|
|
67
|
-
// agy has no npm package — install via https://antigravity.google/cli/
|
|
68
67
|
installPackage: '',
|
|
68
|
+
installUrl: 'https://antigravity.google/cli/',
|
|
69
69
|
},
|
|
70
70
|
];
|
|
71
71
|
/**
|
|
@@ -139,8 +139,8 @@ function emptyStore() {
|
|
|
139
139
|
* The shallow `record.offered && record.fired` this replaced was not enough. A
|
|
140
140
|
* record whose sub-objects exist but are empty passed it and then threw in
|
|
141
141
|
* `buildUsageReport` (`record.offered.log is not iterable`),
|
|
142
|
-
* `findRetirementCandidates` and `deriveStanding`, so
|
|
143
|
-
*
|
|
142
|
+
* `findRetirementCandidates` and `deriveStanding`, so usage reports and
|
|
143
|
+
* candidate analysis failed outright instead of degrading. That state is
|
|
144
144
|
* reachable: the store is a JSON file inside the manager home, which is commonly
|
|
145
145
|
* a synced folder, so an interrupted write or a partial sync produces valid JSON
|
|
146
146
|
* with a hollow record.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.273",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fraim-hub": "bin/fraim-hub.js",
|
|
@@ -168,7 +168,7 @@
|
|
|
168
168
|
"electron-updater": "^6.8.9",
|
|
169
169
|
"express": "^5.2.1",
|
|
170
170
|
"extract-zip": "^2.0.1",
|
|
171
|
-
"fraim": "2.0.
|
|
171
|
+
"fraim": "2.0.273",
|
|
172
172
|
"mongodb": "^7.0.0",
|
|
173
173
|
"node-cron": "4.2.1",
|
|
174
174
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -230,6 +230,36 @@ function cacheBootstrapPayload(bootstrap, docUrl) {
|
|
|
230
230
|
// carrying the previous project's across would re-create the cross-project leak.
|
|
231
231
|
const PERSONA_AUTHORITY_FIELDS = ['subscriptionActive', 'workspaceId', 'userEmail', 'managerTeam'];
|
|
232
232
|
|
|
233
|
+
// Issue #1185: identity is the one authority field where "previous always wins" is wrong.
|
|
234
|
+
// The other three describe the roster, which the placeholder genuinely does not know. But
|
|
235
|
+
// bootstrap resolves `userEmail` from a SEPARATE, cheaper source — the server's resilient
|
|
236
|
+
// identity cache — which warms after the first request even while the persona projection
|
|
237
|
+
// keeps missing its 250ms budget. So an unresolved bootstrap routinely carries a correct
|
|
238
|
+
// email alongside a placeholder roster, and copying `previous.userEmail` over it discarded
|
|
239
|
+
// the real answer. Preferring whichever side actually has an identity keeps the roster
|
|
240
|
+
// preservation of #1005 intact while letting the account menu recover.
|
|
241
|
+
//
|
|
242
|
+
// `authoritative` is the server's answer to "is this null a real answer?" (bootstrap /
|
|
243
|
+
// personas `identityAuthoritative`). An authoritative null is a genuine sign-out and MUST
|
|
244
|
+
// clear — otherwise a user who signed out, or whose ~/.fraim key changed, would keep seeing
|
|
245
|
+
// the previous account's name and initials. A non-authoritative null is "we could not reach
|
|
246
|
+
// the authority yet", and the last known identity stands.
|
|
247
|
+
function preferResolvedIdentity(preferred, fallback, authoritative) {
|
|
248
|
+
if (preferred !== null && preferred !== undefined) return preferred;
|
|
249
|
+
if (authoritative) return preferred ?? null;
|
|
250
|
+
return fallback;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Bootstrap projection merges are intentionally different from a fresh authority answer:
|
|
254
|
+
// the incoming projection is explicitly unresolved, so its null can never clear a known
|
|
255
|
+
// identity. Whichever side is non-null wins, independent of arrival order. A fresh personas
|
|
256
|
+
// response still uses preferResolvedIdentity above, where an authoritative null clears.
|
|
257
|
+
function mergeKnownIdentity(incoming, previous) {
|
|
258
|
+
if (incoming !== null && incoming !== undefined) return incoming;
|
|
259
|
+
if (previous !== null && previous !== undefined) return previous;
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
233
263
|
function mergePersonaProjection(incoming, previous) {
|
|
234
264
|
if (!incoming || incoming.personasResolved !== false) return incoming;
|
|
235
265
|
// Nothing trustworthy to fall back on (cold start, or the previous payload was itself a
|
|
@@ -243,6 +273,10 @@ function mergePersonaProjection(incoming, previous) {
|
|
|
243
273
|
for (const field of PERSONA_AUTHORITY_FIELDS) {
|
|
244
274
|
if (previous[field] !== undefined) merged[field] = previous[field];
|
|
245
275
|
}
|
|
276
|
+
merged.userEmail = mergeKnownIdentity(incoming.userEmail, previous.userEmail);
|
|
277
|
+
merged.identityAuthoritative = merged.userEmail !== null
|
|
278
|
+
? true
|
|
279
|
+
: Boolean(previous.identityAuthoritative && incoming.identityAuthoritative);
|
|
246
280
|
return merged;
|
|
247
281
|
}
|
|
248
282
|
|
|
@@ -337,6 +371,41 @@ async function refreshBootstrapInBackground(projectPath, docUrl) {
|
|
|
337
371
|
|
|
338
372
|
let tfPersonaRefreshInFlight = null;
|
|
339
373
|
|
|
374
|
+
// Issue #1185: the single tfInitShell lookup was the only thing standing between a cold
|
|
375
|
+
// start and a permanently signed-out-looking Hub. When it comes back with no identity —
|
|
376
|
+
// because the hosted authority was briefly unreachable while the server's resilient cache
|
|
377
|
+
// was still cold — nothing used to try again until the user switched projects. These retries
|
|
378
|
+
// close that gap. The schedule is exponential and finite: a user who really is signed out
|
|
379
|
+
// gets a handful of extra requests over ~1 minute, then silence.
|
|
380
|
+
const PERSONA_IDENTITY_RETRY_DELAYS_MS = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
381
|
+
let tfPersonaIdentityRetryCount = 0;
|
|
382
|
+
let tfPersonaIdentityRetryTimer = null;
|
|
383
|
+
|
|
384
|
+
// Called once an identity resolves. Resetting the counter as well as clearing the timer
|
|
385
|
+
// means a LATER outage in the same long-lived window gets a fresh budget — without it, a
|
|
386
|
+
// session that healed once would never retry again, which is the same permanent-latch
|
|
387
|
+
// failure this issue is about, just deferred.
|
|
388
|
+
function tfCancelPersonaIdentityRetry() {
|
|
389
|
+
if (tfPersonaIdentityRetryTimer !== null) {
|
|
390
|
+
clearTimeout(tfPersonaIdentityRetryTimer);
|
|
391
|
+
tfPersonaIdentityRetryTimer = null;
|
|
392
|
+
}
|
|
393
|
+
tfPersonaIdentityRetryCount = 0;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Schedule the next attempt when a lookup produced no identity. Resolving an identity (or
|
|
397
|
+
// exhausting the schedule) stops the chain; a retry already pending is never doubled up.
|
|
398
|
+
function tfSchedulePersonaIdentityRetry() {
|
|
399
|
+
if (tfPersonaIdentityRetryTimer !== null) return;
|
|
400
|
+
const delay = PERSONA_IDENTITY_RETRY_DELAYS_MS[tfPersonaIdentityRetryCount];
|
|
401
|
+
if (delay === undefined) return;
|
|
402
|
+
tfPersonaIdentityRetryCount += 1;
|
|
403
|
+
tfPersonaIdentityRetryTimer = setTimeout(() => {
|
|
404
|
+
tfPersonaIdentityRetryTimer = null;
|
|
405
|
+
void tfRefreshPersonasFromServerInBackground();
|
|
406
|
+
}, delay);
|
|
407
|
+
}
|
|
408
|
+
|
|
340
409
|
function tfRefreshPersonaDependentSurfaces() {
|
|
341
410
|
tfPopulateAccountMenu();
|
|
342
411
|
renderPersonaGrid();
|
|
@@ -359,25 +428,42 @@ async function tfRefreshPersonasFromServerInBackground() {
|
|
|
359
428
|
tfPersonaRefreshInFlight = requestJson('/api/ai-hub/personas' + query)
|
|
360
429
|
.then((payload) => {
|
|
361
430
|
if (!payload || !state.bootstrap) return;
|
|
431
|
+
const resolvedEmail = payload.userEmail ?? null;
|
|
432
|
+
// Issue #1185: only a response the server vouches for is the resolved projection.
|
|
433
|
+
// An identity-less answer from an unreachable authority is a guess; recording it as
|
|
434
|
+
// resolved is what latched the "not connected" avatar for the life of the window —
|
|
435
|
+
// it froze the null identity into every later merge AND switched off
|
|
436
|
+
// tfRefreshAfterBootstrap's re-request. A definitive sign-out IS resolved, and still
|
|
437
|
+
// clears, so signing out remains immediate.
|
|
438
|
+
const identityAuthoritative = Boolean(payload.identityAuthoritative);
|
|
439
|
+
const identityResolved = resolvedEmail !== null || identityAuthoritative;
|
|
362
440
|
state.bootstrap = {
|
|
363
441
|
...state.bootstrap,
|
|
364
442
|
personas: Array.isArray(payload.personas) ? payload.personas : state.bootstrap.personas,
|
|
365
443
|
// Issue #1005: GET /api/ai-hub/personas awaits the authority with no first-paint
|
|
366
|
-
// budget, so a
|
|
367
|
-
// applyBootstrap preserve it against a later unresolved bootstrap,
|
|
368
|
-
// tfRefreshAfterBootstrap
|
|
369
|
-
personasResolved:
|
|
444
|
+
// budget, so a response that carries an identity IS the resolved projection.
|
|
445
|
+
// Marking it lets applyBootstrap preserve it against a later unresolved bootstrap,
|
|
446
|
+
// and stops tfRefreshAfterBootstrap re-requesting a lookup that already succeeded.
|
|
447
|
+
personasResolved: identityResolved,
|
|
370
448
|
subscriptionActive: Boolean(payload.subscriptionActive),
|
|
371
449
|
workspaceId: payload.workspaceId ?? null,
|
|
372
|
-
|
|
450
|
+
// Never trade a known identity for an unknown one, UNLESS the server says the
|
|
451
|
+
// unknown is definitive — a real sign-out has to take effect immediately.
|
|
452
|
+
userEmail: preferResolvedIdentity(resolvedEmail, state.bootstrap.userEmail, identityAuthoritative),
|
|
453
|
+
identityAuthoritative,
|
|
373
454
|
managerTeam: Array.isArray(payload.managerTeam) ? payload.managerTeam : state.bootstrap.managerTeam,
|
|
374
455
|
firstRun: payload.firstRun || state.bootstrap.firstRun,
|
|
375
456
|
};
|
|
376
457
|
cacheBootstrapPayload(state.bootstrap, new URLSearchParams(window.location.search).get('docUrl') || '');
|
|
377
458
|
tfRefreshPersonaDependentSurfaces();
|
|
459
|
+
// Retry only while the "not connected" state is still a guess. A definitive
|
|
460
|
+
// sign-out is a final answer and must not be polled.
|
|
461
|
+
if (state.bootstrap.userEmail === null && !identityAuthoritative) tfSchedulePersonaIdentityRetry();
|
|
462
|
+
else tfCancelPersonaIdentityRetry();
|
|
378
463
|
})
|
|
379
464
|
.catch((error) => {
|
|
380
465
|
console.warn('Could not refresh Hub personas:', error);
|
|
466
|
+
tfSchedulePersonaIdentityRetry();
|
|
381
467
|
})
|
|
382
468
|
.finally(() => {
|
|
383
469
|
tfPersonaRefreshInFlight = null;
|
|
@@ -1049,6 +1135,7 @@ const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'del
|
|
|
1049
1135
|
// any inbound value. That is what preserves issue #913: a marker arriving over
|
|
1050
1136
|
// the wire is never trusted, only one this client set after a real fetch.
|
|
1051
1137
|
const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_stopping'];
|
|
1138
|
+
const FULL_BODY_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'handoffSummary'];
|
|
1052
1139
|
function slimConversationForPersist(conv) {
|
|
1053
1140
|
if (!conv || typeof conv !== 'object') return conv;
|
|
1054
1141
|
const slim = { ...conv };
|
|
@@ -1067,7 +1154,7 @@ function conversationHasBody(conv) {
|
|
|
1067
1154
|
// instead of looping. Issue #913 stays satisfied because `_bodyFetched` is
|
|
1068
1155
|
// client-only and stripped from every inbound header.
|
|
1069
1156
|
if (conv._bodyFetched === true) return true;
|
|
1070
|
-
return
|
|
1157
|
+
return FULL_BODY_CONV_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(conv, field));
|
|
1071
1158
|
}
|
|
1072
1159
|
|
|
1073
1160
|
function cleanConversationHeader(header) {
|
|
@@ -2646,22 +2733,22 @@ function statusLabel(s) {
|
|
|
2646
2733
|
|
|
2647
2734
|
function conversationUiState(conv) {
|
|
2648
2735
|
if (!conv) return 'idle';
|
|
2649
|
-
if (conv.blocked) return 'blocked';
|
|
2650
|
-
// Issue #1081: a submit-phase review handoff is a manager gate even if the
|
|
2651
|
-
// employee process is still alive for post-submit bookkeeping.
|
|
2652
|
-
if (conv.pauseReason === 'awaiting_review') return 'waiting';
|
|
2653
|
-
// Issue #550: recovered legacy freeform conversations can be persisted as
|
|
2654
|
-
// "running" even though there is no FRAIM employee owner and no conversation
|
|
2655
|
-
// body to indicate live work. Surface these as manager-attention items so the
|
|
2656
|
-
// rail dot and label agree.
|
|
2657
|
-
if (
|
|
2658
|
-
conv.status === 'running'
|
|
2659
|
-
&& conv.jobId === '__freeform__'
|
|
2660
|
-
&& !conversationHasFraimEmployee(conv)
|
|
2661
|
-
&& !(conv.messages || []).length
|
|
2662
|
-
&& !(conv.events || []).length
|
|
2663
|
-
) return 'waiting';
|
|
2664
|
-
if (conv.status === 'running') return 'working';
|
|
2736
|
+
if (conv.blocked) return 'blocked';
|
|
2737
|
+
// Issue #1081: a submit-phase review handoff is a manager gate even if the
|
|
2738
|
+
// employee process is still alive for post-submit bookkeeping.
|
|
2739
|
+
if (conv.pauseReason === 'awaiting_review') return 'waiting';
|
|
2740
|
+
// Issue #550: recovered legacy freeform conversations can be persisted as
|
|
2741
|
+
// "running" even though there is no FRAIM employee owner and no conversation
|
|
2742
|
+
// body to indicate live work. Surface these as manager-attention items so the
|
|
2743
|
+
// rail dot and label agree.
|
|
2744
|
+
if (
|
|
2745
|
+
conv.status === 'running'
|
|
2746
|
+
&& conv.jobId === '__freeform__'
|
|
2747
|
+
&& !conversationHasFraimEmployee(conv)
|
|
2748
|
+
&& !(conv.messages || []).length
|
|
2749
|
+
&& !(conv.events || []).length
|
|
2750
|
+
) return 'waiting';
|
|
2751
|
+
if (conv.status === 'running') return 'working';
|
|
2665
2752
|
// Issue #904: read pauseReason first for non-running records so the pill
|
|
2666
2753
|
// reflects the exit classification rather than mapping all completed -> 'waiting'.
|
|
2667
2754
|
if (conv.pauseReason === 'working') return 'working';
|
|
@@ -7423,7 +7510,10 @@ async function startAgentInstall(hubId) {
|
|
|
7423
7510
|
headers: { 'Content-Type': 'application/json' },
|
|
7424
7511
|
body: JSON.stringify({ hubId }),
|
|
7425
7512
|
});
|
|
7426
|
-
if (result.ok) {
|
|
7513
|
+
if (result.ok && result.manualInstall) {
|
|
7514
|
+
if (result.installUrl) window.open(result.installUrl, '_blank');
|
|
7515
|
+
setInstallState(hubId, 'login-triggered', result.loginHint || `Install from the vendor site, then click "Check if Ready".`);
|
|
7516
|
+
} else if (result.ok) {
|
|
7427
7517
|
setInstallState(hubId, 'needs-login', result.loginHint || 'Installed. Sign in to activate.');
|
|
7428
7518
|
} else {
|
|
7429
7519
|
setInstallState(hubId, 'error', result.message || 'Install failed.');
|