fraim-hub 2.0.296 → 2.0.298

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.
@@ -630,12 +630,24 @@ async function bootstrap() {
630
630
  });
631
631
  await launchDesktopShell(options);
632
632
  }
633
- // Issue #1415: self-execution is now gated on `require.main === module` in addition to the
633
+ function isDirectElectronEntry() {
634
+ if (require.main === module)
635
+ return true;
636
+ const currentFile = path_1.default.resolve(__filename).toLowerCase();
637
+ return process.argv.some((arg) => {
638
+ try {
639
+ return path_1.default.resolve(arg).toLowerCase() === currentFile;
640
+ }
641
+ catch {
642
+ return false;
643
+ }
644
+ });
645
+ }
646
+ // Issue #1415: self-execution is gated on a direct Electron entry check in addition to the
634
647
  // existing Electron-main-process check, so `desktop-launcher.ts` requiring this file (bundled or
635
- // materialized) does not double-run bootstrap the launcher calls the exported `bootstrap`
636
- // explicitly instead. Direct invocation (`npm run hub:desktop`, Electron-launching test suites)
637
- // is unaffected: this file is still `require.main` in that case.
638
- if (require.main === module && process.versions.electron && process.type !== 'renderer') {
648
+ // materialized) does not double-run bootstrap. Electron's loader can own `require.main`, so
649
+ // direct test launches also match the resolved script path in argv.
650
+ if (isDirectElectronEntry() && process.versions.electron && process.type !== 'renderer') {
639
651
  bootstrap().catch(async (error) => {
640
652
  console.error(error instanceof Error ? error.message : error);
641
653
  await stopServerOnce();
@@ -2257,13 +2257,15 @@ class FakeHostRuntime {
2257
2257
  detectEmployees() {
2258
2258
  return this.employees;
2259
2259
  }
2260
- startRun(hostId, _projectPath, message, handlers, _sessionId) {
2260
+ startRun(hostId, _projectPath, message, handlers, _sessionId, launchContext) {
2261
2261
  this.lastStartMessage = message;
2262
+ this.lastLaunchContext = launchContext;
2262
2263
  const reply = this.startReply ?? this.fakeEmployeeReply('start', message);
2263
2264
  return this.fakeProcess(hostId, reply, handlers);
2264
2265
  }
2265
- continueRun(hostId, _projectPath, sessionId, message, handlers) {
2266
+ continueRun(hostId, _projectPath, sessionId, message, handlers, launchContext) {
2266
2267
  this.lastContinueMessage = message;
2268
+ this.lastLaunchContext = launchContext;
2267
2269
  return this.fakeProcess(hostId, this.fakeEmployeeReply('continue', message), handlers);
2268
2270
  }
2269
2271
  startDirectRun(hostId, _message, _projectPath, handlers, _sessionId) {
@@ -50,7 +50,7 @@ function buildCommunicationStyleNote() {
50
50
  function buildBackgroundTaskPolicyNote() {
51
51
  return [
52
52
  '',
53
- '[Background task policy] You are running in Hub headless mode. Never end your turn while background tasks (bash commands started with run_in_background=true, or sub-agents) are still active. Instead: monitor them, poll for results, and provide periodic progress commentary to the manager. Only end your turn when all background tasks have finished, or when you genuinely need a user decision or input to continue. If you exit while a background task is active, the Hub loses the link to that task permanently.',
53
+ '[Background task policy] You are running in Hub headless mode. Never end your turn while background tasks (bash commands started with run_in_background=true, or sub-agents) are still active. Instead: monitor them, poll for results, and provide periodic progress commentary to the manager. Only end your turn when all background tasks have finished, or when you genuinely need a user decision or input to continue. If you exit while a background task is active, the Hub loses the link to that task permanently. Background tasks and Monitor watchers cannot survive this turn ending: the next turn is a new process with no link to the old one (Windows: hard-killed; Unix: orphaned). The two things that reliably work: run the command synchronously in the foreground within this turn, or supervise it with `npx tsx ~/.fraim/scripts/exec-with-timeout.ts --supervise <name> --timeout <n> -- <command>` and poll `--status`/`--wait` (or `--stop` to cancel) without ending your turn.',
54
54
  ].join('\n');
55
55
  }
56
56
  // Issue #732: a plain continue of the SAME active job must not re-load the job.
@@ -39,6 +39,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.findAvailablePortExcluding = exports.findAvailablePort = exports.AiHubServer = exports.HubConnectorStatusStore = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
40
  exports.selfHealBrokenManagedAgents = selfHealBrokenManagedAgents;
41
41
  exports.configureFraimForHubAgent = configureFraimForHubAgent;
42
+ exports.installAgentAndRefreshDetection = installAgentAndRefreshDetection;
42
43
  exports.hubCommandVersion = hubCommandVersion;
43
44
  exports.buildOpenFileInvocation = buildOpenFileInvocation;
44
45
  const express_1 = __importDefault(require("express"));
@@ -1760,6 +1761,30 @@ async function configureFraimForHubAgent(hubId) {
1760
1761
  return { configured: false, error: e instanceof Error ? e.message : String(e) };
1761
1762
  }
1762
1763
  }
1764
+ // Issue #1429: extracted from the install-agent route so the post-install cache-invalidation
1765
+ // policy is directly unit-testable with fake deps (no real npm install, no running HTTP
1766
+ // server) rather than only reachable by exercising the whole route.
1767
+ //
1768
+ // Root cause of #1429: this used to invalidate only when `outcome.outcome === 'standard'`.
1769
+ // When the CLI is installed via FRAIM's own managed-Node fallback (`outcome === 'managed'` -
1770
+ // exactly what happens when a CLI is reachable only through FRAIM's managed install
1771
+ // directory, not the Hub process's inherited system PATH), the cache was never invalidated,
1772
+ // so `detectEmployees()` (which backs `configured-agents`/`reconfigure-surfaces`/the employee
1773
+ // roster) kept serving its pre-install "unavailable" answer for up to
1774
+ // `EMPLOYEE_DETECTION_TTL_MS` (5 minutes) while `check-agent`'s uncached `hubCommandVersion()`
1775
+ // found the freshly-installed managed copy immediately - the exact divergence reported.
1776
+ // `selfHealBrokenManagedAgents()` already establishes the correct pattern (invalidate
1777
+ // unconditionally after any successful install); this now matches it.
1778
+ async function installAgentAndRefreshDetection(option, systemPath, deps) {
1779
+ const outcome = await (0, managed_agent_install_1.installManagedAgent)(option, systemPath, deps);
1780
+ // Issue #1010/#1429: something is now installed that was not before - via a standard
1781
+ // system-PATH install OR FRAIM's own managed-Node fallback - so the cached agent-
1782
+ // availability answer is stale either way. Drop it here rather than waiting out the TTL,
1783
+ // so the newly installed CLI shows as available immediately regardless of which install
1784
+ // path produced it.
1785
+ (0, hosts_1.invalidateEmployeeDetectionCache)();
1786
+ return outcome;
1787
+ }
1763
1788
  // Exported for direct unit testing of the Defect D fix (issue #1284/#1285):
1764
1789
  // asserting this resolves via a real system install and skips a poisoned
1765
1790
  // project-local node_modules/.bin entry, without spinning up a full server.
@@ -2199,6 +2224,7 @@ function buildHubBackgroundTaskContinueMessage(run) {
2199
2224
  `Session id: ${run.sessionId || 'unknown'}`,
2200
2225
  'The background task may still be running as an orphan process. Check whether its results are available on disk and continue your work.',
2201
2226
  'If results are not yet available, wait briefly or check again. Once you have the results, continue with your original plan.',
2227
+ 'Going forward, do not end your turn while this kind of task is active: either run it synchronously in the foreground, or supervise it with `npx tsx ~/.fraim/scripts/exec-with-timeout.ts --supervise <name> --timeout <n> -- <command>` and poll `--status`/`--wait` without ending your turn — ending the turn is what put this task at risk the first time.',
2202
2228
  ].join('\n');
2203
2229
  }
2204
2230
  // #1419 (Change 3): auto-resume when the only unresolved condition is a
@@ -2224,6 +2250,7 @@ function buildHubBackgroundTaskLostContinueMessage(run, killedDescription) {
2224
2250
  `A background task was killed when a turn ended before it reached a terminal state: ${killedDescription || describeKilledBackgroundTasks(run)}.`,
2225
2251
  'Its result is unknown, not passing or failing.',
2226
2252
  'Check for partial output first; if none exists, re-run the work and say plainly that this is a re-run because the prior attempt\'s result was never observed.',
2253
+ 'When you re-run it, do not repeat the mistake: run it synchronously in the foreground, or supervise it with `npx tsx ~/.fraim/scripts/exec-with-timeout.ts --supervise <name> --timeout <n> -- <command>` and poll `--status`/`--wait` without ending your turn — ending the turn is what killed it the first time.',
2227
2254
  ].join('\n');
2228
2255
  }
2229
2256
  function isHumanActionGate(run) {
@@ -3130,6 +3157,28 @@ class AiHubServer {
3130
3157
  launchContext: { agent: resolved.agent, env },
3131
3158
  };
3132
3159
  }
3160
+ // Issue #1443: stamp the Hub's own run id into the spawned host process's
3161
+ // env so the local FRAIM MCP proxy (a grandchild of this spawn, wired to
3162
+ // the host CLI over stdio) can report run-scoped signals like executionMode
3163
+ // back to the correct run. The proxy has no other way to know it: the FRAIM
3164
+ // workflow/MCP session id it carries is minted independently of any Hub run,
3165
+ // and the host CLI's own session id (`run.sessionId`, set via
3166
+ // applyRunHostSessionSignal) is not known until the host reports it — often
3167
+ // well after the FRAIM MCP tools have already fired. FRAIM_HUB_RUN_ID is set
3168
+ // at spawn time instead, so it is available for the entire life of the
3169
+ // process. Call this at every startRun/continueRun call site (Direct-mode
3170
+ // spawns are exempt — they never load FRAIM, so there is nothing to report).
3171
+ withHubRunIdEnv(launchContext, runId) {
3172
+ return { ...launchContext, env: { ...(launchContext.env || {}), FRAIM_HUB_RUN_ID: runId } };
3173
+ }
3174
+ // Shared by both the runId-keyed and legacy by-session execution-mode routes.
3175
+ applyExecutionModeToRun(runId, body) {
3176
+ const normalizedMode = body.mode === 'trusted' ? 'trusted' : 'coached';
3177
+ const normalizedRuns = typeof body.completedRuns === 'number' ? body.completedRuns : 0;
3178
+ this.runRegistry.update(runId, (current) => {
3179
+ current.executionMode = { mode: normalizedMode, completedRuns: normalizedRuns };
3180
+ });
3181
+ }
3133
3182
  configuredAgentIdForConversation(conversation, employees) {
3134
3183
  const existingConfiguredAgentId = typeof conversation.configuredAgentId === 'string'
3135
3184
  ? conversation.configuredAgentId.trim()
@@ -3328,9 +3377,17 @@ class AiHubServer {
3328
3377
  conversationRecordFromRun(run) {
3329
3378
  const lastUpdatedAt = run.updatedAt || new Date().toISOString();
3330
3379
  const stages = deriveStages(run, run.projectPath);
3380
+ const rawScope = run.scope
3381
+ ?? run.invokedArea;
3382
+ const recordScope = rawScope === 'manager' || rawScope === 'company' ? rawScope : 'project';
3383
+ const recordProjectPath = recordScope === 'manager'
3384
+ ? conversation_store_1.MANAGER_SCOPE_KEY
3385
+ : recordScope === 'company'
3386
+ ? conversation_store_1.COMPANY_SCOPE_KEY
3387
+ : path_1.default.resolve(run.projectPath);
3331
3388
  const record = {
3332
3389
  id: run.conversationId || run.id,
3333
- projectPath: path_1.default.resolve(run.projectPath),
3390
+ projectPath: recordProjectPath,
3334
3391
  title: run.conversationTitle || run.jobTitle || run.jobId,
3335
3392
  jobId: run.jobId,
3336
3393
  jobTitle: run.jobTitle || run.jobId,
@@ -3392,9 +3449,7 @@ class AiHubServer {
3392
3449
  sourceTrigger: run.sourceTrigger,
3393
3450
  // Issue #708: carry the invocation scope so the record lands in (and is keyed to)
3394
3451
  // the right bucket. Falls back to the legacy client `invokedArea` when present.
3395
- scope: run.scope
3396
- ?? run.invokedArea
3397
- ?? 'project',
3452
+ scope: recordScope,
3398
3453
  run: {
3399
3454
  stages,
3400
3455
  currentPhase: run.currentPhase || null,
@@ -3509,7 +3564,7 @@ class AiHubServer {
3509
3564
  this.maybeStartDelegatedChildRuns(updated);
3510
3565
  });
3511
3566
  },
3512
- }, startSessionSeedForHost(run.hostId, run.id), freshLaunch.launchContext);
3567
+ }, startSessionSeedForHost(run.hostId, run.id), this.withHubRunIdEnv(freshLaunch.launchContext, run.id));
3513
3568
  this.runRegistry.attachChildIfRunning(run.id, freshChild);
3514
3569
  }
3515
3570
  scheduleRunConversationPersistence(run, activeId) {
@@ -3802,7 +3857,7 @@ class AiHubServer {
3802
3857
  onExit: (exitCode) => {
3803
3858
  this.handleRunExit(currentRun.id, exitCode, options.postPark);
3804
3859
  },
3805
- }, launch.launchContext);
3860
+ }, this.withHubRunIdEnv(launch.launchContext, currentRun.id));
3806
3861
  this.runRegistry.attachChildIfRunning(currentRun.id, child);
3807
3862
  const updated = this.runRegistry.get(currentRun.id);
3808
3863
  if (updated)
@@ -4133,7 +4188,7 @@ class AiHubServer {
4133
4188
  }
4134
4189
  this.runRegistry.dispose(childRun.id);
4135
4190
  },
4136
- }, startSessionSeedForHost(managerRun.hostId, childRun.id), childLaunch.launchContext);
4191
+ }, startSessionSeedForHost(managerRun.hostId, childRun.id), this.withHubRunIdEnv(childLaunch.launchContext, childRun.id));
4137
4192
  this.runRegistry.attachChildIfRunning(childRun.id, child);
