fraim-hub 2.0.229 → 2.0.231

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.
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.readCustomEmployees = readCustomEmployees;
7
+ exports.writeCustomEmployee = writeCustomEmployee;
8
+ exports.deleteCustomEmployee = deleteCustomEmployee;
9
+ exports.slugifyDisplayName = slugifyDisplayName;
10
+ exports.resolveEmployee = resolveEmployee;
11
+ exports.buildCustomEmployeePersona = buildCustomEmployeePersona;
12
+ // Issue #945: read/write store for user-authored employees, slug utilities,
13
+ // and the display/attribution resolver union.
14
+ //
15
+ // INVARIANT: resolveEmployee is only for display and run attribution.
16
+ // Entitlement reads (isPersonaHireKey, getProtectedPersonaForJob,
17
+ // evaluatePersonaAccess) must never call resolveEmployee.
18
+ const fs_1 = __importDefault(require("fs"));
19
+ const path_1 = __importDefault(require("path"));
20
+ const persona_hiring_1 = require("../config/persona-hiring");
21
+ const EMPLOYEES_DIR_REL = path_1.default.join('fraim', 'personalized-employee', 'employees');
22
+ function employeesDir(projectDir) {
23
+ return path_1.default.join(projectDir, EMPLOYEES_DIR_REL);
24
+ }
25
+ function slugFor(key) {
26
+ // key is 'custom:<slug>' — file is named '<slug>.json'
27
+ return key.replace(/^custom:/, '');
28
+ }
29
+ function safeSlug(key) {
30
+ // Reject keys whose slug component contains path separators or traversal sequences.
31
+ const slug = slugFor(key);
32
+ if (!slug || /[/\\]|\.\./.test(slug))
33
+ return null;
34
+ return slug;
35
+ }
36
+ function filePath(projectDir, key) {
37
+ const slug = safeSlug(key);
38
+ if (!slug)
39
+ return null;
40
+ return path_1.default.join(employeesDir(projectDir), `${slug}.json`);
41
+ }
42
+ function readCustomEmployees(projectDir) {
43
+ const dir = employeesDir(projectDir);
44
+ if (!fs_1.default.existsSync(dir))
45
+ return [];
46
+ const results = [];
47
+ for (const file of fs_1.default.readdirSync(dir)) {
48
+ if (!file.endsWith('.json'))
49
+ continue;
50
+ try {
51
+ const raw = fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8');
52
+ const parsed = JSON.parse(raw);
53
+ if (parsed && typeof parsed.key === 'string' && parsed.key.startsWith('custom:')) {
54
+ results.push(parsed);
55
+ }
56
+ }
57
+ catch {
58
+ // silently skip malformed files
59
+ }
60
+ }
61
+ return results;
62
+ }
63
+ function writeCustomEmployee(projectDir, employee) {
64
+ const fp = filePath(projectDir, employee.key);
65
+ if (!fp)
66
+ throw new Error(`Invalid employee key: ${employee.key}`);
67
+ const dir = employeesDir(projectDir);
68
+ fs_1.default.mkdirSync(dir, { recursive: true });
69
+ fs_1.default.writeFileSync(fp, JSON.stringify(employee, null, 2), 'utf8');
70
+ }
71
+ function deleteCustomEmployee(projectDir, key) {
72
+ const fp = filePath(projectDir, key);
73
+ if (!fp || !fs_1.default.existsSync(fp))
74
+ return false;
75
+ fs_1.default.unlinkSync(fp);
76
+ return true;
77
+ }
78
+ function slugifyDisplayName(displayName, existingKeys = []) {
79
+ const base = displayName
80
+ .toLowerCase()
81
+ .replace(/[^a-z0-9]+/g, '-')
82
+ .replace(/^-+|-+$/g, '')
83
+ || 'employee';
84
+ let candidate = `custom:${base}`;
85
+ if (!existingKeys.includes(candidate))
86
+ return candidate;
87
+ let i = 2;
88
+ while (existingKeys.includes(`${candidate}-${i}`))
89
+ i++;
90
+ return `${candidate}-${i}`;
91
+ }
92
+ function resolveEmployee(key, customEmployees) {
93
+ // Custom: key starts with 'custom:'
94
+ if (key.startsWith('custom:')) {
95
+ const emp = customEmployees.find((e) => e.key === key);
96
+ if (!emp)
97
+ return null;
98
+ return {
99
+ key: emp.key,
100
+ displayName: emp.displayName,
101
+ role: emp.role,
102
+ avatarUrl: buildCustomAvatarUrl(emp),
103
+ origin: 'custom',
104
+ };
105
+ }
106
+ // Catalog
107
+ if ((0, persona_hiring_1.isPersonaHireKey)(key)) {
108
+ const entry = persona_hiring_1.PERSONA_HIRE_CATALOG[key];
109
+ return {
110
+ key,
111
+ displayName: entry.displayName,
112
+ role: entry.role,
113
+ avatarUrl: (0, persona_hiring_1.buildPersonaAvatarUrl)(key),
114
+ origin: 'catalog',
115
+ };
116
+ }
117
+ return null;
118
+ }
119
+ // Converts a CustomEmployee to an AiHubPersona for bootstrap / rail rendering.
120
+ // status is always 'hired' (no lock on custom employees); no hireUrl; no seat accounting.
121
+ // jobIds is included so the UI can scope the delegate picker without a second fetch.
122
+ function buildCustomEmployeePersona(emp) {
123
+ return {
124
+ key: emp.key,
125
+ displayName: emp.displayName,
126
+ role: emp.role,
127
+ avatarUrl: buildCustomAvatarUrl(emp),
128
+ pricingLabel: '',
129
+ status: 'hired',
130
+ hireUrl: '',
131
+ seatCount: 0,
132
+ seatsInUse: 0,
133
+ origin: 'custom',
134
+ jobIds: emp.jobIds,
135
+ };
136
+ }
137
+ // Builds an avatar URL for a custom employee.
138
+ // - 'emoji': renders a simple letter-based DiceBear avatar seeded on the display name
139
+ // - 'generated': uses the value as the DiceBear seed directly
140
+ // - 'image': returns an empty string (image upload is deferred; caller falls back to icon)
141
+ function buildCustomAvatarUrl(emp) {
142
+ const { icon, displayName } = emp;
143
+ if (icon.kind === 'image')
144
+ return '';
145
+ const seed = icon.kind === 'generated' ? icon.value : displayName;
146
+ const params = new URLSearchParams({ seed, backgroundColor: 'e0e7ff', radius: '50' });
147
+ return `https://api.dicebear.com/9.x/notionists/svg?${params.toString()}`;
148
+ }
@@ -683,12 +683,15 @@ const EMPLOYEE_LABELS = {
683
683
  claude: 'Claude Code',
684
684
  gemini: 'Gemini CLI',
685
685
  copilot: 'GitHub Copilot CLI',
686
+ antigravity: 'Antigravity CLI',
686
687
  };
