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.
- package/README.md +1 -1
- package/dist/src/ai-hub/cert-store.js +70 -0
- package/dist/src/ai-hub/desktop-main.js +224 -50
- package/dist/src/ai-hub/manager-turns.js +38 -0
- package/dist/src/ai-hub/office-sideload.js +156 -0
- package/dist/src/ai-hub/openclaw-bridge.js +239 -0
- package/dist/src/ai-hub/server.js +362 -115
- 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/login.js +1 -2
- package/dist/src/cli/commands/setup.js +0 -2
- package/dist/src/cli/commands/sync.js +19 -10
- 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/utils/fraim-gitignore.js +11 -0
- package/dist/src/cli/utils/remote-sync.js +1 -1
- package/dist/src/core/config-loader.js +1 -2
- package/dist/src/core/fraim-config-schema.generated.js +0 -5
- package/dist/src/core/quality-evidence.js +4 -1
- package/dist/src/core/types.js +0 -1
- package/dist/src/first-run/session-service.js +3 -3
- package/dist/src/local-mcp-server/stdio-server.js +28 -9
- package/package.json +2 -1
- package/public/ai-hub/index.html +20 -2
- package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
- package/public/ai-hub/powerpoint-taskpane/index.html +236 -0
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
- package/public/ai-hub/script.js +337 -120
- 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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
712
|
+
let projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
627
713
|
? req.query.projectPath
|
|
628
|
-
:
|
|
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 =
|
|
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
|
|
731
|
-
const
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
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
|
}
|