fraim 2.0.161 → 2.0.163

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.
@@ -1,4 +1,37 @@
1
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
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -15,6 +48,8 @@ const crypto_1 = require("crypto");
15
48
  const child_process_1 = require("child_process");
16
49
  const https_1 = __importDefault(require("https"));
17
50
  const types_1 = require("../first-run/types");
51
+ const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
52
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
18
53
  const PERSONA_AVATAR_SEEDS = {
19
54
  maestro: { seed: 'MAESTRO-founder-mode', bg: 'fde68a' },
20
55
  beza: { seed: 'BEZA-strategist', bg: 'c7d2fe' },
@@ -44,6 +79,8 @@ const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
44
79
  const hosts_1 = require("./hosts");
45
80
  const manager_turns_1 = require("./manager-turns");
46
81
  const preferences_1 = require("./preferences");
82
+ const conversation_store_1 = require("./conversation-store");
83
+ const managed_browser_1 = require("./managed-browser");
47
84
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
48
85
  function loadPersonaCapabilityModule() {
49
86
  try {
@@ -137,6 +174,151 @@ class AiHubRunRegistry {
137
174
  dispose(runId) {
138
175
  this.children.delete(runId);
139
176
  }
177
+ // #521: terminate the agent process for a run (manager clicked Stop). Returns
178
+ // true if a live child was signalled. The child's onExit handler then fires and
179
+ // parks the run in its waiting state.
180
+ stop(runId) {
181
+ const child = this.children.get(runId);
182
+ if (!child || typeof child.kill !== 'function')
183
+ return false;
184
+ try {
185
+ child.kill();
186
+ return true;
187
+ }
188
+ catch {
189
+ return false;
190
+ }
191
+ }
192
+ }
193
+ function safeHttpUrl(value) {
194
+ if (typeof value !== 'string' || value.trim().length === 0)
195
+ return null;
196
+ try {
197
+ const parsed = new URL(value.trim());
198
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
199
+ return null;
200
+ return parsed.href;
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ function normalizeReviewArtifact(raw, index = 0) {
207
+ if (!raw || typeof raw !== 'object')
208
+ return null;
209
+ const value = raw;
210
+ const artifactPath = typeof value.path === 'string' && value.path.trim().length > 0 ? value.path.trim() : null;
211
+ const url = safeHttpUrl(value.url);
212
+ const type = typeof value.type === 'string' && value.type.trim().length > 0 ? value.type.trim() : 'file';
213
+ const label = typeof value.label === 'string' && value.label.trim().length > 0
214
+ ? value.label.trim()
215
+ : artifactPath
216
+ ? artifactPath.split(/[\\/]/).filter(Boolean).pop() || `Artifact ${index + 1}`
217
+ : url || `Artifact ${index + 1}`;
218
+ if (!artifactPath && !url)
219
+ return null;
220
+ return { type, label, path: artifactPath, url };
221
+ }
222
+ function normalizeReviewHandoff(raw) {
223
+ if (!raw || typeof raw !== 'object')
224
+ return null;
225
+ const value = raw;
226
+ if (typeof value.reviewRequired !== 'boolean')
227
+ return null;
228
+ const artifacts = Array.isArray(value.artifacts)
229
+ ? value.artifacts.map((artifact, index) => normalizeReviewArtifact(artifact, index)).filter((artifact) => Boolean(artifact))
230
+ : [];
231
+ const rawTarget = value.reviewTarget && typeof value.reviewTarget === 'object' ? value.reviewTarget : null;
232
+ const targetType = rawTarget && typeof rawTarget.type === 'string' ? rawTarget.type.trim() : '';
233
+ const targetLabel = rawTarget && typeof rawTarget.label === 'string' && rawTarget.label.trim().length > 0
234
+ ? rawTarget.label.trim()
235
+ : '';
236
+ if (!value.reviewRequired) {
237
+ return {
238
+ reviewRequired: false,
239
+ reviewTarget: rawTarget ? { type: targetType || 'none', label: targetLabel || 'Completed work' } : null,
240
+ artifacts,
241
+ summary: typeof value.summary === 'string' ? value.summary.trim() : '',
242
+ feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : '',
243
+ };
244
+ }
245
+ if (targetType === 'pull_request') {
246
+ const url = safeHttpUrl(rawTarget?.url);
247
+ if (!url)
248
+ return null;
249
+ return {
250
+ reviewRequired: true,
251
+ reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
252
+ artifacts,
253
+ summary: typeof value.summary === 'string' ? value.summary.trim() : '',
254
+ feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'pull_request_comments',
255
+ };
256
+ }
257
+ if (targetType === 'artifact_set' && artifacts.length > 0) {
258
+ return {
259
+ reviewRequired: true,
260
+ reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}` },
261
+ artifacts,
262
+ summary: typeof value.summary === 'string' ? value.summary.trim() : '',
263
+ feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'inline',
264
+ };
265
+ }
266
+ return null;
267
+ }
268
+ function extractReviewHandoffFromText(text) {
269
+ if (!text || !/reviewRequired|reviewTarget|review_handoff/i.test(text))
270
+ return null;
271
+ const candidates = [];
272
+ for (const match of String(text).matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
273
+ candidates.push(match[1]);
274
+ const tagged = String(text).match(/<review_handoff>\s*([\s\S]*?)\s*<\/review_handoff>/i);
275
+ if (tagged)
276
+ candidates.push(tagged[1]);
277
+ const inline = String(text).match(/(\{\s*"reviewRequired"[\s\S]*\})/i);
278
+ if (inline)
279
+ candidates.push(inline[1]);
280
+ for (const candidate of candidates) {
281
+ try {
282
+ const handoff = normalizeReviewHandoff(JSON.parse(candidate.trim()));
283
+ if (handoff)
284
+ return handoff;
285
+ }
286
+ catch {
287
+ // Malformed snippets are ignored; the UI can still surface legacy fallback state.
288
+ }
289
+ }
290
+ return null;
291
+ }
292
+ const ARTIFACT_PATH_RE = /([A-Za-z0-9_\-.\\/]*?(?:docs|public|src|tests)[\\/][A-Za-z0-9_\-.\\/]+\.[A-Za-z0-9]+)/;
293
+ const ARTIFACT_EXCLUDE_RE = /(^|[\\/])(retrospectives|evidence|learnings|mocks|raw|archive)[\\/]/i;
294
+ function extractReviewArtifactFromText(text, projectPath) {
295
+ if (!text)
296
+ return null;
297
+ const match = text.match(ARTIFACT_PATH_RE);
298
+ if (!match)
299
+ return null;
300
+ const relativePath = match[1].replace(/\\/g, '/');
301
+ if (ARTIFACT_EXCLUDE_RE.test(relativePath))
302
+ return null;
303
+ const parts = relativePath.split('/').filter(Boolean);
304
+ const name = parts[parts.length - 1] || relativePath;
305
+ const artifactPath = path_1.default.isAbsolute(relativePath) ? relativePath : path_1.default.join(projectPath, relativePath);
306
+ return { type: path_1.default.extname(name).replace(/^\./, '') || 'file', label: name, path: artifactPath };
307
+ }
308
+ function applyReviewProjection(run, text) {
309
+ const handoff = extractReviewHandoffFromText(text);
310
+ if (handoff) {
311
+ run.reviewHandoff = handoff;
312
+ run.artifacts = handoff.artifacts;
313
+ return;
314
+ }
315
+ const artifact = extractReviewArtifactFromText(text, run.projectPath);
316
+ if (!artifact)
317
+ return;
318
+ run.artifacts = run.artifacts || [];
319
+ if (!run.artifacts.some((entry) => entry.path === artifact.path || entry.url === artifact.url)) {
320
+ run.artifacts.push(artifact);
321
+ }
140
322
  }
141
323
  function emptyTotals() {
142
324
  return {
@@ -202,6 +384,20 @@ function applyAgentIdentitySignal(run, identity) {
202
384
  run.agentName = identity.agentName;
203
385
  run.agentModel = identity.agentModel;
204
386
  }
387
+ function appendHostMessage(run, hostId, event, channel) {
388
+ if (!event.message || channel !== 'stdout')
389
+ return;
390
+ applyReviewProjection(run, event.message);
391
+ if (hostId === 'gemini') {
392
+ const last = run.messages[run.messages.length - 1];
393
+ if (last?.role === 'employee') {
394
+ last.text = `${last.text}\n${event.message}`;
395
+ last.createdAt = new Date().toISOString();
396
+ return;
397
+ }
398
+ }
399
+ run.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
400
+ }
205
401
  // Apply a parsed seekMentoring tool-use signal from the host stream to
206
402
  // the run state. Returns the updated currentPhase.
207
403
  function applySeekMentoringSignal(run, signal) {
@@ -215,6 +411,8 @@ function applySeekMentoringSignal(run, signal) {
215
411
  const callJobId = signal.jobId || signal.jobName;
216
412
  if (callJobId && targetJobId && callJobId !== targetJobId)
217
413
  return;
414
+ if (signal.reviewHandoff)
415
+ run.reviewHandoff = signal.reviewHandoff;
218
416
  // Discriminant signals are routing hints only — they don't move the
219
417
  // tracker, but they do change which phase id will be considered
220
418
  // reachable next time stages are derived. Persist on the run.
@@ -286,7 +484,7 @@ function deriveStages(run, projectPath) {
286
484
  currentIndex < 0 &&
287
485
  historyMap.size === 0;
288
486
  if (completedWithoutPhaseTelemetry) {
289
- return [];
487
+ return declaredPath.map((phase) => ({ phaseId: phase.id, label: phase.label, state: 'done' }));
290
488
  }
291
489
  return declaredPath.map((phase, index) => {
292
490
  let state;
@@ -318,6 +516,7 @@ const HUB_TO_FIRST_RUN_ID = {
318
516
  claude: 'claude-code',
319
517
  codex: 'codex',
320
518
  gemini: 'gemini-cli',
519
+ copilot: 'copilot-cli',
321
520
  };
322
521
  function hubAgentOption(hubId) {
323
522
  const frId = HUB_TO_FIRST_RUN_ID[hubId];
@@ -386,6 +585,37 @@ function hubOpenTerminal(command) {
386
585
  }
387
586
  (0, child_process_1.spawn)('bash', ['-c', command], { detached: true, stdio: 'ignore' }).unref();
388
587
  }
588
+ function pathWithin(root, candidate) {
589
+ const resolvedRoot = path_1.default.resolve(root);
590
+ const resolvedCandidate = path_1.default.resolve(candidate);
591
+ return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(resolvedRoot + path_1.default.sep);
592
+ }
593
+ function resolveSafeArtifactPath(rawPath, projectPath) {
594
+ const trimmed = rawPath.trim();
595
+ if (!trimmed || /^https?:\/\//i.test(trimmed))
596
+ return null;
597
+ const resolved = path_1.default.resolve(path_1.default.isAbsolute(trimmed) ? trimmed : path_1.default.join(projectPath, trimmed));
598
+ const safeRoots = [
599
+ path_1.default.resolve(projectPath),
600
+ path_1.default.resolve(os_1.default.homedir()),
601
+ ];
602
+ return safeRoots.some((root) => pathWithin(root, resolved)) ? resolved : null;
603
+ }
604
+ function hubOpenFile(filePath) {
605
+ if (process.platform === 'win32') {
606
+ (0, child_process_1.spawn)('powershell.exe', ['-NoProfile', '-Command', 'Start-Process -LiteralPath $args[0]', filePath], {
607
+ detached: true,
608
+ stdio: 'ignore',
609
+ windowsHide: true,
610
+ }).unref();
611
+ return;
612
+ }
613
+ if (process.platform === 'darwin') {
614
+ (0, child_process_1.spawn)('open', [filePath], { detached: true, stdio: 'ignore' }).unref();
615
+ return;
616
+ }
617
+ (0, child_process_1.spawn)('xdg-open', [filePath], { detached: true, stdio: 'ignore' }).unref();
618
+ }
389
619
  function buildManagedLoginCommand(command) {
390
620
  const managedPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
391
621
  if (process.platform === 'win32') {
@@ -408,16 +638,130 @@ function ensureDirectoryPath(projectPath) {
408
638
  }
409
639
  return resolved;
410
640
  }
641
+ // ---------------------------------------------------------------------------
642
+ // Issue #512 (S3) — Hub bootstrap projection helpers.
643
+ // ---------------------------------------------------------------------------
644
+ // Resolve the user email used to key personal (L1) learnings. Reads
645
+ // ~/.fraim/preferences.json (the existing user-prefs file) when present so the
646
+ // Brain counts match the auto-load context; falls back to a sensible default
647
+ // that resolveLearningUserId can refine against on-disk files.
648
+ function getHubUserEmail() {
649
+ try {
650
+ const prefsPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'preferences.json');
651
+ if (fs_1.default.existsSync(prefsPath)) {
652
+ const raw = JSON.parse(fs_1.default.readFileSync(prefsPath, 'utf8'));
653
+ if (typeof raw.userEmail === 'string' && raw.userEmail.length > 0)
654
+ return raw.userEmail;
655
+ }
656
+ }
657
+ catch {
658
+ // fall through to default
659
+ }
660
+ // #533: preferences.json may not carry the email yet — the proxy persists it on
661
+ // fraim_connect (see stdio-server). Until then, personal learnings are ALWAYS
662
+ // stamped `<email>-<type>.md`, so a single-user machine resolves deterministically
663
+ // from the on-disk files. This is what makes the avatar + personal learnings work.
664
+ const stamped = resolveSingleStampedUser();
665
+ if (stamped)
666
+ return stamped;
667
+ return 'fraim-user';
668
+ }
669
+ // Return the one user prefix stamped on the global learning files, or null if
670
+ // there is not exactly one (so we never guess between multiple accounts).
671
+ function resolveSingleStampedUser() {
672
+ try {
673
+ const dir = (0, project_fraim_paths_1.getUserFraimLearningsDir)();
674
+ if (!fs_1.default.existsSync(dir))
675
+ return null;
676
+ const prefixes = new Set();
677
+ for (const f of fs_1.default.readdirSync(dir)) {
678
+ if (f.startsWith('org-'))
679
+ continue;
680
+ const m = f.match(/^(.*)-(preferences|manager-coaching|mistake-patterns|validated-patterns)\.md$/);
681
+ if (m)
682
+ prefixes.add(m[1]);
683
+ }
684
+ if (prefixes.size === 1)
685
+ return Array.from(prefixes)[0];
686
+ }
687
+ catch {
688
+ // ignore unreadable dir
689
+ }
690
+ return null;
691
+ }
692
+ // Read persisted Get-started step states from ~/.fraim/install-state.json
693
+ // (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
694
+ // missing key falls back to derivation. Persisted `true` is sticky so the rail
695
+ // never re-shows once a step is completed (R13.4).
696
+ function readPersistedFirstRun() {
697
+ const out = {};
698
+ const apply = (source) => {
699
+ if (!source || typeof source !== 'object')
700
+ return;
701
+ // Support both a flat shape ({ company: true }) and a nested
702
+ // ({ firstRun: { company: true } }) one — the rail mirrors to whichever.
703
+ const nested = source.firstRun;
704
+ const candidates = [source, nested].filter((c) => !!c && typeof c === 'object');
705
+ for (const c of candidates) {
706
+ for (const key of ['install', 'company', 'hire', 'project']) {
707
+ if (c[key] === true)
708
+ out[key] = true;
709
+ }
710
+ }
711
+ };
712
+ const readJson = (fileName) => {
713
+ try {
714
+ const p = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), fileName);
715
+ if (!fs_1.default.existsSync(p))
716
+ return null;
717
+ return JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
718
+ }
719
+ catch {
720
+ return null;
721
+ }
722
+ };
723
+ apply(readJson('install-state.json'));
724
+ apply(readJson('preferences.json'));
725
+ return out;
726
+ }
727
+ // Count .md files recursively under a directory (skills/rules catalog counts).
728
+ function countMarkdownFilesRecursive(dirPath) {
729
+ if (!fs_1.default.existsSync(dirPath))
730
+ return 0;
731
+ let total = 0;
732
+ try {
733
+ for (const entry of fs_1.default.readdirSync(dirPath, { withFileTypes: true })) {
734
+ const child = path_1.default.join(dirPath, entry.name);
735
+ if (entry.isDirectory()) {
736
+ total += countMarkdownFilesRecursive(child);
737
+ }
738
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
739
+ total += 1;
740
+ }
741
+ }
742
+ }
743
+ catch {
744
+ // ignore unreadable dirs
745
+ }
746
+ return total;
747
+ }
411
748
  class AiHubServer {
412
749
  constructor(options = {}) {
413
750
  this.app = (0, express_1.default)();
414
751
  this.runRegistry = new AiHubRunRegistry();
415
752
  this.projectPath = options.projectPath || process.cwd();
416
753
  this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
754
+ this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
417
755
  this.wordTaskpaneDir = options.wordTaskpaneDir ?? resolveWordTaskpaneDir(this.projectPath);
418
756
  this.folderPicker = options.folderPicker ?? pickProjectPath;
419
757
  this.httpsPort = options.httpsPort;
420
758
  this.certBundle = options.certBundle;
759
+ this.managedBrowser = options.managedBrowser || new managed_browser_1.ManagedBrowser({
760
+ channel: process.env.FRAIM_BROWSER_CHANNEL || 'auto',
761
+ port: process.env.FRAIM_BROWSER_PORT ? Number(process.env.FRAIM_BROWSER_PORT) : undefined,
762
+ userDataDir: process.env.FRAIM_BROWSER_USER_DATA_DIR || undefined,
763
+ explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
764
+ });
421
765
  this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
422
766
  if (options.dbService !== undefined) {
423
767
  this.dbService = options.dbService;
@@ -428,11 +772,15 @@ class AiHubServer {
428
772
  this.ownsDbService = this.dbService !== undefined;
429
773
  }
430
774
  this.app.use(express_1.default.json());
775
+ if (this.dbService) {
776
+ const { registerPaymentRoutes } = require('../routes/payment-routes');
777
+ registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
778
+ }
431
779
  // CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
432
780
  // calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
433
781
  this.app.use((_req, res, next) => {
434
782
  res.setHeader('Access-Control-Allow-Origin', '*');
435
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
783
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
436
784
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
437
785
  res.setHeader('Access-Control-Allow-Private-Network', 'true');
438
786
  if (_req.method === 'OPTIONS') {
@@ -441,6 +789,37 @@ class AiHubServer {
441
789
  }
442
790
  next();
443
791
  });
792
+ // Payment success redirect: sync entitlement then bounce to the hub.
793
+ // Stripe redirects here after a completed persona-hire checkout.
794
+ this.app.get('/ai-hub/payment-success', async (req, res) => {
795
+ const sessionId = req.query['session_id'];
796
+ const email = req.query['email'];
797
+ // Persist the buyer's email so bootstrap can look up entitlements by userId.
798
+ if (email) {
799
+ try {
800
+ const prefsPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'preferences.json');
801
+ const existing = fs_1.default.existsSync(prefsPath)
802
+ ? JSON.parse(fs_1.default.readFileSync(prefsPath, 'utf8'))
803
+ : {};
804
+ fs_1.default.writeFileSync(prefsPath, JSON.stringify({ ...existing, userEmail: email }, null, 2));
805
+ }
806
+ catch (err) {
807
+ console.warn('[ai-hub] could not persist userEmail:', err);
808
+ }
809
+ }
810
+ if (sessionId && this.dbService) {
811
+ try {
812
+ const { syncPersonaEntitlementFromCheckoutSession } = await Promise.resolve().then(() => __importStar(require('../services/persona-entitlement-service')));
813
+ const { stripe } = await Promise.resolve().then(() => __importStar(require('../config/stripe')));
814
+ const session = await stripe.checkout.sessions.retrieve(sessionId);
815
+ await syncPersonaEntitlementFromCheckoutSession(this.dbService, session, 'dashboard-link');
816
+ }
817
+ catch (err) {
818
+ console.warn('[ai-hub] payment-success entitlement sync failed:', err);
819
+ }
820
+ }
821
+ res.redirect('/ai-hub/?hired=1');
822
+ });
444
823
  this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
445
824
  // Issue #489: Serve the Word task pane assets at /word-taskpane/*.
446
825
  // Office JS appends ?_host_Info=Word$Win32$... to every request — we must
@@ -513,6 +892,11 @@ class AiHubServer {
513
892
  if (this.dbService) {
514
893
  try {
515
894
  await this.dbService.connect();
895
+ const mongoClient = this.dbService.getClient();
896
+ if (mongoClient) {
897
+ const PaymentRepositoryImpl = require('../db/payment-repository').PaymentRepository;
898
+ this.paymentRepo = new PaymentRepositoryImpl(mongoClient);
899
+ }
516
900
  }
517
901
  catch (err) {
518
902
  console.warn('[ai-hub] DB connect failed — personas will show as locked:', err);
@@ -524,6 +908,13 @@ class AiHubServer {
524
908
  this.httpServer.once('listening', () => resolve());
525
909
  this.httpServer.once('error', (error) => reject(error));
526
910
  });
911
+ // #521: publish where the shared browser lives + how to bring it up, so every
912
+ // agent inherits them. The browser is NOT launched here — it's started lazily
913
+ // (the browser-use skill POSTs /browser/start when it actually needs one, which
914
+ // also relaunches it if the manager closed the window). Deterministic endpoint,
915
+ // so it's known even before the browser is running.
916
+ process.env.FRAIM_BROWSER_CDP_ENDPOINT = this.managedBrowser.cdpEndpoint();
917
+ process.env.FRAIM_HUB_BASE_URL = `http://127.0.0.1:${port}`;
527
918
  // Start HTTPS server when a cert bundle and port are provided.
528
919
  // Word Online requires HTTPS; the HTTPS server shares the same Express app
529
920
  // so all routes (including /word-taskpane/*) are available over both protocols.
@@ -535,6 +926,18 @@ class AiHubServer {
535
926
  this.httpsServer.once('error', (error) => reject(error));
536
927
  });
537
928
  }
929
+ // #521: when the shared browser is enabled, bring it up at boot so the CDP
930
+ // endpoint is already published when the first run spawns. Best-effort — a
931
+ // missing browser must not stop the Hub from serving.
932
+ if (process.env.FRAIM_BROWSER_ENABLED === '1') {
933
+ try {
934
+ const r = await this.ensureManagedBrowser();
935
+ console.log(`[ai-hub] shared browser ready at ${r.endpoint}${r.reused ? ' (reused)' : ''}`);
936
+ }
937
+ catch (err) {
938
+ console.warn('[ai-hub] shared browser not started:', err instanceof Error ? err.message : err);
939
+ }
940
+ }
538
941
  }
539
942
  async stop() {
540
943
  const closeServer = (srv) => new Promise((resolve, reject) => {
@@ -551,6 +954,9 @@ class AiHubServer {
551
954
  await closeServer(this.httpServer);
552
955
  this.httpServer = undefined;
553
956
  }
957
+ // #521: tear down the shared browser if WE launched it (stop() no-ops on a
958
+ // browser the manager owns).
959
+ this.managedBrowser.stop();
554
960
  if (this.ownsDbService && this.dbService) {
555
961
  await this.dbService.close();
556
962
  this.dbService = undefined;
@@ -578,7 +984,8 @@ class AiHubServer {
578
984
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
579
985
  }));
580
986
  const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath);
581
- const { personas, subscriptionActive } = await this.computePersonas(apiKey || preferences.apiKey);
987
+ const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey || preferences.apiKey);
988
+ const managerTeam = await this.computeManagerTeam(workspaceId, userKey);
582
989
  // Issue #347: enrich the activeRun the same way GET /runs/:id does
583
990
  // so the bootstrap surface (used on first paint) carries stages and
584
991
  // live totals — not just the raw run state.
@@ -595,6 +1002,104 @@ class AiHubServer {
595
1002
  personas,
596
1003
  subscriptionActive,
597
1004
  activeRun,
1005
+ // Issue #512 (S3) — additive manager-flow projections.
1006
+ firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
1007
+ teamContext: this.computeTeamContext(normalizedProjectPath),
1008
+ brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length),
1009
+ // #533: the resolved account email, so the profile card shows the real
1010
+ // identity (not a hardcoded placeholder) and the client can display it.
1011
+ userEmail: getHubUserEmail(),
1012
+ assignments: { byProject: {}, source: 'client-localStorage' },
1013
+ // Issue #538 — source of truth for the "Hire a human manager" UI. Lazy-required
1014
+ // (not a top-level import) so the lightweight client CLI paths that import this
1015
+ // module for findAvailablePort do not eagerly load server-only config (repro #422).
1016
+ managerHiring: require('../config/ai-manager-hiring').buildManagerHiringCatalog(),
1017
+ // Issue #540: server-authoritative manager team (personas assigned by this manager).
1018
+ managerTeam,
1019
+ };
1020
+ }
1021
+ conversationRecordFromRun(run) {
1022
+ const lastUpdatedAt = run.updatedAt || new Date().toISOString();
1023
+ return {
1024
+ id: run.conversationId || run.id,
1025
+ projectPath: path_1.default.resolve(run.projectPath),
1026
+ title: run.conversationTitle || run.jobTitle || run.jobId,
1027
+ jobId: run.jobId,
1028
+ jobTitle: run.jobTitle || run.jobId,
1029
+ agentName: run.hostId,
1030
+ personaKey: run.personaKey ?? null,
1031
+ runId: run.id,
1032
+ sessionId: run.sessionId || null,
1033
+ status: run.status,
1034
+ createdAt: run.createdAt,
1035
+ lastUpdatedAt,
1036
+ messages: run.messages.map((message) => ({
1037
+ role: message.role,
1038
+ text: message.text,
1039
+ at: Date.parse(message.createdAt) || Date.now(),
1040
+ })),
1041
+ events: run.events.map((event) => ({
1042
+ channel: event.channel,
1043
+ text: event.text,
1044
+ })),
1045
+ artifacts: run.artifacts || [],
1046
+ reviewHandoff: run.reviewHandoff || null,
1047
+ compareMode: run.runRole === 'fraim' && run.compareRunId ? 'ab' : undefined,
1048
+ compareRunId: run.compareRunId || null,
1049
+ };
1050
+ }
1051
+ persistRunConversation(run, activeId) {
1052
+ try {
1053
+ this.conversationStore.upsertConversation(run.projectPath, this.conversationRecordFromRun(run), activeId);
1054
+ }
1055
+ catch (error) {
1056
+ console.warn('[ai-hub] conversation store write failed:', error instanceof Error ? error.message : error);
1057
+ }
1058
+ }
1059
+ // Issue #512 (S3, R13) — derive the four Get-started step states, then let any
1060
+ // persisted `true` in ~/.fraim/{install-state,preferences}.json override the
1061
+ // derivation (so a completed step stays completed even if its signal vanishes).
1062
+ computeFirstRun(projectPath, jobCount, personas) {
1063
+ const tc = (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
1064
+ // company = any organization-layer context written by org onboarding.
1065
+ const companyDone = tc.orgContext.present || tc.managerContext.present || tc.orgRules.present || tc.managerRules.present;
1066
+ // hire = any persona hired (entitlement active).
1067
+ const hireDone = personas.some((p) => p.status === 'hired');
1068
+ // project = a project brief exists OR the workspace has FRAIM jobs set up.
1069
+ const projectDone = tc.projectContext.present || tc.projectBrief.present || tc.projectRules.present || tc.projectQa.present || jobCount > 0;
1070
+ const derived = {
1071
+ install: true, // the Hub is running, so the toolchain install step is satisfied.
1072
+ company: companyDone,
1073
+ hire: hireDone,
1074
+ project: projectDone,
1075
+ };
1076
+ const persisted = readPersistedFirstRun();
1077
+ return {
1078
+ install: persisted.install ?? derived.install,
1079
+ company: persisted.company || derived.company,
1080
+ hire: persisted.hire || derived.hire,
1081
+ project: persisted.project || derived.project,
1082
+ };
1083
+ }
1084
+ // Issue #512 (S3, R3) — presence + display paths of the three-layer context
1085
+ // files. Reuses the learning-context-builder resolvers (same layering as the
1086
+ // auto-load Team Context block).
1087
+ computeTeamContext(projectPath) {
1088
+ return (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
1089
+ }
1090
+ // Issue #512 (S3, R14) — Brain summary: preserved-learning counts by scope +
1091
+ // registry catalog counts. A read projection; no new storage.
1092
+ computeBrain(projectPath, jobCount) {
1093
+ const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, getHubUserEmail());
1094
+ return {
1095
+ learnings,
1096
+ catalog: {
1097
+ jobs: jobCount,
1098
+ skills: countMarkdownFilesRecursive(path_1.default.join(projectPath, 'registry', 'skills')) +
1099
+ countMarkdownFilesRecursive(path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), 'ai-employee', 'skills')),
1100
+ rules: countMarkdownFilesRecursive(path_1.default.join(projectPath, 'registry', 'rules')) +
1101
+ countMarkdownFilesRecursive(path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), 'ai-employee', 'rules')),
1102
+ },
598
1103
  };
