fraim-hub 2.0.271 → 2.0.272

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.
@@ -1,4 +1,4 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  try {
3
3
  const { createFraimHub2Program } = require('../dist/src/cli/fraim-hub-2.js');
4
4
  createFraimHub2Program().parseAsync(process.argv).catch((error) => {
@@ -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
- return path_1.default.resolve(key || process.cwd());
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 hub install` — register the FRAIM Hub as an OS application so users
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
- // This is an interim mitigation. Full packaged installers (electron-builder, code
17
- // signing, .msi/.dmg) are a separate effort requiring signing infrastructure.
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 hub_release_download_1 = require("./hub-release-download");
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 binary that OS shortcuts should point at. On Windows this is the Electron .exe;
30
- // on POSIX systems it is the unpacked Electron binary or a shell wrapper.
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
- const installDir = resolveHubInstallDir();
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} --no-open
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
- "$sc.WindowStyle = 1",
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 cmd = `@echo off\r\nstart "" "${safeTarget}"\r\n`;
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
- const downloaded = await (0, hub_release_download_1.downloadLatestHubBinary)(installDir, target);
131
- console.log(` Downloaded release asset: ${downloaded.assetName}`);
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
- return path_1.default.resolve(projectPath || process.cwd());
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
- * `skipTokenPrompts` keeps the run fully non-interactive.
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, skipTokenPrompts: true });
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
- return isPathInsideDirectory(projectPath, os_1.default.tmpdir());
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: apiKey ? this.lastResolvedIdentityByApiKey.get(apiKey) ?? null : null,
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.saveProviderTokenToConfig = exports.loadGlobalConfig = exports.addIDECommand = exports.runAddIDE = void 0;
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 promptForProviderTokenIfNeeded = async (providerId, isOptional = false) => {
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, tokens, providerConfigs);
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
- // Add provider server IDs from tokens
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, tokens, providerConfigs);
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', 'github', 'gitlab', 'jira', 'ado', 'linear']);
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, tokens) => {
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
- // Check if any provider tokens exist
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, platformTokens);
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, platformTokens, globalConfig.providerConfigs);
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
- // OAuth-first providers (e.g. GitHub) have no Authorization header — IDE manages auth
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
- * Build an HTTP MCP server.
85
- * When authHeaderTemplate is absent the provider uses IDE-native OAuth (e.g. GitHub);
86
- * write a URL-only entry so the IDE handles the OAuth flow on first tool use.
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: 'Run "fraim add-provider github" your IDE handles OAuth automatically on first use',
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: 'Create a Personal Access Token in your GitLab settings',
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: 'Create a Personal Access Token in Azure DevOps',
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: 'Create an API token at https://id.atlassian.com/manage-profile/security/api-tokens',
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 `fraim learning-usage
143
- * report` and `candidates` failed outright instead of degrading. That state is
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.271",
3
+ "version": "2.0.272",
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.271",
171
+ "fraim": "2.0.272",
172
172
  "mongodb": "^7.0.0",
173
173
  "node-cron": "4.2.1",
174
174
  "node-edge-tts": "^1.2.10",
@@ -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 successful response IS the resolved projection. Marking it lets
367
- // applyBootstrap preserve it against a later unresolved bootstrap, and stops
368
- // tfRefreshAfterBootstrap from re-requesting a lookup that already succeeded.
369
- personasResolved: true,
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
- userEmail: payload.userEmail ?? null,
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 SERVER_OWNED_CONV_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(conv, field));
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.');