fraim 2.0.154 → 2.0.160

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.
Files changed (45) hide show
  1. package/README.md +1 -1
  2. package/dist/src/ai-hub/cert-store.js +70 -0
  3. package/dist/src/ai-hub/desktop-main.js +225 -50
  4. package/dist/src/ai-hub/hosts.js +135 -8
  5. package/dist/src/ai-hub/manager-turns.js +38 -0
  6. package/dist/src/ai-hub/office-sideload.js +138 -0
  7. package/dist/src/ai-hub/openclaw-bridge.js +239 -0
  8. package/dist/src/ai-hub/server.js +479 -48
  9. package/dist/src/ai-hub/word-sideload.js +95 -0
  10. package/dist/src/cli/commands/add-ide.js +9 -0
  11. package/dist/src/cli/commands/init-project.js +46 -34
  12. package/dist/src/cli/commands/login.js +1 -2
  13. package/dist/src/cli/commands/setup.js +0 -2
  14. package/dist/src/cli/commands/sync.js +41 -11
  15. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +66 -2
  16. package/dist/src/cli/doctor/checks/workflow-checks.js +1 -65
  17. package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +136 -0
  18. package/dist/src/cli/mcp/mcp-server-registry.js +14 -10
  19. package/dist/src/cli/setup/auto-mcp-setup.js +1 -1
  20. package/dist/src/cli/setup/ide-invocation-surfaces.js +2 -2
  21. package/dist/src/cli/utils/fraim-gitignore.js +11 -0
  22. package/dist/src/cli/utils/github-workflow-sync.js +231 -0
  23. package/dist/src/cli/utils/managed-agent-paths.js +1 -1
  24. package/dist/src/cli/utils/project-bootstrap.js +6 -3
  25. package/dist/src/cli/utils/remote-sync.js +1 -1
  26. package/dist/src/core/ai-mentor.js +46 -37
  27. package/dist/src/core/config-loader.js +69 -2
  28. package/dist/src/core/fraim-config-schema.generated.js +267 -6
  29. package/dist/src/core/types.js +0 -1
  30. package/dist/src/core/utils/fraim-labels.js +182 -0
  31. package/dist/src/core/utils/git-utils.js +22 -1
  32. package/dist/src/core/utils/project-fraim-paths.js +58 -0
  33. package/dist/src/first-run/session-service.js +3 -3
  34. package/dist/src/first-run/types.js +1 -1
  35. package/dist/src/local-mcp-server/learning-context-builder.js +77 -52
  36. package/dist/src/local-mcp-server/stdio-server.js +212 -13
  37. package/package.json +6 -2
  38. package/public/ai-hub/index.html +289 -229
  39. package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
  40. package/public/ai-hub/powerpoint-taskpane/index.html +235 -0
  41. package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
  42. package/public/ai-hub/script.js +1155 -586
  43. package/public/ai-hub/styles.css +1226 -722
  44. package/public/first-run/index.html +35 -35
  45. package/public/first-run/script.js +667 -667
@@ -12,6 +12,7 @@ const net_1 = __importDefault(require("net"));
12
12
  const os_1 = __importDefault(require("os"));
13
13
  const crypto_1 = require("crypto");
14
14
  const child_process_1 = require("child_process");
15
+ const https_1 = __importDefault(require("https"));
15
16
  const types_1 = require("../first-run/types");
