fraim 2.0.159 → 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 (30) 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/manager-turns.js +38 -0
  5. package/dist/src/ai-hub/office-sideload.js +138 -0
  6. package/dist/src/ai-hub/openclaw-bridge.js +239 -0
  7. package/dist/src/ai-hub/server.js +346 -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/types.js +0 -1
  23. package/dist/src/first-run/session-service.js +3 -3
  24. package/package.json +2 -1
  25. package/public/ai-hub/index.html +20 -2
  26. package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
  27. package/public/ai-hub/powerpoint-taskpane/index.html +235 -0
  28. package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
  29. package/public/ai-hub/script.js +337 -120
  30. package/public/ai-hub/styles.css +456 -135
@@ -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,11 +427,13 @@ class AiHubServer {
421
427
  this.ownsDbService = this.dbService !== undefined;
422
428
  }
423
429
  this.app.use(express_1.default.json());
424
- // CORS for browser extension and Office add-in task panes calling localhost Hub
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.).
425
432
  this.app.use((_req, res, next) => {
426
433
  res.setHeader('Access-Control-Allow-Origin', '*');
427
434
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
428
435
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
436
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
429
437
  if (_req.method === 'OPTIONS') {
430
438
  res.sendStatus(204);
431
439
  return;
@@ -433,6 +441,53 @@ class AiHubServer {
433
441
  next();
434
442
  });
435
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
+ }
436
491
  this.app.get('/health', (_req, res) => {
437
492
  res.json({ status: 'ok', service: 'fraim-ai-hub' });
438
493
  });
@@ -440,72 +495,6 @@ class AiHubServer {
440
495
  this.app.get('/api/health', (_req, res) => {
441
496
  res.json({ status: 'ok', service: 'fraim-ai-hub' });
442
497
  });
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
498
  this.registerRoutes();
510
499
  }
511
500
  getApp() {
@@ -526,18 +515,31 @@ class AiHubServer {
526
515
  this.httpServer.once('listening', () => resolve());
527
516
  this.httpServer.once('error', (error) => reject(error));
528
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
+ }
529
529
  }
530
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
+ }
531
541
  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
- });
542
+ await closeServer(this.httpServer);
541
543
  this.httpServer = undefined;
542
544
  }
543
545
  if (this.ownsDbService && this.dbService) {
@@ -545,6 +547,7 @@ class AiHubServer {
545
547
  this.dbService = undefined;
546
548
  }
547
549
  }
550
+ getHttpsPort() { return this.httpsPort; }
548
551
  async bootstrapResponse(projectPath, apiKey) {
549
552
  const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
550
553
  const employees = this.hostRuntime.detectEmployees();
@@ -585,6 +588,45 @@ class AiHubServer {
585
588
  activeRun,
586
589
  };
587
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
+ }
588
630
  async computePersonas(apiKey) {
589
631
  const allBundles = listHubPersonaBundles();
590
632
  const fallbackPersonas = allBundles.map((bundle) => ({
@@ -622,13 +664,60 @@ class AiHubServer {
622
664
  }
623
665
  }
624
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
+ });
625
702
  this.app.get('/api/ai-hub/bootstrap', async (req, res) => {
626
- 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
627
704
  ? req.query.projectPath
628
- : 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
+ }
629
718
  // Read API key from header — query-param API keys are prohibited (§3.14)
630
719
  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));
720
+ res.json(await this.bootstrapResponse(projectPath || this.projectPath, apiKey));
632
721
  });
633
722
  this.app.post('/api/ai-hub/api-key', (req, res) => {
634
723
  const { apiKey } = req.body;
@@ -644,9 +733,9 @@ class AiHubServer {
644
733
  this.preferencesStore.save({ ...prefs, personaKey: personaKey ?? null });
645
734
  return res.json({ ok: true });
646
735
  });
647
- this.app.post('/api/ai-hub/project-path/pick', (_req, res) => {
736
+ this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
648
737
  try {
649
- const projectPath = pickProjectPath();
738
+ const projectPath = await this.folderPicker();
650
739
  if (!projectPath) {
651
740
  return res.status(204).end();
652
741
  }
@@ -727,22 +816,30 @@ class AiHubServer {
727
816
  try {
728
817
  const projectPath = ensureDirectoryPath(req.body.projectPath || this.projectPath);
729
818
  const hostId = req.body.hostId;
730
- const jobId = req.body.jobId;
731
- const message = (req.body.message || '').trim();
819
+ const instructions = (req.body.instructions || '').trim();
820
+ const legacyMessage = (req.body.message || '').trim();
732
821
  const compareMode = req.body.compareMode;
733
822
  if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
734
823
  throw new Error('Choose an available employee before starting a job.');
735
824
  }
736
- if (!jobId) {
737
- throw new Error('Choose a FRAIM job before starting a run.');
738
- }
739
- if (!message) {
825
+ if (!instructions && !legacyMessage) {
740
826
  throw new Error('Coach your employee before starting the run.');
741
827
  }
742
828
  const employee = this.hostRuntime.detectEmployees().find((entry) => entry.id === hostId);
743
829
  if (!employee?.available) {
744
830
  throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
745
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
+ }
746
843
  const startTimestamp = new Date().toISOString();
747
844
  const run = {
748
845
  id: (0, crypto_1.randomUUID)(),
@@ -772,7 +869,7 @@ class AiHubServer {
772
869
  // so we can cross-link both runs via compareRunId before any events arrive.
773
870
  // directMsg is the plain user instructions — no FRAIM invocation prefix.
774
871
  const directMsg = compareMode === 'ab'
775
- ? ((req.body.directInstructions || '').trim() || message)
872
+ ? ((req.body.directInstructions || '').trim() || instructions || message)
776
873
  : message;
777
874
  let directRun;
778
875
  if (compareMode === 'ab') {
@@ -893,7 +990,16 @@ class AiHubServer {
893
990
  if (!run.sessionId) {
894
991
  return res.status(409).json({ error: 'This run does not have a resumable host session yet.' });
895
992
  }
896
- 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();
897
1003
  if (!message) {
898
1004
  return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
899
1005
  }
@@ -991,6 +1097,96 @@ class AiHubServer {
991
1097
  }
992
1098
  return res.json(this.enrichRunForResponse(run));
993
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
+ });
994
1190
  }
995
1191
  // Issue #347 — assemble the read-side projection of a run. Stages are
996
1192
  // derived from job frontmatter + visited phases; totalDurationMs ticks
@@ -1066,29 +1262,64 @@ function resolveAiHubPublicDir() {
1066
1262
  }
1067
1263
  throw new Error('Could not locate public/ai-hub assets.');
1068
1264
  }
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;
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
+ }
1083
1279
  }
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;
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}`);
1089
1290
  }
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',
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));
1092
1324
  });
1093
- return result.status === 0 ? result.stdout.trim() || null : null;
1094
1325
  }