fraim-hub 2.0.278 → 2.0.280

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.
@@ -0,0 +1,30 @@
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.resolveBundledAssetPath = resolveBundledAssetPath;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ /**
10
+ * Resolves a bundled asset that ships at a different relative depth depending on how the
11
+ * Hub is launched (`npx fraim-hub` unpacked, `npm run hub:desktop`, or a packaged app).
12
+ *
13
+ * Checks, in order: relative to the current working directory, then relative to
14
+ * `moduleDir` (pass `__dirname` from the caller) two and three levels up - the depths
15
+ * `dist/src/ai-hub/` sits at relative to a package root. Returns the first path that
16
+ * exists on disk, or null if none do, so callers can fall back rather than crash on a
17
+ * packaging gap.
18
+ *
19
+ * No Electron import here on purpose: `electron` resolves to a plain path string outside
20
+ * an Electron process, so anything that touches it can only run inside one. Keeping this
21
+ * resolution logic Electron-free is what makes it unit-testable with plain `node:test`.
22
+ */
23
+ function resolveBundledAssetPath(relativePath, moduleDir, cwd = process.cwd()) {
24
+ const candidates = [
25
+ path_1.default.resolve(cwd, relativePath),
26
+ path_1.default.resolve(moduleDir, '..', '..', relativePath),
27
+ path_1.default.resolve(moduleDir, '..', '..', '..', relativePath),
28
+ ];
29
+ return candidates.find((c) => fs_1.default.existsSync(c)) ?? null;
30
+ }
@@ -15,6 +15,7 @@ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
15
15
  const version_utils_1 = require("../cli/utils/version-utils");
16
16
  const hub_runtime_file_1 = require("./hub-runtime-file");
17
17
  const window_open_decision_1 = require("./window-open-decision");
18
+ const bundled_asset_resolver_1 = require("./bundled-asset-resolver");
18
19
  // ---------------------------------------------------------------------------
19
20
  // State
20
21
  // ---------------------------------------------------------------------------
@@ -61,19 +62,22 @@ function applyUserDataOverride() {
61
62
  electron_1.app.setPath('userData', userDataDir);
62
63
  }
63
64
  // ---------------------------------------------------------------------------
64
- // Tray icon resolution prefers bundled icon, falls back to a 1×1 empty image
65
- // so the app never crashes if assets aren't present.
65
+ // App icon — the FRAIM logo (#1329), used for the window/taskbar/dock icon
66
+ // and, downscaled, the system tray. Falls back to Electron's own default
67
+ // (window/dock) or an empty image (tray) so a packaging gap never crashes
68
+ // the app. Path resolution itself lives in bundled-asset-resolver.ts, which
69
+ // has no Electron import and so can be unit-tested directly.
66
70
  // ---------------------------------------------------------------------------
71
+ const APP_ICON_RELATIVE_PATH = 'public/ai-hub/fraim-icon.png';
72
+ function resolveAppIcon() {
73
+ const iconPath = (0, bundled_asset_resolver_1.resolveBundledAssetPath)(APP_ICON_RELATIVE_PATH, __dirname);
74
+ return iconPath ? electron_1.nativeImage.createFromPath(iconPath) : null;
75
+ }
67
76
  function resolveTrayIcon() {
68
- const candidates = [
69
- path_1.default.resolve(process.cwd(), 'extensions/office-word/icon-64.png'),
70
- path_1.default.resolve(__dirname, '..', '..', 'extensions/office-word/icon-64.png'),
71
- path_1.default.resolve(__dirname, '..', '..', '..', 'extensions/office-word/icon-64.png'),
72
- ];
73
- for (const c of candidates) {
74
- if (fs_1.default.existsSync(c))
75
- return electron_1.nativeImage.createFromPath(c).resize({ width: 16, height: 16 });
76
- }
77
+ const iconPath = (0, bundled_asset_resolver_1.resolveBundledAssetPath)(APP_ICON_RELATIVE_PATH, __dirname)
78
+ ?? (0, bundled_asset_resolver_1.resolveBundledAssetPath)('extensions/office-word/icon-64.png', __dirname);
79
+ if (iconPath)
80
+ return electron_1.nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 });
77
81
  return electron_1.nativeImage.createEmpty();
78
82
  }
79
83
  // ---------------------------------------------------------------------------
