fraim 2.0.159 → 2.0.161

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 (32) 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 +224 -50
  4. package/dist/src/ai-hub/manager-turns.js +38 -0
  5. package/dist/src/ai-hub/office-sideload.js +156 -0
  6. package/dist/src/ai-hub/openclaw-bridge.js +239 -0
  7. package/dist/src/ai-hub/server.js +362 -115
  8. package/dist/src/ai-hub/word-sideload.js +95 -0
  9. package/dist/src/cli/commands/add-ide.js +9 -0
  10. package/dist/src/cli/commands/login.js +1 -2
  11. package/dist/src/cli/commands/setup.js +0 -2
  12. package/dist/src/cli/commands/sync.js +19 -10
  13. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +66 -2
  14. package/dist/src/cli/doctor/checks/workflow-checks.js +1 -65
  15. package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +136 -0
  16. package/dist/src/cli/mcp/mcp-server-registry.js +14 -10
  17. package/dist/src/cli/setup/auto-mcp-setup.js +1 -1
  18. package/dist/src/cli/utils/fraim-gitignore.js +11 -0
  19. package/dist/src/cli/utils/remote-sync.js +1 -1
  20. package/dist/src/core/config-loader.js +1 -2
  21. package/dist/src/core/fraim-config-schema.generated.js +0 -5
  22. package/dist/src/core/quality-evidence.js +4 -1
  23. package/dist/src/core/types.js +0 -1
  24. package/dist/src/first-run/session-service.js +3 -3
  25. package/dist/src/local-mcp-server/stdio-server.js +28 -9
  26. package/package.json +2 -1
  27. package/public/ai-hub/index.html +20 -2
  28. package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
  29. package/public/ai-hub/powerpoint-taskpane/index.html +236 -0
  30. package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
  31. package/public/ai-hub/script.js +337 -120
  32. package/public/ai-hub/styles.css +456 -135
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AiHubServer = void 0;
7
7
  exports.findAvailablePort = findAvailablePort;
8
+ exports.findAvailablePortExcluding = findAvailablePortExcluding;
8
9
  const express_1 = __importDefault(require("express"));
9
10
  const path_1 = __importDefault(require("path"));
10
11
  const fs_1 = __importDefault(require("fs"));
@@ -12,6 +13,7 @@ const net_1 = __importDefault(require("net"));
12
13
  const os_1 = __importDefault(require("os"));
13
14
  const crypto_1 = require("crypto");
14
15
  const child_process_1 = require("child_process");
16
+ const https_1 = __importDefault(require("https"));
15
17
  const types_1 = require("../first-run/types");
