fraim-framework 2.0.200 → 2.0.201
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/desktop-main.js +35 -0
- package/dist/src/ai-hub/hub-latest-version.js +52 -0
- package/dist/src/ai-hub/hub-launch-decision.js +57 -0
- package/dist/src/ai-hub/hub-runtime-file.js +43 -0
- package/dist/src/ai-hub/server.js +35 -11
- package/dist/src/cli/commands/hub.js +107 -1
- package/package.json +1 -1
- package/public/ai-hub/index.html +5 -0
- package/public/ai-hub/script.js +24 -0
- package/public/ai-hub/styles.css +7 -0
|
@@ -11,6 +11,9 @@ const server_1 = require("./server");
|
|
|
11
11
|
const cert_store_1 = require("./cert-store");
|
|
12
12
|
const office_sideload_1 = require("./office-sideload");
|
|
13
13
|
const remote_hub_gateway_1 = require("./remote-hub-gateway");
|
|
14
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
15
|
+
const version_utils_1 = require("../cli/utils/version-utils");
|
|
16
|
+
const hub_runtime_file_1 = require("./hub-runtime-file");
|
|
14
17
|
// ---------------------------------------------------------------------------
|
|
15
18
|
// State
|
|
16
19
|
// ---------------------------------------------------------------------------
|
|
@@ -176,6 +179,19 @@ function buildTrayMenu(hubUrl) {
|
|
|
176
179
|
}
|
|
177
180
|
},
|
|
178
181
|
},
|
|
182
|
+
{
|
|
183
|
+
// #755: surface the running build version so staleness is diagnosable at a glance.
|
|
184
|
+
label: 'About FRAIM Hub',
|
|
185
|
+
click: () => {
|
|
186
|
+
void electron_1.dialog.showMessageBox({
|
|
187
|
+
type: 'info',
|
|
188
|
+
title: 'About FRAIM Hub',
|
|
189
|
+
message: 'FRAIM Hub',
|
|
190
|
+
detail: `Version ${(0, version_utils_1.getFraimVersion)()}\nIdentity: ~/.fraim/config.json\nLocal server: ${hubUrl}`,
|
|
191
|
+
buttons: ['OK'],
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
},
|
|
179
195
|
{ type: 'separator' },
|
|
180
196
|
{
|
|
181
197
|
label: 'Word Add-in',
|
|
@@ -354,6 +370,19 @@ async function launchDesktopShell(options) {
|
|
|
354
370
|
},
|
|
355
371
|
});
|
|
356
372
|
await server.start(httpPort);
|
|
373
|
+
// #755: record the live instance so `fraim hub` can detect a stale build and
|
|
374
|
+
// replace it instead of re-focusing an outdated process.
|
|
375
|
+
try {
|
|
376
|
+
(0, hub_runtime_file_1.writeHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), {
|
|
377
|
+
pid: process.pid,
|
|
378
|
+
port: httpPort,
|
|
379
|
+
version: (0, version_utils_1.getFraimVersion)(),
|
|
380
|
+
startedAt: new Date().toISOString(),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
console.warn('[fraim] could not write hub-runtime.json:', err);
|
|
385
|
+
}
|
|
357
386
|
ensureWordSideload(options.projectPath, httpPort);
|
|
358
387
|
const hubUrl = `http://127.0.0.1:${httpPort}/ai-hub/`;
|
|
359
388
|
createTray(hubUrl);
|
|
@@ -394,6 +423,12 @@ async function bootstrap() {
|
|
|
394
423
|
});
|
|
395
424
|
electron_1.app.on('before-quit', () => {
|
|
396
425
|
isQuitting = true;
|
|
426
|
+
// #755: clear the runtime file so a later `fraim hub` doesn't treat a
|
|
427
|
+
// cleanly-exited instance as live.
|
|
428
|
+
try {
|
|
429
|
+
(0, hub_runtime_file_1.removeHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)());
|
|
430
|
+
}
|
|
431
|
+
catch { /* best-effort */ }
|
|
397
432
|
void stopServerOnce();
|
|
398
433
|
});
|
|
399
434
|
// Keep app alive when all windows are closed — server must keep serving
|
|
@@ -0,0 +1,52 @@
|
|
|
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.getLatestPublishedVersion = getLatestPublishedVersion;
|
|
7
|
+
exports.__resetLatestVersionCache = __resetLatestVersionCache;
|
|
8
|
+
const https_1 = __importDefault(require("https"));
|
|
9
|
+
// Issue #755: best-effort "latest published fraim version" for the Hub's
|
|
10
|
+
// update-available nudge. Cached in-process (1h TTL) and never throws — returns
|
|
11
|
+
// null when offline or the registry is unreachable, so the nudge simply hides.
|
|
12
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
13
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/fraim/latest';
|
|
14
|
+
let cache = { value: null, at: 0 };
|
|
15
|
+
async function getLatestPublishedVersion() {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
if (cache.at !== 0 && now - cache.at < TTL_MS)
|
|
18
|
+
return cache.value;
|
|
19
|
+
const value = await fetchNpmLatest();
|
|
20
|
+
// Only cache successful lookups; a failed fetch retries next call rather than
|
|
21
|
+
// caching a null for an hour.
|
|
22
|
+
if (value !== null)
|
|
23
|
+
cache = { value, at: now };
|
|
24
|
+
return value ?? cache.value;
|
|
25
|
+
}
|
|
26
|
+
/** Test-only: reset the module cache. */
|
|
27
|
+
function __resetLatestVersionCache() {
|
|
28
|
+
cache = { value: null, at: 0 };
|
|
29
|
+
}
|
|
30
|
+
function fetchNpmLatest() {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const req = https_1.default.get(REGISTRY_URL, { timeout: 4000 }, (res) => {
|
|
33
|
+
if (res.statusCode !== 200) {
|
|
34
|
+
res.resume();
|
|
35
|
+
return resolve(null);
|
|
36
|
+
}
|
|
37
|
+
let body = '';
|
|
38
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
39
|
+
res.on('end', () => {
|
|
40
|
+
try {
|
|
41
|
+
const version = JSON.parse(body).version;
|
|
42
|
+
resolve(typeof version === 'string' ? version : null);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
resolve(null);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
req.on('error', () => resolve(null));
|
|
50
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.decideHubLaunch = decideHubLaunch;
|
|
37
|
+
const semver = __importStar(require("semver"));
|
|
38
|
+
function decideHubLaunch(input) {
|
|
39
|
+
const { running, pidAlive, cliVersion, restart, noRestart } = input;
|
|
40
|
+
if (!running || !pidAlive) {
|
|
41
|
+
return { action: 'launch-fresh', reason: 'no live Hub instance is running' };
|
|
42
|
+
}
|
|
43
|
+
if (noRestart) {
|
|
44
|
+
return { action: 'focus-existing', reason: '--no-restart: keeping the running instance' };
|
|
45
|
+
}
|
|
46
|
+
if (restart) {
|
|
47
|
+
return { action: 'replace', reason: '--restart: replacing the running instance' };
|
|
48
|
+
}
|
|
49
|
+
// Only treat as stale when BOTH versions are valid semver and the running one is
|
|
50
|
+
// strictly older. On any uncertainty (unknown/unparseable version) do not kill —
|
|
51
|
+
// conservative default is to focus the existing instance.
|
|
52
|
+
const comparable = !!semver.valid(running.version) && !!semver.valid(cliVersion);
|
|
53
|
+
if (comparable && semver.lt(running.version, cliVersion)) {
|
|
54
|
+
return { action: 'replace', reason: `running ${running.version} is older than ${cliVersion}; replacing with the newer build` };
|
|
55
|
+
}
|
|
56
|
+
return { action: 'focus-existing', reason: `running ${running.version} is current or not comparable to ${cliVersion}` };
|
|
57
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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.hubRuntimeFilePath = hubRuntimeFilePath;
|
|
7
|
+
exports.writeHubRuntimeFile = writeHubRuntimeFile;
|
|
8
|
+
exports.readHubRuntimeFile = readHubRuntimeFile;
|
|
9
|
+
exports.removeHubRuntimeFile = removeHubRuntimeFile;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
// Issue #755: the running desktop Hub writes ~/.fraim/hub-runtime.json so the
|
|
13
|
+
// `fraim hub` CLI can discover a live instance (pid/port/version) before deciding
|
|
14
|
+
// whether to replace a stale build. `fraimDir` is injected (never hard-coded to
|
|
15
|
+
// the real ~/.fraim) so tests isolate via a temp directory.
|
|
16
|
+
const RUNTIME_FILE = 'hub-runtime.json';
|
|
17
|
+
function hubRuntimeFilePath(fraimDir) {
|
|
18
|
+
return path_1.default.join(fraimDir, RUNTIME_FILE);
|
|
19
|
+
}
|
|
20
|
+
function writeHubRuntimeFile(fraimDir, info) {
|
|
21
|
+
fs_1.default.mkdirSync(fraimDir, { recursive: true });
|
|
22
|
+
fs_1.default.writeFileSync(hubRuntimeFilePath(fraimDir), JSON.stringify(info, null, 2));
|
|
23
|
+
}
|
|
24
|
+
function readHubRuntimeFile(fraimDir) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(hubRuntimeFilePath(fraimDir), 'utf8'));
|
|
27
|
+
if (typeof parsed.pid !== 'number' || typeof parsed.port !== 'number' || typeof parsed.version !== 'string') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return { pid: parsed.pid, port: parsed.port, version: parsed.version, startedAt: String(parsed.startedAt ?? '') };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function removeHubRuntimeFile(fraimDir) {
|
|
37
|
+
try {
|
|
38
|
+
fs_1.default.rmSync(hubRuntimeFilePath(fraimDir), { force: true });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* no-op: absent file is fine */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -63,6 +63,9 @@ const remote_hub_gateway_1 = require("./remote-hub-gateway");
|
|
|
63
63
|
const managed_browser_1 = require("./managed-browser");
|
|
64
64
|
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
65
65
|
const user_config_1 = require("../cli/utils/user-config");
|
|
66
|
+
const version_utils_1 = require("../cli/utils/version-utils");
|
|
67
|
+
const hub_latest_version_1 = require("./hub-latest-version");
|
|
68
|
+
const semver = __importStar(require("semver"));
|
|
66
69
|
let personaHiringModule;
|
|
67
70
|
let managerHiringModule;
|
|
68
71
|
function loadPersonaHiringModule() {
|
|
@@ -1409,6 +1412,9 @@ class AiHubServer {
|
|
|
1409
1412
|
return {
|
|
1410
1413
|
title: 'AI Hub',
|
|
1411
1414
|
remoteBaseUrl: (0, remote_hub_gateway_1.resolveFraimRemoteUrl)(),
|
|
1415
|
+
// #755: the running build version, so the account menu can show it and flag
|
|
1416
|
+
// when a newer version is available (see GET /api/ai-hub/version for `latest`).
|
|
1417
|
+
version: (0, version_utils_1.getFraimVersion)(),
|
|
1412
1418
|
project,
|
|
1413
1419
|
// Issue #750: `apiKey` is no longer a persisted AiHubPreferences field —
|
|
1414
1420
|
// it is overlaid on the wire response only, freshly resolved from
|
|
@@ -1932,15 +1938,7 @@ class AiHubServer {
|
|
|
1932
1938
|
// passes raw invocation syntax.
|
|
1933
1939
|
const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
|
|
1934
1940
|
const effectiveJobId = explicit?.jobId || coachingJobId || run.jobId;
|
|
1935
|
-
// #730: the manager thread shows the coaching turn with high fidelity — the
|
|
1936
|
-
// correct FRAIM command in front plus what the manager actually said — built
|
|
1937
|
-
// from the shared normalizer, exactly like the start path. With no
|
|
1938
|
-
// instructions (e.g. a plain coaching-template click) buildManagerMessage
|
|
1939
|
-
// collapses to just the invocation. The employee additionally receives the
|
|
1940
|
-
// communication-style note (and, per #732 below, a lighter same-job payload);
|
|
1941
|
-
// those operational details stay out of the visible bubble.
|
|
1942
1941
|
const userText = (explicit?.remainder || instructions || '').trim();
|
|
1943
|
-
const display = (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions);
|
|
1944
1942
|
// Issue #732: a plain continue of the SAME active real job must not re-load
|
|
1945
1943
|
// the job via get_fraim_job on every coaching turn — the host session is
|
|
1946
1944
|
// resumed with the job already in context, so re-fetching is wasted latency
|
|
@@ -1949,9 +1947,26 @@ class AiHubServer {
|
|
|
1949
1947
|
// template or an explicit /fraim <other>). Freeform/adhoc runs keep the
|
|
1950
1948
|
// existing buildManagerMessage path (which already emits no invocation).
|
|
1951
1949
|
const switchesJob = effectiveJobId !== run.jobId;
|
|
1952
|
-
const
|
|
1953
|
-
|
|
1954
|
-
|
|
1950
|
+
const showsInvocation = switchesJob || run.jobId === '__freeform__';
|
|
1951
|
+
// Issue #756: the manager bubble must MIRROR what actually happens. A
|
|
1952
|
+
// same-job coaching continue resumes the active job WITHOUT re-invoking it
|
|
1953
|
+
// (the `message` below carries no `/fraim`), so the bubble shows only what
|
|
1954
|
+
// the manager SAID — never a `/fraim <same-job>` line, which reads as a full
|
|
1955
|
+
// re-run of the active job Y and misled managers into thinking coaching
|
|
1956
|
+
// restarts the workflow. Only a real job SWITCH actually issues an
|
|
1957
|
+
// invocation, so only then does the bubble lead with the command. This still
|
|
1958
|
+
// upholds the #730 guarantee: the manager's coaching text is never dropped,
|
|
1959
|
+
// and when a command IS issued it appears in front of that text.
|
|
1960
|
+
// The invocation form (`/fraim <job>` + words) is built once and reused for
|
|
1961
|
+
// both the bubble and the agent payload when a command is actually issued.
|
|
1962
|
+
const invocationForm = showsInvocation
|
|
1963
|
+
? (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions)
|
|
1964
|
+
: null;
|
|
1965
|
+
// Bubble: the command form for a real switch, else the manager's own words.
|
|
1966
|
+
const display = invocationForm ?? userText;
|
|
1967
|
+
// Agent payload: the same command form for a switch, else a lightweight
|
|
1968
|
+
// same-job continue; the communication-style note is agent-only.
|
|
1969
|
+
const message = (invocationForm ?? (0, manager_turns_1.buildSameJobContinueMessage)(userText)) + (0, manager_turns_1.buildCommunicationStyleNote)();
|
|
1955
1970
|
return { message, display };
|
|
1956
1971
|
}
|
|
1957
1972
|
async computePersonas(apiKey) {
|
|
@@ -2370,6 +2385,15 @@ class AiHubServer {
|
|
|
2370
2385
|
this.preferencesStore.save({ ...prefs, personaKey: personaKey ?? null });
|
|
2371
2386
|
return res.json({ ok: true });
|
|
2372
2387
|
});
|
|
2388
|
+
// #755: running build version + best-effort latest published version, so the
|
|
2389
|
+
// account menu can show the version and flag when an update is available. The
|
|
2390
|
+
// `fraim hub` CLI also queries this to confirm a running instance's version.
|
|
2391
|
+
this.app.get('/api/ai-hub/version', async (_req, res) => {
|
|
2392
|
+
const version = (0, version_utils_1.getFraimVersion)();
|
|
2393
|
+
const latest = await (0, hub_latest_version_1.getLatestPublishedVersion)();
|
|
2394
|
+
const updateAvailable = !!(latest && semver.valid(version) && semver.valid(latest) && semver.gt(latest, version));
|
|
2395
|
+
return res.json({ version, latest, updateAvailable });
|
|
2396
|
+
});
|
|
2373
2397
|
this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
|
|
2374
2398
|
try {
|
|
2375
2399
|
const projectPath = await this.folderPicker();
|
|
@@ -45,6 +45,14 @@ const git_utils_1 = require("../../core/utils/git-utils");
|
|
|
45
45
|
const path_1 = __importDefault(require("path"));
|
|
46
46
|
const child_process_1 = require("child_process");
|
|
47
47
|
const fs_1 = __importDefault(require("fs"));
|
|
48
|
+
const net_1 = __importDefault(require("net"));
|
|
49
|
+
const http_1 = __importDefault(require("http"));
|
|
50
|
+
// #755: light, server-free modules — safe to import eagerly in the packed client
|
|
51
|
+
// (unlike ai-hub/server, which is lazy-required per #422).
|
|
52
|
+
const hub_launch_decision_1 = require("../../ai-hub/hub-launch-decision");
|
|
53
|
+
const hub_runtime_file_1 = require("../../ai-hub/hub-runtime-file");
|
|
54
|
+
const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
|
|
55
|
+
const version_utils_1 = require("../utils/version-utils");
|
|
48
56
|
function resolveElectronBinary() {
|
|
49
57
|
try {
|
|
50
58
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
@@ -97,18 +105,116 @@ function openBrowser(url) {
|
|
|
97
105
|
const child = (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
98
106
|
child.unref();
|
|
99
107
|
}
|
|
108
|
+
// #755: version-aware kill-and-replace. Electron's single-instance lock otherwise
|
|
109
|
+
// re-focuses a stale running build instead of launching a newly-released one.
|
|
110
|
+
function isProcessAlive(pid) {
|
|
111
|
+
try {
|
|
112
|
+
process.kill(pid, 0);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
// EPERM => the process exists but we can't signal it (still "alive").
|
|
117
|
+
return !!(e && e.code === 'EPERM');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function killPid(pid) {
|
|
121
|
+
try {
|
|
122
|
+
if (process.platform === 'win32') {
|
|
123
|
+
(0, child_process_1.execFileSync)('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
process.kill(pid, 'SIGTERM');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* already gone */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function isPortFree(port, timeoutMs = 500) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
const sock = net_1.default.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); resolve(false); });
|
|
136
|
+
sock.on('error', () => resolve(true));
|
|
137
|
+
sock.setTimeout(timeoutMs, () => { sock.destroy(); resolve(true); });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async function waitForPortFree(port, totalMs = 5000) {
|
|
141
|
+
const start = Date.now();
|
|
142
|
+
while (Date.now() - start < totalMs) {
|
|
143
|
+
if (await isPortFree(port))
|
|
144
|
+
return;
|
|
145
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Reconcile a running desktop Hub with the version about to launch. Replaces a
|
|
150
|
+
* stale/older instance (or any instance under --restart) so the new build takes
|
|
151
|
+
* over; keeps the running instance under --keep-running.
|
|
152
|
+
*/
|
|
153
|
+
/** Confirm the recorded port actually serves a FRAIM Hub, returning its authoritative version. */
|
|
154
|
+
function fetchRunningHubVersion(port) {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/version', timeout: 1000 }, (res) => {
|
|
157
|
+
if (res.statusCode !== 200) {
|
|
158
|
+
res.resume();
|
|
159
|
+
return resolve(null);
|
|
160
|
+
}
|
|
161
|
+
let body = '';
|
|
162
|
+
res.on('data', (c) => { body += c; });
|
|
163
|
+
res.on('end', () => { try {
|
|
164
|
+
resolve(JSON.parse(body).version ?? null);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
resolve(null);
|
|
168
|
+
} });
|
|
169
|
+
});
|
|
170
|
+
req.on('error', () => resolve(null));
|
|
171
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async function reconcileRunningHub(flags) {
|
|
175
|
+
const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)());
|
|
176
|
+
// Confirm the recorded port genuinely serves a FRAIM Hub before trusting the file.
|
|
177
|
+
// This (a) prevents killing an unrelated process that reused a crashed Hub's pid,
|
|
178
|
+
// and (b) yields the authoritative running version (the file may be stale).
|
|
179
|
+
const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
|
|
180
|
+
const live = !!(running && confirmedVersion && isProcessAlive(running.pid));
|
|
181
|
+
const effective = live && running ? { ...running, version: confirmedVersion } : null;
|
|
182
|
+
const decision = (0, hub_launch_decision_1.decideHubLaunch)({
|
|
183
|
+
running: effective,
|
|
184
|
+
pidAlive: live,
|
|
185
|
+
cliVersion: (0, version_utils_1.getFraimVersion)(),
|
|
186
|
+
restart: flags.restart,
|
|
187
|
+
noRestart: flags.keepRunning,
|
|
188
|
+
});
|
|
189
|
+
if (decision.action === 'replace' && effective) {
|
|
190
|
+
console.log(`Replacing running Hub (v${effective.version}, pid ${effective.pid}) — ${decision.reason}`);
|
|
191
|
+
killPid(effective.pid);
|
|
192
|
+
await waitForPortFree(effective.port);
|
|
193
|
+
}
|
|
194
|
+
else if (decision.action === 'focus-existing' && effective) {
|
|
195
|
+
console.log(`A Hub (v${effective.version}) is already running — focusing it. Use --restart to replace it.`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
100
198
|
exports.hubCommand = new commander_1.Command('hub')
|
|
101
199
|
.description('Start the AI Hub local companion for running FRAIM jobs through Codex or Claude Code')
|
|
102
200
|
.option('--port <port>', 'Preferred local port for the hub', (value) => Number(value), 43091)
|
|
103
201
|
.option('--project-path <path>', 'Initial project path for job discovery', process.cwd())
|
|
104
202
|
.option('--no-open', 'Do not open the hub after startup')
|
|
105
203
|
.option('--browser', 'Open in the default browser instead of the desktop shell')
|
|
204
|
+
.option('--restart', 'Replace any running Hub instance with this build (even at the same version)')
|
|
205
|
+
.option('--keep-running', 'Do not replace a running Hub; keep single-instance focus (default replaces only an older/stale instance)')
|
|
106
206
|
.action(async (options) => {
|
|
107
207
|
const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('../../ai-hub/server')));
|
|
108
208
|
const preferredPort = options.port || (0, git_utils_1.getPort)() + 100;
|
|
109
209
|
const projectPath = path_1.default.resolve(options.projectPath || process.cwd());
|
|
110
210
|
if (options.open) {
|
|
111
|
-
const
|
|
211
|
+
const wantDesktop = !options.browser;
|
|
212
|
+
// #755: before spawning Electron (whose single-instance lock would re-focus a
|
|
213
|
+
// stale build), replace an older/forced running instance so the new build wins.
|
|
214
|
+
if (wantDesktop) {
|
|
215
|
+
await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning });
|
|
216
|
+
}
|
|
217
|
+
const openedDesktop = wantDesktop && openDesktopWindow(projectPath, preferredPort);
|
|
112
218
|
if (!openedDesktop) {
|
|
113
219
|
const port = await findAvailablePort(preferredPort);
|
|
114
220
|
const server = new AiHubServer({ projectPath });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-framework",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.201",
|
|
4
4
|
"description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/public/ai-hub/index.html
CHANGED
|
@@ -59,7 +59,12 @@
|
|
|
59
59
|
<span class="am-switch" aria-hidden="true"><span class="am-switch-knob"></span></span>
|
|
60
60
|
</button>
|
|
61
61
|
<div class="am-div"></div>
|
|
62
|
+
<div class="am-update" id="am-update" hidden>
|
|
63
|
+
<span class="am-ico">⬆️</span>
|
|
64
|
+
<div><div class="am-label">Update available</div><div class="am-sub" id="am-update-sub"></div></div>
|
|
65
|
+
</div>
|
|
62
66
|
<button class="am-foot" id="am-signout" type="button"><span class="am-ico">⏻</span><span>Sign out</span></button>
|
|
67
|
+
<div class="am-version" id="am-version" title="Running FRAIM Hub build"></div>
|
|
63
68
|
</div>
|
|
64
69
|
</div>
|
|
65
70
|
</nav>
|
package/public/ai-hub/script.js
CHANGED
|
@@ -10876,6 +10876,30 @@ function tfPopulateAccountMenu() {
|
|
|
10876
10876
|
if (emailEl) emailEl.textContent = id.notConnected ? 'Not connected — run `fraim setup`' : id.email;
|
|
10877
10877
|
if (avEl) avEl.textContent = id.initials;
|
|
10878
10878
|
if (avatarBtn) avatarBtn.textContent = id.initials;
|
|
10879
|
+
tfPopulateVersionInfo(); // #755
|
|
10880
|
+
}
|
|
10881
|
+
|
|
10882
|
+
// #755: show the running Hub build version and, when a newer version is published,
|
|
10883
|
+
// an update-available nudge. Running version is cheap (from bootstrap); the latest
|
|
10884
|
+
// check hits the server's cached /api/ai-hub/version (best-effort, hidden on failure).
|
|
10885
|
+
function tfPopulateVersionInfo() {
|
|
10886
|
+
const versionEl = document.getElementById('am-version');
|
|
10887
|
+
const running = (state.bootstrap && state.bootstrap.version) || '';
|
|
10888
|
+
if (versionEl) versionEl.textContent = running ? `FRAIM Hub v${running}` : '';
|
|
10889
|
+
const updateEl = document.getElementById('am-update');
|
|
10890
|
+
const updateSub = document.getElementById('am-update-sub');
|
|
10891
|
+
if (!updateEl) return;
|
|
10892
|
+
fetch('/api/ai-hub/version')
|
|
10893
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
10894
|
+
.then((info) => {
|
|
10895
|
+
if (info && info.updateAvailable && info.latest) {
|
|
10896
|
+
if (updateSub) updateSub.textContent = `v${info.latest} is available — quit the Hub (tray → Quit) and relaunch to update.`;
|
|
10897
|
+
updateEl.hidden = false;
|
|
10898
|
+
} else {
|
|
10899
|
+
updateEl.hidden = true;
|
|
10900
|
+
}
|
|
10901
|
+
})
|
|
10902
|
+
.catch(() => { /* offline / best-effort: leave the nudge hidden */ });
|
|
10879
10903
|
}
|
|
10880
10904
|
|
|
10881
10905
|
function tfRequestedConnectedSurface() {
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -2783,6 +2783,13 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
2783
2783
|
.am-div { height: 1px; background: var(--line); margin: 4px 0; }
|
|
2784
2784
|
.am-foot { padding: 8px 16px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
|
2785
2785
|
.am-foot:hover { background: var(--bg); }
|
|
2786
|
+
/* #755: running-version footer + update-available nudge in the account menu */
|
|
2787
|
+
.am-version { padding: 6px 16px 10px; font-size: 10px; color: var(--muted); text-align: center; }
|
|
2788
|
+
#account-menu .am-update:not([hidden]) { display: grid; grid-template-columns: 30px 1fr; column-gap: 12px; align-items: start; }
|
|
2789
|
+
#account-menu .am-update { padding: 9px 16px; margin: 2px 8px 4px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; }
|
|
2790
|
+
#account-menu .am-update .am-ico { width: 30px; margin: 0; text-align: center; font-size: 16px; }
|
|
2791
|
+
.am-update .am-label { font-size: 13px; font-weight: 700; color: var(--text); }
|
|
2792
|
+
.am-update .am-sub { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
|
2786
2793
|
/* #700: theme toggle row + iOS-style switch */
|
|
2787
2794
|
.am-theme { align-items: center; width: 100%; border: none; background: none; text-align: left; font: inherit; color: var(--text); }
|
|
2788
2795
|
.am-theme .am-switch { box-sizing: border-box; margin-left: auto; align-self: center; width: 38px; height: 22px; border-radius: 999px; background: var(--soft); border: 1px solid var(--line); position: relative; flex-shrink: 0; transition: background .18s ease, border-color .18s ease; }
|