4138
4193
  }
4139
4194
  notifyManagerOfDelegatedChild(managerRunId, childRun) {
@@ -4290,7 +4345,7 @@ class AiHubServer {
4290
4345
  if (latestManager)
4291
4346
  this.drainPendingDelegatedReviews(latestManager);
4292
4347
  },
4293
- }, managerLaunch.launchContext);
4348
+ }, this.withHubRunIdEnv(managerLaunch.launchContext, managerRun.id));
4294
4349
  this.runRegistry.attachChildIfRunning(managerRun.id, child);
4295
4350
  }
4296
4351
  computeFirstRun(projectPath, jobCount, personas) {
@@ -5227,8 +5282,8 @@ class AiHubServer {
5227
5282
  },
5228
5283
  };
5229
5284
  const child = strategy === 'resume_same_host'
5230
- ? this.hostRuntime.continueRun(run.hostId, run.projectPath, priorSessionId, hostMessage, handlers, resolved.launchContext)
5231
- : this.hostRuntime.startRun(run.hostId, run.projectPath, hostMessage, handlers, startSessionSeedForHost(run.hostId, run.id), resolved.launchContext);
5285
+ ? this.hostRuntime.continueRun(run.hostId, run.projectPath, priorSessionId, hostMessage, handlers, this.withHubRunIdEnv(resolved.launchContext, run.id))
5286
+ : this.hostRuntime.startRun(run.hostId, run.projectPath, hostMessage, handlers, startSessionSeedForHost(run.hostId, run.id), this.withHubRunIdEnv(resolved.launchContext, run.id));
5232
5287
  this.runRegistry.attachChildIfRunning(run.id, child);