16
18
  const PERSONA_AVATAR_SEEDS = {
17
19
  maestro: { seed: 'MAESTRO-founder-mode', bg: 'fde68a' },
@@ -40,6 +42,7 @@ function buildPersonaAvatarUrl(personaKey) {
40
42
  const catalog_1 = require("./catalog");
41
43
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
42
44
  const hosts_1 = require("./hosts");
45
+ const manager_turns_1 = require("./manager-turns");
43
46
  const preferences_1 = require("./preferences");
44
47
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
45
48
  function loadPersonaCapabilityModule() {
@@ -282,12 +285,12 @@ function deriveStages(run, projectPath) {
282
285
  const completedWithoutPhaseTelemetry = run.status === 'completed' &&
283
286
  currentIndex < 0 &&
284
287
  historyMap.size === 0;
288
+ if (completedWithoutPhaseTelemetry) {
289
+ return [];
290
+ }
285
291
  return declaredPath.map((phase, index) => {
286
292
  let state;
287
- if (completedWithoutPhaseTelemetry) {
288
- state = 'done';
289
- }
290
- else if (currentIndex < 0) {
293
+ if (currentIndex < 0) {
291
294
  state = 'upcoming';
292
295
  }
293
296
  else if (index < currentIndex) {
@@ -411,6 +414,10 @@ class AiHubServer {
411
414
  this.runRegistry = new AiHubRunRegistry();
412
415
  this.projectPath = options.projectPath || process.cwd();
413
416
  this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
417
+ this.wordTaskpaneDir = options.wordTaskpaneDir ?? resolveWordTaskpaneDir(this.projectPath);
418
+ this.folderPicker = options.folderPicker ?? pickProjectPath;
419
+ this.httpsPort = options.httpsPort;
420
+ this.certBundle = options.certBundle;
414
421
  this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
415
422
  if (options.dbService !== undefined) {
416
423
  this.dbService = options.dbService;
@@ -421,11 +428,13 @@ class AiHubServer {
421
428
  this.ownsDbService = this.dbService !== undefined;
422
429
  }
423
430
  this.app.use(express_1.default.json());
424
- // CORS for browser extension and Office add-in task panes calling localhost Hub
431
+ // CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
432
+ // calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
425
433
  this.app.use((_req, res, next) => {
426
434
  res.setHeader('Access-Control-Allow-Origin', '*');
427
435
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
428
436
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
437
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
429
438
  if (_req.method === 'OPTIONS') {
430
439
  res.sendStatus(204);
431
440
  return;
@@ -433,6 +442,60 @@ class AiHubServer {
433
442
  next();
434
443
  });
435
444
  this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
445
+ // Issue #489: Serve the Word task pane assets at /word-taskpane/*.
446
+ // Office JS appends ?_host_Info=Word$Win32$... to every request — we must
447
+ // strip the query string before resolving the file path, otherwise every
448
+ // request returns 404. express.static does NOT strip query strings, so we
449
+ // use a custom middleware that resolves the pathname manually.
450
+ if (this.wordTaskpaneDir) {
451
+ const wordDir = this.wordTaskpaneDir; // capture for closure
452
+ this.app.use('/word-taskpane', (req, res, next) => {
453
+ // Chrome Private Network Access: public origin (word-edit.officeapps.live.com)
454
+ // loading a private-network resource (localhost) requires this header on both the
455
+ // preflight OPTIONS response and the final GET response.
456
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
457
+ res.setHeader('Access-Control-Allow-Origin', '*');
458
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
459
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
460
+ if (req.method === 'OPTIONS') {
461
+ res.sendStatus(204);
462
+ return;
463
+ }
464
+ // Strip query string — Office appends ?_host_Info=Word$Win32$... to every request
465
+ const { pathname } = new URL(req.url, 'http://localhost');
466
+ const target = pathname === '/' || pathname === '' ? '/taskpane.html' : pathname;
467
+ // Prevent path traversal
468
+ const safeTarget = path_1.default.normalize(target).replace(/^(\.\.(\/|\\|$))+/, '');
469
+ const filePath = path_1.default.join(wordDir, safeTarget);
470
+ if (!filePath.startsWith(wordDir + path_1.default.sep) && filePath !== wordDir) {
471
+ res.status(403).end();
472
+ return;
473
+ }
474
+ const ext = path_1.default.extname(filePath);
475
+ const contentTypes = {
476
+ '.html': 'text/html; charset=utf-8',
477
+ '.css': 'text/css; charset=utf-8',
478
+ '.js': 'application/javascript; charset=utf-8',
479
+ '.xml': 'application/xml; charset=utf-8',
480
+ };
481
+ const contentType = contentTypes[ext] || 'text/plain; charset=utf-8';
482
+ fs_1.default.readFile(filePath, (err, data) => {
483
+ if (err) {
484
+ next(); // fall through to 404
485
+ return;
486
+ }
487
+ res.setHeader('Content-Type', contentType);
488
+ res.end(data);
489
+ });
490
+ });
491
+ }
492
+ this.app.get(['/word-taskpane/config.js', '/powerpoint-taskpane/config.js'], (_req, res) => {
493
+ const port = this.httpPort || 43091;
494
+ const origin = `http://127.0.0.1:${port}`;
495
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
496
+ res.setHeader('Access-Control-Allow-Origin', '*');
497
+ res.end(`window.FRAIM_HUB_ORIGIN=${JSON.stringify(origin)};\n`);
498
+ });
436
499
  this.app.get('/health', (_req, res) => {
437
500
  res.json({ status: 'ok', service: 'fraim-ai-hub' });
438
501
  });
@@ -440,78 +503,13 @@ class AiHubServer {
440
503
  this.app.get('/api/health', (_req, res) => {
441
504
  res.json({ status: 'ok', service: 'fraim-ai-hub' });
442
505
  });
443
- // Trigger endpoint for external surfaces (browser extension, Office add-ins, tray).
444
- // Starts a real Hub run and returns the runId for polling via GET /api/ai-hub/runs/:runId.
445
- this.app.post('/api/trigger', (req, res) => {
446
- const { employeeId, jobName, context, projectPath } = req.body;
447
- if (!employeeId || !jobName) {
448
- res.status(400).json({ error: 'employeeId and jobName are required' });
449
- return;
450
- }
451
- const contextSummary = [
452
- context?.sourceApp ? `Source: ${context.sourceApp}` : '',
453
- context?.fileName ? `File: ${context.fileName}` : '',
454
- context?.text ? `Context:\n${context.text.slice(0, 500)}` : '',
455
- ].filter(Boolean).join('\n');
456
- const message = `/fraim ${jobName}\n\n${contextSummary}`.trim();
457
- const hostId = 'claude';
458
- const resolvedPath = projectPath || this.projectPath;
459
- try {
460
- const employee = this.hostRuntime.detectEmployees().find((e) => e.id === hostId);
461
- if (!employee?.available) {
462
- res.status(503).json({ error: `${hostId} is not available on this machine` });
463
- return;
464
- }
465
- const now = new Date().toISOString();
466
- const run = {
467
- id: (0, crypto_1.randomUUID)(),
468
- jobId: jobName,
469
- hostId,
470
- projectPath: resolvedPath,
471
- status: 'running',
472
- createdAt: now,
473
- updatedAt: now,
474
- messages: [(0, hosts_1.createHubMessage)('manager', message)],
475
- events: [(0, hosts_1.createHubEvent)('system', `Starting ${hostId} via ${context?.sourceApp || 'external-surface'} in ${resolvedPath}`)],
476
- currentPhase: null,
477
- phaseHistory: [],
478
- totals: emptyTotals(),
479
- lastStatusChangeAt: now,
480
- personaKey: getProtectedPersonaForHubJob(jobName),
481
- };
482
- const child = this.hostRuntime.startRun(hostId, resolvedPath, message, {
483
- onEvent: (event, channel) => {
484
- this.runRegistry.update(run.id, (current) => {
485
- if (event.sessionId)
486
- current.sessionId = event.sessionId;
487
- if (event.message)
488
- current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
489
- if (event.raw)
490
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
491
- });
492
- },
493
- onExit: (exitCode) => {
494
- this.runRegistry.update(run.id, (current) => {
495
- current.exitCode = exitCode;
496
- current.status = exitCode === 0 ? 'completed' : 'failed';
497
- current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
498
- });
499
- this.runRegistry.dispose(run.id);
500
- },
501
- });
502
- this.runRegistry.create(run, child);
503
- res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
504
- }
505
- catch (err) {
506
- res.status(500).json({ error: err.message });
507
- }
508
- });
509
506
  this.registerRoutes();
510
507
  }
511
508
  getApp() {
512
509
  return this.app;
513
510
  }
514
511
  async start(port) {
512
+ this.httpPort = port;
515
513
  if (this.dbService) {
516
514
  try {
517
515
  await this.dbService.connect();
@@ -526,18 +524,31 @@ class AiHubServer {
526
524
  this.httpServer.once('listening', () => resolve());
527
525
  this.httpServer.once('error', (error) => reject(error));
528
526
  });
527
+ // Start HTTPS server when a cert bundle and port are provided.
528
+ // Word Online requires HTTPS; the HTTPS server shares the same Express app
529
+ // so all routes (including /word-taskpane/*) are available over both protocols.
530
+ if (this.httpsPort && this.certBundle) {
531
+ await new Promise((resolve, reject) => {
532
+ this.httpsServer = https_1.default.createServer({ key: this.certBundle.key, cert: this.certBundle.cert }, this.app);
533
+ this.httpsServer.listen(this.httpsPort, '127.0.0.1');
534
+ this.httpsServer.once('listening', () => resolve());
535
+ this.httpsServer.once('error', (error) => reject(error));
536
+ });
537
+ }
529
538
  }
530
539
  async stop() {
540
+ const closeServer = (srv) => new Promise((resolve, reject) => {
541
+ srv.close((error) => { if (error)
542
+ reject(error);
543
+ else
544
+ resolve(); });
545
+ });
546
+ if (this.httpsServer) {
547
+ await closeServer(this.httpsServer);
548
+ this.httpsServer = undefined;
549
+ }
531
550
  if (this.httpServer) {
532
- await new Promise((resolve, reject) => {
533
- this.httpServer.close((error) => {
534
- if (error) {
535
- reject(error);
536
- return;
537
- }
538
- resolve();
539
- });
540
- });
551
+ await closeServer(this.httpServer);
541
552
  this.httpServer = undefined;
542
553
  }
543
554
  if (this.ownsDbService && this.dbService) {
@@ -545,6 +556,7 @@ class AiHubServer {
545
556
  this.dbService = undefined;
546
557
  }
547
558
  }
559
+ getHttpsPort() { return this.httpsPort; }
548
560
  async bootstrapResponse(projectPath, apiKey) {
549
561
  const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
550
562
  const employees = this.hostRuntime.detectEmployees();
@@ -585,6 +597,45 @@ class AiHubServer {
585
597
  activeRun,
586
598
  };
587
599
  }
600
+ resolveHubJob(projectPath, jobId) {
601
+ const employeeJob = (0, catalog_1.discoverEmployeeJobs)(projectPath).find((job) => job.id === jobId);
602
+ if (employeeJob)
603
+ return { id: employeeJob.id, stubPath: employeeJob.stubPath };
604
+ const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath).find((job) => job.id === jobId);
605
+ if (managerTemplate)
606
+ return { id: managerTemplate.id, stubPath: managerTemplate.stubPath };
607
+ return null;
608
+ }
609
+ prepareStartPayload(projectPath, hostId, selectedJobId, instructions) {
610
+ const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
611
+ const resolvedJobId = explicit?.jobId || selectedJobId;
612
+ if (!resolvedJobId) {
613
+ throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
614
+ }
615
+ if (resolvedJobId === '__freeform__') {
616
+ return {
617
+ jobId: resolvedJobId,
618
+ message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions),
619
+ };
620
+ }
621
+ const resolvedJob = this.resolveHubJob(projectPath, resolvedJobId);
622
+ const absoluteStubPath = resolvedJob?.stubPath
623
+ ? [projectPath, resolvedJob.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
624
+ : undefined;
625
+ return {
626
+ jobId: resolvedJobId,
627
+ message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath),
628
+ };
629
+ }
630
+ prepareContinueMessage(run, instructions, coachingJobId) {
631
+ // coachingJobId is set when the user selected a manager coaching template
632
+ // (e.g. follow-your-mentor). When present, it overrides the run's own jobId
633
+ // as the target of the FRAIM invocation. The server picks the correct
634
+ // invocation prefix ($fraim / /fraim) based on run.hostId — the UI never
635
+ // passes raw invocation syntax.
636
+ const effectiveJobId = coachingJobId || run.jobId;
637
+ return (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions);
638
+ }
588
639
  async computePersonas(apiKey) {
589
640
  const allBundles = listHubPersonaBundles();
590
641
  const fallbackPersonas = allBundles.map((bundle) => ({
@@ -622,13 +673,60 @@ class AiHubServer {
622
673
  }
623
674
  }
624
675
  registerRoutes() {
676
+ // Issue #478: Serve the PowerPoint task pane HTML and manifest.
677
+ // Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
678
+ // request, so we must strip them before resolving the file path. Use a custom
679
+ // route rather than express.static so we can apply the new URL().pathname fix.
680
+ this.app.get(/^\/powerpoint-taskpane(\/.*)?$/, (req, res) => {
681
+ const taskpaneDir = resolveTaskpaneDir('powerpoint-taskpane');
682
+ // Strip query string — Office appends ?_host_Info=PowerPoint$... to every fetch.
683
+ const { pathname } = new URL(req.url, 'http://localhost');
684
+ // Map root /powerpoint-taskpane/ to index.html
685
+ const relativePath = pathname.replace(/^\/powerpoint-taskpane\/?/, '') || 'index.html';
686
+ const filePath = path_1.default.join(taskpaneDir, relativePath);
687
+ if (!filePath.startsWith(taskpaneDir)) {
688
+ // Path traversal guard
689
+ res.status(403).end();
690
+ return;
691
+ }
692
+ const ext = path_1.default.extname(filePath).toLowerCase();
693
+ const contentTypeMap = {
694
+ '.html': 'text/html; charset=utf-8',
695
+ '.xml': 'application/xml',
696
+ '.js': 'application/javascript',
697
+ '.css': 'text/css',
698
+ '.png': 'image/png',
699
+ };
700
+ const contentType = contentTypeMap[ext] || 'text/plain';
701
+ fs_1.default.readFile(filePath, (err, data) => {
702
+ if (err) {
703
+ res.status(404).end('Not found');
704
+ return;
705
+ }
706
+ res.setHeader('Content-Type', contentType);
707
+ res.setHeader('Access-Control-Allow-Origin', '*');
708
+ res.end(data);
709
+ });
710
+ });
625
711
  this.app.get('/api/ai-hub/bootstrap', async (req, res) => {
626
- const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
712
+ let projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
627
713
  ? req.query.projectPath
628
- : this.projectPath;
714
+ : null;
715
+ // If no explicit projectPath but a file:// docUrl was passed (Word Desktop),
716
+ // derive the project directory from the document's local path.
717
+ if (!projectPath && typeof req.query.docUrl === 'string' && req.query.docUrl.startsWith('file://')) {
718
+ try {
719
+ const fileUrl = new URL(req.query.docUrl);
720
+ const rawPath = process.platform === 'win32'
721
+ ? fileUrl.pathname.replace(/^\/([A-Za-z]:)/, '$1')
722
+ : fileUrl.pathname;
723
+ projectPath = path_1.default.dirname(decodeURIComponent(rawPath));
724
+ }
725
+ catch { }
726
+ }
629
727
  // Read API key from header — query-param API keys are prohibited (§3.14)
630
728
  const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
631
- res.json(await this.bootstrapResponse(projectPath, apiKey));
729
+ res.json(await this.bootstrapResponse(projectPath || this.projectPath, apiKey));
632
730
  });
633
731
  this.app.post('/api/ai-hub/api-key', (req, res) => {
634
732
  const { apiKey } = req.body;
@@ -644,9 +742,9 @@ class AiHubServer {
644
742
  this.preferencesStore.save({ ...prefs, personaKey: personaKey ?? null });
645
743
  return res.json({ ok: true });
646
744
  });
647
- this.app.post('/api/ai-hub/project-path/pick', (_req, res) => {
745
+ this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
648
746
  try {
649
- const projectPath = pickProjectPath();
747
+ const projectPath = await this.folderPicker();
650
748
  if (!projectPath) {
651
749
  return res.status(204).end();
652
750
  }
@@ -727,22 +825,30 @@ class AiHubServer {
727
825
  try {
728
826
  const projectPath = ensureDirectoryPath(req.body.projectPath || this.projectPath);
729
827
  const hostId = req.body.hostId;
730
- const jobId = req.body.jobId;
731
- const message = (req.body.message || '').trim();
828
+ const instructions = (req.body.instructions || '').trim();
829
+ const legacyMessage = (req.body.message || '').trim();
732
830
  const compareMode = req.body.compareMode;
733
831
  if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
734
832
  throw new Error('Choose an available employee before starting a job.');
735
833
  }
736
- if (!jobId) {
737
- throw new Error('Choose a FRAIM job before starting a run.');
738
- }
739
- if (!message) {
834
+ if (!instructions && !legacyMessage) {
740
835
  throw new Error('Coach your employee before starting the run.');
741
836
  }
742
837
  const employee = this.hostRuntime.detectEmployees().find((entry) => entry.id === hostId);
743
838
  if (!employee?.available) {
744
839
  throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
745
840
  }
841
+ const prepared = instructions
842
+ ? this.prepareStartPayload(projectPath, hostId, req.body.jobId, instructions)
843
+ : {
844
+ jobId: req.body.jobId,
845
+ message: legacyMessage,
846
+ };
847
+ const jobId = prepared.jobId;
848
+ const message = prepared.message;
849
+ if (!jobId) {
850
+ throw new Error('Choose a FRAIM job before starting a run.');
851
+ }
746
852
  const startTimestamp = new Date().toISOString();
747
853
  const run = {
748
854
  id: (0, crypto_1.randomUUID)(),
@@ -772,7 +878,7 @@ class AiHubServer {
772
878
  // so we can cross-link both runs via compareRunId before any events arrive.
773
879
  // directMsg is the plain user instructions — no FRAIM invocation prefix.
774
880
  const directMsg = compareMode === 'ab'
775
- ? ((req.body.directInstructions || '').trim() || message)
881
+ ? ((req.body.directInstructions || '').trim() || instructions || message)
776
882
  : message;
777
883
  let directRun;
778
884
  if (compareMode === 'ab') {
@@ -893,7 +999,16 @@ class AiHubServer {
893
999
  if (!run.sessionId) {
894
1000
  return res.status(409).json({ error: 'This run does not have a resumable host session yet.' });
895
1001
  }
896
- const message = (req.body.message || '').trim();
1002
+ const instructions = (req.body.instructions || '').trim();
1003
+ const coachingJobId = req.body.coachingJobId?.trim() || undefined;
1004
+ // When coachingJobId is present (user picked a manager template via the UI),
1005
+ // it overrides the run's own jobId in the invocation. The server always adds
1006
+ // the correct $fraim / /fraim prefix — the UI never passes raw invocation syntax.
1007
+ const message = instructions
1008
+ ? this.prepareContinueMessage(run, instructions, coachingJobId)
1009
+ : coachingJobId
1010
+ ? this.prepareContinueMessage(run, '', coachingJobId)
1011
+ : (req.body.message || '').trim();
897
1012
  if (!message) {
898
1013
  return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
899
1014
  }
@@ -991,6 +1106,96 @@ class AiHubServer {
991
1106
  }
992
1107
  return res.json(this.enrichRunForResponse(run));
993
1108
  });
1109
+ // -------------------------------------------------------------------------
1110
+ // Issue #489: POST /api/trigger
1111
+ // Stable API endpoint for extension surfaces (Office add-ins, browser
1112
+ // extensions, VS Code extensions, Electron tray) to start FRAIM jobs.
1113
+ //
1114
+ // Body: { employeeId, jobName, context?: { text, sourceApp, fileName, ... }, projectPath? }
1115
+ // Response: { runId, status: "started", employee, job }
1116
+ // -------------------------------------------------------------------------
1117
+ this.app.post('/api/trigger', (req, res) => {
1118
+ try {
1119
+ const { employeeId, jobName, context, projectPath: reqProjectPath } = req.body;
1120
+ if (!employeeId) {
1121
+ return res.status(400).json({ error: 'employeeId is required.' });
1122
+ }
1123
+ if (!jobName) {
1124
+ return res.status(400).json({ error: 'jobName is required.' });
1125
+ }
1126
+ const projectPath = ensureDirectoryPath(reqProjectPath || this.projectPath);
1127
+ // Use the requested agent directly — caller specifies which agent to run (claude, codex, gemini).
1128
+ const employees = this.hostRuntime.detectEmployees();
1129
+ const hostId = employeeId;
1130
+ const employee = employees.find(e => e.id === hostId);
1131
+ if (!employee?.available) {
1132
+ return res.status(503).json({ error: `${hostId} is not available on this machine.` });
1133
+ }
1134
+ const contextText = context?.text?.trim() || '';
1135
+ const sourceInfo = [
1136
+ context?.sourceApp ? `sourceApp: ${context.sourceApp}` : '',
1137
+ context?.fileName ? `file: ${context.fileName}` : '',
1138
+ ].filter(Boolean).join(', ');
1139
+ const message = [
1140
+ `/fraim ${jobName}`,
1141
+ '',
1142
+ sourceInfo ? `Context (${sourceInfo}):` : 'Context:',
1143
+ contextText || '(no context provided)',
1144
+ ].join('\n');
1145
+ const startTimestamp = new Date().toISOString();
1146
+ const run = {
1147
+ id: (0, crypto_1.randomUUID)(),
1148
+ jobId: jobName,
1149
+ hostId,
1150
+ projectPath,
1151
+ status: 'running',
1152
+ createdAt: startTimestamp,
1153
+ updatedAt: startTimestamp,
1154
+ messages: [(0, hosts_1.createHubMessage)('manager', message)],
1155
+ events: [(0, hosts_1.createHubEvent)('system', `Trigger: ${employeeId} / ${jobName} from ${context?.sourceApp || 'unknown'} in ${projectPath}`)],
1156
+ currentPhase: null,
1157
+ phaseHistory: [],
1158
+ totals: emptyTotals(),
1159
+ lastStatusChangeAt: startTimestamp,
1160
+ personaKey: getProtectedPersonaForHubJob(jobName),
1161
+ };
1162
+ // Register the run before spawning so onEvent/onExit callbacks can
1163
+ // safely call update() even if they fire synchronously (FakeHostRuntime).
1164
+ this.runRegistry.create(run, {});
1165
+ const child = this.hostRuntime.startRun(hostId, projectPath, message, {
1166
+ onEvent: (event, channel) => {
1167
+ this.runRegistry.update(run.id, (current) => {
1168
+ if (event.sessionId)
1169
+ current.sessionId = event.sessionId;
1170
+ if (event.message)
1171
+ current.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
1172
+ if (event.raw)
1173
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
1174
+ if (event.agentIdentity)
1175
+ applyAgentIdentitySignal(current, event.agentIdentity);
1176
+ if (event.seekMentoring)
1177
+ applySeekMentoringSignal(current, event.seekMentoring);
1178
+ if (event.usage)
1179
+ applyUsageSignal(current, event.usage);
1180
+ });
1181
+ },
1182
+ onExit: (exitCode) => {
1183
+ this.runRegistry.update(run.id, (current) => {
1184
+ current.exitCode = exitCode;
1185
+ current.status = exitCode === 0 ? 'completed' : 'failed';
1186
+ current.events.push((0, hosts_1.createHubEvent)('system', `Trigger run exited with code ${exitCode ?? 'unknown'}.`));
1187
+ });
1188
+ this.runRegistry.dispose(run.id);
1189
+ },
1190
+ });
1191
+ // Update the registry entry with the real child process handle.
1192
+ this.runRegistry.create(run, child);
1193
+ return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
1194
+ }
1195
+ catch (error) {
1196
+ return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
1197
+ }
1198
+ });
994
1199
  }
995
1200
  // Issue #347 — assemble the read-side projection of a run. Stages are
996
1201
  // derived from job frontmatter + visited phases; totalDurationMs ticks
@@ -1037,8 +1242,15 @@ class AiHubServer {
1037
1242
  }
1038
1243
  exports.AiHubServer = AiHubServer;
1039
1244
  async function findAvailablePort(preferredPort) {
1245
+ return findAvailablePortExcluding(preferredPort, new Set());
1246
+ }
1247
+ async function findAvailablePortExcluding(preferredPort, excludedPorts) {
1040
1248
  let port = preferredPort;
1041
1249
  while (port < preferredPort + 20) {
1250
+ if (excludedPorts.has(port)) {
1251
+ port += 1;
1252
+ continue;
1253
+ }
1042
1254
  const available = await new Promise((resolve) => {
1043
1255
  const server = net_1.default.createServer();
1044
1256
  server.once('error', () => resolve(false));
@@ -1066,29 +1278,64 @@ function resolveAiHubPublicDir() {
1066
1278
  }
1067
1279
  throw new Error('Could not locate public/ai-hub assets.');
1068
1280
  }
1069
- function pickProjectPath() {
1070
- if (process.platform === 'win32') {
1071
- const script = [
1072
- 'Add-Type -AssemblyName System.Windows.Forms',
1073
- '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
1074
- '$dialog.ShowNewFolderButton = $false',
1075
- 'if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {',
1076
- ' Write-Output $dialog.SelectedPath',
1077
- '}',
1078
- ].join('; ');
1079
- const result = (0, child_process_1.spawnSync)('powershell', ['-NoProfile', '-Command', script], {
1080
- encoding: 'utf8',
1081
- });
1082
- return result.status === 0 ? result.stdout.trim() || null : null;
1281
+ // Issue #489: Resolve the word taskpane static assets directory.
1282
+ // Returns null (not throws) when the directory does not exist so the Hub
1283
+ // can start without the Word add-in assets present (e.g., older installs).
1284
+ function resolveWordTaskpaneDir(projectPath) {
1285
+ const base = projectPath || process.cwd();
1286
+ const candidates = [
1287
+ path_1.default.resolve(base, 'extensions/office-word'),
1288
+ path_1.default.resolve(__dirname, '..', '..', 'extensions/office-word'),
1289
+ path_1.default.resolve(__dirname, '..', '..', '..', 'extensions/office-word'),
1290
+ ];
1291
+ for (const candidate of candidates) {
1292
+ if (fs_1.default.existsSync(candidate)) {
1293
+ return candidate;
1294
+ }
1083
1295
  }
1084
- if (process.platform === 'darwin') {
1085
- const result = (0, child_process_1.spawnSync)('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")'], {
1086
- encoding: 'utf8',
1087
- });
1088
- return result.status === 0 ? result.stdout.trim() || null : null;
1296
+ // Word add-in assets not found — /word-taskpane/* routes will not be registered.
1297
+ return null;
1298
+ }
1299
+ // Issue #478: resolve the directory that contains a named Office task pane.
1300
+ // Example: resolveTaskpaneDir('powerpoint-taskpane') <repo>/public/ai-hub/powerpoint-taskpane
1301
+ function resolveTaskpaneDir(pane) {
1302
+ const aiHubDir = resolveAiHubPublicDir();
1303
+ const taskpaneDir = path_1.default.join(aiHubDir, pane);
1304
+ if (!fs_1.default.existsSync(taskpaneDir)) {
1305
+ throw new Error(`Task pane directory not found: ${taskpaneDir}`);
1089
1306
  }
1090
- const result = (0, child_process_1.spawnSync)('bash', ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null'], {
1091
- encoding: 'utf8',
1307
+ return taskpaneDir;
1308
+ }
1309
+ function pickProjectPath() {
1310
+ 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")'];
1328
+ }
1329
+ else {
1330
+ cmd = 'bash';
1331
+ args = ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null'];
1332
+ }
1333
+ 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));
1092
1340
  });
1093
- return result.status === 0 ? result.stdout.trim() || null : null;
1094
1341
  }