16
17
  const PERSONA_AVATAR_SEEDS = {
17
18
  maestro: { seed: 'MAESTRO-founder-mode', bg: 'fde68a' },
@@ -40,6 +41,7 @@ function buildPersonaAvatarUrl(personaKey) {
40
41
  const catalog_1 = require("./catalog");
41
42
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
42
43
  const hosts_1 = require("./hosts");
44
+ const manager_turns_1 = require("./manager-turns");
43
45
  const preferences_1 = require("./preferences");
44
46
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
45
47
  function loadPersonaCapabilityModule() {
@@ -282,12 +284,12 @@ function deriveStages(run, projectPath) {
282
284
  const completedWithoutPhaseTelemetry = run.status === 'completed' &&
283
285
  currentIndex < 0 &&
284
286
  historyMap.size === 0;
287
+ if (completedWithoutPhaseTelemetry) {
288
+ return [];
289
+ }
285
290
  return declaredPath.map((phase, index) => {
286
291
  let state;
287
- if (completedWithoutPhaseTelemetry) {
288
- state = 'done';
289
- }
290
- else if (currentIndex < 0) {
292
+ if (currentIndex < 0) {
291
293
  state = 'upcoming';
292
294
  }
293
295
  else if (index < currentIndex) {
@@ -411,6 +413,10 @@ class AiHubServer {
411
413
  this.runRegistry = new AiHubRunRegistry();
412
414
  this.projectPath = options.projectPath || process.cwd();
413
415
  this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
416
+ this.wordTaskpaneDir = options.wordTaskpaneDir ?? resolveWordTaskpaneDir(this.projectPath);
417
+ this.folderPicker = options.folderPicker ?? pickProjectPath;
418
+ this.httpsPort = options.httpsPort;
419
+ this.certBundle = options.certBundle;
414
420
  this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
415
421
  if (options.dbService !== undefined) {
416
422
  this.dbService = options.dbService;
@@ -421,10 +427,74 @@ class AiHubServer {
421
427
  this.ownsDbService = this.dbService !== undefined;
422
428
  }
423
429
  this.app.use(express_1.default.json());
430
+ // CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
431
+ // calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
432
+ this.app.use((_req, res, next) => {
433
+ res.setHeader('Access-Control-Allow-Origin', '*');
434
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
435
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
436
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
437
+ if (_req.method === 'OPTIONS') {
438
+ res.sendStatus(204);
439
+ return;
440
+ }
441
+ next();
442
+ });
424
443
  this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
444
+ // Issue #489: Serve the Word task pane assets at /word-taskpane/*.
445
+ // Office JS appends ?_host_Info=Word$Win32$... to every request — we must
446
+ // strip the query string before resolving the file path, otherwise every
447
+ // request returns 404. express.static does NOT strip query strings, so we
448
+ // use a custom middleware that resolves the pathname manually.
449
+ if (this.wordTaskpaneDir) {
450
+ const wordDir = this.wordTaskpaneDir; // capture for closure
451
+ this.app.use('/word-taskpane', (req, res, next) => {
452
+ // Chrome Private Network Access: public origin (word-edit.officeapps.live.com)
453
+ // loading a private-network resource (localhost) requires this header on both the
454
+ // preflight OPTIONS response and the final GET response.
455
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
456
+ res.setHeader('Access-Control-Allow-Origin', '*');
457
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
458
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
459
+ if (req.method === 'OPTIONS') {
460
+ res.sendStatus(204);
461
+ return;
462
+ }
463
+ // Strip query string — Office appends ?_host_Info=Word$Win32$... to every request
464
+ const { pathname } = new URL(req.url, 'http://localhost');
465
+ const target = pathname === '/' || pathname === '' ? '/taskpane.html' : pathname;
466
+ // Prevent path traversal
467
+ const safeTarget = path_1.default.normalize(target).replace(/^(\.\.(\/|\\|$))+/, '');
468
+ const filePath = path_1.default.join(wordDir, safeTarget);
469
+ if (!filePath.startsWith(wordDir + path_1.default.sep) && filePath !== wordDir) {
470
+ res.status(403).end();
471
+ return;
472
+ }
473
+ const ext = path_1.default.extname(filePath);
474
+ const contentTypes = {
475
+ '.html': 'text/html; charset=utf-8',
476
+ '.css': 'text/css; charset=utf-8',
477
+ '.js': 'application/javascript; charset=utf-8',
478
+ '.xml': 'application/xml; charset=utf-8',
479
+ };
480
+ const contentType = contentTypes[ext] || 'text/plain; charset=utf-8';
481
+ fs_1.default.readFile(filePath, (err, data) => {
482
+ if (err) {
483
+ next(); // fall through to 404
484
+ return;
485
+ }
486
+ res.setHeader('Content-Type', contentType);
487
+ res.end(data);
488
+ });
489
+ });
490
+ }
425
491
  this.app.get('/health', (_req, res) => {
426
492
  res.json({ status: 'ok', service: 'fraim-ai-hub' });
427
493
  });
494
+ // Extended health endpoint for trigger surfaces (browser extension, Office add-ins, tray)
495
+ this.app.get('/api/health', (_req, res) => {
496
+ res.json({ status: 'ok', service: 'fraim-ai-hub' });
497
+ });
428
498
  this.registerRoutes();
429
499
  }
430
500
  getApp() {
@@ -445,18 +515,31 @@ class AiHubServer {
445
515
  this.httpServer.once('listening', () => resolve());
446
516
  this.httpServer.once('error', (error) => reject(error));
447
517
  });
518
+ // Start HTTPS server when a cert bundle and port are provided.
519
+ // Word Online requires HTTPS; the HTTPS server shares the same Express app
520
+ // so all routes (including /word-taskpane/*) are available over both protocols.
521
+ if (this.httpsPort && this.certBundle) {
522
+ await new Promise((resolve, reject) => {
523
+ this.httpsServer = https_1.default.createServer({ key: this.certBundle.key, cert: this.certBundle.cert }, this.app);
524
+ this.httpsServer.listen(this.httpsPort, '127.0.0.1');
525
+ this.httpsServer.once('listening', () => resolve());
526
+ this.httpsServer.once('error', (error) => reject(error));
527
+ });
528
+ }
448
529
  }
449
530
  async stop() {
531
+ const closeServer = (srv) => new Promise((resolve, reject) => {
532
+ srv.close((error) => { if (error)
533
+ reject(error);
534
+ else
535
+ resolve(); });
536
+ });
537
+ if (this.httpsServer) {
538
+ await closeServer(this.httpsServer);
539
+ this.httpsServer = undefined;
540
+ }
450
541
  if (this.httpServer) {
451
- await new Promise((resolve, reject) => {
452
- this.httpServer.close((error) => {
453
- if (error) {
454
- reject(error);
455
- return;
456
- }
457
- resolve();
458
- });
459
- });
542
+ await closeServer(this.httpServer);
460
543
  this.httpServer = undefined;
461
544
  }
462
545
  if (this.ownsDbService && this.dbService) {
@@ -464,6 +547,7 @@ class AiHubServer {
464
547
  this.dbService = undefined;
465
548
  }
466
549
  }
550
+ getHttpsPort() { return this.httpsPort; }
467
551
  async bootstrapResponse(projectPath, apiKey) {
468
552
  const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
469
553
  const employees = this.hostRuntime.detectEmployees();
@@ -504,6 +588,45 @@ class AiHubServer {
504
588
  activeRun,
505
589
  };
506
590
  }
591
+ resolveHubJob(projectPath, jobId) {
592
+ const employeeJob = (0, catalog_1.discoverEmployeeJobs)(projectPath).find((job) => job.id === jobId);
593
+ if (employeeJob)
594
+ return { id: employeeJob.id, stubPath: employeeJob.stubPath };
595
+ const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath).find((job) => job.id === jobId);
596
+ if (managerTemplate)
597
+ return { id: managerTemplate.id, stubPath: managerTemplate.stubPath };
598
+ return null;
599
+ }
600
+ prepareStartPayload(projectPath, hostId, selectedJobId, instructions) {
601
+ const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
602
+ const resolvedJobId = explicit?.jobId || selectedJobId;
603
+ if (!resolvedJobId) {
604
+ throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
605
+ }
606
+ if (resolvedJobId === '__freeform__') {
607
+ return {
608
+ jobId: resolvedJobId,
609
+ message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions),
610
+ };
611
+ }
612
+ const resolvedJob = this.resolveHubJob(projectPath, resolvedJobId);
613
+ const absoluteStubPath = resolvedJob?.stubPath
614
+ ? [projectPath, resolvedJob.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
615
+ : undefined;
616
+ return {
617
+ jobId: resolvedJobId,
618
+ message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath),
619
+ };
620
+ }
621
+ prepareContinueMessage(run, instructions, coachingJobId) {
622
+ // coachingJobId is set when the user selected a manager coaching template
623
+ // (e.g. follow-your-mentor). When present, it overrides the run's own jobId
624
+ // as the target of the FRAIM invocation. The server picks the correct
625
+ // invocation prefix ($fraim / /fraim) based on run.hostId — the UI never
626
+ // passes raw invocation syntax.
627
+ const effectiveJobId = coachingJobId || run.jobId;
628
+ return (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions);
629
+ }
507
630
  async computePersonas(apiKey) {
508
631
  const allBundles = listHubPersonaBundles();
509
632
  const fallbackPersonas = allBundles.map((bundle) => ({
@@ -541,13 +664,60 @@ class AiHubServer {
541
664
  }
542
665
  }
543
666
  registerRoutes() {
667
+ // Issue #478: Serve the PowerPoint task pane HTML and manifest.
668
+ // Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
669
+ // request, so we must strip them before resolving the file path. Use a custom
670
+ // route rather than express.static so we can apply the new URL().pathname fix.
671
+ this.app.get(/^\/powerpoint-taskpane(\/.*)?$/, (req, res) => {
672
+ const taskpaneDir = resolveTaskpaneDir('powerpoint-taskpane');
673
+ // Strip query string — Office appends ?_host_Info=PowerPoint$... to every fetch.
674
+ const { pathname } = new URL(req.url, 'http://localhost');
675
+ // Map root /powerpoint-taskpane/ to index.html
676
+ const relativePath = pathname.replace(/^\/powerpoint-taskpane\/?/, '') || 'index.html';
677
+ const filePath = path_1.default.join(taskpaneDir, relativePath);
678
+ if (!filePath.startsWith(taskpaneDir)) {
679
+ // Path traversal guard
680
+ res.status(403).end();
681
+ return;
682
+ }
683
+ const ext = path_1.default.extname(filePath).toLowerCase();
684
+ const contentTypeMap = {
685
+ '.html': 'text/html; charset=utf-8',
686
+ '.xml': 'application/xml',
687
+ '.js': 'application/javascript',
688
+ '.css': 'text/css',
689
+ '.png': 'image/png',
690
+ };
691
+ const contentType = contentTypeMap[ext] || 'text/plain';
692
+ fs_1.default.readFile(filePath, (err, data) => {
693
+ if (err) {
694
+ res.status(404).end('Not found');
695
+ return;
696
+ }
697
+ res.setHeader('Content-Type', contentType);
698
+ res.setHeader('Access-Control-Allow-Origin', '*');
699
+ res.end(data);
700
+ });
701
+ });
544
702
  this.app.get('/api/ai-hub/bootstrap', async (req, res) => {
545
- const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
703
+ let projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
546
704
  ? req.query.projectPath
547
- : this.projectPath;
705
+ : null;
706
+ // If no explicit projectPath but a file:// docUrl was passed (Word Desktop),
707
+ // derive the project directory from the document's local path.
708
+ if (!projectPath && typeof req.query.docUrl === 'string' && req.query.docUrl.startsWith('file://')) {
709
+ try {
710
+ const fileUrl = new URL(req.query.docUrl);
711
+ const rawPath = process.platform === 'win32'
712
+ ? fileUrl.pathname.replace(/^\/([A-Za-z]:)/, '$1')
713
+ : fileUrl.pathname;
714
+ projectPath = path_1.default.dirname(decodeURIComponent(rawPath));
715
+ }
716
+ catch { }
717
+ }
548
718
  // Read API key from header — query-param API keys are prohibited (§3.14)
549
719
  const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
550
- res.json(await this.bootstrapResponse(projectPath, apiKey));
720
+ res.json(await this.bootstrapResponse(projectPath || this.projectPath, apiKey));
551
721
  });
552
722
  this.app.post('/api/ai-hub/api-key', (req, res) => {
553
723
  const { apiKey } = req.body;
@@ -563,9 +733,9 @@ class AiHubServer {
563
733
  this.preferencesStore.save({ ...prefs, personaKey: personaKey ?? null });
564
734
  return res.json({ ok: true });
565
735
  });
566
- this.app.post('/api/ai-hub/project-path/pick', (_req, res) => {
736
+ this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
567
737
  try {
568
- const projectPath = pickProjectPath();
738
+ const projectPath = await this.folderPicker();
569
739
  if (!projectPath) {
570
740
  return res.status(204).end();
571
741
  }
@@ -646,21 +816,30 @@ class AiHubServer {
646
816
  try {
647
817
  const projectPath = ensureDirectoryPath(req.body.projectPath || this.projectPath);
648
818
  const hostId = req.body.hostId;
649
- const jobId = req.body.jobId;
650
- const message = (req.body.message || '').trim();
819
+ const instructions = (req.body.instructions || '').trim();
820
+ const legacyMessage = (req.body.message || '').trim();
821
+ const compareMode = req.body.compareMode;
651
822
  if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
652
823
  throw new Error('Choose an available employee before starting a job.');
653
824
  }
654
- if (!jobId) {
655
- throw new Error('Choose a FRAIM job before starting a run.');
656
- }
657
- if (!message) {
825
+ if (!instructions && !legacyMessage) {
658
826
  throw new Error('Coach your employee before starting the run.');
659
827
  }
660
828
  const employee = this.hostRuntime.detectEmployees().find((entry) => entry.id === hostId);
661
829
  if (!employee?.available) {
662
830
  throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
663
831
  }
832
+ const prepared = instructions
833
+ ? this.prepareStartPayload(projectPath, hostId, req.body.jobId, instructions)
834
+ : {
835
+ jobId: req.body.jobId,
836
+ message: legacyMessage,
837
+ };
838
+ const jobId = prepared.jobId;
839
+ const message = prepared.message;
840
+ if (!jobId) {
841
+ throw new Error('Choose a FRAIM job before starting a run.');
842
+ }
664
843
  const startTimestamp = new Date().toISOString();
665
844
  const run = {
666
845
  id: (0, crypto_1.randomUUID)(),
@@ -682,8 +861,43 @@ class AiHubServer {
682
861
  // pre-seed one so the Send button is enabled and the continue
683
862
  // endpoint can proceed. continueRun for Gemini ignores it.
684
863
  ...(hostId === 'gemini' ? { sessionId: (0, crypto_1.randomUUID)() } : {}),
864
+ // Issue #442: mark this as the FRAIM side of an A/B pair when applicable.
865
+ ...(compareMode === 'ab' ? { runRole: 'fraim' } : {}),
685
866
  };
686
867
  this.runRegistry.create(run, {});
868
+ // Issue #442: create the Direct (B) run before spawning either process
869
+ // so we can cross-link both runs via compareRunId before any events arrive.
870
+ // directMsg is the plain user instructions — no FRAIM invocation prefix.
871
+ const directMsg = compareMode === 'ab'
872
+ ? ((req.body.directInstructions || '').trim() || instructions || message)
873
+ : message;
874
+ let directRun;
875
+ if (compareMode === 'ab') {
876
+ const directTimestamp = new Date().toISOString();
877
+ directRun = {
878
+ id: (0, crypto_1.randomUUID)(),
879
+ jobId: 'direct',
880
+ hostId,
881
+ projectPath,
882
+ status: 'running',
883
+ createdAt: directTimestamp,
884
+ updatedAt: directTimestamp,
885
+ messages: [(0, hosts_1.createHubMessage)('manager', directMsg)],
886
+ events: [(0, hosts_1.createHubEvent)('system', `Starting direct (no FRAIM) ${hostId} in ${projectPath}`)],
887
+ currentPhase: null,
888
+ phaseHistory: [],
889
+ totals: emptyTotals(),
890
+ lastStatusChangeAt: directTimestamp,
891
+ personaKey: null,
892
+ runRole: 'direct',
893
+ compareRunId: run.id,
894
+ };
895
+ // Back-link the FRAIM run to the Direct run.
896
+ this.runRegistry.update(run.id, (current) => {
897
+ current.compareRunId = directRun.id;
898
+ });
899
+ this.runRegistry.create(directRun, {});
900
+ }
687
901
  const child = this.hostRuntime.startRun(hostId, projectPath, message, {
688
902
  onEvent: (event, channel) => {
689
903
  this.runRegistry.update(run.id, (current) => {
@@ -711,6 +925,37 @@ class AiHubServer {
711
925
  },
712
926
  });
713
927
  this.runRegistry.create(run, child);
928
+ // Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
929
+ // uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
930
+ // the FRAIM-wrapping buildStartPlan path.
931
+ if (directRun) {
932
+ const directId = directRun.id;
933
+ const directChild = this.hostRuntime.startDirectRun(hostId, directMsg, projectPath, {
934
+ onEvent: (event, channel) => {
935
+ this.runRegistry.update(directId, (current) => {
936
+ if (event.sessionId)
937
+ current.sessionId = event.sessionId;
938
+ if (event.message)
939
+ current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
940
+ if (event.raw)
941
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
942
+ if (event.agentIdentity)
943
+ applyAgentIdentitySignal(current, event.agentIdentity);
944
+ if (event.usage)
945
+ applyUsageSignal(current, event.usage);
946
+ });
947
+ },
948
+ onExit: (exitCode) => {
949
+ this.runRegistry.update(directId, (current) => {
950
+ current.exitCode = exitCode;
951
+ current.status = exitCode === 0 ? 'completed' : 'failed';
952
+ current.events.push((0, hosts_1.createHubEvent)('system', `Direct run exited with code ${exitCode ?? 'unknown'}.`));
953
+ });
954
+ this.runRegistry.dispose(directId);
955
+ },
956
+ });
957
+ this.runRegistry.create(directRun, directChild);
958
+ }
714
959
  const existingPreferences = this.preferencesStore.load(projectPath);
715
960
  this.preferencesStore.remember({
716
961
  ...existingPreferences,
@@ -718,7 +963,11 @@ class AiHubServer {
718
963
  employeeId: hostId,
719
964
  recentJobIds: existingPreferences.recentJobIds,
720
965
  }, jobId);
721
- res.status(201).json(this.enrichRunForResponse(run));
966
+ const fraimRunEnriched = this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run);
967
+ const responsePayload = directRun
968
+ ? { ...fraimRunEnriched, compareRun: this.enrichRunForResponse(directRun) }
969
+ : fraimRunEnriched;
970
+ res.status(201).json(responsePayload);
722
971
  // Background sync: refresh the local FRAIM catalog so the next job
723
972
  // picker load sees any jobs that were added or updated since last run.
724
973
  try {
@@ -741,7 +990,16 @@ class AiHubServer {
741
990
  if (!run.sessionId) {
742
991
  return res.status(409).json({ error: 'This run does not have a resumable host session yet.' });
743
992
  }
744
- const message = (req.body.message || '').trim();
993
+ const instructions = (req.body.instructions || '').trim();
994
+ const coachingJobId = req.body.coachingJobId?.trim() || undefined;
995
+ // When coachingJobId is present (user picked a manager template via the UI),
996
+ // it overrides the run's own jobId in the invocation. The server always adds
997
+ // the correct $fraim / /fraim prefix — the UI never passes raw invocation syntax.
998
+ const message = instructions
999
+ ? this.prepareContinueMessage(run, instructions, coachingJobId)
1000
+ : coachingJobId
1001
+ ? this.prepareContinueMessage(run, '', coachingJobId)
1002
+ : (req.body.message || '').trim();
745
1003
  if (!message) {
746
1004
  return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
747
1005
  }
@@ -784,6 +1042,54 @@ class AiHubServer {
784
1042
  res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue run.' });
785
1043
  }
786
1044
  });
1045
+ // Issue #442: continue the Direct (B) run without FRAIM MCP servers.
1046
+ this.app.post('/api/ai-hub/runs/:runId/direct-messages', (req, res) => {
1047
+ try {
1048
+ const run = this.runRegistry.get(req.params.runId);
1049
+ if (!run)
1050
+ return res.status(404).json({ error: 'Run not found.' });
1051
+ if (run.runRole !== 'direct')
1052
+ return res.status(400).json({ error: 'Run is not a Direct run.' });
1053
+ if (!run.sessionId)
1054
+ return res.status(409).json({ error: 'Direct run does not have a resumable session yet.' });
1055
+ const message = (req.body.message || '').trim();
1056
+ if (!message)
1057
+ return res.status(400).json({ error: 'Message is required.' });
1058
+ this.runRegistry.update(run.id, (current) => {
1059
+ current.status = 'running';
1060
+ current.messages.push((0, hosts_1.createHubMessage)('manager', message));
1061
+ });
1062
+ this.runRegistry.create(run, {});
1063
+ const child = this.hostRuntime.continueDirectRun(run.hostId, run.sessionId, message, run.projectPath, {
1064
+ onEvent: (event, channel) => {
1065
+ this.runRegistry.update(run.id, (current) => {
1066
+ if (event.sessionId)
1067
+ current.sessionId = event.sessionId;
1068
+ if (event.message)
1069
+ current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
1070
+ if (event.raw)
1071
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
1072
+ if (event.usage)
1073
+ applyUsageSignal(current, event.usage);
1074
+ });
1075
+ },
1076
+ onExit: (exitCode) => {
1077
+ this.runRegistry.update(run.id, (current) => {
1078
+ current.exitCode = exitCode;
1079
+ current.status = exitCode === 0 ? 'completed' : 'failed';
1080
+ current.events.push((0, hosts_1.createHubEvent)('system', `Direct run exited with code ${exitCode ?? 'unknown'}.`));
1081
+ });
1082
+ this.runRegistry.dispose(run.id);
1083
+ },
1084
+ });
1085
+ this.runRegistry.create(run, child);
1086
+ const refreshed = this.runRegistry.get(run.id);
1087
+ res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
1088
+ }
1089
+ catch (error) {
1090
+ res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue Direct run.' });
1091
+ }
1092
+ });
787
1093
  this.app.get('/api/ai-hub/runs/:runId', (req, res) => {
788
1094
  const run = this.runRegistry.get(req.params.runId);
789
1095
  if (!run) {
@@ -791,6 +1097,96 @@ class AiHubServer {
791
1097
  }
792
1098
  return res.json(this.enrichRunForResponse(run));
793
1099
  });
1100
+ // -------------------------------------------------------------------------
1101
+ // Issue #489: POST /api/trigger
1102
+ // Stable API endpoint for extension surfaces (Office add-ins, browser
1103
+ // extensions, VS Code extensions, Electron tray) to start FRAIM jobs.
1104
+ //
1105
+ // Body: { employeeId, jobName, context?: { text, sourceApp, fileName, ... }, projectPath? }
1106
+ // Response: { runId, status: "started", employee, job }
1107
+ // -------------------------------------------------------------------------
1108
+ this.app.post('/api/trigger', (req, res) => {
1109
+ try {
1110
+ const { employeeId, jobName, context, projectPath: reqProjectPath } = req.body;
1111
+ if (!employeeId) {
1112
+ return res.status(400).json({ error: 'employeeId is required.' });
1113
+ }
1114
+ if (!jobName) {
1115
+ return res.status(400).json({ error: 'jobName is required.' });
1116
+ }
1117
+ const projectPath = ensureDirectoryPath(reqProjectPath || this.projectPath);
1118
+ // Use the requested agent directly — caller specifies which agent to run (claude, codex, gemini).
1119
+ const employees = this.hostRuntime.detectEmployees();
1120
+ const hostId = employeeId;
1121
+ const employee = employees.find(e => e.id === hostId);
1122
+ if (!employee?.available) {
1123
+ return res.status(503).json({ error: `${hostId} is not available on this machine.` });
1124
+ }
1125
+ const contextText = context?.text?.trim() || '';
1126
+ const sourceInfo = [
1127
+ context?.sourceApp ? `sourceApp: ${context.sourceApp}` : '',
1128
+ context?.fileName ? `file: ${context.fileName}` : '',
1129
+ ].filter(Boolean).join(', ');
1130
+ const message = [
1131
+ `/fraim ${jobName}`,
1132
+ '',
1133
+ sourceInfo ? `Context (${sourceInfo}):` : 'Context:',
1134
+ contextText || '(no context provided)',
1135
+ ].join('\n');
1136
+ const startTimestamp = new Date().toISOString();
1137
+ const run = {
1138
+ id: (0, crypto_1.randomUUID)(),
1139
+ jobId: jobName,
1140
+ hostId,
1141
+ projectPath,
1142
+ status: 'running',
1143
+ createdAt: startTimestamp,
1144
+ updatedAt: startTimestamp,
1145
+ messages: [(0, hosts_1.createHubMessage)('manager', message)],
1146
+ events: [(0, hosts_1.createHubEvent)('system', `Trigger: ${employeeId} / ${jobName} from ${context?.sourceApp || 'unknown'} in ${projectPath}`)],
1147
+ currentPhase: null,
1148
+ phaseHistory: [],
1149
+ totals: emptyTotals(),
1150
+ lastStatusChangeAt: startTimestamp,
1151
+ personaKey: getProtectedPersonaForHubJob(jobName),
1152
+ };
1153
+ // Register the run before spawning so onEvent/onExit callbacks can
1154
+ // safely call update() even if they fire synchronously (FakeHostRuntime).
1155
+ this.runRegistry.create(run, {});
1156
+ const child = this.hostRuntime.startRun(hostId, projectPath, message, {
1157
+ onEvent: (event, channel) => {
1158
+ this.runRegistry.update(run.id, (current) => {
1159
+ if (event.sessionId)
1160
+ current.sessionId = event.sessionId;
1161
+ if (event.message)
1162
+ current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
1163
+ if (event.raw)
1164
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
1165
+ if (event.agentIdentity)
1166
+ applyAgentIdentitySignal(current, event.agentIdentity);
1167
+ if (event.seekMentoring)
1168
+ applySeekMentoringSignal(current, event.seekMentoring);
1169
+ if (event.usage)
1170
+ applyUsageSignal(current, event.usage);
1171
+ });
1172
+ },
1173
+ onExit: (exitCode) => {
1174
+ this.runRegistry.update(run.id, (current) => {
1175
+ current.exitCode = exitCode;
1176
+ current.status = exitCode === 0 ? 'completed' : 'failed';
1177
+ current.events.push((0, hosts_1.createHubEvent)('system', `Trigger run exited with code ${exitCode ?? 'unknown'}.`));
1178
+ });
1179
+ this.runRegistry.dispose(run.id);
1180
+ },
1181
+ });
1182
+ // Update the registry entry with the real child process handle.
1183
+ this.runRegistry.create(run, child);
1184
+ return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
1185
+ }
1186
+ catch (error) {
1187
+ return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
1188
+ }
1189
+ });
794
1190
  }
795
1191
  // Issue #347 — assemble the read-side projection of a run. Stages are
796
1192
  // derived from job frontmatter + visited phases; totalDurationMs ticks
@@ -866,29 +1262,64 @@ function resolveAiHubPublicDir() {
866
1262
  }
867
1263
  throw new Error('Could not locate public/ai-hub assets.');
868
1264
  }
869
- function pickProjectPath() {
870
- if (process.platform === 'win32') {
871
- const script = [
872
- 'Add-Type -AssemblyName System.Windows.Forms',
873
- '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
874
- '$dialog.ShowNewFolderButton = $false',
875
- 'if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {',
876
- ' Write-Output $dialog.SelectedPath',
877
- '}',
878
- ].join('; ');
879
- const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-Command', script], {
880
- encoding: 'utf8',
881
- });
882
- return result.status === 0 ? result.stdout.trim() || null : null;
1265
+ // Issue #489: Resolve the word taskpane static assets directory.
1266
+ // Returns null (not throws) when the directory does not exist so the Hub
1267
+ // can start without the Word add-in assets present (e.g., older installs).
1268
+ function resolveWordTaskpaneDir(projectPath) {
1269
+ const base = projectPath || process.cwd();
1270
+ const candidates = [
1271
+ path_1.default.resolve(base, 'extensions/office-word'),
1272
+ path_1.default.resolve(__dirname, '..', '..', 'extensions/office-word'),
1273
+ path_1.default.resolve(__dirname, '..', '..', '..', 'extensions/office-word'),
1274
+ ];
1275
+ for (const candidate of candidates) {
1276
+ if (fs_1.default.existsSync(candidate)) {
1277
+ return candidate;
1278
+ }
883
1279
  }
884
- if (process.platform === 'darwin') {
885
- const result = (0, child_process_1.spawnSync)('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")'], {
886
- encoding: 'utf8',
887
- });
888
- return result.status === 0 ? result.stdout.trim() || null : null;
1280
+ // Word add-in assets not found — /word-taskpane/* routes will not be registered.
1281
+ return null;
1282
+ }
1283
+ // Issue #478: resolve the directory that contains a named Office task pane.
1284
+ // Example: resolveTaskpaneDir('powerpoint-taskpane') <repo>/public/ai-hub/powerpoint-taskpane
1285
+ function resolveTaskpaneDir(pane) {
1286
+ const aiHubDir = resolveAiHubPublicDir();
1287
+ const taskpaneDir = path_1.default.join(aiHubDir, pane);
1288
+ if (!fs_1.default.existsSync(taskpaneDir)) {
1289
+ throw new Error(`Task pane directory not found: ${taskpaneDir}`);
889
1290
  }
890
- const result = (0, child_process_1.spawnSync)('bash', ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null'], {
891
- encoding: 'utf8',
1291
+ return taskpaneDir;
1292
+ }
1293
+ function pickProjectPath() {
1294
+ return new Promise((resolve) => {
1295
+ let cmd;
1296
+ let args;
1297
+ if (process.platform === 'win32') {
1298
+ const script = [
1299
+ 'Add-Type -AssemblyName System.Windows.Forms',
1300
+ '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
1301
+ '$dialog.ShowNewFolderButton = $false',
1302
+ 'if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {',
1303
+ ' Write-Output $dialog.SelectedPath',
1304
+ '}',
1305
+ ].join('; ');
1306
+ cmd = 'powershell';
1307
+ args = ['-NoProfile', '-Command', script];
1308
+ }
1309
+ else if (process.platform === 'darwin') {
1310
+ cmd = 'osascript';
1311
+ args = ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")'];
1312
+ }
1313
+ else {
1314
+ cmd = 'bash';
1315
+ args = ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null'];
1316
+ }
1317
+ let stdout = '';
1318
+ const child = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
1319
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
1320
+ child.on('close', (code) => {
1321
+ resolve(code === 0 ? stdout.trim() || null : null);
1322
+ });
1323
+ child.on('error', () => resolve(null));
892
1324
  });
893
- return result.status === 0 ? result.stdout.trim() || null : null;
894
1325
  }