5233
5288
  attemptedRunId = null;
5234
5289
  console.info(`[ai-hub] hub.agent_switch.${strategy}`, {
@@ -5693,7 +5748,7 @@ class AiHubServer {
5693
5748
  loginHint: `Once installed, click "Check if Ready" to verify ${option.label} is on your PATH.`,
5694
5749
  });
5695
5750
  }
5696
- const outcome = await (0, managed_agent_install_1.installManagedAgent)({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath, { runProcess: hubRunProcess, commandVersion: hubCommandVersion });
5751
+ const outcome = await installAgentAndRefreshDetection({ label: option.label, installPackage: option.installPackage, launchCommand: option.launchCommand }, systemPath, { runProcess: hubRunProcess, commandVersion: hubCommandVersion });
5697
5752
  if (outcome.outcome === 'standard' && outcome.npmGlobalBinDirs.length > 0) {
5698
5753
  process.env.PATH = (0, managed_agent_paths_1.appendBinDirsToPath)(systemPath, outcome.npmGlobalBinDirs);
5699
5754
  }
@@ -5701,12 +5756,6 @@ class AiHubServer {
5701
5756
  if (!mcp.configured) {
5702
5757
  console.warn(`[ai-hub] install-agent: FRAIM add-ide did not run for ${option.label} (${outcome.outcome} install): ${mcp.error || 'unknown reason'}`);
5703
5758
  }
5704
- if (outcome.outcome === 'standard') {
5705
- // Issue #1010: something is now installed that was not before, so the cached
5706
- // agent-availability answer is stale. Drop it here rather than waiting out the
5707
- // TTL, so the newly installed CLI shows as available immediately.
5708
- (0, hosts_1.invalidateEmployeeDetectionCache)();
5709
- }
5710
5759
  return res.json({
5711
5760
  ok: true,
5712
5761
  message: `${option.label} installed successfully.`,
@@ -5832,21 +5881,35 @@ class AiHubServer {
5832
5881
  });
5833
5882
  // Issue #1345: accept executionMode forwarded from get_fraim_job response by the local proxy.
5834
5883
  // The proxy parses the Execution Mode Context block and POSTs here; Hub script reads from run poll.
5884
+ //
5885
+ // Issue #1443: the local proxy's only stable identity for "which run is this" is the
5886
+ // Hub's own run id (see withHubRunIdEnv / FRAIM_HUB_RUN_ID) — the FRAIM/MCP session id it
5887
+ // carries is unrelated to any Hub run, and the host CLI's own session id (run.sessionId)
5888
+ // often isn't known yet when the first get_fraim_job call fires. Runs by id, not session.
5889
+ this.app.post('/api/ai-hub/runs/:runId/execution-mode', (req, res) => {
5890
+ if (!this.requireTrustedHubOrigin(req, res))
5891
+ return;
5892
+ const run = this.runRegistry.get(req.params.runId);
5893
+ if (!run)
5894
+ return res.status(404).json({ error: 'Run not found.' });
5895
+ this.applyExecutionModeToRun(run.id, req.body);
5896
+ return res.json({ ok: true });
5897
+ });
5898
+ // Legacy fallback for a local proxy build that predates #1443 and has not yet been
5899
+ // updated to send FRAIM_HUB_RUN_ID. Session-id correlation is unreliable by construction
5900
+ // (see the comment above), so this only succeeds when a run happens to already carry a
5901
+ // matching sessionId; it exists to avoid a hard 404 during a mixed-version rollout, not as
5902
+ // a supported correlation path.
5835
5903
  this.app.post('/api/ai-hub/runs/by-session/:sessionId/execution-mode', (req, res) => {
5836
5904
  if (!this.requireTrustedHubOrigin(req, res))
5837
5905
  return;
5838
5906
  const { sessionId } = req.params;
5839
- const { mode, completedRuns } = req.body;
5840
5907
  if (!sessionId)
5841
5908
  return res.status(400).json({ error: 'sessionId required' });
5842
- const normalizedMode = mode === 'trusted' ? 'trusted' : 'coached';
5843
- const normalizedRuns = typeof completedRuns === 'number' ? completedRuns : 0;
5844
5909
  const run = this.runRegistry.all().find((r) => r.sessionId === sessionId);
5845
5910
  if (!run)
5846
5911
  return res.status(404).json({ error: 'Run not found for session.' });
5847
- this.runRegistry.update(run.id, (current) => {
5848
- current.executionMode = { mode: normalizedMode, completedRuns: normalizedRuns };
5849
- });
5912
+ this.applyExecutionModeToRun(run.id, req.body);
5850
5913
  return res.json({ ok: true });
5851
5914
  });
5852
5915
  this.app.post('/api/ai-hub/runs', (req, res) => {
@@ -6031,7 +6094,7 @@ class AiHubServer {
6031
6094
  this.maybeStartDelegatedChildRuns(updated);
6032
6095
  });
6033
6096
  },
6034
- }, startSessionSeedForHost(hostId, run.id), launchContext);
6097
+ }, startSessionSeedForHost(hostId, run.id), this.withHubRunIdEnv(launchContext, run.id));
6035
6098
  this.runRegistry.attachChildIfRunning(run.id, child);