687
688
  // GitHub Copilot CLI binary name after `npm install -g @github/copilot`.
688
689
  // The @github/copilot package installs a binary named `copilot` on PATH.
689
690
  // Note: the package name is @github/copilot (NOT @github/copilot-cli which
690
691
  // does not exist on npm). The binary is `copilot` (NOT `github-copilot-cli`).
691
692
  const COPILOT_BINARY = 'copilot';
693
+ // Issue #928: agy has no npm package; installed via https://antigravity.google/cli/
694
+ const AGY_BINARY = 'agy';
692
695
  const executableName = (command) => command;
693
696
  function quoteWindowsArg(value) {
694
697
  if (value.length === 0) {
@@ -721,6 +724,8 @@ const availableByVersionProbe = (command) => {
721
724
  function agentBinaryName(id) {
722
725
  if (id === 'copilot')
723
726
  return COPILOT_BINARY;
727
+ if (id === 'antigravity')
728
+ return AGY_BINARY;
724
729
  return executableName(id);
725
730
  }
726
731
  function detectEmployees() {
@@ -989,6 +994,18 @@ function buildStartPlan(hostId, message, sessionId) {
989
994
  env: browser.env,
990
995
  };
991
996
  }
997
+ // Issue #928: agy (Antigravity CLI) is TUI-only — no subprocess/headless mode.
998
+ // supportsSubprocessStream:false is a capability flag for future Branch A
999
+ // (subprocess mode). Currently unused by spawnHostProcess; TUI output is
1000
+ // handled gracefully by the antigravity branch in parseHostLine.
1001
+ if (hostId === 'antigravity') {
1002
+ return {
1003
+ command: AGY_BINARY,
1004
+ args: ['--dangerously-skip-permissions'],
1005
+ stdin: transformHeadlessFraimMessage(message, 'start'),
1006
+ supportsSubprocessStream: false,
1007
+ };
1008
+ }
992
1009
  const browser = sharedBrowserHostConfig('claude');
993
1010
  return {
994
1011
  command: executableName('claude'),
@@ -1038,6 +1055,15 @@ function buildContinuePlan(hostId, sessionId, message) {
1038
1055
  env: browser.env,
1039
1056
  };
1040
1057
  }
1058
+ // Issue #928: agy resume uses --conversation <sessionId>.
1059
+ if (hostId === 'antigravity') {
1060
+ return {
1061
+ command: AGY_BINARY,
1062
+ args: ['--dangerously-skip-permissions', '--conversation', sessionId],
1063
+ stdin: transformHeadlessFraimMessage(message, 'continue'),
1064
+ supportsSubprocessStream: false,
1065
+ };
1066
+ }
1041
1067
  const browser = sharedBrowserHostConfig('claude');
1042
1068
  return {
1043
1069
  command: executableName('claude'),
@@ -1097,6 +1123,14 @@ function buildDirectStartPlan(hostId, message, sessionId) {
1097
1123
  stdin: DIRECT_PREAMBLE + message,
1098
1124
  };
1099
1125
  }
1126
+ if (hostId === 'antigravity') {
1127
+ return {
1128
+ command: AGY_BINARY,
1129
+ args: ['--dangerously-skip-permissions'],
1130
+ stdin: DIRECT_PREAMBLE + message,
1131
+ supportsSubprocessStream: false,
1132
+ };
1133
+ }
1100
1134
  return {
1101
1135
  command: executableName('claude'),
1102
1136
  args: [
@@ -1204,6 +1238,11 @@ function parseHostLine(hostId, line) {
1204
1238
  return withSignal({ raw: trimmed });
1205
1239
  }
1206
1240
  catch {
1241
+ // Issue #928: detect IneligibleTierError before other notice checks so
1242
+ // the Hub can surface the Antigravity migration prompt.
1243
+ if (trimmed.startsWith('IneligibleTierError')) {
1244
+ return withSignal({ raw: trimmed, geminiDeprecated: true });
1245
+ }
1207
1246
  if (isGeminiCliNotice(trimmed)) {
1208
1247
  return withSignal({ raw: trimmed });
1209
1248
  }
@@ -1211,6 +1250,11 @@ function parseHostLine(hostId, line) {
1211
1250
  return withSignal(message ? { message, raw: trimmed } : { raw: trimmed });
1212
1251
  }
1213
1252
  }
1253
+ // Issue #928: antigravity (agy) is TUI-only. Return raw for any line received;
1254
+ // TUI output is not a structured event stream.
1255
+ if (hostId === 'antigravity') {
1256
+ return withSignal({ raw: trimmed });
1257
+ }
1214
1258
  // GitHub Copilot CLI output: JSON stream where each event carries a `type`
1215
1259
  // field. Known event shapes (from the agentic CLI stream):
1216
1260
  // { "type": "session.started", "session_id": "..." } — session id
@@ -1441,6 +1485,7 @@ class FakeHostRuntime {
1441
1485
  { id: 'claude', label: 'Claude Code', available: true, detail: 'Test double employee.', supportsRaw: true },
1442
1486
  { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Test double employee.', supportsRaw: true },
1443
1487
  { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Test double agent tool.', supportsRaw: true },
1488
+ { id: 'antigravity', label: 'Antigravity CLI', available: true, detail: 'Test double agent tool.', supportsRaw: false },
1444
1489
  ];
1445
1490
  // Remembered across turns like a resumed agent session: the job label from the
1446
1491
  // start turn. Issue #732 — a same-job continue no longer carries a /fraim <job>
@@ -1531,6 +1576,7 @@ class ScriptedHostRuntime {
1531
1576
  { id: 'claude', label: 'Claude Code', available: true, detail: 'Scripted test double.', supportsRaw: true },
1532
1577
  { id: 'gemini', label: 'Gemini CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1533
1578
  { id: 'copilot', label: 'GitHub Copilot CLI', available: true, detail: 'Scripted test double.', supportsRaw: true },
1579
+ { id: 'antigravity', label: 'Antigravity CLI', available: true, detail: 'Scripted test double.', supportsRaw: false },
1534
1580
  ];
1535
1581
  // Track each active run so the test can emit signals at it. The Hub
1536
1582
  // passes the run id as the requested start session id in test/demo paths;
@@ -147,7 +147,7 @@ class AiHubPreferencesStore {
147
147
  const removedProjectPaths = normalizeRemovedProjectPaths(raw.removedProjectPaths);
148
148
  return {
149
149
  projectPath: raw.projectPath || projectPath,
150
- employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex' || raw.employeeId === 'gemini' || raw.employeeId === 'copilot') ? raw.employeeId : DEFAULT_EMPLOYEE,
150
+ employeeId: (raw.employeeId === 'claude' || raw.employeeId === 'codex' || raw.employeeId === 'gemini' || raw.employeeId === 'copilot' || raw.employeeId === 'antigravity') ? raw.employeeId : DEFAULT_EMPLOYEE,
151
151
  categoryId: typeof raw.categoryId === 'string' && raw.categoryId.length > 0 ? raw.categoryId : DEFAULT_CATEGORY,
152
152
  recentJobIds: Array.isArray(raw.recentJobIds) ? raw.recentJobIds.filter((value) => typeof value === 'string') : [],
153
153
  recentJobInstructions: (typeof raw.recentJobInstructions === 'object' && raw.recentJobInstructions !== null && !Array.isArray(raw.recentJobInstructions))
@@ -51,6 +51,7 @@ const learning_context_builder_1 = require("../local-mcp-server/learning-context
51
51
  const brand_store_1 = require("../core/brand-store");
52
52
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
53
53
  const catalog_1 = require("./catalog");
54
+ const custom_employees_1 = require("./custom-employees");
54
55
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
55
56
  const hosts_1 = require("./hosts");
56
57
  const configured_agents_1 = require("./configured-agents");
@@ -66,6 +67,7 @@ const org_publish_1 = require("../cli/utils/org-publish");
66
67
  const version_utils_1 = require("../cli/utils/version-utils");
67
68
  const hub_latest_version_1 = require("./hub-latest-version");
68
69
  const semver = __importStar(require("semver"));
70
+ const BOOTSTRAP_PERSONA_FIRST_PAINT_BUDGET_MS = 250;
69
71
  let personaHiringModule;
70
72
  let managerHiringModule;
71
73
  function loadPersonaHiringModule() {
@@ -311,7 +313,7 @@ class AiHubRunRegistry {
311
313
  }
312
314
  }
313
315
  // ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
314
- const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot'];
316
+ const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot', 'antigravity'];
315
317
  const SCHEDULED_FIRE_LEASE_TTL_MS = 2 * 60 * 1000;
316
318
  const SCHEDULED_FIRE_LEASE_PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
317
319
  function startSessionSeedForHost(hostId, runId) {
@@ -1777,13 +1779,16 @@ class AiHubServer {
1777
1779
  this.managedBrowser.stop();
1778
1780
  }
1779
1781
  getHttpsPort() { return this.httpsPort; }
1780
- knownProjects(projectPath, extras = []) {
1782
+ knownProjects(projectPath, extras = [], options = {}) {
1781
1783
  // #866 R2: guard against path.resolve('') === cwd. When there is no active
1782
1784
  // project, keep the path empty so no invocation-directory project is injected.
1783
1785
  const activeProjectPath = projectPath || this.projectPath;
1784
1786
  const normalizedProjectPath = activeProjectPath ? path_1.default.resolve(activeProjectPath) : '';
1785
1787
  const preferences = this.preferencesStore.load(normalizedProjectPath);
1786
- const conversationProjects = this.conversationStore.listProjectPaths().map((folderPath) => ({ folderPath }));
1788
+ const includeConversationProjects = options.includeConversationProjects !== false;
1789
+ const conversationProjects = includeConversationProjects
1790
+ ? this.conversationStore.listProjectPaths().map((folderPath) => ({ folderPath }))
1791
+ : [];
1787
1792
  return (0, preferences_1.normalizeAiHubProjectList)([
1788
1793
  ...(preferences.projects || []),
1789
1794
  ...conversationProjects,
@@ -1825,10 +1830,25 @@ class AiHubServer {
1825
1830
  // Issue #750: the apiKey always comes from ~/.fraim/config.json — no header
1826
1831
  // override, no ai-hub-state.json copy, no fallback chain.
1827
1832
  const resolvedApiKey = resolveApiKey();
1828
- const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(resolvedApiKey);
1829
- const managerTeam = await this.computeManagerTeam(resolvedApiKey);
1833
+ const managerTeamPromise = this.computeManagerTeam(resolvedApiKey).catch((err) => {
1834
+ console.warn('[ai-hub] manager-team lookup failed:', err?.message || err);
1835
+ return [];
1836
+ });
1837
+ const personaProjectionPromise = this.computePersonas(resolvedApiKey, managerTeamPromise);
1838
+ const fallbackPersonaProjection = this.fallbackPersonaProjection(resolvedApiKey);
1839
+ const personaProjection = await this.withFirstPaintBudget(personaProjectionPromise, fallbackPersonaProjection, 'persona projection');
1840
+ const managerTeam = personaProjection === fallbackPersonaProjection
1841
+ ? []
1842
+ : await this.withFirstPaintBudget(managerTeamPromise, [], 'manager team');
1843
+ const { personas, subscriptionActive, workspaceId, userKey } = personaProjection;
1844
+ void personaProjectionPromise.catch(() => undefined);
1845
+ void managerTeamPromise.catch(() => undefined);
1830
1846
  const resolvedUserEmail = userKey ?? null;
1831
- const projects = this.knownProjects(normalizedProjectPath);
1847
+ // Issue #975: do not scan every conversation-store bucket on first paint.
1848
+ // Large long-lived Hub installs can have thousands of buckets; the Projects
1849
+ // API still performs the full merge, and the client refreshes it after the
1850
+ // shell is visible.
1851
+ const projects = this.knownProjects(normalizedProjectPath, [], { includeConversationProjects: false });
1832
1852
  preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
1833
1853
  this.preferencesStore.save(preferences);
1834
1854
  // Issue #347: enrich the activeRun the same way GET /runs/:id does
@@ -1986,11 +2006,15 @@ class AiHubServer {
1986
2006
  ...(run.pauseReason !== undefined && { pauseReason: run.pauseReason }),
1987
2007
  createdAt: run.createdAt,
1988
2008
  lastUpdatedAt,
1989
- messages: run.messages.map((message) => ({
1990
- role: message.role,
1991
- text: message.text,
1992
- at: Date.parse(message.createdAt) || Date.now(),
1993
- })),
2009
+ messages: run.messages.map((message) => {
2010
+ const at = Date.parse(message.createdAt);
2011
+ return {
2012
+ role: message.role,
2013
+ text: message.text,
2014
+ createdAt: message.createdAt,
2015
+ ...(Number.isFinite(at) && at > 0 ? { at } : {}),
2016
+ };
2017
+ }),
1994
2018
  events: run.events.map((event) => ({
1995
2019
  channel: event.channel,
1996
2020
  text: event.text,
@@ -2550,19 +2574,9 @@ class AiHubServer {
2550
2574
  const message = (invocationForm ?? (0, manager_turns_1.buildSameJobContinueMessage)(userText)) + (0, manager_turns_1.buildCommunicationStyleNote)();
2551
2575
  return { message, display };
2552
2576
  }
2553
- async computePersonas(apiKey) {
2577
+ async computePersonas(apiKey, managerTeamPromise) {
2554
2578
  const allBundles = listHubPersonaBundles();
2555
- const fallbackPersonas = allBundles.map((bundle) => ({
2556
- key: bundle.personaKey,
2557
- displayName: bundle.catalogMetadata.displayName,
2558
- role: bundle.catalogMetadata.role,
2559
- avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
2560
- pricingLabel: bundle.catalogMetadata.pricingLabel,
2561
- status: 'locked',
2562
- hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
2563
- seatCount: 0,
2564
- seatsInUse: 0,
2565
- }));
2579
+ const fallbackProjection = this.fallbackPersonaProjection(apiKey);
2566
2580
  // Issue #701: persona state comes from the hosted server (GET /api/personas/me)
2567
2581
  // via the user's API key — never a local MongoDB connection. Issue #925: resolve
2568
2582
  // it in a transport-aware way so a transient outage does not collapse to the same
@@ -2579,15 +2593,16 @@ class AiHubServer {
2579
2593
  // (feature-off, legacy bypass, per-entitlement gating) lives solely in
2580
2594
  // persona-entitlement-service.resolvePersonaAccessStatuses — the Hub does not
2581
2595
  // re-derive it.
2596
+ const customPersonas = (0, custom_employees_1.readCustomEmployees)(this.projectPath).map(custom_employees_1.buildCustomEmployeePersona);
2582
2597
  if (!state) {
2583
- return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey };
2598
+ return { ...fallbackProjection, userKey };
2584
2599
  }
2585
2600
  try {
2586
2601
  const verdictByKey = new Map((state.personas || []).map((p) => [p.personaKey, p]));
2587
2602
  // seatsInUse (manager-team assignments) is display-only accounting fetched
2588
2603
  // separately; it is not part of the access verdict.
2589
2604
  const seatsInUseByKey = {};
2590
- const team = await this.remoteGateway.listManagerTeam(apiKey);
2605
+ const team = managerTeamPromise ? await managerTeamPromise : await this.remoteGateway.listManagerTeam(apiKey);
2591
2606
  for (const entry of team) {
2592
2607
  seatsInUseByKey[entry.personaKey] = (seatsInUseByKey[entry.personaKey] ?? 0) + 1;
2593
2608
  }
@@ -2604,14 +2619,56 @@ class AiHubServer {
2604
2619
  hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
2605
2620
  seatCount: verdict?.seatCount ?? 0,
2606
2621
  seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
2622
+ origin: 'catalog',
2607
2623
  };
2608
2624
  });
2609
- return { personas, subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey };
2625
+ // Issue #945: union custom employees they are always 'hired' and carry no
2626
+ // entitlement. Custom personas are appended so catalog order is unchanged.
2627
+ return { personas: [...personas, ...customPersonas], subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey };
2610
2628
  }
2611
2629
  catch (err) {
2612
2630
  console.error('[ai-hub] persona lookup failed:', err);
2613
2631
  // Keep the (already-resolved) identity even if per-persona accounting fails.
2614
- return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey };
2632
+ return { ...fallbackProjection, userKey };
2633
+ }
2634
+ }
2635
+ fallbackPersonaProjection(apiKey) {
2636
+ const fallbackPersonas = listHubPersonaBundles().map((bundle) => ({
2637
+ key: bundle.personaKey,
2638
+ displayName: bundle.catalogMetadata.displayName,
2639
+ role: bundle.catalogMetadata.role,
2640
+ avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
2641
+ pricingLabel: bundle.catalogMetadata.pricingLabel,
2642
+ status: 'locked',
2643
+ hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
2644
+ seatCount: 0,
2645
+ seatsInUse: 0,
2646
+ origin: 'catalog',
2647
+ }));
2648
+ const customPersonas = (0, custom_employees_1.readCustomEmployees)(this.projectPath).map(custom_employees_1.buildCustomEmployeePersona);
2649
+ return {
2650
+ personas: [...fallbackPersonas, ...customPersonas],
2651
+ subscriptionActive: false,
2652
+ workspaceId: null,
2653
+ userKey: apiKey ? this.lastResolvedIdentityByApiKey.get(apiKey) ?? null : null,
2654
+ };
2655
+ }
2656
+ async withFirstPaintBudget(promise, fallback, label) {
2657
+ let timeout;
2658
+ try {
2659
+ return await Promise.race([
2660
+ promise,
2661
+ new Promise((resolve) => {
2662
+ timeout = setTimeout(() => {
2663
+ console.warn(`[ai-hub] bootstrap ${label} exceeded first-paint budget; hydrating after render`);
2664
+ resolve(fallback);
2665
+ }, BOOTSTRAP_PERSONA_FIRST_PAINT_BUDGET_MS);
2666
+ }),
2667
+ ]);
2668
+ }
2669
+ finally {
2670
+ if (timeout)
2671
+ clearTimeout(timeout);
2615
2672
  }
2616
2673
  }
2617
2674
  // Issue #925: prefer the transport-aware gateway result when available; fall back to
@@ -2747,6 +2804,29 @@ class AiHubServer {
2747
2804
  // Hub project, not whatever directory happened to launch the process.
2748
2805
  res.json(await this.bootstrapResponse(projectPath || this.defaultProjectPath()));
2749
2806
  });
2807
+ this.app.get('/api/ai-hub/personas', async (req, res) => {
2808
+ const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
2809
+ ? path_1.default.resolve(req.query.projectPath)
2810
+ : this.defaultProjectPath();
2811
+ const apiKey = resolveApiKey();
2812
+ const managerTeamPromise = this.computeManagerTeam(apiKey).catch((err) => {
2813
+ console.warn('[ai-hub] manager-team lookup failed:', err?.message || err);
2814
+ return [];
2815
+ });
2816
+ const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey, managerTeamPromise);
2817
+ const managerTeam = await managerTeamPromise;
2818
+ const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath, { includeRegistry: true })
2819
+ .filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
2820
+ .length;
2821
+ res.json({
2822
+ personas,
2823
+ subscriptionActive,
2824
+ workspaceId,
2825
+ userEmail: userKey ?? null,
2826
+ managerTeam,
2827
+ firstRun: this.computeFirstRun(projectPath, jobCount, personas),
2828
+ });
2829
+ });
2750
2830
  // Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
2751
2831
  // same projection folded into bootstrap. Useful for the avatar→Brain view
2752
2832
  // without re-fetching the whole bootstrap payload.
@@ -4463,6 +4543,81 @@ class AiHubServer {
4463
4543
  return res.status(500).json({ error: msg });
4464
4544
  }
4465
4545
  });
4546
+ // ─── Issue #945: Custom Employee CRUD ────────────────────────────────────
4547
+ // GET /api/ai-hub/custom-employees — list project-scoped custom employees.
4548
+ this.app.get('/api/ai-hub/custom-employees', (req, res) => {
4549
+ const projectPath = req.query.projectPath || this.projectPath;
4550
+ const employees = (0, custom_employees_1.readCustomEmployees)(projectPath);
4551
+ return res.json(employees);
4552
+ });
4553
+ // POST /api/ai-hub/custom-employees — create a new custom employee.
4554
+ this.app.post('/api/ai-hub/custom-employees', (req, res) => {
4555
+ const { projectPath: reqProjectPath, displayName, role, icon, jobIds } = req.body;
4556
+ const projectPath = reqProjectPath || this.projectPath;
4557
+ if (!displayName || typeof displayName !== 'string' || !displayName.trim()) {
4558
+ return res.status(400).json({ error: 'displayName is required.' });
4559
+ }
4560
+ if (!Array.isArray(jobIds) || jobIds.length === 0) {
4561
+ return res.status(400).json({ error: 'jobIds must be a non-empty array.' });
4562
+ }
4563
+ const existing = (0, custom_employees_1.readCustomEmployees)(projectPath).map((e) => e.key);
4564
+ const key = (0, custom_employees_1.slugifyDisplayName)(displayName.trim(), existing);
4565
+ const VALID_ICON_KINDS = new Set(['emoji', 'generated', 'image']);
4566
+ const iconKind = (icon?.kind && VALID_ICON_KINDS.has(icon.kind) ? icon.kind : 'generated');
4567
+ const employee = {
4568
+ key,
4569
+ displayName: displayName.trim(),
4570
+ role: (role ?? '').trim() || 'AI Employee',
4571
+ icon: { kind: iconKind, value: icon?.value ?? displayName.trim() },
4572
+ jobIds,
4573
+ createdBy: '',
4574
+ scope: 'project',
4575
+ createdAt: new Date().toISOString(),
4576
+ };
4577
+ (0, custom_employees_1.writeCustomEmployee)(projectPath, employee);
4578
+ return res.status(201).json(employee);
4579
+ });
4580
+ // PATCH /api/ai-hub/custom-employees/:key — update an existing custom employee.
4581
+ this.app.patch('/api/ai-hub/custom-employees/:key', (req, res) => {
4582
+ const key = decodeURIComponent(req.params.key);
4583
+ if (!key.startsWith('custom:') || /[/\\]|\.\./.test(key.replace(/^custom:/, ''))) {
4584
+ return res.status(400).json({ error: 'Invalid employee key.' });
4585
+ }
4586
+ const projectPath = req.body.projectPath || this.projectPath;
4587
+ const existing = (0, custom_employees_1.readCustomEmployees)(projectPath);
4588
+ const record = existing.find((e) => e.key === key);
4589
+ if (!record) {
4590
+ return res.status(404).json({ error: `Custom employee ${key} not found.` });
4591
+ }
4592
+ const { displayName, role, icon, jobIds } = req.body;
4593
+ const VALID_ICON_KINDS_PATCH = new Set(['emoji', 'generated', 'image']);
4594
+ const patchedIcon = icon !== undefined
4595
+ ? { kind: (VALID_ICON_KINDS_PATCH.has(icon.kind) ? icon.kind : record.icon.kind), value: icon.value ?? record.icon.value }
4596
+ : undefined;
4597
+ const updated = {
4598
+ ...record,
4599
+ ...(displayName !== undefined ? { displayName } : {}),
4600
+ ...(role !== undefined ? { role } : {}),
4601
+ ...(patchedIcon !== undefined ? { icon: patchedIcon } : {}),
4602
+ ...(jobIds !== undefined ? { jobIds } : {}),
4603
+ };
4604
+ (0, custom_employees_1.writeCustomEmployee)(projectPath, updated);
4605
+ return res.json(updated);
4606
+ });
4607
+ // DELETE /api/ai-hub/custom-employees/:key — remove a custom employee.
4608
+ this.app.delete('/api/ai-hub/custom-employees/:key', (req, res) => {
4609
+ const key = decodeURIComponent(req.params.key);
4610
+ if (!key.startsWith('custom:') || /[/\\]|\.\./.test(key.replace(/^custom:/, ''))) {
4611
+ return res.status(400).json({ error: 'Invalid employee key.' });
4612
+ }
4613
+ const projectPath = req.query.projectPath || this.projectPath;
4614
+ const removed = (0, custom_employees_1.deleteCustomEmployee)(projectPath, key);
4615
+ if (!removed) {
4616
+ return res.status(404).json({ error: `Custom employee ${key} not found.` });
4617
+ }
4618
+ return res.json({ key, removed: true });
4619
+ });
4620
+ // ─── End Issue #945 ───────────────────────────────────────────────────────
4466
4621
  // GET /api/ai-hub/hosts — list registered remote hosts with health status.
4467
4622
  this.app.get('/api/ai-hub/hosts', async (_req, res) => {
4468
4623
  const hosts = this.hostConfigStore.load();
@@ -58,6 +58,15 @@ exports.FIRST_RUN_AGENT_OPTIONS = [
58
58
  launchCommand: 'copilot',
59
59
  installPackage: '@github/copilot',
60
60
  },
61
+ {
62
+ id: 'antigravity-cli',
63
+ label: 'Antigravity CLI',
64
+ detectAliases: ['agy', 'antigravity', 'antigravity-cli'],
65
+ loginCommand: 'agy auth login',
66
+ launchCommand: 'agy',
67
+ // agy has no npm package — install via https://antigravity.google/cli/
68
+ installPackage: '',
69
+ },
61
70
  ];
62
71
  /**
63
72
  * The canonical row set, in display order. Each row starts in `pending`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.229",
3
+ "version": "2.0.231",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js"
@@ -158,7 +158,7 @@
158
158
  "electron": "^41.2.2",
159
159
  "electron-updater": "^6.8.9",
160
160
  "express": "^5.2.1",
161
- "fraim": "2.0.229",
161
+ "fraim": "2.0.231",
162
162
  "mongodb": "^7.0.0",
163
163
  "node-cron": "4.2.1",
164
164
  "node-edge-tts": "^1.2.10",