fraim 2.0.171 → 2.0.173

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 (32) hide show
  1. package/dist/src/ai-hub/hosts.js +227 -6
  2. package/dist/src/ai-hub/server.js +1014 -35
  3. package/dist/src/cli/commands/add-ide.js +2 -0
  4. package/dist/src/cli/commands/cleanup-artifacts.js +38 -0
  5. package/dist/src/cli/commands/init-project.js +12 -5
  6. package/dist/src/cli/commands/sync.js +74 -7
  7. package/dist/src/cli/fraim.js +2 -0
  8. package/dist/src/cli/setup/ide-detector.js +6 -0
  9. package/dist/src/cli/utils/agent-adapters.js +40 -18
  10. package/dist/src/cli/utils/fraim-gitignore.js +13 -0
  11. package/dist/src/cli/utils/remote-sync.js +129 -53
  12. package/dist/src/cli/utils/user-config.js +12 -0
  13. package/dist/src/config/ai-manager-hiring.js +121 -0
  14. package/dist/src/config/compat.js +16 -0
  15. package/dist/src/config/feature-flags.js +25 -0
  16. package/dist/src/config/persona-capability-bundles.js +273 -0
  17. package/dist/src/config/persona-hiring.js +270 -0
  18. package/dist/src/config/portfolio-slug-overrides.js +17 -0
  19. package/dist/src/config/pricing.js +37 -0
  20. package/dist/src/config/stripe.js +43 -0
  21. package/dist/src/core/fraim-config-schema.generated.js +8 -2
  22. package/dist/src/core/utils/local-registry-resolver.js +26 -0
  23. package/dist/src/core/utils/project-fraim-paths.js +89 -2
  24. package/dist/src/first-run/session-service.js +9 -0
  25. package/dist/src/local-mcp-server/artifact-retention-cleanup.js +255 -0
  26. package/dist/src/local-mcp-server/learning-context-builder.js +41 -81
  27. package/dist/src/local-mcp-server/stdio-server.js +42 -7
  28. package/package.json +5 -1
  29. package/public/ai-hub/index.html +205 -89
  30. package/public/ai-hub/review.css +12 -0
  31. package/public/ai-hub/script.js +1720 -240
  32. package/public/ai-hub/styles.css +473 -6
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.AiHubServer = void 0;
39
+ exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
40
  exports.findAvailablePort = findAvailablePort;
41
41
  exports.findAvailablePortExcluding = findAvailablePortExcluding;
42
42
  const express_1 = __importDefault(require("express"));
@@ -166,6 +166,13 @@ class AiHubRunRegistry {
166
166
  this.children.set(run.id, child);
167
167
  return run;
168
168
  }