6036
6099
  // Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
6037
6100
  // uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
@@ -6250,7 +6313,7 @@ class AiHubServer {
6250
6313
  this.maybeStartDelegatedChildRuns(updated);
6251
6314
  });
6252
6315
  },
6253
- }, startSessionSeedForHost(run.hostId, run.id), freshLaunch.launchContext);
6316
+ }, startSessionSeedForHost(run.hostId, run.id), this.withHubRunIdEnv(freshLaunch.launchContext, run.id));
6254
6317
  this.runRegistry.attachChildIfRunning(run.id, freshChild);
6255
6318
  const refreshedFresh = this.runRegistry.get(run.id);
6256
6319
  return res.json(refreshedFresh ? this.enrichRunForResponse(refreshedFresh) : refreshedFresh);
@@ -6335,7 +6398,7 @@ class AiHubServer {
6335
6398
  this.maybeStartDelegatedChildRuns(updated);
6336
6399
  });
6337
6400
  },
6338
- }, continueLaunch.launchContext, deliveryIntent);
6401
+ }, this.withHubRunIdEnv(continueLaunch.launchContext, run.id), deliveryIntent);
6339
6402
  if (!startedCodexReviewApprovalFallback)
6340
6403
  this.runRegistry.attachChildIfRunning(run.id, child);