599
1104
  }
600
1105
  resolveHubJob(projectPath, jobId) {
@@ -606,16 +1111,61 @@ class AiHubServer {
606
1111
  return { id: managerTemplate.id, stubPath: managerTemplate.stubPath };
607
1112
  return null;
608
1113
  }
1114
+ // Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
1115
+ // content) export routes so a conversational deliverable with no on-disk file
1116
+ // can still be downloaded for Word annotation.
1117
+ async markdownToDocxBuffer(md) {
1118
+ const html = md
1119
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1120
+ // headings
1121
+ .replace(/^#### (.+)$/gm, '<h4>$1</h4>')
1122
+ .replace(/^### (.+)$/gm, '<h3>$1</h3>')
1123
+ .replace(/^## (.+)$/gm, '<h2>$1</h2>')
1124
+ .replace(/^# (.+)$/gm, '<h1>$1</h1>')
1125
+ // bold, italic, code
1126
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
1127
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
1128
+ .replace(/`(.+?)`/g, '<code>$1</code>')
1129
+ // unordered lists
1130
+ .replace(/^[-*] (.+)$/gm, '<li>$1</li>')
1131
+ // horizontal rules
1132
+ .replace(/^---+$/gm, '<hr/>')
1133
+ // paragraphs (double newlines)
1134
+ .replace(/\n{2,}/g, '</p><p>')
1135
+ .replace(/^/, '<p>')
1136
+ .replace(/$/, '</p>')
1137
+ // clean up list items into a ul
1138
+ .replace(/(<li>.+<\/li>\n?)+/g, (m) => `<ul>${m}</ul>`);
1139
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1140
+ const htmlToDocx = require('html-to-docx');
1141
+ return await htmlToDocx(`<html><body>${html}</body></html>`, null, {
1142
+ table: { row: { cantSplit: true } },
1143
+ footer: false,
1144
+ pageNumber: false,
1145
+ });
1146
+ }
609
1147
  prepareStartPayload(projectPath, hostId, selectedJobId, instructions) {
610
1148
  const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
611
1149
  const resolvedJobId = explicit?.jobId || selectedJobId;
612
1150
  if (!resolvedJobId) {
613
1151
  throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
614
1152
  }
1153
+ // #521: `display` is what the manager's conversation bubble shows — only their own
1154
+ // words. The FRAIM invocation (`$fraim <job>`), the stub path, and the shared-
1155
+ // browser / communication-style notes are system context for the AGENT, kept out
1156
+ // of the bubble so the thread isn't cluttered with machinery. `message` (with all
1157
+ // of it) is what the agent actually receives.
1158
+ const display = (explicit?.remainder || instructions || '').trim();
1159
+ // #521: the shared-browser guidance is injected HERE, at the Hub layer — never
1160
+ // baked into the registry job/skill. It only appears when a shared browser is
1161
+ // available (env published at boot).
1162
+ const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
1163
+ const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
615
1164
  if (resolvedJobId === '__freeform__') {
616
1165
  return {
617
1166
  jobId: resolvedJobId,
618
- message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions),
1167
+ message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions) + browserNote + styleNote,
1168
+ display,
619
1169
  };
620
1170
  }
621
1171
  const resolvedJob = this.resolveHubJob(projectPath, resolvedJobId);
@@ -624,7 +1174,8 @@ class AiHubServer {
624
1174
  : undefined;
625
1175
  return {
626
1176
  jobId: resolvedJobId,
627
- message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath),
1177
+ message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath) + browserNote + styleNote,
1178
+ display,
628
1179
  };
629
1180
  }
630
1181
  prepareContinueMessage(run, instructions, coachingJobId) {
@@ -634,7 +1185,16 @@ class AiHubServer {
634
1185
  // invocation prefix ($fraim / /fraim) based on run.hostId — the UI never
635
1186
  // passes raw invocation syntax.
636
1187
  const effectiveJobId = coachingJobId || run.jobId;
637
- return (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions);
1188
+ // #521: `display` = the manager's own words for the conversation bubble. When the
1189
+ // manager typed nothing (a quick-coach button), fall back to the bare invocation
1190
+ // so the bubble still shows what was triggered — but never the style note.
1191
+ const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
1192
+ const userText = (explicit?.remainder || instructions || '').trim();
1193
+ const display = userText || (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', '');
1194
+ return {
1195
+ message: (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions) + (0, manager_turns_1.buildCommunicationStyleNote)(),
1196
+ display,
1197
+ };
638
1198
  }
639
1199
  async computePersonas(apiKey) {
640
1200
  const allBundles = listHubPersonaBundles();
@@ -646,16 +1206,36 @@ class AiHubServer {
646
1206
  pricingLabel: bundle.catalogMetadata.pricingLabel,
647
1207
  status: 'locked',
648
1208
  hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
1209
+ seatCount: 0,
1210
+ seatsInUse: 0,
649
1211
  }));
650
- if (!apiKey || !this.dbService)
651
- return { personas: fallbackPersonas, subscriptionActive: false };
1212
+ if (!this.dbService) {
1213
+ return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1214
+ }
652
1215
  try {
653
- const state = await getHubWorkspacePersonaState(this.dbService, 'anonymous', apiKey);
654
- if (!state)
655
- return { personas: fallbackPersonas, subscriptionActive: false };
1216
+ const state = await getHubWorkspacePersonaState(this.dbService, getHubUserEmail(), apiKey ?? '');
1217
+ if (!state) {
1218
+ return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1219
+ }
656
1220
  const hiredKeys = new Set(state.entitlements
657
1221
  .filter((e) => e.status === 'active')
658
1222
  .map((e) => e.personaKey));
1223
+ // Build per-persona seat counts from entitlement records.
1224
+ // jobCreditsRemaining represents the number of job-credits (seats) purchased.
1225
+ const seatCountByKey = {};
1226
+ for (const e of state.entitlements) {
1227
+ if (e.status === 'active') {
1228
+ seatCountByKey[e.personaKey] = (seatCountByKey[e.personaKey] ?? 0) + (e.jobCreditsRemaining ?? 1);
1229
+ }
1230
+ }
1231
+ // seatsInUse is the count of manager-assignment rows for this workspace/persona.
1232
+ const workspaceId = state.workspaceId;
1233
+ const seatsInUseByKey = {};
1234
+ if (this.dbService) {
1235
+ await Promise.all(Object.keys(seatCountByKey).map(async (pKey) => {
1236
+ seatsInUseByKey[pKey] = await this.dbService.countHubManagerAssignments(workspaceId, pKey);
1237
+ }));
1238
+ }
659
1239
  const personas = allBundles.map((bundle) => ({
660
1240
  key: bundle.personaKey,
661
1241
  displayName: bundle.catalogMetadata.displayName,
@@ -664,15 +1244,51 @@ class AiHubServer {
664
1244
  pricingLabel: hiredKeys.has(bundle.personaKey) ? '' : bundle.catalogMetadata.pricingLabel,
665
1245
  status: (hiredKeys.has(bundle.personaKey) ? 'hired' : 'locked'),
666
1246
  hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
1247
+ seatCount: seatCountByKey[bundle.personaKey] ?? 0,
1248
+ seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
667
1249
  }));
668
- return { personas, subscriptionActive: state.subscriptionActive };
1250
+ return { personas, subscriptionActive: state.subscriptionActive, workspaceId, userKey: state.userId ?? null };
669
1251
  }
670
1252
  catch (err) {
671
1253
  console.error('[ai-hub] persona entitlement lookup failed:', err);
672
- return { personas: fallbackPersonas, subscriptionActive: false };
1254
+ return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1255
+ }
1256
+ }
1257
+ async computeManagerTeam(workspaceId, userKey) {
1258
+ if (!workspaceId || !userKey || !this.dbService)
1259
+ return [];
1260
+ try {
1261
+ const rows = await this.dbService.getHubManagerAssignments(workspaceId, userKey);
1262
+ return rows.map((r) => ({ personaKey: r.personaKey, assignedAt: r.assignedAt.toISOString() }));
1263
+ }
1264
+ catch {
1265
+ return [];
673
1266
  }
674
1267
  }
675
1268
  registerRoutes() {
1269
+ // Issue #512: Serve the account and analytics pages from public/.
1270
+ // These live outside of /ai-hub so they need explicit routes.
1271
+ const publicDir = resolveAiHubPublicDir().replace(/[\\/]ai-hub$/, '');
1272
+ this.app.get('/account', (_req, res) => {
1273
+ const filePath = path_1.default.join(publicDir, 'account', 'index.html');
1274
+ if (fs_1.default.existsSync(filePath)) {
1275
+ res.sendFile(filePath);
1276
+ }
1277
+ else {
1278
+ res.status(404).send('Account page not found.');
1279
+ }
1280
+ });
1281
+ this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
1282
+ this.app.get('/analytics', (_req, res) => {
1283
+ const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
1284
+ if (fs_1.default.existsSync(filePath)) {
1285
+ res.sendFile(filePath);
1286
+ }
1287
+ else {
1288
+ res.status(404).send('Analytics page not found.');
1289
+ }
1290
+ });
1291
+ this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
676
1292
  // Issue #478: Serve the PowerPoint task pane HTML and manifest.
677
1293
  // Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
678
1294
  // request, so we must strip them before resolving the file path. Use a custom
@@ -728,6 +1344,160 @@ class AiHubServer {
728
1344
  const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
729
1345
  res.json(await this.bootstrapResponse(projectPath || this.projectPath, apiKey));
730
1346
  });
1347
+ // Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
1348
+ // same projection folded into bootstrap. Useful for the avatar→Brain view
1349
+ // without re-fetching the whole bootstrap payload.
1350
+ this.app.get('/api/ai-hub/brain', (req, res) => {
1351
+ const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
1352
+ ? path_1.default.resolve(req.query.projectPath)
1353
+ : this.projectPath;
1354
+ const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath).length + (0, catalog_1.discoverManagerTemplates)(projectPath).length;
1355
+ return res.json(this.computeBrain(projectPath, jobCount));
1356
+ });
1357
+ // #533: read the PRESERVED learnings for a section + storage level so the
1358
+ // Company/Manager sections (machine level) and the project workspace (project
1359
+ // level) can DISPLAY and edit them.
1360
+ const VALID_SCOPES = ['org', 'manager', 'reverse'];
1361
+ const VALID_LEVELS = ['machine', 'project'];
1362
+ const VALID_CATEGORIES = ['avoid', 'preference', 'repeat', 'coaching'];
1363
+ const resolveProjectPath = (req) => typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
1364
+ ? path_1.default.resolve(req.query.projectPath)
1365
+ : (typeof (req.body && req.body.projectPath) === 'string' && req.body.projectPath.length > 0
1366
+ ? path_1.default.resolve(req.body.projectPath)
1367
+ : this.projectPath);
1368
+ this.app.get('/api/ai-hub/learnings', (req, res) => {
1369
+ const scope = req.query.scope;
1370
+ if (typeof scope !== 'string' || !VALID_SCOPES.includes(scope)) {
1371
+ return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
1372
+ }
1373
+ const level = (typeof req.query.level === 'string' && VALID_LEVELS.includes(req.query.level))
1374
+ ? req.query.level : 'machine';
1375
+ try {
1376
+ const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), getHubUserEmail(), scope, level);
1377
+ return res.json({ scope, level, entries });
1378
+ }
1379
+ catch (error) {
1380
+ return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not read learnings.' });
1381
+ }
1382
+ });
1383
+ // #533 §3: add / edit / delete a single learning entry — writes the real file
1384
+ // at the section's level (machine for the Manager/Company tabs, project for the
1385
+ // project workspace). The action targets exactly the card's file.
1386
+ this.app.post('/api/ai-hub/learnings/entry', (req, res) => {
1387
+ const b = (req.body || {});
1388
+ if (!['add', 'edit', 'delete'].includes(b.action || '')) {
1389
+ return res.status(400).json({ error: 'action must be one of: add, edit, delete' });
1390
+ }
1391
+ if (!b.scope || !VALID_SCOPES.includes(b.scope))
1392
+ return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
1393
+ if (!b.category || !VALID_CATEGORIES.includes(b.category))
1394
+ return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
1395
+ const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
1396
+ const ref = { scope: b.scope, level, category: b.category };
1397
+ try {
1398
+ const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), getHubUserEmail(), ref, b.action, {
1399
+ originalTitle: b.originalTitle,
1400
+ severity: b.severity,
1401
+ title: b.title,
1402
+ body: b.body,
1403
+ });
1404
+ return res.json({ ok: true, path: result.path });
1405
+ }
1406
+ catch (error) {
1407
+ return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
1408
+ }
1409
+ });
1410
+ // Issue #540: POST /api/ai-hub/manager-team/assign
1411
+ // Assigns a persona to the authenticated manager's team (uses X-Fraim-Api-Key header).
1412
+ // Returns 404 {error:'no_company_seat'} when the workspace has purchased no seats for the persona.
1413
+ // Returns 409 {error:'out_of_stock'} when all company seats are already assigned to other managers.
1414
+ this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
1415
+ const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
1416
+ const { personaKey } = (req.body ?? {});
1417
+ if (!personaKey)
1418
+ return res.status(400).json({ error: 'personaKey required' });
1419
+ if (!this.dbService) {
1420
+ return res.status(404).json({ error: 'no_company_seat', personaKey });
1421
+ }
1422
+ try {
1423
+ const { workspaceId, userKey, personas } = await this.computePersonas(apiKey);
1424
+ if (!workspaceId || !userKey) {
1425
+ return res.status(404).json({ error: 'no_company_seat', personaKey });
1426
+ }
1427
+ const persona = personas.find((p) => p.key === personaKey);
1428
+ if (!persona || persona.seatCount === 0) {
1429
+ return res.status(404).json({ error: 'no_company_seat', personaKey });
1430
+ }
1431
+ if (persona.seatsInUse >= persona.seatCount) {
1432
+ return res.status(409).json({ error: 'out_of_stock', personaKey, seatCount: persona.seatCount });
1433
+ }
1434
+ await this.dbService.addHubManagerAssignment(workspaceId, userKey, personaKey);
1435
+ const team = await this.computeManagerTeam(workspaceId, userKey);
1436
+ return res.json({ managerTeam: team });
1437
+ }
1438
+ catch (err) {
1439
+ console.error('[ai-hub] assign manager seat failed:', err);
1440
+ return res.status(500).json({ error: 'internal_error' });
1441
+ }
1442
+ });
1443
+ // Issue #540: DELETE /api/ai-hub/manager-team/assign/:personaKey
1444
+ // Removes a persona from the authenticated manager's team.
1445
+ this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
1446
+ const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
1447
+ const { personaKey } = req.params;
1448
+ if (!this.dbService)
1449
+ return res.status(204).end();
1450
+ try {
1451
+ const { workspaceId, userKey } = await this.computePersonas(apiKey);
1452
+ if (workspaceId && userKey) {
1453
+ await this.dbService.removeHubManagerAssignment(workspaceId, userKey, personaKey);
1454
+ }
1455
+ return res.status(204).end();
1456
+ }
1457
+ catch (err) {
1458
+ console.error('[ai-hub] remove manager seat failed:', err);
1459
+ return res.status(500).json({ error: 'internal_error' });
1460
+ }
1461
+ });
1462
+ this.app.get('/api/ai-hub/conversations', (req, res) => {
1463
+ const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
1464
+ ? path_1.default.resolve(req.query.projectPath)
1465
+ : this.projectPath;
1466
+ const loaded = this.conversationStore.loadProject(projectPath);
1467
+ return res.json({ projectPath, ...loaded, source: 'disk' });
1468
+ });
1469
+ this.app.put('/api/ai-hub/conversations', (req, res) => {
1470
+ try {
1471
+ const body = (req.body ?? {});
1472
+ const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
1473
+ if (!Array.isArray(body.conversations)) {
1474
+ return res.status(400).json({ error: 'conversations array required' });
1475
+ }
1476
+ const saved = this.conversationStore.replaceProject(projectPath, {
1477
+ activeId: body.activeId ?? null,
1478
+ conversations: body.conversations,
1479
+ });
1480
+ return res.json({ projectPath, ...saved, source: 'disk' });
1481
+ }
1482
+ catch (error) {
1483
+ return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversations.' });
1484
+ }
1485
+ });
1486
+ this.app.patch('/api/ai-hub/conversations/:conversationId', (req, res) => {
1487
+ try {
1488
+ const body = (req.body ?? {});
1489
+ const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
1490
+ const saved = this.conversationStore.patchConversation(projectPath, req.params.conversationId, body);
1491
+ if (body.activeId !== undefined) {
1492
+ const withActive = this.conversationStore.replaceProject(projectPath, { ...saved, activeId: body.activeId });
1493
+ return res.json({ projectPath, ...withActive, source: 'disk' });
1494
+ }
1495
+ return res.json({ projectPath, ...saved, source: 'disk' });
1496
+ }
1497
+ catch (error) {
1498
+ return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversation.' });
1499
+ }
1500
+ });
731
1501
  this.app.post('/api/ai-hub/api-key', (req, res) => {
732
1502
  const { apiKey } = req.body;
733
1503
  if (!apiKey || typeof apiKey !== 'string')
@@ -746,6 +1516,8 @@ class AiHubServer {
746
1516
  try {
747
1517
  const projectPath = await this.folderPicker();
748
1518
  if (!projectPath) {
1519
+ // User cancelled the native dialog, or no interactive dialog was
1520
+ // available (e.g. CI/headless). Front-end treats 204 as "no change".
749
1521
  return res.status(204).end();
750
1522
  }
751
1523
  return res.json({ path: projectPath });
@@ -754,6 +1526,152 @@ class AiHubServer {
754
1526
  return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not open the folder picker.' });
755
1527
  }
756
1528
  });
1529
+ // ── Issue #512 R3/R8: Team Context inline read/write ────────────────────
1530
+ // GET /api/ai-hub/context[?key=<key>] → file content for the editor.
1531
+ // With ?key=<one of eight>: { key, present, displayPath, scope, content }.
1532
+ // Without key: { files: { <key>: {present, displayPath, scope, content} } }.
1533
+ // POST /api/ai-hub/context { key, content } → persists to the correct
1534
+ // destination (user-level for org/manager/orgRules/managerRules unless a repo-local
1535
+ // override exists; repo-local for project*), creating parent dirs.
1536
+ // Loopback-only (the Hub binds 127.0.0.1); writes are constrained to live
1537
+ // under a personalized-employee/ directory (path-traversal guard).
1538
+ const readContextFile = (projectPath, key) => {
1539
+ const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, key);
1540
+ let content = '';
1541
+ if (loc.present && loc.readPath) {
1542
+ try {
1543
+ content = fs_1.default.readFileSync(loc.readPath, 'utf8');
1544
+ }
1545
+ catch {
1546
+ content = '';
1547
+ }
1548
+ }
1549
+ return {
1550
+ key,
1551
+ present: loc.present,
1552
+ displayPath: loc.displayPath,
1553
+ scope: loc.scope,
1554
+ content,
1555
+ };
1556
+ };
1557
+ this.app.get('/api/ai-hub/context', (req, res) => {
1558
+ const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
1559
+ ? path_1.default.resolve(req.query.projectPath)
1560
+ : this.projectPath;
1561
+ const rawKey = typeof req.query.key === 'string' ? req.query.key : undefined;
1562
+ if (rawKey !== undefined) {
1563
+ if (!(0, learning_context_builder_1.isTeamContextKey)(rawKey)) {
1564
+ return res.status(400).json({ error: `Unknown context key: ${rawKey}` });
1565
+ }
1566
+ return res.json(readContextFile(projectPath, rawKey));
1567
+ }
1568
+ const keys = ['org', 'manager', 'orgRules', 'managerRules', 'projectContext', 'projectBrief', 'projectRules', 'projectQa'];
1569
+ const files = {};
1570
+ for (const key of keys)
1571
+ files[key] = readContextFile(projectPath, key);
1572
+ return res.json({ files });
1573
+ });
1574
+ this.app.post('/api/ai-hub/context', (req, res) => {
1575
+ const body = (req.body ?? {});
1576
+ if (!(0, learning_context_builder_1.isTeamContextKey)(body.key)) {
1577
+ return res.status(400).json({ error: 'key must be one of org|manager|orgRules|managerRules|projectContext|projectBrief|projectRules|projectQa.' });
1578
+ }
1579
+ if (typeof body.content !== 'string') {
1580
+ return res.status(400).json({ error: 'content (string) is required.' });
1581
+ }
1582
+ const projectPath = typeof body.projectPath === 'string' && body.projectPath.length > 0
1583
+ ? path_1.default.resolve(body.projectPath)
1584
+ : this.projectPath;
1585
+ const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, body.key);
1586
+ const dest = path_1.default.resolve(loc.writePath);
1587
+ // Path-traversal guard: the resolved destination must live under a
1588
+ // personalized-employee directory (covers both ~/.fraim/... and repo-local).
1589
+ const segments = dest.split(path_1.default.sep);
1590
+ if (!segments.includes('personalized-employee')) {
1591
+ return res.status(403).json({ error: 'Write destination outside personalized-employee.' });
1592
+ }
1593
+ try {
1594
+ fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
1595
+ fs_1.default.writeFileSync(dest, body.content, 'utf8');
1596
+ }
1597
+ catch (err) {
1598
+ return res.status(500).json({ error: err instanceof Error ? err.message : 'Write failed.' });
1599
+ }
1600
+ // Re-read so the client gets the canonical post-write state (present flips).
1601
+ return res.json(readContextFile(projectPath, body.key));
1602
+ });
1603
+ // ── Issue #512 R7: Artifact export (md → docx) ──────────────────────────
1604
+ // GET /api/ai-hub/artifact/export-docx?path=<abs-path-to-md>
1605
+ // Converts a markdown file to .docx using html-to-docx and streams the result.
1606
+ // The manager downloads it, annotates in Word, and saves it in place on disk
1607
+ // (no upload). The agent then reads the comments + tracked changes via the
1608
+ // `apply-docx-changes-to-md` skill (registry script extract-docx-edits.js)
1609
+ // during the address-feedback phase.
1610
+ this.app.get('/api/ai-hub/artifact/export-docx', async (req, res) => {
1611
+ const rawPath = typeof req.query.path === 'string' ? req.query.path : '';
1612
+ if (!rawPath)
1613
+ return res.status(400).json({ error: 'path is required.' });
1614
+ // Safety: path must be under the current workspace or a known safe root.
1615
+ const resolved = resolveSafeArtifactPath(rawPath, this.projectPath);
1616
+ if (!resolved)
1617
+ return res.status(403).json({ error: 'Path outside allowed roots.' });
1618
+ if (!fs_1.default.existsSync(resolved))
1619
+ return res.status(404).json({ error: 'File not found.' });
1620
+ try {
1621
+ const md = fs_1.default.readFileSync(resolved, 'utf8');
1622
+ const docxBuf = await this.markdownToDocxBuffer(md);
1623
+ const basename = path_1.default.basename(resolved, path_1.default.extname(resolved));
1624
+ res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
1625
+ res.setHeader('Content-Disposition', `attachment; filename="${basename}.docx"`);
1626
+ res.send(docxBuf);
1627
+ }
1628
+ catch (err) {
1629
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Export failed.' });
1630
+ }
1631
+ });
1632
+ // POST /api/ai-hub/artifact/export-docx { content, filename? }
1633
+ // Converts inline markdown (the employee's conversational deliverable) to .docx
1634
+ // when the run produced no on-disk file — e.g. an onboarding answer to an empty
1635
+ // repo. Keeps the "annotate in Word" review flow working instead of erroring
1636
+ // with "no local artifact path available".
1637
+ this.app.post('/api/ai-hub/artifact/export-docx', async (req, res) => {
1638
+ const content = typeof req.body?.content === 'string' ? req.body.content : '';
1639
+ if (!content.trim())
1640
+ return res.status(400).json({ error: 'content is required.' });
1641
+ const rawName = typeof req.body?.filename === 'string' && req.body.filename.trim()
1642
+ ? req.body.filename.trim()
1643
+ : 'deliverable';
1644
+ const safeName = rawName.replace(/[^a-zA-Z0-9._ -]/g, '').replace(/\.docx?$/i, '').slice(0, 80) || 'deliverable';
1645
+ try {
1646
+ const docxBuf = await this.markdownToDocxBuffer(content);
1647
+ res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
1648
+ res.setHeader('Content-Disposition', `attachment; filename="${safeName}.docx"`);
1649
+ res.send(docxBuf);
1650
+ }
1651
+ catch (err) {
1652
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Export failed.' });
1653
+ }
1654
+ });
1655
+ this.app.post('/api/ai-hub/artifact/open', (req, res) => {
1656
+ const rawPath = typeof req.body?.path === 'string' ? req.body.path : '';
1657
+ const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.length > 0
1658
+ ? path_1.default.resolve(req.body.projectPath)
1659
+ : this.projectPath;
1660
+ if (!rawPath)
1661
+ return res.status(400).json({ error: 'path is required.' });
1662
+ const resolved = resolveSafeArtifactPath(rawPath, projectPath);
1663
+ if (!resolved)
1664
+ return res.status(403).json({ error: 'Path outside allowed roots.' });
1665
+ if (!fs_1.default.existsSync(resolved))
1666
+ return res.status(404).json({ error: 'File not found.' });
1667
+ try {
1668
+ hubOpenFile(resolved);
1669
+ return res.json({ ok: true, path: resolved });
1670
+ }
1671
+ catch (err) {
1672
+ return res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to open artifact.' });
1673
+ }
1674
+ });
757
1675
  this.app.post('/api/ai-hub/install-agent', async (req, res) => {
758
1676
  const { hubId } = req.body;
759
1677
  if (!hubId)
@@ -843,22 +1761,29 @@ class AiHubServer {
843
1761
  : {
844
1762
  jobId: req.body.jobId,
845
1763
  message: legacyMessage,
1764
+ display: legacyMessage,
846
1765
  };
847
1766
  const jobId = prepared.jobId;
848
1767
  const message = prepared.message;
1768
+ // #521: store only the manager's own words in the conversation; the agent
1769
+ // still receives `message` (with the Hub-injected invocation + notes).
1770
+ const managerDisplay = prepared.display || message;
849
1771
  if (!jobId) {
850
1772
  throw new Error('Choose a FRAIM job before starting a run.');
851
1773
  }
852
1774
  const startTimestamp = new Date().toISOString();
853
1775
  const run = {
854
1776
  id: (0, crypto_1.randomUUID)(),
1777
+ conversationId: typeof req.body.conversationId === 'string' && req.body.conversationId.trim() ? req.body.conversationId.trim() : undefined,
1778
+ conversationTitle: typeof req.body.conversationTitle === 'string' && req.body.conversationTitle.trim() ? req.body.conversationTitle.trim() : undefined,
1779
+ jobTitle: typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim() ? req.body.jobTitle.trim() : jobId,
855
1780
  jobId,
856
1781
  hostId,
857
1782
  projectPath,
858
1783
  status: 'running',
859
1784
  createdAt: startTimestamp,
860
1785
  updatedAt: startTimestamp,
861
- messages: [(0, hosts_1.createHubMessage)('manager', message)],
1786
+ messages: [(0, hosts_1.createHubMessage)('manager', managerDisplay)],
862
1787
  events: [(0, hosts_1.createHubEvent)('system', `Starting ${hostId} in ${projectPath}`)],
863
1788
  // Issue #347 — seed phase + totals state on creation.
864
1789
  currentPhase: null,
@@ -866,14 +1791,11 @@ class AiHubServer {
866
1791
  totals: emptyTotals(),
867
1792
  lastStatusChangeAt: startTimestamp,
868
1793
  personaKey: getProtectedPersonaForHubJob(jobId),
869
- // Gemini CLI does not emit a session ID in its output stream;
870
- // pre-seed one so the Send button is enabled and the continue
871
- // endpoint can proceed. continueRun for Gemini ignores it.
872
- ...(hostId === 'gemini' ? { sessionId: (0, crypto_1.randomUUID)() } : {}),
873
1794
  // Issue #442: mark this as the FRAIM side of an A/B pair when applicable.
874
1795
  ...(compareMode === 'ab' ? { runRole: 'fraim' } : {}),
875
1796
  };
876
1797
  this.runRegistry.create(run, {});
1798
+ this.persistRunConversation(run, run.conversationId || run.id);
877
1799
  // Issue #442: create the Direct (B) run before spawning either process
878
1800
  // so we can cross-link both runs via compareRunId before any events arrive.
879
1801
  // directMsg is the plain user instructions — no FRAIM invocation prefix.
@@ -912,10 +1834,11 @@ class AiHubServer {
912
1834
  this.runRegistry.update(run.id, (current) => {
913
1835
  if (event.sessionId)
914
1836
  current.sessionId = event.sessionId;
915
- if (event.message)
916
- current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
917
- if (event.raw)
1837
+ appendHostMessage(current, hostId, event, channel);
1838
+ if (event.raw) {
918
1839
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
1840
+ applyReviewProjection(current, event.raw);
1841
+ }
919
1842
  if (event.agentIdentity)
920
1843
  applyAgentIdentitySignal(current, event.agentIdentity);
921
1844
  if (event.seekMentoring)
@@ -923,16 +1846,29 @@ class AiHubServer {
923
1846
  if (event.usage)
924
1847
  applyUsageSignal(current, event.usage);
925
1848
  });
1849
+ const updated = this.runRegistry.get(run.id);
1850
+ if (updated)
1851
+ this.persistRunConversation(updated, updated.conversationId || updated.id);
926
1852
  },
927
1853
  onExit: (exitCode) => {
928
1854
  this.runRegistry.update(run.id, (current) => {
929
1855
  current.exitCode = exitCode;
930
- current.status = exitCode === 0 ? 'completed' : 'failed';
931
- current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
1856
+ if (current.stoppedByUser) {
1857
+ // Manager stopped it park in "waiting on you", don't call it a failure.
1858
+ current.status = 'failed';
1859
+ current.events.push((0, hosts_1.createHubEvent)('system', '⏹ Run stopped by you. The employee is paused — send the next instruction to continue.'));
1860
+ }
1861
+ else {
1862
+ current.status = exitCode === 0 ? 'completed' : 'failed';
1863
+ current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
1864
+ }
932
1865
  });
1866
+ const updated = this.runRegistry.get(run.id);
1867
+ if (updated)
1868
+ this.persistRunConversation(updated, updated.conversationId || updated.id);
933
1869
  this.runRegistry.dispose(run.id);
934
1870
  },
935
- });
1871
+ }, run.sessionId);
936
1872
  this.runRegistry.create(run, child);
937
1873
  // Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
938
1874
  // uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
@@ -944,8 +1880,7 @@ class AiHubServer {
944
1880
  this.runRegistry.update(directId, (current) => {
945
1881
  if (event.sessionId)
946
1882
  current.sessionId = event.sessionId;
947
- if (event.message)
948
- current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
1883
+ appendHostMessage(current, hostId, event, channel);
949
1884
  if (event.raw)
950
1885
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
951
1886
  if (event.agentIdentity)
@@ -962,7 +1897,7 @@ class AiHubServer {
962
1897
  });
963
1898
  this.runRegistry.dispose(directId);
964
1899
  },
965
- });
1900
+ }, directRun.sessionId);
966
1901
  this.runRegistry.create(directRun, directChild);