169
+ attachChildIfRunning(runId, child) {
170
+ const run = this.runs.get(runId);
171
+ if (!run || run.status !== 'running')
172
+ return false;
173
+ this.children.set(runId, child);
174
+ return true;
175
+ }
169
176
  update(runId, updater) {
170
177
  const run = this.runs.get(runId);
171
178
  if (!run) {
@@ -227,6 +234,114 @@ function safeHttpUrl(value) {
227
234
  return null;
228
235
  }
229
236
  }
237
+ // ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
238
+ const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot'];
239
+ class DeploymentStore {
240
+ constructor(filePath) {
241
+ this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
242
+ }
243
+ load() {
244
+ try {
245
+ if (!fs_1.default.existsSync(this.filePath))
246
+ return [];
247
+ return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
248
+ }
249
+ catch {
250
+ return [];
251
+ }
252
+ }
253
+ save(deployments) {
254
+ fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
255
+ fs_1.default.writeFileSync(this.filePath, JSON.stringify(deployments, null, 2));
256
+ }
257
+ create(deployment) {
258
+ const list = this.load();
259
+ list.push(deployment);
260
+ this.save(list);
261
+ return deployment;
262
+ }
263
+ update(id, updater) {
264
+ const list = this.load();
265
+ const dep = list.find((d) => d.id === id);
266
+ if (!dep)
267
+ return false;
268
+ updater(dep);
269
+ dep.updatedAt = new Date().toISOString();
270
+ this.save(list);
271
+ return true;
272
+ }
273
+ delete(id) {
274
+ const list = this.load();
275
+ const next = list.filter((d) => d.id !== id);
276
+ if (next.length === list.length)
277
+ return false;
278
+ this.save(next);
279
+ return true;
280
+ }
281
+ }
282
+ exports.DeploymentStore = DeploymentStore;
283
+ class HostConfigStore {
284
+ constructor(filePath) {
285
+ this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-hosts.json');
286
+ }
287
+ load() {
288
+ try {
289
+ if (!fs_1.default.existsSync(this.filePath))
290
+ return [];
291
+ return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
292
+ }
293
+ catch {
294
+ return [];
295
+ }
296
+ }
297
+ save(hosts) {
298
+ fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
299
+ fs_1.default.writeFileSync(this.filePath, JSON.stringify(hosts, null, 2));
300
+ }
301
+ add(host) {
302
+ const list = this.load();
303
+ list.push(host);
304
+ this.save(list);
305
+ return host;
306
+ }
307
+ delete(id) {
308
+ const list = this.load();
309
+ const next = list.filter((h) => h.id !== id);
310
+ if (next.length === list.length)
311
+ return false;
312
+ this.save(next);
313
+ return true;
314
+ }
315
+ }
316
+ exports.HostConfigStore = HostConfigStore;
317
+ async function pingHost(host) {
318
+ const start = Date.now();
319
+ try {
320
+ const resp = await fetch(`${host.url.replace(/\/$/, '')}/health`, {
321
+ signal: AbortSignal.timeout(5000),
322
+ headers: host.authToken ? { 'X-Hub-Auth': host.authToken } : {},
323
+ });
324
+ const latencyMs = Date.now() - start;
325
+ return {
326
+ id: host.id,
327
+ label: host.label,
328
+ url: host.url,
329
+ status: resp.ok ? 'online' : 'degraded',
330
+ latencyMs,
331
+ lastPingAt: new Date().toISOString(),
332
+ };
333
+ }
334
+ catch {
335
+ return {
336
+ id: host.id,
337
+ label: host.label,
338
+ url: host.url,
339
+ status: 'offline',
340
+ latencyMs: null,
341
+ lastPingAt: new Date().toISOString(),
342
+ };
343
+ }
344
+ }
230
345
  function normalizeReviewArtifact(raw, index = 0) {
231
346
  if (!raw || typeof raw !== 'object')
232
347
  return null;
@@ -289,6 +404,114 @@ function normalizeReviewHandoff(raw) {
289
404
  }
290
405
  return null;
291
406
  }
407
+ const DELEGATION_TASK_STATUSES = new Set([
408
+ 'planned',
409
+ 'running',
410
+ 'submitted',
411
+ 'reviewed',
412
+ 'blocked',
413
+ 'completed',
414
+ 'failed',
415
+ ]);
416
+ function cleanString(value) {
417
+ return typeof value === 'string' ? value.trim() : '';
418
+ }
419
+ function cleanNullableString(value) {
420
+ const cleaned = cleanString(value);
421
+ return cleaned.length > 0 ? cleaned : null;
422
+ }
423
+ function normalizeDelegationLedger(raw) {
424
+ if (!raw || typeof raw !== 'object')
425
+ return null;
426
+ const value = raw;
427
+ // Accept delegationRequired:true OR presence of goal/objective + tasks/jobs/groups (AI may omit the flag).
428
+ const hasFlag = value.delegationRequired === true;
429
+ const objective = cleanString((value.objective || value.goal));
430
+ if (!hasFlag && !objective)
431
+ return null;
432
+ if (!objective)
433
+ return null;
434
+ // Flatten tasks from multiple schemas:
435
+ // canonical: tasks/jobs: [{title, taskId, personaKey, jobId, instructions, dependsOn}]
436
+ // group-based: groups: [{runs: [{run_id, job, persona, briefing, expected_artifact}]}]
437
+ // with optional depends_on referencing other run_id values
438
+ let rawTasks = [];
439
+ const rawJobs = (value.jobs || value.tasks);
440
+ if (Array.isArray(rawJobs)) {
441
+ rawTasks = rawJobs;
442
+ }
443
+ else if (Array.isArray(value.groups)) {
444
+ // Flatten groups[].runs into a linear task list, resolving depends_on from group membership.
445
+ let groupDepIds = [];
446
+ for (const group of value.groups) {
447
+ const runs = Array.isArray(group.runs) ? group.runs : [];
448
+ const currentIds = runs.map((r) => cleanString(r.run_id) || '').filter(Boolean);
449
+ for (const run of runs) {
450
+ // Synthesise a canonical task from the run schema.
451
+ const dependsOnRaw = Array.isArray(group.depends_on) ? group.depends_on.map(String) : groupDepIds.length ? groupDepIds : [];
452
+ rawTasks.push({
453
+ taskId: run.run_id,
454
+ title: cleanString(run.briefing)?.slice(0, 80) || cleanString(run.job) || 'Task',
455
+ personaKey: run.persona,
456
+ jobId: run.job,
457
+ instructions: run.briefing,
458
+ dependsOn: dependsOnRaw,
459
+ status: 'planned',
460
+ });
461
+ }
462
+ groupDepIds = currentIds;
463
+ }
464
+ }
465
+ const tasks = rawTasks.map((task, index) => {
466
+ if (!task || typeof task !== 'object')
467
+ return null;
468
+ const rawTask = task;
469
+ // Accept title or first 80 chars of briefing/instructions
470
+ const title = cleanString((rawTask.title || rawTask.briefing || rawTask.instructions))?.slice(0, 80);
471
+ if (!title)
472
+ return null;
473
+ const taskId = cleanString((rawTask.taskId || rawTask.run_id || rawTask.id))
474
+ || title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
475
+ || `task-${index + 1}`;
476
+ const rawStatus = cleanString(rawTask.status);
477
+ const status = DELEGATION_TASK_STATUSES.has(rawStatus) ? rawStatus : 'planned';
478
+ const artifacts = Array.isArray(rawTask.artifacts)
479
+ ? rawTask.artifacts.map((a, ai) => normalizeReviewArtifact(a, ai)).filter((a) => Boolean(a))
480
+ : [];
481
+ const reviewHandoff = normalizeReviewHandoff(rawTask.reviewHandoff);
482
+ // Accept dependsOn or depends_on as string array
483
+ const rawDeps = rawTask.dependsOn || rawTask.depends_on;
484
+ return {
485
+ taskId,
486
+ title,
487
+ status,
488
+ personaKey: cleanNullableString((rawTask.personaKey || rawTask.persona)),
489
+ jobId: cleanNullableString((rawTask.jobId || rawTask.job || rawTask.job_id)),
490
+ reviewJobId: cleanNullableString(rawTask.reviewJobId),
491
+ reviewType: cleanNullableString(rawTask.reviewType),
492
+ instructions: cleanString((rawTask.instructions || rawTask.briefing)) || undefined,
493
+ latestSummary: cleanString(rawTask.latestSummary) || undefined,
494
+ hostThreadId: cleanNullableString(rawTask.hostThreadId),
495
+ hostSessionId: cleanNullableString(rawTask.hostSessionId),
496
+ runId: cleanNullableString(rawTask.runId),
497
+ conversationId: cleanNullableString(rawTask.conversationId),
498
+ dependsOn: Array.isArray(rawDeps) ? rawDeps.map(String).filter(Boolean) : [],
499
+ artifacts,
500
+ reviewHandoff,
501
+ };
502
+ }).filter((task) => Boolean(task));
503
+ if (tasks.length === 0)
504
+ return null;
505
+ return {
506
+ delegationRequired: true,
507
+ objective,
508
+ orchestratorPersonaKey: cleanNullableString((value.orchestratorPersonaKey || value.manager_persona)),
509
+ rootRunId: cleanNullableString(value.rootRunId),
510
+ managerRunId: cleanNullableString((value.managerRunId || value.manager_session)),
511
+ latestSummary: cleanString(value.latestSummary) || undefined,
512
+ tasks,
513
+ };
514
+ }
292
515
  function extractReviewHandoffFromText(text) {
293
516
  if (!text || !/reviewRequired|reviewTarget|review_handoff/i.test(text))
294
517
  return null;
@@ -313,36 +536,102 @@ function extractReviewHandoffFromText(text) {
313
536
  }
314
537
  return null;
315
538
  }
316
- const ARTIFACT_PATH_RE = /([A-Za-z0-9_\-.\\/]*?(?:docs|public|src|tests)[\\/][A-Za-z0-9_\-.\\/]+\.[A-Za-z0-9]+)/;
539
+ function extractDelegationLedgerFromText(text) {
540
+ if (!text || !/delegationRequired|delegation_ledger|delegationLedger|"goal"\s*:.*"groups"\s*:/is.test(text))
541
+ return null;
542
+ const candidates = [];
543
+ for (const match of String(text).matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
544
+ candidates.push(match[1]);
545
+ const tagged = String(text).match(/<delegation_ledger>\s*([\s\S]*?)\s*<\/delegation_ledger>/i);
546
+ if (tagged)
547
+ candidates.push(tagged[1]);
548
+ const inline = String(text).match(/(\{\s*"delegationRequired"[\s\S]*\})/i);
549
+ if (inline)
550
+ candidates.push(inline[1]);
551
+ for (const candidate of candidates) {
552
+ try {
553
+ const ledger = normalizeDelegationLedger(JSON.parse(candidate.trim()));
554
+ if (ledger)
555
+ return ledger;
556
+ }
557
+ catch {
558
+ // Malformed delegation snippets are ignored; the host transcript remains available in Micro-manage.
559
+ }
560
+ }
561
+ return null;
562
+ }
317
563
  const ARTIFACT_EXCLUDE_RE = /(^|[\\/])(retrospectives|evidence|learnings|mocks|raw|archive)[\\/]/i;
318
- function extractReviewArtifactFromText(text, projectPath) {
564
+ function extractReviewArtifactsFromText(text, projectPath) {
319
565
  if (!text)
320
- return null;
321
- const match = text.match(ARTIFACT_PATH_RE);
322
- if (!match)
323
- return null;
324
- const relativePath = match[1].replace(/\\/g, '/');
325
- if (ARTIFACT_EXCLUDE_RE.test(relativePath))
326
- return null;
327
- const parts = relativePath.split('/').filter(Boolean);
328
- const name = parts[parts.length - 1] || relativePath;
329
- const artifactPath = path_1.default.isAbsolute(relativePath) ? relativePath : path_1.default.join(projectPath, relativePath);
330
- return { type: path_1.default.extname(name).replace(/^\./, '') || 'file', label: name, path: artifactPath };
566
+ return [];
567
+ const artifacts = [];
568
+ const seen = new Set();
569
+ const matches = String(text)
570
+ .split(/[\s`"'()<>{}\[\],;:]+/)
571
+ .filter((token) => /^(docs|public|src|tests)[\\/]/.test(token));
572
+ for (const candidate of matches) {
573
+ const relativePath = candidate.replace(/[.)]+$/g, '').replace(/\\/g, '/');
574
+ if (!/\.[A-Za-z0-9]+$/.test(relativePath))
575
+ continue;
576
+ if (ARTIFACT_EXCLUDE_RE.test(relativePath))
577
+ continue;
578
+ const parts = relativePath.split('/').filter(Boolean);
579
+ const name = parts[parts.length - 1] || relativePath;
580
+ const artifactPath = path_1.default.isAbsolute(relativePath) ? relativePath : path_1.default.join(projectPath, relativePath);
581
+ if (seen.has(artifactPath))
582
+ continue;
583
+ seen.add(artifactPath);
584
+ artifacts.push({ type: path_1.default.extname(name).replace(/^\./, '') || 'file', label: name, path: artifactPath });
585
+ }
586
+ return artifacts;
331
587
  }
332
588
  function applyReviewProjection(run, text) {
589
+ const delegation = extractDelegationLedgerFromText(text);
590
+ if (delegation) {
591
+ run.delegation = {
592
+ ...delegation,
593
+ rootRunId: delegation.rootRunId || run.id,
594
+ managerRunId: delegation.managerRunId || run.id,
595
+ };
596
+ }
333
597
  const handoff = extractReviewHandoffFromText(text);
334
598
  if (handoff) {
335
599
  run.reviewHandoff = handoff;
336
600
  run.artifacts = handoff.artifacts;
337
601
  return;
338
602
  }
339
- const artifact = extractReviewArtifactFromText(text, run.projectPath);
340
- if (!artifact)
603
+ const artifacts = extractReviewArtifactsFromText(text, run.projectPath);
604
+ if (!artifacts.length)
341
605
  return;
342
606
  run.artifacts = run.artifacts || [];
343
- if (!run.artifacts.some((entry) => entry.path === artifact.path || entry.url === artifact.url)) {
344
- run.artifacts.push(artifact);
607
+ for (const artifact of artifacts) {
608
+ if (!run.artifacts.some((entry) => (entry.path && artifact.path && entry.path === artifact.path) || (entry.url && artifact.url && entry.url === artifact.url))) {
609
+ run.artifacts.push(artifact);
610
+ }
611
+ }
612
+ }
613
+ function mergeReviewArtifacts(existing, discovered) {
614
+ const merged = [...(existing || [])];
615
+ for (const artifact of discovered) {
616
+ if (!merged.some((entry) => (entry.path && artifact.path && entry.path === artifact.path) || (entry.url && artifact.url && entry.url === artifact.url))) {
617
+ merged.push(artifact);
618
+ }
619
+ }
620
+ return merged;
621
+ }
622
+ function extractRunReviewArtifacts(run) {
623
+ const discovered = [];
624
+ for (const message of run.messages || []) {
625
+ if (message.role !== 'employee')
626
+ continue;
627
+ discovered.push(...extractReviewArtifactsFromText(message.text, run.projectPath));
628
+ }
629
+ for (const event of run.events || []) {
630
+ if (event.channel !== 'stdout')
631
+ continue;
632
+ discovered.push(...extractReviewArtifactsFromText(event.text, run.projectPath));
345
633
  }
634
+ return mergeReviewArtifacts(run.artifacts, discovered);
346
635
  }
347
636
  function emptyTotals() {
348
637
  return {
@@ -408,6 +697,12 @@ function applyAgentIdentitySignal(run, identity) {
408
697
  run.agentName = identity.agentName;
409
698
  run.agentModel = identity.agentModel;
410
699
  }
700
+ function stripStructuredHostPayloads(text) {
701
+ return text
702
+ .replace(/<delegation_ledger>\s*[\s\S]*?\s*<\/delegation_ledger>/gi, '')
703
+ .replace(/<review_handoff>\s*[\s\S]*?\s*<\/review_handoff>/gi, '')
704
+ .trim();
705
+ }
411
706
  function appendHostMessage(run, hostId, event, channel) {
412
707
  if (!event.message || channel !== 'stdout')
413
708
  return;
@@ -417,10 +712,17 @@ function appendHostMessage(run, hostId, event, channel) {
417
712
  if (last?.role === 'employee') {
418
713
  last.text = `${last.text}\n${event.message}`;
419
714
  last.createdAt = new Date().toISOString();
715
+ applyReviewProjection(run, last.text);
716
+ last.text = stripStructuredHostPayloads(last.text);
717
+ if (!last.text)
718
+ run.messages.pop();
420
719
  return;
421
720
  }
422
721
  }
423
- run.messages.push((0, hosts_1.createHubMessage)('employee', event.message));
722
+ const displayMessage = stripStructuredHostPayloads(event.message);
723
+ if (!displayMessage)
724
+ return;
725
+ run.messages.push((0, hosts_1.createHubMessage)('employee', displayMessage));
424
726
  }
425
727
  // Apply a parsed seekMentoring tool-use signal from the host stream to
426
728
  // the run state. Returns the updated currentPhase.
@@ -437,6 +739,16 @@ function applySeekMentoringSignal(run, signal) {
437
739
  return;
438
740
  if (signal.reviewHandoff)
439
741
  run.reviewHandoff = signal.reviewHandoff;
742
+ if (signal.delegationLedger &&
743
+ run.jobId === 'fully-delegate' &&
744
+ signal.phaseStatus === 'complete' &&
745
+ (signal.phaseId === 'confirm-or-fallback' || signal.phaseId === 'create-delegation-graph')) {
746
+ run.delegation = {
747
+ ...signal.delegationLedger,
748
+ rootRunId: signal.delegationLedger.rootRunId || run.id,
749
+ managerRunId: signal.delegationLedger.managerRunId || run.id,
750
+ };
751
+ }
440
752
  // Discriminant signals are routing hints only — they don't move the
441
753
  // tracker, but they do change which phase id will be considered
442
754
  // reachable next time stages are derived. Persist on the run.
@@ -792,9 +1104,13 @@ function countMarkdownFilesRecursive(dirPath) {
792
1104
  return total;
793
1105
  }
794
1106
  class AiHubServer {
1107
+ get hubBase() {
1108
+ return process.env.FRAIM_HUB_BASE_URL || `http://127.0.0.1:${this.httpPort}`;
1109
+ }
795
1110
  constructor(options = {}) {
796
1111
  this.app = (0, express_1.default)();
797
1112
  this.runRegistry = new AiHubRunRegistry();
1113
+ this.cronHandles = new Map();
798
1114
  this.projectPath = options.projectPath || process.cwd();
799
1115
  this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
800
1116
  this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
@@ -817,7 +1133,9 @@ class AiHubServer {
817
1133
  this.dbService = createDefaultDbService();
818
1134
  this.ownsDbService = this.dbService !== undefined;
819
1135
  }
820
- this.app.use(express_1.default.json());
1136
+ this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
1137
+ this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
1138
+ this.app.use(express_1.default.json({ limit: '10mb' }));
821
1139
  if (this.dbService) {
822
1140
  const { registerPaymentRoutes } = require('../routes/payment-routes');
823
1141
  registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
@@ -961,6 +1279,8 @@ class AiHubServer {
961
1279
  // so it's known even before the browser is running.
962
1280
  process.env.FRAIM_BROWSER_CDP_ENDPOINT = this.managedBrowser.cdpEndpoint();
963
1281
  process.env.FRAIM_HUB_BASE_URL = `http://127.0.0.1:${port}`;
1282
+ // Issue #578: rehydrate active scheduled deployments from disk.
1283
+ this.rehydrateScheduledDeployments();
964
1284
  // Start HTTPS server when a cert bundle and port are provided.
965
1285
  // Word Online requires HTTPS; the HTTPS server shares the same Express app
966
1286
  // so all routes (including /word-taskpane/*) are available over both protocols.
@@ -987,10 +1307,19 @@ class AiHubServer {
987
1307
  }
988
1308
  async stop() {
989
1309
  const closeServer = (srv) => new Promise((resolve, reject) => {
990
- srv.close((error) => { if (error)
991
- reject(error);
992
- else
993
- resolve(); });
1310
+ const closable = srv;
1311
+ closable.closeIdleConnections?.();
1312
+ const forceCloseTimer = setTimeout(() => {
1313
+ closable.closeIdleConnections?.();
1314
+ closable.closeAllConnections?.();
1315
+ }, 250);
1316
+ srv.close((error) => {
1317
+ clearTimeout(forceCloseTimer);
1318
+ if (error)
1319
+ reject(error);
1320
+ else
1321
+ resolve();
1322
+ });
994
1323
  });
995
1324
  if (this.httpsServer) {
996
1325
  await closeServer(this.httpsServer);
@@ -1000,6 +1329,11 @@ class AiHubServer {
1000
1329
  await closeServer(this.httpServer);
1001
1330
  this.httpServer = undefined;
1002
1331
  }
1332
+ // Issue #578: stop all active scheduled deployments.
1333
+ for (const [, task] of this.cronHandles) {
1334
+ task.stop();
1335
+ }
1336
+ this.cronHandles.clear();
1003
1337
  // #521: tear down the shared browser if WE launched it (stop() no-ops on a
1004
1338
  // browser the manager owns).
1005
1339
  this.managedBrowser.stop();
@@ -1094,8 +1428,16 @@ class AiHubServer {
1094
1428
  })),
1095
1429
  artifacts: run.artifacts || [],
1096
1430
  reviewHandoff: run.reviewHandoff || null,
1431
+ delegation: run.delegation || null,
1432
+ delegationTaskId: run.delegationTaskId || null,
1433
+ managedByRunId: run.managedByRunId || null,
1434
+ managedByPersonaKey: run.managedByPersonaKey || null,
1435
+ humanCoachingDisabled: run.humanCoachingDisabled || false,
1436
+ managedReviewStatus: run.managedReviewStatus || null,
1097
1437
  compareMode: run.runRole === 'fraim' && run.compareRunId ? 'ab' : undefined,
1098
1438
  compareRunId: run.compareRunId || null,
1439
+ // Issue #578: preserve trigger source so the UI can render the chip.
1440
+ sourceTrigger: run.sourceTrigger,
1099
1441
  };
1100
1442
  }
1101
1443
  persistRunConversation(run, activeId) {
@@ -1109,6 +1451,280 @@ class AiHubServer {
1109
1451
  // Issue #512 (S3, R13) — derive the four Get-started step states, then let any
1110
1452
  // persisted `true` in ~/.fraim/{install-state,preferences}.json override the
1111
1453
  // derivation (so a completed step stays completed even if its signal vanishes).
1454
+ maybeStartDelegatedChildRuns(managerRun) {
1455
+ const ledger = managerRun.delegation;
1456
+ if (!ledger || managerRun.managedByRunId)
1457
+ return;
1458
+ managerRun.orchestratedDelegationTaskIds = managerRun.orchestratedDelegationTaskIds || [];
1459
+ const started = new Set(managerRun.orchestratedDelegationTaskIds);
1460
+ for (const task of ledger.tasks || []) {
1461
+ if (!task.taskId || started.has(task.taskId))
1462
+ continue;
1463
+ if (!task.jobId || !this.delegationDependenciesSatisfied(ledger, task.dependsOn || []))
1464
+ continue;
1465
+ if (!task.personaKey)
1466
+ task.personaKey = getProtectedPersonaForHubJob(task.jobId);
1467
+ this.startDelegatedChildRun(managerRun, task.taskId);
1468
+ started.add(task.taskId);
1469
+ managerRun.orchestratedDelegationTaskIds.push(task.taskId);
1470
+ }
1471
+ }
1472
+ delegationDependenciesSatisfied(ledger, dependsOn) {
1473
+ if (!dependsOn.length)
1474
+ return true;
1475
+ const terminal = new Set(['reviewed', 'completed']);
1476
+ return dependsOn.every((dependency) => {
1477
+ const matched = ledger.tasks.find((task) => task.taskId === dependency || task.jobId === dependency);
1478
+ return !!matched && terminal.has(matched.status);
1479
+ });
1480
+ }
1481
+ startDelegatedChildRun(managerRun, taskId) {
1482
+ const ledger = managerRun.delegation;
1483
+ const task = ledger?.tasks.find((entry) => entry.taskId === taskId);
1484
+ if (!ledger || !task || !task.jobId)
1485
+ return;
1486
+ if (!task.personaKey)
1487
+ task.personaKey = getProtectedPersonaForHubJob(task.jobId);
1488
+ const resolvedJob = this.resolveHubJob(managerRun.projectPath, task.jobId);
1489
+ if (!resolvedJob) {
1490
+ task.status = 'blocked';
1491
+ task.latestSummary = `Delegation blocked: job "${task.jobId}" is not available in this project.`;
1492
+ managerRun.events.push((0, hosts_1.createHubEvent)('system', task.latestSummary));
1493
+ this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1494
+ return;
1495
+ }
1496
+ const childConversationId = task.conversationId || `${managerRun.id}-${task.taskId}`;
1497
+ const now = new Date().toISOString();
1498
+ const childInstructions = [
1499
+ task.instructions || task.latestSummary || `Complete the delegated workstream: ${task.title}.`,
1500
+ '',
1501
+ task.personaKey
1502
+ ? `You are working as ${task.personaKey} for the manager job.`
1503
+ : 'You are working as the specialist assigned by this delegated job.',
1504
+ `Parent objective: ${ledger.objective}.`,
1505
+ task.reviewJobId ? `Manager review route: this output should be reviewed using ${task.reviewJobId}.` : '',
1506
+ 'Submit a concise deliverable summary and any artifact references back to Mandy.',
1507
+ '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.',
1508
+ 'Do not ask the human for coaching; Mandy is your manager for this workstream.',
1509
+ ].join('\n');
1510
+ const prepared = this.prepareStartPayload(managerRun.projectPath, managerRun.hostId, task.jobId, childInstructions);
1511
+ const childRun = {
1512
+ id: (0, crypto_1.randomUUID)(),
1513
+ conversationId: childConversationId,
1514
+ conversationTitle: task.title,
1515
+ jobTitle: task.title,
1516
+ jobId: prepared.jobId || task.jobId,
1517
+ hostId: managerRun.hostId,
1518
+ projectPath: managerRun.projectPath,
1519
+ status: 'running',
1520
+ createdAt: now,
1521
+ updatedAt: now,
1522
+ messages: [(0, hosts_1.createHubMessage)('manager', `Mandy delegated: ${task.title}\n\n${childInstructions}`)],
1523
+ events: [(0, hosts_1.createHubEvent)('system', `Mandy started delegated workstream ${task.taskId} for ${task.personaKey || task.jobId}.`)],
1524
+ currentPhase: null,
1525
+ phaseHistory: [],
1526
+ totals: emptyTotals(),
1527
+ lastStatusChangeAt: now,
1528
+ personaKey: task.personaKey,
1529
+ delegationTaskId: task.taskId,
1530
+ managedByRunId: managerRun.id,
1531
+ managedByPersonaKey: managerRun.personaKey || ledger.orchestratorPersonaKey || 'mandy',
1532
+ humanCoachingDisabled: true,
1533
+ };
1534
+ task.runId = childRun.id;
1535
+ task.conversationId = childConversationId;
1536
+ task.status = 'running';
1537
+ this.runRegistry.create(childRun, {});
1538
+ this.persistRunConversation(childRun);
1539
+ this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1540
+ const child = this.hostRuntime.startRun(managerRun.hostId, managerRun.projectPath, prepared.message, {
1541
+ onEvent: (event, channel) => {
1542
+ this.runRegistry.update(childRun.id, (current) => {
1543
+ if (event.sessionId)
1544
+ current.sessionId = event.sessionId;
1545
+ appendHostMessage(current, managerRun.hostId, event, channel);
1546
+ if (event.raw) {
1547
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
1548
+ applyReviewProjection(current, event.raw);
1549
+ }
1550
+ if (event.agentIdentity)
1551
+ applyAgentIdentitySignal(current, event.agentIdentity);
1552
+ if (event.seekMentoring)
1553
+ applySeekMentoringSignal(current, event.seekMentoring);
1554
+ if (event.usage)
1555
+ applyUsageSignal(current, event.usage);
1556
+ });
1557
+ const updatedChild = this.runRegistry.get(childRun.id);
1558
+ if (updatedChild)
1559
+ this.persistRunConversation(updatedChild);
1560
+ },
1561
+ onExit: (exitCode) => {
1562
+ this.runRegistry.update(childRun.id, (current) => {
1563
+ current.exitCode = exitCode;
1564
+ current.status = exitCode === 0 ? 'completed' : 'failed';
1565
+ current.events.push((0, hosts_1.createHubEvent)('system', `Delegated workstream exited with code ${exitCode ?? 'unknown'}.`));
1566
+ });
1567
+ const updatedChild = this.runRegistry.get(childRun.id);
1568
+ if (updatedChild) {
1569
+ this.persistRunConversation(updatedChild);
1570
+ this.markDelegatedChildSubmitted(managerRun.id, updatedChild);
1571
+ this.notifyManagerOfDelegatedChild(managerRun.id, updatedChild);
1572
+ }
1573
+ this.runRegistry.dispose(childRun.id);
1574
+ },
1575
+ }, childRun.sessionId);
1576
+ this.runRegistry.attachChildIfRunning(childRun.id, child);
1577
+ }
1578
+ notifyManagerOfDelegatedChild(managerRunId, childRun) {
1579
+ const managerRun = this.runRegistry.get(managerRunId);
1580
+ if (!managerRun) {
1581
+ childRun.events.push((0, hosts_1.createHubEvent)('system', 'Delegated deliverable could not be routed because the manager run is unavailable.'));
1582
+ this.persistRunConversation(childRun);
1583
+ return;
1584
+ }
1585
+ if (!managerRun.sessionId || managerRun.status === 'running') {
1586
+ this.enqueueDelegatedReview(managerRun, childRun, !managerRun.sessionId ? 'manager session is unavailable' : 'manager is still running');
1587
+ return;
1588
+ }
1589
+ const latest = [...(childRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
1590
+ const artifacts = childRun.reviewHandoff?.artifacts?.map((artifact) => artifact.label || artifact.path || artifact.url).filter(Boolean).join(', ') || 'none reported';
1591
+ const reviewPrompt = [
1592
+ `Delegated workstream submitted by ${childRun.personaKey || 'a peer agent'}: ${childRun.conversationTitle || childRun.jobTitle || childRun.jobId}.`,
1593
+ `Status: ${childRun.status}.`,
1594
+ `Latest deliverable summary: ${latest || 'No employee summary captured.'}`,
1595
+ `Artifacts: ${artifacts}.`,
1596
+ childRun.delegationTaskId ? this.delegatedReviewRouteLine(managerRun, childRun.delegationTaskId) : '',
1597
+ '',
1598
+ '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.',
1599
+ ].join('\n');
1600
+ this.continueManagerRunForDelegation(managerRun, reviewPrompt, `Mandy reviews ${childRun.conversationTitle || childRun.jobTitle || childRun.jobId}`, childRun);
1601
+ }
1602
+ delegatedReviewRouteLine(managerRun, taskId) {
1603
+ const task = managerRun.delegation?.tasks.find((entry) => entry.taskId === taskId);
1604
+ if (!task?.reviewJobId)
1605
+ return '';
1606
+ return `Declared manager review job: ${task.reviewJobId}${task.reviewType ? ` (${task.reviewType})` : ''}. Use that review standard when judging the deliverable.`;
1607
+ }
1608
+ enqueueDelegatedReview(managerRun, childRun, reason) {
1609
+ managerRun.pendingDelegatedReviewChildRunIds = managerRun.pendingDelegatedReviewChildRunIds || [];
1610
+ if (!managerRun.pendingDelegatedReviewChildRunIds.includes(childRun.id)) {
1611
+ managerRun.pendingDelegatedReviewChildRunIds.push(childRun.id);
1612
+ managerRun.events.push((0, hosts_1.createHubEvent)('system', `Delegated deliverable queued for Mandy review because ${reason}.`));
1613
+ }
1614
+ childRun.events.push((0, hosts_1.createHubEvent)('system', `Deliverable submitted to Mandy; review is queued because ${reason}.`));
1615
+ this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1616
+ this.persistRunConversation(childRun);
1617
+ }
1618
+ drainPendingDelegatedReviews(managerRun) {
1619
+ if (managerRun.status === 'running' || !managerRun.sessionId)
1620
+ return;
1621
+ const queue = managerRun.pendingDelegatedReviewChildRunIds || [];
1622
+ while (queue.length > 0) {
1623
+ const childRunId = queue.shift();
1624
+ const childRun = this.runRegistry.get(childRunId);
1625
+ if (!childRun || childRun.managedReviewStatus === 'reviewed')
1626
+ continue;
1627
+ managerRun.pendingDelegatedReviewChildRunIds = queue;
1628
+ this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1629
+ this.notifyManagerOfDelegatedChild(managerRun.id, childRun);
1630
+ return;
1631
+ }
1632
+ managerRun.pendingDelegatedReviewChildRunIds = queue;
1633
+ this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1634
+ }
1635
+ markDelegatedChildSubmitted(managerRunId, childRun) {
1636
+ const managerRun = this.runRegistry.get(managerRunId);
1637
+ if (!managerRun?.delegation)
1638
+ return;
1639
+ const task = managerRun.delegation.tasks.find((entry) => entry.taskId === childRun.delegationTaskId || entry.runId === childRun.id);
1640
+ if (!task)
1641
+ return;
1642
+ task.status = childRun.status === 'completed' ? 'submitted' : 'failed';
1643
+ const latest = [...(childRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
1644
+ if (latest)
1645
+ task.latestSummary = latest;
1646
+ if (childRun.reviewHandoff)
1647
+ task.reviewHandoff = childRun.reviewHandoff;
1648
+ const artifacts = childRun.reviewHandoff?.artifacts?.length ? childRun.reviewHandoff.artifacts : childRun.artifacts || [];
1649
+ if (artifacts.length)
1650
+ task.artifacts = artifacts;
1651
+ this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
1652
+ }
1653
+ markDelegatedChildReviewed(childRun, managerRun) {
1654
+ const reviewText = [...(managerRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
1655
+ childRun.managedReviewStatus = 'reviewed';
1656
+ childRun.updatedAt = new Date().toISOString();
1657
+ if (reviewText) {
1658
+ childRun.messages.push((0, hosts_1.createHubMessage)('manager', `Mandy reviewed this workstream:\n\n${reviewText}`));
1659
+ }
1660
+ childRun.events.push((0, hosts_1.createHubEvent)('system', 'Mandy reviewed the delegated deliverable.'));
1661
+ this.persistRunConversation(childRun);
1662
+ const task = managerRun.delegation?.tasks.find((entry) => entry.taskId === childRun.delegationTaskId || entry.runId === childRun.id);
1663
+ if (task) {
1664
+ task.status = 'reviewed';
1665
+ task.latestSummary = reviewText || task.latestSummary;
1666
+ if (childRun.reviewHandoff)
1667
+ task.reviewHandoff = childRun.reviewHandoff;
1668
+ const artifacts = childRun.reviewHandoff?.artifacts?.length ? childRun.reviewHandoff.artifacts : childRun.artifacts || [];
1669
+ if (artifacts.length)
1670
+ task.artifacts = artifacts;
1671
+ }
1672
+ this.maybeStartDelegatedChildRuns(managerRun);
1673
+ }
1674
+ continueManagerRunForDelegation(managerRun, instructions, display, reviewedChildRun) {
1675
+ if (!managerRun.sessionId || managerRun.status === 'running')
1676
+ return;
1677
+ const prepared = this.prepareContinueMessage(managerRun, instructions);
1678
+ this.runRegistry.update(managerRun.id, (current) => {
1679
+ current.status = 'running';
1680
+ current.messages.push((0, hosts_1.createHubMessage)('manager', display));
1681
+ current.events.push((0, hosts_1.createHubEvent)('system', 'Delegated child output routed back to Mandy for review.'));
1682
+ });
1683
+ const started = this.runRegistry.get(managerRun.id);
1684
+ if (started)
1685
+ this.persistRunConversation(started, started.conversationId || started.id);
1686
+ this.runRegistry.create(managerRun, {});
1687
+ const child = this.hostRuntime.continueRun(managerRun.hostId, managerRun.projectPath, managerRun.sessionId, prepared.message, {
1688
+ onEvent: (event, channel) => {
1689
+ this.runRegistry.update(managerRun.id, (current) => {
1690
+ if (event.sessionId)
1691
+ current.sessionId = event.sessionId;
1692
+ appendHostMessage(current, managerRun.hostId, event, channel);
1693
+ if (event.raw) {
1694
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
1695
+ applyReviewProjection(current, event.raw);
1696
+ }
1697
+ if (event.agentIdentity)
1698
+ applyAgentIdentitySignal(current, event.agentIdentity);
1699
+ if (event.seekMentoring)
1700
+ applySeekMentoringSignal(current, event.seekMentoring);
1701
+ if (event.usage)
1702
+ applyUsageSignal(current, event.usage);
1703
+ });
1704
+ const updated = this.runRegistry.get(managerRun.id);
1705
+ if (updated)
1706
+ this.persistRunConversation(updated, updated.conversationId || updated.id);
1707
+ },
1708
+ onExit: (exitCode) => {
1709
+ this.runRegistry.update(managerRun.id, (current) => {
1710
+ current.exitCode = exitCode;
1711
+ current.status = exitCode === 0 ? 'completed' : 'failed';
1712
+ current.events.push((0, hosts_1.createHubEvent)('system', `Mandy review turn exited with code ${exitCode ?? 'unknown'}.`));
1713
+ });
1714
+ const updated = this.runRegistry.get(managerRun.id);
1715
+ if (updated) {
1716
+ if (exitCode === 0 && reviewedChildRun)
1717
+ this.markDelegatedChildReviewed(reviewedChildRun, updated);
1718
+ this.persistRunConversation(updated, updated.conversationId || updated.id);
1719
+ }
1720
+ this.runRegistry.dispose(managerRun.id);
1721
+ const latestManager = this.runRegistry.get(managerRun.id);
1722
+ if (latestManager)
1723
+ this.drainPendingDelegatedReviews(latestManager);
1724
+ },
1725
+ });
1726
+ this.runRegistry.attachChildIfRunning(managerRun.id, child);
1727
+ }
1112
1728
  computeFirstRun(projectPath, jobCount, personas) {
1113
1729
  const tc = (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
1114
1730
  // company = any organization-layer context written by org onboarding.
@@ -1827,8 +2443,10 @@ class AiHubServer {
1827
2443
  applyUsageSignal(current, event.usage);
1828
2444
  });
1829
2445
  const updated = this.runRegistry.get(run.id);
1830
- if (updated)
2446
+ if (updated) {
2447
+ this.maybeStartDelegatedChildRuns(updated);
1831
2448
  this.persistRunConversation(updated, updated.conversationId || updated.id);
2449
+ }
1832
2450
  },
1833
2451
  onExit: (exitCode) => {
1834
2452
  this.runRegistry.update(run.id, (current) => {
@@ -1844,12 +2462,17 @@ class AiHubServer {
1844
2462
  }
1845
2463
  });
1846
2464
  const updated = this.runRegistry.get(run.id);
1847
- if (updated)
2465
+ if (updated) {
2466
+ this.maybeStartDelegatedChildRuns(updated);
1848
2467
  this.persistRunConversation(updated, updated.conversationId || updated.id);
2468
+ }
1849
2469
  this.runRegistry.dispose(run.id);
2470
+ const latest = this.runRegistry.get(run.id);
2471
+ if (latest)
2472
+ this.drainPendingDelegatedReviews(latest);
1850
2473
  },
1851
2474
  }, run.sessionId);
1852
- this.runRegistry.create(run, child);
2475
+ this.runRegistry.attachChildIfRunning(run.id, child);
1853
2476
  // Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
1854
2477
  // uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
1855
2478
  // the FRAIM-wrapping buildStartPlan path.
@@ -1878,7 +2501,7 @@ class AiHubServer {
1878
2501
  this.runRegistry.dispose(directId);
1879
2502
  },
1880
2503
  }, directRun.sessionId);
1881
- this.runRegistry.create(directRun, directChild);
2504
+ this.runRegistry.attachChildIfRunning(directRun.id, directChild);
1882
2505
  }
1883
2506
  const existingPreferences = this.preferencesStore.load(projectPath);
1884
2507
  this.preferencesStore.remember({
@@ -2009,8 +2632,10 @@ class AiHubServer {
2009
2632
  applyUsageSignal(current, event.usage);
2010
2633
  });
2011
2634
  const updated = this.runRegistry.get(run.id);
2012
- if (updated)
2635
+ if (updated) {
2636
+ this.maybeStartDelegatedChildRuns(updated);
2013
2637
  this.persistRunConversation(updated, updated.conversationId || updated.id);
2638
+ }
2014
2639
  },
2015
2640
  onExit: (exitCode) => {
2016
2641
  this.runRegistry.update(run.id, (current) => {
@@ -2019,12 +2644,17 @@ class AiHubServer {
2019
2644
  current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
2020
2645
  });
2021
2646
  const updated = this.runRegistry.get(run.id);
2022
- if (updated)
2647
+ if (updated) {
2648
+ this.maybeStartDelegatedChildRuns(updated);
2023
2649
  this.persistRunConversation(updated, updated.conversationId || updated.id);
2650
+ }
2024
2651
  this.runRegistry.dispose(run.id);
2652
+ const latest = this.runRegistry.get(run.id);
2653
+ if (latest)
2654
+ this.drainPendingDelegatedReviews(latest);
2025
2655
  },
2026
2656
  });
2027
- this.runRegistry.create(run, child);
2657
+ this.runRegistry.attachChildIfRunning(run.id, child);
2028
2658
  const refreshed = this.runRegistry.get(run.id);
2029
2659
  res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
2030
2660
  }
@@ -2110,9 +2740,12 @@ class AiHubServer {
2110
2740
  if (updated)
2111
2741
  this.persistRunConversation(updated, updated.conversationId || updated.id);
2112
2742
  this.runRegistry.dispose(run.id);
2743
+ const latest = this.runRegistry.get(run.id);
2744
+ if (latest)
2745
+ this.drainPendingDelegatedReviews(latest);
2113
2746
  },
2114
2747
  });
2115
- this.runRegistry.create(run, child);
2748
+ this.runRegistry.attachChildIfRunning(run.id, child);
2116
2749
  res.status(201).json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
2117
2750
  }
2118
2751
  catch (error) {
@@ -2158,7 +2791,7 @@ class AiHubServer {
2158
2791
  this.runRegistry.dispose(run.id);
2159
2792
  },
2160
2793
  });
2161
- this.runRegistry.create(run, child);
2794
+ this.runRegistry.attachChildIfRunning(run.id, child);
2162
2795
  const refreshed = this.runRegistry.get(run.id);
2163
2796
  res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
2164
2797
  }
@@ -2181,6 +2814,27 @@ class AiHubServer {
2181
2814
  runs = runs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
2182
2815
  return res.json(runs.map((r) => this.enrichRunForResponse(r)));
2183
2816
  });
2817
+ // ─── Issue #578: /api/ai-hub/runs/merged must be registered BEFORE :runId ──
2818
+ this.app.get('/api/ai-hub/runs/merged', async (req, res) => {
2819
+ const local = this.runRegistry.all().map((r) => this.enrichRunForResponse(r));
2820
+ const hosts = this.hostConfigStore.load();
2821
+ const remoteResults = await Promise.allSettled(hosts.map(async (host) => {
2822
+ const url = `${host.url.replace(/\/$/, '')}/api/ai-hub/runs`;
2823
+ const resp = await fetch(url, {
2824
+ signal: AbortSignal.timeout(8000),
2825
+ headers: host.authToken ? { 'X-Hub-Auth': host.authToken } : {},
2826
+ });
2827
+ if (!resp.ok)
2828
+ return [];
2829
+ return resp.json();
2830
+ }));
2831
+ const remote = remoteResults
2832
+ .filter((r) => r.status === 'fulfilled')
2833
+ .flatMap((r) => r.value);
2834
+ const merged = [...local, ...remote].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
2835
+ return res.json(merged);
2836
+ });
2837
+ // GET /api/ai-hub/runs/:runId — registered AFTER /merged to avoid shadowing.
2184
2838
  this.app.get('/api/ai-hub/runs/:runId', (req, res) => {
2185
2839
  const run = this.runRegistry.get(req.params.runId);
2186
2840
  if (!run) {
@@ -2188,6 +2842,216 @@ class AiHubServer {
2188
2842
  }
2189
2843
  return res.json(this.enrichRunForResponse(run));
2190
2844
  });
2845
+ // ─── Issue #578: Scheduled + Reactive Employees ───────────────────────────
2846
+ // POST /api/ai-hub/schedules — create a recurring scheduled deployment.
2847
+ this.app.post('/api/ai-hub/schedules', (req, res) => {
2848
+ const { label, jobId, projectPath, hostId, cronExpr, instructions, outputChannel, allowConcurrent } = req.body ?? {};
2849
+ if (!label || !jobId || !cronExpr) {
2850
+ return res.status(400).json({ error: 'label, jobId, and cronExpr are required.' });
2851
+ }
2852
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
2853
+ const cronLib = require('node-cron');
2854
+ if (!cronLib.validate(cronExpr)) {
2855
+ return res.status(400).json({ error: 'Invalid cron expression.' });
2856
+ }
2857
+ const validEmployees = VALID_EMPLOYEE_IDS;
2858
+ const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
2859
+ const now = new Date().toISOString();
2860
+ const deployment = {
2861
+ id: (0, crypto_1.randomUUID)(),
2862
+ type: 'scheduled',
2863
+ label,
2864
+ jobId,
2865
+ projectPath: ensureDirectoryPath(projectPath || this.projectPath),
2866
+ hostId: resolvedHostId,
2867
+ cronExpr,
2868
+ instructions: typeof instructions === 'string' ? instructions : undefined,
2869
+ outputChannel: typeof outputChannel === 'string' ? outputChannel : undefined,
2870
+ allowConcurrent: allowConcurrent === true,
2871
+ active: true,
2872
+ createdAt: now,
2873
+ updatedAt: now,
2874
+ };
2875
+ this.deploymentStore.create(deployment);
2876
+ this.scheduleDeployment(deployment);
2877
+ return res.status(201).json(deployment);
2878
+ });
2879
+ // GET /api/ai-hub/schedules — list all scheduled deployments.
2880
+ this.app.get('/api/ai-hub/schedules', (_req, res) => {
2881
+ return res.json(this.deploymentStore.load().filter((d) => d.type === 'scheduled'));
2882
+ });
2883
+ // DELETE /api/ai-hub/schedules/:id — remove a scheduled deployment.
2884
+ this.app.delete('/api/ai-hub/schedules/:id', (req, res) => {
2885
+ const { id } = req.params;
2886
+ const task = this.cronHandles.get(id);
2887
+ if (task) {
2888
+ task.stop();
2889
+ this.cronHandles.delete(id);
2890
+ }
2891
+ const deleted = this.deploymentStore.delete(id);
2892
+ if (!deleted)
2893
+ return res.status(404).json({ error: 'Deployment not found.' });
2894
+ return res.json({ ok: true });
2895
+ });
2896
+ // PUT /api/ai-hub/schedules/:id — update an existing scheduled deployment.
2897
+ this.app.put('/api/ai-hub/schedules/:id', (req, res) => {
2898
+ const { id } = req.params;
2899
+ const { label, jobId, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
2900
+ if (cronExpr !== undefined) {
2901
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
2902
+ const cronLib = require('node-cron');
2903
+ if (!cronLib.validate(cronExpr)) {
2904
+ return res.status(400).json({ error: 'Invalid cron expression.' });
2905
+ }
2906
+ }
2907
+ const validEmployees = VALID_EMPLOYEE_IDS;
2908
+ let updated = null;
2909
+ const ok = this.deploymentStore.update(id, (dep) => {
2910
+ if (label !== undefined)
2911
+ dep.label = label;
2912
+ if (jobId !== undefined)
2913
+ dep.jobId = jobId;
2914
+ if (cronExpr !== undefined)
2915
+ dep.cronExpr = cronExpr;
2916
+ if (hostId !== undefined && validEmployees.includes(hostId))
2917
+ dep.hostId = hostId;
2918
+ if (instructions !== undefined)
2919
+ dep.instructions = typeof instructions === 'string' ? instructions : undefined;
2920
+ if (allowConcurrent !== undefined)
2921
+ dep.allowConcurrent = allowConcurrent === true;
2922
+ updated = dep;
2923
+ });
2924
+ if (!ok || !updated)
2925
+ return res.status(404).json({ error: 'Deployment not found.' });
2926
+ const oldTask = this.cronHandles.get(id);
2927
+ if (oldTask) {
2928
+ oldTask.stop();
2929
+ this.cronHandles.delete(id);
2930
+ }
2931
+ this.scheduleDeployment(updated);
2932
+ return res.json(updated);
2933
+ });
2934
+ // POST /api/ai-hub/webhooks — register an inbound webhook deployment.
2935
+ this.app.post('/api/ai-hub/webhooks', (req, res) => {
2936
+ const { label, jobId, projectPath, hostId, instructions, outputChannel, allowConcurrent } = req.body ?? {};
2937
+ if (!label || !jobId) {
2938
+ return res.status(400).json({ error: 'label and jobId are required.' });
2939
+ }
2940
+ const validEmployees = VALID_EMPLOYEE_IDS;
2941
+ const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
2942
+ const now = new Date().toISOString();
2943
+ const deployment = {
2944
+ id: (0, crypto_1.randomUUID)(),
2945
+ type: 'webhook',
2946
+ label,
2947
+ jobId,
2948
+ projectPath: ensureDirectoryPath(projectPath || this.projectPath),
2949
+ hostId: resolvedHostId,
2950
+ instructions: typeof instructions === 'string' ? instructions : undefined,
2951
+ outputChannel: typeof outputChannel === 'string' ? outputChannel : undefined,
2952
+ allowConcurrent: allowConcurrent === true,
2953
+ active: true,
2954
+ createdAt: now,
2955
+ updatedAt: now,
2956
+ };
2957
+ this.deploymentStore.create(deployment);
2958
+ return res.status(201).json({ ...deployment, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${deployment.id}/inbound` });
2959
+ });
2960
+ // GET /api/ai-hub/webhooks — list all webhook deployments.
2961
+ this.app.get('/api/ai-hub/webhooks', (_req, res) => {
2962
+ const hubBase = this.hubBase;
2963
+ return res.json(this.deploymentStore.load()
2964
+ .filter((d) => d.type === 'webhook')
2965
+ .map((d) => ({ ...d, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${d.id}/inbound` })));
2966
+ });
2967
+ // DELETE /api/ai-hub/webhooks/:id — remove a webhook deployment.
2968
+ this.app.delete('/api/ai-hub/webhooks/:id', (req, res) => {
2969
+ const deleted = this.deploymentStore.delete(req.params.id);
2970
+ if (!deleted)
2971
+ return res.status(404).json({ error: 'Deployment not found.' });
2972
+ return res.json({ ok: true });
2973
+ });
2974
+ // PUT /api/ai-hub/webhooks/:id — update an existing webhook deployment.
2975
+ this.app.put('/api/ai-hub/webhooks/:id', (req, res) => {
2976
+ const { id } = req.params;
2977
+ const { label, jobId, hostId, instructions, allowConcurrent } = req.body ?? {};
2978
+ const validEmployees = VALID_EMPLOYEE_IDS;
2979
+ let updated = null;
2980
+ const ok = this.deploymentStore.update(id, (dep) => {
2981
+ if (label !== undefined)
2982
+ dep.label = label;
2983
+ if (jobId !== undefined)
2984
+ dep.jobId = jobId;
2985
+ if (hostId !== undefined && validEmployees.includes(hostId))
2986
+ dep.hostId = hostId;
2987
+ if (instructions !== undefined)
2988
+ dep.instructions = typeof instructions === 'string' ? instructions : undefined;
2989
+ if (allowConcurrent !== undefined)
2990
+ dep.allowConcurrent = allowConcurrent === true;
2991
+ updated = dep;
2992
+ });
2993
+ if (!ok || !updated)
2994
+ return res.status(404).json({ error: 'Deployment not found.' });
2995
+ return res.json({ ...updated, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${id}/inbound` });
2996
+ });
2997
+ // POST /api/ai-hub/webhooks/:id/inbound — webhook inbound trigger from external systems.
2998
+ this.app.post('/api/ai-hub/webhooks/:id/inbound', async (req, res) => {
2999
+ const deployments = this.deploymentStore.load();
3000
+ const deployment = deployments.find((d) => d.id === req.params.id && d.type === 'webhook' && d.active);
3001
+ if (!deployment) {
3002
+ return res.status(404).json({ error: 'Webhook not found or inactive.' });
3003
+ }
3004
+ try {
3005
+ const run = await this.fireDeploymentRun(deployment, req.body);
3006
+ return res.status(202).json({ runId: run.id, status: run.status });
3007
+ }
3008
+ catch (err) {
3009
+ const msg = err instanceof Error ? err.message : 'Failed to start run.';
3010
+ return res.status(500).json({ error: msg });
3011
+ }
3012
+ });
3013
+ // GET /api/ai-hub/hosts — list registered remote hosts with health status.
3014
+ this.app.get('/api/ai-hub/hosts', async (_req, res) => {
3015
+ const hosts = this.hostConfigStore.load();
3016
+ const healthResults = await Promise.allSettled(hosts.map((h) => pingHost(h)));
3017
+ const health = healthResults.map((r, i) => r.status === 'fulfilled'
3018
+ ? r.value
3019
+ : { id: hosts[i].id, label: hosts[i].label, url: hosts[i].url, status: 'offline', latencyMs: null, lastPingAt: new Date().toISOString() });
3020
+ return res.json(health);
3021
+ });
3022
+ // POST /api/ai-hub/hosts — register a named remote hub host.
3023
+ this.app.post('/api/ai-hub/hosts', (req, res) => {
3024
+ const { label, url, authToken } = req.body ?? {};
3025
+ const validUrl = safeHttpUrl(url);
3026
+ if (!label || !validUrl) {
3027
+ return res.status(400).json({ error: 'label and a valid http(s) url are required.' });
3028
+ }
3029
+ const host = {
3030
+ id: (0, crypto_1.randomUUID)(),
3031
+ label,
3032
+ url: validUrl,
3033
+ authToken: typeof authToken === 'string' && authToken ? authToken : undefined,
3034
+ createdAt: new Date().toISOString(),
3035
+ };
3036
+ this.hostConfigStore.add(host);
3037
+ return res.status(201).json({ id: host.id, label: host.label, url: host.url, createdAt: host.createdAt });
3038
+ });
3039
+ // DELETE /api/ai-hub/hosts/:id — remove a named remote host.
3040
+ this.app.delete('/api/ai-hub/hosts/:id', (req, res) => {
3041
+ const deleted = this.hostConfigStore.delete(req.params.id);
3042
+ if (!deleted)
3043
+ return res.status(404).json({ error: 'Host not found.' });
3044
+ return res.json({ ok: true });
3045
+ });
3046
+ // GET /api/ai-hub/hosts/:id/health — ping a single host.
3047
+ this.app.get('/api/ai-hub/hosts/:id/health', async (req, res) => {
3048
+ const host = this.hostConfigStore.load().find((h) => h.id === req.params.id);
3049
+ if (!host)
3050
+ return res.status(404).json({ error: 'Host not found.' });
3051
+ const health = await pingHost(host);
3052
+ return res.json(health);
3053
+ });
3054
+ // ─── End Issue #578 ───────────────────────────────────────────────────────
2191
3055
  // -------------------------------------------------------------------------
2192
3056
  // Issue #489: POST /api/trigger
2193
3057
  // Stable API endpoint for extension surfaces (Office add-ins, browser
@@ -2270,7 +3134,7 @@ class AiHubServer {
2270
3134
  },
2271
3135
  }, run.sessionId);
2272
3136
  // Update the registry entry with the real child process handle.
2273
- this.runRegistry.create(run, child);
3137
+ this.runRegistry.attachChildIfRunning(run.id, child);
2274
3138
  return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
2275
3139
  }
2276
3140
  catch (error) {
@@ -2286,6 +3150,121 @@ class AiHubServer {
2286
3150
  process.env.FRAIM_BROWSER_CDP_ENDPOINT = result.endpoint;
2287
3151
  return { endpoint: result.endpoint, reused: result.reused, channel: result.channel };
2288
3152
  }
3153
+ // ─── Issue #578: Scheduled deployment helpers ─────────────────────────────
3154
+ rehydrateScheduledDeployments() {
3155
+ const active = this.deploymentStore.load().filter((d) => d.type === 'scheduled' && d.active);
3156
+ for (const dep of active) {
3157
+ this.scheduleDeployment(dep);
3158
+ }
3159
+ if (active.length > 0) {
3160
+ console.log(`[ai-hub] rehydrated ${active.length} scheduled deployment(s)`);
3161
+ }
3162
+ }
3163
+ scheduleDeployment(deployment) {
3164
+ if (!deployment.cronExpr)
3165
+ return;
3166
+ try {
3167
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
3168
+ const cron = require('node-cron');
3169
+ if (!cron.validate(deployment.cronExpr)) {
3170
+ console.warn(`[ai-hub] invalid cronExpr for deployment ${deployment.id}: ${deployment.cronExpr}`);
3171
+ return;
3172
+ }
3173
+ const task = cron.schedule(deployment.cronExpr, async () => {
3174
+ try {
3175
+ await this.fireDeploymentRun(deployment);
3176
+ }
3177
+ catch (err) {
3178
+ console.warn(`[ai-hub] scheduled deployment ${deployment.id} fire failed:`, err);
3179
+ }
3180
+ });
3181
+ this.cronHandles.set(deployment.id, task);
3182
+ }
3183
+ catch (err) {
3184
+ console.warn('[ai-hub] node-cron not available — scheduled deployments require node-cron:', err);
3185
+ }
3186
+ }
3187
+ async fireDeploymentRun(deployment, webhookBody) {
3188
+ // Overlapping run guard: if the prior run is still active and allowConcurrent is false, skip.
3189
+ if (!deployment.allowConcurrent && deployment.activeRunId) {
3190
+ const active = this.runRegistry.get(deployment.activeRunId);
3191
+ if (active && active.status === 'running') {
3192
+ console.log(`[ai-hub] deployment ${deployment.id} skipped — prior run ${deployment.activeRunId} still running`);
3193
+ return active;
3194
+ }
3195
+ }
3196
+ const employees = this.hostRuntime.detectEmployees();
3197
+ const employee = employees.find((e) => e.id === deployment.hostId);
3198
+ if (!employee?.available) {
3199
+ throw new Error(`Employee ${deployment.hostId} is not available for scheduled/webhook run.`);
3200
+ }
3201
+ const instructions = [
3202
+ deployment.instructions ?? `/fraim ${deployment.jobId}`,
3203
+ webhookBody ? `\n\nInbound payload:\n${JSON.stringify(webhookBody, null, 2)}` : '',
3204
+ ].join('').trim();
3205
+ const startTimestamp = new Date().toISOString();
3206
+ const run = {
3207
+ id: (0, crypto_1.randomUUID)(),
3208
+ jobId: deployment.jobId,
3209
+ hostId: deployment.hostId,
3210
+ projectPath: deployment.projectPath,
3211
+ status: 'running',
3212
+ sourceTrigger: deployment.type === 'scheduled' ? 'scheduled' : 'webhook',
3213
+ createdAt: startTimestamp,
3214
+ updatedAt: startTimestamp,
3215
+ messages: [(0, hosts_1.createHubMessage)('manager', instructions)],
3216
+ events: [(0, hosts_1.createHubEvent)('system', `Triggered by deployment: ${deployment.label} (${deployment.type})`)],
3217
+ currentPhase: null,
3218
+ phaseHistory: [],
3219
+ totals: emptyTotals(),
3220
+ lastStatusChangeAt: startTimestamp,
3221
+ // Deployment runs group under the selected employee (hostId), not the job's
3222
+ // protected persona. Leaving personaKey unset lets conversationRecordFromRun
3223
+ // fall through to agentName (= hostId), which is what the rail groups by.
3224
+ personaKey: null,
3225
+ };
3226
+ // Pre-register before startRun so synchronous onEvent calls (e.g. FakeHostRuntime)
3227
+ // can call runRegistry.update without "Run not found" throws.
3228
+ this.runRegistry.create(run, {});
3229
+ const child = this.hostRuntime.startRun(deployment.hostId, deployment.projectPath, instructions, {
3230
+ onEvent: (event, channel) => {
3231
+ this.runRegistry.update(run.id, (current) => {
3232
+ if (event.sessionId)
3233
+ current.sessionId = event.sessionId;
3234
+ appendHostMessage(current, deployment.hostId, event, channel);
3235
+ if (event.raw) {
3236
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
3237
+ applyReviewProjection(current, event.raw);
3238
+ }
3239
+ if (event.agentIdentity)
3240
+ applyAgentIdentitySignal(current, event.agentIdentity);
3241
+ if (event.seekMentoring)
3242
+ applySeekMentoringSignal(current, event.seekMentoring);
3243
+ if (event.usage)
3244
+ applyUsageSignal(current, event.usage);
3245
+ });
3246
+ const updated = this.runRegistry.get(run.id);
3247
+ if (updated)
3248
+ this.persistRunConversation(updated, updated.conversationId || updated.id);
3249
+ },
3250
+ onExit: (exitCode) => {
3251
+ this.runRegistry.update(run.id, (r) => {
3252
+ r.exitCode = exitCode;
3253
+ r.status = exitCode === 0 ? 'completed' : 'failed';
3254
+ r.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
3255
+ });
3256
+ const updated = this.runRegistry.get(run.id);
3257
+ if (updated)
3258
+ this.persistRunConversation(updated, updated.conversationId || updated.id);
3259
+ this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
3260
+ this.runRegistry.dispose(run.id);
3261
+ },
3262
+ });
3263
+ this.runRegistry.create(run, child);
3264
+ this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
3265
+ return run;
3266
+ }
3267
+ // ─── End Issue #578 helpers ───────────────────────────────────────────────
2289
3268
  // Issue #347 — assemble the read-side projection of a run. Stages are
2290
3269
  // derived from job frontmatter + visited phases; totalDurationMs ticks
2291
3270
  // forward while the run is still running so the UI's totals line
@@ -2326,7 +3305,7 @@ class AiHubServer {
2326
3305
  liveTotals.waitingDurationMs = Math.max(0, liveTotals.waitingDurationMs - overflow);
2327
3306
  }
2328
3307
  }
2329
- return { ...run, stages, totals: liveTotals };
3308
+ return { ...run, stages, totals: liveTotals, artifacts: extractRunReviewArtifacts(run) };
2330
3309
  }
2331
3310
  }
2332
3311
  exports.AiHubServer = AiHubServer;