fraim 2.0.203 → 2.0.205

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +3 -3
  2. package/bin/fraim.js +8 -18
  3. package/dist/src/cli/commands/first-run.js +2 -37
  4. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +1 -1
  5. package/dist/src/cli/fraim.js +0 -2
  6. package/dist/src/cli/setup/ide-invocation-surfaces.js +6 -6
  7. package/dist/src/cli/utils/managed-agent-paths.js +76 -6
  8. package/dist/src/cli/utils/version-utils.js +2 -2
  9. package/dist/src/config/persona-capability-bundles.js +3 -3
  10. package/dist/src/core/job-phases.js +168 -0
  11. package/dist/src/core/utils/ports.js +32 -0
  12. package/dist/src/core/utils/stub-generator.js +24 -2
  13. package/dist/src/first-run/session-service.js +106 -24
  14. package/dist/src/local-mcp-server/learning-context-builder.js +1 -1
  15. package/dist/src/local-mcp-server/stdio-server.js +41 -9
  16. package/dist/src/routes/analytics.js +2 -2
  17. package/dist/src/services/registry-service.js +2 -2
  18. package/index.js +1 -1
  19. package/package.json +61 -181
  20. package/dist/src/ai-hub/catalog.js +0 -406
  21. package/dist/src/ai-hub/cert-store.js +0 -70
  22. package/dist/src/ai-hub/conversation-store.js +0 -359
  23. package/dist/src/ai-hub/desktop-main.js +0 -449
  24. package/dist/src/ai-hub/hosts.js +0 -1664
  25. package/dist/src/ai-hub/hub-latest-version.js +0 -52
  26. package/dist/src/ai-hub/hub-launch-decision.js +0 -57
  27. package/dist/src/ai-hub/hub-runtime-file.js +0 -43
  28. package/dist/src/ai-hub/managed-browser.js +0 -269
  29. package/dist/src/ai-hub/manager-turns.js +0 -64
  30. package/dist/src/ai-hub/office-sideload.js +0 -156
  31. package/dist/src/ai-hub/openclaw-bridge.js +0 -250
  32. package/dist/src/ai-hub/preferences.js +0 -201
  33. package/dist/src/ai-hub/remote-hub-gateway.js +0 -88
  34. package/dist/src/ai-hub/server.js +0 -3827
  35. package/dist/src/ai-hub/types.js +0 -2
  36. package/dist/src/ai-hub/word-sideload.js +0 -95
  37. package/dist/src/cli/commands/hub.js +0 -238
  38. package/dist/src/cli/commands/test-mcp.js +0 -171
  39. package/dist/src/cli/setup/first-run.js +0 -242
  40. package/dist/src/core/config-writer.js +0 -75
  41. package/dist/src/core/utils/job-aliases.js +0 -47
  42. package/dist/src/core/utils/workflow-parser.js +0 -174
  43. package/dist/src/services/email-service-clean.js +0 -782
  44. package/extensions/office-word/favicon.ico +0 -0
  45. package/extensions/office-word/icon-64.png +0 -0
  46. package/extensions/office-word/manifest.xml +0 -33
  47. package/extensions/office-word/taskpane.html +0 -242
  48. package/public/ai-hub/index.html +0 -894
  49. package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
  50. package/public/ai-hub/powerpoint-taskpane/index.html +0 -236
  51. package/public/ai-hub/powerpoint-taskpane/manifest.xml +0 -30
  52. package/public/ai-hub/review.css +0 -381
  53. package/public/ai-hub/script.js +0 -11015
  54. package/public/ai-hub/styles.css +0 -4873
  55. package/public/portfolio/ashley.html +0 -523
  56. package/public/portfolio/auditya.html +0 -83
  57. package/public/portfolio/banke.html +0 -83
  58. package/public/portfolio/beza.html +0 -659
  59. package/public/portfolio/careena.html +0 -632
  60. package/public/portfolio/casey.html +0 -568
  61. package/public/portfolio/celia.html +0 -490
  62. package/public/portfolio/deidre.html +0 -642
  63. package/public/portfolio/gautam.html +0 -597
  64. package/public/portfolio/hari.html +0 -469
  65. package/public/portfolio/huxley.html +0 -1354
  66. package/public/portfolio/index.html +0 -741
  67. package/public/portfolio/maestro.html +0 -518
  68. package/public/portfolio/mandy.html +0 -590
  69. package/public/portfolio/mona.html +0 -597
  70. package/public/portfolio/pam.html +0 -887
  71. package/public/portfolio/procella.html +0 -107
  72. package/public/portfolio/qasm.html +0 -569
  73. package/public/portfolio/ricardo.html +0 -489
  74. package/public/portfolio/sade.html +0 -560
  75. package/public/portfolio/sam.html +0 -654
  76. package/public/portfolio/sechar.html +0 -580
  77. package/public/portfolio/sreya.html +0 -599
  78. package/public/portfolio/swen.html +0 -601
  79. /package/dist/src/{ai-hub → core}/brand-store.js +0 -0
