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.
@@ -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 message = (switchesJob || run.jobId === '__freeform__')
1953
- ? (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)()
1954
- : (0, manager_turns_1.buildSameJobContinueMessage)(userText) + (0, manager_turns_1.buildCommunicationStyleNote)();
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 openedDesktop = !options.browser && openDesktopWindow(projectPath, preferredPort);
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.200",
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": {
@@ -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>
@@ -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() {
@@ -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; }