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.
- 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/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 +346 -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/types.js +0 -1
- package/dist/src/first-run/session-service.js +3 -3
- 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 +235 -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
|
@@ -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,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
|
|
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
|
|
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
|
-
|
|
703
|
+
let projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
627
704
|
? req.query.projectPath
|
|
628
|
-
:
|
|
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 =
|
|
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
|
|
731
|
-
const
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
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
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
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
|
}
|