fraim-framework 2.0.199 → 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.
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AiHubConversationStore = exports.COMPANY_SCOPE_KEY = exports.MANAGER_SCOPE_KEY = void 0;
7
7
  exports.conversationScopeKey = conversationScopeKey;
8
+ exports.migrateConversationStore = migrateConversationStore;
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const path_1 = __importDefault(require("path"));
10
11
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
@@ -93,6 +94,103 @@ function migrateScopeBuckets(store) {
93
94
  }
94
95
  return { version: 1, projects };
95
96
  }
97
+ // Canonical comparison key for a project path (case-insensitive on win32), matching the
98
+ // convention used by removeProject and the preferences store.
99
+ function canonicalProjectPathKey(value) {
100
+ const resolved = path_1.default.resolve(value);
101
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
102
+ }
103
+ // How much run history a record carries. Used to decide the winner when the same conversation id
104
+ // exists in two buckets: the copy with real history must never be overwritten by a header-only
105
+ // shell (which is exactly what a mis-filed slim PUT produces).
106
+ function conversationRichness(conv) {
107
+ const value = conv;
108
+ const messages = Array.isArray(value?.messages) ? value.messages.length : 0;
109
+ const events = Array.isArray(value?.events) ? value.events.length : 0;
110
+ return messages + events;
111
+ }
112
+ // Place a raw conversation into a bucket, deduping by id. When the id already exists, keep the
113
+ // richer copy (more run history); tie-break on newer lastUpdatedAt. Order-independent.
114
+ function placeConversationInBucket(bucket, conv) {
115
+ const value = conv;
116
+ const id = value && typeof value.id === 'string' ? value.id : '';
117
+ if (!id) {
118
+ bucket.conversations.push(conv);
119
+ return;
120
+ }
121
+ const idx = bucket.conversations.findIndex((entry) => entry && entry.id === id);
122
+ if (idx < 0) {
123
+ bucket.conversations.push(conv);
124
+ return;
125
+ }
126
+ const existing = bucket.conversations[idx];
127
+ const existingScore = conversationRichness(existing);
128
+ const incomingScore = conversationRichness(conv);
129
+ if (incomingScore > existingScore) {
130
+ bucket.conversations[idx] = conv;
131
+ }
132
+ else if (incomingScore === existingScore
133
+ && timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
134
+ bucket.conversations[idx] = conv;
135
+ }
136
+ }
137
+ // A conversation's identity is unique only WITHIN a bucket, and a bucket key is a project path.
138
+ // The buggy client PUT path could file a conversation whose own projectPath points at project B
139
+ // into project A's bucket (see docs/rca/hub-conversation-cross-project-leak.md), so the same run
140
+ // appeared under two projects and the mis-filed copy was a header-only shell. This one-time,
141
+ // idempotent, read-time migration relocates every project-scoped record back to the bucket that
142
+ // matches its own projectPath, deduping so the copy with real run history wins. A bucket activeId
143
+ // that pointed at a relocated record is cleared. Sentinel scope buckets (@manager/@company)
144
+ // legitimately hold records whose projectPath points at a project, so they are left untouched.
145
+ function migrateProjectBuckets(store) {
146
+ const projects = {};
147
+ const ensure = (key) => {
148
+ if (!projects[key])
149
+ projects[key] = { activeId: null, conversations: [] };
150
+ return projects[key];
151
+ };
152
+ // Preserve every existing bucket key and its activeId up front.
153
+ for (const [key, state] of Object.entries(store.projects || {})) {
154
+ ensure(key).activeId = state.activeId ?? null;
155
+ }
156
+ for (const [bucketKey, state] of Object.entries(store.projects || {})) {
157
+ const isSentinel = bucketKey === exports.MANAGER_SCOPE_KEY || bucketKey === exports.COMPANY_SCOPE_KEY;
158
+ for (const conv of (state.conversations || [])) {
159
+ if (!conv || typeof conv !== 'object')
160
+ continue;
161
+ const scope = conv.scope
162
+ ?? conv.invokedArea;
163
+ const ownPath = typeof conv.projectPath === 'string'
164
+ ? conv.projectPath
165
+ : '';
166
+ const mismatched = !isSentinel
167
+ && scope !== 'manager' && scope !== 'company'
168
+ && ownPath.length > 0
169
+ && canonicalProjectPathKey(ownPath) !== canonicalProjectPathKey(bucketKey);
170
+ if (!mismatched) {
171
+ placeConversationInBucket(ensure(bucketKey), conv);
172
+ continue;
173
+ }
174
+ // Relocate to the record's own project bucket.
175
+ placeConversationInBucket(ensure(path_1.default.resolve(ownPath)), conv);
176
+ }
177
+ }
178
+ // Any activeId that no longer resolves to a conversation in its bucket (because that record was
179
+ // relocated out) is stale — clear it so a project never points at another project's run.
180
+ for (const state of Object.values(projects)) {
181
+ if (state.activeId && !state.conversations.some((entry) => entry && entry.id === state.activeId)) {
182
+ state.activeId = null;
183
+ }
184
+ }
185
+ return { version: 1, projects };
186
+ }
187
+ // The full on-read migration pipeline, exported so a one-off maintenance/cleanup pass can heal an
188
+ // existing store file through the EXACT same code path the store uses at runtime (rather than a
189
+ // duplicated, drift-prone reimplementation). Operates on raw records, so it never drops a record
190
+ // via normalization.
191
+ function migrateConversationStore(store) {
192
+ return migrateProjectBuckets(migrateScopeBuckets(store));
193
+ }
96
194
  function normalizeConversation(projectPath, raw) {
97
195
  if (!raw || typeof raw !== 'object')
98
196
  return null;
@@ -242,8 +340,10 @@ class AiHubConversationStore {
242
340
  if (raw.version !== 1 || !raw.projects || typeof raw.projects !== 'object') {
243
341
  return { version: 1, projects: {} };
244
342
  }
245
- // Issue #708: fold legacy manager/company-invoked runs into scope buckets on read.
246
- return migrateScopeBuckets({ version: 1, projects: raw.projects });
343
+ // Issue #708: fold legacy manager/company-invoked runs into scope buckets on read, then
344
+ // relocate any project-scoped record mis-filed under the wrong project bucket back to its
345
+ // own project (see docs/rca/hub-conversation-cross-project-leak.md).
346
+ return migrateProjectBuckets(migrateScopeBuckets({ version: 1, projects: raw.projects }));
247
347
  }
248
348
  catch {
249
349
  return { version: 1, projects: {} };
@@ -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
+ }
@@ -140,7 +140,9 @@ class AiHubPreferencesStore {
140
140
  ? raw.recentJobInstructions
141
141
  : {},
142
142
  personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
143
- apiKey: typeof raw.apiKey === 'string' && raw.apiKey.length > 0 ? raw.apiKey : undefined,
143
+ // Issue #750: apiKey is no longer a persisted field here at all — any
144
+ // legacy apiKey left over from a pre-#750 ai-hub-state.json is silently
145
+ // dropped, never read back. Identity comes from ~/.fraim/config.json only.
144
146
  projects: normalizeAiHubProjectList(Array.isArray(raw.projects) ? raw.projects : [], projectPath, { removedProjectPaths }),
145
147
  removedProjectPaths,
146
148
  };
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
40
  exports.configureFraimForHubAgent = configureFraimForHubAgent;
41
+ exports.buildOpenFileInvocation = buildOpenFileInvocation;
41
42
  exports.findAvailablePort = findAvailablePort;
42
43
  exports.findAvailablePortExcluding = findAvailablePortExcluding;
43
44
  const express_1 = __importDefault(require("express"));
@@ -61,6 +62,10 @@ const conversation_store_1 = require("./conversation-store");
61
62
  const remote_hub_gateway_1 = require("./remote-hub-gateway");
62
63
  const managed_browser_1 = require("./managed-browser");
63
64
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
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"));
64
69
  let personaHiringModule;
65
70
  let managerHiringModule;
66
71
  function loadPersonaHiringModule() {
@@ -973,28 +978,43 @@ function resolveSafeArtifactPath(rawPath, projectPath) {
973
978
  ];
974
979
  return safeRoots.some((root) => pathWithin(root, resolved)) ? resolved : null;
975
980
  }
976
- function hubOpenFile(filePath) {
977
- return new Promise((resolve, reject) => {
978
- const args = process.platform === 'win32'
979
- ? [
981
+ // Builds the OS "open this file with its default app" invocation. Exported for regression
982
+ // testing (see tests/isolated/test-ai-hub-open-file.ts).
983
+ //
984
+ // Windows contract: the path travels via an ENVIRONMENT VARIABLE, never as a trailing `-Command`
985
+ // argument. PowerShell re-parses trailing args on its own command line and mangles Windows paths —
986
+ // it strips backslashes and truncates at the first space — so `Invoke-Item -LiteralPath $p`
987
+ // received a broken path (e.g. `C:UserssidmaOneDriveCodeDrug`) and failed for every real artifact
988
+ // path, which is why the Hub's "open file" button did nothing. `$env:FRAIM_OPEN_PATH` is read
989
+ // directly from the environment and is not re-parsed, so the path arrives intact (verified against
990
+ // paths containing spaces and backslashes).
991
+ function buildOpenFileInvocation(filePath, platform = process.platform) {
992
+ if (platform === 'win32') {
993
+ return {
994
+ command: 'powershell.exe',
995
+ args: [
980
996
  '-NoProfile',
981
997
  '-ExecutionPolicy',
982
998
  'Bypass',
983
999
  '-Command',
984
- '& { param($p) try { Invoke-Item -LiteralPath $p; exit 0 } catch { Write-Error $_; exit 1 } }',
985
- filePath,
986
- ]
987
- : process.platform === 'darwin'
988
- ? [filePath]
989
- : [filePath];
990
- const command = process.platform === 'win32'
991
- ? 'powershell.exe'
992
- : process.platform === 'darwin'
993
- ? 'open'
994
- : 'xdg-open';
1000
+ 'try { Invoke-Item -LiteralPath $env:FRAIM_OPEN_PATH; exit 0 } catch { Write-Error $_; exit 1 }',
1001
+ ],
1002
+ envPatch: { FRAIM_OPEN_PATH: filePath },
1003
+ };
1004
+ }
1005
+ return {
1006
+ command: platform === 'darwin' ? 'open' : 'xdg-open',
1007
+ args: [filePath],
1008
+ envPatch: {},
1009
+ };
1010
+ }
1011
+ function hubOpenFile(filePath) {
1012
+ return new Promise((resolve, reject) => {
1013
+ const { command, args, envPatch } = buildOpenFileInvocation(filePath);
995
1014
  const child = (0, child_process_1.spawn)(command, args, {
996
1015
  stdio: ['ignore', 'ignore', 'pipe'],
997
1016
  windowsHide: process.platform === 'win32',
1017
+ env: { ...process.env, ...envPatch },
998
1018
  });
999
1019
  let stderr = '';
1000
1020
  child.stderr?.on('data', (chunk) => {
@@ -1051,53 +1071,15 @@ function deploymentBelongsToProject(deployment, projectPath, fallbackProjectPath
1051
1071
  // ---------------------------------------------------------------------------
1052
1072
  // Issue #512 (S3) — Hub bootstrap projection helpers.
1053
1073
  // ---------------------------------------------------------------------------
1054
- // Resolve the user email used to key personal (L1) learnings. Reads
1055
- // ~/.fraim/preferences.json (the existing user-prefs file) when present so the
1056
- // Brain counts match the auto-load context; falls back to a sensible default
1057
- // that resolveLearningUserId can refine against on-disk files.
1058
- function getHubUserEmail() {
1059
- try {
1060
- const prefsPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'preferences.json');
1061
- if (fs_1.default.existsSync(prefsPath)) {
1062
- const raw = JSON.parse(fs_1.default.readFileSync(prefsPath, 'utf8'));
1063
- if (typeof raw.userEmail === 'string' && raw.userEmail.length > 0)
1064
- return raw.userEmail;
1065
- }
1066
- }
1067
- catch {
1068
- // fall through to default
1069
- }
1070
- // #533: preferences.json may not carry the email yet — the proxy persists it on
1071
- // fraim_connect (see stdio-server). Until then, personal learnings are ALWAYS
1072
- // stamped `<email>-<type>.md`, so a single-user machine resolves deterministically
1073
- // from the on-disk files. This is what makes the avatar + personal learnings work.
1074
- const stamped = resolveSingleStampedUser();
1075
- if (stamped)
1076
- return stamped;
1077
- return 'fraim-user';
1078
- }
1079
- // Return the one user prefix stamped on the global learning files, or null if
1080
- // there is not exactly one (so we never guess between multiple accounts).
1081
- function resolveSingleStampedUser() {
1082
- try {
1083
- const dir = (0, project_fraim_paths_1.getUserFraimLearningsDir)();
1084
- if (!fs_1.default.existsSync(dir))
1085
- return null;
1086
- const prefixes = new Set();
1087
- for (const f of fs_1.default.readdirSync(dir)) {
1088
- if (f.startsWith('org-'))
1089
- continue;
1090
- const m = f.match(/^(.*)-(preferences|manager-coaching|mistake-patterns|validated-patterns)\.md$/);
1091
- if (m)
1092
- prefixes.add(m[1]);
1093
- }
1094
- if (prefixes.size === 1)
1095
- return Array.from(prefixes)[0];
1096
- }
1097
- catch {
1098
- // ignore unreadable dir
1099
- }
1100
- return null;
1074
+ // Issue #750: the Hub's identity for persona/entitlement resolution, the
1075
+ // displayed "signed in as" email, and personal (L1) learnings keying comes
1076
+ // from ~/.fraim/config.json's apiKey (architecture.md §4.3.4's "Global config
1077
+ // stores only identity"), the same credential every other FRAIM CLI/MCP
1078
+ // surface already reads via readUserFraimConfig(). There is no fallback: a
1079
+ // missing or invalid apiKey resolves to null (rendered as a "not connected"
1080
+ // state), never a guess from another local file.
1081
+ function resolveApiKey() {
1082
+ return (0, user_config_1.readUserFraimConfig)().apiKey;
1101
1083
  }
1102
1084
  // Read persisted Get-started step states from ~/.fraim/install-state.json
1103
1085
  // (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
@@ -1177,21 +1159,13 @@ class AiHubServer {
1177
1159
  explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
1178
1160
  });
1179
1161
  this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
1180
- // Issue #701: the AI Hub is a loopback companion that never touches MongoDB directly
1181
- // on every machine (dev, CI, prod) it resolves persona/manager-team state from the hosted
1182
- // server through the same code path. The hosted server is the sole owner of DB access.
1183
- // A dbService is used ONLY when explicitly injected (e.g. the hosted server embedding the
1184
- // Hub, or a test); it is never auto-created, so there is no dev/prod divergence.
1185
- this.dbService = options.dbService;
1186
- this.ownsDbService = false;
1162
+ // Issue #701 / #749: the AI Hub is a loopback companion that runs on user machines and
1163
+ // never touches a database. Persona and manager-team state resolve from the hosted server
1164
+ // through the remote gateway; the hosted server is the sole owner of DB access.
1187
1165
  this.remoteGateway = options.remoteGateway ?? new remote_hub_gateway_1.HttpHubRemoteGateway();
1188
1166
  this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
1189
1167
  this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
1190
1168
  this.app.use(express_1.default.json({ limit: '10mb' }));
1191
- if (this.dbService) {
1192
- const { registerPaymentRoutes } = require('../routes/payment-routes');
1193
- registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
1194
- }
1195
1169
  // CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
1196
1170
  // calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
1197
1171
  this.app.use((_req, res, next) => {
@@ -1207,8 +1181,7 @@ class AiHubServer {
1207
1181
  });
1208
1182
  // Payment success redirect: sync entitlement then bounce to the hub.
1209
1183
  // Stripe redirects here after a completed persona-hire checkout.
1210
- this.app.get('/ai-hub/payment-success', async (req, res) => {
1211
- const sessionId = req.query['session_id'];
1184
+ this.app.get('/ai-hub/payment-success', (req, res) => {
1212
1185
  const email = req.query['email'];
1213
1186
  // Persist the buyer's email so bootstrap can look up entitlements by userId.
1214
1187
  if (email) {
@@ -1223,17 +1196,8 @@ class AiHubServer {
1223
1196
  console.warn('[ai-hub] could not persist userEmail:', err);
1224
1197
  }
1225
1198
  }
1226
- if (sessionId && this.dbService) {
1227
- try {
1228
- const { syncPersonaEntitlementFromCheckoutSession } = await Promise.resolve().then(() => __importStar(require('../services/persona-entitlement-service')));
1229
- const { stripe } = await Promise.resolve().then(() => __importStar(require('../config/stripe')));
1230
- const session = await stripe.checkout.sessions.retrieve(sessionId);
1231
- await syncPersonaEntitlementFromCheckoutSession(this.dbService, session, 'dashboard-link');
1232
- }
1233
- catch (err) {
1234
- console.warn('[ai-hub] payment-success entitlement sync failed:', err);
1235
- }
1236
- }
1199
+ // Issue #749: the Hub holds no DB. Entitlement is synced authoritatively by the backend
1200
+ // Stripe webhook; the Hub re-reads status via the remote gateway on the next bootstrap.
1237
1201
  res.redirect('/ai-hub/?hired=1');
1238
1202
  });
1239
1203
  this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
@@ -1322,20 +1286,6 @@ class AiHubServer {
1322
1286
  }
1323
1287
  async start(port) {
1324
1288
  this.httpPort = port;
1325
- if (this.dbService) {
1326
- try {
1327
- await this.dbService.connect();
1328
- const mongoClient = this.dbService.getClient();
1329
- if (mongoClient) {
1330
- const PaymentRepositoryImpl = require('../db/payment-repository').PaymentRepository;
1331
- this.paymentRepo = new PaymentRepositoryImpl(mongoClient);
1332
- }
1333
- }
1334
- catch (err) {
1335
- console.warn('[ai-hub] DB connect failed — personas will show as locked:', err);
1336
- this.dbService = undefined;
1337
- }
1338
- }
1339
1289
  await new Promise((resolve, reject) => {
1340
1290
  this.httpServer = this.app.listen(port, '127.0.0.1');
1341
1291
  this.httpServer.once('listening', () => resolve());
@@ -1406,10 +1356,6 @@ class AiHubServer {
1406
1356
  // #521: tear down the shared browser if WE launched it (stop() no-ops on a
1407
1357
  // browser the manager owns).
1408
1358
  this.managedBrowser.stop();
1409
- if (this.ownsDbService && this.dbService) {
1410
- await this.dbService.close();
1411
- this.dbService = undefined;
1412
- }
1413
1359
  }
1414
1360
  getHttpsPort() { return this.httpsPort; }
1415
1361
  knownProjects(projectPath, extras = []) {
@@ -1422,7 +1368,7 @@ class AiHubServer {
1422
1368
  ...extras,
1423
1369
  ], normalizedProjectPath, { removedProjectPaths: preferences.removedProjectPaths || [] });
1424
1370
  }
1425
- async bootstrapResponse(projectPath, apiKey) {
1371
+ async bootstrapResponse(projectPath) {
1426
1372
  const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
1427
1373
  const employees = this.hostRuntime.detectEmployees();
1428
1374
  let preferences = this.preferencesStore.load(normalizedProjectPath);
@@ -1449,9 +1395,12 @@ class AiHubServer {
1449
1395
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
1450
1396
  }));
1451
1397
  const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
1452
- const resolvedApiKey = apiKey || preferences.apiKey;
1398
+ // Issue #750: the apiKey always comes from ~/.fraim/config.json — no header
1399
+ // override, no ai-hub-state.json copy, no fallback chain.
1400
+ const resolvedApiKey = resolveApiKey();
1453
1401
  const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(resolvedApiKey);
1454
1402
  const managerTeam = await this.computeManagerTeam(resolvedApiKey);
1403
+ const resolvedUserEmail = userKey ?? null;
1455
1404
  const projects = this.knownProjects(normalizedProjectPath);
1456
1405
  preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
1457
1406
  this.preferencesStore.save(preferences);
@@ -1463,8 +1412,15 @@ class AiHubServer {
1463
1412
  return {
1464
1413
  title: 'AI Hub',
1465
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)(),
1466
1418
  project,
1467
- preferences,
1419
+ // Issue #750: `apiKey` is no longer a persisted AiHubPreferences field —
1420
+ // it is overlaid on the wire response only, freshly resolved from
1421
+ // ~/.fraim/config.json, so public/ai-hub/script.js's existing
1422
+ // tfConnectedApiKey()/tfConnectedSurfaceUrl read path needs no changes.
1423
+ preferences: { ...preferences, apiKey: resolvedApiKey },
1468
1424
  categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
1469
1425
  jobs,
1470
1426
  managerTemplates,
@@ -1476,10 +1432,11 @@ class AiHubServer {
1476
1432
  // Issue #512 (S3) — additive manager-flow projections.
1477
1433
  firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
1478
1434
  teamContext: this.computeTeamContext(normalizedProjectPath),
1479
- brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length),
1480
- // #533: the resolved account email, so the profile card shows the real
1481
- // identity (not a hardcoded placeholder) and the client can display it.
1482
- userEmail: getHubUserEmail(),
1435
+ brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length, resolvedUserEmail),
1436
+ // #533/#750: the resolved account email (from the same ~/.fraim/config.json
1437
+ // apiKey used for personas above), or null when not connected — so the
1438
+ // profile card and personas can never disagree about who's signed in.
1439
+ userEmail: resolvedUserEmail,
1483
1440
  // #744: the org cobrand identity (name/color/logo) from the org context
1484
1441
  // storage, or null when unset so the Hub falls back to FRAIM identity.
1485
1442
  orgBrand: (0, learning_context_builder_1.readOrgBrand)(normalizedProjectPath),
@@ -1866,8 +1823,19 @@ class AiHubServer {
1866
1823
  }
1867
1824
  // Issue #512 (S3, R14) — Brain summary: preserved-learning counts by scope +
1868
1825
  // registry catalog counts. A read projection; no new storage.
1869
- computeBrain(projectPath, jobCount) {
1870
- const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, getHubUserEmail());
1826
+ // Issue #750: `userEmail` is the config.json-resolved identity, or `null` when
1827
+ // not connected. `organization`/`rawSignals` are identity-independent (org-*
1828
+ // files and repo-level raw signals) and must always be counted regardless of
1829
+ // connection state — only `manager`/`project` (L1, individual-keyed) are
1830
+ // zeroed when not connected, rather than guessing an identity via
1831
+ // resolveLearningUserId's single-stamped-user fallback (a legitimate
1832
+ // convenience for MCP auto-load context, but not appropriate here).
1833
+ computeBrain(projectPath, jobCount, userEmail) {
1834
+ const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, userEmail || '');
1835
+ if (!userEmail) {
1836
+ learnings.manager = 0;
1837
+ learnings.project = 0;
1838
+ }
1871
1839
  return {
1872
1840
  learnings,
1873
1841
  catalog: {
@@ -1970,15 +1938,7 @@ class AiHubServer {
1970
1938
  // passes raw invocation syntax.
1971
1939
  const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
1972
1940
  const effectiveJobId = explicit?.jobId || coachingJobId || run.jobId;
1973
- // #730: the manager thread shows the coaching turn with high fidelity — the
1974
- // correct FRAIM command in front plus what the manager actually said — built
1975
- // from the shared normalizer, exactly like the start path. With no
1976
- // instructions (e.g. a plain coaching-template click) buildManagerMessage
1977
- // collapses to just the invocation. The employee additionally receives the
1978
- // communication-style note (and, per #732 below, a lighter same-job payload);
1979
- // those operational details stay out of the visible bubble.
1980
1941
  const userText = (explicit?.remainder || instructions || '').trim();
1981
- const display = (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions);
1982
1942
  // Issue #732: a plain continue of the SAME active real job must not re-load
1983
1943
  // the job via get_fraim_job on every coaching turn — the host session is
1984
1944
  // resumed with the job already in context, so re-fetching is wasted latency
@@ -1987,9 +1947,26 @@ class AiHubServer {
1987
1947
  // template or an explicit /fraim <other>). Freeform/adhoc runs keep the
1988
1948
  // existing buildManagerMessage path (which already emits no invocation).
1989
1949
  const switchesJob = effectiveJobId !== run.jobId;
1990
- const message = (switchesJob || run.jobId === '__freeform__')
1991
- ? (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)()
1992
- : (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)();
1993
1970
  return { message, display };
1994
1971
  }
1995
1972
  async computePersonas(apiKey) {
@@ -2053,51 +2030,48 @@ class AiHubServer {
2053
2030
  // Issue #701: manager team comes from the hosted server, not local Mongo.
2054
2031
  return this.remoteGateway.listManagerTeam(apiKey);
2055
2032
  }
2033
+ // Issue #750: identity for routes that don't already call computePersonas()
2034
+ // (which fetches this same hosted state for personas/manager-team). Always
2035
+ // sources the apiKey from ~/.fraim/config.json; null means "not connected" —
2036
+ // no local-guess fallback.
2037
+ async resolveHubIdentity() {
2038
+ const apiKey = resolveApiKey();
2039
+ if (!apiKey)
2040
+ return null;
2041
+ const state = await this.remoteGateway.getPersonaState(apiKey);
2042
+ return state?.userId ?? null;
2043
+ }
2044
+ // Issue #750: shared by the two learnings routes below. 'org' scope never
2045
+ // needs identity (readPreservedLearnings/applyLearningEntryChange ignore the
2046
+ // userId param for it); every other scope resolves through resolveHubIdentity,
2047
+ // returning '' (not a guess) when not connected — callers distinguish
2048
+ // "org" from "not connected" via the scope check they already have to make.
2049
+ async resolveLearningIdentity(scope) {
2050
+ if (scope === 'org')
2051
+ return '';
2052
+ return (await this.resolveHubIdentity()) || '';
2053
+ }
2056
2054
  registerRoutes() {
2057
- // Issue #512: Serve the account and analytics pages from public/.
2058
- // These live outside of /ai-hub so they need explicit routes.
2059
- const publicDir = resolveAiHubPublicDir().replace(/[\\/]ai-hub$/, '');
2060
- if (!this.dbService) {
2061
- this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
2062
- const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
2063
- if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
2064
- const rawSurface = parsed.searchParams.get('surface');
2065
- const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
2066
- ? rawSurface
2067
- : 'analytics';
2068
- parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
2069
- }
2070
- res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
2071
- });
2072
- this.app.get(['/account', '/account/'], (req, res) => {
2073
- res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
2074
- });
2075
- this.app.get(['/analytics', '/analytics/'], (req, res) => {
2076
- res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
2077
- });
2078
- }
2079
- else {
2080
- this.app.get('/account', (_req, res) => {
2081
- const filePath = path_1.default.join(publicDir, 'account', 'index.html');
2082
- if (fs_1.default.existsSync(filePath)) {
2083
- res.sendFile(filePath);
2084
- }
2085
- else {
2086
- res.status(404).send('Account page not found.');
2087
- }
2088
- });
2089
- this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
2090
- this.app.get('/analytics', (_req, res) => {
2091
- const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
2092
- if (fs_1.default.existsSync(filePath)) {
2093
- res.sendFile(filePath);
2094
- }
2095
- else {
2096
- res.status(404).send('Analytics page not found.');
2097
- }
2098
- });
2099
- this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
2100
- }
2055
+ // Issue #512 / #749: the account and analytics surfaces live outside /ai-hub. The
2056
+ // on-machine Hub holds no DB, so it always redirects these (and /auth) to the hosted
2057
+ // server, which owns the authenticated, DB-backed pages.
2058
+ this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
2059
+ const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
2060
+ if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
2061
+ const rawSurface = parsed.searchParams.get('surface');
2062
+ const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
2063
+ ? rawSurface
2064
+ : 'analytics';
2065
+ parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
2066
+ }
2067
+ res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
2068
+ });
2069
+ this.app.get(['/account', '/account/'], (req, res) => {
2070
+ res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
2071
+ });
2072
+ this.app.get(['/analytics', '/analytics/'], (req, res) => {
2073
+ res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
2074
+ });
2101
2075
  // Issue #478: Serve the PowerPoint task pane HTML and manifest.
2102
2076
  // Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
2103
2077
  // request, so we must strip them before resolving the file path. Use a custom
@@ -2149,8 +2123,6 @@ class AiHubServer {
2149
2123
  }
2150
2124
  catch { }
2151
2125
  }
2152
- // Read API key from header — query-param API keys are prohibited (§3.14)
2153
- const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2154
2126
  // #719: a bare reload (no projectPath query) must land on the last-recorded
2155
2127
  // workspace project, not the launch folder. The bootstrap saves its resolved
2156
2128
  // current path back into the projects list, so defaulting to the launch
@@ -2164,17 +2136,18 @@ class AiHubServer {
2164
2136
  projectPath = recorded;
2165
2137
  }
2166
2138
  }
2167
- res.json(await this.bootstrapResponse(projectPath || this.projectPath, apiKey));
2139
+ res.json(await this.bootstrapResponse(projectPath || this.projectPath));
2168
2140
  });
2169
2141
  // Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
2170
2142
  // same projection folded into bootstrap. Useful for the avatar→Brain view
2171
2143
  // without re-fetching the whole bootstrap payload.
2172
- this.app.get('/api/ai-hub/brain', (req, res) => {
2144
+ this.app.get('/api/ai-hub/brain', async (req, res) => {
2173
2145
  const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2174
2146
  ? path_1.default.resolve(req.query.projectPath)
2175
2147
  : this.projectPath;
2176
2148
  const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath).length + (0, catalog_1.discoverManagerTemplates)(projectPath).length;
2177
- return res.json(this.computeBrain(projectPath, jobCount));
2149
+ const userEmail = await this.resolveHubIdentity();
2150
+ return res.json(this.computeBrain(projectPath, jobCount, userEmail));
2178
2151
  });
2179
2152
  // #533: read the PRESERVED learnings for a section + storage level so the
2180
2153
  // Company/Manager sections (machine level) and the project workspace (project
@@ -2187,7 +2160,7 @@ class AiHubServer {
2187
2160
  : (typeof (req.body && req.body.projectPath) === 'string' && req.body.projectPath.length > 0
2188
2161
  ? path_1.default.resolve(req.body.projectPath)
2189
2162
  : this.projectPath);
2190
- this.app.get('/api/ai-hub/learnings', (req, res) => {
2163
+ this.app.get('/api/ai-hub/learnings', async (req, res) => {
2191
2164
  const scope = req.query.scope;
2192
2165
  if (typeof scope !== 'string' || !VALID_SCOPES.includes(scope)) {
2193
2166
  return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
@@ -2195,7 +2168,11 @@ class AiHubServer {
2195
2168
  const level = (typeof req.query.level === 'string' && VALID_LEVELS.includes(req.query.level))
2196
2169
  ? req.query.level : 'machine';
2197
2170
  try {
2198
- const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), getHubUserEmail(), scope, level);
2171
+ const userEmail = await this.resolveLearningIdentity(scope);
2172
+ if (scope !== 'org' && !userEmail) {
2173
+ return res.json({ scope, level, entries: [] });
2174
+ }
2175
+ const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), userEmail, scope, level);
2199
2176
  return res.json({ scope, level, entries });
2200
2177
  }
2201
2178
  catch (error) {
@@ -2205,7 +2182,7 @@ class AiHubServer {
2205
2182
  // #533 §3: add / edit / delete a single learning entry — writes the real file
2206
2183
  // at the section's level (machine for the Manager/Company tabs, project for the
2207
2184
  // project workspace). The action targets exactly the card's file.
2208
- this.app.post('/api/ai-hub/learnings/entry', (req, res) => {
2185
+ this.app.post('/api/ai-hub/learnings/entry', async (req, res) => {
2209
2186
  const b = (req.body || {});
2210
2187
  if (!['add', 'edit', 'delete'].includes(b.action || '')) {
2211
2188
  return res.status(400).json({ error: 'action must be one of: add, edit, delete' });
@@ -2216,8 +2193,14 @@ class AiHubServer {
2216
2193
  return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
2217
2194
  const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
2218
2195
  const ref = { scope: b.scope, level, category: b.category };
2196
+ // Issue #750: writes to a personal-scope file need a resolved identity —
2197
+ // fail explicitly rather than writing to a guessed or empty-string file.
2198
+ const userEmail = await this.resolveLearningIdentity(b.scope);
2199
+ if (b.scope !== 'org' && !userEmail) {
2200
+ return res.status(400).json({ error: 'not_connected', message: 'No FRAIM identity resolved. Run `fraim setup` first.' });
2201
+ }
2219
2202
  try {
2220
- const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), getHubUserEmail(), ref, b.action, {
2203
+ const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), userEmail, ref, b.action, {
2221
2204
  originalTitle: b.originalTitle,
2222
2205
  severity: b.severity,
2223
2206
  title: b.title,
@@ -2229,24 +2212,22 @@ class AiHubServer {
2229
2212
  return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
2230
2213
  }
2231
2214
  });
2232
- // Issue #540/#701: POST /api/ai-hub/manager-team/assign
2233
- // Proxies to the hosted server (which owns the DB + seat enforcement). The Hub
2234
- // accepts the local X-Fraim-Api-Key header and forwards it as the hosted x-api-key.
2215
+ // Issue #540/#701/#750: POST /api/ai-hub/manager-team/assign
2216
+ // Proxies to the hosted server (which owns the DB + seat enforcement), using
2217
+ // the Hub's own ~/.fraim/config.json apiKey not a caller-supplied header.
2235
2218
  // Hosted returns 404 no_company_seat / 409 out_of_stock; those are passed through.
2236
2219
  this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
2237
- const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2238
2220
  const { personaKey } = (req.body ?? {});
2239
2221
  if (!personaKey)
2240
2222
  return res.status(400).json({ error: 'personaKey required' });
2241
- const result = await this.remoteGateway.assignManagerTeam(apiKey, personaKey);
2223
+ const result = await this.remoteGateway.assignManagerTeam(resolveApiKey(), personaKey);
2242
2224
  return res.status(result.status).json(result.body);
2243
2225
  });
2244
- // Issue #540/#701: DELETE /api/ai-hub/manager-team/assign/:personaKey
2226
+ // Issue #540/#701/#750: DELETE /api/ai-hub/manager-team/assign/:personaKey
2245
2227
  // Proxies the seat release to the hosted server.
2246
2228
  this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
2247
- const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
2248
2229
  const { personaKey } = req.params;
2249
- await this.remoteGateway.removeManagerTeam(apiKey, personaKey);
2230
+ await this.remoteGateway.removeManagerTeam(resolveApiKey(), personaKey);
2250
2231
  return res.status(204).end();
2251
2232
  });
2252
2233
  // Issue #708: manager/company scopes resolve to a project-independent sentinel bucket
@@ -2277,7 +2258,27 @@ class AiHubServer {
2277
2258
  // deletes are honored (a conversation absent here is dropped).
2278
2259
  const prior = this.conversationStore.loadProject(key);
2279
2260
  const priorById = new Map(prior.conversations.map((entry) => [entry.id, entry]));
2280
- const conversations = body.conversations.map((incoming) => {
2261
+ // Write-boundary guard for the cross-project leak (docs/rca/hub-conversation-cross-project-leak.md):
2262
+ // a project PUT persists the client's current-project list, but that list can transiently
2263
+ // include a conversation belonging to ANOTHER project (e.g. the just-finished run of the
2264
+ // project the user switched away from). Never file such a record into this project bucket —
2265
+ // its authoritative home is its own project bucket. Scope (manager/company) PUTs are exempt:
2266
+ // their sentinel buckets legitimately hold records whose projectPath points at a project.
2267
+ const belongsInBucket = (incoming) => {
2268
+ if (scope)
2269
+ return true;
2270
+ const incomingScope = incoming.scope
2271
+ ?? incoming.invokedArea;
2272
+ if (incomingScope === 'manager' || incomingScope === 'company')
2273
+ return false;
2274
+ const own = incoming && typeof incoming.projectPath === 'string' ? incoming.projectPath : '';
2275
+ if (!own)
2276
+ return true;
2277
+ return normalizedDirectoryPath(own) === normalizedDirectoryPath(key);
2278
+ };
2279
+ const conversations = body.conversations
2280
+ .filter((incoming) => belongsInBucket(incoming))
2281
+ .map((incoming) => {
2281
2282
  const existing = incoming && incoming.id ? priorById.get(incoming.id) : undefined;
2282
2283
  return existing ? { ...existing, ...incoming } : incoming;
2283
2284
  });
@@ -2375,20 +2376,24 @@ class AiHubServer {
2375
2376
  const projects = this.preferencesStore.removeProject(projectPath, known.filter((project) => project.id !== entry.id), entry.folderPath);
2376
2377
  return res.json({ ok: true, projects });
2377
2378
  });
2378
- this.app.post('/api/ai-hub/api-key', (req, res) => {
2379
- const { apiKey } = req.body;
2380
- if (!apiKey || typeof apiKey !== 'string')
2381
- return res.status(400).json({ error: 'apiKey required' });
2382
- const prefs = this.preferencesStore.load(this.projectPath);
2383
- this.preferencesStore.save({ ...prefs, apiKey });
2384
- return res.json({ ok: true });
2385
- });
2379
+ // Issue #750: POST /api/ai-hub/api-key is removed there is no Hub-local
2380
+ // apiKey to persist. Identity always comes from ~/.fraim/config.json
2381
+ // (set by `fraim setup`), read fresh via resolveApiKey() on every request.
2386
2382
  this.app.post('/api/ai-hub/preferences', (req, res) => {
2387
2383
  const { personaKey } = req.body;
2388
2384
  const prefs = this.preferencesStore.load(this.projectPath);
2389
2385
  this.preferencesStore.save({ ...prefs, personaKey: personaKey ?? null });
2390
2386
  return res.json({ ok: true });
2391
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
+ });
2392
2397
  this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
2393
2398
  try {
2394
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.199",
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>
@@ -9631,23 +9631,15 @@ async function tfHandleConnectedAuthMessage(event) {
9631
9631
  }
9632
9632
  }
9633
9633
 
9634
+ // Issue #750: the Hub's identity comes from ~/.fraim/config.json (set by
9635
+ // `fraim setup`), not a Hub-local copy — there is nothing to persist here.
9636
+ // A key recovered via embedded sign-in is used for the rest of this browser
9637
+ // session only (in-memory); it does not survive a Hub restart, by design.
9634
9638
  async function tfRememberConnectedApiKey(apiKey) {
9635
- const changed = apiKey !== state.storedApiKey;
9636
9639
  state.storedApiKey = apiKey;
9637
9640
  if (state.bootstrap && state.bootstrap.preferences) {
9638
9641
  state.bootstrap.preferences.apiKey = apiKey;
9639
9642
  }
9640
- if (changed) {
9641
- try {
9642
- await requestJson('/api/ai-hub/api-key', {
9643
- method: 'POST',
9644
- headers: { 'Content-Type': 'application/json' },
9645
- body: JSON.stringify({ apiKey }),
9646
- });
9647
- } catch (error) {
9648
- console.warn('[ai-hub] Could not persist connected auth key:', error);
9649
- }
9650
- }
9651
9643
  }
9652
9644
 
9653
9645
  // ---------------------------------------------------------------------------
@@ -10352,6 +10344,12 @@ async function tfSwitchProjectFolder(folderPath) {
10352
10344
  return;
10353
10345
  }
10354
10346
  state.projectPath = normalized.folderPath;
10347
+ // Cross-project leak fix (docs/rca/hub-conversation-cross-project-leak.md): drop the previous
10348
+ // project's active conversation on switch. Otherwise the just-finished run stays state.activeId
10349
+ // while state.projectPath is the new project, and a debounced conversation PUT during the
10350
+ // cached-bucket fast render below would flush that foreign conversation into the new project's
10351
+ // bucket. hydrateConversationsFromServer recomputes the correct activeId for this project.
10352
+ state.activeId = null;
10355
10353
  // #521 fix: the context cache (state._ctxCache) is keyed by context key only
10356
10354
  // (projectContext/projectBrief/projectRules), NOT by project — so without this
10357
10355
  // the new project's Brief section would render the PREVIOUS project's content
@@ -10850,6 +10848,12 @@ function tfWireShell() {
10850
10848
  // replacing the hardcoded "SM" placeholder. Falls back gracefully when the email
10851
10849
  // isn't known yet (e.g. before the proxy has persisted it).
10852
10850
  function tfAccountIdentity() {
10851
+ // Issue #750: bootstrap.userEmail is `null` (not just missing) once the server
10852
+ // has definitively resolved "no ~/.fraim/config.json apiKey" — distinct from
10853
+ // the transient pre-bootstrap window where state.bootstrap doesn't exist yet.
10854
+ if (state.bootstrap && state.bootstrap.userEmail === null) {
10855
+ return { email: '', name: 'You', initials: '·', notConnected: true };
10856
+ }
10853
10857
  const email = (state.bootstrap && state.bootstrap.userEmail) || '';
10854
10858
  if (!email || !email.includes('@')) {
10855
10859
  return { email: '', name: 'You', initials: '·' };
@@ -10869,9 +10873,33 @@ function tfPopulateAccountMenu() {
10869
10873
  const avEl = document.getElementById('am-av');
10870
10874
  const avatarBtn = document.getElementById('avatar-btn');
10871
10875
  if (nameEl) nameEl.textContent = id.name;
10872
- if (emailEl) emailEl.textContent = id.email;
10876
+ if (emailEl) emailEl.textContent = id.notConnected ? 'Not connected — run `fraim setup`' : id.email;
10873
10877
  if (avEl) avEl.textContent = id.initials;
10874
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 */ });
10875
10903
  }
10876
10904
 
10877
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; }