6341
6404
  const refreshed = this.runRegistry.get(run.id);
@@ -6494,7 +6557,7 @@ class AiHubServer {
6494
6557
  onExit: (exitCode) => {
6495
6558
  this.handleRunExit(run.id, exitCode);
6496
6559
  },
6497
- }, launchContext);
6560
+ }, this.withHubRunIdEnv(launchContext, run.id));
6498
6561
  this.runRegistry.attachChildIfRunning(run.id, child);
6499
6562
  res.status(201).json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
6500
6563
  }
@@ -7263,7 +7326,7 @@ class AiHubServer {
7263
7326
  this.finalizeRunConversationProjection(run.id, updated.conversationId || updated.id);
7264
7327
  this.runRegistry.dispose(run.id);
7265
7328
  },
7266
- }, startSessionSeedForHost(hostId, run.id), launchContext);
7329
+ }, startSessionSeedForHost(hostId, run.id), this.withHubRunIdEnv(launchContext, run.id));
7267
7330
  // Update the registry entry with the real child process handle.
7268
7331
  this.runRegistry.attachChildIfRunning(run.id, child);
7269
7332
  return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
@@ -7423,7 +7486,7 @@ class AiHubServer {
7423
7486
  this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
7424
7487
  this.runRegistry.dispose(run.id);
7425
7488
  },
