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.
- package/dist/src/ai-hub/bundled-asset-resolver.js +30 -0
- package/dist/src/ai-hub/desktop-main.js +24 -11
- package/dist/src/ai-hub/hosts.js +90 -17
- package/dist/src/ai-hub/server.js +97 -63
- package/dist/src/ai-hub/ui-runtime.js +8 -0
- package/dist/src/cli/doctor/checks/agent-cli-health-checks.js +165 -0
- package/dist/src/cli/mcp/command-resolution.js +43 -43
- package/dist/src/cli/utils/managed-agent-install.js +55 -0
- package/dist/src/cli/utils/managed-agent-paths.js +55 -2
- package/package.json +7 -2
- package/public/ai-hub/fraim-icon.png +0 -0
- package/public/ai-hub/script.js +50 -16
- package/public/ai-hub/styles.css +7 -0
|
@@ -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
|
-
//
|
|
65
|
-
//
|
|
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
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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();
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -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
|
|
839
|
+
return resolvedPlan;
|
|
825
840
|
}
|
|
826
|
-
const [command, ...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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
74
|
+
if (!MANAGED_COMMANDS.has(command))
|
|
74
75
|
return command;
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
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
|
-
? [
|
|
42
|
-
: [nodeRoot, path_1.default.join(nodeRoot, 'bin')
|
|
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.
|
|
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.
|
|
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
|
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
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:
|
|
7388
|
+
// Issue #1278 R1/R2/R14: skip ad-hoc row, stop propagation so job is not selected.
|
|
7377
7389
|
if (job.id !== '__freeform__') {
|
|
7378
|
-
|
|
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
|
-
|
|
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
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -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 ────────────────────────────────────────────────────── */
|