967
1902
  }
968
1903
  const existingPreferences = this.preferencesStore.load(projectPath);
@@ -971,7 +1906,7 @@ class AiHubServer {
971
1906
  projectPath,
972
1907
  employeeId: hostId,
973
1908
  recentJobIds: existingPreferences.recentJobIds,
974
- }, jobId);
1909
+ }, jobId, typeof instructions === 'string' ? instructions : undefined);
975
1910
  const fraimRunEnriched = this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run);
976
1911
  const responsePayload = directRun
977
1912
  ? { ...fraimRunEnriched, compareRun: this.enrichRunForResponse(directRun) }
@@ -990,6 +1925,60 @@ class AiHubServer {
990
1925
  res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
991
1926
  }
992
1927
  });
1928
+ // #521: Shared persistent browser. start() launches (or reuses) the one
1929
+ // FRAIM-owned Chrome/Edge and publishes its CDP endpoint to agents via env so
1930
+ // the browser-use skill connects to it instead of launching a throwaway one.
1931
+ this.app.get('/api/ai-hub/browser/status', async (_req, res) => {
1932
+ const running = await this.managedBrowser.isRunning();
1933
+ res.json({ running, ...this.managedBrowser.status() });
1934
+ });
1935
+ this.app.post('/api/ai-hub/browser/start', async (_req, res) => {
1936
+ try {
1937
+ const result = await this.ensureManagedBrowser();
1938
+ res.json({
1939
+ ok: true,
1940
+ ...result,
1941
+ loginHint: 'Log into your sites in the FRAIM browser window — the session persists across agent turns.',
1942
+ });
1943
+ }
1944
+ catch (err) {
1945
+ res.status(500).json({ error: err instanceof Error ? err.message : 'Could not start the browser.' });
1946
+ }
1947
+ });
1948
+ this.app.post('/api/ai-hub/browser/stop', (_req, res) => {
1949
+ // Kill the process; keep FRAIM_BROWSER_CDP_ENDPOINT published — it's the
1950
+ // deterministic location, so the agent can re-ensure and reconnect later.
1951
+ this.managedBrowser.stop();
1952
+ res.json({ ok: true });
1953
+ });
1954
+ // #521: Stop — the manager interrupts a running agent. Kills the process and
1955
+ // parks the run in a "waiting on you" state so they can give the next
1956
+ // instruction (continue) or leave it. Idempotent for already-finished runs.
1957
+ this.app.post('/api/ai-hub/runs/:runId/stop', (req, res) => {
1958
+ const run = this.runRegistry.get(req.params.runId);
1959
+ if (!run) {
1960
+ return res.status(404).json({ error: 'Run not found.' });
1961
+ }
1962
+ if (run.status !== 'running') {
1963
+ // Nothing to stop — return the current state unchanged.
1964
+ return res.json(this.enrichRunForResponse(run));
1965
+ }
1966
+ this.runRegistry.update(run.id, (current) => { current.stoppedByUser = true; });
1967
+ const killed = this.runRegistry.stop(run.id);
1968
+ // Park it immediately (don't wait for onExit, which may lag or not fire on a
1969
+ // host that already detached). onExit, if it fires, keeps this same state.
1970
+ this.runRegistry.update(run.id, (current) => {
1971
+ current.status = 'failed';
1972
+ current.events.push((0, hosts_1.createHubEvent)('system', killed
1973
+ ? '⏹ Run stopped by you. The employee is paused — send the next instruction to continue.'
1974
+ : '⏹ Stop requested. The employee was already wrapping up — send the next instruction to continue.'));
1975
+ });
1976
+ const stopped = this.runRegistry.get(run.id);
1977
+ if (stopped)
1978
+ this.persistRunConversation(stopped, stopped.conversationId || stopped.id);
1979
+ this.runRegistry.dispose(run.id);
1980
+ return res.json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
1981
+ });
993
1982
  this.app.post('/api/ai-hub/runs/:runId/messages', (req, res) => {
994
1983
  try {
995
1984
  const run = this.runRegistry.get(req.params.runId);
@@ -1004,28 +1993,34 @@ class AiHubServer {
1004
1993
  // When coachingJobId is present (user picked a manager template via the UI),
1005
1994
  // it overrides the run's own jobId in the invocation. The server always adds
1006
1995
  // the correct $fraim / /fraim prefix — the UI never passes raw invocation syntax.
1007
- const message = instructions
1996
+ const prepared = instructions
1008
1997
  ? this.prepareContinueMessage(run, instructions, coachingJobId)
1009
1998
  : coachingJobId
1010
1999
  ? this.prepareContinueMessage(run, '', coachingJobId)
1011
- : (req.body.message || '').trim();
2000
+ : { message: (req.body.message || '').trim(), display: (req.body.message || '').trim() };
2001
+ const message = prepared.message;
1012
2002
  if (!message) {
1013
2003
  return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
1014
2004
  }
1015
2005
  this.runRegistry.update(run.id, (current) => {
1016
2006
  current.status = 'running';
1017
- current.messages.push((0, hosts_1.createHubMessage)('manager', message));
2007
+ // #521: bubble shows the manager's words; the agent gets the full message.
2008
+ current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
1018
2009
  });
2010
+ const started = this.runRegistry.get(run.id);
2011
+ if (started)
2012
+ this.persistRunConversation(started, started.conversationId || started.id);
1019
2013
  this.runRegistry.create(run, {});
1020
2014
  const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message, {
1021
2015
  onEvent: (event, channel) => {
1022
2016
  this.runRegistry.update(run.id, (current) => {
1023
2017
  if (event.sessionId)
1024
2018
  current.sessionId = event.sessionId;
1025
- if (event.message)
1026
- current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
1027
- if (event.raw)
2019
+ appendHostMessage(current, run.hostId, event, channel);
2020
+ if (event.raw) {
1028
2021
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
2022
+ applyReviewProjection(current, event.raw);
2023
+ }
1029
2024
  if (event.agentIdentity)
1030
2025
  applyAgentIdentitySignal(current, event.agentIdentity);
1031
2026
  if (event.seekMentoring)
@@ -1033,6 +2028,9 @@ class AiHubServer {
1033
2028
  if (event.usage)
1034
2029
  applyUsageSignal(current, event.usage);
1035
2030
  });
2031
+ const updated = this.runRegistry.get(run.id);
2032
+ if (updated)
2033
+ this.persistRunConversation(updated, updated.conversationId || updated.id);
1036
2034
  },
1037
2035
  onExit: (exitCode) => {
1038
2036
  this.runRegistry.update(run.id, (current) => {
@@ -1040,6 +2038,9 @@ class AiHubServer {
1040
2038
  current.status = exitCode === 0 ? 'completed' : 'failed';
1041
2039
  current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
1042
2040
  });
2041
+ const updated = this.runRegistry.get(run.id);
2042
+ if (updated)
2043
+ this.persistRunConversation(updated, updated.conversationId || updated.id);
1043
2044
  this.runRegistry.dispose(run.id);
1044
2045
  },
1045
2046
  });
@@ -1051,6 +2052,93 @@ class AiHubServer {
1051
2052
  res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue run.' });
1052
2053
  }
1053
2054
  });
2055
+ // #521: resume a conversation whose Hub run was lost (e.g. a server restart)
2056
+ // but whose agent session still exists on disk. Recreates a run bound to the
2057
+ // existing sessionId and continues it via the host's resume path — so a
2058
+ // conversation can be carried forward without losing its context.
2059
+ this.app.post('/api/ai-hub/runs/resume', (req, res) => {
2060
+ try {
2061
+ const body = (req.body ?? {});
2062
+ const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
2063
+ const hostId = body.hostId;
2064
+ const sessionId = (body.sessionId || '').trim();
2065
+ const jobId = (body.jobId || '').trim();
2066
+ const instructions = (body.instructions || '').trim();
2067
+ const coachingJobId = body.coachingJobId?.trim() || undefined;
2068
+ if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
2069
+ throw new Error('Choose an available employee to resume.');
2070
+ }
2071
+ if (!sessionId)
2072
+ throw new Error('A host sessionId is required to resume.');
2073
+ if (!jobId)
2074
+ throw new Error('A jobId is required to resume.');
2075
+ if (!instructions)
2076
+ throw new Error('Provide an instruction to continue.');
2077
+ const employee = this.hostRuntime.detectEmployees().find((entry) => entry.id === hostId);
2078
+ if (!employee?.available) {
2079
+ throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
2080
+ }
2081
+ const now = new Date().toISOString();
2082
+ const run = {
2083
+ id: (0, crypto_1.randomUUID)(),
2084
+ conversationId: typeof body.conversationId === 'string' && body.conversationId.trim() ? body.conversationId.trim() : undefined,
2085
+ conversationTitle: typeof body.conversationTitle === 'string' && body.conversationTitle.trim() ? body.conversationTitle.trim() : undefined,
2086
+ jobTitle: typeof body.jobTitle === 'string' && body.jobTitle.trim() ? body.jobTitle.trim() : jobId,
2087
+ jobId, hostId, projectPath, status: 'running', sessionId,
2088
+ createdAt: now, updatedAt: now, messages: [],
2089
+ events: [(0, hosts_1.createHubEvent)('system', `Resuming ${hostId} session ${sessionId} in ${projectPath}`)],
2090
+ currentPhase: null, phaseHistory: [], totals: emptyTotals(), lastStatusChangeAt: now,
2091
+ personaKey: getProtectedPersonaForHubJob(jobId),
2092
+ };
2093
+ // Continue-turn message (FRAIM invocation for the job + instructions) plus
2094
+ // the shared-browser note so the resumed agent knows about it.
2095
+ const preparedResume = this.prepareContinueMessage(run, instructions, coachingJobId);
2096
+ const message = preparedResume.message
2097
+ + (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
2098
+ // #521: bubble shows the manager's words; the agent gets the full message.
2099
+ run.messages.push((0, hosts_1.createHubMessage)('manager', preparedResume.display || message));
2100
+ this.runRegistry.create(run, {});
2101
+ this.persistRunConversation(run, run.conversationId || run.id);
2102
+ const child = this.hostRuntime.continueRun(hostId, projectPath, sessionId, message, {
2103
+ onEvent: (event, channel) => {
2104
+ this.runRegistry.update(run.id, (current) => {
2105
+ if (event.sessionId)
2106
+ current.sessionId = event.sessionId;
2107
+ appendHostMessage(current, hostId, event, channel);
2108
+ if (event.raw) {
2109
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
2110
+ applyReviewProjection(current, event.raw);
2111
+ }
2112
+ if (event.agentIdentity)
2113
+ applyAgentIdentitySignal(current, event.agentIdentity);
2114
+ if (event.seekMentoring)
2115
+ applySeekMentoringSignal(current, event.seekMentoring);
2116
+ if (event.usage)
2117
+ applyUsageSignal(current, event.usage);
2118
+ });
2119
+ const updated = this.runRegistry.get(run.id);
2120
+ if (updated)
2121
+ this.persistRunConversation(updated, updated.conversationId || updated.id);
2122
+ },
2123
+ onExit: (exitCode) => {
2124
+ this.runRegistry.update(run.id, (current) => {
2125
+ current.exitCode = exitCode;
2126
+ current.status = exitCode === 0 ? 'completed' : 'failed';
2127
+ current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
2128
+ });
2129
+ const updated = this.runRegistry.get(run.id);
2130
+ if (updated)
2131
+ this.persistRunConversation(updated, updated.conversationId || updated.id);
2132
+ this.runRegistry.dispose(run.id);
2133
+ },
2134
+ });
2135
+ this.runRegistry.create(run, child);
2136
+ res.status(201).json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
2137
+ }
2138
+ catch (error) {
2139
+ res.status(400).json({ error: error instanceof Error ? error.message : 'Could not resume the conversation.' });
2140
+ }
2141
+ });
1054
2142
  // Issue #442: continue the Direct (B) run without FRAIM MCP servers.
1055
2143
  this.app.post('/api/ai-hub/runs/:runId/direct-messages', (req, res) => {
1056
2144
  try {
@@ -1074,8 +2162,7 @@ class AiHubServer {
1074
2162
  this.runRegistry.update(run.id, (current) => {
1075
2163
  if (event.sessionId)
1076
2164
  current.sessionId = event.sessionId;
1077
- if (event.message)
1078
- current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
2165
+ appendHostMessage(current, run.hostId, event, channel);
1079
2166
  if (event.raw)
1080
2167
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
1081
2168
  if (event.usage)
@@ -1167,8 +2254,7 @@ class AiHubServer {
1167
2254
  this.runRegistry.update(run.id, (current) => {
1168
2255
  if (event.sessionId)
1169
2256
  current.sessionId = event.sessionId;
1170
- if (event.message)
1171
- current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
2257
+ appendHostMessage(current, hostId, event, channel);
1172
2258
  if (event.raw)
1173
2259
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
1174
2260
  if (event.agentIdentity)
@@ -1187,7 +2273,7 @@ class AiHubServer {
1187
2273
  });
1188
2274
  this.runRegistry.dispose(run.id);
1189
2275
  },
1190
- });
2276
+ }, run.sessionId);
1191
2277
  // Update the registry entry with the real child process handle.
1192
2278
  this.runRegistry.create(run, child);
1193
2279
  return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
@@ -1197,6 +2283,14 @@ class AiHubServer {
1197
2283
  }
1198
2284
  });
1199
2285
  }
2286
+ // #521: start (or reuse) the shared browser and publish its CDP endpoint so
2287
+ // every agent spawned afterward inherits FRAIM_BROWSER_CDP_ENDPOINT and its
2288
+ // browser-use skill connects to the long-lived window instead of a throwaway.
2289
+ async ensureManagedBrowser() {
2290
+ const result = await this.managedBrowser.start();
2291
+ process.env.FRAIM_BROWSER_CDP_ENDPOINT = result.endpoint;
2292
+ return { endpoint: result.endpoint, reused: result.reused, channel: result.channel };
2293
+ }
1200
2294
  // Issue #347 — assemble the read-side projection of a run. Stages are
1201
2295
  // derived from job frontmatter + visited phases; totalDurationMs ticks
1202
2296
  // forward while the run is still running so the UI's totals line
@@ -1306,36 +2400,105 @@ function resolveTaskpaneDir(pane) {
1306
2400
  }
1307
2401
  return taskpaneDir;
1308
2402
  }
2403
+ /**
2404
+ * Open the OS-native "choose a folder" dialog and resolve to the selected
2405
+ * absolute path, or null if the user cancelled / no dialog was available.
2406
+ *
2407
+ * This is the genuinely native picker: it spawns the platform's own folder
2408
+ * chooser (WinForms FolderBrowserDialog on Windows, `choose folder` on macOS,
2409
+ * zenity/kdialog on Linux). The Hub runs on loopback on the user's machine, so
2410
+ * it can launch the real dialog itself.
2411
+ *
2412
+ * Implementation notes (kept in lockstep with src/first-run/server.ts, which
2413
+ * proved this out — see the long-form comment there):
2414
+ * - ASYNC (`spawn`, not `spawnSync`). The dialog blocks until the user
2415
+ * dismisses it, which can be many seconds. With `spawnSync` the entire Node
2416
+ * event loop freezes for that whole time — every other Hub HTTP request
2417
+ * (bootstrap, run polling) stalls and the Hub looks dead. `spawn` keeps the
2418
+ * server responsive while the dialog is up.
2419
+ * - Windows needs `-STA` (Single-Threaded Apartment) for FolderBrowserDialog
2420
+ * to work reliably, plus a hidden TopMost `$owner` form so the picker comes
2421
+ * to the foreground instead of opening behind the browser window.
2422
+ * - A hard timeout guarantees the endpoint can never hang forever: if nothing
2423
+ * is selected within the window (or the spawn wedges) we resolve null and
2424
+ * the caller degrades to "no change".
2425
+ * - NON-INTERACTIVE GUARD: a real OS folder dialog blocks waiting for a human.
2426
+ * In CI / automated UI tests (which DO reach this live endpoint) that would
2427
+ * hang. When AI_HUB_DISABLE_NATIVE_PICKER is set (or NODE_ENV=test) we skip
2428
+ * the dialog entirely and resolve null, so the endpoint returns 204 without
2429
+ * ever popping a window. Real desktop/loopback usage leaves it unset and
2430
+ * gets the genuine native dialog.
2431
+ */
1309
2432
  function pickProjectPath() {
2433
+ if (process.env.AI_HUB_DISABLE_NATIVE_PICKER === '1' ||
2434
+ process.env.NODE_ENV === 'test') {
2435
+ return Promise.resolve(null);
2436
+ }
2437
+ if (process.platform === 'win32') {
2438
+ const script = [
2439
+ 'Add-Type -AssemblyName System.Windows.Forms',
2440
+ '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
2441
+ '$dialog.Description = "Select a FRAIM project folder"',
2442
+ '$dialog.ShowNewFolderButton = $true',
2443
+ // Hidden owner form forces the dialog above the user's browser. Without
2444
+ // this the dialog often appears behind the browser tab and the user sees
2445
+ // nothing happen when they click the folder button.
2446
+ '$owner = New-Object System.Windows.Forms.Form',
2447
+ '$owner.TopMost = $true',
2448
+ '$owner.ShowInTaskbar = $false',
2449
+ 'if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {',
2450
+ ' Write-Output $dialog.SelectedPath',
2451
+ '}',
2452
+ '$owner.Dispose()',
2453
+ ].join('; ');
2454
+ return runPickerProcess('powershell', ['-NoProfile', '-STA', '-Command', script]);
2455
+ }
2456
+ if (process.platform === 'darwin') {
2457
+ return runPickerProcess('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")']);
2458
+ }
2459
+ return runPickerProcess('bash', ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null']);
2460
+ }
2461
+ /**
2462
+ * Spawn a native folder-dialog process and resolve to its trimmed stdout (the
2463
+ * chosen path) or null. Hardened so it can never hang the server: any spawn
2464
+ * error resolves null, and a hard timeout kills a wedged child and resolves
2465
+ * null. The timeout is generous (the user may take a while to browse) but
2466
+ * finite.
2467
+ */
2468
+ function runPickerProcess(command, args, timeoutMs = 5 * 60_000) {
1310
2469
  return new Promise((resolve) => {
1311
- let cmd;
1312
- let args;
1313
- if (process.platform === 'win32') {
1314
- const script = [
1315
- 'Add-Type -AssemblyName System.Windows.Forms',
1316
- '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
1317
- '$dialog.ShowNewFolderButton = $false',
1318
- 'if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {',
1319
- ' Write-Output $dialog.SelectedPath',
1320
- '}',
1321
- ].join('; ');
1322
- cmd = 'powershell';
1323
- args = ['-NoProfile', '-Command', script];
1324
- }
1325
- else if (process.platform === 'darwin') {
1326
- cmd = 'osascript';
1327
- args = ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")'];
2470
+ let settled = false;
2471
+ const finish = (value) => {
2472
+ if (settled)
2473
+ return;
2474
+ settled = true;
2475
+ clearTimeout(timer);
2476
+ resolve(value);
2477
+ };
2478
+ let proc;
2479
+ try {
2480
+ proc = (0, child_process_1.spawn)(command, args, {
2481
+ stdio: ['ignore', 'pipe', 'pipe'],
2482
+ windowsHide: true,
2483
+ });
1328
2484
  }
1329
- else {
1330
- cmd = 'bash';
1331
- args = ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null'];
2485
+ catch {
2486
+ finish(null);
2487
+ return;
1332
2488
  }
2489
+ const timer = setTimeout(() => {
2490
+ try {
2491
+ proc.kill();
2492
+ }
2493
+ catch {
2494
+ /* ignore */
2495
+ }
2496
+ finish(null);
2497
+ }, timeoutMs);
1333
2498
  let stdout = '';
1334
- const child = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
1335
- child.stdout.on('data', (chunk) => { stdout += chunk; });
1336
- child.on('close', (code) => {
1337
- resolve(code === 0 ? stdout.trim() || null : null);
1338
- });
1339
- child.on('error', () => resolve(null));
2499
+ proc.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
2500
+ proc.stderr?.on('data', () => { });
2501
+ proc.on('close', () => finish(stdout.trim() || null));
2502
+ proc.on('error', () => finish(null));
1340
2503
  });
1341
2504
  }