fraim-hub 2.0.326 → 2.0.327

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.
@@ -337,7 +337,7 @@ function normalizeConversation(projectPath, raw) {
337
337
  status: value.status,
338
338
  // Issue #904: carry pauseReason explicitly so it survives the ...value spread round-trip.
339
339
  ...(value.pauseReason !== undefined && { pauseReason: value.pauseReason }),
340
- createdAt: value.createdAt ?? new Date().toISOString(),
340
+ createdAt: value.createdAt ?? value.lastUpdatedAt ?? new Date().toISOString(),
341
341
  lastUpdatedAt: value.lastUpdatedAt ?? value.createdAt ?? new Date().toISOString(),
342
342
  };
343
343
  }
@@ -0,0 +1,79 @@
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.readInstallerSetupApiKey = readInstallerSetupApiKey;
7
+ exports.repairInstallerIdeMcpEntries = repairInstallerIdeMcpEntries;
8
+ exports.runDesktopInstallerSetup = runDesktopInstallerSetup;
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const child_process_1 = require("child_process");
12
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
13
+ const ide_detector_1 = require("../cli/setup/ide-detector");
14
+ const auto_mcp_setup_1 = require("../cli/setup/auto-mcp-setup");
15
+ const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
16
+ const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
17
+ const win32_cmd_spawn_1 = require("../cli/utils/win32-cmd-spawn");
18
+ function readInstallerSetupApiKey(configPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'config.json')) {
19
+ if (!fs_1.default.existsSync(configPath))
20
+ return null;
21
+ try {
22
+ const parsed = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
23
+ return typeof parsed.apiKey === 'string' && parsed.apiKey.trim() ? parsed.apiKey.trim() : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ async function repairInstallerIdeMcpEntries(options = {}) {
30
+ const logger = options.logger ?? console;
31
+ const fraimKey = readInstallerSetupApiKey(options.configPath);
32
+ if (!fraimKey) {
33
+ logger.log('[fraim-installer-setup] no existing config.json apiKey; IDE MCP repair will run from first-run');
34
+ return { status: 'skipped', ideCount: 0, reason: 'missing-config-api-key' };
35
+ }
36
+ const detectedIDEs = (options.detectInstalledIDEs ?? ide_detector_1.detectInstalledIDEs)();
37
+ const ideNames = detectedIDEs.map((ide) => ide.name).filter(Boolean);
38
+ if (ideNames.length === 0) {
39
+ logger.log('[fraim-installer-setup] no supported IDEs detected for MCP repair');
40
+ return { status: 'skipped', ideCount: 0, reason: 'no-detected-ides' };
41
+ }
42
+ const previousNonInteractive = process.env.FRAIM_NON_INTERACTIVE;
43
+ process.env.FRAIM_NON_INTERACTIVE = '1';
44
+ try {
45
+ await (options.autoConfigureMCP ?? auto_mcp_setup_1.autoConfigureMCP)(fraimKey, ideNames);
46
+ logger.log(`[fraim-installer-setup] repaired MCP config for ${ideNames.length} detected IDE(s)`);
47
+ return { status: 'repaired', ideCount: ideNames.length };
48
+ }
49
+ catch (error) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ logger.warn(`[fraim-installer-setup] IDE MCP repair skipped after error: ${message}`);
52
+ return { status: 'failed', ideCount: ideNames.length, reason: message };
53
+ }
54
+ finally {
55
+ if (previousNonInteractive === undefined)
56
+ delete process.env.FRAIM_NON_INTERACTIVE;
57
+ else
58
+ process.env.FRAIM_NON_INTERACTIVE = previousNonInteractive;
59
+ }
60
+ }
61
+ function warmInstallerCliCache() {
62
+ const cliShim = (0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)();
63
+ const options = { env: process.env, encoding: 'utf8', stdio: 'inherit' };
64
+ const result = process.platform === 'win32'
65
+ ? (0, win32_cmd_spawn_1.spawnViaCmdExe)(cliShim, ['--version'], options)
66
+ : (0, child_process_1.spawnSync)(cliShim, ['--version'], options);
67
+ if (result.status !== 0) {
68
+ throw new Error(`FRAIM CLI bootstrap exited with code ${result.status ?? 'unknown'}.`);
69
+ }
70
+ }
71
+ async function runDesktopInstallerSetup(logger = console) {
72
+ await (0, managed_node_runtime_1.ensureManagedNpm)({
73
+ onProgress: (message) => logger.log(`[fraim-installer-setup] ${message}`),
74
+ });
75
+ (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
76
+ warmInstallerCliCache();
77
+ await repairInstallerIdeMcpEntries({ logger });
78
+ logger.log('[fraim-installer-setup] stable CLI and MCP launchers are ready');
79
+ }
@@ -20,6 +20,10 @@ async function bootstrapFrom(entryPath) {
20
20
  await entry.bootstrap();
21
21
  }
22
22
  async function launch() {
23
+ if (process.argv.includes('--fraim-installer-setup')) {
24
+ await bootstrapFrom(BUNDLED_ENTRY_PATH);
25
+ return;
26
+ }
23
27
  // Issue #1415: deterministic escape hatch for integration testing — skips the real npm
24
28
  // resolution (and its dependence on whatever happens to be published at test time) so a test can
25
29
  // prove the Electron main-process boot chain itself (resolveHubAppEntry -> require -> bootstrap
@@ -20,7 +20,7 @@ const window_open_decision_1 = require("./window-open-decision");
20
20
  const bundled_asset_resolver_1 = require("./bundled-asset-resolver");
21
21
  const server_2 = require("../first-run/server");
22
22
  const session_service_1 = require("../first-run/session-service");
23
- const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
23
+ const desktop_installer_setup_1 = require("./desktop-installer-setup");
24
24
  const desktop_auto_updater_1 = require("./desktop-auto-updater");
25
25
  const hub_app_materializer_1 = require("./hub-app-materializer");
26
26
  const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
@@ -89,13 +89,6 @@ function applyUserDataOverride() {
89
89
  fs_1.default.mkdirSync(userDataDir, { recursive: true });
90
90
  electron_1.app.setPath('userData', userDataDir);
91
91
  }
92
- function configurePackagedFraimRuntime() {
93
- if (!electron_1.app.isPackaged && process.env.FRAIM_DESKTOP_FIRST_RUN !== '1')
94
- return;
95
- process.env.FRAIM_PACKAGED_CLI_EXECUTABLE = process.execPath;
96
- process.env.FRAIM_PACKAGED_CLI_SCRIPT = (0, hub_app_materializer_1.resolvePackagedCliScriptPath)(__dirname);
97
- (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
98
- }
99
92
  function shouldShowEmbeddedFirstRun() {
100
93
  if (!electron_1.app.isPackaged && process.env.FRAIM_DESKTOP_FIRST_RUN !== '1')
101
94
  return false;
@@ -153,6 +146,16 @@ async function configureAutoUpdater() {
153
146
  });
154
147
  return result.action === 'installing';
155
148
  }
149
+ async function runPackagedInstallerRepair() {
150
+ if (!electron_1.app.isPackaged || process.env.FRAIM_INSTALLER_LIFECYCLE_TEST === '1')
151
+ return;
152
+ try {
153
+ await (0, desktop_installer_setup_1.runDesktopInstallerSetup)(console);
154
+ }
155
+ catch (error) {
156
+ console.warn('[fraim] packaged installer repair failed:', error);
157
+ }
158
+ }
156
159
  // Issue #1415: shared by every path that needs to know "is a newer FRAIM Hub published than the
157
160
  // one currently running" — the automatic second-instance/activate recheck below, and the
158
161
  // manually-triggered restart from the web UI's update badge (see restartToLatestNow). Compares
@@ -655,6 +658,12 @@ async function bootstrap() {
655
658
  preferredPort: options.preferredPort,
656
659
  hasProjectPath: Boolean(options.projectPath),
657
660
  });
661
+ if (process.argv.includes('--fraim-installer-setup')) {
662
+ await electron_1.app.whenReady();
663
+ await (0, desktop_installer_setup_1.runDesktopInstallerSetup)(console);
664
+ electron_1.app.exit(0);
665
+ return;
666
+ }
658
667
  electron_1.app.on('child-process-gone', (_event, details) => {
659
668
  (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('app.child_process_gone', details);
660
669
  });
@@ -679,7 +688,6 @@ async function bootstrap() {
679
688
  });
680
689
  await electron_1.app.whenReady();
681
690
  electron_1.app.setName(displayName(options.runtimeId));
682
- configurePackagedFraimRuntime();
683
691
  // Issue #1415 (follow-up): app.requestSingleInstanceLock() above only sees another instance of
684
692
  // *this same identity* — it cannot see a Hub already running via a different launch path (e.g.
685
693
  // `npx fraim-hub`'s raw electron.exe, confirmed live to end up under a different userData scope
@@ -710,6 +718,7 @@ async function bootstrap() {
710
718
  // First-launch housekeeping (idempotent, fast on subsequent runs)
711
719
  if (process.env.FRAIM_INSTALLER_LIFECYCLE_TEST !== '1') {
712
720
  ensureLoginItem();
721
+ await runPackagedInstallerRepair();
713
722
  const installingUpdate = await configureAutoUpdater();
714
723
  if (installingUpdate)
715
724
  return;
@@ -5,11 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.HUB_APP_ENTRY_RELATIVE = exports.HUB_APP_PACKAGE_NAME = void 0;
7
7
  exports.redactCredentialLikeSubstrings = redactCredentialLikeSubstrings;
8
- exports.resolvePackagedCliScriptPath = resolvePackagedCliScriptPath;
9
8
  exports.planHubAppMaterialization = planHubAppMaterialization;
10
9
  exports.resolveHubAppCacheDir = resolveHubAppCacheDir;
11
10
  exports.isHubAppCacheComplete = isHubAppCacheComplete;
12
11
  exports.listCachedHubAppVersions = listCachedHubAppVersions;
12
+ exports.runNpmInstall = runNpmInstall;
13
13
  exports.ensureMaterializedHubApp = ensureMaterializedHubApp;
14
14
  exports.resolveHubAppEntry = resolveHubAppEntry;
15
15
  const fs_1 = __importDefault(require("fs"));
@@ -55,19 +55,6 @@ function redactCredentialLikeSubstrings(text) {
55
55
  exports.HUB_APP_PACKAGE_NAME = 'fraim-hub';
56
56
  /** Relative path, from a materialized (or bundled) package root, to the Electron main entry. */
57
57
  exports.HUB_APP_ENTRY_RELATIVE = path_1.default.join('dist', 'src', 'ai-hub', 'desktop-main.js');
58
- /**
59
- * Where the packaged CLI/MCP shim (`ensureFraimMcpLatestLauncher()`) should find `fraim`'s CLI
60
- * script, derived from `desktop-main.ts`'s own directory (`<packageRoot>/dist/src/ai-hub/`) rather
61
- * than Electron's `app.getAppPath()`. `desktop-launcher.ts` may have loaded `desktop-main.js` from a
62
- * materialized `fraim-hub@<latest>` copy under `~/.fraim/bin/hub-app/<version>/` rather than from
63
- * the original app.asar, and `app.getAppPath()` always names the latter regardless of which copy is
64
- * actually running. `moduleDir` tracks whichever copy is really executing. Kept in this
65
- * Electron-free module (rather than inlined in `desktop-main.ts`) so it is unit-testable without an
66
- * Electron `app` object.
67
- */
68
- function resolvePackagedCliScriptPath(moduleDir) {
69
- return path_1.default.join(moduleDir, '..', '..', '..', 'node_modules', 'fraim', 'bin', 'fraim.js');
70
- }
71
58
  function pickNewestComplete(cachedVersions) {
72
59
  const versions = cachedVersions.filter((entry) => entry.complete).map((entry) => entry.version);
73
60
  if (versions.length === 0)
@@ -178,16 +165,19 @@ function listCachedHubAppVersions(fraimDir = (0, project_fraim_paths_1.getUserFr
178
165
  * every dependency to that top level; nothing here relies on nested-dependency resolution.
179
166
  *
180
167
  * The `npm` executable itself is resolved via `ensureManagedNpm()` (system PATH, an already-managed
181
- * runtime, or a freshly downloaded one) rather than assumed present on PATH — a genuinely GUI-only
182
- * install (native installer only, no CLI ever run) has neither system npm nor FRAIM's own managed
183
- * one, since first-run's embedded-desktop path only needs Electron's own bundled Node for its own
184
- * purposes and never downloads a standalone npm (see managed-node-runtime.ts's own header comment).
168
+ * runtime, or a freshly downloaded one) rather than assumed present on PATH. Installer setup
169
+ * prepares that shared runtime, while this fallback also supports older installs and recovery
170
+ * when installer setup did not finish.
185
171
  */
186
172
  function runNpmInstall(npmPath, args) {
173
+ // POSIX npm uses /usr/bin/env node. A GUI launch may have no system Node
174
+ // on PATH even after ensureManagedNpm has downloaded a complete runtime.
175
+ const npmPathValue = [path_1.default.dirname(npmPath), process.env.PATH].filter(Boolean).join(path_1.default.delimiter);
176
+ const env = { ...process.env, PATH: npmPathValue, ...(process.platform === 'win32' ? { Path: npmPathValue } : {}) };
187
177
  if (process.platform !== 'win32') {
188
- return (0, child_process_1.spawnSync)(npmPath, args, { encoding: 'utf8' });
178
+ return (0, child_process_1.spawnSync)(npmPath, args, { encoding: 'utf8', env });
189
179
  }
190
- return (0, win32_cmd_spawn_1.spawnViaCmdExe)(npmPath, args, { encoding: 'utf8' });
180
+ return (0, win32_cmd_spawn_1.spawnViaCmdExe)(npmPath, args, { encoding: 'utf8', env });
191
181
  }
192
182
  const defaultInstallPackage = async (version, destDir) => {
193
183
  fs_1.default.mkdirSync(destDir, { recursive: true });
@@ -3826,8 +3826,9 @@ class AiHubServer {
3826
3826
  try {
3827
3827
  const ref = this.rawEventLogStore.append((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id, run.id, { channel: 'system', text, hubKind: kind, hostId: run.baseHostId || run.hostId, createdAt });
3828
3828
  run.eventLogRefs = upsertEventLogRef(run.eventLogRefs, ref);
3829
- if (ref.eventCount === 1)
3829
+ if (ref.eventCount === 1 && this.runRegistry.get(run.id)) {
3830
3830
  this.persistRunConversationNow(run, run.managedByRunId ? undefined : (run.conversationId || run.id));
3831
+ }
3831
3832
  }
3832
3833
  catch (error) {
3833
3834
  run.events.push((0, hosts_1.createHubEvent)('system', 'Event archive write failed; some Micro-manage entries are unavailable.'));
@@ -8,15 +8,12 @@ exports.getFraimMcpShimPath = getFraimMcpShimPath;
8
8
  exports.getFraimNpxShimPath = getFraimNpxShimPath;
9
9
  exports.getFraimCliShimPath = getFraimCliShimPath;
10
10
  exports.getPackagedFraimRuntimePath = getPackagedFraimRuntimePath;
11
- exports.getPackagedFraimRuntime = getPackagedFraimRuntime;
12
- exports.hasPackagedFraimRuntime = hasPackagedFraimRuntime;
13
11
  exports.ensureFraimMcpLatestLauncher = ensureFraimMcpLatestLauncher;
14
12
  const fs_1 = __importDefault(require("fs"));
15
13
  const os_1 = __importDefault(require("os"));
16
14
  const path_1 = __importDefault(require("path"));
17
15
  const windows_shim_path_search_1 = require("../../core/utils/windows-shim-path-search");
18
16
  const LAUNCHER_VERSION = 1;
19
- const PACKAGED_RUNTIME_VERSION = 1;
20
17
  const launcherSource = `#!/usr/bin/env node
21
18
  const fs = require('fs');
22
19
  const os = require('os');
@@ -32,7 +29,9 @@ const CACHE_ROOT = path.join(USER_DIR, 'npm-cache', 'mcp');
32
29
  const commandName = (name) => process.platform === 'win32' ? name + '.cmd' : name;
33
30
  const npmCommand = commandName('npm');
34
31
  const npxCommand = commandName('npx');
35
- const cliArgs = process.argv.slice(2).length > 0 ? process.argv.slice(2) : ['mcp'];
32
+ const rawArgs = process.argv.slice(2);
33
+ const cliMode = rawArgs[0] === '--fraim-cli';
34
+ const cliArgs = cliMode ? rawArgs.slice(1) : (rawArgs.length > 0 ? rawArgs : ['mcp']);
36
35
 
37
36
  function quoteCmdArg(arg) {
38
37
  const value = String(arg);
@@ -133,75 +132,6 @@ function getFraimCliShimPath() {
133
132
  function getPackagedFraimRuntimePath() {
134
133
  return path_1.default.join(process.env.FRAIM_USER_DIR || path_1.default.join(os_1.default.homedir(), '.fraim'), 'bin', 'packaged-fraim-runtime.json');
135
134
  }
136
- function runtimeFromEnv(env = process.env) {
137
- return env.FRAIM_PACKAGED_CLI_EXECUTABLE && env.FRAIM_PACKAGED_CLI_SCRIPT
138
- ? { executable: env.FRAIM_PACKAGED_CLI_EXECUTABLE, script: env.FRAIM_PACKAGED_CLI_SCRIPT }
139
- : null;
140
- }
141
- // Regular Node.js (and ELECTRON_RUN_AS_NODE) cannot see inside Electron's
142
- // .asar archives. For paths like …/app.asar/node_modules/…, verify the
143
- // archive file itself rather than the internal entry.
144
- function existsOrAsarExists(filePath) {
145
- const normalized = filePath.replace(/\\/g, '/');
146
- const asarSep = normalized.indexOf('.asar/');
147
- if (asarSep === -1)
148
- return fs_1.default.existsSync(filePath);
149
- return fs_1.default.existsSync(filePath.slice(0, asarSep + '.asar'.length));
150
- }
151
- function getPackagedFraimRuntime(env = process.env) {
152
- const envRuntime = runtimeFromEnv(env);
153
- if (envRuntime)
154
- return envRuntime;
155
- try {
156
- const raw = JSON.parse(fs_1.default.readFileSync(getPackagedFraimRuntimePath(), 'utf8'));
157
- if (!raw.executable || !raw.script)
158
- return null;
159
- if (!fs_1.default.existsSync(raw.executable) || !existsOrAsarExists(raw.script))
160
- return null;
161
- return { executable: raw.executable, script: raw.script };
162
- }
163
- catch {
164
- return null;
165
- }
166
- }
167
- function hasPackagedFraimRuntime(env = process.env) {
168
- return getPackagedFraimRuntime(env) !== null;
169
- }
170
- function posixSingleQuote(value) {
171
- return `'${value.replace(/'/g, `'\\''`)}'`;
172
- }
173
- function windowsBatchPath(value) {
174
- return value.replace(/%/g, '%%').replace(/"/g, '""');
175
- }
176
- function writePackagedRuntimeMetadata(runtime) {
177
- writeFileIfChanged(getPackagedFraimRuntimePath(), JSON.stringify({
178
- version: PACKAGED_RUNTIME_VERSION,
179
- executable: runtime.executable,
180
- script: runtime.script,
181
- updatedAt: new Date().toISOString(),
182
- }, null, 2) + '\n');
183
- }
184
- function packagedFraimShimSource(command, runtime) {
185
- const { executable, script } = runtime;
186
- const commandArg = command === 'mcp' ? ' mcp' : '';
187
- if (process.platform === 'win32') {
188
- return [
189
- '@echo off',
190
- 'setlocal',
191
- 'set "ELECTRON_RUN_AS_NODE=1"',
192
- `"${windowsBatchPath(executable)}" "${windowsBatchPath(script)}"${commandArg} %*`,
193
- 'exit /b %ERRORLEVEL%',
194
- '',
195
- ].join('\r\n');
196
- }
197
- return [
198
- '#!/usr/bin/env sh',
199
- 'set -eu',
200
- 'export ELECTRON_RUN_AS_NODE=1',
201
- `exec ${posixSingleQuote(executable)} ${posixSingleQuote(script)}${commandArg} "$@"`,
202
- '',
203
- ].join('\n');
204
- }
205
135
  const windowsFraimMcpShimSource = `@echo off
206
136
  setlocal
207
137
  for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
@@ -225,6 +155,9 @@ for /f "delims=" %%D in ('dir /b /ad /o-n "%FRAIM_USER_DIR_VALUE%\\node\\node-v*
225
155
  echo [fraim-mcp] Could not find node on PATH or in "%FRAIM_USER_DIR_VALUE%\\node" 1>&2
226
156
  exit /b 1
227
157
  `;
158
+ const windowsFraimCliShimSource = windowsFraimMcpShimSource
159
+ .replace(/fraim-mcp-latest\.js" %\*/g, 'fraim-mcp-latest.js" --fraim-cli %*')
160
+ .replace(/\[fraim-mcp\]/g, '[fraim]');
228
161
  const windowsFraimNpxShimSource = `@echo off
229
162
  setlocal
230
163
  for %%I in ("%~dp0..") do set "FRAIM_USER_DIR_VALUE=%%~fI"
@@ -246,47 +179,31 @@ for /f "delims=" %%D in ('dir /b /ad /o-n "%FRAIM_USER_DIR_VALUE%\\node\\node-v*
246
179
  echo [fraim-npx] Could not find npx on PATH or in "%FRAIM_USER_DIR_VALUE%\\node" 1>&2
247
180
  exit /b 1
248
181
  `;
249
- const posixFraimMcpShimSource = `#!/usr/bin/env sh
182
+ function posixFraimShimSource(executable) {
183
+ const args = executable === 'node' ? '"$FRAIM_USER_DIR_VALUE/bin/fraim-mcp-latest.js" "$@"' : '"$@"';
184
+ const label = executable === 'node' ? 'fraim-mcp' : 'fraim-npx';
185
+ return `#!/usr/bin/env sh
250
186
  set -eu
251
187
  SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
252
188
  FRAIM_USER_DIR_VALUE=$(dirname "$SHIM_DIR")
253
- LAUNCHER="$FRAIM_USER_DIR_VALUE/bin/fraim-mcp-latest.js"
254
189
 
255
- if command -v node >/dev/null 2>&1; then
256
- exec node "$LAUNCHER" "$@"
190
+ if command -v ${executable} >/dev/null 2>&1; then
191
+ exec ${executable} ${args}
257
192
  fi
258
- if [ -x "$FRAIM_USER_DIR_VALUE/node/bin/node" ]; then
259
- PATH="$FRAIM_USER_DIR_VALUE/node/bin:$PATH"
260
- export PATH
261
- exec "$FRAIM_USER_DIR_VALUE/node/bin/node" "$LAUNCHER" "$@"
262
- fi
263
- if [ -x "$FRAIM_USER_DIR_VALUE/node/node" ]; then
264
- PATH="$FRAIM_USER_DIR_VALUE/node:$PATH"
265
- export PATH
266
- exec "$FRAIM_USER_DIR_VALUE/node/node" "$LAUNCHER" "$@"
267
- fi
268
-
269
- echo "[fraim-mcp] Could not find node on PATH or in $FRAIM_USER_DIR_VALUE/node" >&2
270
- exit 1
271
- `;
272
- const posixFraimNpxShimSource = `#!/usr/bin/env sh
273
- set -eu
274
- SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
275
- FRAIM_USER_DIR_VALUE=$(dirname "$SHIM_DIR")
276
-
277
- if command -v npx >/dev/null 2>&1; then
278
- exec npx "$@"
279
- fi
280
- if [ -x "$FRAIM_USER_DIR_VALUE/node/bin/npx" ]; then
281
- exec "$FRAIM_USER_DIR_VALUE/node/bin/npx" "$@"
282
- fi
283
- if [ -x "$FRAIM_USER_DIR_VALUE/node/npx" ]; then
284
- exec "$FRAIM_USER_DIR_VALUE/node/npx" "$@"
285
- fi
286
-
287
- echo "[fraim-npx] Could not find npx on PATH or in $FRAIM_USER_DIR_VALUE/node" >&2
193
+ # Match the generating host's distribution layout; staging and other architectures
194
+ # cannot match this suffix. Legacy installs remain valid fallback locations.
195
+ for NODE_BIN in "$FRAIM_USER_DIR_VALUE/node"/node-v*-${process.platform}-${process.arch}/bin "$FRAIM_USER_DIR_VALUE/node/bin" "$FRAIM_USER_DIR_VALUE/node"; do
196
+ if [ -x "$NODE_BIN/node" ] && [ -x "$NODE_BIN/${executable}" ]; then
197
+ PATH="$NODE_BIN:$PATH"
198
+ export PATH
199
+ exec "$NODE_BIN/${executable}" ${args}
200
+ fi
201
+ done
202
+
203
+ echo "[${label}] Could not find ${executable} on PATH or in $FRAIM_USER_DIR_VALUE/node" >&2
288
204
  exit 1
289
205
  `;
206
+ }
290
207
  const writeFileIfChanged = (filePath, content, mode) => {
291
208
  if (!fs_1.default.existsSync(filePath) || fs_1.default.readFileSync(filePath, 'utf8') !== content) {
292
209
  fs_1.default.writeFileSync(filePath, content, 'utf8');
@@ -306,30 +223,14 @@ function ensureFraimMcpLatestLauncher() {
306
223
  fs_1.default.mkdirSync(launcherDir, { recursive: true });
307
224
  const fraimMcpShimPath = getFraimMcpShimPath();
308
225
  const fraimNpxShimPath = getFraimNpxShimPath();
309
- const packagedRuntime = getPackagedFraimRuntime();
310
- if (packagedRuntime) {
311
- const fraimCliShimPath = getFraimCliShimPath();
312
- if (runtimeFromEnv()) {
313
- writePackagedRuntimeMetadata(packagedRuntime);
314
- }
315
- writeFileIfChanged(fraimMcpShimPath, packagedFraimShimSource('mcp', packagedRuntime), process.platform === 'win32' ? undefined : 0o755);
316
- writeFileIfChanged(fraimCliShimPath, packagedFraimShimSource('cli', packagedRuntime), process.platform === 'win32' ? undefined : 0o755);
317
- // Issue #1474: keep the npx companion shim in parity with the non-packaged branch below.
318
- // The packaged fraim-mcp shim itself never invokes npx, but git/playwright base MCP servers
319
- // (added later via the connect-mcp skill once a system npm is available) and doctor's
320
- // launch-plan diagnostic both expect this file to exist alongside the stable fraim shim.
321
- // Without it, doctor's "stable shim missing on disk" check could never be satisfied on a
322
- // packaged install, and --fix-mcp/add-ide (which call this same function) could never fix it.
323
- writeFileIfChanged(fraimNpxShimPath, process.platform === 'win32' ? windowsFraimNpxShimSource : posixFraimNpxShimSource, process.platform === 'win32' ? undefined : 0o755);
324
- return {
325
- command: fraimMcpShimPath,
326
- args: [],
327
- path: packagedRuntime.script,
328
- };
329
- }
226
+ const fraimCliShimPath = getFraimCliShimPath();
227
+ fs_1.default.rmSync(getPackagedFraimRuntimePath(), { force: true });
330
228
  writeFileIfChanged(launcherPath, launcherSource, process.platform === 'win32' ? undefined : 0o755);
331
- writeFileIfChanged(fraimMcpShimPath, process.platform === 'win32' ? windowsFraimMcpShimSource : posixFraimMcpShimSource, process.platform === 'win32' ? undefined : 0o755);
332
- writeFileIfChanged(fraimNpxShimPath, process.platform === 'win32' ? windowsFraimNpxShimSource : posixFraimNpxShimSource, process.platform === 'win32' ? undefined : 0o755);
229
+ writeFileIfChanged(fraimMcpShimPath, process.platform === 'win32' ? windowsFraimMcpShimSource : posixFraimShimSource('node'), process.platform === 'win32' ? undefined : 0o755);
230
+ writeFileIfChanged(fraimNpxShimPath, process.platform === 'win32' ? windowsFraimNpxShimSource : posixFraimShimSource('npx'), process.platform === 'win32' ? undefined : 0o755);
231
+ writeFileIfChanged(fraimCliShimPath, process.platform === 'win32' ? windowsFraimCliShimSource : posixFraimShimSource('node')
232
+ .replace(/fraim-mcp-latest\.js" "\$@"/g, 'fraim-mcp-latest.js" --fraim-cli "$@"')
233
+ .replace(/\[fraim-mcp\]/g, '[fraim]'), process.platform === 'win32' ? undefined : 0o755);
333
234
  return {
334
235
  command: fraimMcpShimPath,
335
236
  args: [],
@@ -56,12 +56,7 @@ exports.BASE_MCP_SERVERS = [
56
56
  }
57
57
  ];
58
58
  function activeBaseMCPServers() {
59
- // The packaged desktop owns FRAIM's runtime but intentionally does not bundle
60
- // npm. Keep third-party npx MCP servers out of fresh desktop-generated config;
61
- // users can add them later through connect-mcp when a system npm is available.
62
- return (0, fraim_mcp_latest_launcher_1.hasPackagedFraimRuntime)()
63
- ? exports.BASE_MCP_SERVERS.filter((server) => server.id === 'fraim')
64
- : exports.BASE_MCP_SERVERS;
59
+ return exports.BASE_MCP_SERVERS;
65
60
  }
66
61
  // ============================================================================
67
62
  // PROVIDER MCP SERVER BUILDER (uses provider registry)
@@ -36,12 +36,12 @@ Object.defineProperty(exports, "resolveMachinePathCached", { enumerable: true, g
36
36
  function getManagedNodeRoot() {
37
37
  return path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
38
38
  }
39
- function getPortableNodeBinPath() {
39
+ function getPortableNodeBinPath(platform = process.platform, arch = process.arch) {
40
40
  const nodeRoot = getManagedNodeRoot();
41
- if (process.platform === 'win32') {
41
+ if (platform === 'win32') {
42
42
  if (fs_1.default.existsSync(nodeRoot)) {
43
43
  const extractedDir = fs_1.default.readdirSync(nodeRoot, { withFileTypes: true })
44
- .filter((entry) => entry.isDirectory() && entry.name.startsWith('node-v'))
44
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('node-v') && !entry.name.includes('.staging-'))
45
45
  .sort((a, b) => b.name.localeCompare(a.name))[0];
46
46
  if (extractedDir) {
47
47
  return path_1.default.join(nodeRoot, extractedDir.name);
@@ -49,6 +49,17 @@ function getPortableNodeBinPath() {
49
49
  }
50
50
  return nodeRoot;
51
51
  }
52
+ if (fs_1.default.existsSync(nodeRoot)) {
53
+ const versioned = fs_1.default.readdirSync(nodeRoot, { withFileTypes: true })
54
+ .filter(entry => entry.isDirectory() && /^node-v\d+\.\d+\.\d+-/.test(entry.name)
55
+ && entry.name.endsWith(`-${platform}-${arch}`))
56
+ .sort((a, b) => b.name.localeCompare(a.name, undefined, { numeric: true }));
57
+ for (const entry of versioned) {
58
+ const bin = path_1.default.join(nodeRoot, entry.name, 'bin');
59
+ if (fs_1.default.existsSync(path_1.default.join(bin, 'node')) && fs_1.default.existsSync(path_1.default.join(bin, 'npm')))
60
+ return bin;
61
+ }
62
+ }
52
63
  return path_1.default.join(nodeRoot, 'bin');
53
64
  }
54
65
  function getManagedAgentBinDirs() {
@@ -17,16 +17,11 @@ const path_1 = __importDefault(require("path"));
17
17
  const https_1 = __importDefault(require("https"));
18
18
  const crypto_1 = __importDefault(require("crypto"));
19
19
  const managed_agent_paths_1 = require("./managed-agent-paths");
20
- // Issue #1415 (extended): a genuinely GUI-only FRAIM Hub install — the native installer
21
- // downloaded from the website, never a CLI — has no working `npm`/`npx` anywhere. First-run's
22
- // `embeddedDesktop` branch (session-service.ts) deliberately skips installing one (it only needs
23
- // Electron's own bundled Node for FRAIM's own CLI/MCP shim, via ELECTRON_RUN_AS_NODE), and the
24
- // separate CLI installer's portable-Node bootstrap (scripts/installer/fraim-install-win.template.cmd)
25
- // never runs for a desktop-only user. That left two features silently unable to do their real job
26
- // on such a machine: `hub-app-materializer.ts`'s npm install (this issue), and agent-CLI
27
- // installation (`managed-agent-install.ts`), which also shells out to a bare `npm`.
20
+ // Desktop-only users may have no working system npm/npx. Installer setup, Hub
21
+ // materialization, first-run setup, and agent installation share this managed
22
+ // runtime resolver so each can prepare or reuse standalone Node and npm.
28
23
  //
29
- // This module is the one place that guarantees a real, usable `npm`/`node` exists, for both
24
+ // This module is the one place that guarantees a real, usable `npm`/`node` exists for its
30
25
  // consumers: prefer whatever is already on system PATH (common for technical users), then an
31
26
  // already-downloaded managed runtime (shared with the CLI installer's `~/.fraim/node/` convention
32
27
  // via `managed-agent-paths.ts` — reused, not duplicated, so a user who has ever run the `fraim` CLI
@@ -65,11 +60,14 @@ function findExecutableOnPath(name, pathValue = process.env.PATH) {
65
60
  function hasWorkingNodeAndNpm(dir, platform = process.platform) {
66
61
  return fs_1.default.existsSync(path_1.default.join(dir, npmExecutableName(platform))) && fs_1.default.existsSync(path_1.default.join(dir, nodeExecutableName(platform)));
67
62
  }
68
- /** Whether `dir` (a specific versioned bin dir) holds a complete extraction for exactly `version`. */
63
+ function nodeBinDir(root, platform) {
64
+ return platform === 'win32' ? root : path_1.default.join(root, 'bin');
65
+ }
66
+ /** Whether `dir` (a versioned distribution root) holds a complete extraction for `version`. */
69
67
  function isManagedNodeVersionComplete(dir, version, platform = process.platform) {
70
68
  try {
71
69
  const recorded = fs_1.default.readFileSync(path_1.default.join(dir, 'fraim-managed-node-version'), 'utf8').trim();
72
- return recorded === version && hasWorkingNodeAndNpm(dir, platform);
70
+ return recorded === version && hasWorkingNodeAndNpm(nodeBinDir(dir, platform), platform);
73
71
  }
74
72
  catch {
75
73
  return false;
@@ -99,6 +97,9 @@ function assertPathSafeNodeVersion(version) {
99
97
  }
100
98
  /** The exact folder name Node's own official archive extracts as — also this module's versioned subfolder name under the shared `~/.fraim/node/` root. */
101
99
  function nodeVersionedDirName(request) {
100
+ if (!/^[a-z0-9_]+$/.test(request.arch)) {
101
+ throw new Error('Refusing to use an invalid managed Node.js architecture.');
102
+ }
102
103
  return `node-v${assertPathSafeNodeVersion(request.version)}-${nodePlatformName(request.platform)}-${request.arch}`;
103
104
  }
104
105
  function nodeArchiveFileName(request) {
@@ -203,15 +204,16 @@ const defaultExtractNodeArchive = async (archivePath, destDir, request) => {
203
204
  async function downloadAndPromoteVersionedNodeDir(args) {
204
205
  const { nodeRoot, request, downloadArchive, extractArchive } = args;
205
206
  const destDir = path_1.default.join(nodeRoot, nodeVersionedDirName(request));
206
- const stagingDir = `${destDir}.staging-${process.pid}`;
207
207
  fs_1.default.mkdirSync(nodeRoot, { recursive: true });
208
- fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
209
208
  const archivePath = await downloadArchive(request);
209
+ let stagingDir;
210
210
  try {
211
+ // tar requires an existing cwd. Each invocation owns a separate directory,
212
+ // including simultaneous requests within the same process.
213
+ stagingDir = fs_1.default.mkdtempSync(`${destDir}.staging-${process.pid}-`);
211
214
  await extractArchive(archivePath, stagingDir, request);
212
- // The archive's own top-level folder (matching nodeVersionedDirName) may land either as the
213
- // extraction root itself (tar) or as one nested level inside it (some zip extractors preserve
214
- // the archive's internal folder) — handle both without assuming either.
215
+ // Official tar and ZIP archives preserve their top-level distribution folder.
216
+ // Also accept a flattened root from a supplied extractor.
215
217
  const nestedCandidate = path_1.default.join(stagingDir, nodeVersionedDirName(request));
216
218
  const extractedRoot = fs_1.default.existsSync(nestedCandidate) ? nestedCandidate : stagingDir;
217
219
  fs_1.default.writeFileSync(path_1.default.join(extractedRoot, 'fraim-managed-node-version'), request.version, 'utf8');
@@ -233,9 +235,10 @@ async function downloadAndPromoteVersionedNodeDir(args) {
233
235
  }
234
236
  finally {
235
237
  fs_1.default.rmSync(archivePath, { force: true });
236
- fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
238
+ if (stagingDir)
239
+ fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
237
240
  }
238
- return destDir;
241
+ return nodeBinDir(destDir, request.platform);
239
242
  }
240
243
  /**
241
244
  * Resolve a working `npm` executable, preferring (in order): system PATH, an already-downloaded
@@ -252,7 +255,7 @@ async function ensureManagedNpm(options = {}) {
252
255
  if (systemNpm) {
253
256
  return { npmPath: systemNpm, binDir: path_1.default.dirname(systemNpm), source: 'system' };
254
257
  }
255
- const existingBinDir = (0, managed_agent_paths_1.getPortableNodeBinPath)();
258
+ const existingBinDir = (0, managed_agent_paths_1.getPortableNodeBinPath)(platform, arch);
256
259
  if (hasWorkingNodeAndNpm(existingBinDir, platform)) {
257
260
  return { npmPath: path_1.default.join(existingBinDir, npmExecutableName(platform)), binDir: existingBinDir, source: 'managed-existing' };
258
261
  }
@@ -323,7 +323,7 @@ function buildFirstRunHubLaunchArgs() {
323
323
  class FirstRunSessionService {
324
324
  constructor(options) {
325
325
  // Issue #646: the key may be absent when first-run is launched by the macOS
326
- // installer (.pkg) instead of `fraim first-run --key=…`. In that case the
326
+ // installer (.dmg) instead of `fraim first-run --key=…`. In that case the
327
327
  // wizard prompts the user to paste their key (see setKey / needsKey).
328
328
  this.key = options.key || '';
329
329
  this.headless = options.headless === true;
@@ -393,13 +393,15 @@ class FirstRunSessionService {
393
393
  // Real detection — populate row statuses from `command -v` style probes.
394
394
  const nodeRow = this.getRow('node');
395
395
  const nodeVer = this.embeddedDesktop ? process.version : commandVersion('node');
396
- if (nodeVer) {
396
+ const embeddedLaunchersReady = !this.embeddedDesktop
397
+ || (fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) && fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()));
398
+ if (nodeVer && embeddedLaunchersReady) {
397
399
  nodeRow.status = 'ok';
398
400
  nodeRow.verb = this.embeddedDesktop ? `${nodeVer} bundled with FRAIM` : `${nodeVer} detected`;
399
401
  }
400
402
  else {
401
403
  nodeRow.status = 'pending';
402
- nodeRow.verb = "we'll install";
404
+ nodeRow.verb = this.embeddedDesktop ? 'installer repair required' : "we'll install";
403
405
  }
404
406
  const gitRow = this.getRow('git');
405
407
  const gitVer = commandVersion('git');
@@ -669,11 +671,16 @@ class FirstRunSessionService {
669
671
  async runNodeRow() {
670
672
  const row = this.getRow('node');
671
673
  if (this.embeddedDesktop) {
674
+ if (!this.fakeMode && (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()))) {
675
+ this.setRowError(row, 'Checking the native installer setup', 'Stable FRAIM CLI/MCP launchers are missing. Re-run the native installer in repair mode.', [{ id: 'retry', label: 'Check again', variant: 'primary' }]);
676
+ this.persist();
677
+ return this.respond('FRAIM installer repair is required.', false);
678
+ }
672
679
  persistShellPath(this.embeddedDesktop);
673
680
  row.status = 'ok';
674
- row.verb = `${process.version} bundled with FRAIM`;
681
+ row.verb = 'Node and FRAIM launchers ready';
675
682
  this.persist();
676
- return this.respond(`FRAIM includes its own runtime (${process.version}).`, true);
683
+ return this.respond('FRAIM command-line tools are ready.', true);
677
684
  }
678
685
  // Node is bootstrapped by the install template before the wizard starts.
679
686
  // If node is missing here something is severely wrong; fall through to error.
@@ -737,10 +744,10 @@ class FirstRunSessionService {
737
744
  return this.respond('FRAIM configured.', true);
738
745
  }
739
746
  try {
740
- if (this.embeddedDesktop) {
741
- (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
747
+ if (this.embeddedDesktop && (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)()))) {
748
+ throw new Error('Stable FRAIM launchers are missing. Re-run the native installer in repair mode.');
742
749
  }
743
- else if (!forceConfigure && !commandVersion('fraim')) {
750
+ else if (!this.embeddedDesktop && !forceConfigure && !commandVersion('fraim')) {
744
751
  const prefix = path_1.default.join((0, script_sync_utils_1.getUserFraimDir)(), 'node');
745
752
  fs_1.default.mkdirSync(prefix, { recursive: true });
746
753
  row.streamOutput = 'Installing FRAIM on this machine...';
@@ -1051,12 +1058,11 @@ class FirstRunSessionService {
1051
1058
  };
1052
1059
  }
1053
1060
  if (this.embeddedDesktop) {
1054
- (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
1055
- if (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)())) {
1061
+ if (!fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)()) || !fs_1.default.existsSync((0, fraim_mcp_latest_launcher_1.getFraimMcpShimPath)())) {
1056
1062
  return {
1057
1063
  ok: false,
1058
1064
  needsAgentSetup: true,
1059
- message: 'FRAIM CLI was not installed into the packaged desktop profile. Re-run FRAIM setup before opening Hub.',
1065
+ message: 'FRAIM CLI/MCP launchers are missing. Re-run the native installer in repair mode before opening Hub.',
1060
1066
  };
1061
1067
  }
1062
1068
  appendInstallLog('hub-opened embedded-desktop');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.326",
3
+ "version": "2.0.327",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -168,7 +168,8 @@
168
168
  "shortcutName": "FRAIM Hub",
169
169
  "uninstallDisplayName": "FRAIM Hub",
170
170
  "artifactName": "FRAIM-Setup.${ext}",
171
- "runAfterFinish": true
171
+ "runAfterFinish": false,
172
+ "include": "build/installer.nsh"
172
173
  },
173
174
  "mac": {
174
175
  "icon": "build/icon.icns",
@@ -213,7 +214,7 @@
213
214
  "electron-updater": "^6.8.9",
214
215
  "express": "^5.2.1",
215
216
  "extract-zip": "^2.0.1",
216
- "fraim": "2.0.326",
217
+ "fraim": "2.0.327",
217
218
  "mongodb": "^7.0.0",
218
219
  "node-cron": "4.2.1",
219
220
  "node-edge-tts": "^1.2.10",
@@ -442,6 +442,7 @@
442
442
  <span class="panel-summary-copy">
443
443
  <span class="panel-kicker" id="thread-panel-kicker">Manage</span>
444
444
  </span>
445
+ <button class="conversation-focus-btn" id="conversation-focus-btn" type="button" aria-label="Expand conversation" title="Expand conversation" onclick="event.preventDefault(); event.stopPropagation();">&#8599;</button>
445
446
  </summary>
446
447
  <!-- Issue #1065: find a passage inside the open conversation (R44-R46). A result that
447
448
  promises "+6 more matches" is an unkept promise without this: the manager lands in
@@ -475,7 +476,6 @@
475
476
  </div>
476
477
  </details>
477
478
  <div class="manage-header-actions">
478
- <button class="conversation-focus-btn" id="conversation-focus-btn" type="button" aria-label="Expand conversation" title="Expand conversation" onclick="event.preventDefault(); event.stopPropagation();">&#8599;</button>
479
479
  <button class="micro-manage-toggle" id="micro-manage-toggle" type="button" role="switch" aria-checked="false" aria-controls="manage-body"><span>Micro-Manage</span><span class="micro-switch-track" aria-hidden="true"></span></button>
480
480
  </div>
481
481
  </div>
@@ -114,6 +114,18 @@ const state = {
114
114
 
115
115
  const bootstrapCache = new Map();
116
116
  const conversationBodyFetches = new Map();
117
+ const deletedConversationIds = new Set();
118
+ let activeConversationId = state.activeId;
119
+ Object.defineProperty(state, 'activeId', {
120
+ get() {
121
+ return activeConversationId && deletedConversationIds.has(activeConversationId) ? null : activeConversationId;
122
+ },
123
+ set(value) {
124
+ activeConversationId = value && !deletedConversationIds.has(value) ? value : null;
125
+ },
126
+ enumerable: true,
127
+ configurable: true,
128
+ });
117
129
 
118
130
  const els = {};
119
131
 
@@ -1406,6 +1418,7 @@ function clearConversationBodyPendingFor(conv, fallbackProjectPath, id) {
1406
1418
 
1407
1419
  async function hydrateConversationBody(projectPath, id, options = {}) {
1408
1420
  if (!id) return null;
1421
+ if (deletedConversationIds.has(id)) return null;
1409
1422
  const existing = findConversation(id);
1410
1423
  if (!options.force && conversationHasBody(existing) && !conversationBodyIsStale(existing)) {
1411
1424
  clearConversationBodyPendingFor(existing, projectPath, id);
@@ -1418,7 +1431,9 @@ async function hydrateConversationBody(projectPath, id, options = {}) {
1418
1431
  const promise = (async () => {
1419
1432
  try {
1420
1433
  const payload = await requestJson(`/api/ai-hub/conversations?${params.toString()}`);
1434
+ if (deletedConversationIds.has(id)) return null;
1421
1435
  const body = payload && payload.conversation;
1436
+ if (body && deletedConversationIds.has(body.id)) return null;
1422
1437
  if (!body) throw new Error('Conversation activity is unavailable');
1423
1438
  activityLoadErrors.delete(manageKey(body));
1424
1439
  normalizeGeminiConversationMessages(body);
@@ -1613,7 +1628,8 @@ async function hydrateConversationsFromServer() {
1613
1628
  try {
1614
1629
  for (const [scope, key] of [['manager', MANAGER_CONV_KEY], ['company', COMPANY_CONV_KEY]]) {
1615
1630
  const scopePayload = await requestJson(`/api/ai-hub/conversations?scope=${scope}`);
1616
- const scopeConvs = Array.isArray(scopePayload.conversations) ? scopePayload.conversations : [];
1631
+ const scopeConvs = (Array.isArray(scopePayload.conversations) ? scopePayload.conversations : [])
1632
+ .filter((conv) => conv && !deletedConversationIds.has(conv.id));
1617
1633
  for (const conv of scopeConvs) normalizeGeminiConversationMessages(conv);
1618
1634
  // Only materialize a scope bucket when it has content (or already exists). Creating
1619
1635
  // empty @manager/@company buckets on every project hydrate pollutes state.conversations
@@ -1633,7 +1649,13 @@ async function hydrateConversationsFromServer() {
1633
1649
  const projectPath = state.projectPath;
1634
1650
  const enc = encodeURIComponent(projectPath);
1635
1651
  const pickActive = (list, serverActiveId) => {
1636
- const activeCandidates = [state.activeId, serverActiveId].filter(Boolean);
1652
+ const suppressServerActive = !state.activeId && (state.cpBrowseAll || state.cpSelectedJob);
1653
+ if (suppressServerActive) {
1654
+ state.activeId = null;
1655
+ return;
1656
+ }
1657
+ const activeCandidates = [state.activeId, suppressServerActive ? null : serverActiveId]
1658
+ .filter((id) => id && !deletedConversationIds.has(id));
1637
1659
  // Issue #1408: a manager/company-scoped conversation must not become the Projects
1638
1660
  // workspace active conversation — it's shown in its own Company/Manager panel via
1639
1661
  // tfActiveOrgConv/tfActiveMgrConv instead. Decided by scope, not by job id.
@@ -1647,7 +1669,8 @@ async function hydrateConversationsFromServer() {
1647
1669
  // the selected conversation body. Never fetch the full project body on the interactive path.
1648
1670
  const headerPayload = await requestJson(`/api/ai-hub/conversations?projectPath=${enc}&headersOnly=1`);
1649
1671
  if (state.projectPath !== projectPath) return; // user switched projects mid-load
1650
- const headers = Array.isArray(headerPayload.conversations) ? headerPayload.conversations : [];
1672
+ const headers = (Array.isArray(headerPayload.conversations) ? headerPayload.conversations : [])
1673
+ .filter((conv) => conv && !deletedConversationIds.has(conv.id));
1651
1674
  const mergedHeaders = mergeConversationListHeaders(state.conversations[projectPath] || [], headers);
1652
1675
  state.conversations[projectPath] = mergedHeaders;
1653
1676
  pickActive(mergedHeaders, headerPayload.activeId);
@@ -1668,7 +1691,8 @@ async function hydrateConversationsFromServer() {
1668
1691
  async function hydrateProjectConversationHeaders(projectPath) {
1669
1692
  if (!projectPath) return false;
1670
1693
  const payload = await requestJson(`/api/ai-hub/conversations?projectPath=${encodeURIComponent(projectPath)}&headersOnly=1`);
1671
- const incoming = Array.isArray(payload.conversations) ? payload.conversations : [];
1694
+ const incoming = (Array.isArray(payload.conversations) ? payload.conversations : [])
1695
+ .filter((conv) => conv && !deletedConversationIds.has(conv.id));
1672
1696
  const existing = state.conversations[projectPath] || [];
1673
1697
  const merged = mergeConversationListHeaders(existing, incoming);
1674
1698
  const changed = existing.length !== merged.length
@@ -2037,6 +2061,9 @@ function findConversation(id) {
2037
2061
  }
2038
2062
 
2039
2063
  function activeConversation() {
2064
+ if (state.activeId && deletedConversationIds.has(state.activeId)) {
2065
+ state.activeId = null;
2066
+ }
2040
2067
  if (!state.activeId) return null;
2041
2068
  return findConversation(state.activeId);
2042
2069
  }
@@ -2380,11 +2407,20 @@ async function deleteConversation(id) {
2380
2407
  const target = findConversation(id);
2381
2408
  if (!target) return false;
2382
2409
  const params = conversationLookupParams(target, target.projectPath);
2410
+ // Tombstone before the async delete so any in-flight header/body hydrate that
2411
+ // resolves during the server round trip cannot reselect or reinsert this run.
2412
+ deletedConversationIds.add(id);
2413
+ if (activeConversationId === id || state.activeId === id) {
2414
+ activeConversationId = null;
2415
+ state.activeId = null;
2416
+ if (state.pollHandle) { window.clearInterval(state.pollHandle); state.pollHandle = null; }
2417
+ }
2383
2418
  try {
2384
2419
  await requestJson(`/api/ai-hub/conversations/${encodeURIComponent(id)}?${params.toString()}`, {
2385
2420
  method: 'DELETE',
2386
2421
  });
2387
2422
  } catch (error) {
2423
+ deletedConversationIds.delete(id);
2388
2424
  showStatus(error instanceof Error ? error.message : String(error), true);
2389
2425
  return false;
2390
2426
  }
@@ -2394,16 +2430,19 @@ async function deleteConversation(id) {
2394
2430
  const next = list.filter((c) => c && c.id !== id);
2395
2431
  if (next.length !== list.length) { state.conversations[key] = next; removed = true; }
2396
2432
  }
2397
- if (!removed) return false;
2398
- manageViews.delete(manageKey(target));
2399
- microArchiveViews.delete(manageKey(target));
2400
- activityLoadErrors.delete(manageKey(target));
2401
- if (state.activeId === id) {
2433
+ const clearDeletedActive = () => {
2434
+ if (!(activeConversationId === id || state.activeId === id || deletedConversationIds.has(activeConversationId) || deletedConversationIds.has(state.activeId))) return;
2435
+ activeConversationId = null;
2402
2436
  state.activeId = null;
2403
2437
  // Deleting the active run: stop its poll so no zombie interval keeps ticking
2404
2438
  // against a conversation that no longer exists.
2405
2439
  if (state.pollHandle) { window.clearInterval(state.pollHandle); state.pollHandle = null; }
2406
- }
2440
+ };
2441
+ clearDeletedActive();
2442
+ if (!removed) return false;
2443
+ manageViews.delete(manageKey(target));
2444
+ microArchiveViews.delete(manageKey(target));
2445
+ activityLoadErrors.delete(manageKey(target));
2407
2446
  if (state.panelState) delete state.panelState[id];
2408
2447
  // Drop any learning-run pointer that referenced this conversation.
2409
2448
  if (typeof tfLearningRunByScope === 'object' && tfLearningRunByScope) {
@@ -2413,6 +2452,7 @@ async function deleteConversation(id) {
2413
2452
  }
2414
2453
  if (typeof refreshStatusSurfaces === 'function') refreshStatusSurfaces();
2415
2454
  if (typeof renderActive === 'function') renderActive();
2455
+ clearDeletedActive();
2416
2456
  if (typeof showStatus === 'function') showStatus('Run deleted.', false);
2417
2457
  return true;
2418
2458
  }
@@ -7938,6 +7978,7 @@ function parseCpSearch(text) {
7938
7978
  function focusComposerForDelegate(opts) {
7939
7979
  state.cpActionContext = (opts && opts.actionContext) || managementActionContext(state.pendingNextJobArea || undefined);
7940
7980
  state.activeId = null;
7981
+ persistActiveConversationSelection(null, { projectPath: state.projectPath || '' });
7941
7982
  state.showProjectInfo = false;
7942
7983
  if (tf.area === 'company' || tf.area === 'manager') {
7943
7984
  tf.areaSelection[tf.area] = 'delegate';
@@ -8036,6 +8077,9 @@ function renderComposerSuggestions(searchText) {
8036
8077
  // and never reaches this function. This is the only thing that changes
8037
8078
  // across every path; the box itself never does.
8038
8079
  async function sendFromComposer() {
8080
+ const submitDelegate = (typeof window !== 'undefined' && typeof window.submitComposerDelegate === 'function')
8081
+ ? window.submitComposerDelegate
8082
+ : submitComposerDelegate;
8039
8083
  if (!state.activeId) {
8040
8084
  // Issue #989: a next-job-recommendation chip can pre-select a job (and
8041
8085
  // pre-fill instructions) without going through the suggestion list at
@@ -8053,7 +8097,7 @@ async function sendFromComposer() {
8053
8097
  }
8054
8098
  const job = state.cpSelectedJob;
8055
8099
  state.cpSelectedJob = null;
8056
- await submitComposerDelegate(job, text);
8100
+ await submitDelegate(job, text);
8057
8101
  return;
8058
8102
  }
8059
8103
  if (state.cpHighlightIndex >= 0 && state.cpRows[state.cpHighlightIndex]) {
@@ -8061,7 +8105,7 @@ async function sendFromComposer() {
8061
8105
  } else {
8062
8106
  const text = els['coach-text'].value.trim();
8063
8107
  if (!text) return;
8064
- await submitComposerDelegate({ id: '__freeform__', title: 'Ad-hoc task', intent: '' }, text);
8108
+ await submitDelegate({ id: '__freeform__', title: 'Ad-hoc task', intent: '' }, text);
8065
8109
  }
8066
8110
  return;
8067
8111
  }
@@ -11452,6 +11496,12 @@ function convFindOpen(term) {
11452
11496
  convFind.term = cleaned;
11453
11497
  convFind.index = 0;
11454
11498
  convFindApply({ scrollToCurrent: true });
11499
+ setTimeout(() => {
11500
+ if (convFind.open && convFind.term === cleaned) convFindApply({ scrollToCurrent: true });
11501
+ }, 100);
11502
+ setTimeout(() => {
11503
+ if (convFind.open && convFind.term === cleaned) convFindApply({ scrollToCurrent: true });
11504
+ }, 300);
11455
11505
  }
11456
11506
 
11457
11507
  function convFindClose() {
@@ -11562,8 +11612,31 @@ function convFindHighlightCurrent(scrollIntoView) {
11562
11612
  hits.forEach((hit, index) => hit.classList.toggle('on', index === convFind.index));
11563
11613
  const current = hits[convFind.index];
11564
11614
  if (current && scrollIntoView) {
11615
+ const scrollCurrentIntoMessages = () => {
11616
+ let host = document.getElementById('messages');
11617
+ for (let node = current.parentElement; node && node !== document.body; node = node.parentElement) {
11618
+ if (node.scrollHeight > node.clientHeight + 1) {
11619
+ host = node;
11620
+ break;
11621
+ }
11622
+ if (node.id === 'thread-panel') break;
11623
+ }
11624
+ if (!host) return;
11625
+ const hostBox = host.getBoundingClientRect();
11626
+ const currentBox = current.getBoundingClientRect();
11627
+ const targetTop = host.scrollTop
11628
+ + (currentBox.top - hostBox.top)
11629
+ - (host.clientHeight / 2)
11630
+ + (currentBox.height / 2);
11631
+ host.scrollTop = Math.max(0, targetTop);
11632
+ };
11565
11633
  // The thread panel scrolls, and a match can be hundreds of messages down (R45).
11566
- current.scrollIntoView({ block: 'center', inline: 'nearest' });
11634
+ // scrollIntoView can target the page/details wrapper in Chromium; move the
11635
+ // actual overflowing message ancestor directly.
11636
+ scrollCurrentIntoMessages();
11637
+ requestAnimationFrame(scrollCurrentIntoMessages);
11638
+ setTimeout(scrollCurrentIntoMessages, 0);
11639
+ setTimeout(scrollCurrentIntoMessages, 50);
11567
11640
  }
11568
11641
  }
11569
11642
 
@@ -18365,4 +18438,5 @@ if (typeof window !== 'undefined') {
18365
18438
  // Issue #1067: let the suite trigger the create-employee form so scope picker
18366
18439
  // tests can assert the rendered controls without depending on click coordinates.
18367
18440
  window.openInlineCreateEmployee = openInlineCreateEmployee;
18441
+ window.submitComposerDelegate = submitComposerDelegate;
18368
18442
  }
@@ -5738,10 +5738,11 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5738
5738
  .hub-cobrand[hidden] { display: none; }
5739
5739
  .hub-cobrand-mark { width: 17px; height: 17px; border-radius: 5px; display: block; }
5740
5740
  .hub-cobrand-mark img { width: 100%; height: 100%; object-fit: contain; display: block; border-radius: 5px; }
5741
- /* Tablet-width navs cannot hold the company lockup, tabs, search, co-mark, and
5742
- account control together. Keep the functional controls and shed the co-mark. */
5741
+ /* Tablet-width navs cannot hold the company lockup, tabs, search, co-mark,
5742
+ update badge, and account control together. Keep the avatar reachable. */
5743
5743
  @media (max-width: 820px) {
5744
5744
  .hub-cobrand { display: none; }
5745
+ .hub-update-badge { display: none; }
5745
5746
  }
5746
5747
  /* Very narrow windows (the Hub is desktop/Electron-first, but keep the dense nav
5747
5748
  from forcing horizontal page scroll): drop the co-mark and the company name,
@@ -5751,12 +5752,6 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5751
5752
  .hub-brand { padding: 0 2px 0 10px; }
5752
5753
  .hub-brand-divider { margin: 0 4px 0 8px; }
5753
5754
  .hub-tab { padding: 10px 12px; }
5754
- /* #1379: the badge's own text ("Updates available") is exactly what forced the same
5755
- horizontal-scroll problem this block already exists to prevent - measured at 375px:
5756
- 95px of overflow with the badge shown, none without it. Same treatment as the
5757
- co-mark above: the update is still reachable via #avatar-btn -> #am-update at this
5758
- width, just not duplicated in the top bar where there's no room for it. */
5759
- .hub-update-badge { display: none; }
5760
5755
  }
5761
5756
 
5762
5757
  /* Brand editor (Company tab → Brand accordion). */
@@ -5778,6 +5773,20 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5778
5773
  [data-theme="dark"] .brand-editor-actions .send-button { color: var(--bg); }
5779
5774
  .brand-preview { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; margin-top: 14px; }
5780
5775
  .brand-preview-cap { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: 10px 14px; background: var(--bg); border-bottom: 1px solid var(--line); }
5776
+ .brand-preview .hub-tabs { width: 100%; min-width: 0; max-width: 100%; overflow: hidden; }
5777
+ .brand-preview .hub-brand { min-width: 0; }
5778
+ .brand-preview .hub-brand-name { max-width: min(220px, 24vw); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
5779
+ .brand-preview .nav-right { min-width: 0; flex-shrink: 0; }
5780
+ @media (max-width: 820px) {
5781
+ .brand-preview .hub-cobrand { display: none; }
5782
+ .brand-preview .hub-brand-name { max-width: 120px; }
5783
+ .brand-preview .hub-tab { padding-left: 12px; padding-right: 12px; }
5784
+ }
5785
+ @media (max-width: 700px) {
5786
+ .brand-preview .hub-brand-name { display: none; }
5787
+ .brand-preview .hub-brand { padding-left: 8px; }
5788
+ .brand-preview .hub-tab { padding-left: 10px; padding-right: 10px; }
5789
+ }
5781
5790
 
5782
5791
  /* ── New-project: folder picker row ── */
5783
5792
  .np-folder-row { display:flex; gap:8px; }