7426
- }, startSessionSeedForHost(hostId, run.id), launchContext);
7489
+ }, startSessionSeedForHost(hostId, run.id), this.withHubRunIdEnv(launchContext, run.id));
7427
7490
  this.runRegistry.create(run, child);
7428
7491
  return run;
7429
7492
  }
@@ -7579,7 +7642,7 @@ class AiHubServer {
7579
7642
  this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
7580
7643
  },
7581
7644
  onExit: (code) => this.handleRunExit(runId, code, postPark),
7582
- }, launchContext);
7645
+ }, this.withHubRunIdEnv(launchContext, runId));
7583
7646
  this.runRegistry.attachChildIfRunning(runId, child);
7584
7647
  }, delay);
7585
7648
  if (tid.unref)
@@ -172,6 +172,8 @@ class FraimDbService {
172
172
  this.orgAuditCollection = this.db.collection('fraim_org_audit');
173
173
  // Issue #1345 — job execution modes (Coached/Trusted).
174
174
  this.jobExecutionModesCollection = this.db.collection('fraim_job_execution_modes');
175
+ // Issue #1431 — per-user referral codes.
176
+ this.referralCodesCollection = this.db.collection('fraim_referral_codes');
175
177
  }
176
178
  async initializeIndexes() {
177
179
  if (!this.db)
@@ -252,6 +254,9 @@ class FraimDbService {
252
254
  await this.auditLogCollection.createIndex({ sequence: 1 }).catch(() => { });
253
255
  await this.auditLogCollection.createIndex({ userId: 1, ts: -1 }).catch(() => { });
254
256
  await this.auditLogCollection.createIndex({ ts: -1 }).catch(() => { });
257
+ // Issue #1431 — referral codes: one code per user, globally unique codes.
258
+ await this.referralCodesCollection.createIndex({ userId: 1 }, { unique: true }).catch(() => { });
259
+ await this.referralCodesCollection.createIndex({ code: 1 }, { unique: true }).catch(() => { });
255
260
  }
256
261
  async createSession(session) {
257
262
  if (!this.sessionsCollection)
@@ -613,6 +618,65 @@ class FraimDbService {
613
618
  throw new Error('DB not connected');
614
619
  return await this.signupsCollection.findOne({ email });
615
620
  }
621
+ async setSignupAttribution(email, data) {
622
+ if (!this.signupsCollection)
623
+ throw new Error('DB not connected');
624
+ const now = new Date();
625
+ await this.signupsCollection.updateOne({ email }, {
626
+ $setOnInsert: {
627
+ email,
628
+ name: '',
629
+ company: '',
630
+ source: data.source,
631
+ timestamp: now,
632
+ ipAddress: data.ipAddress,
633
+ userAgent: data.userAgent,
634
+ ...(data.referrerName ? { referrerName: data.referrerName } : {}),
635
+ ...(data.referralCodeUsed ? { referralCodeUsed: data.referralCodeUsed } : {}),
636
+ },
637
+ }, { upsert: true });
638
+ }
639
+ generateReferralCodeString() {
640
+ const crypto = require('crypto');
641
+ const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
642
+ const bytes = crypto.randomBytes(12);
643
+ return Array.from(bytes).map((b) => ALPHABET[b % ALPHABET.length]).join('');
644
+ }
645
+ async getReferralCode(userId) {
646
+ if (!this.referralCodesCollection)
647
+ throw new Error('DB not connected');
648
+ return await this.referralCodesCollection.findOne({ userId });
649
+ }
650
+ async getOrCreateReferralCode(userId) {
651
+ if (!this.referralCodesCollection)
652
+ throw new Error('DB not connected');
653
+ const existing = await this.referralCodesCollection.findOne({ userId });
654
+ if (existing)
655
+ return existing.code;
656
+ for (let attempt = 0; attempt < 5; attempt++) {
657
+ const code = this.generateReferralCodeString();
658
+ try {
659
+ await this.referralCodesCollection.insertOne({ userId, code, createdAt: new Date() });
660
+ return code;
661
+ }
662
+ catch (err) {
663
+ if (err?.code === 11000) {
664
+ const row = await this.referralCodesCollection.findOne({ userId });
665
+ if (row)
666
+ return row.code;
667
+ continue;
668
+ }
669
+ throw err;
670
+ }
671
+ }
672
+ throw new Error('Failed to generate unique referral code after retries');
673
+ }
674
+ async validateReferralCode(code) {
675
+ if (!this.referralCodesCollection)
676
+ throw new Error('DB not connected');
677
+ const exists = await this.referralCodesCollection.findOne({ code });
678
+ return exists !== null;
679
+ }
616
680
  /** Get existing API key by userId (email for self-serve) */
617
681
  async getApiKeyByUserId(userId, activeOnly = true) {
618
682
  if (!this.db)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.296",
3
+ "version": "2.0.298",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -211,7 +211,7 @@
211
211
  "electron-updater": "^6.8.9",
212
212
  "express": "^5.2.1",
213
213
  "extract-zip": "^2.0.1",
214
- "fraim": "2.0.296",
214
+ "fraim": "2.0.298",
215
215
  "mongodb": "^7.0.0",
216
216
  "node-cron": "4.2.1",
217
217
  "node-edge-tts": "^1.2.10",
@@ -8515,6 +8515,12 @@ function convNeedsPolling(conv) {
8515
8515
  }
8516
8516
 
8517
8517
  function switchToConversation(id) {
8518
+ const conv = findConversation(id);
8519
+ const scope = convScope(conv);
8520
+ const targetArea = scope === 'manager' || scope === 'company' ? scope : 'projects';
8521
+ if (conv && tf.area !== targetArea) {
8522
+ tfShowArea(targetArea);
8523
+ }
8518
8524
  state.activeId = id;
8519
8525
  persistActiveConversationSelection(id);
8520
8526
  // Issue #1065: a find session belongs to one thread. srchOpenResult re-opens it with the search
@@ -8522,23 +8528,23 @@ function switchToConversation(id) {
8522
8528
  if (typeof convFindClose === 'function') convFindClose();
8523
8529
  // For Company/Manager: move the shared .page into this area's conv-host and
8524
8530
  // switch from the info view to the full coaching panel.
8525
- if (tf.area === 'company' || tf.area === 'manager') {
8526
- tfEnsurePageInArea(tf.area);
8527
- const host = document.getElementById(tf.area + '-conv-host');
8528
- const info = document.getElementById(tf.area + '-info-view');
8531
+ if (targetArea === 'company' || targetArea === 'manager') {
8532
+ tfEnsurePageInArea(targetArea);
8533
+ const host = document.getElementById(targetArea + '-conv-host');
8534
+ const info = document.getElementById(targetArea + '-info-view');
8529
8535
  if (host) host.hidden = false;
8530
8536
  if (info) info.hidden = true;
8531
8537
  // #1397: an area selection represents every explicit destination, not only
8532
8538
  // the Info view. Poll-driven renders must preserve this conversation until
8533
8539
  // the user chooses another destination or the conversation disappears.
8534
- if (tf.areaSelection) tf.areaSelection[tf.area] = id;
8540
+ if (tf.areaSelection) tf.areaSelection[targetArea] = id;
8535
8541
  }
8536
8542
  renderRail();
8537
8543
  renderActive();
8538
- hydrateConversationBody(state.projectPath, id).catch((error) =>
8544
+ hydrateConversationBody(conv ? convBucketKey(conv) : state.projectPath, id).catch((error) =>
8539
8545
  console.warn('Could not hydrate switched conversation body:', error));
8540
- const conv = activeConversation();
8541
- if (convNeedsPolling(conv)) {
8546
+ const active = activeConversation();
8547
+ if (convNeedsPolling(active)) {
8542
8548
  startPolling();
8543
8549
  } else if (state.pollHandle) {
8544
8550
  window.clearInterval(state.pollHandle);
@@ -11050,7 +11056,9 @@ async function tfSelectProjectView(view, projectId) {
11050
11056
  if (tab.id === 'ptab-add') continue;
11051
11057
  const isOverview = tab.id === 'ptab-overview';
11052
11058
  const matchesProj = tab.dataset.projectId === tf.activeProjectId;
11053
- tab.classList.toggle('on', view === 'overview' ? isOverview : matchesProj);
11059
+ const isOn = view === 'overview' ? isOverview : matchesProj;
11060
+ tab.classList.toggle('on', isOn);
11061
+ if (isOn && matchesProj) tab.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' });
11054
11062
  }
11055
11063
  if (view === 'overview') {
11056
11064
  tfRenderOverview();
@@ -11187,6 +11195,8 @@ function tfRenderProjectTabs() {
11187
11195
  btn.addEventListener('click', () => tfSelectProjectView('workspace', proj.id));
11188
11196
  host.appendChild(btn);
11189
11197
  }
11198
+ const activeTab = host.querySelector('.ptab.on');
11199
+ if (activeTab) activeTab.scrollIntoView({ behavior: 'instant', block: 'nearest', inline: 'nearest' });
11190
11200
  }
11191
11201
 
11192
11202
  function tfRenderOverview() {