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.
- package/README.md +1 -1
- package/dist/src/ai-hub/cert-store.js +70 -0
- package/dist/src/ai-hub/desktop-main.js +225 -50
- package/dist/src/ai-hub/hosts.js +135 -8
- package/dist/src/ai-hub/manager-turns.js +38 -0
- package/dist/src/ai-hub/office-sideload.js +138 -0
- package/dist/src/ai-hub/openclaw-bridge.js +239 -0
- package/dist/src/ai-hub/server.js +479 -48
- package/dist/src/ai-hub/word-sideload.js +95 -0
- package/dist/src/cli/commands/add-ide.js +9 -0
- package/dist/src/cli/commands/init-project.js +46 -34
- package/dist/src/cli/commands/login.js +1 -2
- package/dist/src/cli/commands/setup.js +0 -2
- package/dist/src/cli/commands/sync.js +41 -11
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +66 -2
- package/dist/src/cli/doctor/checks/workflow-checks.js +1 -65
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +136 -0
- package/dist/src/cli/mcp/mcp-server-registry.js +14 -10
- package/dist/src/cli/setup/auto-mcp-setup.js +1 -1
- package/dist/src/cli/setup/ide-invocation-surfaces.js +2 -2
- package/dist/src/cli/utils/fraim-gitignore.js +11 -0
- package/dist/src/cli/utils/github-workflow-sync.js +231 -0
- package/dist/src/cli/utils/managed-agent-paths.js +1 -1
- package/dist/src/cli/utils/project-bootstrap.js +6 -3
- package/dist/src/cli/utils/remote-sync.js +1 -1
- package/dist/src/core/ai-mentor.js +46 -37
- package/dist/src/core/config-loader.js +69 -2
- package/dist/src/core/fraim-config-schema.generated.js +267 -6
- package/dist/src/core/types.js +0 -1
- package/dist/src/core/utils/fraim-labels.js +182 -0
- package/dist/src/core/utils/git-utils.js +22 -1
- package/dist/src/core/utils/project-fraim-paths.js +58 -0
- package/dist/src/first-run/session-service.js +3 -3
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/learning-context-builder.js +77 -52
- package/dist/src/local-mcp-server/stdio-server.js +212 -13
- package/package.json +6 -2
- package/public/ai-hub/index.html +289 -229
- package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
- package/public/ai-hub/powerpoint-taskpane/index.html +235 -0
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
- package/public/ai-hub/script.js +1155 -586
- package/public/ai-hub/styles.css +1226 -722
- package/public/first-run/index.html +35 -35
- 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 (
|
|
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
|
|
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
|
-
|
|
703
|
+
let projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
546
704
|
? req.query.projectPath
|
|
547
|
-
:
|
|
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 =
|
|
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
|
|
650
|
-
const
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
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
|
-
|
|
891
|
-
|
|
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
|
}
|