@@ -210,6 +214,7 @@ async function createWindow(url, runtimeId = process.env.FRAIM_HUB_RUNTIME_ID ||
210
214
  const isWin = process.platform === 'win32';
211
215
  mainWindow = new electron_1.BrowserWindow({
212
216
  title: displayName(runtimeId),
217
+ icon: resolveAppIcon() ?? undefined,
213
218
  width,
214
219
  height,
215
220
  minWidth: 1200,
@@ -407,6 +412,14 @@ async function bootstrap() {
407
412
  });
408
413
  await electron_1.app.whenReady();
409
414
  electron_1.app.setName(displayName(options.runtimeId));
415
+ // macOS reads the dock icon from the app bundle once packaged, but an
416
+ // unpackaged `npm run hub:desktop` / `npx fraim-hub` run shows Electron's
417
+ // own icon there unless we set it explicitly (#1329).
418
+ if (process.platform === 'darwin') {
419
+ const dockIcon = resolveAppIcon();
420
+ if (dockIcon)
421
+ electron_1.app.dock?.setIcon(dockIcon);
422
+ }
410
423
  // First-launch housekeeping (idempotent, fast on subsequent runs)
411
424
  ensureLoginItem();
412
425
  configureAutoUpdater();
@@ -3,11 +3,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.createHubEvent = exports.createHubMessage = exports.ScriptedHostRuntime = exports.FakeHostRuntime = exports.CliHostRuntime = void 0;
6
+ exports.createHubEvent = exports.createHubMessage = exports.ScriptedHostRuntime = exports.FakeHostRuntime = exports.CliHostRuntime = exports.COMPACT_TASK_FAILURE_PATTERN = void 0;
7
7
  exports.parseSeekMentoringSignal = parseSeekMentoringSignal;
8
8
  exports.parseFraimJobLoadSignal = parseFraimJobLoadSignal;
9
9
  exports.parseUsageSignal = parseUsageSignal;
10
10
  exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
11
+ exports.escapeWindowsArg = escapeWindowsArg;
12
+ exports.resolveHostInvocation = resolveHostInvocation;
11
13
  exports.__setAgentAvailabilityPathForTests = __setAgentAvailabilityPathForTests;
12
14
  exports.invalidateEmployeeDetectionCache = invalidateEmployeeDetectionCache;
13
15
  exports.__clearEmployeeDetectionMemoryCacheForTests = __clearEmployeeDetectionMemoryCacheForTests;
@@ -25,6 +27,9 @@ exports.supportsDirectPath = supportsDirectPath;
25
27
  exports.buildDirectStartPlan = buildDirectStartPlan;
26
28
  exports.buildDirectContinuePlan = buildDirectContinuePlan;
27
29
  exports.parseHostLine = parseHostLine;
30
+ exports.detectCodexCompactTaskFailure = detectCodexCompactTaskFailure;
31
+ exports.describeSpawnError = describeSpawnError;
32
+ exports.buildHostSpawnEnv = buildHostSpawnEnv;
28
33
  exports.findGeminiSessionIdForPrompt = findGeminiSessionIdForPrompt;
29
34
  const crypto_1 = require("crypto");
30
35
  const child_process_1 = require("child_process");
@@ -34,6 +39,7 @@ const os_1 = __importDefault(require("os"));
34
39
  const path_1 = __importDefault(require("path"));
35
40
  const manager_turns_1 = require("./manager-turns");
36
41
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
42
+ const command_resolution_1 = require("../cli/mcp/command-resolution");
37
43
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
38
44
  const configured_agents_1 = require("./configured-agents");
39
45
  const pack_home_1 = require("../cli/utils/pack-home");
@@ -816,32 +822,35 @@ function quoteWindowsArg(value) {
816
822
  }
817
823
  return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"`;
818
824
  }
825
+ // Exported for reuse by server.ts's hubCommandVersion(), which builds a
826
+ // similar single cmd.exe command-line string around a resolved absolute path.
819
827
  function escapeWindowsArg(value) {
820
828
  return /[\s"]/u.test(value) ? quoteWindowsArg(value) : value;
821
829
  }
830
+ // Issue #1284/#1285 (Implementation Strategy §0): resolve the real agent CLI
831
+ // binary fresh at every launch instead of handing a bare command name to
832
+ // `spawn`/`cmd.exe` and trusting whatever PATH the process happens to have.
833
+ // Covers every call site that builds an invocation through this function:
834
+ // spawnHostProcess (real job launches) and both version-probe helpers.
835
+ // Exported for direct unit testing.
822
836
  function resolveHostInvocation(plan) {
837
+ const resolvedPlan = { ...plan, command: (0, command_resolution_1.resolveManagedCommand)(plan.command) };
823
838
  if (process.platform !== 'win32') {
824
- return plan;
839
+ return resolvedPlan;
825
840
  }
826
- const [command, ...args] = [plan.command, ...plan.args];
841
+ const [command, ...args] = [resolvedPlan.command, ...resolvedPlan.args];
842
+ // Issue #1284/#1285: `command` can now be an absolute path resolved by
843
+ // resolveManagedCommand() (e.g. a temp/user directory containing a space),
844
+ // not just a bare command name — it needs the same escaping the args
845
+ // already get before joining into the single cmd.exe command-line string.
827
846
  return {
828
847
  command: 'cmd.exe',
829
- args: ['/d', '/s', '/c', [command, ...args.map(escapeWindowsArg)].join(' ')],
848
+ args: ['/d', '/s', '/c', [command, ...args].map(escapeWindowsArg).join(' ')],
830
849
  };
831
850
  }
832
- function stripProjectLocalNodeBinDirs(basePath) {
833
- return (basePath ?? '')
834
- .split(path_1.default.delimiter)
835
- .filter(Boolean)
836
- .filter((entry) => {
837
- const normalized = path_1.default.normalize(entry).toLowerCase();
838
- return !normalized.endsWith(`${path_1.default.sep}node_modules${path_1.default.sep}.bin`);
839
- })
840
- .join(path_1.default.delimiter);
841
- }
842
851
  function buildAgentVersionProbePath(basePath) {
843
852
  const withoutManaged = (0, managed_agent_paths_1.stripManagedAgentBinDirsFromPath)(basePath);
844
- const withoutProjectBins = stripProjectLocalNodeBinDirs(withoutManaged);
853
+ const withoutProjectBins = (0, managed_agent_paths_1.stripProjectLocalNodeBinDirs)(withoutManaged);
845
854
  return (0, managed_agent_paths_1.appendBinDirsToPath)(withoutProjectBins, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
846
855
  }
847
856
  // Single source for the probe environment, shared by the sync and async probes so the two
@@ -1795,6 +1804,26 @@ function scrubGeminiAssistantLine(line) {
1795
1804
  const scrubbed = line.replace(/\[Thought:\s*true\]\s*/gi, '').trim();
1796
1805
  return scrubbed.length > 0 ? scrubbed : null;
1797
1806
  }
1807
+ // Issue #1284: Codex's own error text for a rejected remote context-compaction
1808
+ // call, e.g. "Error running remote compact task: unexpected status 404 Not
1809
+ // Found: ...". Exported so `server.ts`'s `classifyExit()` can recognize a
1810
+ // repeat of this exact signature from `run.lastHostError` without duplicating
1811
+ // the pattern.
1812
+ exports.COMPACT_TASK_FAILURE_PATTERN = /Error running remote compact task/;
1813
+ // Issue #1284 (Defect C): Codex reports a remote compact-endpoint failure as
1814
+ // plain stderr text, not one of the JSON shapes `parseHostLine` understands,
1815
+ // so it silently fell through to `{ raw }` with no `hostError`/classification
1816
+ // signal at all — the run then retried blindly against the same doomed call.
1817
+ // Surfacing it as `hostError` here lets `classifyExit()` recognize and
1818
+ // short-circuit a repeat instead of exhausting the retry budget. A pure,
1819
+ // directly-testable function, matching the shape of `parseSeekMentoringSignal`
1820
+ // et al. rather than inlining this check into `wireHostProcess`'s closure.
1821
+ function detectCodexCompactTaskFailure(line) {
1822
+ if (exports.COMPACT_TASK_FAILURE_PATTERN.test(line) && /status 4\d\d/.test(line)) {
1823
+ return { hostError: line, raw: line };
1824
+ }
1825
+ return null;
1826
+ }
1798
1827
  function wireHostProcess(hostId, child, handlers) {
1799
1828
  const wire = (buffer, channel) => {
1800
1829
  let pending = '';
@@ -1815,6 +1844,11 @@ function wireHostProcess(hostId, child, handlers) {
1815
1844
  if (suppressCodexModelRefreshStderr) {
1816
1845
  continue;
1817
1846
  }
1847
+ const compactFailure = detectCodexCompactTaskFailure(line);
1848
+ if (compactFailure) {
1849
+ handlers.onEvent(compactFailure, channel);
1850
+ continue;
1851
+ }
1818
1852
  }
1819
1853
  const parsed = parseHostLine(hostId, line);
1820
1854
  if (parsed.message || parsed.sessionId || parsed.raw) {
@@ -1833,16 +1867,55 @@ function wireHostProcess(hostId, child, handlers) {
1833
1867
  };
1834
1868
  wire(child.stdout, 'stdout');
1835
1869
  wire(child.stderr, 'stderr');
1836
- child.on('close', (code) => handlers.onExit(code));
1870
+ // Issue #1327: preserve signal information so SIGSEGV kills (code=null,
1871
+ // signal='SIGSEGV') are NOT treated as clean exits by classifyExit's
1872
+ // `exitCode ?? 0` guard. Pass -1 for any signal-terminated child so the
1873
+ // non-zero branch fires and recovery is attempted.
1874
+ child.on('close', (code, signal) => handlers.onExit(signal ? -1 : code));
1875
+ // Issue #1327: without this listener, an OS-level spawn failure (EAGAIN,
1876
+ // ENOENT, EACCES) fires an 'error' event on the ChildProcess. An unhandled
1877
+ // EventEmitter 'error' throws synchronously and crashes the Hub process.
1878
+ // Surface the failure as a human-readable hostError so the run's terminal
1879
+ // message names the cause. 'close' still fires after 'error' for spawn
1880
+ // failures, so handlers.onExit is called by the close listener above.
1881
+ child.on('error', (err) => {
1882
+ handlers.onEvent({ hostError: describeSpawnError(err) }, 'system');
1883
+ });
1837
1884
  return child;
1838
1885
  }
1886
+ function describeSpawnError(err) {
1887
+ if (err.code === 'ENOENT') {
1888
+ return `Agent binary not found${err.path ? ` (${err.path})` : ''}. Ensure the agent CLI is installed and on PATH.`;
1889
+ }
1890
+ if (err.code === 'EACCES') {
1891
+ return `Permission denied starting agent process${err.path ? ` (${err.path})` : ''}. Check executable permissions.`;
1892
+ }
1893
+ if (err.code === 'EAGAIN') {
1894
+ return `Could not start agent process: system resources temporarily unavailable (EAGAIN). The OS process limit may have been reached; try again in a moment.`;
1895
+ }
1896
+ return `Failed to start agent process: ${err.message}`;
1897
+ }
1898
+ // Issue #1284/#1285 (Defect D): the version-probe path already excludes
1899
+ // project-local `node_modules/.bin` shims via `versionProbeEnv()`; the real
1900
+ // job-spawn path built `env.PATH` straight from `process.env`/`plan.env` with
1901
+ // no such filtering, so a poisoned PATH silently ran the wrong binary on the
1902
+ // one path that actually executes jobs. Reuse the exact same construction the
1903
+ // probe uses so the two can never disagree again.
1904
+ // Exported for direct unit testing of the Defect D fix (issue #1284/#1285):
1905
+ // asserting spawnHostProcess's environment construction matches
1906
+ // versionProbeEnv()'s filtering without spawning a real child process.
1907
+ function buildHostSpawnEnv(plan) {
1908
+ const merged = plan.env ? { ...process.env, ...plan.env } : { ...process.env };
1909
+ const resolvedPath = buildAgentVersionProbePath(merged.PATH ?? merged.Path);
1910
+ return { ...merged, PATH: resolvedPath, Path: resolvedPath };
1911
+ }
1839
1912
  function spawnHostProcess(hostId, plan, projectPath, handlers) {
1840
1913
  const invocation = resolveHostInvocation(plan);
1841
1914
  const startedAtMs = Date.now();
1842
1915
  const child = (0, child_process_1.spawn)(invocation.command, invocation.args, {
1843
1916
  cwd: projectPath,
1844
1917
  stdio: ['pipe', 'pipe', 'pipe'],
1845
- env: plan.env ? { ...process.env, ...plan.env } : process.env,
1918
+ env: buildHostSpawnEnv(plan),
1846
1919
  });
1847
1920
  if (typeof plan.stdin === 'string') {
1848
1921
  child.stdin.write(plan.stdin);
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
40
  exports.configureFraimForHubAgent = configureFraimForHubAgent;
41
+ exports.hubCommandVersion = hubCommandVersion;
41
42
  exports.buildOpenFileInvocation = buildOpenFileInvocation;
42
43
  const express_1 = __importDefault(require("express"));
43
44
  const path_1 = __importDefault(require("path"));
@@ -55,6 +56,7 @@ const job_visualization_1 = require("../core/job-visualization");
55
56
  const custom_employees_1 = require("./custom-employees");
56
57
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
57
58
  const hosts_1 = require("./hosts");
59
+ const command_resolution_1 = require("../cli/mcp/command-resolution");
58
60
  const configured_agents_1 = require("./configured-agents");
59
61
  const host_session_state_1 = require("./host-session-state");
60
62
  const url_safety_1 = require("./url-safety");
@@ -69,6 +71,8 @@ const restart_recovery_policy_1 = require("./restart-recovery-policy");
69
71
  const remote_hub_gateway_1 = require("./remote-hub-gateway");
70
72
  const managed_browser_1 = require("./managed-browser");
71
73
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
74
+ const managed_agent_install_1 = require("../cli/utils/managed-agent-install");
75
+ const agent_cli_health_checks_1 = require("../cli/doctor/checks/agent-cli-health-checks");
72
76
  const user_config_1 = require("../cli/utils/user-config");
73
77
  const version_utils_1 = require("../cli/utils/version-utils");
74
78
  const hub_latest_version_1 = require("./hub-latest-version");
@@ -1499,17 +1503,26 @@ async function configureFraimForHubAgent(hubId) {
1499
1503
  return { configured: false, error: e instanceof Error ? e.message : String(e) };
1500
1504
  }
1501
1505
  }
1506
+ // Exported for direct unit testing of the Defect D fix (issue #1284/#1285):
1507
+ // asserting this resolves via a real system install and skips a poisoned
1508
+ // project-local node_modules/.bin entry, without spinning up a full server.
1502
1509
  function hubCommandVersion(command, extraBinDirs, basePath) {
1503
1510
  if (process.env.NODE_ENV === 'test' && process.env.FRAIM_TEST_HUB_COMMAND_VERSION_EMPTY === '1') {
1504
1511
  return null;
1505
1512
  }
1506
- const executable = process.platform === 'win32' ? 'cmd.exe' : command;
1507
- const args = process.platform === 'win32'
1508
- ? ['/d', '/s', '/c', `${command} --version`]
1509
- : ['--version'];
1510
1513
  const pathValue = extraBinDirs && extraBinDirs.length > 0
1511
1514
  ? (0, managed_agent_paths_1.appendBinDirsToPath)(basePath ?? process.env.PATH, extraBinDirs)
1512
1515
  : basePath;
1516
+ // Issue #1284/#1285 (Defect D, §0): resolve to a real absolute path — with
1517
+ // project-local node_modules/.bin stripped — instead of handing the bare
1518
+ // command name to cmd.exe's own PATH search, which does not exclude those
1519
+ // shims. Falls back to the bare command (today's behavior, resolves to
1520
+ // null below) when no system install is found on the constructed PATH.
1521
+ const resolvedCommand = (0, command_resolution_1.getSystemCommandPath)(command, pathValue ?? process.env.PATH) || command;
1522
+ const executable = process.platform === 'win32' ? 'cmd.exe' : resolvedCommand;
1523
+ const args = process.platform === 'win32'
1524
+ ? ['/d', '/s', '/c', `${(0, hosts_1.escapeWindowsArg)(resolvedCommand)} --version`]
1525
+ : ['--version'];
1513
1526
  const env = pathValue === undefined ? undefined : { ...process.env, PATH: pathValue };
1514
1527
  const result = (0, child_process_1.spawnSync)(executable, args, { encoding: 'utf8', timeout: 5000, ...(env ? { env } : {}) });
1515
1528
  if (result.status !== 0 || result.error)
@@ -1893,6 +1906,22 @@ function isHumanActionGate(run) {
1893
1906
  const lastEntry = phaseHistory.length > 0 ? phaseHistory[phaseHistory.length - 1] : null;
1894
1907
  return lastEntry?.latestStatus === 'incomplete' || lastEntry?.latestStatus === 'failure';
1895
1908
  }
1909
+ // Issue #1284 (Defect C): a 404/4xx on Codex's remote context-compaction
1910
+ // endpoint means the installed Codex build's compact request is not accepted
1911
+ // by the current backend (a version incompatibility) — not a transient
1912
+ // failure blind retry can recover from. Reads the most recent host error
1913
+ // already captured on the run (recordHostEvent sets `run.lastHostError` from
1914
+ // the stderr signal wireHostProcess raises for this exact line).
1915
+ function detectCompactFailureSignature(run) {
1916
+ const error = run.lastHostError;
1917
+ if (error && hosts_1.COMPACT_TASK_FAILURE_PATTERN.test(error) && /status 4\d\d/.test(error)) {
1918
+ return 'compact-4xx';
1919
+ }
1920
+ return null;
1921
+ }
1922
+ const COMPACT_INCOMPATIBLE_NOTE = "Codex's remote context-compaction call was rejected by the backend (404). "
1923
+ + 'This usually means the installed Codex CLI is out of date. Run `codex update`, confirm `codex --version` '
1924
+ + 'matches the latest release, and retry.';
1896
1925
  // #1275: true when the host reported an active local_bash task. These are
1897
1926
  // intentional async continuations — the agent ended its turn knowing bash was
1898
1927
  // running, intending to check results in the next turn. In -p mode Claude Code
@@ -1928,11 +1957,21 @@ function classifyExit(run, exitCode) {
1928
1957
  if (run.stoppedByUser) {
1929
1958
  return { action: 'park', pauseReason: 'stopped' };
1930
1959
  }
1931
- const code = exitCode ?? 0;
1960
+ // Issue #1327: null exit code means the child was killed by a signal (e.g.
1961
+ // SIGSEGV). wireHostProcess now passes -1 in that case, but guard here too:
1962
+ // any null that reaches classifyExit must not be treated as a clean exit.
1963
+ const code = exitCode ?? -1;
1932
1964
  if (code !== 0) {
1965
+ // Issue #1284: a repeat of the exact same classified-unrecoverable
1966
+ // signature short-circuits to 'error' immediately instead of exhausting
1967
+ // the retry budget against a call that will fail identically every time.
1968
+ const signature = detectCompactFailureSignature(run);
1969
+ if (signature && run.lastRecoverySignature === signature) {
1970
+ return { action: 'error', pauseReason: 'error', systemNote: COMPACT_INCOMPATIBLE_NOTE };
1971
+ }
1933
1972
  const attempts = (run.recoveryAttempts ?? 0);
1934
1973
  if (attempts < MAX_RECOVERY_ATTEMPTS) {
1935
- return { action: 'resume', pauseReason: 'working' };
1974
+ return { action: 'resume', pauseReason: 'working', matchedSignature: signature ?? undefined };
1936
1975
  }
1937
1976
  return { action: 'error', pauseReason: 'error' };
1938
1977
  }
@@ -2801,15 +2840,28 @@ class AiHubServer {
2801
2840
  appendHostMessage(run, hostId, event, channel);
2802
2841
  // Issue #1221: a real assistant reply proves the host recovered, so a
2803
2842
  // previously recorded error is stale and must not keep haunting the next
2804
- // exit/recovery message.
2843
+ // exit/recovery message. Issue #1284: a classified recovery signature is
2844
+ // scoped to the same failure recurring; a genuine recovery must not let
2845
+ // it bleed into an unrelated later failure in the same run.
2805
2846
  if (event.message && channel === 'stdout') {
2806
2847
  run.lastHostError = undefined;
2848
+ run.lastRecoverySignature = undefined;
2807
2849
  }
2808
2850
  if (event.raw) {
2809
2851
  applyDelegationLedgerProjection(run, event.raw);
2810
2852
  try {
2811
2853
  const ref = this.rawEventLogStore.append((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id, run.id, { channel, text: event.raw });
2812
2854
  run.eventLogRefs = upsertEventLogRef(run.eventLogRefs, ref);
2855
+ // Issue #1327: if the Hub crashes before the debounced conversation
2856
+ // flush fires, run.eventLogRefs is lost from the store and the raw
2857
+ // event log on disk becomes unreachable. Flush immediately on the
2858
+ // first event so the ref is always on disk within the same synchronous
2859
+ // turn. Subsequent events use the normal debounced path.
2860
+ if (ref.eventCount === 1) {
2861
+ // Issue #1009: a delegated child's first raw event must not steal the
2862
+ // active board slot from its parent/orchestrator conversation.
2863
+ this.persistRunConversationNow(run, run.managedByRunId ? undefined : (run.conversationId || run.id));
2864
+ }
2813
2865
  }
2814
2866
  catch (error) {
2815
2867
  run.events.push((0, hosts_1.createHubEvent)('system', 'Raw host event logging failed; visible conversation state was preserved.'));
@@ -2993,7 +3045,11 @@ class AiHubServer {
2993
3045
  const run = this.runRegistry.get(runId);
2994
3046
  if (!run)
2995
3047
  return;
2996
- this.persistRunConversationNow(run, activeId ?? run.conversationId ?? run.id);
3048
+ // Issue #1009: a delegated child run finishing or parking must not steal the
3049
+ // active board slot from its parent/orchestrator conversation. Only a
3050
+ // standalone (non-delegated) run's own finalize should claim activeId.
3051
+ const resolvedActiveId = run.managedByRunId ? undefined : (activeId ?? run.conversationId ?? run.id);
3052
+ this.persistRunConversationNow(run, resolvedActiveId);
2997
3053
  console.info('[ai-hub] hub.conversation_projection.finalized', {
2998
3054
  conversationId: run.conversationId || run.id,
2999
3055
  runId,
@@ -3014,6 +3070,7 @@ class AiHubServer {
3014
3070
  current.status = 'running';
3015
3071
  current.sessionId = undefined;
3016
3072
  current.recoveryAttempts = 0;
3073
+ current.lastRecoverySignature = undefined;
3017
3074
  current.pauseReason = 'working';
3018
3075
  current.events.push((0, hosts_1.createHubEvent)('system', 'Codex resume failed because the saved session was missing a required reasoning item; starting a fresh handoff-backed turn from Hub conversation context.'));
3019
3076
  });
@@ -5174,6 +5231,14 @@ class AiHubServer {
5174
5231
  if (!option)
5175
5232
  return res.status(400).json({ error: `Unknown agent: ${hubId}` });
5176
5233
  try {
5234
+ // Issue #1285 (Implementation Strategy §3): self-heal any shim orphaned
5235
+ // in the legacy flat managed directory before this run's version checks
5236
+ // consult FRAIM's managed PATH, so a stale build can no longer shadow
5237
+ // the current one. Idempotent; cheap enough to run on every call.
5238
+ const removedShims = (0, managed_agent_paths_1.cleanupOrphanedManagedShims)();
5239
+ if (removedShims.length > 0) {
5240
+ console.warn(`[ai-hub] install-agent: removed orphaned managed shim(s): ${removedShims.join(', ')}`);
5241
+ }
5177
5242
  const systemPath = (0, managed_agent_paths_1.stripManagedAgentBinDirsFromPath)(process.env.PATH);
5178
5243
  const existingVersion = hubCommandVersion(option.launchCommand, undefined, systemPath);
5179
5244
  if (existingVersion) {
@@ -5200,62 +5265,19 @@ class AiHubServer {
5200
5265
  loginHint: `Once installed, click "Check if Ready" to verify ${option.label} is on your PATH.`,
5201
5266
  });
5202
5267
  }
5203
- let standardInstallError = null;
5204
- try {
5205
- await hubRunProcess('npm', ['install', '-g', option.installPackage], {
5206
- PATH: systemPath,
5207
- npm_config_prefix: undefined,
5208
- NPM_CONFIG_PREFIX: undefined,
5209
- });
5210
- const standardVersion = hubCommandVersion(option.launchCommand, undefined, systemPath);
5211
- const npmGlobalBinDirs = standardVersion
5212
- ? []
5213
- : (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)(systemPath, {
5214
- npm_config_prefix: undefined,
5215
- NPM_CONFIG_PREFIX: undefined,
5216
- });
5217
- const standardVersionWithNpmBin = standardVersion
5218
- || (npmGlobalBinDirs.length > 0
5219
- ? hubCommandVersion(option.launchCommand, npmGlobalBinDirs, systemPath)
5220
- : null);
5221
- if (standardVersionWithNpmBin) {
5222
- if (npmGlobalBinDirs.length > 0) {
5223
- process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, npmGlobalBinDirs);
5224
- }
5225
- const mcp = await configureFraimForHubAgent(hubId);
5226
- if (!mcp.configured) {
5227
- console.warn(`[ai-hub] install-agent: FRAIM add-ide did not run for standard ${option.label}: ${mcp.error || 'unknown reason'}`);
5228
- }
5229
- // Issue #1010: something is now installed that was not before, so the cached
5230
- // agent-availability answer is stale. Drop it here rather than waiting out the
5231
- // TTL, so the newly installed CLI shows as available immediately.
5232
- (0, hosts_1.invalidateEmployeeDetectionCache)();
5233
- return res.json({
5234
- ok: true,
5235
- message: `${option.label} installed successfully.`,
5236
- needsLogin: true,
5237
- loginCommand: option.loginCommand,
5238
- loginHint: `Sign in to ${option.label} to activate it. A terminal window will open — complete sign-in there, then click "Check if Ready".`,
5239
- fraimConfigured: mcp.configured,
5240
- });
5241
- }
5242
- standardInstallError = `${option.label} standard install completed, but the CLI is not runnable from the user PATH.`;
5243
- }
5244
- catch (error) {
5245
- standardInstallError = error instanceof Error ? error.message : 'Unknown error';
5246
- }
5247
- const prefix = (0, managed_agent_paths_1.getManagedNodeRoot)();
5248
- fs_1.default.mkdirSync(prefix, { recursive: true });
5249
- await hubRunProcess('npm', ['install', '-g', option.installPackage], { npm_config_prefix: prefix });
5250
- const ver = hubCommandVersion(option.launchCommand, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
5251
- if (!ver) {
5252
- throw new Error(`${option.label} install completed, but the CLI is not runnable from FRAIM's managed PATH. Standard install failure: ${standardInstallError}`);
5268
+ const outcome = await (0, managed_agent_install_1.installManagedAgent)({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath, { runProcess: hubRunProcess, commandVersion: hubCommandVersion });
5269
+ if (outcome.outcome === 'standard' && outcome.npmGlobalBinDirs.length > 0) {
5270
+ process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, outcome.npmGlobalBinDirs);
5253
5271
  }
5254
- // Issue #747: run `add-ide` for the newly installed agent so the FRAIM MCP is wired in
5255
- // and its first run works (previously the agent launched with no `fraim` MCP server).
5256
5272
  const mcp = await configureFraimForHubAgent(hubId);
5257
5273
  if (!mcp.configured) {
5258
- console.warn(`[ai-hub] install-agent: FRAIM add-ide did not run for ${option.label}: ${mcp.error || 'unknown reason'}`);
5274
+ console.warn(`[ai-hub] install-agent: FRAIM add-ide did not run for ${option.label} (${outcome.outcome} install): ${mcp.error || 'unknown reason'}`);
5275
+ }
5276
+ if (outcome.outcome === 'standard') {
5277
+ // Issue #1010: something is now installed that was not before, so the cached
5278
+ // agent-availability answer is stale. Drop it here rather than waiting out the
5279
+ // TTL, so the newly installed CLI shows as available immediately.
5280
+ (0, hosts_1.invalidateEmployeeDetectionCache)();
5259
5281
  }
5260
5282
  return res.json({
5261
5283
  ok: true,
@@ -5293,16 +5315,27 @@ class AiHubServer {
5293
5315
  });
5294
5316
  }
5295
5317
  });
5296
- this.app.post('/api/ai-hub/check-agent', (req, res) => {
5318
+ this.app.post('/api/ai-hub/check-agent', async (req, res) => {
5297
5319
  const { hubId } = req.body;
5298
5320
  if (!hubId)
5299
5321
  return res.status(400).json({ error: 'hubId is required.' });
5300
5322
  const option = hubAgentOption(hubId);
5301
5323
  if (!option)
5302
5324
  return res.status(400).json({ error: `Unknown agent: ${hubId}` });
5325
+ (0, managed_agent_paths_1.cleanupOrphanedManagedShims)();
5303
5326
  const ver = hubCommandVersion(option.launchCommand, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
5304
5327
  if (ver) {
5305
- return res.json({ ok: true, ready: true, message: `${option.label} is ready.` });
5328
+ // Issue #1285 (§4): a manager who ran `codex update` and clicks "Check
5329
+ // if Ready" should see PATH drift immediately rather than a bare pass.
5330
+ const health = await (0, agent_cli_health_checks_1.checkAgentCliHealthByCommand)(option.launchCommand);
5331
+ return res.json({
5332
+ ok: true,
5333
+ ready: true,
5334
+ message: `${option.label} is ready.`,
5335
+ ...(health && health.status !== 'passed'
5336
+ ? { driftWarning: health.message, driftSuggestion: health.suggestion }
5337
+ : {}),
5338
+ });
5306
5339
  }
5307
5340
  return res.json({
5308
5341
  ok: true,
@@ -6829,6 +6862,7 @@ class AiHubServer {
6829
6862
  : createHubRecoveryEvent(current, exitCode, current.recoveryAttempts));
6830
6863
  }
6831
6864
  current.lastRecoveryAt = new Date().toISOString();
6865
+ current.lastRecoverySignature = classification.matchedSignature ?? null;
6832
6866
  current.pauseReason = 'working';
6833
6867
  });
6834
6868
  const refreshed = this.runRegistry.get(runId);
@@ -18,6 +18,7 @@ const BUNDLED_ASSETS = {
18
18
  styles: ['/ai-hub/styles.css', '/ai-hub/review.css'],
19
19
  scripts: ['/ai-hub/script.js'],
20
20
  };
21
+ const REQUIRED_REMOTE_UI_CAPABILITIES = ['jobs.visualization'];
21
22
  function createAiHubBridgeInfo(options) {
22
23
  const remoteUiEnabled = options.remoteUiEnabled ?? process.env.FRAIM_HUB_REMOTE_UI === '1';
23
24
  return {
@@ -34,6 +35,7 @@ function createAiHubBridgeInfo(options) {
34
35
  'office.wordTaskpane': true,
35
36
  'customEmployees': true,
36
37
  'ui.serverDrivenRuntime': true,
38
+ 'jobs.visualization': true,
37
39
  },
38
40
  };
39
41
  }
@@ -53,6 +55,12 @@ function evaluateHubUiManifestCompatibility(manifest, bridge) {
53
55
  if (missing) {
54
56
  return { compatible: false, updateRequired: true, reason: `missing-capability:${missing}` };
55
57
  }
58
+ const missingRequired = REQUIRED_REMOTE_UI_CAPABILITIES.find((capability) => {
59
+ return bridge.capabilities[capability] && !manifest.requiredCapabilities.includes(capability);
60
+ });
61
+ if (missingRequired) {
62
+ return { compatible: false, updateRequired: true, reason: `missing-required-capability:${missingRequired}` };
63
+ }
56
64
  return { compatible: true, updateRequired: false };
57
65
  }
58
66
  function runtimeAssetsForRelease(releaseId, manifest) {
@@ -0,0 +1,165 @@
1
+ "use strict";
2
+ /**
3
+ * Agent CLI PATH/version health checks for FRAIM doctor command
4
+ * Issue #1284/#1285: detect drift between FRAIM's own managed-PATH resolution,
5
+ * the ambient shell PATH, and the actual npm-global install for agent CLIs
6
+ * FRAIM installs through its managed-Node fallback.
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.getAgentCliHealthChecks = getAgentCliHealthChecks;
13
+ exports.checkAgentCliHealthByCommand = checkAgentCliHealthByCommand;
14
+ const child_process_1 = require("child_process");
15
+ const path_1 = __importDefault(require("path"));
16
+ const managed_agent_paths_1 = require("../../utils/managed-agent-paths");
17
+ const command_resolution_1 = require("../../mcp/command-resolution");
18
+ // Codex is FRAIM's first Hub-compatible CLI with a managed-install fallback
19
+ // (npm install -g into FRAIM's portable Node when no system install is
20
+ // found). Extend this list as claude/gemini/copilot gain the same fallback.
21
+ const MANAGED_CLIS = [
22
+ { id: 'codex', label: 'Codex', command: 'codex' },
23
+ ];
24
+ // Windows cannot CreateProcess a `.cmd`/`.bat` file directly (spawnSync on a
25
+ // resolved absolute `.cmd` path throws EINVAL) — it must go through cmd.exe,
26
+ // matching the same wrapping `hosts.ts`'s `resolveHostInvocation()` and
27
+ // `server.ts`'s `hubCommandVersion()` already use. Duplicated here (not
28
+ // imported from `hosts.ts`) because `src/cli` is a pure layer that cannot
29
+ // import the server-layer `src/ai-hub` module (`scripts/validate-purity.ts`).
30
+ function quoteWindowsArg(value) {
31
+ if (value.length === 0)
32
+ return '""';
33
+ return `"${value.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"`;
34
+ }
35
+ function escapeWindowsArg(value) {
36
+ return /[\s"]/u.test(value) ? quoteWindowsArg(value) : value;
37
+ }
38
+ function probeVersion(commandPath) {
39
+ try {
40
+ const executable = process.platform === 'win32' ? 'cmd.exe' : commandPath;
41
+ const args = process.platform === 'win32'
42
+ ? ['/d', '/s', '/c', `${escapeWindowsArg(commandPath)} --version`]
43
+ : ['--version'];
44
+ const result = (0, child_process_1.spawnSync)(executable, args, { encoding: 'utf8', timeout: 5000 });
45
+ if (result.status !== 0 || result.error)
46
+ return null;
47
+ return (result.stdout || result.stderr || '').trim() || null;
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ // The persisted Windows User PATH (what a brand-new terminal reads) can
54
+ // disagree with this process's own inherited `process.env.PATH` (what a
55
+ // shell already open before the fix landed still uses) — issue #1285's
56
+ // exact "update said success but --version looked wrong" ambiguity.
57
+ // Windows-only; there is no equivalent persisted-PATH registry on macOS/Linux.
58
+ function readPersistedUserPath() {
59
+ if (process.platform !== 'win32')
60
+ return null;
61
+ try {
62
+ const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-NonInteractive', '-Command', "[Environment]::GetEnvironmentVariable('PATH','User')"], { encoding: 'utf8', timeout: 5000 });
63
+ if (result.status !== 0 || result.error)
64
+ return null;
65
+ return (result.stdout || '').trim() || null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ async function runAgentCliHealthCheck(cli) {
72
+ // "Ambient" is this process's own raw, unmodified PATH — exactly what a
73
+ // shell already open before a PATH fix landed still sees (issue #1285's
74
+ // "any shell the user already has open keeps its own stale in-memory copy
75
+ // until restarted" applies to the process running this check too, not
76
+ // only the user's terminal). "Managed" strips any stale managed directory
77
+ // already sitting on that ambient PATH and re-appends FRAIM's current,
78
+ // correctly-ordered managed dirs — so it always reflects the current
79
+ // versioned build, independent of whatever the ambient PATH happens to
80
+ // still contain. Deliberately NOT `resolveManagedCommand()`: that helper
81
+ // is system-PATH-first for launch purposes and would silently agree with
82
+ // a stale ambient entry instead of surfacing the drift this check exists
83
+ // to catch.
84
+ const ambientPath = (0, command_resolution_1.getSystemCommandPath)(cli.command);
85
+ const managedSearchPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
86
+ const managedPath = (0, command_resolution_1.getSystemCommandPath)(cli.command, managedSearchPath);
87
+ if (!ambientPath && !managedPath) {
88
+ return {
89
+ status: 'passed',
90
+ message: `${cli.label} is not installed; nothing to check.`,
91
+ };
92
+ }
93
+ const ambientVersion = ambientPath ? probeVersion(ambientPath) : null;
94
+ const managedVersion = !managedPath
95
+ ? null
96
+ : managedPath === ambientPath
97
+ ? ambientVersion
98
+ : probeVersion(managedPath);
99
+ const npmGlobalBinDirs = (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)();
100
+ const npmGlobalPath = npmGlobalBinDirs.length > 0
101
+ ? (0, command_resolution_1.getSystemCommandPath)(cli.command, npmGlobalBinDirs.join(path_1.default.delimiter))
102
+ : null;
103
+ const npmGlobalVersion = npmGlobalPath ? probeVersion(npmGlobalPath) : null;
104
+ // Drift means either: the ambient shell resolves a different version than
105
+ // the actual npm-global install, or this process's own ambient PATH
106
+ // resolves a *different file* than FRAIM's freshly-recomputed managed-dir
107
+ // resolution would — the exact "stale shim still shadows the current one"
108
+ // signature from #1285.
109
+ const versionMismatch = Boolean((ambientVersion && npmGlobalVersion && ambientVersion !== npmGlobalVersion)
110
+ || (ambientPath && managedPath && ambientPath !== managedPath));
111
+ if (!versionMismatch) {
112
+ return {
113
+ status: 'passed',
114
+ message: `${cli.label} is consistent (${ambientVersion || managedVersion || 'unknown version'}).`,
115
+ details: { ambientPath, managedPath, npmGlobalPath },
116
+ };
117
+ }
118
+ const persistedUserPath = readPersistedUserPath();
119
+ const persistedResolved = persistedUserPath ? (0, command_resolution_1.getSystemCommandPath)(cli.command, persistedUserPath) : null;
120
+ const persistedVersion = persistedResolved ? probeVersion(persistedResolved) : null;
121
+ const persistedMatchesManaged = Boolean(persistedResolved && managedPath && persistedResolved === managedPath);
122
+ const suggestion = persistedUserPath === null
123
+ ? `Restart your terminal, then run "${cli.command} --version" again to confirm.`
124
+ : persistedMatchesManaged
125
+ ? `Your saved PATH is already correct — open a new terminal window so it takes effect, then run "${cli.command} --version" again.`
126
+ : `Your saved PATH still resolves the old ${cli.label} build. Run "${cli.command} update" (or reinstall ${cli.label}), then open a new terminal.`;
127
+ return {
128
+ status: 'warning',
129
+ message: `${cli.label} PATH drift detected: ambient ${ambientVersion || 'not found'} vs managed ${managedVersion || 'not found'} vs npm-global ${npmGlobalVersion || 'not found'}.`,
130
+ suggestion,
131
+ details: {
132
+ ambientPath, ambientVersion,
133
+ managedPath, managedVersion,
134
+ npmGlobalPath, npmGlobalVersion,
135
+ persistedUserPathResolvedTo: persistedResolved,
136
+ persistedUserPathVersion: persistedVersion,
137
+ },
138
+ };
139
+ }
140
+ function agentCliHealthCheck(cli) {
141
+ return {
142
+ name: `${cli.label} PATH/version health`,
143
+ category: 'agentCliHealth',
144
+ critical: false,
145
+ run: () => runAgentCliHealthCheck(cli),
146
+ };
147
+ }
148
+ function getAgentCliHealthChecks() {
149
+ return MANAGED_CLIS.map(agentCliHealthCheck);
150
+ }
151
+ // Reused by the Hub's `/api/ai-hub/check-agent` route (issue #1285 §4) so a
152
+ // manager who clicks "Check if Ready" right after `codex update` sees PATH
153
+ // drift immediately instead of a bare pass/fail. Returns null for a CLI this
154
+ // module does not track.
155
+ async function checkAgentCliHealthByCommand(command) {
156
+ const cli = MANAGED_CLIS.find((entry) => entry.command === command);
157
+ if (!cli)
158
+ return null;
159
+ try {
160
+ return await runAgentCliHealthCheck(cli);
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
@@ -3,47 +3,24 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.resolveManagedCommand = exports.getSystemCommandPath = exports.getPortableNpxCommand = void 0;
6
+ exports.resolveManagedCommand = exports.getPortableManagedCommandPath = exports.getSystemCommandPath = void 0;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
- const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
10
- const getPortableNodeRoot = () => path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'node');
11
- const getPortableNpxCandidates = () => {
12
- const nodeRoot = getPortableNodeRoot();
13
- if (process.platform === 'win32') {
14
- const candidates = [path_1.default.join(nodeRoot, 'npx.cmd')];
15
- if (fs_1.default.existsSync(nodeRoot)) {
16
- const extractedDirs = fs_1.default.readdirSync(nodeRoot, { withFileTypes: true })
17
- .filter((entry) => entry.isDirectory() && entry.name.startsWith('node-v'))
18
- .sort((a, b) => b.name.localeCompare(a.name));
19
- for (const entry of extractedDirs) {
20
- candidates.push(path_1.default.join(nodeRoot, entry.name, 'npx.cmd'));
21
- }
22
- }
23
- return candidates;
24
- }
25
- return [
26
- path_1.default.join(nodeRoot, 'bin', 'npx'),
27
- path_1.default.join(nodeRoot, 'npx')
28
- ];
29
- };
30
- const getPortableNpxCommand = () => {
31
- for (const candidate of getPortableNpxCandidates()) {
32
- if (fs_1.default.existsSync(candidate)) {
33
- return candidate;
34
- }
35
- }
36
- return null;
37
- };
38
- exports.getPortableNpxCommand = getPortableNpxCommand;
39
- const getPathEntries = () => {
40
- const rawPath = process.env.PATH || '';
9
+ const managed_agent_paths_1 = require("../utils/managed-agent-paths");
10
+ // Issue #1284/#1285 (Implementation Strategy §0): commands FRAIM resolves
11
+ // fresh at launch time instead of trusting a persisted PATH string. Started
12
+ // as `npx`-only; generalized to every agent CLI FRAIM itself launches
13
+ // (Hub-driven runs, version probes, install/check-agent routes) so those
14
+ // launches are immune to PATH-order drift structurally.
15
+ const MANAGED_COMMANDS = new Set(['npx', 'codex', 'claude', 'gemini', 'copilot']);
16
+ const getPathEntries = (basePath) => {
17
+ const rawPath = (0, managed_agent_paths_1.stripProjectLocalNodeBinDirs)(basePath ?? process.env.PATH ?? '');
41
18
  return rawPath
42
19
  .split(path_1.default.delimiter)
43
20
  .map((entry) => entry.trim())
44
21
  .filter(Boolean);
45
22
  };
46
- const getSystemCommandCandidates = (command) => {
23
+ const getSystemCommandCandidates = (command, basePath) => {
47
24
  if (!command || path_1.default.isAbsolute(command)) {
48
25
  return command ? [command] : [];
49
26
  }
@@ -52,10 +29,14 @@ const getSystemCommandCandidates = (command) => {
52
29
  ? [command]
53
30
  : [command, `${command}.cmd`, `${command}.exe`, `${command}.bat`, `${command}.com`]
54
31
  : [command];
55
- return getPathEntries().flatMap((entry) => commandNames.map((name) => path_1.default.join(entry, name)));
32
+ return getPathEntries(basePath).flatMap((entry) => commandNames.map((name) => path_1.default.join(entry, name)));
56
33
  };
57
- const getSystemCommandPath = (command) => {
58
- for (const candidate of getSystemCommandCandidates(command)) {
34
+ // `basePath` defaults to the current process PATH; pass an explicit PATH
35
+ // string to resolve a command against a different candidate list (e.g. the
36
+ // npm-global bin dirs computed by `resolveNpmGlobalBinDirs`) without mutating
37
+ // `process.env.PATH`.
38
+ const getSystemCommandPath = (command, basePath) => {
39
+ for (const candidate of getSystemCommandCandidates(command, basePath)) {
59
40
  try {
60
41
  const stats = fs_1.default.statSync(candidate);
61
42
  if (stats.isFile()) {
@@ -69,13 +50,32 @@ const getSystemCommandPath = (command) => {
69
50
  return null;
70
51
  };
71
52
  exports.getSystemCommandPath = getSystemCommandPath;
53
+ // Mirrors the shape of the old `getPortableNpxCandidates()`, but checks only
54
+ // the current versioned `getPortableNodeBinPath()` directory — never the
55
+ // legacy flat directory — so a shim orphaned there (Defect A) can never be
56
+ // selected here regardless of what's on disk.
57
+ const getPortableManagedCommandCandidates = (command) => {
58
+ const versionedDir = (0, managed_agent_paths_1.getPortableNodeBinPath)();
59
+ if (process.platform === 'win32') {
60
+ return [`${command}.cmd`, `${command}.exe`].map((name) => path_1.default.join(versionedDir, name));
61
+ }
62
+ return [path_1.default.join(versionedDir, command)];
63
+ };
64
+ const getPortableManagedCommandPath = (command) => {
65
+ for (const candidate of getPortableManagedCommandCandidates(command)) {
66
+ if (fs_1.default.existsSync(candidate)) {
67
+ return candidate;
68
+ }
69
+ }
70
+ return null;
71
+ };
72
+ exports.getPortableManagedCommandPath = getPortableManagedCommandPath;
72
73
  const resolveManagedCommand = (command) => {
73
- if (command !== 'npx') {
74
+ if (!MANAGED_COMMANDS.has(command))
74
75
  return command;
75
- }
76
- // Prefer system-installed npx so we don't install our own Node when the
77
- // machine already has one. Fall back to the FRAIM-managed portable copy
78
- // only when no system npx is found. Last resort: bare command name.
79
- return (0, exports.getSystemCommandPath)(command) || (0, exports.getPortableNpxCommand)() || command;
76
+ // Prefer a system-installed CLI so FRAIM doesn't install its own copy when
77
+ // the machine already has one. Fall back to the FRAIM-managed portable copy
78
+ // only when no system install is found. Last resort: bare command name.
79
+ return (0, exports.getSystemCommandPath)(command) || (0, exports.getPortableManagedCommandPath)(command) || command;
80
80
  };
81
81
  exports.resolveManagedCommand = resolveManagedCommand;
@@ -0,0 +1,55 @@
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.installManagedAgent = installManagedAgent;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const managed_agent_paths_1 = require("./managed-agent-paths");
9
+ // Issue #1284/#1285 (Implementation Strategy §2): the exact "standard npm
10
+ // global install, then fall back to FRAIM's managed prefix" sequence used to
11
+ // be duplicated verbatim in `server.ts` (Hub's install-agent route) and
12
+ // `session-service.ts` (first-run's installAgent), including Defect A — both
13
+ // pointed `npm_config_prefix` at the flat `nodeRoot` dir instead of the
14
+ // versioned dir that actually contains the Node/npm binaries running the
15
+ // install. Extracted so the prefix fix (and any future fix to this sequence)
16
+ // lives in one place. Callers are responsible for the "already installed"
17
+ // check before invoking this — it only covers the install-then-fallback path.
18
+ async function installManagedAgent(option, systemPath, deps) {
19
+ let standardInstallError = null;
20
+ try {
21
+ await deps.runProcess('npm', ['install', '-g', option.installPackage], {
22
+ PATH: systemPath,
23
+ npm_config_prefix: undefined,
24
+ NPM_CONFIG_PREFIX: undefined,
25
+ });
26
+ const standardVersion = deps.commandVersion(option.launchCommand, undefined, systemPath);
27
+ const npmGlobalBinDirs = standardVersion
28
+ ? []
29
+ : (0, managed_agent_paths_1.resolveNpmGlobalBinDirs)(systemPath, {
30
+ npm_config_prefix: undefined,
31
+ NPM_CONFIG_PREFIX: undefined,
32
+ });
33
+ const standardVersionWithNpmBin = standardVersion
34
+ || (npmGlobalBinDirs.length > 0
35
+ ? deps.commandVersion(option.launchCommand, npmGlobalBinDirs, systemPath)
36
+ : null);
37
+ if (standardVersionWithNpmBin) {
38
+ return { outcome: 'standard', npmGlobalBinDirs };
39
+ }
40
+ standardInstallError = `${option.label} standard install completed, but the CLI is not runnable from the user PATH.`;
41
+ }
42
+ catch (error) {
43
+ standardInstallError = error instanceof Error ? error.message : 'Unknown error';
44
+ }
45
+ // Defect A fix: co-locate npm-global shims with the node.exe/npm.cmd that
46
+ // actually runs the install, instead of the flat legacy directory.
47
+ const prefix = (0, managed_agent_paths_1.getPortableNodeBinPath)();
48
+ fs_1.default.mkdirSync(prefix, { recursive: true });
49
+ await deps.runProcess('npm', ['install', '-g', option.installPackage], { npm_config_prefix: prefix });
50
+ const ver = deps.commandVersion(option.launchCommand, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
51
+ if (!ver) {
52
+ throw new Error(`${option.label} install completed, but the CLI is not runnable from FRAIM's managed PATH. Standard install failure: ${standardInstallError}`);
53
+ }
54
+ return { outcome: 'managed' };
55
+ }
@@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.getManagedNodeRoot = getManagedNodeRoot;
7
7
  exports.getPortableNodeBinPath = getPortableNodeBinPath;
8
8
  exports.getManagedAgentBinDirs = getManagedAgentBinDirs;
9
+ exports.stripProjectLocalNodeBinDirs = stripProjectLocalNodeBinDirs;
10
+ exports.cleanupOrphanedManagedShims = cleanupOrphanedManagedShims;
9
11
  exports.stripManagedAgentBinDirsFromPath = stripManagedAgentBinDirsFromPath;
10
12
  exports.appendBinDirsToPath = appendBinDirsToPath;
11
13
  exports.getNpmGlobalBinDirsFromPrefix = getNpmGlobalBinDirsFromPrefix;
@@ -37,11 +39,62 @@ function getPortableNodeBinPath() {
37
39
  function getManagedAgentBinDirs() {
38
40
  const nodeRoot = getManagedNodeRoot();
39
41
  const portableNodeBin = getPortableNodeBinPath();
42
+ // The current versioned Node directory must outrank the legacy flat directory:
43
+ // a shim orphaned in the flat directory by an older FRAIM version must never
44
+ // shadow a newer bundled build (issue #1285). uniquePathEntries'/Set-based dedup
45
+ // collapses this to a single entry when no versioned subfolder exists yet.
40
46
  const candidates = process.platform === 'win32'
41
- ? [nodeRoot, portableNodeBin]
42
- : [nodeRoot, path_1.default.join(nodeRoot, 'bin'), portableNodeBin];
47
+ ? [portableNodeBin, nodeRoot]
48
+ : [portableNodeBin, nodeRoot, path_1.default.join(nodeRoot, 'bin')];
43
49
  return [...new Set(candidates.filter(Boolean))];
44
50
  }
51
+ // Issue #1284/#1285: strips PATH entries that point at a project-local
52
+ // `node_modules/.bin` directory. Relocated from `src/ai-hub/hosts.ts` (Defect D)
53
+ // so the pure `src/cli` layer (which `command-resolution.ts` belongs to, and
54
+ // which cannot import from the server-layer `hosts.ts`) can apply the same
55
+ // filtering when resolving a managed command, not just when probing agent
56
+ // versions. A devDependency's own CLI shim (e.g. a stale `@openai/codex-sdk`
57
+ // pulled in transitively) must never shadow the real global install.
58
+ function stripProjectLocalNodeBinDirs(basePath) {
59
+ return (basePath ?? '')
60
+ .split(path_1.default.delimiter)
61
+ .filter(Boolean)
62
+ .filter((entry) => {
63
+ const normalized = path_1.default.normalize(entry).toLowerCase();
64
+ return !normalized.endsWith(`${path_1.default.sep}node_modules${path_1.default.sep}.bin`);
65
+ })
66
+ .join(path_1.default.delimiter);
67
+ }
68
+ // Issue #1285 (Implementation Strategy §3): after (re)ordering the managed
69
+ // candidates above, nothing new is ever written to the flat, legacy `nodeRoot`
70
+ // directory — but installs made before this fix left real shims sitting there.
71
+ // Scoped strictly to filenames npm itself would have generated for a managed
72
+ // agent CLI install (never a wildcard sweep), so this can run unconditionally
73
+ // and idempotently on every FRAIM startup.
74
+ const MANAGED_AGENT_COMMANDS = ['codex', 'claude', 'gemini', 'copilot'];
75
+ function cleanupOrphanedManagedShims() {
76
+ const nodeRoot = getManagedNodeRoot();
77
+ const versionedDir = getPortableNodeBinPath();
78
+ if (versionedDir === nodeRoot || !fs_1.default.existsSync(nodeRoot))
79
+ return [];
80
+ const basenames = process.platform === 'win32'
81
+ ? MANAGED_AGENT_COMMANDS.flatMap((command) => [`${command}.cmd`, `${command}.ps1`])
82
+ : MANAGED_AGENT_COMMANDS;
83
+ const removed = [];
84
+ for (const basename of basenames) {
85
+ const candidate = path_1.default.join(nodeRoot, basename);
86
+ try {
87
+ if (fs_1.default.statSync(candidate).isFile()) {
88
+ fs_1.default.unlinkSync(candidate);
89
+ removed.push(candidate);
90
+ }
91
+ }
92
+ catch {
93
+ // Not present or inaccessible — nothing to clean up.
94
+ }
95
+ }
96
+ return removed;
97
+ }
45
98
  function normalizePathEntry(entry) {
46
99
  const resolved = path_1.default.resolve(entry.trim());
47
100
  return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.278",
3
+ "version": "2.0.280",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -19,6 +19,7 @@
19
19
  "dist/src/cli/api/get-provider-client.js",
20
20
  "dist/src/cli/api/provider-client.js",
21
21
  "dist/src/cli/commands/add-ide.js",
22
+ "dist/src/cli/doctor/checks/agent-cli-health-checks.js",
22
23
  "dist/src/cli/fraim-hub.js",
23
24
  "dist/src/cli/fraim-hub-2.js",
24
25
  "dist/src/cli/internal/device-flow-service.js",
@@ -37,6 +38,7 @@
37
38
  "dist/src/cli/setup/mcp-config-generator.js",
38
39
  "dist/src/cli/setup/provider-prompts.js",
39
40
  "dist/src/cli/utils/local-folder-sync.js",
41
+ "dist/src/cli/utils/managed-agent-install.js",
40
42
  "dist/src/cli/utils/managed-agent-paths.js",
41
43
  "dist/src/cli/utils/org-publish.js",
42
44
  "dist/src/cli/utils/pack-git-publish.js",
@@ -133,6 +135,7 @@
133
135
  "repo": "FRAIM"
134
136
  },
135
137
  "win": {
138
+ "icon": "build/icon.ico",
136
139
  "target": [
137
140
  "nsis",
138
141
  "portable"
@@ -144,6 +147,7 @@
144
147
  "allowToChangeInstallationDirectory": true
145
148
  },
146
149
  "mac": {
150
+ "icon": "build/icon.icns",
147
151
  "target": [
148
152
  "dmg",
149
153
  "zip"
@@ -152,6 +156,7 @@
152
156
  "gatekeeperAssess": false
153
157
  },
154
158
  "linux": {
159
+ "icon": "build/icon.png",
155
160
  "target": [
156
161
  "AppImage",
157
162
  "deb"
@@ -174,7 +179,7 @@
174
179
  "electron-updater": "^6.8.9",
175
180
  "express": "^5.2.1",
176
181
  "extract-zip": "^2.0.1",
177
- "fraim": "2.0.278",
182
+ "fraim": "2.0.280",
178
183
  "mongodb": "^7.0.0",
179
184
  "node-cron": "4.2.1",
180
185
  "node-edge-tts": "^1.2.10",
Binary file
@@ -3663,9 +3663,21 @@ function renderActive() {
3663
3663
  // this diff (same role/text/timestamp), so the "Queued N" / "Redirecting
3664
3664
  // now" badge would never disappear even after the server clears the field.
3665
3665
  const messageFingerprints = messages.map((message) => `${message.role || ''}\u0000${message.text || ''}\u0000${messageTimestamp(message) || ''}\u0000${message.deliveryStatus ? JSON.stringify(message.deliveryStatus) : ''}`);
3666
- const messagesMutated =
3667
- messages.length === renderedMessageCount &&
3668
- messageFingerprints.some((fingerprint, index) => fingerprint !== renderedMessageFingerprints[index]);
3666
+ // Issue #1309: detect a mutation on any already-rendered row over the
3667
+ // overlapping prefix, not only when the total count is unchanged. A
3668
+ // continuation's first event commonly both clears a queued message's
3669
+ // deliveryStatus AND appends a brand-new employee message in the same
3670
+ // update (server.ts's onEvent runs recordHostEvent, which can push a new
3671
+ // message, before the turnStarted clear-loop). Gating this check on
3672
+ // `messages.length === renderedMessageCount` made that coincidence
3673
+ // invisible: the badge-clearing row's fingerprint changed, but the
3674
+ // guard's length check was false, so the full rebuild that is the only
3675
+ // place a stale "Queued N" badge / data-pending tag gets removed never
3676
+ // ran, orphaning it on that row forever.
3677
+ const overlapCount = Math.min(messages.length, renderedMessageCount);
3678
+ const messagesMutated = messageFingerprints
3679
+ .slice(0, overlapCount)
3680
+ .some((fingerprint, index) => fingerprint !== renderedMessageFingerprints[index]);
3669
3681
  // Issue #1249: capture the pre-reset count so "newly arrived" below reflects
3670
3682
  // genuinely new messages, not every row a messagesMutated full rebuild just
3671
3683
  // re-appended (e.g. an existing message's badge disappearing).
@@ -7373,18 +7385,9 @@ function renderJobCatalog(searchTerm = '') {
7373
7385
  lockBadge.textContent = `🔒 ${persona ? persona.displayName : job.requiredPersonaKey}`;
7374
7386
  btn.appendChild(lockBadge);
7375
7387
  }
7376
- // Issue #1278 R1/R2/R14: ⓘ button — skip ad-hoc row, stop propagation so job is not selected.
7388
+ // Issue #1278 R1/R2/R14: skip ad-hoc row, stop propagation so job is not selected.
7377
7389
  if (job.id !== '__freeform__') {
7378
- const vizBtn = document.createElement('button');
7379
- vizBtn.type = 'button';
7380
- vizBtn.className = 'job-viz-btn';
7381
- vizBtn.textContent = 'ⓘ';
7382
- vizBtn.setAttribute('aria-label', `Visualize ${job.title}`);
7383
- vizBtn.addEventListener('click', (e) => {
7384
- e.stopPropagation();
7385
- tfOpenJobViz(job);
7386
- });
7387
- btn.appendChild(vizBtn);
7390
+ btn.appendChild(tfCreateJobVizButton(job));
7388
7391
  }
7389
7392
  btn.addEventListener('click', () => {
7390
7393
  if (isLocked) {
@@ -15123,6 +15126,7 @@ function tfOpenAssignJob(employeeKey) {
15123
15126
  btn.type = 'button';
15124
15127
  btn.textContent = 'Assign';
15125
15128
  btn.addEventListener('click', () => tfAssignJobToEmployee(persona.key, job));
15129
+ if (job.id !== '__freeform__') row.appendChild(tfCreateJobVizButton(job));
15126
15130
  row.appendChild(btn);
15127
15131
  return row;
15128
15132
  };
@@ -15199,8 +15203,23 @@ function tfCloseAssignJob() {
15199
15203
  if (m) m.hidden = true;
15200
15204
  }
15201
15205
 
15206
+ function tfCreateJobVizButton(job) {
15207
+ const vizBtn = document.createElement('button');
15208
+ vizBtn.type = 'button';
15209
+ vizBtn.className = 'job-viz-btn';
15210
+ vizBtn.textContent = 'ⓘ';
15211
+ vizBtn.setAttribute('aria-label', `Visualize ${job.title || job.id}`);
15212
+ vizBtn.title = `Visualize ${job.title || job.id}`;
15213
+ vizBtn.addEventListener('click', (e) => {
15214
+ e.stopPropagation();
15215
+ const fromAssignJobModal = !!vizBtn.closest('#assign-job-modal');
15216
+ tfOpenJobViz(job, { returnToAssignJob: fromAssignJobModal });
15217
+ });
15218
+ return vizBtn;
15219
+ }
15220
+
15202
15221
  // Issue #1278 — Job Visualization Modal
15203
- function tfOpenJobViz(job) {
15222
+ function tfOpenJobViz(job, options) {
15204
15223
  const modal = document.getElementById('job-viz-modal');
15205
15224
  if (!modal) return;
15206
15225
  const titleEl = document.getElementById('jv-title');
@@ -15208,6 +15227,13 @@ function tfOpenJobViz(job) {
15208
15227
  const badgeEl = document.getElementById('jv-badge');
15209
15228
  const phasesEl = document.getElementById('jv-phases');
15210
15229
  if (!phasesEl) return;
15230
+ const assignModal = document.getElementById('assign-job-modal');
15231
+ if (options && options.returnToAssignJob && assignModal && !assignModal.hidden) {
15232
+ modal.dataset.returnToModal = 'assign-job-modal';
15233
+ assignModal.hidden = true;
15234
+ } else {
15235
+ delete modal.dataset.returnToModal;
15236
+ }
15211
15237
  if (titleEl) titleEl.textContent = job.title || job.id;
15212
15238
  if (intentEl) intentEl.textContent = '';
15213
15239
  if (badgeEl) badgeEl.hidden = true;
@@ -15295,8 +15321,16 @@ function tfOpenJobViz(job) {
15295
15321
  }
15296
15322
  function tfCloseJobViz() {
15297
15323
  const m = document.getElementById('job-viz-modal');
15298
- if (m) m.hidden = true;
15324
+ const returnToModal = m ? m.dataset.returnToModal : '';
15325
+ if (m) {
15326
+ m.hidden = true;
15327
+ delete m.dataset.returnToModal;
15328
+ }
15299
15329
  jvHideTooltip();
15330
+ if (returnToModal === 'assign-job-modal') {
15331
+ const assignModal = document.getElementById('assign-job-modal');
15332
+ if (assignModal) assignModal.hidden = false;
15333
+ }
15300
15334
  }
15301
15335
 
15302
15336
  // Issue #1278 — fixed-position tooltip portal, escapes scroll-container clip
@@ -6435,4 +6435,11 @@ img.eh-av { object-fit: cover; background: var(--surface); }
6435
6435
  }
6436
6436
  .job-viz-btn:hover { background: var(--soft); color: var(--accent); }
6437
6437
  .job-viz-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
6438
+ .job-row .job-viz-btn {
6439
+ grid-column: auto; grid-row: auto;
6440
+ flex-shrink: 0; width: 26px; height: 26px;
6441
+ display: inline-flex; align-items: center; justify-content: center;
6442
+ border: 1px solid var(--line); border-radius: 50%;
6443
+ font-weight: 700;
6444
+ }
6438
6445
  /* ─── End Issue #1278 ────────────────────────────────────────────────────── */