@@ -1,3827 +0,0 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
- var __importDefault = (this && this.__importDefault) || function (mod) {
36
- return (mod && mod.__esModule) ? mod : { "default": mod };
37
- };
38
- Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
- exports.configureFraimForHubAgent = configureFraimForHubAgent;
41
- exports.buildOpenFileInvocation = buildOpenFileInvocation;
42
- exports.findAvailablePort = findAvailablePort;
43
- exports.findAvailablePortExcluding = findAvailablePortExcluding;
44
- const express_1 = __importDefault(require("express"));
45
- const path_1 = __importDefault(require("path"));
46
- const fs_1 = __importDefault(require("fs"));
47
- const net_1 = __importDefault(require("net"));
48
- const os_1 = __importDefault(require("os"));
49
- const crypto_1 = require("crypto");
50
- const child_process_1 = require("child_process");
51
- const https_1 = __importDefault(require("https"));
52
- const types_1 = require("../first-run/types");
53
- const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
54
- const brand_store_1 = require("./brand-store");
55
- const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
56
- const catalog_1 = require("./catalog");
57
- const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
58
- const hosts_1 = require("./hosts");
59
- const manager_turns_1 = require("./manager-turns");
60
- const preferences_1 = require("./preferences");
61
- const conversation_store_1 = require("./conversation-store");
62
- const remote_hub_gateway_1 = require("./remote-hub-gateway");
63
- const managed_browser_1 = require("./managed-browser");
64
- const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
65
- const user_config_1 = require("../cli/utils/user-config");
66
- const version_utils_1 = require("../cli/utils/version-utils");
67
- const hub_latest_version_1 = require("./hub-latest-version");
68
- const semver = __importStar(require("semver"));
69
- let personaHiringModule;
70
- let managerHiringModule;
71
- function loadPersonaHiringModule() {
72
- const cached = personaHiringModule;
73
- if (cached !== undefined) {
74
- return cached;
75
- }
76
- try {
77
- // Server deployments include the persona catalog. The npm client package
78
- // intentionally does not, so importing ai-hub/server must not require it.
79
- // eslint-disable-next-line @typescript-eslint/no-var-requires
80
- const loaded = require('../config/persona-hiring');
81
- personaHiringModule = loaded;
82
- return loaded;
83
- }
84
- catch {
85
- personaHiringModule = null;
86
- return null;
87
- }
88
- }
89
- function loadManagerHiringModule() {
90
- const cached = managerHiringModule;
91
- if (cached !== undefined) {
92
- return cached;
93
- }
94
- try {
95
- // Server deployments include the manager-hiring catalog. The npm client
96
- // package intentionally does not, so Hub bootstrap must not require it.
97
- // eslint-disable-next-line @typescript-eslint/no-var-requires
98
- const loaded = require('../config/ai-manager-hiring');
99
- managerHiringModule = loaded;
100
- return loaded;
101
- }
102
- catch {
103
- managerHiringModule = null;
104
- return null;
105
- }
106
- }
107
- function loadPersonaCapabilityModule() {
108
- try {
109
- // Server deployments include the persona catalog. The npm client package
110
- // intentionally does not, so Hub setup must degrade without loading it.
111
- // eslint-disable-next-line @typescript-eslint/no-var-requires
112
- return require('../config/persona-capability-bundles');
113
- }
114
- catch {
115
- return null;
116
- }
117
- }
118
- function getProtectedPersonaForHubJob(jobName) {
119
- return loadPersonaCapabilityModule()?.getProtectedPersonaForJob(jobName) ?? null;
120
- }
121
- const FRAIM_INTERNAL_JOB_IDS = new Set([
122
- 'contribute-to-fraim',
123
- 'create-registry-asset',
124
- 'extract-ashley-learnings',
125
- 'file-fraim-issue',
126
- 'praise-fraim',
127
- 'run-on-remote-hub',
128
- 'setup-remote-hub',
129
- 'update-registry-override',
130
- ]);
131
- function listHubPersonaBundles() {
132
- return loadPersonaCapabilityModule()?.listPersonaCapabilityBundles() ?? [];
133
- }
134
- function buildHubPersonaHireUrl(personaKey, hireMode = 'job') {
135
- const params = new URLSearchParams({ persona: personaKey, mode: hireMode });
136
- return `/pricing?${params.toString()}`;
137
- }
138
- function buildLocalHubReturnUrl(req, surface) {
139
- const proto = req.protocol || 'http';
140
- const host = req.get('host') || '127.0.0.1';
141
- const target = new URL('/ai-hub/', `${proto}://${host}`);
142
- target.searchParams.set('connected', surface);
143
- return target.toString();
144
- }
145
- function buildHostedAuthUrl(surface, redirectTo, hubReturn) {
146
- const target = new URL('/auth/sign-in.html', (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
147
- target.searchParams.set('surface', surface);
148
- target.searchParams.set('redirect_to', redirectTo);
149
- if (hubReturn)
150
- target.searchParams.set('hub_return', hubReturn);
151
- return target.toString();
152
- }
153
- function buildHostedPathUrl(pathname, queryString) {
154
- const target = new URL(pathname, (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
155
- target.search = queryString;
156
- return target.toString();
157
- }
158
- function buildHubPersonaAvatarUrl(personaKey) {
159
- return loadPersonaHiringModule()?.buildPersonaAvatarUrl(personaKey) ?? '';
160
- }
161
- function buildHubManagerHiringCatalog() {
162
- return loadManagerHiringModule()?.buildManagerHiringCatalog() ?? {
163
- qualities: [],
164
- services: [],
165
- roles: {},
166
- };
167
- }
168
- class AiHubRunRegistry {
169
- constructor() {
170
- this.runs = new Map();
171
- this.children = new Map();
172
- }
173
- listLatest() {
174
- return [...this.runs.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
175
- }
176
- get(runId) {
177
- return this.runs.get(runId);
178
- }
179
- all() {
180
- return [...this.runs.values()];
181
- }
182
- create(run, child) {
183
- this.runs.set(run.id, run);
184
- this.children.set(run.id, child);
185
- return run;
186
- }
187
- attachChildIfRunning(runId, child) {
188
- const run = this.runs.get(runId);
189
- if (!run || run.status !== 'running')
190
- return false;
191
- this.children.set(runId, child);
192
- return true;
193
- }
194
- update(runId, updater) {
195
- const run = this.runs.get(runId);
196
- if (!run) {
197
- throw new Error(`Run ${runId} not found`);
198
- }
199
- const priorStatus = run.status;
200
- updater(run);
201
- const now = new Date().toISOString();
202
- // Issue #347: when status flipped, accumulate the previous status's
203
- // duration into the appropriate working/waiting bucket. lastStatusChangeAt
204
- // is the wall-clock timestamp of the most recent flip; on the first
205
- // call it falls back to createdAt.
206
- if (run.status !== priorStatus) {
207
- const lastAt = run.lastStatusChangeAt || run.createdAt;
208
- const elapsedMs = Date.parse(now) - Date.parse(lastAt);
209
- if (Number.isFinite(elapsedMs) && elapsedMs > 0) {
210
- run.totals = run.totals || emptyTotals();
211
- if (priorStatus === 'running') {
212
- run.totals.workingDurationMs += elapsedMs;
213
- }
214
- else {
215
- run.totals.waitingDurationMs += elapsedMs;
216
- }
217
- }
218
- run.lastStatusChangeAt = now;
219
- }
220
- run.updatedAt = now;
221
- return run;
222
- }
223
- dispose(runId) {
224
- this.children.delete(runId);
225
- }
226
- // #521: terminate the agent process for a run (manager clicked Stop). Returns
227
- // true if a live child was signalled. The child's onExit handler then fires and
228
- // parks the run in its waiting state.
229
- stop(runId) {
230
- const child = this.children.get(runId);
231
- if (!child || typeof child.kill !== 'function')
232
- return false;
233
- try {
234
- child.kill();
235
- return true;
236
- }
237
- catch {
238
- return false;
239
- }
240
- }
241
- }
242
- function safeHttpUrl(value) {
243
- if (typeof value !== 'string' || value.trim().length === 0)
244
- return null;
245
- try {
246
- const parsed = new URL(value.trim());
247
- if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
248
- return null;
249
- return parsed.href;
250
- }
251
- catch {
252
- return null;
253
- }
254
- }
255
- // ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
256
- const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot'];
257
- function startSessionSeedForHost(hostId, runId) {
258
- return hostId === 'gemini' ? undefined : runId;
259
- }
260
- class DeploymentStore {
261
- constructor(filePath) {
262
- this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
263
- }
264
- load() {
265
- try {
266
- if (!fs_1.default.existsSync(this.filePath))
267
- return [];
268
- return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
269
- }
270
- catch {
271
- return [];
272
- }
273
- }
274
- save(deployments) {
275
- fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
276
- fs_1.default.writeFileSync(this.filePath, JSON.stringify(deployments, null, 2));
277
- }
278
- create(deployment) {
279
- const list = this.load();
280
- list.push(deployment);
281
- this.save(list);
282
- return deployment;
283
- }
284
- update(id, updater) {
285
- const list = this.load();
286
- const dep = list.find((d) => d.id === id);
287
- if (!dep)
288
- return false;
289
- updater(dep);
290
- dep.updatedAt = new Date().toISOString();
291
- this.save(list);
292
- return true;
293
- }
294
- delete(id) {
295
- const list = this.load();
296
- const next = list.filter((d) => d.id !== id);
297
- if (next.length === list.length)
298
- return false;
299
- this.save(next);
300
- return true;
301
- }
302
- }
303
- exports.DeploymentStore = DeploymentStore;
304
- class HostConfigStore {
305
- constructor(filePath) {
306
- this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-hosts.json');
307
- }
308
- load() {
309
- try {
310
- if (!fs_1.default.existsSync(this.filePath))
311
- return [];
312
- return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
313
- }
314
- catch {
315
- return [];
316
- }
317
- }
318
- save(hosts) {
319
- fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
320
- fs_1.default.writeFileSync(this.filePath, JSON.stringify(hosts, null, 2));
321
- }
322
- add(host) {
323
- const list = this.load();
324
- list.push(host);
325
- this.save(list);
326
- return host;
327
- }
328
- delete(id) {
329
- const list = this.load();
330
- const next = list.filter((h) => h.id !== id);
331
- if (next.length === list.length)
332
- return false;
333
- this.save(next);
334
- return true;
335
- }
336
- }
337
- exports.HostConfigStore = HostConfigStore;
338
- async function pingHost(host) {
339
- const start = Date.now();
340
- try {
341
- const resp = await fetch(`${host.url.replace(/\/$/, '')}/health`, {
342
- signal: AbortSignal.timeout(5000),
343
- headers: host.authToken ? { 'X-Hub-Auth': host.authToken } : {},
344
- });
345
- const latencyMs = Date.now() - start;
346
- return {
347
- id: host.id,
348
- label: host.label,
349
- url: host.url,
350
- status: resp.ok ? 'online' : 'degraded',
351
- latencyMs,
352
- lastPingAt: new Date().toISOString(),
353
- };
354
- }
355
- catch {
356
- return {
357
- id: host.id,
358
- label: host.label,
359
- url: host.url,
360
- status: 'offline',
361
- latencyMs: null,
362
- lastPingAt: new Date().toISOString(),
363
- };
364
- }
365
- }
366
- function normalizeReviewArtifact(raw, index = 0) {
367
- if (!raw || typeof raw !== 'object')
368
- return null;
369
- const value = raw;
370
- const artifactPath = typeof value.path === 'string' && value.path.trim().length > 0 ? value.path.trim() : null;
371
- const url = safeHttpUrl(value.url);
372
- const type = typeof value.type === 'string' && value.type.trim().length > 0 ? value.type.trim() : 'file';
373
- const label = typeof value.label === 'string' && value.label.trim().length > 0
374
- ? value.label.trim()
375
- : artifactPath
376
- ? artifactPath.split(/[\\/]/).filter(Boolean).pop() || `Artifact ${index + 1}`
377
- : url || `Artifact ${index + 1}`;
378
- if (!artifactPath && !url)
379
- return null;
380
- return { type, label, path: artifactPath, url };
381
- }
382
- function normalizeReviewHandoff(raw) {
383
- if (!raw || typeof raw !== 'object')
384
- return null;
385
- const value = raw;
386
- if (typeof value.reviewRequired !== 'boolean')
387
- return null;
388
- const artifacts = Array.isArray(value.artifacts)
389
- ? value.artifacts.map((artifact, index) => normalizeReviewArtifact(artifact, index)).filter((artifact) => Boolean(artifact))
390
- : [];
391
- const rawTarget = value.reviewTarget && typeof value.reviewTarget === 'object' ? value.reviewTarget : null;
392
- const targetType = rawTarget && typeof rawTarget.type === 'string' ? rawTarget.type.trim() : '';
393
- const targetLabel = rawTarget && typeof rawTarget.label === 'string' && rawTarget.label.trim().length > 0
394
- ? rawTarget.label.trim()
395
- : '';
396
- if (!value.reviewRequired) {
397
- return {
398
- reviewRequired: false,
399
- reviewTarget: rawTarget ? { type: targetType || 'none', label: targetLabel || 'Completed work' } : null,
400
- artifacts,
401
- summary: typeof value.summary === 'string' ? value.summary.trim() : '',
402
- feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : '',
403
- };
404
- }
405
- if (targetType === 'pull_request') {
406
- const url = safeHttpUrl(rawTarget?.url);
407
- if (!url)
408
- return null;
409
- if (artifacts.length > 0)
410
- return null;
411
- return {
412
- reviewRequired: true,
413
- reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
414
- artifacts: [],
415
- summary: typeof value.summary === 'string' ? value.summary.trim() : '',
416
- feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'pull_request_comments',
417
- };
418
- }
419
- const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
420
- if (targetType === 'artifact_set' && fileArtifacts.length > 0 && fileArtifacts.length === artifacts.length) {
421
- return {
422
- reviewRequired: true,
423
- reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}` },
424
- artifacts: fileArtifacts,
425
- summary: typeof value.summary === 'string' ? value.summary.trim() : '',
426
- feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'inline',
427
- };
428
- }
429
- return null;
430
- }
431
- const DELEGATION_TASK_STATUSES = new Set([
432
- 'planned',
433
- 'running',
434
- 'submitted',
435
- 'reviewed',
436
- 'blocked',
437
- 'completed',
438
- 'failed',
439
- ]);
440
- function cleanString(value) {
441
- return typeof value === 'string' ? value.trim() : '';
442
- }
443
- function cleanNullableString(value) {
444
- const cleaned = cleanString(value);
445
- return cleaned.length > 0 ? cleaned : null;
446
- }
447
- function normalizeDelegationLedger(raw) {
448
- if (!raw || typeof raw !== 'object')
449
- return null;
450
- const value = raw;
451
- // Accept delegationRequired:true OR presence of goal/objective + tasks/jobs/groups (AI may omit the flag).
452
- const hasFlag = value.delegationRequired === true;
453
- const objective = cleanString((value.objective || value.goal));
454
- if (!hasFlag && !objective)
455
- return null;
456
- if (!objective)
457
- return null;
458
- // Flatten tasks from multiple schemas:
459
- // canonical: tasks/jobs: [{title, taskId, personaKey, jobId, instructions, dependsOn}]
460
- // group-based: groups: [{runs: [{run_id, job, persona, briefing, expected_artifact}]}]
461
- // with optional depends_on referencing other run_id values
462
- let rawTasks = [];
463
- const rawJobs = (value.jobs || value.tasks);
464
- if (Array.isArray(rawJobs)) {
465
- rawTasks = rawJobs;
466
- }
467
- else if (Array.isArray(value.groups)) {
468
- // Flatten groups[].runs into a linear task list, resolving depends_on from group membership.
469
- let groupDepIds = [];
470
- for (const group of value.groups) {
471
- const runs = Array.isArray(group.runs) ? group.runs : [];
472
- const currentIds = runs.map((r) => cleanString(r.run_id) || '').filter(Boolean);
473
- for (const run of runs) {
474
- // Synthesise a canonical task from the run schema.
475
- const dependsOnRaw = Array.isArray(group.depends_on) ? group.depends_on.map(String) : groupDepIds.length ? groupDepIds : [];
476
- rawTasks.push({
477
- taskId: run.run_id,
478
- title: cleanString(run.briefing)?.slice(0, 80) || cleanString(run.job) || 'Task',
479
- personaKey: run.persona,
480
- jobId: run.job,
481
- instructions: run.briefing,
482
- dependsOn: dependsOnRaw,
483
- status: 'planned',
484
- });
485
- }
486
- groupDepIds = currentIds;
487
- }
488
- }
489
- const tasks = rawTasks.map((task, index) => {
490
- if (!task || typeof task !== 'object')
491
- return null;
492
- const rawTask = task;
493
- // Accept title or first 80 chars of briefing/instructions
494
- const title = cleanString((rawTask.title || rawTask.briefing || rawTask.instructions))?.slice(0, 80);
495
- if (!title)
496
- return null;
497
- const taskId = cleanString((rawTask.taskId || rawTask.run_id || rawTask.id))
498
- || title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
499
- || `task-${index + 1}`;
500
- const rawStatus = cleanString(rawTask.status);
501
- const status = DELEGATION_TASK_STATUSES.has(rawStatus) ? rawStatus : 'planned';
502
- const artifacts = Array.isArray(rawTask.artifacts)
503
- ? rawTask.artifacts.map((a, ai) => normalizeReviewArtifact(a, ai)).filter((a) => Boolean(a))
504
- : [];
505
- const reviewHandoff = normalizeReviewHandoff(rawTask.reviewHandoff);
506
- // Accept dependsOn or depends_on as string array
507
- const rawDeps = rawTask.dependsOn || rawTask.depends_on;
508
- return {
509
- taskId,
510
- title,
511
- status,
512
- personaKey: cleanNullableString((rawTask.personaKey || rawTask.persona)),
513
- jobId: cleanNullableString((rawTask.jobId || rawTask.job || rawTask.job_id)),
514
- reviewJobId: cleanNullableString(rawTask.reviewJobId),
515
- reviewType: cleanNullableString(rawTask.reviewType),
516
- instructions: cleanString((rawTask.instructions || rawTask.briefing)) || undefined,
517
- latestSummary: cleanString(rawTask.latestSummary) || undefined,
518
- hostThreadId: cleanNullableString(rawTask.hostThreadId),
519
- hostSessionId: cleanNullableString(rawTask.hostSessionId),
520
- runId: cleanNullableString(rawTask.runId),
521
- conversationId: cleanNullableString(rawTask.conversationId),
522
- dependsOn: Array.isArray(rawDeps) ? rawDeps.map(String).filter(Boolean) : [],
523
- artifacts,
524
- reviewHandoff,
525
- };
526
- }).filter((task) => Boolean(task));
527
- if (tasks.length === 0)
528
- return null;
529
- return {
530
- delegationRequired: true,
531
- objective,
532
- orchestratorPersonaKey: cleanNullableString((value.orchestratorPersonaKey || value.manager_persona)),
533
- rootRunId: cleanNullableString(value.rootRunId),
534
- managerRunId: cleanNullableString((value.managerRunId || value.manager_session)),
535
- latestSummary: cleanString(value.latestSummary) || undefined,
536
- tasks,
537
- };
538
- }
539
- function extractReviewHandoffFromText(text) {
540
- if (!text || !/reviewRequired|reviewTarget|review_handoff/i.test(text))
541
- return null;
542
- const candidates = [];
543
- for (const match of String(text).matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
544
- candidates.push(match[1]);
545
- const tagged = String(text).match(/<review_handoff>\s*([\s\S]*?)\s*<\/review_handoff>/i);
546
- if (tagged)
547
- candidates.push(tagged[1]);
548
- const inline = String(text).match(/(\{\s*"reviewRequired"[\s\S]*\})/i);
549
- if (inline)
550
- candidates.push(inline[1]);
551
- for (const candidate of candidates) {
552
- try {
553
- const handoff = normalizeReviewHandoff(JSON.parse(candidate.trim()));
554
- if (handoff)
555
- return handoff;
556
- }
557
- catch {
558
- // Malformed snippets are ignored; the UI can still surface legacy fallback state.
559
- }
560
- }
561
- return null;
562
- }
563
- function extractDelegationLedgerFromText(text) {
564
- if (!text || !/delegationRequired|delegation_ledger|delegationLedger|"goal"\s*:.*"groups"\s*:/is.test(text))
565
- return null;
566
- const candidates = [];
567
- for (const match of String(text).matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
568
- candidates.push(match[1]);
569
- const tagged = String(text).match(/<delegation_ledger>\s*([\s\S]*?)\s*<\/delegation_ledger>/i);
570
- if (tagged)
571
- candidates.push(tagged[1]);
572
- const inline = String(text).match(/(\{\s*"delegationRequired"[\s\S]*\})/i);
573
- if (inline)
574
- candidates.push(inline[1]);
575
- for (const candidate of candidates) {
576
- try {
577
- const ledger = normalizeDelegationLedger(JSON.parse(candidate.trim()));
578
- if (ledger)
579
- return ledger;
580
- }
581
- catch {
582
- // Malformed delegation snippets are ignored; the host transcript remains available in Micro-manage.
583
- }
584
- }
585
- return null;
586
- }
587
- function applyReviewProjection(run, text) {
588
- const delegation = extractDelegationLedgerFromText(text);
589
- if (delegation) {
590
- run.delegation = {
591
- ...delegation,
592
- rootRunId: delegation.rootRunId || run.id,
593
- managerRunId: delegation.managerRunId || run.id,
594
- };
595
- }
596
- const handoff = extractReviewHandoffFromText(text);
597
- if (handoff) {
598
- run.reviewHandoff = handoff;
599
- run.artifacts = handoff.reviewTarget?.type === 'artifact_set' ? handoff.artifacts : [];
600
- return;
601
- }
602
- }
603
- function emptyTotals() {
604
- return {
605
- totalDurationMs: 0,
606
- workingDurationMs: 0,
607
- waitingDurationMs: 0,
608
- tokenTotals: {
609
- inputTokens: null,
610
- outputTokens: null,
611
- costUsd: null,
612
- // Issue #347: Hub server cannot read tokenSnapshot from the local
613
- // proxy in-process. Cross-process attribution is a follow-up;
614
- // until then, every Hub-driven run reports unavailable. The Hub UI
615
- // renders "—" per spec R4.6.
616
- coverage: 'unavailable',
617
- },
618
- };
619
- }
620
- const PHASE_STATUSES = new Set(['starting', 'complete', 'incomplete', 'failure']);
621
- function normalizePersistedPhaseHistory(value) {
622
- if (!Array.isArray(value))
623
- return [];
624
- return value
625
- .map((entry) => {
626
- if (!entry || typeof entry !== 'object')
627
- return null;
628
- const raw = entry;
629
- if (typeof raw.phaseId !== 'string' || !raw.phaseId)
630
- return null;
631
- const latestStatus = typeof raw.latestStatus === 'string' && PHASE_STATUSES.has(raw.latestStatus)
632
- ? raw.latestStatus
633
- : null;
634
- return {
635
- phaseId: raw.phaseId,
636
- enteredAt: typeof raw.enteredAt === 'string' ? raw.enteredAt : new Date().toISOString(),
637
- latestStatus,
638
- latestText: typeof raw.latestText === 'string' ? raw.latestText : null,
639
- };
640
- })
641
- .filter((entry) => Boolean(entry));
642
- }
643
- function readPersistedRunProjection(conversation) {
644
- const rawRun = conversation?.run;
645
- if (!rawRun || typeof rawRun !== 'object')
646
- return null;
647
- const value = rawRun;
648
- return {
649
- currentPhase: typeof value.currentPhase === 'string' ? value.currentPhase : null,
650
- phaseHistory: normalizePersistedPhaseHistory(value.phaseHistory),
651
- stages: Array.isArray(value.stages) ? value.stages : [],
652
- totals: value.totals && typeof value.totals === 'object' ? value.totals : null,
653
- runDiscriminant: typeof value.runDiscriminant === 'string' ? value.runDiscriminant : null,
654
- };
655
- }
656
- // Issue #347 — apply per-turn usage from the host stream into the
657
- // run's tokenTotals. Idempotent on the same turn (we replace, not add)
658
- // because Codex's `turn.completed.usage` is cumulative, not delta.
659
- // When the host doesn't emit costUsd directly (Codex), we compute it
660
- // from the captured agent identity via the price table.
661
- function applyUsageSignal(run, usage) {
662
- run.totals = run.totals || emptyTotals();
663
- const tt = run.totals.tokenTotals;
664
- // Total input tokens (for the user-facing display) = non-cached + cached.
665
- // Cumulative across the whole run on both hosts; take max to guard
666
- // against any out-of-order delivery.
667
- const totalInput = usage.nonCachedInputTokens + usage.cachedInputTokens;
668
- tt.inputTokens = Math.max(tt.inputTokens || 0, totalInput);
669
- tt.outputTokens = Math.max(tt.outputTokens || 0, usage.outputTokens);
670
- // Cost: prefer the host's own number (Claude). For hosts that don't
671
- // emit it (Codex), compute from the captured agent identity + price
672
- // table. If neither source applies, leave costUsd null and coverage
673
- // stays 'partial'.
674
- let computedCost = null;
675
- if (typeof usage.costUsd === 'number') {
676
- computedCost = usage.costUsd;
677
- }
678
- else if (run.agentName && run.agentModel) {
679
- const price = (0, agent_token_prices_1.lookupPrice)(run.agentName.toLowerCase(), run.agentModel.toLowerCase());
680
- if (price) {
681
- computedCost =
682
- (usage.nonCachedInputTokens / 1_000_000) * price.inputPerMTok +
683
- (usage.cachedInputTokens / 1_000_000) * price.cacheReadPerMTok +
684
- (usage.cacheCreationTokens / 1_000_000) * price.cacheCreationPerMTok +
685
- (usage.outputTokens / 1_000_000) * price.outputPerMTok;
686
- }
687
- }
688
- if (computedCost !== null) {
689
- tt.costUsd = Math.max(tt.costUsd || 0, computedCost);
690
- tt.coverage = 'complete';
691
- }
692
- else if (tt.coverage !== 'complete') {
693
- tt.coverage = 'partial';
694
- }
695
- }
696
- // Issue #347 — capture agent identity from the host stream's fraim_connect
697
- // call. Used downstream by applyUsageSignal to compute cost via the price
698
- // table when the host does not emit costUsd directly.
699
- function applyAgentIdentitySignal(run, identity) {
700
- run.agentName = identity.agentName;
701
- run.agentModel = identity.agentModel;
702
- }
703
- function stripStructuredHostPayloads(text) {
704
- return text
705
- .replace(/<delegation_ledger>\s*[\s\S]*?\s*<\/delegation_ledger>/gi, '')
706
- .replace(/<review_handoff>\s*[\s\S]*?\s*<\/review_handoff>/gi, '')
707
- .trim();
708
- }
709
- function appendHostMessage(run, hostId, event, channel) {
710
- if (!event.message || channel !== 'stdout')
711
- return;
712
- applyReviewProjection(run, event.message);
713
- if (hostId === 'gemini') {
714
- const last = run.messages[run.messages.length - 1];
715
- if (last?.role === 'employee') {
716
- last.text = `${last.text}\n${event.message}`;
717
- last.createdAt = new Date().toISOString();
718
- applyReviewProjection(run, last.text);
719
- last.text = stripStructuredHostPayloads(last.text);
720
- if (!last.text)
721
- run.messages.pop();
722
- return;
723
- }
724
- }
725
- const displayMessage = stripStructuredHostPayloads(event.message);
726
- if (!displayMessage)
727
- return;
728
- run.messages.push((0, hosts_1.createHubMessage)('employee', displayMessage));
729
- }
730
- // Apply a parsed seekMentoring tool-use signal from the host stream to
731
- // the run state. Returns the updated currentPhase.
732
- function applySeekMentoringSignal(run, signal) {
733
- // Issue #347: filter cross-job pollution. The agent may call
734
- // seekMentoring for jobs OTHER than the one this run is tracking
735
- // (e.g., consulting `organizational-learning-synthesis` mid-run).
736
- // Only apply signals whose job identity matches this run's.
737
- // Issue #732: match on `jobName` — the stable job-name slug that equals
738
- // run.jobId — NOT `jobId`. get_fraim_job mints a fresh randomUUID as the
739
- // job id on every call and the agent echoes that UUID into
740
- // seekMentoring.jobId, so a UUID never equals run.jobId. Comparing the UUID
741
- // discarded every real phase signal as if it were foreign-job pollution and
742
- // froze the tracker at "no phases done". The UUID cannot positively identify
743
- // this run's job, so it must not participate in the match.
744
- const targetJobId = run.jobId;
745
- const callJobName = signal.jobName;
746
- if (callJobName && targetJobId && callJobName !== targetJobId)
747
- return;
748
- if (signal.reviewHandoff) {
749
- const normalizedReviewHandoff = normalizeReviewHandoff(signal.reviewHandoff);
750
- run.reviewHandoff = normalizedReviewHandoff || signal.reviewHandoff;
751
- run.artifacts = normalizedReviewHandoff?.reviewTarget?.type === 'artifact_set'
752
- ? normalizedReviewHandoff.artifacts
753
- : [];
754
- }
755
- if (signal.delegationLedger &&
756
- run.jobId === 'fully-delegate' &&
757
- signal.phaseStatus === 'complete' &&
758
- (signal.phaseId === 'confirm-or-fallback' || signal.phaseId === 'create-delegation-graph')) {
759
- run.delegation = {
760
- ...signal.delegationLedger,
761
- rootRunId: signal.delegationLedger.rootRunId || run.id,
762
- managerRunId: signal.delegationLedger.managerRunId || run.id,
763
- };
764
- }
765
- // Discriminant signals are routing hints only — they don't move the
766
- // tracker, but they do change which phase id will be considered
767
- // reachable next time stages are derived. Persist on the run.
768
- if (signal.phaseId === '__discriminant__') {
769
- if (signal.discriminant)
770
- run.runDiscriminant = signal.discriminant;
771
- return;
772
- }
773
- if (signal.discriminant)
774
- run.runDiscriminant = signal.discriminant;
775
- // The seekMentoring workflow uses `currentPhase: "starting"` as a
776
- // sentinel for the very first call ("give me phase 1 instructions").
777
- // It is not an actual phase id — skip it so the tracker doesn't
778
- // render an extra "starting" stage at the end of the path.
779
- if (signal.phaseId === 'starting')
780
- return;
781
- run.phaseHistory = run.phaseHistory || [];
782
- const existing = run.phaseHistory.find((entry) => entry.phaseId === signal.phaseId);
783
- if (existing) {
784
- existing.latestStatus = signal.phaseStatus;
785
- if (signal.findingsText)
786
- existing.latestText = signal.findingsText;
787
- }
788
- else {
789
- const entry = {
790
- phaseId: signal.phaseId,
791
- enteredAt: new Date().toISOString(),
792
- latestStatus: signal.phaseStatus,
793
- latestText: signal.findingsText || null,
794
- };
795
- run.phaseHistory.push(entry);
796
- }
797
- run.currentPhase = signal.phaseId;
798
- }
799
- // Build the stage list for a run. Combines the FSM's reachable path with
800
- // any phases the run has actually visited (in case the run took an
801
- // onFailure back-edge into a phase the simple onSuccess walk wouldn't
802
- // surface). Each stage is marked done / current / upcoming based on
803
- // whether it precedes / matches / follows the current phase along the
804
- // rendered order.
805
- function deriveStages(run, projectPath) {
806
- const declaredPath = (0, catalog_1.loadJobPhases)(run.jobId, projectPath, run.runDiscriminant || 'feature');
807
- if (declaredPath.length === 0)
808
- return [];
809
- // Merge in any visited phase that's not on the declared path BUT is
810
- // declared in the job's frontmatter (e.g., a phase reached via an
811
- // onFailure back-edge that the simple onSuccess walk didn't surface).
812
- // Skip the 'starting' sentinel, the __discriminant__ marker, and any
813
- // phase id NOT in the frontmatter — that last filter prevents
814
- // cross-job pollution from showing up on the tracker.
815
- const allDeclared = (0, catalog_1.loadAllJobPhaseIds)(run.jobId, projectPath);
816
- const visited = (run.phaseHistory || []).map((entry) => entry.phaseId);
817
- const known = new Set(declaredPath.map((p) => p.id));
818
- for (const visitedId of visited) {
819
- if (visitedId === 'starting' || visitedId === '__discriminant__')
820
- continue;
821
- if (!allDeclared.has(visitedId))
822
- continue;
823
- if (!known.has(visitedId)) {
824
- declaredPath.push({ id: visitedId, label: (0, catalog_1.labelForPhaseId)(visitedId, run.jobId, projectPath) });
825
- known.add(visitedId);
826
- }
827
- }
828
- const currentIndex = run.currentPhase
829
- ? declaredPath.findIndex((p) => p.id === run.currentPhase)
830
- : -1;
831
- const historyMap = new Map((run.phaseHistory || []).map((e) => [e.phaseId, e]));
832
- const completedWithoutPhaseTelemetry = run.status === 'completed' &&
833
- currentIndex < 0 &&
834
- historyMap.size === 0;
835
- if (completedWithoutPhaseTelemetry) {
836
- return declaredPath.map((phase) => ({ phaseId: phase.id, label: phase.label, state: 'done' }));
837
- }
838
- return declaredPath.map((phase, index) => {
839
- let state;
840
- const entry = historyMap.get(phase.id);
841
- if (index === currentIndex) {
842
- // If the agent has already reported this phase as 'complete', advance
843
- // its visual state to 'done' so the tracker doesn't look frozen while
844
- // waiting for the agent to start the next phase (e.g. after
845
- // implement-submission completes but before address-feedback starts).
846
- state = entry?.latestStatus === 'complete' ? 'done' : 'current';
847
- }
848
- else if (entry?.latestStatus === 'complete' || (currentIndex >= 0 && index < currentIndex && entry)) {
849
- state = 'done';
850
- }
851
- else {
852
- state = 'upcoming';
853
- }
854
- return { phaseId: phase.id, label: phase.label, state };
855
- });
856
- }
857
- // ---------------------------------------------------------------------------
858
- // Agent install helpers (shared with first-run; duplicated here to avoid
859
- // the session-key dependency in that module's public API).
860
- // ---------------------------------------------------------------------------
861
- const HUB_TO_FIRST_RUN_ID = {
862
- claude: 'claude-code',
863
- codex: 'codex',
864
- gemini: 'gemini-cli',
865
- copilot: 'copilot-cli',
866
- };
867
- function hubAgentOption(hubId) {
868
- const frId = HUB_TO_FIRST_RUN_ID[hubId];
869
- return frId ? types_1.FIRST_RUN_AGENT_OPTIONS.find((o) => o.id === frId) : undefined;
870
- }
871
- /**
872
- * Issue #747: after the Hub installs an agent CLI, run the `add-ide` command for that agent so the
873
- * FRAIM MCP (plus slash commands / rules) is wired into its config and its first run works.
874
- * Previously the install only ran `npm install -g` + a version probe, so the agent launched with
875
- * no `fraim` MCP server. This invokes the same `runAddIDE` the `fraim add-ide` CLI runs — scoped to
876
- * the just-installed agent — rather than re-implementing the MCP-config write.
877
- *
878
- * `runAddIDE` reads the FRAIM key from ~/.fraim/config.json (written by setup/first-run) and
879
- * `process.exit(1)`s when it is missing. We guard on that here via the exported `loadGlobalConfig`
880
- * so a missing key degrades to a logged no-op instead of terminating the long-running Hub server.
881
- * `skipTokenPrompts` keeps the run fully non-interactive.
882
- */
883
- async function configureFraimForHubAgent(hubId) {
884
- const frId = HUB_TO_FIRST_RUN_ID[hubId];
885
- if (!frId)
886
- return { configured: false, error: `Unknown hub agent: ${hubId}` };
887
- try {
888
- const { runAddIDE, loadGlobalConfig } = await Promise.resolve().then(() => __importStar(require('../cli/commands/add-ide')));
889
- const config = await loadGlobalConfig();
890
- if (!config?.fraimKey) {
891
- return { configured: false, error: 'No FRAIM key in ~/.fraim/config.json; run `fraim setup` first.' };
892
- }
893
- // `frId` (e.g. 'claude-code', 'codex', 'gemini-cli', 'copilot-cli') is a valid add-ide
894
- // `--ide` name/alias; runAddIDE resolves and configures it even when not yet detected.
895
- await runAddIDE({ ide: frId, skipTokenPrompts: true });
896
- return { configured: true, ideName: frId };
897
- }
898
- catch (e) {
899
- return { configured: false, error: e instanceof Error ? e.message : String(e) };
900
- }
901
- }
902
- function hubCommandVersion(command, extraBinDirs) {
903
- const executable = process.platform === 'win32' ? 'cmd.exe' : command;
904
- const args = process.platform === 'win32'
905
- ? ['/d', '/s', '/c', `${command} --version`]
906
- : ['--version'];
907
- const env = extraBinDirs && extraBinDirs.length > 0
908
- ? {
909
- ...process.env,
910
- PATH: [...new Set([...extraBinDirs, ...(process.env.PATH || '').split(path_1.default.delimiter).filter(Boolean)])].join(path_1.default.delimiter),
911
- }
912
- : undefined;
913
- const result = (0, child_process_1.spawnSync)(executable, args, { encoding: 'utf8', timeout: 5000, ...(env ? { env } : {}) });
914
- if (result.status !== 0 || result.error)
915
- return null;
916
- const raw = (result.stdout || result.stderr || '').trim();
917
- return raw || null;
918
- }
919
- function hubRunProcess(command, args, env) {
920
- return new Promise((resolve, reject) => {
921
- const [realCmd, realArgs] = process.platform === 'win32'
922
- ? ['cmd.exe', ['/d', '/s', '/c', command, ...args]]
923
- : [command, args];
924
- const child = (0, child_process_1.spawn)(realCmd, realArgs, {
925
- env: { ...process.env, ...env },
926
- stdio: ['ignore', 'pipe', 'pipe'],
927
- });
928
- let stdout = '';
929
- let stderr = '';
930
- child.stdout.on('data', (d) => { stdout += d.toString(); });
931
- child.stderr.on('data', (d) => { stderr += d.toString(); });
932
- child.on('close', (code) => {
933
- if (code === 0)
934
- resolve({ stdout, stderr });
935
- else
936
- reject(new Error(stderr || `Process exited with code ${code}`));
937
- });
938
- child.on('error', reject);
939
- });
940
- }
941
- function hubOpenTerminal(command) {
942
- if (process.platform === 'win32') {
943
- (0, child_process_1.spawn)('cmd.exe', ['/c', 'start', 'cmd.exe', '/k', command], { detached: true, stdio: 'ignore' }).unref();
944
- return;
945
- }
946
- if (process.platform === 'darwin') {
947
- const script = `tell application "Terminal" to do script "${command.replace(/"/g, '\\"')}"`;
948
- (0, child_process_1.spawn)('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
949
- return;
950
- }
951
- const linux = [
952
- ['gnome-terminal', ['--', 'bash', '-c', `${command}; exec bash`]],
953
- ['xterm', ['-e', `bash -c '${command}; exec bash'`]],
954
- ['konsole', ['--noclose', '-e', 'bash', '-c', command]],
955
- ['x-terminal-emulator', ['-e', `bash -c '${command}; exec bash'`]],
956
- ];
957
- for (const [term, args] of linux) {
958
- if ((0, child_process_1.spawnSync)('which', [term], { encoding: 'utf8' }).status === 0) {
959
- (0, child_process_1.spawn)(term, args, { detached: true, stdio: 'ignore' }).unref();
960
- return;
961
- }
962
- }
963
- (0, child_process_1.spawn)('bash', ['-c', command], { detached: true, stdio: 'ignore' }).unref();
964
- }
965
- function pathWithin(root, candidate) {
966
- const resolvedRoot = path_1.default.resolve(root);
967
- const resolvedCandidate = path_1.default.resolve(candidate);
968
- return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(resolvedRoot + path_1.default.sep);
969
- }
970
- function resolveSafeArtifactPath(rawPath, projectPath) {
971
- const trimmed = rawPath.trim();
972
- if (!trimmed || /^https?:\/\//i.test(trimmed))
973
- return null;
974
- const resolved = path_1.default.resolve(path_1.default.isAbsolute(trimmed) ? trimmed : path_1.default.join(projectPath, trimmed));
975
- const safeRoots = [
976
- path_1.default.resolve(projectPath),
977
- path_1.default.resolve(os_1.default.homedir()),
978
- ];
979
- return safeRoots.some((root) => pathWithin(root, resolved)) ? resolved : null;
980
- }
981
- // Builds the OS "open this file with its default app" invocation. Exported for regression
982
- // testing (see tests/isolated/test-ai-hub-open-file.ts).
983
- //
984
- // Windows contract: the path travels via an ENVIRONMENT VARIABLE, never as a trailing `-Command`
985
- // argument. PowerShell re-parses trailing args on its own command line and mangles Windows paths —
986
- // it strips backslashes and truncates at the first space — so `Invoke-Item -LiteralPath $p`
987
- // received a broken path (e.g. `C:UserssidmaOneDriveCodeDrug`) and failed for every real artifact
988
- // path, which is why the Hub's "open file" button did nothing. `$env:FRAIM_OPEN_PATH` is read
989
- // directly from the environment and is not re-parsed, so the path arrives intact (verified against
990
- // paths containing spaces and backslashes).
991
- function buildOpenFileInvocation(filePath, platform = process.platform) {
992
- if (platform === 'win32') {
993
- return {
994
- command: 'powershell.exe',
995
- args: [
996
- '-NoProfile',
997
- '-ExecutionPolicy',
998
- 'Bypass',
999
- '-Command',
1000
- 'try { Invoke-Item -LiteralPath $env:FRAIM_OPEN_PATH; exit 0 } catch { Write-Error $_; exit 1 }',
1001
- ],
1002
- envPatch: { FRAIM_OPEN_PATH: filePath },
1003
- };
1004
- }
1005
- return {
1006
- command: platform === 'darwin' ? 'open' : 'xdg-open',
1007
- args: [filePath],
1008
- envPatch: {},
1009
- };
1010
- }
1011
- function hubOpenFile(filePath) {
1012
- return new Promise((resolve, reject) => {
1013
- const { command, args, envPatch } = buildOpenFileInvocation(filePath);
1014
- const child = (0, child_process_1.spawn)(command, args, {
1015
- stdio: ['ignore', 'ignore', 'pipe'],
1016
- windowsHide: process.platform === 'win32',
1017
- env: { ...process.env, ...envPatch },
1018
- });
1019
- let stderr = '';
1020
- child.stderr?.on('data', (chunk) => {
1021
- stderr += String(chunk);
1022
- });
1023
- child.on('error', reject);
1024
- child.on('close', (code) => {
1025
- if (code === 0) {
1026
- resolve();
1027
- return;
1028
- }
1029
- reject(new Error(stderr.trim() || `Open command failed with exit code ${code ?? 'unknown'}.`));
1030
- });
1031
- });
1032
- }
1033
- function buildManagedLoginCommand(command) {
1034
- const managedPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
1035
- if (process.platform === 'win32') {
1036
- return `set "PATH=${managedPath}" && ${command}`;
1037
- }
1038
- return `export PATH="${managedPath}"; ${command}`;
1039
- }
1040
- function getUserHubDir() {
1041
- return path_1.default.join(os_1.default.homedir(), '.fraim');
1042
- }
1043
- function ensureDirectoryPath(projectPath) {
1044
- const trimmed = (projectPath || '').trim();
1045
- if (!trimmed) {
1046
- throw new Error('Project path is required.');
1047
- }
1048
- const resolved = path_1.default.resolve(trimmed);
1049
- const stat = fs_1.default.existsSync(resolved) ? fs_1.default.statSync(resolved) : null;
1050
- if (!stat || !stat.isDirectory()) {
1051
- throw new Error('Project path must point to an existing directory.');
1052
- }
1053
- return resolved;
1054
- }
1055
- function normalizedDirectoryPath(projectPath) {
1056
- const resolved = path_1.default.resolve(projectPath);
1057
- return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
1058
- }
1059
- function sameDirectoryPath(left, right) {
1060
- return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
1061
- }
1062
- function deploymentProjectFilter(rawProjectPath, fallbackProjectPath) {
1063
- return ensureDirectoryPath(typeof rawProjectPath === 'string' && rawProjectPath.trim() ? rawProjectPath : fallbackProjectPath);
1064
- }
1065
- function deploymentBelongsToProject(deployment, projectPath, fallbackProjectPath) {
1066
- const deploymentProjectPath = typeof deployment.projectPath === 'string' && deployment.projectPath.trim()
1067
- ? deployment.projectPath
1068
- : fallbackProjectPath;
1069
- return sameDirectoryPath(deploymentProjectPath, projectPath);
1070
- }
1071
- // ---------------------------------------------------------------------------
1072
- // Issue #512 (S3) — Hub bootstrap projection helpers.
1073
- // ---------------------------------------------------------------------------
1074
- // Issue #750: the Hub's identity — for persona/entitlement resolution, the
1075
- // displayed "signed in as" email, and personal (L1) learnings keying — comes
1076
- // from ~/.fraim/config.json's apiKey (architecture.md §4.3.4's "Global config
1077
- // stores only identity"), the same credential every other FRAIM CLI/MCP
1078
- // surface already reads via readUserFraimConfig(). There is no fallback: a
1079
- // missing or invalid apiKey resolves to null (rendered as a "not connected"
1080
- // state), never a guess from another local file.
1081
- function resolveApiKey() {
1082
- return (0, user_config_1.readUserFraimConfig)().apiKey;
1083
- }
1084
- // Read persisted Get-started step states from ~/.fraim/install-state.json
1085
- // (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
1086
- // missing key falls back to derivation. Persisted `true` is sticky so the rail
1087
- // never re-shows once a step is completed (R13.4).
1088
- function readPersistedFirstRun() {
1089
- const out = {};
1090
- const apply = (source) => {
1091
- if (!source || typeof source !== 'object')
1092
- return;
1093
- // Support both a flat shape ({ company: true }) and a nested
1094
- // ({ firstRun: { company: true } }) one — the rail mirrors to whichever.
1095
- const nested = source.firstRun;
1096
- const candidates = [source, nested].filter((c) => !!c && typeof c === 'object');
1097
- for (const c of candidates) {
1098
- for (const key of ['install', 'company', 'hire', 'project']) {
1099
- if (c[key] === true)
1100
- out[key] = true;
1101
- }
1102
- }
1103
- };
1104
- const readJson = (fileName) => {
1105
- try {
1106
- const p = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), fileName);
1107
- if (!fs_1.default.existsSync(p))
1108
- return null;
1109
- return JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
1110
- }
1111
- catch {
1112
- return null;
1113
- }
1114
- };
1115
- apply(readJson('install-state.json'));
1116
- apply(readJson('preferences.json'));
1117
- return out;
1118
- }
1119
- // Count .md files recursively under a directory (skills/rules catalog counts).
1120
- function countMarkdownFilesRecursive(dirPath) {
1121
- if (!fs_1.default.existsSync(dirPath))
1122
- return 0;
1123
- let total = 0;
1124
- try {
1125
- for (const entry of fs_1.default.readdirSync(dirPath, { withFileTypes: true })) {
1126
- const child = path_1.default.join(dirPath, entry.name);
1127
- if (entry.isDirectory()) {
1128
- total += countMarkdownFilesRecursive(child);
1129
- }
1130
- else if (entry.isFile() && entry.name.endsWith('.md')) {
1131
- total += 1;
1132
- }
1133
- }
1134
- }
1135
- catch {
1136
- // ignore unreadable dirs
1137
- }
1138
- return total;
1139
- }
1140
- class AiHubServer {
1141
- get hubBase() {
1142
- return process.env.FRAIM_HUB_BASE_URL || `http://127.0.0.1:${this.httpPort}`;
1143
- }
1144
- constructor(options = {}) {
1145
- this.app = (0, express_1.default)();
1146
- this.runRegistry = new AiHubRunRegistry();
1147
- this.cronHandles = new Map();
1148
- this.projectPath = options.projectPath || process.cwd();
1149
- this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
1150
- this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
1151
- this.wordTaskpaneDir = options.wordTaskpaneDir ?? resolveWordTaskpaneDir(this.projectPath);
1152
- this.folderPicker = options.folderPicker ?? pickProjectPath;
1153
- this.httpsPort = options.httpsPort;
1154
- this.certBundle = options.certBundle;
1155
- this.managedBrowser = options.managedBrowser || new managed_browser_1.ManagedBrowser({
1156
- channel: process.env.FRAIM_BROWSER_CHANNEL || 'auto',
1157
- port: process.env.FRAIM_BROWSER_PORT ? Number(process.env.FRAIM_BROWSER_PORT) : undefined,
1158
- userDataDir: process.env.FRAIM_BROWSER_USER_DATA_DIR || undefined,
1159
- explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
1160
- });
1161
- this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
1162
- // Issue #701 / #749: the AI Hub is a loopback companion that runs on user machines and
1163
- // never touches a database. Persona and manager-team state resolve from the hosted server
1164
- // through the remote gateway; the hosted server is the sole owner of DB access.
1165
- this.remoteGateway = options.remoteGateway ?? new remote_hub_gateway_1.HttpHubRemoteGateway();
1166
- this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
1167
- this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
1168
- this.app.use(express_1.default.json({ limit: '10mb' }));
1169
- // CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
1170
- // calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
1171
- this.app.use((_req, res, next) => {
1172
- res.setHeader('Access-Control-Allow-Origin', '*');
1173
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
1174
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1175
- res.setHeader('Access-Control-Allow-Private-Network', 'true');
1176
- if (_req.method === 'OPTIONS') {
1177
- res.sendStatus(204);
1178
- return;
1179
- }
1180
- next();
1181
- });
1182
- // Payment success redirect: sync entitlement then bounce to the hub.
1183
- // Stripe redirects here after a completed persona-hire checkout.
1184
- this.app.get('/ai-hub/payment-success', (req, res) => {
1185
- const email = req.query['email'];
1186
- // Persist the buyer's email so bootstrap can look up entitlements by userId.
1187
- if (email) {
1188
- try {
1189
- const prefsPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'preferences.json');
1190
- const existing = fs_1.default.existsSync(prefsPath)
1191
- ? JSON.parse(fs_1.default.readFileSync(prefsPath, 'utf8'))
1192
- : {};
1193
- fs_1.default.writeFileSync(prefsPath, JSON.stringify({ ...existing, userEmail: email }, null, 2));
1194
- }
1195
- catch (err) {
1196
- console.warn('[ai-hub] could not persist userEmail:', err);
1197
- }
1198
- }
1199
- // Issue #749: the Hub holds no DB. Entitlement is synced authoritatively by the backend
1200
- // Stripe webhook; the Hub re-reads status via the remote gateway on the next bootstrap.
1201
- res.redirect('/ai-hub/?hired=1');
1202
- });
1203
- this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
1204
- // Issue #489: Serve the Word task pane assets at /word-taskpane/*.
1205
- // Office JS appends ?_host_Info=Word$Win32$... to every request — we must
1206
- // strip the query string before resolving the file path, otherwise every
1207
- // request returns 404. express.static does NOT strip query strings, so we
1208
- // use a custom middleware that resolves the pathname manually.
1209
- if (this.wordTaskpaneDir) {
1210
- const wordDir = this.wordTaskpaneDir; // capture for closure
1211
- this.app.use('/word-taskpane', (req, res, next) => {
1212
- // Chrome Private Network Access: public origin (word-edit.officeapps.live.com)
1213
- // loading a private-network resource (localhost) requires this header on both the
1214
- // preflight OPTIONS response and the final GET response.
1215
- res.setHeader('Access-Control-Allow-Private-Network', 'true');
1216
- res.setHeader('Access-Control-Allow-Origin', '*');
1217
- res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
1218
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
1219
- if (req.method === 'OPTIONS') {
1220
- res.sendStatus(204);
1221
- return;
1222
- }
1223
- // Strip query string — Office appends ?_host_Info=Word$Win32$... to every request
1224
- const { pathname } = new URL(req.url, 'http://localhost');
1225
- const target = pathname === '/' || pathname === '' ? '/taskpane.html' : pathname;
1226
- // Prevent path traversal
1227
- const safeTarget = path_1.default.normalize(target).replace(/^(\.\.(\/|\\|$))+/, '');
1228
- const filePath = path_1.default.join(wordDir, safeTarget);
1229
- if (!filePath.startsWith(wordDir + path_1.default.sep) && filePath !== wordDir) {
1230
- res.status(403).end();
1231
- return;
1232
- }
1233
- const ext = path_1.default.extname(filePath);
1234
- const contentTypes = {
1235
- '.html': 'text/html; charset=utf-8',
1236
- '.css': 'text/css; charset=utf-8',
1237
- '.js': 'application/javascript; charset=utf-8',
1238
- '.xml': 'application/xml; charset=utf-8',
1239
- };
1240
- const contentType = contentTypes[ext] || 'text/plain; charset=utf-8';
1241
- fs_1.default.readFile(filePath, (err, data) => {
1242
- if (err) {
1243
- next(); // fall through to 404
1244
- return;
1245
- }
1246
- res.setHeader('Content-Type', contentType);
1247
- res.end(data);
1248
- });
1249
- });
1250
- }
1251
- this.app.get(['/word-taskpane/config.js', '/powerpoint-taskpane/config.js'], (_req, res) => {
1252
- const port = this.httpPort || 43091;
1253
- const origin = `http://127.0.0.1:${port}`;
1254
- res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
1255
- res.setHeader('Access-Control-Allow-Origin', '*');
1256
- res.end(`window.FRAIM_HUB_ORIGIN=${JSON.stringify(origin)};\n`);
1257
- });
1258
- this.app.get('/health', (_req, res) => {
1259
- res.json({ status: 'ok', service: 'fraim-ai-hub' });
1260
- });
1261
- // Extended health endpoint for trigger surfaces (browser extension, Office add-ins, tray)
1262
- this.app.get('/api/health', (_req, res) => {
1263
- res.json({ status: 'ok', service: 'fraim-ai-hub' });
1264
- });
1265
- // Issue #659: Inbound auth middleware for remote-exposed hubs.
1266
- // Activated only when FRAIM_HUB_AUTH_TOKEN env var is set. Gates all
1267
- // /api/ai-hub/* routes. /health and /api/health are registered above
1268
- // this block and are never gated.
1269
- if (process.env.FRAIM_HUB_AUTH_TOKEN) {
1270
- const expectedToken = process.env.FRAIM_HUB_AUTH_TOKEN;
1271
- this.app.use('/api/ai-hub', (req, res, next) => {
1272
- const token = req.headers['x-hub-auth'];
1273
- if (typeof token !== 'string' ||
1274
- token.length !== expectedToken.length ||
1275
- !(0, crypto_1.timingSafeEqual)(Buffer.from(token), Buffer.from(expectedToken))) {
1276
- res.status(401).json({ error: 'Unauthorized' });
1277
- return;
1278
- }
1279
- next();
1280
- });
1281
- }
1282
- this.registerRoutes();
1283
- }
1284
- getApp() {
1285
- return this.app;
1286
- }
1287
- async start(port) {
1288
- this.httpPort = port;
1289
- await new Promise((resolve, reject) => {
1290
- this.httpServer = this.app.listen(port, '127.0.0.1');
1291
- this.httpServer.once('listening', () => resolve());
1292
- this.httpServer.once('error', (error) => reject(error));
1293
- });
1294
- // #521: publish where the shared browser lives + how to bring it up, so every
1295
- // agent inherits them. The browser is NOT launched here — it's started lazily
1296
- // (the browser-use skill POSTs /browser/start when it actually needs one, which
1297
- // also relaunches it if the manager closed the window). Deterministic endpoint,
1298
- // so it's known even before the browser is running.
1299
- process.env.FRAIM_BROWSER_CDP_ENDPOINT = this.managedBrowser.cdpEndpoint();
1300
- process.env.FRAIM_HUB_BASE_URL = `http://127.0.0.1:${port}`;
1301
- // Issue #578: rehydrate active scheduled deployments from disk.
1302
- this.rehydrateScheduledDeployments();
1303
- // Start HTTPS server when a cert bundle and port are provided.
1304
- // Word Online requires HTTPS; the HTTPS server shares the same Express app
1305
- // so all routes (including /word-taskpane/*) are available over both protocols.
1306
- if (this.httpsPort && this.certBundle) {
1307
- await new Promise((resolve, reject) => {
1308
- this.httpsServer = https_1.default.createServer({ key: this.certBundle.key, cert: this.certBundle.cert }, this.app);
1309
- this.httpsServer.listen(this.httpsPort, '127.0.0.1');
1310
- this.httpsServer.once('listening', () => resolve());
1311
- this.httpsServer.once('error', (error) => reject(error));
1312
- });
1313
- }
1314
- // #521: when the shared browser is enabled, bring it up at boot so the CDP
1315
- // endpoint is already published when the first run spawns. Best-effort — a
1316
- // missing browser must not stop the Hub from serving.
1317
- if (process.env.FRAIM_BROWSER_ENABLED === '1') {
1318
- try {
1319
- const r = await this.ensureManagedBrowser();
1320
- console.log(`[ai-hub] shared browser ready at ${r.endpoint}${r.reused ? ' (reused)' : ''}`);
1321
- }
1322
- catch (err) {
1323
- console.warn('[ai-hub] shared browser not started:', err instanceof Error ? err.message : err);
1324
- }
1325
- }
1326
- }
1327
- async stop() {
1328
- const closeServer = (srv) => new Promise((resolve, reject) => {
1329
- const closable = srv;
1330
- closable.closeIdleConnections?.();
1331
- const forceCloseTimer = setTimeout(() => {
1332
- closable.closeIdleConnections?.();
1333
- closable.closeAllConnections?.();
1334
- }, 250);
1335
- srv.close((error) => {
1336
- clearTimeout(forceCloseTimer);
1337
- if (error)
1338
- reject(error);
1339
- else
1340
- resolve();
1341
- });
1342
- });
1343
- if (this.httpsServer) {
1344
- await closeServer(this.httpsServer);
1345
- this.httpsServer = undefined;
1346
- }
1347
- if (this.httpServer) {
1348
- await closeServer(this.httpServer);
1349
- this.httpServer = undefined;
1350
- }
1351
- // Issue #578: stop all active scheduled deployments.
1352
- for (const [, task] of this.cronHandles) {
1353
- task.stop();
1354
- }
1355
- this.cronHandles.clear();
1356
- // #521: tear down the shared browser if WE launched it (stop() no-ops on a
1357
- // browser the manager owns).
1358
- this.managedBrowser.stop();
1359
- }
1360
- getHttpsPort() { return this.httpsPort; }
1361
- knownProjects(projectPath, extras = []) {
1362
- const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
1363
- const preferences = this.preferencesStore.load(normalizedProjectPath);
1364
- const conversationProjects = this.conversationStore.listProjectPaths().map((folderPath) => ({ folderPath }));
1365
- return (0, preferences_1.normalizeAiHubProjectList)([
1366
- ...(preferences.projects || []),
1367
- ...conversationProjects,
1368
- ...extras,
1369
- ], normalizedProjectPath, { removedProjectPaths: preferences.removedProjectPaths || [] });
1370
- }
1371
- async bootstrapResponse(projectPath) {
1372
- const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
1373
- const employees = this.hostRuntime.detectEmployees();
1374
- let preferences = this.preferencesStore.load(normalizedProjectPath);
1375
- // If the stored employee isn't available on this machine, auto-select the
1376
- // first available one so the Hub never opens showing "(unavailable)".
1377
- const storedAvailable = employees.find((e) => e.id === preferences.employeeId)?.available ?? false;
1378
- if (!storedAvailable) {
1379
- const firstAvailable = employees.find((e) => e.available);
1380
- if (firstAvailable) {
1381
- preferences = { ...preferences, employeeId: firstAvailable.id };
1382
- this.preferencesStore.save(preferences);
1383
- }
1384
- }
1385
- const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
1386
- const catalogOptions = { includeRegistry: true };
1387
- const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath, catalogOptions);
1388
- // Issue #566 (R7): jobs already carry `personalized` from catalog discovery
1389
- // (true for the fraim/personalized-employee layer). The Hub renders a plain
1390
- // "Personalized" marking from that flag — no author/attribution is tracked.
1391
- const jobs = rawJobs
1392
- .filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
1393
- .map((job) => ({
1394
- ...job,
1395
- requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
1396
- }));
1397
- const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
1398
- // Issue #750: the apiKey always comes from ~/.fraim/config.json — no header
1399
- // override, no ai-hub-state.json copy, no fallback chain.
1400
- const resolvedApiKey = resolveApiKey();
1401
- const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(resolvedApiKey);
1402
- const managerTeam = await this.computeManagerTeam(resolvedApiKey);
1403
- const resolvedUserEmail = userKey ?? null;
1404
- const projects = this.knownProjects(normalizedProjectPath);
1405
- preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
1406
- this.preferencesStore.save(preferences);
1407
- // Issue #347: enrich the activeRun the same way GET /runs/:id does
1408
- // so the bootstrap surface (used on first paint) carries stages and
1409
- // live totals — not just the raw run state.
1410
- const latest = this.runRegistry.listLatest();
1411
- const activeRun = latest ? this.enrichRunForResponse(latest) : undefined;
1412
- return {
1413
- title: 'AI Hub',
1414
- remoteBaseUrl: (0, remote_hub_gateway_1.resolveFraimRemoteUrl)(),
1415
- // #755: the running build version, so the account menu can show it and flag
1416
- // when a newer version is available (see GET /api/ai-hub/version for `latest`).
1417
- version: (0, version_utils_1.getFraimVersion)(),
1418
- project,
1419
- // Issue #750: `apiKey` is no longer a persisted AiHubPreferences field —
1420
- // it is overlaid on the wire response only, freshly resolved from
1421
- // ~/.fraim/config.json, so public/ai-hub/script.js's existing
1422
- // tfConnectedApiKey()/tfConnectedSurfaceUrl read path needs no changes.
1423
- preferences: { ...preferences, apiKey: resolvedApiKey },
1424
- categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
1425
- jobs,
1426
- managerTemplates,
1427
- employees,
1428
- personas,
1429
- subscriptionActive,
1430
- activeRun,
1431
- projects,
1432
- // Issue #512 (S3) — additive manager-flow projections.
1433
- firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
1434
- teamContext: this.computeTeamContext(normalizedProjectPath),
1435
- brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length, resolvedUserEmail),
1436
- // #533/#750: the resolved account email (from the same ~/.fraim/config.json
1437
- // apiKey used for personas above), or null when not connected — so the
1438
- // profile card and personas can never disagree about who's signed in.
1439
- userEmail: resolvedUserEmail,
1440
- // #744: the org cobrand identity (name/color/logo) from the org context
1441
- // storage, or null when unset so the Hub falls back to FRAIM identity.
1442
- orgBrand: (0, learning_context_builder_1.readOrgBrand)(normalizedProjectPath),
1443
- assignments: { byProject: {}, source: 'client-localStorage' },
1444
- // Issue #538 — source of truth for the "Hire a human manager" UI. Lazy-required
1445
- // (not a top-level import) so the lightweight client CLI paths that import this
1446
- // module for findAvailablePort do not eagerly load server-only config (repro #422).
1447
- managerHiring: buildHubManagerHiringCatalog(),
1448
- // Issue #540: server-authoritative manager team (personas assigned by this manager).
1449
- managerTeam,
1450
- };
1451
- }
1452
- conversationRecordFromRun(run) {
1453
- const lastUpdatedAt = run.updatedAt || new Date().toISOString();
1454
- const stages = deriveStages(run, run.projectPath);
1455
- return {
1456
- id: run.conversationId || run.id,
1457
- projectPath: path_1.default.resolve(run.projectPath),
1458
- title: run.conversationTitle || run.jobTitle || run.jobId,
1459
- jobId: run.jobId,
1460
- jobTitle: run.jobTitle || run.jobId,
1461
- agentName: run.hostId,
1462
- personaKey: run.personaKey ?? null,
1463
- runId: run.id,
1464
- sessionId: run.sessionId || null,
1465
- status: run.status,
1466
- createdAt: run.createdAt,
1467
- lastUpdatedAt,
1468
- messages: run.messages.map((message) => ({
1469
- role: message.role,
1470
- text: message.text,
1471
- at: Date.parse(message.createdAt) || Date.now(),
1472
- })),
1473
- events: run.events.map((event) => ({
1474
- channel: event.channel,
1475
- text: event.text,
1476
- })),
1477
- artifacts: run.artifacts || [],
1478
- reviewHandoff: run.reviewHandoff || null,
1479
- delegation: run.delegation || null,
1480
- delegationTaskId: run.delegationTaskId || null,
1481
- managedByRunId: run.managedByRunId || null,
1482
- managedByPersonaKey: run.managedByPersonaKey || null,
1483
- humanCoachingDisabled: run.humanCoachingDisabled || false,
1484
- managedReviewStatus: run.managedReviewStatus || null,
1485
- compareMode: run.runRole === 'fraim' && run.compareRunId ? 'ab' : undefined,
1486
- compareRunId: run.compareRunId || null,
1487
- // Issue #578: preserve trigger source so the UI can render the chip.
1488
- sourceTrigger: run.sourceTrigger,
1489
- // Issue #708: carry the invocation scope so the record lands in (and is keyed to)
1490
- // the right bucket. Falls back to the legacy client `invokedArea` when present.
1491
- scope: run.scope
1492
- ?? run.invokedArea
1493
- ?? 'project',
1494
- run: {
1495
- stages,
1496
- currentPhase: run.currentPhase || null,
1497
- phaseHistory: run.phaseHistory || [],
1498
- totals: run.totals || null,
1499
- runDiscriminant: run.runDiscriminant || null,
1500
- },
1501
- };
1502
- }
1503
- persistRunConversation(run, activeId) {
1504
- try {
1505
- // Issue #708: route the record to its scope bucket (manager/company runs get a
1506
- // project-independent home); project runs continue to key by project path.
1507
- const record = this.conversationRecordFromRun(run);
1508
- const key = (0, conversation_store_1.conversationScopeKey)(record.scope, run.projectPath);
1509
- this.conversationStore.upsertConversation(key, record, activeId);
1510
- }
1511
- catch (error) {
1512
- console.warn('[ai-hub] conversation store write failed:', error instanceof Error ? error.message : error);
1513
- }
1514
- }
1515
- // Issue #512 (S3, R13) — derive the four Get-started step states, then let any
1516
- // persisted `true` in ~/.fraim/{install-state,preferences}.json override the
1517
- // derivation (so a completed step stays completed even if its signal vanishes).
1518
- maybeStartDelegatedChildRuns(managerRun) {
1519
- const ledger = managerRun.delegation;
1520
- if (!ledger || managerRun.managedByRunId)
1521
- return;
1522
- managerRun.orchestratedDelegationTaskIds = managerRun.orchestratedDelegationTaskIds || [];
1523
- const started = new Set(managerRun.orchestratedDelegationTaskIds);
1524
- for (const task of ledger.tasks || []) {
1525
- if (!task.taskId || started.has(task.taskId))
1526
- continue;
1527
- if (!task.jobId || !this.delegationDependenciesSatisfied(ledger, task.dependsOn || []))
1528
- continue;
1529
- if (!task.personaKey)
1530
- task.personaKey = getProtectedPersonaForHubJob(task.jobId);
1531
- this.startDelegatedChildRun(managerRun, task.taskId);
1532
- started.add(task.taskId);
1533
- managerRun.orchestratedDelegationTaskIds.push(task.taskId);
1534
- }
1535
- }
1536
- delegationDependenciesSatisfied(ledger, dependsOn) {
1537
- if (!dependsOn.length)
1538
- return true;
1539
- const terminal = new Set(['reviewed', 'completed']);
1540
- return dependsOn.every((dependency) => {
1541
- const matched = ledger.tasks.find((task) => task.taskId === dependency || task.jobId === dependency);
1542
- return !!matched && terminal.has(matched.status);
1543
- });
1544
- }
1545
- startDelegatedChildRun(managerRun, taskId) {
1546
- const ledger = managerRun.delegation;
1547
- const task = ledger?.tasks.find((entry) => entry.taskId === taskId);
1548
- if (!ledger || !task || !task.jobId)
1549
- return;
1550
- if (!task.personaKey)
1551
- task.personaKey = getProtectedPersonaForHubJob(task.jobId);
1552
- const resolvedJob = this.resolveHubJob(managerRun.projectPath, task.jobId);
1553
- if (!resolvedJob) {
1554
- task.status = 'blocked';
1555
- task.latestSummary = `Delegation blocked: job "${task.jobId}" is not available in this project.`;
1556
- managerRun.events.push((0, hosts_1.createHubEvent)('system', task.latestSummary));
1557
- this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1558
- return;
1559
- }
1560
- const childConversationId = task.conversationId || `${managerRun.id}-${task.taskId}`;
1561
- const now = new Date().toISOString();
1562
- const childInstructions = [
1563
- task.instructions || task.latestSummary || `Complete the delegated workstream: ${task.title}.`,
1564
- '',
1565
- task.personaKey
1566
- ? `You are working as ${task.personaKey} for the manager job.`
1567
- : 'You are working as the specialist assigned by this delegated job.',
1568
- `Parent objective: ${ledger.objective}.`,
1569
- task.reviewJobId ? `Manager review route: this output should be reviewed using ${task.reviewJobId}.` : '',
1570
- 'Submit a concise deliverable summary and any artifact references back to Mandy.',
1571
- 'This is a delegated subtask, not an end-to-end FRAIM submission workflow. After you provide the deliverable for Mandy, stop. Do not create evidence docs, open or update PRs, update GitHub issues, or ask the human for review.',
1572
- 'Do not ask the human for coaching; Mandy is your manager for this workstream.',
1573
- ].join('\n');
1574
- const prepared = this.prepareStartPayload(managerRun.projectPath, managerRun.hostId, task.jobId, childInstructions);
1575
- const childRun = {
1576
- id: (0, crypto_1.randomUUID)(),
1577
- conversationId: childConversationId,
1578
- conversationTitle: task.title,
1579
- jobTitle: task.title,
1580
- jobId: prepared.jobId || task.jobId,
1581
- hostId: managerRun.hostId,
1582
- projectPath: managerRun.projectPath,
1583
- status: 'running',
1584
- createdAt: now,
1585
- updatedAt: now,
1586
- messages: [(0, hosts_1.createHubMessage)('manager', `Mandy delegated: ${task.title}\n\n${childInstructions}`)],
1587
- events: [(0, hosts_1.createHubEvent)('system', `Mandy started delegated workstream ${task.taskId} for ${task.personaKey || task.jobId}.`)],
1588
- currentPhase: null,
1589
- phaseHistory: [],
1590
- totals: emptyTotals(),
1591
- lastStatusChangeAt: now,
1592
- personaKey: task.personaKey,
1593
- delegationTaskId: task.taskId,
1594
- managedByRunId: managerRun.id,
1595
- managedByPersonaKey: managerRun.personaKey || ledger.orchestratorPersonaKey || 'mandy',
1596
- humanCoachingDisabled: true,
1597
- };
1598
- task.runId = childRun.id;
1599
- task.conversationId = childConversationId;
1600
- task.status = 'running';
1601
- this.runRegistry.create(childRun, {});
1602
- this.persistRunConversation(childRun);
1603
- this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1604
- const child = this.hostRuntime.startRun(managerRun.hostId, managerRun.projectPath, prepared.message, {
1605
- onEvent: (event, channel) => {
1606
- this.runRegistry.update(childRun.id, (current) => {
1607
- if (event.sessionId)
1608
- current.sessionId = event.sessionId;
1609
- appendHostMessage(current, managerRun.hostId, event, channel);
1610
- if (event.raw) {
1611
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
1612
- applyReviewProjection(current, event.raw);
1613
- }
1614
- if (event.agentIdentity)
1615
- applyAgentIdentitySignal(current, event.agentIdentity);
1616
- if (event.fraimJob)
1617
- this.applyFraimJobSignalToRun(current, event.fraimJob);
1618
- if (event.seekMentoring)
1619
- this.applySeekMentoringSignalToRun(current, event.seekMentoring);
1620
- if (event.usage)
1621
- applyUsageSignal(current, event.usage);
1622
- });
1623
- const updatedChild = this.runRegistry.get(childRun.id);
1624
- if (updatedChild)
1625
- this.persistRunConversation(updatedChild);
1626
- },
1627
- onExit: (exitCode) => {
1628
- this.runRegistry.update(childRun.id, (current) => {
1629
- current.exitCode = exitCode;
1630
- current.status = exitCode === 0 ? 'completed' : 'failed';
1631
- current.events.push((0, hosts_1.createHubEvent)('system', `Delegated workstream exited with code ${exitCode ?? 'unknown'}.`));
1632
- });
1633
- const updatedChild = this.runRegistry.get(childRun.id);
1634
- if (updatedChild) {
1635
- this.persistRunConversation(updatedChild);
1636
- this.markDelegatedChildSubmitted(managerRun.id, updatedChild);
1637
- this.notifyManagerOfDelegatedChild(managerRun.id, updatedChild);
1638
- }
1639
- this.runRegistry.dispose(childRun.id);
1640
- },
1641
- }, startSessionSeedForHost(managerRun.hostId, childRun.id));
1642
- this.runRegistry.attachChildIfRunning(childRun.id, child);
1643
- }
1644
- notifyManagerOfDelegatedChild(managerRunId, childRun) {
1645
- const managerRun = this.runRegistry.get(managerRunId);
1646
- if (!managerRun) {
1647
- childRun.events.push((0, hosts_1.createHubEvent)('system', 'Delegated deliverable could not be routed because the manager run is unavailable.'));
1648
- this.persistRunConversation(childRun);
1649
- return;
1650
- }
1651
- if (!managerRun.sessionId || managerRun.status === 'running') {
1652
- this.enqueueDelegatedReview(managerRun, childRun, !managerRun.sessionId ? 'manager session is unavailable' : 'manager is still running');
1653
- return;
1654
- }
1655
- const latest = [...(childRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
1656
- const artifacts = childRun.reviewHandoff?.artifacts?.map((artifact) => artifact.label || artifact.path || artifact.url).filter(Boolean).join(', ') || 'none reported';
1657
- const reviewPrompt = [
1658
- `Delegated workstream submitted by ${childRun.personaKey || 'a peer agent'}: ${childRun.conversationTitle || childRun.jobTitle || childRun.jobId}.`,
1659
- `Status: ${childRun.status}.`,
1660
- `Latest deliverable summary: ${latest || 'No employee summary captured.'}`,
1661
- `Artifacts: ${artifacts}.`,
1662
- childRun.delegationTaskId ? this.delegatedReviewRouteLine(managerRun, childRun.delegationTaskId) : '',
1663
- '',
1664
- 'As Mandy, review this child deliverable. If it is acceptable, say it is reviewed for synthesis. If it needs correction, write specific coaching feedback for that child workstream.',
1665
- ].join('\n');
1666
- this.continueManagerRunForDelegation(managerRun, reviewPrompt, `Mandy reviews ${childRun.conversationTitle || childRun.jobTitle || childRun.jobId}`, childRun);
1667
- }
1668
- delegatedReviewRouteLine(managerRun, taskId) {
1669
- const task = managerRun.delegation?.tasks.find((entry) => entry.taskId === taskId);
1670
- if (!task?.reviewJobId)
1671
- return '';
1672
- return `Declared manager review job: ${task.reviewJobId}${task.reviewType ? ` (${task.reviewType})` : ''}. Use that review standard when judging the deliverable.`;
1673
- }
1674
- enqueueDelegatedReview(managerRun, childRun, reason) {
1675
- managerRun.pendingDelegatedReviewChildRunIds = managerRun.pendingDelegatedReviewChildRunIds || [];
1676
- if (!managerRun.pendingDelegatedReviewChildRunIds.includes(childRun.id)) {
1677
- managerRun.pendingDelegatedReviewChildRunIds.push(childRun.id);
1678
- managerRun.events.push((0, hosts_1.createHubEvent)('system', `Delegated deliverable queued for Mandy review because ${reason}.`));
1679
- }
1680
- childRun.events.push((0, hosts_1.createHubEvent)('system', `Deliverable submitted to Mandy; review is queued because ${reason}.`));
1681
- this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1682
- this.persistRunConversation(childRun);
1683
- }
1684
- drainPendingDelegatedReviews(managerRun) {
1685
- if (managerRun.status === 'running' || !managerRun.sessionId)
1686
- return;
1687
- const queue = managerRun.pendingDelegatedReviewChildRunIds || [];
1688
- while (queue.length > 0) {
1689
- const childRunId = queue.shift();
1690
- const childRun = this.runRegistry.get(childRunId);
1691
- if (!childRun || childRun.managedReviewStatus === 'reviewed')
1692
- continue;
1693
- managerRun.pendingDelegatedReviewChildRunIds = queue;
1694
- this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1695
- this.notifyManagerOfDelegatedChild(managerRun.id, childRun);
1696
- return;
1697
- }
1698
- managerRun.pendingDelegatedReviewChildRunIds = queue;
1699
- this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1700
- }
1701
- markDelegatedChildSubmitted(managerRunId, childRun) {
1702
- const managerRun = this.runRegistry.get(managerRunId);
1703
- if (!managerRun?.delegation)
1704
- return;
1705
- const task = managerRun.delegation.tasks.find((entry) => entry.taskId === childRun.delegationTaskId || entry.runId === childRun.id);
1706
- if (!task)
1707
- return;
1708
- task.status = childRun.status === 'completed' ? 'submitted' : 'failed';
1709
- const latest = [...(childRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
1710
- if (latest)
1711
- task.latestSummary = latest;
1712
- if (childRun.reviewHandoff)
1713
- task.reviewHandoff = childRun.reviewHandoff;
1714
- const artifacts = childRun.reviewHandoff?.artifacts?.length ? childRun.reviewHandoff.artifacts : childRun.artifacts || [];
1715
- if (artifacts.length)
1716
- task.artifacts = artifacts;
1717
- this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1718
- }
1719
- markDelegatedChildReviewed(childRun, managerRun) {
1720
- const reviewText = [...(managerRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
1721
- childRun.managedReviewStatus = 'reviewed';
1722
- childRun.updatedAt = new Date().toISOString();
1723
- if (reviewText) {
1724
- childRun.messages.push((0, hosts_1.createHubMessage)('manager', `Mandy reviewed this workstream:\n\n${reviewText}`));
1725
- }
1726
- childRun.events.push((0, hosts_1.createHubEvent)('system', 'Mandy reviewed the delegated deliverable.'));
1727
- this.persistRunConversation(childRun);
1728
- const task = managerRun.delegation?.tasks.find((entry) => entry.taskId === childRun.delegationTaskId || entry.runId === childRun.id);
1729
- if (task) {
1730
- task.status = 'reviewed';
1731
- task.latestSummary = reviewText || task.latestSummary;
1732
- if (childRun.reviewHandoff)
1733
- task.reviewHandoff = childRun.reviewHandoff;
1734
- const artifacts = childRun.reviewHandoff?.artifacts?.length ? childRun.reviewHandoff.artifacts : childRun.artifacts || [];
1735
- if (artifacts.length)
1736
- task.artifacts = artifacts;
1737
- }
1738
- this.maybeStartDelegatedChildRuns(managerRun);
1739
- }
1740
- continueManagerRunForDelegation(managerRun, instructions, display, reviewedChildRun) {
1741
- if (!managerRun.sessionId || managerRun.status === 'running')
1742
- return;
1743
- const prepared = this.prepareContinueMessage(managerRun, instructions);
1744
- this.runRegistry.update(managerRun.id, (current) => {
1745
- current.status = 'running';
1746
- current.messages.push((0, hosts_1.createHubMessage)('manager', display));
1747
- current.events.push((0, hosts_1.createHubEvent)('system', 'Delegated child output routed back to Mandy for review.'));
1748
- });
1749
- const started = this.runRegistry.get(managerRun.id);
1750
- if (started)
1751
- this.persistRunConversation(started, started.conversationId || started.id);
1752
- this.runRegistry.create(managerRun, {});
1753
- const child = this.hostRuntime.continueRun(managerRun.hostId, managerRun.projectPath, managerRun.sessionId, prepared.message, {
1754
- onEvent: (event, channel) => {
1755
- this.runRegistry.update(managerRun.id, (current) => {
1756
- if (event.sessionId)
1757
- current.sessionId = event.sessionId;
1758
- appendHostMessage(current, managerRun.hostId, event, channel);
1759
- if (event.raw) {
1760
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
1761
- applyReviewProjection(current, event.raw);
1762
- }
1763
- if (event.agentIdentity)
1764
- applyAgentIdentitySignal(current, event.agentIdentity);
1765
- if (event.fraimJob)
1766
- this.applyFraimJobSignalToRun(current, event.fraimJob);
1767
- if (event.seekMentoring)
1768
- this.applySeekMentoringSignalToRun(current, event.seekMentoring);
1769
- if (event.usage)
1770
- applyUsageSignal(current, event.usage);
1771
- });
1772
- const updated = this.runRegistry.get(managerRun.id);
1773
- if (updated)
1774
- this.persistRunConversation(updated, updated.conversationId || updated.id);
1775
- },
1776
- onExit: (exitCode) => {
1777
- this.runRegistry.update(managerRun.id, (current) => {
1778
- current.exitCode = exitCode;
1779
- current.status = exitCode === 0 ? 'completed' : 'failed';
1780
- current.events.push((0, hosts_1.createHubEvent)('system', `Mandy review turn exited with code ${exitCode ?? 'unknown'}.`));
1781
- });
1782
- const updated = this.runRegistry.get(managerRun.id);
1783
- if (updated) {
1784
- if (exitCode === 0 && reviewedChildRun)
1785
- this.markDelegatedChildReviewed(reviewedChildRun, updated);
1786
- this.persistRunConversation(updated, updated.conversationId || updated.id);
1787
- }
1788
- this.runRegistry.dispose(managerRun.id);
1789
- const latestManager = this.runRegistry.get(managerRun.id);
1790
- if (latestManager)
1791
- this.drainPendingDelegatedReviews(latestManager);
1792
- },
1793
- });
1794
- this.runRegistry.attachChildIfRunning(managerRun.id, child);
1795
- }
1796
- computeFirstRun(projectPath, jobCount, personas) {
1797
- const tc = (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
1798
- // company = any organization-layer context written by org onboarding.
1799
- const companyDone = tc.orgContext.present || tc.managerContext.present || tc.orgRules.present || tc.managerRules.present;
1800
- // hire = any persona hired (entitlement active).
1801
- const hireDone = personas.some((p) => p.status === 'hired');
1802
- // project = a project brief exists OR the workspace has FRAIM jobs set up.
1803
- const projectDone = tc.projectContext.present || tc.projectBrief.present || tc.projectRules.present || tc.projectQa.present || jobCount > 0;
1804
- const derived = {
1805
- install: true, // the Hub is running, so the toolchain install step is satisfied.
1806
- company: companyDone,
1807
- hire: hireDone,
1808
- project: projectDone,
1809
- };
1810
- const persisted = readPersistedFirstRun();
1811
- return {
1812
- install: persisted.install ?? derived.install,
1813
- company: persisted.company || derived.company,
1814
- hire: persisted.hire || derived.hire,
1815
- project: persisted.project || derived.project,
1816
- };
1817
- }
1818
- // Issue #512 (S3, R3) — presence + display paths of the three-layer context
1819
- // files. Reuses the learning-context-builder resolvers (same layering as the
1820
- // auto-load Team Context block).
1821
- computeTeamContext(projectPath) {
1822
- return (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
1823
- }
1824
- // Issue #512 (S3, R14) — Brain summary: preserved-learning counts by scope +
1825
- // registry catalog counts. A read projection; no new storage.
1826
- // Issue #750: `userEmail` is the config.json-resolved identity, or `null` when
1827
- // not connected. `organization`/`rawSignals` are identity-independent (org-*
1828
- // files and repo-level raw signals) and must always be counted regardless of
1829
- // connection state — only `manager`/`project` (L1, individual-keyed) are
1830
- // zeroed when not connected, rather than guessing an identity via
1831
- // resolveLearningUserId's single-stamped-user fallback (a legitimate
1832
- // convenience for MCP auto-load context, but not appropriate here).
1833
- computeBrain(projectPath, jobCount, userEmail) {
1834
- const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, userEmail || '');
1835
- if (!userEmail) {
1836
- learnings.manager = 0;
1837
- learnings.project = 0;
1838
- }
1839
- return {
1840
- learnings,
1841
- catalog: {
1842
- jobs: jobCount,
1843
- skills: countMarkdownFilesRecursive(path_1.default.join(projectPath, 'registry', 'skills')) +
1844
- countMarkdownFilesRecursive(path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), 'ai-employee', 'skills')),
1845
- rules: countMarkdownFilesRecursive(path_1.default.join(projectPath, 'registry', 'rules')) +
1846
- countMarkdownFilesRecursive(path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), 'ai-employee', 'rules')),
1847
- },
1848
- };
1849
- }
1850
- resolveHubJob(projectPath, jobId) {
1851
- if (!jobId || jobId === '__freeform__')
1852
- return null;
1853
- const employeeJob = (0, catalog_1.discoverEmployeeJobs)(projectPath).find((job) => job.id === jobId);
1854
- if (employeeJob) {
1855
- return {
1856
- id: employeeJob.id,
1857
- title: employeeJob.title,
1858
- stubPath: employeeJob.stubPath,
1859
- personaKey: employeeJob.requiredPersonaKey ?? getProtectedPersonaForHubJob(employeeJob.id),
1860
- };
1861
- }
1862
- const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath).find((job) => job.id === jobId);
1863
- if (managerTemplate) {
1864
- return {
1865
- id: managerTemplate.id,
1866
- title: managerTemplate.title,
1867
- stubPath: managerTemplate.stubPath,
1868
- personaKey: getProtectedPersonaForHubJob(managerTemplate.id),
1869
- };
1870
- }
1871
- return null;
1872
- }
1873
- applySeekMentoringSignalToRun(run, signal) {
1874
- // Issue #732: promote using the stable jobName slug, not the per-call UUID
1875
- // jobId (resolveHubJob would never match a UUID, leaving a freeform run
1876
- // unpromoted and then dropping its phase signals).
1877
- this.maybePromoteFreeformRunToJob(run, signal.jobName || signal.jobId);
1878
- applySeekMentoringSignal(run, signal);
1879
- }
1880
- applyFraimJobSignalToRun(run, signal) {
1881
- this.maybePromoteFreeformRunToJob(run, signal.jobId);
1882
- }
1883
- maybePromoteFreeformRunToJob(run, signalJobId) {
1884
- const normalizedJobId = (signalJobId || '').trim().toLowerCase();
1885
- if (run.jobId !== '__freeform__' || !normalizedJobId)
1886
- return;
1887
- const metadata = this.resolveHubJob(run.projectPath, normalizedJobId);
1888
- if (!metadata)
1889
- return;
1890
- run.jobId = metadata.id;
1891
- run.jobTitle = metadata.title;
1892
- run.personaKey = metadata.personaKey;
1893
- run.updatedAt = new Date().toISOString();
1894
- run.events.push((0, hosts_1.createHubEvent)('system', `Recognized FRAIM job ${metadata.id} from structured telemetry.`));
1895
- }
1896
- // Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
1897
- // content) export routes so a conversational deliverable with no on-disk file
1898
- // can still be downloaded for Word annotation.
1899
- prepareStartPayload(projectPath, hostId, selectedJobId, instructions) {
1900
- const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
1901
- const resolvedJobId = explicit?.jobId || selectedJobId;
1902
- if (!resolvedJobId) {
1903
- throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
1904
- }
1905
- // `display` is the manager conversation bubble. It keeps the user's full
1906
- // coaching text with the host-specific FRAIM invocation. The agent payload
1907
- // still receives Hub-only helper blocks such as `[FRAIM shared browser]`
1908
- // and `[How to talk to me]`; those are omitted from the visible bubble.
1909
- // #521: the shared-browser guidance is injected HERE, at the Hub layer — never
1910
- // baked into the registry job/skill. It only appears when a shared browser is
1911
- // available (env published at boot).
1912
- const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
1913
- const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
1914
- if (resolvedJobId === '__freeform__') {
1915
- const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
1916
- return {
1917
- jobId: resolvedJobId,
1918
- message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions) + browserNote + styleNote,
1919
- display,
1920
- };
1921
- }
1922
- const resolvedJob = this.resolveHubJob(projectPath, resolvedJobId);
1923
- const absoluteStubPath = resolvedJob?.stubPath
1924
- ? [projectPath, resolvedJob.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
1925
- : undefined;
1926
- const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
1927
- return {
1928
- jobId: resolvedJobId,
1929
- message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath) + browserNote + styleNote,
1930
- display,
1931
- };
1932
- }
1933
- prepareContinueMessage(run, instructions, coachingJobId) {
1934
- // coachingJobId is set when the user selected a manager coaching template
1935
- // (e.g. follow-your-mentor). When present, it overrides the run's own jobId
1936
- // as the target of the FRAIM invocation. The server picks the correct
1937
- // invocation prefix ($fraim / /fraim) based on run.hostId — the UI never
1938
- // passes raw invocation syntax.
1939
- const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
1940
- const effectiveJobId = explicit?.jobId || coachingJobId || run.jobId;
1941
- const userText = (explicit?.remainder || instructions || '').trim();
1942
- // Issue #732: a plain continue of the SAME active real job must not re-load
1943
- // the job via get_fraim_job on every coaching turn — the host session is
1944
- // resumed with the job already in context, so re-fetching is wasted latency
1945
- // and tokens (the reported hub slowness) and mints a fresh UUID job id each
1946
- // time. Only load the job when the continue SWITCHES jobs (a coaching
1947
- // template or an explicit /fraim <other>). Freeform/adhoc runs keep the
1948
- // existing buildManagerMessage path (which already emits no invocation).
1949
- const switchesJob = effectiveJobId !== run.jobId;
1950
- const showsInvocation = switchesJob || run.jobId === '__freeform__';
1951
- // Issue #756: the manager bubble must MIRROR what actually happens. A
1952
- // same-job coaching continue resumes the active job WITHOUT re-invoking it
1953
- // (the `message` below carries no `/fraim`), so the bubble shows only what
1954
- // the manager SAID — never a `/fraim <same-job>` line, which reads as a full
1955
- // re-run of the active job Y and misled managers into thinking coaching
1956
- // restarts the workflow. Only a real job SWITCH actually issues an
1957
- // invocation, so only then does the bubble lead with the command. This still
1958
- // upholds the #730 guarantee: the manager's coaching text is never dropped,
1959
- // and when a command IS issued it appears in front of that text.
1960
- // The invocation form (`/fraim <job>` + words) is built once and reused for
1961
- // both the bubble and the agent payload when a command is actually issued.
1962
- const invocationForm = showsInvocation
1963
- ? (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions)
1964
- : null;
1965
- // Bubble: the command form for a real switch, else the manager's own words.
1966
- const display = invocationForm ?? userText;
1967
- // Agent payload: the same command form for a switch, else a lightweight
1968
- // same-job continue; the communication-style note is agent-only.
1969
- const message = (invocationForm ?? (0, manager_turns_1.buildSameJobContinueMessage)(userText)) + (0, manager_turns_1.buildCommunicationStyleNote)();
1970
- return { message, display };
1971
- }
1972
- async computePersonas(apiKey) {
1973
- const allBundles = listHubPersonaBundles();
1974
- const fallbackPersonas = allBundles.map((bundle) => ({
1975
- key: bundle.personaKey,
1976
- displayName: bundle.catalogMetadata.displayName,
1977
- role: bundle.catalogMetadata.role,
1978
- avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
1979
- pricingLabel: bundle.catalogMetadata.pricingLabel,
1980
- status: 'locked',
1981
- hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
1982
- seatCount: 0,
1983
- seatsInUse: 0,
1984
- }));
1985
- try {
1986
- // Issue #701: persona state comes from the hosted server (GET /api/personas/me)
1987
- // via the user's API key — never a local MongoDB connection.
1988
- const state = await this.remoteGateway.getPersonaState(apiKey);
1989
- // A null result means we could not reach the authority (no/expired key or a
1990
- // transport error). That is the ONLY access decision the Hub makes on its own —
1991
- // render the locked "not-signed-in" fallback. When a state IS returned, its
1992
- // per-persona `status` is authoritative and rendered verbatim: the hired/locked
1993
- // decision (feature-off, legacy bypass, per-entitlement gating) lives solely in
1994
- // persona-entitlement-service.resolvePersonaAccessStatuses — the Hub does not
1995
- // re-derive it.
1996
- if (!state) {
1997
- return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
1998
- }
1999
- const verdictByKey = new Map((state.personas || []).map((p) => [p.personaKey, p]));
2000
- // seatsInUse (manager-team assignments) is display-only accounting fetched
2001
- // separately; it is not part of the access verdict.
2002
- const seatsInUseByKey = {};
2003
- const team = await this.remoteGateway.listManagerTeam(apiKey);
2004
- for (const entry of team) {
2005
- seatsInUseByKey[entry.personaKey] = (seatsInUseByKey[entry.personaKey] ?? 0) + 1;
2006
- }
2007
- const personas = allBundles.map((bundle) => {
2008
- const verdict = verdictByKey.get(bundle.personaKey);
2009
- const status = (verdict?.status ?? 'locked');
2010
- return {
2011
- key: bundle.personaKey,
2012
- displayName: bundle.catalogMetadata.displayName,
2013
- role: bundle.catalogMetadata.role,
2014
- avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
2015
- pricingLabel: status === 'hired' ? '' : bundle.catalogMetadata.pricingLabel,
2016
- status,
2017
- hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
2018
- seatCount: verdict?.seatCount ?? 0,
2019
- seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
2020
- };
2021
- });
2022
- return { personas, subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey: state.userId ?? null };
2023
- }
2024
- catch (err) {
2025
- console.error('[ai-hub] persona lookup failed:', err);
2026
- return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
2027
- }
2028
- }
2029
- async computeManagerTeam(apiKey) {
2030
- // Issue #701: manager team comes from the hosted server, not local Mongo.
2031
- return this.remoteGateway.listManagerTeam(apiKey);
2032
- }
2033
- // Issue #750: identity for routes that don't already call computePersonas()
2034
- // (which fetches this same hosted state for personas/manager-team). Always
2035
- // sources the apiKey from ~/.fraim/config.json; null means "not connected" —
2036
- // no local-guess fallback.
2037
- async resolveHubIdentity() {
2038
- const apiKey = resolveApiKey();
2039
- if (!apiKey)
2040
- return null;
2041
- const state = await this.remoteGateway.getPersonaState(apiKey);
2042
- return state?.userId ?? null;
2043
- }
2044
- // Issue #750: shared by the two learnings routes below. 'org' scope never
2045
- // needs identity (readPreservedLearnings/applyLearningEntryChange ignore the
2046
- // userId param for it); every other scope resolves through resolveHubIdentity,
2047
- // returning '' (not a guess) when not connected — callers distinguish
2048
- // "org" from "not connected" via the scope check they already have to make.
2049
- async resolveLearningIdentity(scope) {
2050
- if (scope === 'org')
2051
- return '';
2052
- return (await this.resolveHubIdentity()) || '';
2053
- }
2054
- registerRoutes() {
2055
- // Issue #512 / #749: the account and analytics surfaces live outside /ai-hub. The
2056
- // on-machine Hub holds no DB, so it always redirects these (and /auth) to the hosted
2057
- // server, which owns the authenticated, DB-backed pages.
2058
- this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
2059
- const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
2060
- if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
2061
- const rawSurface = parsed.searchParams.get('surface');
2062
- const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
2063
- ? rawSurface
2064
- : 'analytics';
2065
- parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
2066
- }
2067
- res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
2068
- });
2069
- this.app.get(['/account', '/account/'], (req, res) => {
2070
- res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
2071
- });
2072
- this.app.get(['/analytics', '/analytics/'], (req, res) => {
2073
- res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
2074
- });
2075
- // Issue #478: Serve the PowerPoint task pane HTML and manifest.
2076
- // Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
2077
- // request, so we must strip them before resolving the file path. Use a custom
2078
- // route rather than express.static so we can apply the new URL().pathname fix.
2079
- this.app.get(/^\/powerpoint-taskpane(\/.*)?$/, (req, res) => {
2080
- const taskpaneDir = resolveTaskpaneDir('powerpoint-taskpane');
2081
- // Strip query string — Office appends ?_host_Info=PowerPoint$... to every fetch.
2082
- const { pathname } = new URL(req.url, 'http://localhost');
2083
- // Map root /powerpoint-taskpane/ to index.html
2084
- const relativePath = pathname.replace(/^\/powerpoint-taskpane\/?/, '') || 'index.html';
2085
- const filePath = path_1.default.join(taskpaneDir, relativePath);
2086
- if (!filePath.startsWith(taskpaneDir)) {
2087
- // Path traversal guard
2088
- res.status(403).end();
2089
- return;
2090
- }
2091
- const ext = path_1.default.extname(filePath).toLowerCase();
2092
- const contentTypeMap = {
2093
- '.html': 'text/html; charset=utf-8',
2094
- '.xml': 'application/xml',
2095
- '.js': 'application/javascript',
2096
- '.css': 'text/css',
2097
- '.png': 'image/png',
2098
- };
2099
- const contentType = contentTypeMap[ext] || 'text/plain';
2100
- fs_1.default.readFile(filePath, (err, data) => {
2101
- if (err) {
2102
- res.status(404).end('Not found');
2103
- return;
2104
- }
2105
- res.setHeader('Content-Type', contentType);
2106
- res.setHeader('Access-Control-Allow-Origin', '*');
2107
- res.end(data);
2108
- });
2109
- });
2110
- this.app.get('/api/ai-hub/bootstrap', async (req, res) => {
2111
- let projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2112
- ? req.query.projectPath
2113
- : null;
2114
- // If no explicit projectPath but a file:// docUrl was passed (Word Desktop),
2115
- // derive the project directory from the document's local path.
2116
- if (!projectPath && typeof req.query.docUrl === 'string' && req.query.docUrl.startsWith('file://')) {
2117
- try {
2118
- const fileUrl = new URL(req.query.docUrl);
2119
- const rawPath = process.platform === 'win32'
2120
- ? fileUrl.pathname.replace(/^\/([A-Za-z]:)/, '$1')
2121
- : fileUrl.pathname;
2122
- projectPath = path_1.default.dirname(decodeURIComponent(rawPath));
2123
- }
2124
- catch { }
2125
- }
2126
- // #719: a bare reload (no projectPath query) must land on the last-recorded
2127
- // workspace project, not the launch folder. The bootstrap saves its resolved
2128
- // current path back into the projects list, so defaulting to the launch
2129
- // folder would resurrect a removed launch-folder project on every reload
2130
- // (tfSwitchProjectFolder already documents the recorded folder as "the
2131
- // default on next load"). The launch folder stays the fallback when nothing
2132
- // is recorded or the recorded folder no longer exists.
2133
- if (!projectPath) {
2134
- const recorded = this.preferencesStore.load(this.projectPath).projectPath;
2135
- if (recorded && path_1.default.resolve(recorded) !== path_1.default.resolve(this.projectPath) && fs_1.default.existsSync(recorded)) {
2136
- projectPath = recorded;
2137
- }
2138
- }
2139
- res.json(await this.bootstrapResponse(projectPath || this.projectPath));
2140
- });
2141
- // Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
2142
- // same projection folded into bootstrap. Useful for the avatar→Brain view
2143
- // without re-fetching the whole bootstrap payload.
2144
- this.app.get('/api/ai-hub/brain', async (req, res) => {
2145
- const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2146
- ? path_1.default.resolve(req.query.projectPath)
2147
- : this.projectPath;
2148
- const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath).length + (0, catalog_1.discoverManagerTemplates)(projectPath).length;
2149
- const userEmail = await this.resolveHubIdentity();
2150
- return res.json(this.computeBrain(projectPath, jobCount, userEmail));
2151
- });
2152
- // #533: read the PRESERVED learnings for a section + storage level so the
2153
- // Company/Manager sections (machine level) and the project workspace (project
2154
- // level) can DISPLAY and edit them.
2155
- const VALID_SCOPES = ['org', 'manager', 'reverse'];
2156
- const VALID_LEVELS = ['machine', 'project'];
2157
- const VALID_CATEGORIES = ['avoid', 'preference', 'repeat', 'coaching'];
2158
- const resolveProjectPath = (req) => typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2159
- ? path_1.default.resolve(req.query.projectPath)
2160
- : (typeof (req.body && req.body.projectPath) === 'string' && req.body.projectPath.length > 0
2161
- ? path_1.default.resolve(req.body.projectPath)
2162
- : this.projectPath);
2163
- this.app.get('/api/ai-hub/learnings', async (req, res) => {
2164
- const scope = req.query.scope;
2165
- if (typeof scope !== 'string' || !VALID_SCOPES.includes(scope)) {
2166
- return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
2167
- }
2168
- const level = (typeof req.query.level === 'string' && VALID_LEVELS.includes(req.query.level))
2169
- ? req.query.level : 'machine';
2170
- try {
2171
- const userEmail = await this.resolveLearningIdentity(scope);
2172
- if (scope !== 'org' && !userEmail) {
2173
- return res.json({ scope, level, entries: [] });
2174
- }
2175
- const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), userEmail, scope, level);
2176
- return res.json({ scope, level, entries });
2177
- }
2178
- catch (error) {
2179
- return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not read learnings.' });
2180
- }
2181
- });
2182
- // #533 §3: add / edit / delete a single learning entry — writes the real file
2183
- // at the section's level (machine for the Manager/Company tabs, project for the
2184
- // project workspace). The action targets exactly the card's file.
2185
- this.app.post('/api/ai-hub/learnings/entry', async (req, res) => {
2186
- const b = (req.body || {});
2187
- if (!['add', 'edit', 'delete'].includes(b.action || '')) {
2188
- return res.status(400).json({ error: 'action must be one of: add, edit, delete' });
2189
- }
2190
- if (!b.scope || !VALID_SCOPES.includes(b.scope))
2191
- return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
2192
- if (!b.category || !VALID_CATEGORIES.includes(b.category))
2193
- return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
2194
- const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
2195
- const ref = { scope: b.scope, level, category: b.category };
2196
- // Issue #750: writes to a personal-scope file need a resolved identity —
2197
- // fail explicitly rather than writing to a guessed or empty-string file.
2198
- const userEmail = await this.resolveLearningIdentity(b.scope);
2199
- if (b.scope !== 'org' && !userEmail) {
2200
- return res.status(400).json({ error: 'not_connected', message: 'No FRAIM identity resolved. Run `fraim setup` first.' });
2201
- }
2202
- try {
2203
- const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), userEmail, ref, b.action, {
2204
- originalTitle: b.originalTitle,
2205
- severity: b.severity,
2206
- title: b.title,
2207
- body: b.body,
2208
- });
2209
- return res.json({ ok: true, path: result.path });
2210
- }
2211
- catch (error) {
2212
- return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
2213
- }
2214
- });
2215
- // Issue #540/#701/#750: POST /api/ai-hub/manager-team/assign
2216
- // Proxies to the hosted server (which owns the DB + seat enforcement), using
2217
- // the Hub's own ~/.fraim/config.json apiKey — not a caller-supplied header.
2218
- // Hosted returns 404 no_company_seat / 409 out_of_stock; those are passed through.
2219
- this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
2220
- const { personaKey } = (req.body ?? {});
2221
- if (!personaKey)
2222
- return res.status(400).json({ error: 'personaKey required' });
2223
- const result = await this.remoteGateway.assignManagerTeam(resolveApiKey(), personaKey);
2224
- return res.status(result.status).json(result.body);
2225
- });
2226
- // Issue #540/#701/#750: DELETE /api/ai-hub/manager-team/assign/:personaKey
2227
- // Proxies the seat release to the hosted server.
2228
- this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
2229
- const { personaKey } = req.params;
2230
- await this.remoteGateway.removeManagerTeam(resolveApiKey(), personaKey);
2231
- return res.status(204).end();
2232
- });
2233
- // Issue #708: manager/company scopes resolve to a project-independent sentinel bucket
2234
- // key (which must bypass path/dir resolution); everything else keys by project path.
2235
- const scopeParam = (raw) => (raw === 'manager' || raw === 'company') ? raw : undefined;
2236
- this.app.get('/api/ai-hub/conversations', (req, res) => {
2237
- const scope = scopeParam(req.query.scope);
2238
- const key = scope
2239
- ? (0, conversation_store_1.conversationScopeKey)(scope, '')
2240
- : (typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2241
- ? path_1.default.resolve(req.query.projectPath)
2242
- : this.projectPath);
2243
- const loaded = this.conversationStore.loadProject(key);
2244
- return res.json({ projectPath: key, scope: scope ?? 'project', ...loaded, source: 'disk' });
2245
- });
2246
- this.app.put('/api/ai-hub/conversations', (req, res) => {
2247
- try {
2248
- const body = (req.body ?? {});
2249
- const scope = scopeParam(body.scope);
2250
- const key = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(body.projectPath || this.projectPath);
2251
- if (!Array.isArray(body.conversations)) {
2252
- return res.status(400).json({ error: 'conversations array required' });
2253
- }
2254
- // The client strips server-owned heavy fields (messages/events/artifacts/run/
2255
- // delegation) from its PUT payload to stay under the body-size limit. Merge them
2256
- // back from the stored record so a client-owned change (e.g. reviewApproved) never
2257
- // erases run history. List membership still comes from the incoming array, so
2258
- // deletes are honored (a conversation absent here is dropped).
2259
- const prior = this.conversationStore.loadProject(key);
2260
- const priorById = new Map(prior.conversations.map((entry) => [entry.id, entry]));
2261
- // Write-boundary guard for the cross-project leak (docs/rca/hub-conversation-cross-project-leak.md):
2262
- // a project PUT persists the client's current-project list, but that list can transiently
2263
- // include a conversation belonging to ANOTHER project (e.g. the just-finished run of the
2264
- // project the user switched away from). Never file such a record into this project bucket —
2265
- // its authoritative home is its own project bucket. Scope (manager/company) PUTs are exempt:
2266
- // their sentinel buckets legitimately hold records whose projectPath points at a project.
2267
- const belongsInBucket = (incoming) => {
2268
- if (scope)
2269
- return true;
2270
- const incomingScope = incoming.scope
2271
- ?? incoming.invokedArea;
2272
- if (incomingScope === 'manager' || incomingScope === 'company')
2273
- return false;
2274
- const own = incoming && typeof incoming.projectPath === 'string' ? incoming.projectPath : '';
2275
- if (!own)
2276
- return true;
2277
- return normalizedDirectoryPath(own) === normalizedDirectoryPath(key);
2278
- };
2279
- const conversations = body.conversations
2280
- .filter((incoming) => belongsInBucket(incoming))
2281
- .map((incoming) => {
2282
- const existing = incoming && incoming.id ? priorById.get(incoming.id) : undefined;
2283
- return existing ? { ...existing, ...incoming } : incoming;
2284
- });
2285
- const saved = this.conversationStore.replaceProject(key, {
2286
- activeId: body.activeId ?? null,
2287
- conversations,
2288
- });
2289
- return res.json({ projectPath: key, scope: scope ?? 'project', ...saved, source: 'disk' });
2290
- }
2291
- catch (error) {
2292
- return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversations.' });
2293
- }
2294
- });
2295
- this.app.patch('/api/ai-hub/conversations/:conversationId', (req, res) => {
2296
- try {
2297
- const body = (req.body ?? {});
2298
- const scope = scopeParam(body.scope);
2299
- const projectPath = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(body.projectPath || this.projectPath);
2300
- const saved = this.conversationStore.patchConversation(projectPath, req.params.conversationId, body);
2301
- if (body.activeId !== undefined) {
2302
- const withActive = this.conversationStore.replaceProject(projectPath, { ...saved, activeId: body.activeId });
2303
- return res.json({ projectPath, ...withActive, source: 'disk' });
2304
- }
2305
- return res.json({ projectPath, ...saved, source: 'disk' });
2306
- }
2307
- catch (error) {
2308
- return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversation.' });
2309
- }
2310
- });
2311
- this.app.get('/api/ai-hub/projects', (req, res) => {
2312
- const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2313
- ? path_1.default.resolve(req.query.projectPath)
2314
- : this.projectPath;
2315
- return res.json({ projectPath, projects: this.knownProjects(projectPath), source: 'disk' });
2316
- });
2317
- this.app.put('/api/ai-hub/projects', (req, res) => {
2318
- try {
2319
- const body = (req.body ?? {});
2320
- const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
2321
- if (!Array.isArray(body.projects)) {
2322
- return res.status(400).json({ error: 'projects array required' });
2323
- }
2324
- const projects = this.preferencesStore.saveProjects(projectPath, [...this.knownProjects(projectPath), ...body.projects], { reviveRemovedPaths: Array.isArray(body.reviveRemovedPaths) ? body.reviveRemovedPaths : [] });
2325
- return res.json({ projectPath, projects, source: 'disk' });
2326
- }
2327
- catch (error) {
2328
- return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
2329
- }
2330
- });
2331
- // Issue #719: DELETE /api/ai-hub/projects/:id — remove a project from the Hub.
2332
- // PUT is a merge by design and cannot express removal, so removal gets its own
2333
- // route (same convention as /schedules/:id, /webhooks/:id, /hosts/:id). Deletes
2334
- // every server-side derivation layer — preferences entry, conversation-store
2335
- // KEY, project-scoped deployments — so the project cannot resurrect (R3/R6/R9).
2336
- // Never touches anything under the project's folderPath (R8).
2337
- this.app.delete('/api/ai-hub/projects/:id', (req, res) => {
2338
- const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2339
- ? path_1.default.resolve(req.query.projectPath)
2340
- : this.projectPath;
2341
- const known = this.knownProjects(projectPath);
2342
- // Match by id, falling back to the canonical folderPath. The client and server
2343
- // hash a path to an id with different algorithms, and the client mints its own id
2344
- // for the CURRENT project (tfEnsureCurrentProject), so a delete-by-id can carry an
2345
- // id the server never assigned — the folderPath is the true project identity (#726).
2346
- const requestedFolderPath = typeof req.query.folderPath === 'string' && req.query.folderPath.length > 0
2347
- ? req.query.folderPath
2348
- : null;
2349
- const entry = known.find((project) => project.id === req.params.id)
2350
- || (requestedFolderPath ? known.find((project) => sameDirectoryPath(project.folderPath, requestedFolderPath)) : undefined);
2351
- if (!entry)
2352
- return res.status(404).json({ error: 'Project not found.' });
2353
- // The current projectPath is unconditionally re-injected on every load, so
2354
- // deleting it would resurrect on the next request — reject as a sequencing
2355
- // error; the client switches workspaces before deleting (R5/D4).
2356
- if (sameDirectoryPath(entry.folderPath, projectPath)) {
2357
- return res.status(409).json({ error: 'Cannot remove the current project. Switch to another project first.' });
2358
- }
2359
- // A live schedule/webhook would fire later, write a conversation under the
2360
- // removed folderPath, and resurrect the project in the background (R6).
2361
- for (const deployment of this.deploymentStore.load()) {
2362
- if (!deploymentBelongsToProject(deployment, entry.folderPath, this.projectPath))
2363
- continue;
2364
- const task = this.cronHandles.get(deployment.id);
2365
- if (task) {
2366
- task.stop();
2367
- this.cronHandles.delete(deployment.id);
2368
- }
2369
- this.deploymentStore.delete(deployment.id);
2370
- }
2371
- // Delete the conversation-store KEY — an empty list would still re-derive
2372
- // the project via listProjectPaths (R9).
2373
- this.conversationStore.removeProject(entry.folderPath);
2374
- // Persist the filtered list with a tombstone: a normal project save
2375
- // still merges derived projects and cannot express removal.
2376
- const projects = this.preferencesStore.removeProject(projectPath, known.filter((project) => project.id !== entry.id), entry.folderPath);
2377
- return res.json({ ok: true, projects });
2378
- });
2379
- // Issue #750: POST /api/ai-hub/api-key is removed — there is no Hub-local
2380
- // apiKey to persist. Identity always comes from ~/.fraim/config.json
2381
- // (set by `fraim setup`), read fresh via resolveApiKey() on every request.
2382
- this.app.post('/api/ai-hub/preferences', (req, res) => {
2383
- const { personaKey } = req.body;
2384
- const prefs = this.preferencesStore.load(this.projectPath);
2385
- this.preferencesStore.save({ ...prefs, personaKey: personaKey ?? null });
2386
- return res.json({ ok: true });
2387
- });
2388
- // #755: running build version + best-effort latest published version, so the
2389
- // account menu can show the version and flag when an update is available. The
2390
- // `fraim hub` CLI also queries this to confirm a running instance's version.
2391
- this.app.get('/api/ai-hub/version', async (_req, res) => {
2392
- const version = (0, version_utils_1.getFraimVersion)();
2393
- const latest = await (0, hub_latest_version_1.getLatestPublishedVersion)();
2394
- const updateAvailable = !!(latest && semver.valid(version) && semver.valid(latest) && semver.gt(latest, version));
2395
- return res.json({ version, latest, updateAvailable });
2396
- });
2397
- this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
2398
- try {
2399
- const projectPath = await this.folderPicker();
2400
- if (!projectPath) {
2401
- // User cancelled the native dialog, or no interactive dialog was
2402
- // available (e.g. CI/headless). Front-end treats 204 as "no change".
2403
- return res.status(204).end();
2404
- }
2405
- return res.json({ path: projectPath });
2406
- }
2407
- catch (error) {
2408
- return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not open the folder picker.' });
2409
- }
2410
- });
2411
- // ── Issue #512 R3/R8: Team Context inline read/write ────────────────────
2412
- // GET /api/ai-hub/context[?key=<key>] → file content for the editor.
2413
- // With ?key=<one of eight>: { key, present, displayPath, scope, content }.
2414
- // Without key: { files: { <key>: {present, displayPath, scope, content} } }.
2415
- // POST /api/ai-hub/context { key, content } → persists to the correct
2416
- // destination (user-level for org/manager/orgRules/managerRules unless a repo-local
2417
- // override exists; repo-local for project*), creating parent dirs.
2418
- // Loopback-only (the Hub binds 127.0.0.1); writes are constrained to live
2419
- // under a personalized-employee/ directory (path-traversal guard).
2420
- const readContextFile = (projectPath, key) => {
2421
- const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, key);
2422
- let content = '';
2423
- if (loc.present && loc.readPath) {
2424
- try {
2425
- content = fs_1.default.readFileSync(loc.readPath, 'utf8');
2426
- }
2427
- catch {
2428
- content = '';
2429
- }
2430
- }
2431
- return {
2432
- key,
2433
- present: loc.present,
2434
- displayPath: loc.displayPath,
2435
- scope: loc.scope,
2436
- content,
2437
- };
2438
- };
2439
- this.app.get('/api/ai-hub/context', (req, res) => {
2440
- const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2441
- ? path_1.default.resolve(req.query.projectPath)
2442
- : this.projectPath;
2443
- const rawKey = typeof req.query.key === 'string' ? req.query.key : undefined;
2444
- if (rawKey !== undefined) {
2445
- if (!(0, learning_context_builder_1.isTeamContextKey)(rawKey)) {
2446
- return res.status(400).json({ error: `Unknown context key: ${rawKey}` });
2447
- }
2448
- return res.json(readContextFile(projectPath, rawKey));
2449
- }
2450
- const keys = ['org', 'manager', 'orgRules', 'managerRules', 'projectContext', 'projectBrief', 'projectRules', 'projectQa'];
2451
- const files = {};
2452
- for (const key of keys)
2453
- files[key] = readContextFile(projectPath, key);
2454
- return res.json({ files });
2455
- });
2456
- this.app.post('/api/ai-hub/context', (req, res) => {
2457
- const body = (req.body ?? {});
2458
- if (!(0, learning_context_builder_1.isTeamContextKey)(body.key)) {
2459
- return res.status(400).json({ error: 'key must be one of org|manager|orgRules|managerRules|projectContext|projectBrief|projectRules|projectQa.' });
2460
- }
2461
- if (typeof body.content !== 'string') {
2462
- return res.status(400).json({ error: 'content (string) is required.' });
2463
- }
2464
- const projectPath = typeof body.projectPath === 'string' && body.projectPath.length > 0
2465
- ? path_1.default.resolve(body.projectPath)
2466
- : this.projectPath;
2467
- const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, body.key);
2468
- if (loc.managedByOrgSync || loc.managedByManagerSync || !loc.writePath) {
2469
- // Enforcement only: block editing a synced org file (it would be
2470
- // overwritten on next sync). The how-to-change procedure lives in the
2471
- // organization-onboarding job, not in this error body (issue #563 review).
2472
- return res.status(409).json({
2473
- error: loc.managedByManagerSync
2474
- ? 'This manager file is managed by manager sync and is read-only here.'
2475
- : 'This organization file is managed by org sync and is read-only here.'
2476
- });
2477
- }
2478
- const dest = path_1.default.resolve(loc.writePath);
2479
- // Path-traversal guard: the resolved destination must live under a
2480
- // personalized-employee directory (covers both ~/.fraim/... and repo-local).
2481
- const segments = dest.split(path_1.default.sep);
2482
- if (!segments.includes('personalized-employee')) {
2483
- return res.status(403).json({ error: 'Write destination outside personalized-employee.' });
2484
- }
2485
- try {
2486
- fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
2487
- fs_1.default.writeFileSync(dest, body.content, 'utf8');
2488
- }
2489
- catch (err) {
2490
- return res.status(500).json({ error: err instanceof Error ? err.message : 'Write failed.' });
2491
- }
2492
- // Re-read so the client gets the canonical post-write state (present flips).
2493
- return res.json(readContextFile(projectPath, body.key));
2494
- });
2495
- // Issue #744 — Hub cobranding.
2496
- // GET /api/ai-hub/brand → { brand: OrgBrand | null } resolved from the org
2497
- // context storage (same layering as org_context.md).
2498
- // POST /api/ai-hub/brand { name?, color?, logo?, projectPath? } → sanitizes
2499
- // + normalizes (SVG allowlist, hex normalize) and persists org_brand.json to
2500
- // the org context write location. Loopback-only; write is constrained under
2501
- // a personalized-employee/ directory (same path-traversal guard as context).
2502
- this.app.get('/api/ai-hub/brand', (req, res) => {
2503
- const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2504
- ? path_1.default.resolve(req.query.projectPath)
2505
- : this.projectPath;
2506
- return res.json({ brand: (0, learning_context_builder_1.readOrgBrand)(projectPath) });
2507
- });
2508
- this.app.post('/api/ai-hub/brand', (req, res) => {
2509
- const body = (req.body ?? {});
2510
- const projectPath = typeof body.projectPath === 'string' && body.projectPath.length > 0
2511
- ? path_1.default.resolve(body.projectPath)
2512
- : this.projectPath;
2513
- const input = {
2514
- name: typeof body.name === 'string' ? body.name : undefined,
2515
- color: typeof body.color === 'string' ? body.color : undefined,
2516
- logo: typeof body.logo === 'string' ? body.logo : undefined,
2517
- };
2518
- const dest = path_1.default.resolve((0, learning_context_builder_1.resolveOrgBrandWriteDir)(projectPath));
2519
- // Path-traversal guard: the write dir must live under a personalized-employee
2520
- // directory (matches the context write guard; never the managed org cache).
2521
- if (!dest.split(path_1.default.sep).includes('personalized-employee')) {
2522
- return res.status(403).json({ error: 'Brand write destination outside personalized-employee.' });
2523
- }
2524
- try {
2525
- // writeBrandToDir sanitizes the logo (SVG allowlist) and normalizes the
2526
- // color before persisting, so unsafe input never reaches disk.
2527
- const stored = (0, brand_store_1.writeBrandToDir)(dest, input);
2528
- return res.json({ brand: stored });
2529
- }
2530
- catch (err) {
2531
- return res.status(500).json({ error: err instanceof Error ? err.message : 'Brand write failed.' });
2532
- }
2533
- });
2534
- this.app.post('/api/ai-hub/artifact/open', async (req, res) => {
2535
- const rawPath = typeof req.body?.path === 'string' ? req.body.path : '';
2536
- const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.length > 0
2537
- ? path_1.default.resolve(req.body.projectPath)
2538
- : this.projectPath;
2539
- if (!rawPath)
2540
- return res.status(400).json({ error: 'path is required.' });
2541
- const resolved = resolveSafeArtifactPath(rawPath, projectPath);
2542
- if (!resolved)
2543
- return res.status(403).json({ error: 'Path outside allowed roots.' });
2544
- if (!fs_1.default.existsSync(resolved))
2545
- return res.status(404).json({ error: 'File not found.' });
2546
- try {
2547
- await hubOpenFile(resolved);
2548
- return res.json({ ok: true, path: resolved });
2549
- }
2550
- catch (err) {
2551
- return res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to open artifact.' });
2552
- }
2553
- });
2554
- this.app.post('/api/ai-hub/install-agent', async (req, res) => {
2555
- const { hubId } = req.body;
2556
- if (!hubId)
2557
- return res.status(400).json({ error: 'hubId is required.' });
2558
- const option = hubAgentOption(hubId);
2559
- if (!option)
2560
- return res.status(400).json({ error: `Unknown agent: ${hubId}` });
2561
- try {
2562
- const prefix = (0, managed_agent_paths_1.getManagedNodeRoot)();
2563
- fs_1.default.mkdirSync(prefix, { recursive: true });
2564
- await hubRunProcess('npm', ['install', '-g', option.installPackage], { npm_config_prefix: prefix });
2565
- const ver = hubCommandVersion(option.launchCommand, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
2566
- if (!ver) {
2567
- throw new Error(`${option.label} install completed, but the CLI is not runnable from FRAIM's managed PATH.`);
2568
- }
2569
- // Issue #747: run `add-ide` for the newly installed agent so the FRAIM MCP is wired in
2570
- // and its first run works (previously the agent launched with no `fraim` MCP server).
2571
- const mcp = await configureFraimForHubAgent(hubId);
2572
- if (!mcp.configured) {
2573
- console.warn(`[ai-hub] install-agent: FRAIM add-ide did not run for ${option.label}: ${mcp.error || 'unknown reason'}`);
2574
- }
2575
- return res.json({
2576
- ok: true,
2577
- message: `${option.label} installed successfully.`,
2578
- needsLogin: true,
2579
- loginCommand: option.loginCommand,
2580
- loginHint: `Sign in to ${option.label} to activate it. A terminal window will open — complete sign-in there, then click "Check if Ready".`,
2581
- fraimConfigured: mcp.configured,
2582
- });
2583
- }
2584
- catch (error) {
2585
- const detail = error instanceof Error ? error.message : 'Unknown error';
2586
- return res.status(500).json({ ok: false, error: `Failed to install ${option.label}: ${detail}` });
2587
- }
2588
- });
2589
- this.app.post('/api/ai-hub/trigger-agent-login', (req, res) => {
2590
- const { hubId } = req.body;
2591
- if (!hubId)
2592
- return res.status(400).json({ error: 'hubId is required.' });
2593
- const option = hubAgentOption(hubId);
2594
- if (!option)
2595
- return res.status(400).json({ error: `Unknown agent: ${hubId}` });
2596
- try {
2597
- hubOpenTerminal(buildManagedLoginCommand(option.loginCommand));
2598
- return res.json({
2599
- ok: true,
2600
- message: `A terminal window opened with the ${option.label} sign-in command. Complete sign-in there, then return here.`,
2601
- });
2602
- }
2603
- catch (error) {
2604
- const detail = error instanceof Error ? error.message : 'Unknown error';
2605
- return res.json({
2606
- ok: false,
2607
- message: `Could not open a terminal automatically: ${detail}. Run \`${option.loginCommand}\` in a terminal to sign in.`,
2608
- });
2609
- }
2610
- });
2611
- this.app.post('/api/ai-hub/check-agent', (req, res) => {
2612
- const { hubId } = req.body;
2613
- if (!hubId)
2614
- return res.status(400).json({ error: 'hubId is required.' });
2615
- const option = hubAgentOption(hubId);
2616
- if (!option)
2617
- return res.status(400).json({ error: `Unknown agent: ${hubId}` });
2618
- const ver = hubCommandVersion(option.launchCommand, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
2619
- if (ver) {
2620
- return res.json({ ok: true, ready: true, message: `${option.label} is ready.` });
2621
- }
2622
- return res.json({
2623
- ok: true,
2624
- ready: false,
2625
- message: `${option.label} is not detected yet. Make sure sign-in is complete and try again.`,
2626
- });
2627
- });
2628
- this.app.post('/api/ai-hub/runs', (req, res) => {
2629
- try {
2630
- const projectPath = ensureDirectoryPath(req.body.projectPath || this.projectPath);
2631
- const hostId = req.body.hostId;
2632
- const instructions = (req.body.instructions || '').trim();
2633
- const legacyMessage = (req.body.message || '').trim();
2634
- const compareMode = req.body.compareMode;
2635
- if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
2636
- throw new Error('Choose an available employee before starting a job.');
2637
- }
2638
- if (!instructions && !legacyMessage) {
2639
- throw new Error('Coach your employee before starting the run.');
2640
- }
2641
- const employee = this.hostRuntime.detectEmployees().find((entry) => entry.id === hostId);
2642
- if (!employee?.available) {
2643
- throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
2644
- }
2645
- const prepared = instructions
2646
- ? this.prepareStartPayload(projectPath, hostId, req.body.jobId, instructions)
2647
- : {
2648
- jobId: req.body.jobId,
2649
- message: legacyMessage,
2650
- display: legacyMessage,
2651
- };
2652
- const jobId = prepared.jobId;
2653
- const message = prepared.message;
2654
- // #521: store only the manager's own words in the conversation; the agent
2655
- // still receives `message` (with the Hub-injected invocation + notes).
2656
- const managerDisplay = prepared.display || message;
2657
- if (!jobId) {
2658
- throw new Error('Choose a FRAIM job before starting a run.');
2659
- }
2660
- const startTimestamp = new Date().toISOString();
2661
- const jobMetadata = this.resolveHubJob(projectPath, jobId);
2662
- const fallbackJobTitle = typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim()
2663
- ? req.body.jobTitle.trim()
2664
- : jobId;
2665
- const run = {
2666
- id: (0, crypto_1.randomUUID)(),
2667
- conversationId: typeof req.body.conversationId === 'string' && req.body.conversationId.trim() ? req.body.conversationId.trim() : undefined,
2668
- conversationTitle: typeof req.body.conversationTitle === 'string' && req.body.conversationTitle.trim() ? req.body.conversationTitle.trim() : undefined,
2669
- jobTitle: jobMetadata?.title || fallbackJobTitle,
2670
- jobId,
2671
- hostId,
2672
- projectPath,
2673
- status: 'running',
2674
- createdAt: startTimestamp,
2675
- updatedAt: startTimestamp,
2676
- messages: [(0, hosts_1.createHubMessage)('manager', managerDisplay)],
2677
- events: [(0, hosts_1.createHubEvent)('system', `Starting ${hostId} in ${projectPath}`)],
2678
- // Issue #347 — seed phase + totals state on creation.
2679
- currentPhase: null,
2680
- phaseHistory: [],
2681
- totals: emptyTotals(),
2682
- lastStatusChangeAt: startTimestamp,
2683
- personaKey: jobMetadata?.personaKey ?? getProtectedPersonaForHubJob(jobId),
2684
- // Issue #442: mark this as the FRAIM side of an A/B pair when applicable.
2685
- ...(compareMode === 'ab' ? { runRole: 'fraim' } : {}),
2686
- // #0: trigger source — defaults to 'manager' when not provided by the caller.
2687
- sourceTrigger: req.body.sourceTrigger ?? 'manager',
2688
- };
2689
- this.runRegistry.create(run, {});
2690
- this.persistRunConversation(run, run.conversationId || run.id);
2691
- // Issue #442: create the Direct (B) run before spawning either process
2692
- // so we can cross-link both runs via compareRunId before any events arrive.
2693
- // directMsg is the plain user instructions — no FRAIM invocation prefix.
2694
- const directMsg = compareMode === 'ab'
2695
- ? ((req.body.directInstructions || '').trim() || instructions || message)
2696
- : message;
2697
- let directRun;
2698
- if (compareMode === 'ab') {
2699
- const directTimestamp = new Date().toISOString();
2700
- directRun = {
2701
- id: (0, crypto_1.randomUUID)(),
2702
- jobId: 'direct',
2703
- hostId,
2704
- projectPath,
2705
- status: 'running',
2706
- createdAt: directTimestamp,
2707
- updatedAt: directTimestamp,
2708
- messages: [(0, hosts_1.createHubMessage)('manager', directMsg)],
2709
- events: [(0, hosts_1.createHubEvent)('system', `Starting direct (no FRAIM) ${hostId} in ${projectPath}`)],
2710
- currentPhase: null,
2711
- phaseHistory: [],
2712
- totals: emptyTotals(),
2713
- lastStatusChangeAt: directTimestamp,
2714
- personaKey: null,
2715
- runRole: 'direct',
2716
- compareRunId: run.id,
2717
- };
2718
- // Back-link the FRAIM run to the Direct run.
2719
- this.runRegistry.update(run.id, (current) => {
2720
- current.compareRunId = directRun.id;
2721
- });
2722
- this.runRegistry.create(directRun, {});
2723
- }
2724
- const child = this.hostRuntime.startRun(hostId, projectPath, message, {
2725
- onEvent: (event, channel) => {
2726
- this.runRegistry.update(run.id, (current) => {
2727
- if (event.sessionId)
2728
- current.sessionId = event.sessionId;
2729
- appendHostMessage(current, hostId, event, channel);
2730
- if (event.raw) {
2731
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
2732
- applyReviewProjection(current, event.raw);
2733
- }
2734
- if (event.agentIdentity)
2735
- applyAgentIdentitySignal(current, event.agentIdentity);
2736
- if (event.fraimJob)
2737
- this.applyFraimJobSignalToRun(current, event.fraimJob);
2738
- if (event.seekMentoring)
2739
- this.applySeekMentoringSignalToRun(current, event.seekMentoring);
2740
- if (event.usage)
2741
- applyUsageSignal(current, event.usage);
2742
- });
2743
- const updated = this.runRegistry.get(run.id);
2744
- if (updated) {
2745
- this.maybeStartDelegatedChildRuns(updated);
2746
- this.persistRunConversation(updated, updated.conversationId || updated.id);
2747
- }
2748
- },
2749
- onExit: (exitCode) => {
2750
- this.runRegistry.update(run.id, (current) => {
2751
- current.exitCode = exitCode;
2752
- if (current.stoppedByUser) {
2753
- // Manager stopped it — park in "waiting on you", don't call it a failure.
2754
- current.status = 'failed';
2755
- current.events.push((0, hosts_1.createHubEvent)('system', '⏹ Run stopped by you. The employee is paused — send the next instruction to continue.'));
2756
- }
2757
- else {
2758
- current.status = exitCode === 0 ? 'completed' : 'failed';
2759
- current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
2760
- }
2761
- });
2762
- const updated = this.runRegistry.get(run.id);
2763
- if (updated) {
2764
- this.maybeStartDelegatedChildRuns(updated);
2765
- this.persistRunConversation(updated, updated.conversationId || updated.id);
2766
- }
2767
- this.runRegistry.dispose(run.id);
2768
- const latest = this.runRegistry.get(run.id);
2769
- if (latest)
2770
- this.drainPendingDelegatedReviews(latest);
2771
- },
2772
- }, startSessionSeedForHost(hostId, run.id));
2773
- this.runRegistry.attachChildIfRunning(run.id, child);
2774
- // Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
2775
- // uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
2776
- // the FRAIM-wrapping buildStartPlan path.
2777
- if (directRun) {
2778
- const directId = directRun.id;
2779
- const directChild = this.hostRuntime.startDirectRun(hostId, directMsg, projectPath, {
2780
- onEvent: (event, channel) => {
2781
- this.runRegistry.update(directId, (current) => {
2782
- if (event.sessionId)
2783
- current.sessionId = event.sessionId;
2784
- appendHostMessage(current, hostId, event, channel);
2785
- if (event.raw)
2786
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
2787
- if (event.agentIdentity)
2788
- applyAgentIdentitySignal(current, event.agentIdentity);
2789
- if (event.usage)
2790
- applyUsageSignal(current, event.usage);
2791
- });
2792
- },
2793
- onExit: (exitCode) => {
2794
- this.runRegistry.update(directId, (current) => {
2795
- current.exitCode = exitCode;
2796
- current.status = exitCode === 0 ? 'completed' : 'failed';
2797
- current.events.push((0, hosts_1.createHubEvent)('system', `Direct run exited with code ${exitCode ?? 'unknown'}.`));
2798
- });
2799
- this.runRegistry.dispose(directId);
2800
- },
2801
- }, startSessionSeedForHost(hostId, directRun.id));
2802
- this.runRegistry.attachChildIfRunning(directRun.id, directChild);
2803
- }
2804
- const existingPreferences = this.preferencesStore.load(projectPath);
2805
- this.preferencesStore.remember({
2806
- ...existingPreferences,
2807
- projectPath,
2808
- employeeId: hostId,
2809
- recentJobIds: existingPreferences.recentJobIds,
2810
- }, jobId, typeof instructions === 'string' ? instructions : undefined);
2811
- const fraimRunEnriched = this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run);
2812
- const responsePayload = directRun
2813
- ? { ...fraimRunEnriched, compareRun: this.enrichRunForResponse(directRun) }
2814
- : fraimRunEnriched;
2815
- res.status(201).json(responsePayload);
2816
- // Background sync: refresh the local FRAIM catalog so the next job
2817
- // picker load sees any jobs that were added or updated since last run.
2818
- try {
2819
- const syncChild = (0, child_process_1.spawn)('npx', ['fraim', 'sync'], { cwd: projectPath, detached: true, stdio: 'ignore' });
2820
- syncChild.on('error', () => { });
2821
- syncChild.unref();
2822
- }
2823
- catch { /* ignore if spawn itself throws synchronously */ }
2824
- }
2825
- catch (error) {
2826
- res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
2827
- }
2828
- });
2829
- // #521: Shared persistent browser. start() launches (or reuses) the one
2830
- // FRAIM-owned Chrome/Edge and publishes its CDP endpoint to agents via env so
2831
- // the browser-use skill connects to it instead of launching a throwaway one.
2832
- this.app.get('/api/ai-hub/browser/status', async (_req, res) => {
2833
- const running = await this.managedBrowser.isRunning();
2834
- res.json({ running, ...this.managedBrowser.status() });
2835
- });
2836
- this.app.post('/api/ai-hub/browser/start', async (_req, res) => {
2837
- try {
2838
- const result = await this.ensureManagedBrowser();
2839
- res.json({
2840
- ok: true,
2841
- ...result,
2842
- loginHint: 'Log into your sites in the FRAIM browser window — the session persists across agent turns.',
2843
- });
2844
- }
2845
- catch (err) {
2846
- res.status(500).json({ error: err instanceof Error ? err.message : 'Could not start the browser.' });
2847
- }
2848
- });
2849
- this.app.post('/api/ai-hub/browser/stop', (_req, res) => {
2850
- // Kill the process; keep FRAIM_BROWSER_CDP_ENDPOINT published — it's the
2851
- // deterministic location, so the agent can re-ensure and reconnect later.
2852
- this.managedBrowser.stop();
2853
- res.json({ ok: true });
2854
- });
2855
- // #521: Stop — the manager interrupts a running agent. Kills the process and
2856
- // parks the run in a "waiting on you" state so they can give the next
2857
- // instruction (continue) or leave it. Idempotent for already-finished runs.
2858
- this.app.post('/api/ai-hub/runs/:runId/stop', (req, res) => {
2859
- const run = this.runRegistry.get(req.params.runId);
2860
- if (!run) {
2861
- return res.status(404).json({ error: 'Run not found.' });
2862
- }
2863
- if (run.status !== 'running') {
2864
- // Nothing to stop — return the current state unchanged.
2865
- return res.json(this.enrichRunForResponse(run));
2866
- }
2867
- this.runRegistry.update(run.id, (current) => { current.stoppedByUser = true; });
2868
- const killed = this.runRegistry.stop(run.id);
2869
- // Park it immediately (don't wait for onExit, which may lag or not fire on a
2870
- // host that already detached). onExit, if it fires, keeps this same state.
2871
- this.runRegistry.update(run.id, (current) => {
2872
- current.status = 'failed';
2873
- current.events.push((0, hosts_1.createHubEvent)('system', killed
2874
- ? '⏹ Run stopped by you. The employee is paused — send the next instruction to continue.'
2875
- : '⏹ Stop requested. The employee was already wrapping up — send the next instruction to continue.'));
2876
- });
2877
- const stopped = this.runRegistry.get(run.id);
2878
- if (stopped)
2879
- this.persistRunConversation(stopped, stopped.conversationId || stopped.id);
2880
- this.runRegistry.dispose(run.id);
2881
- return res.json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
2882
- });
2883
- this.app.post('/api/ai-hub/runs/:runId/messages', (req, res) => {
2884
- try {
2885
- const run = this.runRegistry.get(req.params.runId);
2886
- if (!run) {
2887
- return res.status(404).json({ error: 'Run not found.' });
2888
- }
2889
- if (!run.sessionId) {
2890
- return res.status(409).json({ error: 'This run does not have a resumable host session yet.' });
2891
- }
2892
- const instructions = (req.body.instructions || '').trim();
2893
- const coachingJobId = req.body.coachingJobId?.trim() || undefined;
2894
- // When coachingJobId is present (user picked a manager template via the UI),
2895
- // it overrides the run's own jobId in the invocation. The server always adds
2896
- // the correct $fraim / /fraim prefix — the UI never passes raw invocation syntax.
2897
- const prepared = instructions
2898
- ? this.prepareContinueMessage(run, instructions, coachingJobId)
2899
- : coachingJobId
2900
- ? this.prepareContinueMessage(run, '', coachingJobId)
2901
- : { message: (req.body.message || '').trim(), display: (req.body.message || '').trim() };
2902
- const message = prepared.message;
2903
- if (!message) {
2904
- return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
2905
- }
2906
- this.runRegistry.update(run.id, (current) => {
2907
- current.status = 'running';
2908
- // #521: bubble shows the manager's words; the agent gets the full message.
2909
- current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
2910
- });
2911
- const started = this.runRegistry.get(run.id);
2912
- if (started)
2913
- this.persistRunConversation(started, started.conversationId || started.id);
2914
- this.runRegistry.create(run, {});
2915
- const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message, {
2916
- onEvent: (event, channel) => {
2917
- this.runRegistry.update(run.id, (current) => {
2918
- if (event.sessionId)
2919
- current.sessionId = event.sessionId;
2920
- appendHostMessage(current, run.hostId, event, channel);
2921
- if (event.raw) {
2922
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
2923
- applyReviewProjection(current, event.raw);
2924
- }
2925
- if (event.agentIdentity)
2926
- applyAgentIdentitySignal(current, event.agentIdentity);
2927
- if (event.fraimJob)
2928
- this.applyFraimJobSignalToRun(current, event.fraimJob);
2929
- if (event.seekMentoring)
2930
- this.applySeekMentoringSignalToRun(current, event.seekMentoring);
2931
- if (event.usage)
2932
- applyUsageSignal(current, event.usage);
2933
- });
2934
- const updated = this.runRegistry.get(run.id);
2935
- if (updated) {
2936
- this.maybeStartDelegatedChildRuns(updated);
2937
- this.persistRunConversation(updated, updated.conversationId || updated.id);
2938
- }
2939
- },
2940
- onExit: (exitCode) => {
2941
- this.runRegistry.update(run.id, (current) => {
2942
- current.exitCode = exitCode;
2943
- current.status = exitCode === 0 ? 'completed' : 'failed';
2944
- current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
2945
- });
2946
- const updated = this.runRegistry.get(run.id);
2947
- if (updated) {
2948
- this.maybeStartDelegatedChildRuns(updated);
2949
- this.persistRunConversation(updated, updated.conversationId || updated.id);
2950
- }
2951
- this.runRegistry.dispose(run.id);
2952
- const latest = this.runRegistry.get(run.id);
2953
- if (latest)
2954
- this.drainPendingDelegatedReviews(latest);
2955
- },
2956
- });
2957
- this.runRegistry.attachChildIfRunning(run.id, child);
2958
- const refreshed = this.runRegistry.get(run.id);
2959
- res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
2960
- }
2961
- catch (error) {
2962
- res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue run.' });
2963
- }
2964
- });
2965
- // #521: resume a conversation whose Hub run was lost (e.g. a server restart)
2966
- // but whose agent session still exists on disk. Recreates a run bound to the
2967
- // existing sessionId and continues it via the host's resume path — so a
2968
- // conversation can be carried forward without losing its context.
2969
- this.app.post('/api/ai-hub/runs/resume', (req, res) => {
2970
- try {
2971
- const body = (req.body ?? {});
2972
- const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
2973
- const hostId = body.hostId;
2974
- const sessionId = (body.sessionId || '').trim();
2975
- const jobId = (body.jobId || '').trim();
2976
- const instructions = (body.instructions || '').trim();
2977
- const coachingJobId = body.coachingJobId?.trim() || undefined;
2978
- if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
2979
- throw new Error('Choose an available employee to resume.');
2980
- }
2981
- if (!sessionId)
2982
- throw new Error('A host sessionId is required to resume.');
2983
- if (!jobId)
2984
- throw new Error('A jobId is required to resume.');
2985
- if (!instructions)
2986
- throw new Error('Provide an instruction to continue.');
2987
- const employee = this.hostRuntime.detectEmployees().find((entry) => entry.id === hostId);
2988
- if (!employee?.available) {
2989
- throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
2990
- }
2991
- const conversationId = typeof body.conversationId === 'string' && body.conversationId.trim()
2992
- ? body.conversationId.trim()
2993
- : undefined;
2994
- const persistedConversation = conversationId
2995
- ? this.conversationStore.loadProject(projectPath).conversations.find((entry) => entry.id === conversationId)
2996
- : undefined;
2997
- const persistedRun = readPersistedRunProjection(persistedConversation);
2998
- const now = new Date().toISOString();
2999
- const run = {
3000
- id: (0, crypto_1.randomUUID)(),
3001
- conversationId,
3002
- conversationTitle: typeof body.conversationTitle === 'string' && body.conversationTitle.trim() ? body.conversationTitle.trim() : undefined,
3003
- jobTitle: typeof body.jobTitle === 'string' && body.jobTitle.trim() ? body.jobTitle.trim() : jobId,
3004
- jobId, hostId, projectPath, status: 'running', sessionId,
3005
- createdAt: now, updatedAt: now, messages: [],
3006
- events: [(0, hosts_1.createHubEvent)('system', `Resuming ${hostId} session ${sessionId} in ${projectPath}`)],
3007
- currentPhase: persistedRun?.currentPhase || null,
3008
- phaseHistory: persistedRun?.phaseHistory || [],
3009
- totals: persistedRun?.totals || emptyTotals(),
3010
- lastStatusChangeAt: now,
3011
- runDiscriminant: persistedRun?.runDiscriminant || undefined,
3012
- personaKey: getProtectedPersonaForHubJob(jobId),
3013
- };
3014
- // Continue-turn message (FRAIM invocation for the job + instructions) plus
3015
- // the shared-browser note so the resumed agent knows about it.
3016
- const preparedResume = this.prepareContinueMessage(run, instructions, coachingJobId);
3017
- const message = preparedResume.message
3018
- + (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
3019
- // #521: bubble shows the manager's words; the agent gets the full message.
3020
- run.messages.push((0, hosts_1.createHubMessage)('manager', preparedResume.display || message));
3021
- this.runRegistry.create(run, {});
3022
- this.persistRunConversation(run, run.conversationId || run.id);
3023
- const child = this.hostRuntime.continueRun(hostId, projectPath, sessionId, message, {
3024
- onEvent: (event, channel) => {
3025
- this.runRegistry.update(run.id, (current) => {
3026
- if (event.sessionId)
3027
- current.sessionId = event.sessionId;
3028
- appendHostMessage(current, hostId, event, channel);
3029
- if (event.raw) {
3030
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
3031
- applyReviewProjection(current, event.raw);
3032
- }
3033
- if (event.agentIdentity)
3034
- applyAgentIdentitySignal(current, event.agentIdentity);
3035
- if (event.fraimJob)
3036
- this.applyFraimJobSignalToRun(current, event.fraimJob);
3037
- if (event.seekMentoring)
3038
- this.applySeekMentoringSignalToRun(current, event.seekMentoring);
3039
- if (event.usage)
3040
- applyUsageSignal(current, event.usage);
3041
- });
3042
- const updated = this.runRegistry.get(run.id);
3043
- if (updated)
3044
- this.persistRunConversation(updated, updated.conversationId || updated.id);
3045
- },
3046
- onExit: (exitCode) => {
3047
- this.runRegistry.update(run.id, (current) => {
3048
- current.exitCode = exitCode;
3049
- current.status = exitCode === 0 ? 'completed' : 'failed';
3050
- current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
3051
- });
3052
- const updated = this.runRegistry.get(run.id);
3053
- if (updated)
3054
- this.persistRunConversation(updated, updated.conversationId || updated.id);
3055
- this.runRegistry.dispose(run.id);
3056
- const latest = this.runRegistry.get(run.id);
3057
- if (latest)
3058
- this.drainPendingDelegatedReviews(latest);
3059
- },
3060
- });
3061
- this.runRegistry.attachChildIfRunning(run.id, child);
3062
- res.status(201).json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
3063
- }
3064
- catch (error) {
3065
- res.status(400).json({ error: error instanceof Error ? error.message : 'Could not resume the conversation.' });
3066
- }
3067
- });
3068
- // Issue #442: continue the Direct (B) run without FRAIM MCP servers.
3069
- this.app.post('/api/ai-hub/runs/:runId/direct-messages', (req, res) => {
3070
- try {
3071
- const run = this.runRegistry.get(req.params.runId);
3072
- if (!run)
3073
- return res.status(404).json({ error: 'Run not found.' });
3074
- if (run.runRole !== 'direct')
3075
- return res.status(400).json({ error: 'Run is not a Direct run.' });
3076
- if (!run.sessionId)
3077
- return res.status(409).json({ error: 'Direct run does not have a resumable session yet.' });
3078
- const message = (req.body.message || '').trim();
3079
- if (!message)
3080
- return res.status(400).json({ error: 'Message is required.' });
3081
- this.runRegistry.update(run.id, (current) => {
3082
- current.status = 'running';
3083
- current.messages.push((0, hosts_1.createHubMessage)('manager', message));
3084
- });
3085
- this.runRegistry.create(run, {});
3086
- const child = this.hostRuntime.continueDirectRun(run.hostId, run.sessionId, message, run.projectPath, {
3087
- onEvent: (event, channel) => {
3088
- this.runRegistry.update(run.id, (current) => {
3089
- if (event.sessionId)
3090
- current.sessionId = event.sessionId;
3091
- appendHostMessage(current, run.hostId, event, channel);
3092
- if (event.raw)
3093
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
3094
- if (event.usage)
3095
- applyUsageSignal(current, event.usage);
3096
- });
3097
- },
3098
- onExit: (exitCode) => {
3099
- this.runRegistry.update(run.id, (current) => {
3100
- current.exitCode = exitCode;
3101
- current.status = exitCode === 0 ? 'completed' : 'failed';
3102
- current.events.push((0, hosts_1.createHubEvent)('system', `Direct run exited with code ${exitCode ?? 'unknown'}.`));
3103
- });
3104
- this.runRegistry.dispose(run.id);
3105
- },
3106
- });
3107
- this.runRegistry.attachChildIfRunning(run.id, child);
3108
- const refreshed = this.runRegistry.get(run.id);
3109
- res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
3110
- }
3111
- catch (error) {
3112
- res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue Direct run.' });
3113
- }
3114
- });
3115
- // GET /api/ai-hub/runs — list runs for cross-host polling and the UI ledger.
3116
- // Query params: status (filter), sourceTrigger (filter), limit (default 100, max 200).
3117
- // Must be registered BEFORE the :runId route to avoid 'runs' being matched as a runId.
3118
- this.app.get('/api/ai-hub/runs', (req, res) => {
3119
- const statusFilter = typeof req.query.status === 'string' ? req.query.status : null;
3120
- const triggerFilter = typeof req.query.sourceTrigger === 'string' ? req.query.sourceTrigger : null;
3121
- const limit = Math.min(200, parseInt(String(req.query.limit || '100'), 10) || 100);
3122
- let runs = this.runRegistry.all();
3123
- if (statusFilter)
3124
- runs = runs.filter((r) => r.status === statusFilter);
3125
- if (triggerFilter)
3126
- runs = runs.filter((r) => (r.sourceTrigger ?? 'manager') === triggerFilter);
3127
- runs = runs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
3128
- return res.json(runs.map((r) => this.enrichRunForResponse(r)));
3129
- });
3130
- // ─── Issue #578: /api/ai-hub/runs/merged must be registered BEFORE :runId ──
3131
- this.app.get('/api/ai-hub/runs/merged', async (req, res) => {
3132
- const local = this.runRegistry.all().map((r) => this.enrichRunForResponse(r));
3133
- const hosts = this.hostConfigStore.load();
3134
- const remoteResults = await Promise.allSettled(hosts.map(async (host) => {
3135
- const url = `${host.url.replace(/\/$/, '')}/api/ai-hub/runs`;
3136
- const resp = await fetch(url, {
3137
- signal: AbortSignal.timeout(8000),
3138
- headers: host.authToken ? { 'X-Hub-Auth': host.authToken } : {},
3139
- });
3140
- if (!resp.ok)
3141
- return [];
3142
- return resp.json();
3143
- }));
3144
- const remote = remoteResults
3145
- .filter((r) => r.status === 'fulfilled')
3146
- .flatMap((r) => r.value);
3147
- const merged = [...local, ...remote].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
3148
- return res.json(merged);
3149
- });
3150
- // GET /api/ai-hub/runs/:runId — registered AFTER /merged to avoid shadowing.
3151
- this.app.get('/api/ai-hub/runs/:runId', (req, res) => {
3152
- const run = this.runRegistry.get(req.params.runId);
3153
- if (!run) {
3154
- return res.status(404).json({ error: 'Run not found.' });
3155
- }
3156
- return res.json(this.enrichRunForResponse(run));
3157
- });
3158
- // ─── Issue #578: Scheduled + Reactive Employees ───────────────────────────
3159
- // POST /api/ai-hub/schedules — create a recurring scheduled deployment.
3160
- this.app.post('/api/ai-hub/schedules', (req, res) => {
3161
- const { label, jobId, projectPath, hostId, cronExpr, instructions, outputChannel, allowConcurrent } = req.body ?? {};
3162
- if (!label || !jobId || !cronExpr) {
3163
- return res.status(400).json({ error: 'label, jobId, and cronExpr are required.' });
3164
- }
3165
- // eslint-disable-next-line @typescript-eslint/no-var-requires
3166
- const cronLib = require('node-cron');
3167
- if (!cronLib.validate(cronExpr)) {
3168
- return res.status(400).json({ error: 'Invalid cron expression.' });
3169
- }
3170
- const validEmployees = VALID_EMPLOYEE_IDS;
3171
- const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
3172
- const now = new Date().toISOString();
3173
- const deployment = {
3174
- id: (0, crypto_1.randomUUID)(),
3175
- type: 'scheduled',
3176
- label,
3177
- jobId,
3178
- projectPath: ensureDirectoryPath(projectPath || this.projectPath),
3179
- hostId: resolvedHostId,
3180
- cronExpr,
3181
- instructions: typeof instructions === 'string' ? instructions : undefined,
3182
- outputChannel: typeof outputChannel === 'string' ? outputChannel : undefined,
3183
- allowConcurrent: allowConcurrent === true,
3184
- active: true,
3185
- createdAt: now,
3186
- updatedAt: now,
3187
- };
3188
- this.deploymentStore.create(deployment);
3189
- this.scheduleDeployment(deployment);
3190
- return res.status(201).json(deployment);
3191
- });
3192
- // GET /api/ai-hub/schedules — list scheduled deployments for one project.
3193
- this.app.get('/api/ai-hub/schedules', (req, res) => {
3194
- try {
3195
- const projectPath = deploymentProjectFilter(req.query.projectPath, this.projectPath);
3196
- return res.json(this.deploymentStore.load().filter((d) => d.type === 'scheduled' && deploymentBelongsToProject(d, projectPath, this.projectPath)));
3197
- }
3198
- catch (err) {
3199
- return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
3200
- }
3201
- });
3202
- // DELETE /api/ai-hub/schedules/:id — remove a scheduled deployment.
3203
- this.app.delete('/api/ai-hub/schedules/:id', (req, res) => {
3204
- const { id } = req.params;
3205
- const task = this.cronHandles.get(id);
3206
- if (task) {
3207
- task.stop();
3208
- this.cronHandles.delete(id);
3209
- }
3210
- const deleted = this.deploymentStore.delete(id);
3211
- if (!deleted)
3212
- return res.status(404).json({ error: 'Deployment not found.' });
3213
- return res.json({ ok: true });
3214
- });
3215
- // PUT /api/ai-hub/schedules/:id — update an existing scheduled deployment.
3216
- this.app.put('/api/ai-hub/schedules/:id', (req, res) => {
3217
- const { id } = req.params;
3218
- const { label, jobId, projectPath, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
3219
- if (cronExpr !== undefined) {
3220
- // eslint-disable-next-line @typescript-eslint/no-var-requires
3221
- const cronLib = require('node-cron');
3222
- if (!cronLib.validate(cronExpr)) {
3223
- return res.status(400).json({ error: 'Invalid cron expression.' });
3224
- }
3225
- }
3226
- let resolvedProjectPath;
3227
- try {
3228
- if (projectPath !== undefined)
3229
- resolvedProjectPath = ensureDirectoryPath(projectPath || this.projectPath);
3230
- }
3231
- catch (err) {
3232
- return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
3233
- }
3234
- const validEmployees = VALID_EMPLOYEE_IDS;
3235
- let updated = null;
3236
- const ok = this.deploymentStore.update(id, (dep) => {
3237
- if (label !== undefined)
3238
- dep.label = label;
3239
- if (jobId !== undefined)
3240
- dep.jobId = jobId;
3241
- if (resolvedProjectPath !== undefined)
3242
- dep.projectPath = resolvedProjectPath;
3243
- if (cronExpr !== undefined)
3244
- dep.cronExpr = cronExpr;
3245
- if (hostId !== undefined && validEmployees.includes(hostId))
3246
- dep.hostId = hostId;
3247
- if (instructions !== undefined)
3248
- dep.instructions = typeof instructions === 'string' ? instructions : undefined;
3249
- if (allowConcurrent !== undefined)
3250
- dep.allowConcurrent = allowConcurrent === true;
3251
- updated = dep;
3252
- });
3253
- if (!ok || !updated)
3254
- return res.status(404).json({ error: 'Deployment not found.' });
3255
- const oldTask = this.cronHandles.get(id);
3256
- if (oldTask) {
3257
- oldTask.stop();
3258
- this.cronHandles.delete(id);
3259
- }
3260
- this.scheduleDeployment(updated);
3261
- return res.json(updated);
3262
- });
3263
- // POST /api/ai-hub/webhooks — register an inbound webhook deployment.
3264
- this.app.post('/api/ai-hub/webhooks', (req, res) => {
3265
- const { label, jobId, projectPath, hostId, instructions, outputChannel, allowConcurrent } = req.body ?? {};
3266
- if (!label || !jobId) {
3267
- return res.status(400).json({ error: 'label and jobId are required.' });
3268
- }
3269
- const validEmployees = VALID_EMPLOYEE_IDS;
3270
- const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
3271
- const now = new Date().toISOString();
3272
- const deployment = {
3273
- id: (0, crypto_1.randomUUID)(),
3274
- type: 'webhook',
3275
- label,
3276
- jobId,
3277
- projectPath: ensureDirectoryPath(projectPath || this.projectPath),
3278
- hostId: resolvedHostId,
3279
- instructions: typeof instructions === 'string' ? instructions : undefined,
3280
- outputChannel: typeof outputChannel === 'string' ? outputChannel : undefined,
3281
- allowConcurrent: allowConcurrent === true,
3282
- active: true,
3283
- createdAt: now,
3284
- updatedAt: now,
3285
- };
3286
- this.deploymentStore.create(deployment);
3287
- return res.status(201).json({ ...deployment, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${deployment.id}/inbound` });
3288
- });
3289
- // GET /api/ai-hub/webhooks — list webhook deployments for one project.
3290
- this.app.get('/api/ai-hub/webhooks', (req, res) => {
3291
- const hubBase = this.hubBase;
3292
- try {
3293
- const projectPath = deploymentProjectFilter(req.query.projectPath, this.projectPath);
3294
- return res.json(this.deploymentStore.load()
3295
- .filter((d) => d.type === 'webhook' && deploymentBelongsToProject(d, projectPath, this.projectPath))
3296
- .map((d) => ({ ...d, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${d.id}/inbound` })));
3297
- }
3298
- catch (err) {
3299
- return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
3300
- }
3301
- });
3302
- // DELETE /api/ai-hub/webhooks/:id — remove a webhook deployment.
3303
- this.app.delete('/api/ai-hub/webhooks/:id', (req, res) => {
3304
- const deleted = this.deploymentStore.delete(req.params.id);
3305
- if (!deleted)
3306
- return res.status(404).json({ error: 'Deployment not found.' });
3307
- return res.json({ ok: true });
3308
- });
3309
- // PUT /api/ai-hub/webhooks/:id — update an existing webhook deployment.
3310
- this.app.put('/api/ai-hub/webhooks/:id', (req, res) => {
3311
- const { id } = req.params;
3312
- const { label, jobId, projectPath, hostId, instructions, allowConcurrent } = req.body ?? {};
3313
- let resolvedProjectPath;
3314
- try {
3315
- if (projectPath !== undefined)
3316
- resolvedProjectPath = ensureDirectoryPath(projectPath || this.projectPath);
3317
- }
3318
- catch (err) {
3319
- return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
3320
- }
3321
- const validEmployees = VALID_EMPLOYEE_IDS;
3322
- let updated = null;
3323
- const ok = this.deploymentStore.update(id, (dep) => {
3324
- if (label !== undefined)
3325
- dep.label = label;
3326
- if (jobId !== undefined)
3327
- dep.jobId = jobId;
3328
- if (resolvedProjectPath !== undefined)
3329
- dep.projectPath = resolvedProjectPath;
3330
- if (hostId !== undefined && validEmployees.includes(hostId))
3331
- dep.hostId = hostId;
3332
- if (instructions !== undefined)
3333
- dep.instructions = typeof instructions === 'string' ? instructions : undefined;
3334
- if (allowConcurrent !== undefined)
3335
- dep.allowConcurrent = allowConcurrent === true;
3336
- updated = dep;
3337
- });
3338
- if (!ok || !updated)
3339
- return res.status(404).json({ error: 'Deployment not found.' });
3340
- return res.json({ ...updated, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${id}/inbound` });
3341
- });
3342
- // POST /api/ai-hub/webhooks/:id/inbound — webhook inbound trigger from external systems.
3343
- this.app.post('/api/ai-hub/webhooks/:id/inbound', async (req, res) => {
3344
- const deployments = this.deploymentStore.load();
3345
- const deployment = deployments.find((d) => d.id === req.params.id && d.type === 'webhook' && d.active);
3346
- if (!deployment) {
3347
- return res.status(404).json({ error: 'Webhook not found or inactive.' });
3348
- }
3349
- try {
3350
- const run = await this.fireDeploymentRun(deployment, req.body);
3351
- return res.status(202).json({ runId: run.id, status: run.status });
3352
- }
3353
- catch (err) {
3354
- const msg = err instanceof Error ? err.message : 'Failed to start run.';
3355
- return res.status(500).json({ error: msg });
3356
- }
3357
- });
3358
- // GET /api/ai-hub/hosts — list registered remote hosts with health status.
3359
- this.app.get('/api/ai-hub/hosts', async (_req, res) => {
3360
- const hosts = this.hostConfigStore.load();
3361
- const healthResults = await Promise.allSettled(hosts.map((h) => pingHost(h)));
3362
- const health = healthResults.map((r, i) => r.status === 'fulfilled'
3363
- ? r.value
3364
- : { id: hosts[i].id, label: hosts[i].label, url: hosts[i].url, status: 'offline', latencyMs: null, lastPingAt: new Date().toISOString() });
3365
- return res.json(health);
3366
- });
3367
- // POST /api/ai-hub/hosts — register a named remote hub host.
3368
- this.app.post('/api/ai-hub/hosts', (req, res) => {
3369
- const { label, url, authToken } = req.body ?? {};
3370
- const validUrl = safeHttpUrl(url);
3371
- if (!label || !validUrl) {
3372
- return res.status(400).json({ error: 'label and a valid http(s) url are required.' });
3373
- }
3374
- const host = {
3375
- id: (0, crypto_1.randomUUID)(),
3376
- label,
3377
- url: validUrl,
3378
- authToken: typeof authToken === 'string' && authToken ? authToken : undefined,
3379
- createdAt: new Date().toISOString(),
3380
- };
3381
- this.hostConfigStore.add(host);
3382
- return res.status(201).json({ id: host.id, label: host.label, url: host.url, createdAt: host.createdAt });
3383
- });
3384
- // DELETE /api/ai-hub/hosts/:id — remove a named remote host.
3385
- this.app.delete('/api/ai-hub/hosts/:id', (req, res) => {
3386
- const deleted = this.hostConfigStore.delete(req.params.id);
3387
- if (!deleted)
3388
- return res.status(404).json({ error: 'Host not found.' });
3389
- return res.json({ ok: true });
3390
- });
3391
- // GET /api/ai-hub/hosts/:id/health — ping a single host.
3392
- this.app.get('/api/ai-hub/hosts/:id/health', async (req, res) => {
3393
- const host = this.hostConfigStore.load().find((h) => h.id === req.params.id);
3394
- if (!host)
3395
- return res.status(404).json({ error: 'Host not found.' });
3396
- const health = await pingHost(host);
3397
- return res.json(health);
3398
- });
3399
- // ─── End Issue #578 ───────────────────────────────────────────────────────
3400
- // -------------------------------------------------------------------------
3401
- // Issue #489: POST /api/trigger
3402
- // Stable API endpoint for extension surfaces (Office add-ins, browser
3403
- // extensions, VS Code extensions, Electron tray) to start FRAIM jobs.
3404
- //
3405
- // Body: { employeeId, jobName, context?: { text, sourceApp, fileName, ... }, projectPath? }
3406
- // Response: { runId, status: "started", employee, job }
3407
- // -------------------------------------------------------------------------
3408
- this.app.post('/api/trigger', (req, res) => {
3409
- try {
3410
- const { employeeId, jobName, context, projectPath: reqProjectPath } = req.body;
3411
- if (!employeeId) {
3412
- return res.status(400).json({ error: 'employeeId is required.' });
3413
- }
3414
- if (!jobName) {
3415
- return res.status(400).json({ error: 'jobName is required.' });
3416
- }
3417
- const projectPath = ensureDirectoryPath(reqProjectPath || this.projectPath);
3418
- // Use the requested agent directly — caller specifies which agent to run (claude, codex, gemini).
3419
- const employees = this.hostRuntime.detectEmployees();
3420
- const hostId = employeeId;
3421
- const employee = employees.find(e => e.id === hostId);
3422
- if (!employee?.available) {
3423
- return res.status(503).json({ error: `${hostId} is not available on this machine.` });
3424
- }
3425
- const contextText = context?.text?.trim() || '';
3426
- const sourceInfo = [
3427
- context?.sourceApp ? `sourceApp: ${context.sourceApp}` : '',
3428
- context?.fileName ? `file: ${context.fileName}` : '',
3429
- ].filter(Boolean).join(', ');
3430
- const message = [
3431
- `/fraim ${jobName}`,
3432
- '',
3433
- sourceInfo ? `Context (${sourceInfo}):` : 'Context:',
3434
- contextText || '(no context provided)',
3435
- ].join('\n');
3436
- const startTimestamp = new Date().toISOString();
3437
- const run = {
3438
- id: (0, crypto_1.randomUUID)(),
3439
- jobId: jobName,
3440
- hostId,
3441
- projectPath,
3442
- status: 'running',
3443
- createdAt: startTimestamp,
3444
- updatedAt: startTimestamp,
3445
- messages: [(0, hosts_1.createHubMessage)('manager', message)],
3446
- events: [(0, hosts_1.createHubEvent)('system', `Trigger: ${employeeId} / ${jobName} from ${context?.sourceApp || 'unknown'} in ${projectPath}`)],
3447
- currentPhase: null,
3448
- phaseHistory: [],
3449
- totals: emptyTotals(),
3450
- lastStatusChangeAt: startTimestamp,
3451
- personaKey: getProtectedPersonaForHubJob(jobName),
3452
- };
3453
- // Register the run before spawning so onEvent/onExit callbacks can
3454
- // safely call update() even if they fire synchronously (FakeHostRuntime).
3455
- this.runRegistry.create(run, {});
3456
- const child = this.hostRuntime.startRun(hostId, projectPath, message, {
3457
- onEvent: (event, channel) => {
3458
- this.runRegistry.update(run.id, (current) => {
3459
- if (event.sessionId)
3460
- current.sessionId = event.sessionId;
3461
- appendHostMessage(current, hostId, event, channel);
3462
- if (event.raw)
3463
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
3464
- if (event.agentIdentity)
3465
- applyAgentIdentitySignal(current, event.agentIdentity);
3466
- if (event.fraimJob)
3467
- this.applyFraimJobSignalToRun(current, event.fraimJob);
3468
- if (event.seekMentoring)
3469
- this.applySeekMentoringSignalToRun(current, event.seekMentoring);
3470
- if (event.usage)
3471
- applyUsageSignal(current, event.usage);
3472
- });
3473
- },
3474
- onExit: (exitCode) => {
3475
- this.runRegistry.update(run.id, (current) => {
3476
- current.exitCode = exitCode;
3477
- current.status = exitCode === 0 ? 'completed' : 'failed';
3478
- current.events.push((0, hosts_1.createHubEvent)('system', `Trigger run exited with code ${exitCode ?? 'unknown'}.`));
3479
- });
3480
- this.runRegistry.dispose(run.id);
3481
- },
3482
- }, startSessionSeedForHost(hostId, run.id));
3483
- // Update the registry entry with the real child process handle.
3484
- this.runRegistry.attachChildIfRunning(run.id, child);
3485
- return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
3486
- }
3487
- catch (error) {
3488
- return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
3489
- }
3490
- });
3491
- }
3492
- // #521: start (or reuse) the shared browser and publish its CDP endpoint so
3493
- // every agent spawned afterward inherits FRAIM_BROWSER_CDP_ENDPOINT and its
3494
- // browser-use skill connects to the long-lived window instead of a throwaway.
3495
- async ensureManagedBrowser() {
3496
- const result = await this.managedBrowser.start();
3497
- process.env.FRAIM_BROWSER_CDP_ENDPOINT = result.endpoint;
3498
- return { endpoint: result.endpoint, reused: result.reused, channel: result.channel };
3499
- }
3500
- // ─── Issue #578: Scheduled deployment helpers ─────────────────────────────
3501
- rehydrateScheduledDeployments() {
3502
- const active = this.deploymentStore.load().filter((d) => d.type === 'scheduled' && d.active);
3503
- for (const dep of active) {
3504
- this.scheduleDeployment(dep);
3505
- }
3506
- if (active.length > 0) {
3507
- console.log(`[ai-hub] rehydrated ${active.length} scheduled deployment(s)`);
3508
- }
3509
- }
3510
- scheduleDeployment(deployment) {
3511
- if (!deployment.cronExpr)
3512
- return;
3513
- try {
3514
- // eslint-disable-next-line @typescript-eslint/no-var-requires
3515
- const cron = require('node-cron');
3516
- if (!cron.validate(deployment.cronExpr)) {
3517
- console.warn(`[ai-hub] invalid cronExpr for deployment ${deployment.id}: ${deployment.cronExpr}`);
3518
- return;
3519
- }
3520
- const task = cron.schedule(deployment.cronExpr, async () => {
3521
- try {
3522
- await this.fireDeploymentRun(deployment);
3523
- }
3524
- catch (err) {
3525
- console.warn(`[ai-hub] scheduled deployment ${deployment.id} fire failed:`, err);
3526
- }
3527
- });
3528
- this.cronHandles.set(deployment.id, task);
3529
- }
3530
- catch (err) {
3531
- console.warn('[ai-hub] node-cron not available — scheduled deployments require node-cron:', err);
3532
- }
3533
- }
3534
- async fireDeploymentRun(deployment, webhookBody) {
3535
- // Overlapping run guard: if the prior run is still active and allowConcurrent is false, skip.
3536
- if (!deployment.allowConcurrent && deployment.activeRunId) {
3537
- const active = this.runRegistry.get(deployment.activeRunId);
3538
- if (active && active.status === 'running') {
3539
- console.log(`[ai-hub] deployment ${deployment.id} skipped — prior run ${deployment.activeRunId} still running`);
3540
- return active;
3541
- }
3542
- }
3543
- const employees = this.hostRuntime.detectEmployees();
3544
- const employee = employees.find((e) => e.id === deployment.hostId);
3545
- if (!employee?.available) {
3546
- throw new Error(`Employee ${deployment.hostId} is not available for scheduled/webhook run.`);
3547
- }
3548
- const instructions = [
3549
- deployment.instructions ?? `/fraim ${deployment.jobId}`,
3550
- webhookBody ? `\n\nInbound payload:\n${JSON.stringify(webhookBody, null, 2)}` : '',
3551
- ].join('').trim();
3552
- const startTimestamp = new Date().toISOString();
3553
- const run = {
3554
- id: (0, crypto_1.randomUUID)(),
3555
- jobId: deployment.jobId,
3556
- hostId: deployment.hostId,
3557
- projectPath: deployment.projectPath,
3558
- status: 'running',
3559
- sourceTrigger: deployment.type === 'scheduled' ? 'scheduled' : 'webhook',
3560
- createdAt: startTimestamp,
3561
- updatedAt: startTimestamp,
3562
- messages: [(0, hosts_1.createHubMessage)('manager', instructions)],
3563
- events: [(0, hosts_1.createHubEvent)('system', `Triggered by deployment: ${deployment.label} (${deployment.type})`)],
3564
- currentPhase: null,
3565
- phaseHistory: [],
3566
- totals: emptyTotals(),
3567
- lastStatusChangeAt: startTimestamp,
3568
- // Deployment runs group under the selected employee (hostId), not the job's
3569
- // protected persona. Leaving personaKey unset lets conversationRecordFromRun
3570
- // fall through to agentName (= hostId), which is what the rail groups by.
3571
- personaKey: null,
3572
- };
3573
- // Pre-register before startRun so synchronous onEvent calls (e.g. FakeHostRuntime)
3574
- // can call runRegistry.update without "Run not found" throws.
3575
- this.runRegistry.create(run, {});
3576
- const child = this.hostRuntime.startRun(deployment.hostId, deployment.projectPath, instructions, {
3577
- onEvent: (event, channel) => {
3578
- this.runRegistry.update(run.id, (current) => {
3579
- if (event.sessionId)
3580
- current.sessionId = event.sessionId;
3581
- appendHostMessage(current, deployment.hostId, event, channel);
3582
- if (event.raw) {
3583
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
3584
- applyReviewProjection(current, event.raw);
3585
- }
3586
- if (event.agentIdentity)
3587
- applyAgentIdentitySignal(current, event.agentIdentity);
3588
- if (event.fraimJob)
3589
- this.applyFraimJobSignalToRun(current, event.fraimJob);
3590
- if (event.seekMentoring)
3591
- this.applySeekMentoringSignalToRun(current, event.seekMentoring);
3592
- if (event.usage)
3593
- applyUsageSignal(current, event.usage);
3594
- });
3595
- const updated = this.runRegistry.get(run.id);
3596
- if (updated)
3597
- this.persistRunConversation(updated, updated.conversationId || updated.id);
3598
- },
3599
- onExit: (exitCode) => {
3600
- this.runRegistry.update(run.id, (r) => {
3601
- r.exitCode = exitCode;
3602
- r.status = exitCode === 0 ? 'completed' : 'failed';
3603
- r.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
3604
- });
3605
- const updated = this.runRegistry.get(run.id);
3606
- if (updated)
3607
- this.persistRunConversation(updated, updated.conversationId || updated.id);
3608
- this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
3609
- this.runRegistry.dispose(run.id);
3610
- },
3611
- }, startSessionSeedForHost(deployment.hostId, run.id));
3612
- this.runRegistry.create(run, child);
3613
- this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
3614
- return run;
3615
- }
3616
- // ─── End Issue #578 helpers ───────────────────────────────────────────────
3617
- // Issue #347 — assemble the read-side projection of a run. Stages are
3618
- // derived from job frontmatter + visited phases; totalDurationMs ticks
3619
- // forward while the run is still running so the UI's totals line
3620
- // updates each poll without the server having to rewrite the run on
3621
- // every tick.
3622
- enrichRunForResponse(run) {
3623
- const stages = deriveStages(run, run.projectPath);
3624
- const now = Date.now();
3625
- const created = Date.parse(run.createdAt);
3626
- const updated = Date.parse(run.updatedAt);
3627
- const liveTotalMs = run.status === 'running'
3628
- ? Math.max(0, now - created)
3629
- : Math.max(0, updated - created);
3630
- const baseTotals = run.totals || emptyTotals();
3631
- // While the run is still in its current status (not yet flipped), the
3632
- // current bucket needs to include the in-flight delta since the last
3633
- // flip. Compute it here so the client sees a smoothly increasing
3634
- // working/waiting figure instead of frozen counters between flips.
3635
- const lastFlipMs = run.lastStatusChangeAt
3636
- ? Date.parse(run.lastStatusChangeAt)
3637
- : created;
3638
- const inflightMs = Math.max(0, now - lastFlipMs);
3639
- const liveTotals = {
3640
- totalDurationMs: liveTotalMs,
3641
- workingDurationMs: baseTotals.workingDurationMs + (run.status === 'running' ? inflightMs : 0),
3642
- waitingDurationMs: baseTotals.waitingDurationMs + (run.status !== 'running' ? inflightMs : 0),
3643
- tokenTotals: baseTotals.tokenTotals,
3644
- };
3645
- // Defensive cap: working + waiting should never exceed total. Trim
3646
- // the trailing bucket if rounding pushes us past.
3647
- const sum = liveTotals.workingDurationMs + liveTotals.waitingDurationMs;
3648
- if (sum > liveTotalMs) {
3649
- const overflow = sum - liveTotalMs;
3650
- if (run.status === 'running') {
3651
- liveTotals.workingDurationMs = Math.max(0, liveTotals.workingDurationMs - overflow);
3652
- }
3653
- else {
3654
- liveTotals.waitingDurationMs = Math.max(0, liveTotals.waitingDurationMs - overflow);
3655
- }
3656
- }
3657
- return { ...run, stages, totals: liveTotals, artifacts: run.artifacts || [] };
3658
- }
3659
- }
3660
- exports.AiHubServer = AiHubServer;
3661
- async function findAvailablePort(preferredPort) {
3662
- return findAvailablePortExcluding(preferredPort, new Set());
3663
- }
3664
- async function findAvailablePortExcluding(preferredPort, excludedPorts) {
3665
- let port = preferredPort;
3666
- while (port < preferredPort + 20) {
3667
- if (excludedPorts.has(port)) {
3668
- port += 1;
3669
- continue;
3670
- }
3671
- const available = await new Promise((resolve) => {
3672
- const server = net_1.default.createServer();
3673
- server.once('error', () => resolve(false));
3674
- server.once('listening', () => {
3675
- server.close(() => resolve(true));
3676
- });
3677
- server.listen(port, '127.0.0.1');
3678
- });
3679
- if (available)
3680
- return port;
3681
- port += 1;
3682
- }
3683
- throw new Error(`No free loopback port found near ${preferredPort}.`);
3684
- }
3685
- function resolveAiHubPublicDir() {
3686
- const candidates = [
3687
- path_1.default.resolve(process.cwd(), 'public/ai-hub'),
3688
- path_1.default.resolve(__dirname, '..', '..', 'public/ai-hub'),
3689
- path_1.default.resolve(__dirname, '..', '..', '..', 'public/ai-hub'),
3690
- ];
3691
- for (const candidate of candidates) {
3692
- if (fs_1.default.existsSync(candidate)) {
3693
- return candidate;
3694
- }
3695
- }
3696
- throw new Error('Could not locate public/ai-hub assets.');
3697
- }
3698
- // Issue #489: Resolve the word taskpane static assets directory.
3699
- // Returns null (not throws) when the directory does not exist so the Hub
3700
- // can start without the Word add-in assets present (e.g., older installs).
3701
- function resolveWordTaskpaneDir(projectPath) {
3702
- const base = projectPath || process.cwd();
3703
- const candidates = [
3704
- path_1.default.resolve(base, 'extensions/office-word'),
3705
- path_1.default.resolve(__dirname, '..', '..', 'extensions/office-word'),
3706
- path_1.default.resolve(__dirname, '..', '..', '..', 'extensions/office-word'),
3707
- ];
3708
- for (const candidate of candidates) {
3709
- if (fs_1.default.existsSync(candidate)) {
3710
- return candidate;
3711
- }
3712
- }
3713
- // Word add-in assets not found — /word-taskpane/* routes will not be registered.
3714
- return null;
3715
- }
3716
- // Issue #478: resolve the directory that contains a named Office task pane.
3717
- // Example: resolveTaskpaneDir('powerpoint-taskpane') → <repo>/public/ai-hub/powerpoint-taskpane
3718
- function resolveTaskpaneDir(pane) {
3719
- const aiHubDir = resolveAiHubPublicDir();
3720
- const taskpaneDir = path_1.default.join(aiHubDir, pane);
3721
- if (!fs_1.default.existsSync(taskpaneDir)) {
3722
- throw new Error(`Task pane directory not found: ${taskpaneDir}`);
3723
- }
3724
- return taskpaneDir;
3725
- }
3726
- /**
3727
- * Open the OS-native "choose a folder" dialog and resolve to the selected
3728
- * absolute path, or null if the user cancelled / no dialog was available.
3729
- *
3730
- * This is the genuinely native picker: it spawns the platform's own folder
3731
- * chooser (WinForms FolderBrowserDialog on Windows, `choose folder` on macOS,
3732
- * zenity/kdialog on Linux). The Hub runs on loopback on the user's machine, so
3733
- * it can launch the real dialog itself.
3734
- *
3735
- * Implementation notes (kept in lockstep with src/first-run/server.ts, which
3736
- * proved this out — see the long-form comment there):
3737
- * - ASYNC (`spawn`, not `spawnSync`). The dialog blocks until the user
3738
- * dismisses it, which can be many seconds. With `spawnSync` the entire Node
3739
- * event loop freezes for that whole time — every other Hub HTTP request
3740
- * (bootstrap, run polling) stalls and the Hub looks dead. `spawn` keeps the
3741
- * server responsive while the dialog is up.
3742
- * - Windows needs `-STA` (Single-Threaded Apartment) for FolderBrowserDialog
3743
- * to work reliably, plus a hidden TopMost `$owner` form so the picker comes
3744
- * to the foreground instead of opening behind the browser window.
3745
- * - A hard timeout guarantees the endpoint can never hang forever: if nothing
3746
- * is selected within the window (or the spawn wedges) we resolve null and
3747
- * the caller degrades to "no change".
3748
- * - NON-INTERACTIVE GUARD: a real OS folder dialog blocks waiting for a human.
3749
- * In CI / automated UI tests (which DO reach this live endpoint) that would
3750
- * hang. When AI_HUB_DISABLE_NATIVE_PICKER is set (or NODE_ENV=test) we skip
3751
- * the dialog entirely and resolve null, so the endpoint returns 204 without
3752
- * ever popping a window. Real desktop/loopback usage leaves it unset and
3753
- * gets the genuine native dialog.
3754
- */
3755
- function pickProjectPath() {
3756
- if (process.env.AI_HUB_DISABLE_NATIVE_PICKER === '1' ||
3757
- process.env.NODE_ENV === 'test') {
3758
- return Promise.resolve(null);
3759
- }
3760
- if (process.platform === 'win32') {
3761
- const script = [
3762
- 'Add-Type -AssemblyName System.Windows.Forms',
3763
- '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
3764
- '$dialog.Description = "Select a FRAIM project folder"',
3765
- '$dialog.ShowNewFolderButton = $true',
3766
- // Hidden owner form forces the dialog above the user's browser. Without
3767
- // this the dialog often appears behind the browser tab and the user sees
3768
- // nothing happen when they click the folder button.
3769
- '$owner = New-Object System.Windows.Forms.Form',
3770
- '$owner.TopMost = $true',
3771
- '$owner.ShowInTaskbar = $false',
3772
- 'if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {',
3773
- ' Write-Output $dialog.SelectedPath',
3774
- '}',
3775
- '$owner.Dispose()',
3776
- ].join('; ');
3777
- return runPickerProcess('powershell', ['-NoProfile', '-STA', '-Command', script]);
3778
- }
3779
- if (process.platform === 'darwin') {
3780
- return runPickerProcess('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")']);
3781
- }
3782
- return runPickerProcess('bash', ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null']);
3783
- }
3784
- /**
3785
- * Spawn a native folder-dialog process and resolve to its trimmed stdout (the
3786
- * chosen path) or null. Hardened so it can never hang the server: any spawn
3787
- * error resolves null, and a hard timeout kills a wedged child and resolves
3788
- * null. The timeout is generous (the user may take a while to browse) but
3789
- * finite.
3790
- */
3791
- function runPickerProcess(command, args, timeoutMs = 5 * 60_000) {
3792
- return new Promise((resolve) => {
3793
- let settled = false;
3794
- const finish = (value) => {
3795
- if (settled)
3796
- return;
3797
- settled = true;
3798
- clearTimeout(timer);
3799
- resolve(value);
3800
- };
3801
- let proc;
3802
- try {
3803
- proc = (0, child_process_1.spawn)(command, args, {
3804
- stdio: ['ignore', 'pipe', 'pipe'],
3805
- windowsHide: true,
3806
- });
3807
- }
3808
- catch {
3809
- finish(null);
3810
- return;
3811
- }
3812
- const timer = setTimeout(() => {
3813
- try {
3814
- proc.kill();
3815
- }
3816
- catch {
3817
- /* ignore */
3818
- }
3819
- finish(null);
3820
- }, timeoutMs);
3821
- let stdout = '';
3822
- proc.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
3823
- proc.stderr?.on('data', () => { });
3824
- proc.on('close', () => finish(stdout.trim() || null));
3825
- proc.on('error', () => finish(null));
3826
- });
3827
- }