fraim-hub 2.0.204

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