fraim-hub 2.0.264 → 2.0.266

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.
@@ -51,9 +51,11 @@ function withBucketLock(lockPath, fn, opts = {}) {
51
51
  fd = fs_1.default.openSync(lockPath, 'wx'); // O_CREAT | O_EXCL: fails if the lock already exists
52
52
  }
53
53
  catch (error) {
54
- if (error.code !== 'EEXIST')
54
+ const code = error.code;
55
+ const retryableContention = code === 'EEXIST' || code === 'EPERM' || code === 'EACCES';
56
+ if (!retryableContention)
55
57
  throw error;
56
- if (isLockStale(lockPath, staleMs)) {
58
+ if (fs_1.default.existsSync(lockPath) && isLockStale(lockPath, staleMs)) {
57
59
  try {
58
60
  fs_1.default.unlinkSync(lockPath);
59
61
  }
@@ -89,6 +89,7 @@ class HostSessionState {
89
89
  if (conversation.sessionId === normalizedSessionId) {
90
90
  const replacement = this.resolve({ ...conversation, sessionId: null }, owner);
91
91
  conversation.sessionId = replacement?.sessionId || null;
92
+ conversation.resumeCommand = null;
92
93
  }
93
94
  }
94
95
  markInvalidRun(run, owner, sessionId, reason, at = new Date().toISOString()) {
@@ -110,6 +111,7 @@ class HostSessionState {
110
111
  };
111
112
  if (run.sessionId === normalizedSessionId) {
112
113
  run.sessionId = undefined;
114
+ run.resumeCommand = null;
113
115
  }
114
116
  }
115
117
  hasInvalidSession(conversation, owner, sessionId) {
@@ -10,6 +10,7 @@ exports.parseUsageSignal = parseUsageSignal;
10
10
  exports.parseAgentIdentitySignal = parseAgentIdentitySignal;
11
11
  exports.__setAgentAvailabilityPathForTests = __setAgentAvailabilityPathForTests;
12
12
  exports.invalidateEmployeeDetectionCache = invalidateEmployeeDetectionCache;
13
+ exports.__clearEmployeeDetectionMemoryCacheForTests = __clearEmployeeDetectionMemoryCacheForTests;
13
14
  exports.__setEmployeeDetectionTtlForTests = __setEmployeeDetectionTtlForTests;
14
15
  exports.__getEmployeeProbeRoundsForTests = __getEmployeeProbeRoundsForTests;
15
16
  exports.__resetEmployeeProbeRoundsForTests = __resetEmployeeProbeRoundsForTests;
@@ -940,6 +941,13 @@ function invalidateEmployeeDetectionCache() {
940
941
  }
941
942
  catch { /* best effort */ }
942
943
  }
944
+ /** Test seam only. Simulates a process restart while preserving persisted last-known data. */
945
+ function __clearEmployeeDetectionMemoryCacheForTests() {
946
+ cachedEmployees = null;
947
+ cachedEmployeesAtMs = 0;
948
+ cachedEmployeesContext = null;
949
+ inFlightDetection = null;
950
+ }
943
951
  /** Test seam only. Mirrors __resetLatestVersionCache in hub-latest-version.ts. */
944
952
  function __setEmployeeDetectionTtlForTests(ttlMs) {
945
953
  employeeDetectionTtlMs = ttlMs ?? EMPLOYEE_DETECTION_TTL_MS;
@@ -1493,6 +1501,9 @@ function parseHostLine(hostId, line) {
1493
1501
  if (hostId === 'codex') {
1494
1502
  try {
1495
1503
  const parsed = JSON.parse(trimmed);
1504
+ if (parsed.type === 'system' && parsed.subtype === 'status' && parsed.status === 'compacting') {
1505
+ return withSignal({ raw: trimmed, hostLifecycle: { status: 'compacting', source: hostId } });
1506
+ }
1496
1507
  if (parsed.type === 'thread.started' && parsed.thread_id) {
1497
1508
  return withSignal({ sessionId: parsed.thread_id, raw: trimmed });
1498
1509
  }
@@ -74,6 +74,7 @@ const hub_latest_version_1 = require("./hub-latest-version");
74
74
  const ui_runtime_1 = require("./ui-runtime");
75
75
  const ui_cache_1 = require("./ui-cache");
76
76
  const semver = __importStar(require("semver"));
77
+ const tree_kill_1 = __importDefault(require("tree-kill"));
77
78
  const BOOTSTRAP_PERSONA_FIRST_PAINT_BUDGET_MS = 250;
78
79
  let personaHiringModule;
79
80
  let managerHiringModule;
@@ -118,6 +119,14 @@ function buildReviewApprovalSystemEventText(instructions) {
118
119
  return 'review_approved send_delivery merge_pr_work_completion';
119
120
  if (/^Approved and send, then clean up branch\.$/i.test(text))
120
121
  return 'review_approved send_delivery cleanup_branch';
122
+ // Generic fallback: any "Approved and <something>." or "Approved, <something>." produced
123
+ // by a job-owned domain action not covered by a specific pattern above. These arrive when
124
+ // approvalCommandFromLabel builds a label-derived command for a non-send-campaign domain
125
+ // action (e.g. "Approved and dispatch.", "Approved and send outreach.", "Approved and stage drafts.").
126
+ // Return review_approved so prepareContinueMessage sends the raw command to the agent instead
127
+ // of wrapping it in "Continue the active FRAIM job..." prose.
128
+ if (/^Approved(?:\sand\s|\s*,\s*)\S/i.test(text))
129
+ return 'review_approved send_delivery';
121
130
  return null;
122
131
  }
123
132
  function extractMissingHostSessionId(text) {
@@ -317,15 +326,17 @@ class AiHubRunRegistry {
317
326
  dispose(runId) {
318
327
  this.children.delete(runId);
319
328
  }
320
- // #521: terminate the agent process for a run (manager clicked Stop). Returns
321
- // true if a live child was signalled. The child's onExit handler then fires and
322
- // parks the run in its waiting state.
329
+ // #521/#1082: terminate the agent process and its entire process tree for a run
330
+ // (manager clicked Stop). Returns true if a live child was signalled. The
331
+ // child's onExit handler then fires and parks the run in its waiting state.
332
+ // tree-kill is used so that supervised long-running children (which are
333
+ // grandchildren of the cmd.exe wrapper) are also killed, not just the wrapper.
323
334
  stop(runId) {
324
335
  const child = this.children.get(runId);
325
- if (!child || typeof child.kill !== 'function')
336
+ if (!child || child.pid == null)
326
337
  return false;
327
338
  try {
328
- child.kill();
339
+ (0, tree_kill_1.default)(child.pid, 'SIGTERM');
329
340
  return true;
330
341
  }
331
342
  catch {
@@ -1139,6 +1150,12 @@ function applySeekMentoringSignal(run, signal) {
1139
1150
  run.artifacts = normalizedReviewHandoff?.reviewTarget?.type === 'artifact_set'
1140
1151
  ? normalizedReviewHandoff.artifacts
1141
1152
  : [];
1153
+ if (run.reviewHandoff?.reviewRequired === true) {
1154
+ run.pauseReason = 'awaiting_review';
1155
+ }
1156
+ else if (run.reviewHandoff?.reviewRequired === false && run.pauseReason === 'awaiting_review') {
1157
+ run.pauseReason = 'working';
1158
+ }
1142
1159
  }
1143
1160
  if (signal.delegationLedger &&
1144
1161
  run.jobId === 'fully-delegate' &&
@@ -1579,6 +1596,21 @@ function buildHubRecoveryContinueMessage(run, exitCode, attempt) {
1579
1596
  function createHubRecoveryEvent(run, exitCode, attempt) {
1580
1597
  return (0, hosts_1.createHubEvent)('system', `Hub auto-recovery attempt ${attempt}/${MAX_RECOVERY_ATTEMPTS} for run ${run.id} session ${run.sessionId || 'unknown'} after exit ${exitCode ?? 'unknown'}.`);
1581
1598
  }
1599
+ function buildHubCompactionRecoveryContinueMessage(run, exitCode, attempt) {
1600
+ return [
1601
+ '[FRAIM Hub system recovery]',
1602
+ 'The host compacted context or restarted its internal runtime while this run was still active.',
1603
+ 'This is not a manager-authored instruction. Do not say the manager asked you to continue.',
1604
+ `Run id: ${run.id}`,
1605
+ `Session id: ${run.sessionId || 'unknown'}`,
1606
+ `Compaction recovery attempt: ${attempt}/${MAX_RECOVERY_ATTEMPTS}`,
1607
+ `Prior exit code: ${exitCode ?? 'unknown'}`,
1608
+ 'Resume only if the tracked FRAIM phase is non-terminal and not waiting for human review or approval.',
1609
+ ].join('\n');
1610
+ }
1611
+ function createHubCompactionRecoveryEvent(run, exitCode, attempt) {
1612
+ return (0, hosts_1.createHubEvent)('system', `Hub compaction recovery attempt ${attempt}/${MAX_RECOVERY_ATTEMPTS} for run ${run.id} session ${run.sessionId || 'unknown'} after exit ${exitCode ?? 'unknown'}.`);
1613
+ }
1582
1614
  function isHumanActionGate(run) {
1583
1615
  if (run.stoppedByUser)
1584
1616
  return true;
@@ -1612,6 +1644,7 @@ function classifyExit(run, exitCode) {
1612
1644
  const lastEntry = phaseHistory.length > 0 ? phaseHistory[phaseHistory.length - 1] : null;
1613
1645
  const lastStatus = lastEntry?.latestStatus;
1614
1646
  const currentPhase = run.currentPhase;
1647
+ const compactingActive = run.hostLifecycle?.compacting?.active === true;
1615
1648
  if (currentPhase && currentPhase !== '__discriminant__' && currentPhase !== 'starting') {
1616
1649
  if (lastStatus === 'incomplete' || lastStatus === 'failure') {
1617
1650
  return { action: 'park', pauseReason: 'awaiting_user' };
@@ -1622,14 +1655,66 @@ function classifyExit(run, exitCode) {
1622
1655
  if (lastDeclared && lastDeclared.id === currentPhase) {
1623
1656
  return { action: 'done', pauseReason: 'done' };
1624
1657
  }
1658
+ if (compactingActive) {
1659
+ return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
1660
+ }
1625
1661
  return { action: 'park', pauseReason: 'awaiting_user' };
1626
1662
  }
1663
+ if (compactingActive) {
1664
+ return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
1665
+ }
1627
1666
  // Phase mid-flight (started but not completed/incomplete): conservative park.
1628
1667
  return { action: 'park', pauseReason: 'awaiting_user' };
1629
1668
  }
1669
+ if (compactingActive) {
1670
+ return { action: 'resume', pauseReason: 'working', recoveryKind: 'compaction' };
1671
+ }
1630
1672
  // Signal-less non-FRAIM run: conservative park.
1631
1673
  return { action: 'park', pauseReason: 'awaiting_user' };
1632
1674
  }
1675
+ function applyHostLifecycleSignal(run, signal) {
1676
+ if (!signal)
1677
+ return;
1678
+ const now = new Date().toISOString();
1679
+ if (signal.status === 'compacting') {
1680
+ const existing = run.hostLifecycle?.compacting;
1681
+ run.hostLifecycle = {
1682
+ ...(run.hostLifecycle || {}),
1683
+ compacting: {
1684
+ active: true,
1685
+ startedAt: existing?.startedAt || now,
1686
+ lastEventAt: now,
1687
+ source: signal.source,
1688
+ },
1689
+ };
1690
+ if (!isHumanActionGate(run)) {
1691
+ run.status = 'running';
1692
+ run.pauseReason = 'working';
1693
+ }
1694
+ }
1695
+ else if (signal.status === 'resumed' && run.hostLifecycle?.compacting) {
1696
+ run.hostLifecycle = {
1697
+ ...run.hostLifecycle,
1698
+ compacting: {
1699
+ ...run.hostLifecycle.compacting,
1700
+ active: false,
1701
+ lastEventAt: now,
1702
+ },
1703
+ };
1704
+ }
1705
+ }
1706
+ function clearCompactionLifecycle(run) {
1707
+ if (!run.hostLifecycle?.compacting?.active)
1708
+ return;
1709
+ run.hostLifecycle = {
1710
+ ...run.hostLifecycle,
1711
+ compacting: {
1712
+ ...run.hostLifecycle.compacting,
1713
+ active: false,
1714
+ lastEventAt: new Date().toISOString(),
1715
+ },
1716
+ };
1717
+ }
1633
1718
  class AiHubServer {
1634
1719
  get hubBase() {
1635
1720
  return process.env.FRAIM_HUB_BASE_URL || `http://127.0.0.1:${this.httpPort}`;
@@ -2273,6 +2358,44 @@ class AiHubServer {
2273
2358
  res.status(403).json({ error: 'Configured agent management is only allowed from the local Hub origin.' });
2274
2359
  return false;
2275
2360
  }
2361
+ applyRunHostSessionSignal(run, sessionId, hostIdForCommand = run.hostId) {
2362
+ const owner = {
2363
+ configuredAgentId: run.configuredAgentId || null,
2364
+ baseHostId: (run.baseHostId || run.hostId),
2365
+ };
2366
+ host_session_state_1.hostSessionState.applySession(run, owner, sessionId, { sourceRunId: run.id });
2367
+ run.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostIdForCommand, sessionId);
2368
+ }
2369
+ repairConversationHostSessionFromEvents(conversation, owner) {
2370
+ if (!conversation || !Array.isArray(conversation.events))
2371
+ return false;
2372
+ let repaired = false;
2373
+ for (const event of conversation.events) {
2374
+ const text = typeof event?.text === 'string' ? event.text.trim() : '';
2375
+ if (!text)
2376
+ continue;
2377
+ try {
2378
+ const parsed = (0, hosts_1.parseHostLine)(owner.baseHostId, text);
2379
+ if (!parsed.sessionId)
2380
+ continue;
2381
+ const projectedRun = {
2382
+ id: conversation.runId || conversation.id,
2383
+ hostSessions: conversation.hostSessions,
2384
+ sessionId: conversation.sessionId || undefined,
2385
+ };
2386
+ host_session_state_1.hostSessionState.applySession(projectedRun, owner, parsed.sessionId, { sourceRunId: conversation.runId || conversation.id });
2387
+ conversation.hostSessions = projectedRun.hostSessions;
2388
+ conversation.sessionId = projectedRun.sessionId || null;
2389
+ conversation.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(owner.baseHostId, parsed.sessionId);
2390
+ repaired = true;
2391
+ }
2392
+ catch {
2393
+ // Persisted event logs include plain text, diagnostics, and historical host output.
2394
+ // Non-parseable lines are not session signals.
2395
+ }
2396
+ }
2397
+ return repaired;
2398
+ }
2276
2399
  conversationRecordFromRun(run) {
2277
2400
  const lastUpdatedAt = run.updatedAt || new Date().toISOString();
2278
2401
  const stages = deriveStages(run, run.projectPath);
@@ -2289,6 +2412,7 @@ class AiHubServer {
2289
2412
  personaKey: run.personaKey ?? null,
2290
2413
  runId: run.id,
2291
2414
  sessionId: run.sessionId || null,
2415
+ resumeCommand: run.resumeCommand || (run.sessionId ? (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, run.sessionId) : null),
2292
2416
  status: run.status,
2293
2417
  // Issue #904: carry auxiliary exit classification to the persisted record.
2294
2418
  ...(run.pauseReason !== undefined && { pauseReason: run.pauseReason }),
@@ -2329,6 +2453,7 @@ class AiHubServer {
2329
2453
  hostSessions: { ...(run.hostSessions || {}) },
2330
2454
  restartRecovery: run.restartRecovery || null,
2331
2455
  restartRecoverySkippedReason: run.restartRecoverySkippedReason || null,
2456
+ hostLifecycle: run.hostLifecycle || null,
2332
2457
  // Issue #578: preserve trigger source so the UI can render the chip.
2333
2458
  sourceTrigger: run.sourceTrigger,
2334
2459
  // Issue #708: carry the invocation scope so the record lands in (and is keyed to)
@@ -2345,6 +2470,13 @@ class AiHubServer {
2345
2470
  },
2346
2471
  };
2347
2472
  host_session_state_1.hostSessionState.mergeFromRun(record, run);
2473
+ const activeHostSession = host_session_state_1.hostSessionState.resolve(record, {
2474
+ configuredAgentId: run.configuredAgentId || record.configuredAgentId || null,
2475
+ baseHostId: (run.baseHostId || run.hostId || record.baseHostId || record.agentName),
2476
+ });
2477
+ record.resumeCommand = activeHostSession
2478
+ ? (0, hosts_1.buildInteractiveResumeCommand)(record.baseHostId || record.agentName, activeHostSession.sessionId)
2479
+ : null;
2348
2480
  return record;
2349
2481
  }
2350
2482
  persistRunConversationNow(run, activeId) {
@@ -2394,9 +2526,10 @@ class AiHubServer {
2394
2526
  onEvent: (event, channel) => {
2395
2527
  this.runRegistry.update(run.id, (current) => {
2396
2528
  if (event.sessionId) {
2397
- host_session_state_1.hostSessionState.applySession(current, { configuredAgentId: current.configuredAgentId || null, baseHostId: current.baseHostId || current.hostId }, event.sessionId, { sourceRunId: current.id });
2398
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, event.sessionId);
2529
+ this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
2530
+ clearCompactionLifecycle(current);
2399
2531
  }
2532
+ applyHostLifecycleSignal(current, event.hostLifecycle);
2400
2533
  appendHostMessage(current, run.hostId, event, channel);
2401
2534
  if (event.raw) {
2402
2535
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -2572,6 +2705,10 @@ class AiHubServer {
2572
2705
  const projectPath = conversation.projectPath
2573
2706
  ? path_1.default.resolve(conversation.projectPath)
2574
2707
  : (MACHINE_LEVEL_JOB_IDS.has(conversation.jobId) ? os_1.default.homedir() : this.defaultProjectPath());
2708
+ const hostSessionOwner = { configuredAgentId: configuredAgent.id, baseHostId: hostId };
2709
+ this.repairConversationHostSessionFromEvents(conversation, hostSessionOwner);
2710
+ const recoveredHostSession = host_session_state_1.hostSessionState.resolve(conversation, hostSessionOwner);
2711
+ const recoveredSessionId = recoveredHostSession?.sessionId || undefined;
2575
2712
  return {
2576
2713
  id: (0, crypto_1.randomUUID)(),
2577
2714
  conversationId: conversation.id,
@@ -2585,8 +2722,9 @@ class AiHubServer {
2585
2722
  projectPath,
2586
2723
  scope: conversation.scope || 'project',
2587
2724
  status: 'running',
2588
- sessionId: conversation.sessionId || undefined,
2589
- resumeCommand: conversation.sessionId ? (0, hosts_1.buildInteractiveResumeCommand)(hostId, conversation.sessionId) : null,
2725
+ sessionId: recoveredSessionId,
2726
+ hostSessions: conversation.hostSessions ? { ...conversation.hostSessions } : undefined,
2727
+ resumeCommand: recoveredSessionId ? (0, hosts_1.buildInteractiveResumeCommand)(hostId, recoveredSessionId) : null,
2590
2728
  createdAt: typeof conversation.createdAt === 'string' ? conversation.createdAt : now,
2591
2729
  updatedAt: now,
2592
2730
  messages: persistedMessagesForRun(conversation),
@@ -2615,6 +2753,7 @@ class AiHubServer {
2615
2753
  reason,
2616
2754
  },
2617
2755
  restartRecoverySkippedReason: null,
2756
+ hostLifecycle: conversation.hostLifecycle || undefined,
2618
2757
  nextJobRecommendations: conversation.nextJobRecommendations || null,
2619
2758
  issueNumber: conversation.issueNumber ?? null,
2620
2759
  agentSwitches: conversation.agentSwitches || [],
@@ -2646,9 +2785,10 @@ class AiHubServer {
2646
2785
  onEvent: (event, channel) => {
2647
2786
  this.runRegistry.update(currentRun.id, (current) => {
2648
2787
  if (event.sessionId) {
2649
- current.sessionId = event.sessionId;
2650
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(current.hostId, event.sessionId);
2788
+ this.applyRunHostSessionSignal(current, event.sessionId, current.hostId);
2789
+ clearCompactionLifecycle(current);
2651
2790
  }
2791
+ applyHostLifecycleSignal(current, event.hostLifecycle);
2652
2792
  appendHostMessage(current, current.hostId, event, channel);
2653
2793
  if (event.raw) {
2654
2794
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -2970,9 +3110,10 @@ class AiHubServer {
2970
3110
  onEvent: (event, channel) => {
2971
3111
  this.runRegistry.update(childRun.id, (current) => {
2972
3112
  if (event.sessionId) {
2973
- current.sessionId = event.sessionId;
2974
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(managerRun.hostId, event.sessionId);
3113
+ this.applyRunHostSessionSignal(current, event.sessionId, managerRun.hostId);
3114
+ clearCompactionLifecycle(current);
2975
3115
  }
3116
+ applyHostLifecycleSignal(current, event.hostLifecycle);
2976
3117
  appendHostMessage(current, managerRun.hostId, event, channel);
2977
3118
  if (event.raw) {
2978
3119
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -3122,9 +3263,10 @@ class AiHubServer {
3122
3263
  onEvent: (event, channel) => {
3123
3264
  this.runRegistry.update(managerRun.id, (current) => {
3124
3265
  if (event.sessionId) {
3125
- current.sessionId = event.sessionId;
3126
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(managerRun.hostId, event.sessionId);
3266
+ this.applyRunHostSessionSignal(current, event.sessionId, managerRun.hostId);
3267
+ clearCompactionLifecycle(current);
3127
3268
  }
3269
+ applyHostLifecycleSignal(current, event.hostLifecycle);
3128
3270
  appendHostMessage(current, managerRun.hostId, event, channel);
3129
3271
  if (event.raw) {
3130
3272
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -3793,7 +3935,19 @@ class AiHubServer {
3793
3935
  .filter((incoming) => belongsInBucket(incoming))
3794
3936
  .map((incoming) => {
3795
3937
  const existing = incoming && incoming.id ? priorById.get(incoming.id) : undefined;
3796
- return existing ? { ...existing, ...incoming } : incoming;
3938
+ const merged = existing ? { ...existing, ...incoming } : incoming;
3939
+ const activeRun = merged?.runId ? this.runRegistry.get(merged.runId) : undefined;
3940
+ const incomingErrorState = merged.status === 'failed'
3941
+ || (merged.status === 'running' && merged.pauseReason === 'error');
3942
+ const activeRunAwaitingReview = activeRun?.pauseReason === 'awaiting_review';
3943
+ if (activeRun?.status === 'running' && (incomingErrorState || activeRunAwaitingReview)) {
3944
+ return {
3945
+ ...merged,
3946
+ status: 'running',
3947
+ pauseReason: activeRun.pauseReason === 'awaiting_review' ? 'awaiting_review' : 'working',
3948
+ };
3949
+ }
3950
+ return merged;
3797
3951
  });
3798
3952
  const saved = this.conversationStore.replaceProject(key, {
3799
3953
  activeId: body.activeId ?? null,
@@ -3960,9 +4114,10 @@ class AiHubServer {
3960
4114
  onEvent: (event, channel) => {
3961
4115
  this.runRegistry.update(run.id, (current) => {
3962
4116
  if (event.sessionId) {
3963
- host_session_state_1.hostSessionState.applySession(current, { configuredAgentId: current.configuredAgentId || null, baseHostId: current.baseHostId || current.hostId }, event.sessionId, { sourceRunId: current.id });
3964
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, event.sessionId);
4117
+ this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
4118
+ clearCompactionLifecycle(current);
3965
4119
  }
4120
+ applyHostLifecycleSignal(current, event.hostLifecycle);
3966
4121
  appendHostMessage(current, run.hostId, event, channel);
3967
4122
  if (event.raw) {
3968
4123
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -4650,7 +4805,7 @@ class AiHubServer {
4650
4805
  continuityDecision: continuity.continuityDecision,
4651
4806
  };
4652
4807
  this.runRegistry.create(run, {});
4653
- this.scheduleRunConversationPersistence(run, run.conversationId || run.id);
4808
+ this.persistRunConversation(run, run.conversationId || run.id);
4654
4809
  // Issue #442: create the Direct (B) run before spawning either process
4655
4810
  // so we can cross-link both runs via compareRunId before any events arrive.
4656
4811
  // directMsg is the plain user instructions — no FRAIM invocation prefix.
@@ -4692,9 +4847,10 @@ class AiHubServer {
4692
4847
  onEvent: (event, channel) => {
4693
4848
  this.runRegistry.update(run.id, (current) => {
4694
4849
  if (event.sessionId) {
4695
- current.sessionId = event.sessionId;
4696
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostId, event.sessionId);
4850
+ this.applyRunHostSessionSignal(current, event.sessionId, hostId);
4851
+ clearCompactionLifecycle(current);
4697
4852
  }
4853
+ applyHostLifecycleSignal(current, event.hostLifecycle);
4698
4854
  appendHostMessage(current, hostId, event, channel);
4699
4855
  if (event.raw) {
4700
4856
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -4733,9 +4889,10 @@ class AiHubServer {
4733
4889
  onEvent: (event, channel) => {
4734
4890
  this.runRegistry.update(directId, (current) => {
4735
4891
  if (event.sessionId) {
4736
- current.sessionId = event.sessionId;
4737
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostId, event.sessionId);
4892
+ this.applyRunHostSessionSignal(current, event.sessionId, hostId);
4893
+ clearCompactionLifecycle(current);
4738
4894
  }
4895
+ applyHostLifecycleSignal(current, event.hostLifecycle);
4739
4896
  appendHostMessage(current, hostId, event, channel);
4740
4897
  if (event.raw)
4741
4898
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -4884,6 +5041,7 @@ class AiHubServer {
4884
5041
  : this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, prepared.display || message);
4885
5042
  this.runRegistry.update(run.id, (current) => {
4886
5043
  current.status = 'running';
5044
+ current.pauseReason = 'working';
4887
5045
  current.sessionId = undefined;
4888
5046
  current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
4889
5047
  if (reviewApprovalSystemEventText)
@@ -4899,9 +5057,10 @@ class AiHubServer {
4899
5057
  onEvent: (event, channel) => {
4900
5058
  this.runRegistry.update(run.id, (current) => {
4901
5059
  if (event.sessionId) {
4902
- current.sessionId = event.sessionId;
4903
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, event.sessionId);
5060
+ this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
5061
+ clearCompactionLifecycle(current);
4904
5062
  }
5063
+ applyHostLifecycleSignal(current, event.hostLifecycle);
4905
5064
  appendHostMessage(current, run.hostId, event, channel);
4906
5065
  if (event.raw) {
4907
5066
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -4934,6 +5093,7 @@ class AiHubServer {
4934
5093
  }
4935
5094
  this.runRegistry.update(run.id, (current) => {
4936
5095
  current.status = 'running';
5096
+ current.pauseReason = 'working';
4937
5097
  // #521: bubble shows the manager's words; the agent gets the full message.
4938
5098
  current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
4939
5099
  if (reviewApprovalSystemEventText)
@@ -4948,9 +5108,10 @@ class AiHubServer {
4948
5108
  onEvent: (event, channel) => {
4949
5109
  this.runRegistry.update(run.id, (current) => {
4950
5110
  if (event.sessionId) {
4951
- current.sessionId = event.sessionId;
4952
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, event.sessionId);
5111
+ this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
5112
+ clearCompactionLifecycle(current);
4953
5113
  }
5114
+ applyHostLifecycleSignal(current, event.hostLifecycle);
4954
5115
  appendHostMessage(current, run.hostId, event, channel);
4955
5116
  if (event.raw) {
4956
5117
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -5026,11 +5187,16 @@ class AiHubServer {
5026
5187
  ? this.conversationStore.loadProject(conversationBucketKey).conversations.find((entry) => entry.id === requestedConversationId)
5027
5188
  : inferredConversation ?? undefined;
5028
5189
  const persistedRun = readPersistedRunProjection(persistedConversation);
5029
- const resolvedHostSession = host_session_state_1.hostSessionState.resolve(persistedConversation, {
5190
+ const hostSessionOwner = {
5030
5191
  configuredAgentId: configuredAgent.id,
5031
5192
  baseHostId: hostId,
5032
- });
5033
- if (!resolvedHostSession && host_session_state_1.hostSessionState.hasInvalidSession(persistedConversation, { configuredAgentId: configuredAgent.id, baseHostId: hostId }, requestedSessionId)) {
5193
+ };
5194
+ const repairedHostSessionFromEvents = this.repairConversationHostSessionFromEvents(persistedConversation, hostSessionOwner);
5195
+ if (repairedHostSessionFromEvents && persistedConversation) {
5196
+ this.conversationStore.upsertConversation(conversationBucketKey, persistedConversation, conversationId || undefined);
5197
+ }
5198
+ const resolvedHostSession = host_session_state_1.hostSessionState.resolve(persistedConversation, hostSessionOwner);
5199
+ if (!resolvedHostSession && host_session_state_1.hostSessionState.hasInvalidSession(persistedConversation, hostSessionOwner, requestedSessionId)) {
5034
5200
  return res.status(409).json({
5035
5201
  error: 'The saved host session was already marked invalid. Start a fresh handoff-backed run instead of retrying the same session.',
5036
5202
  hostSessionStatus: 'invalid',
@@ -5044,6 +5210,7 @@ class AiHubServer {
5044
5210
  conversationTitle: typeof body.conversationTitle === 'string' && body.conversationTitle.trim() ? body.conversationTitle.trim() : undefined,
5045
5211
  jobTitle: typeof body.jobTitle === 'string' && body.jobTitle.trim() ? body.jobTitle.trim() : jobId,
5046
5212
  jobId, hostId, configuredAgentId: configuredAgent.id, configuredAgentLabel: configuredAgent.label, baseHostId: configuredAgent.baseHostId, projectPath, status: 'running', sessionId,
5213
+ resumeCommand: (0, hosts_1.buildInteractiveResumeCommand)(hostId, sessionId),
5047
5214
  hostSessions: persistedConversation?.hostSessions ? { ...persistedConversation.hostSessions } : undefined,
5048
5215
  // Issue #892: keep the invocation scope so a resumed manager/company run stays
5049
5216
  // in its project-independent conversation bucket.
@@ -5078,16 +5245,19 @@ class AiHubServer {
5078
5245
  this.scheduleRunConversationPersistence(run, run.conversationId || run.id);
5079
5246
  const child = this.hostRuntime.continueRun(hostId, projectPath, sessionId, message, {
5080
5247
  onEvent: (event, channel) => {
5248
+ let invalidatedMissingHostSession = false;
5081
5249
  this.runRegistry.update(run.id, (current) => {
5082
5250
  if (event.sessionId) {
5083
- host_session_state_1.hostSessionState.applySession(current, { configuredAgentId: current.configuredAgentId || null, baseHostId: current.baseHostId || current.hostId }, event.sessionId, { sourceRunId: current.id });
5084
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostId, event.sessionId);
5251
+ this.applyRunHostSessionSignal(current, event.sessionId, hostId);
5252
+ clearCompactionLifecycle(current);
5085
5253
  }
5254
+ applyHostLifecycleSignal(current, event.hostLifecycle);
5086
5255
  appendHostMessage(current, hostId, event, channel);
5087
5256
  if (event.raw) {
5088
5257
  const missingSessionId = extractMissingHostSessionId(event.raw);
5089
5258
  if (missingSessionId) {
5090
5259
  host_session_state_1.hostSessionState.markInvalidRun(current, { configuredAgentId: current.configuredAgentId || null, baseHostId: current.baseHostId || current.hostId }, missingSessionId, 'host-reported-missing-session');
5260
+ invalidatedMissingHostSession = true;
5091
5261
  }
5092
5262
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
5093
5263
  applyReviewProjection(current, event.raw);
@@ -5102,8 +5272,14 @@ class AiHubServer {
5102
5272
  applyUsageSignal(current, event.usage);
5103
5273
  });
5104
5274
  const updated = this.runRegistry.get(run.id);
5105
- if (updated)
5106
- this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
5275
+ if (updated) {
5276
+ if (invalidatedMissingHostSession) {
5277
+ this.persistRunConversationNow(updated, updated.conversationId || updated.id);
5278
+ }
5279
+ else {
5280
+ this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
5281
+ }
5282
+ }
5107
5283
  },
5108
5284
  onExit: (exitCode) => {
5109
5285
  this.handleRunExit(run.id, exitCode);
@@ -5139,9 +5315,10 @@ class AiHubServer {
5139
5315
  onEvent: (event, channel) => {
5140
5316
  this.runRegistry.update(run.id, (current) => {
5141
5317
  if (event.sessionId) {
5142
- current.sessionId = event.sessionId;
5143
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, event.sessionId);
5318
+ this.applyRunHostSessionSignal(current, event.sessionId, run.hostId);
5319
+ clearCompactionLifecycle(current);
5144
5320
  }
5321
+ applyHostLifecycleSignal(current, event.hostLifecycle);
5145
5322
  appendHostMessage(current, run.hostId, event, channel);
5146
5323
  if (event.raw)
5147
5324
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -5733,9 +5910,10 @@ class AiHubServer {
5733
5910
  onEvent: (event, channel) => {
5734
5911
  this.runRegistry.update(run.id, (current) => {
5735
5912
  if (event.sessionId) {
5736
- current.sessionId = event.sessionId;
5737
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostId, event.sessionId);
5913
+ this.applyRunHostSessionSignal(current, event.sessionId, hostId);
5914
+ clearCompactionLifecycle(current);
5738
5915
  }
5916
+ applyHostLifecycleSignal(current, event.hostLifecycle);
5739
5917
  appendHostMessage(current, hostId, event, channel);
5740
5918
  if (event.raw)
5741
5919
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -5883,9 +6061,10 @@ class AiHubServer {
5883
6061
  onEvent: (event, channel) => {
5884
6062
  this.runRegistry.update(run.id, (current) => {
5885
6063
  if (event.sessionId) {
5886
- current.sessionId = event.sessionId;
5887
- current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostId, event.sessionId);
6064
+ this.applyRunHostSessionSignal(current, event.sessionId, hostId);
6065
+ clearCompactionLifecycle(current);
5888
6066
  }
6067
+ applyHostLifecycleSignal(current, event.hostLifecycle);
5889
6068
  appendHostMessage(current, hostId, event, channel);
5890
6069
  if (event.raw) {
5891
6070
  current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -5937,7 +6116,9 @@ class AiHubServer {
5937
6116
  current.recoveryAttempts = (current.recoveryAttempts ?? 0) + 1;
5938
6117
  current.lastRecoveryAt = new Date().toISOString();
5939
6118
  current.pauseReason = 'working';
5940
- current.events.push(createHubRecoveryEvent(current, exitCode, current.recoveryAttempts));
6119
+ current.events.push(classification.recoveryKind === 'compaction'
6120
+ ? createHubCompactionRecoveryEvent(current, exitCode, current.recoveryAttempts)
6121
+ : createHubRecoveryEvent(current, exitCode, current.recoveryAttempts));
5941
6122
  });
5942
6123
  const refreshed = this.runRegistry.get(runId);
5943
6124
  if (!refreshed?.sessionId) {
@@ -5958,12 +6139,17 @@ class AiHubServer {
5958
6139
  const current = this.runRegistry.get(runId);
5959
6140
  if (!current || current.status !== 'running')
5960
6141
  return;
5961
- const message = buildHubRecoveryContinueMessage(current, exitCode, attempt);
6142
+ const message = classification.recoveryKind === 'compaction'
6143
+ ? buildHubCompactionRecoveryContinueMessage(current, exitCode, attempt)
6144
+ : buildHubRecoveryContinueMessage(current, exitCode, attempt);
5962
6145
  const child = this.hostRuntime.continueRun(current.hostId, current.projectPath, current.sessionId, message, {
5963
6146
  onEvent: (event, channel) => {
5964
6147
  this.runRegistry.update(runId, (r) => {
5965
- if (event.sessionId)
5966
- r.sessionId = event.sessionId;
6148
+ if (event.sessionId) {
6149
+ this.applyRunHostSessionSignal(r, event.sessionId, current.hostId);
6150
+ clearCompactionLifecycle(r);
6151
+ }
6152
+ applyHostLifecycleSignal(r, event.hostLifecycle);
5967
6153
  appendHostMessage(r, current.hostId, event, channel);
5968
6154
  if (event.raw) {
5969
6155
  r.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
@@ -6002,6 +6188,7 @@ class AiHubServer {
6002
6188
  : (exitCode === 0 ? 'completed' : 'failed');
6003
6189
  }
6004
6190
  current.pauseReason = classification.pauseReason;
6191
+ clearCompactionLifecycle(current);
6005
6192
  if (!current.stoppedByUser) {
6006
6193
  current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
6007
6194
  }
@@ -53,8 +53,8 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
53
53
  pam: {
54
54
  personaKey: 'pam',
55
55
  bundleId: 'persona-pam-core',
56
- catalogMetadata: buildCatalogMetadata('pam', ['feature-specification', 'technical-design', 'project-plan-creation']),
57
- protectedJobs: ['feature-specification', 'technical-design', 'experiment-tracking', 'project-plan-creation', 'implementation-feature-review', 'scrum-sprint-planning', 'mvp-validation-plan', 'sprint-planning', 'customer-prospect-discovery', 'interview-preparation', 'participant-recruitment', 'process-interview-notes', 'review-customer-development', 'triage-customer-needs'],
56
+ catalogMetadata: buildCatalogMetadata('pam', ['feature-specification', 'project-plan-creation', 'implementation-feature-review']),
57
+ protectedJobs: ['feature-specification', 'send-newsletter', 'send-thank-you-notes', 'experiment-tracking', 'project-plan-creation', 'implementation-feature-review', 'scrum-sprint-planning', 'mvp-validation-plan', 'sprint-planning', 'customer-prospect-discovery', 'interview-preparation', 'participant-recruitment', 'process-interview-notes', 'review-customer-development', 'triage-customer-needs'],
58
58
  protectedAliases: ['product-management', 'product-spec'],
59
59
  defaultHireMode: 'job',
60
60
  lockCopy: 'Hire PaM to unlock product-management work for this request.'
@@ -63,7 +63,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
63
63
  personaKey: 'swen',
64
64
  bundleId: 'persona-swen-core',
65
65
  catalogMetadata: buildCatalogMetadata('swen', ['feature-implementation', 'technical-design', 'code-refactoring']),
66
- protectedJobs: ['feature-implementation', 'implementation-design-review', 'code-refactoring', 'pr-iteration', 'mobile-app-development', 'mcp-server-creation', 'cloud-application-deployment', 'cloud-cost-optimization', 'cloud-performance-diagnosis', 'route-llm-spend-to-cloud-credits', 'set-up-cloud-cost-alerts', 'gitlabs-to-github', 'system-migration', 'cross-cloud-migration', 'data-pipeline-design', 'data-quality-monitoring', 'data-platform-architecture', 'write-dev-docs', 'database-schema-design', 'create-architecture', 'project-scaffolding', 'codebase-analysis-and-ideation', 'github-org-setup', 'google-workspace-setup', 'mobile-app-rejection-response', 'mobile-app-submission', 'publish-mcp-app', 'application-replication-workflow'],
66
+ protectedJobs: ['feature-implementation', 'technical-design', 'implementation-design-review', 'code-refactoring', 'pr-iteration', 'mobile-app-development', 'mcp-server-creation', 'cloud-application-deployment', 'cloud-cost-optimization', 'cloud-performance-diagnosis', 'route-llm-spend-to-cloud-credits', 'set-up-cloud-cost-alerts', 'gitlabs-to-github', 'system-migration', 'cross-cloud-migration', 'data-pipeline-design', 'data-quality-monitoring', 'data-platform-architecture', 'write-dev-docs', 'database-schema-design', 'create-architecture', 'project-scaffolding', 'codebase-analysis-and-ideation', 'github-org-setup', 'google-workspace-setup', 'mobile-app-rejection-response', 'mobile-app-submission', 'publish-mcp-app', 'application-replication-workflow'],
67
67
  protectedAliases: ['software-engineering', 'implementation'],
68
68
  defaultHireMode: 'job',
69
69
  lockCopy: 'Hire SWEn to unlock software-engineering delivery for this request.'
@@ -117,7 +117,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
117
117
  personaKey: 'ashley',
118
118
  bundleId: 'persona-ashley-core',
119
119
  catalogMetadata: buildCatalogMetadata('ashley', ['chief-of-staff-briefing', 'executive-assistant', 'analyze-transcript']),
120
- protectedJobs: ['chief-of-staff-briefing', 'calendar-triage', 'meeting-preparation', 'executive-assistant', 'send-newsletter', 'send-thank-you-notes', 'analyze-transcript', 'travel-planning', 'travel-disruption-rebooking'],
120
+ protectedJobs: ['chief-of-staff-briefing', 'calendar-triage', 'meeting-preparation', 'executive-assistant', 'analyze-transcript', 'travel-planning', 'travel-disruption-rebooking'],
121
121
  protectedAliases: ['executive-assistant', 'operations-assistant'],
122
122
  defaultHireMode: 'job',
123
123
  lockCopy: 'Hire AshLey to unlock executive-assistant work for this request.'
@@ -273,6 +273,7 @@ for (const bundle of Object.values(exports.PERSONA_CAPABILITY_BUNDLES)) {
273
273
  // as "free") so the Hub attributes them to FRAIMworker, but they are never
274
274
  // hire-gated because FRAIMworker is not a purchasable persona.
275
275
  const GENERIC_WORKER_OWNED_JOBS = new Set([
276
+ 'create-hub-configured-agent',
276
277
  'contribute-to-fraim',
277
278
  'file-fraim-issue',
278
279
  'praise-fraim',
@@ -90,6 +90,13 @@ function getLearningRoots(workspaceRoot) {
90
90
  repoLearningsBase: (0, project_fraim_paths_1.getWorkspaceLearningsDir)(workspaceRoot)
91
91
  };
92
92
  }
93
+ function sameResolvedPath(a, b) {
94
+ return (0, path_1.resolve)(a) === (0, path_1.resolve)(b);
95
+ }
96
+ function portablePersonalBaseIsLive(globalBase) {
97
+ return sameResolvedPath((0, path_1.join)((0, pack_home_1.resolvePackHome)('manager').contentRoot, 'learnings'), globalBase)
98
+ || !sameResolvedPath(globalBase, (0, project_fraim_paths_1.getUserFraimLearningsDir)());
99
+ }
93
100
  function resolvePersonalLearningFile(repoBase, managerCacheBase, managerCacheDisplayBase, globalBase, globalDisplayBase, fileName) {
94
101
  const repoPath = (0, path_1.join)(repoBase, fileName);
95
102
  if ((0, fs_1.existsSync)(repoPath)) {
@@ -100,14 +107,14 @@ function resolvePersonalLearningFile(repoBase, managerCacheBase, managerCacheDis
100
107
  };
101
108
  }
102
109
  const globalPath = (0, path_1.join)(globalBase, fileName);
103
- if ((0, fs_1.existsSync)(globalPath)) {
110
+ const managerCachePath = (0, path_1.join)(managerCacheBase, fileName);
111
+ if (portablePersonalBaseIsLive(globalBase) && (0, fs_1.existsSync)(globalPath)) {
104
112
  return {
105
113
  present: true,
106
114
  path: globalPath,
107
115
  displayPath: `${globalDisplayBase.replace(/\/$/, '')}/${fileName}`
108
116
  };
109
117
  }
110
- const managerCachePath = (0, path_1.join)(managerCacheBase, fileName);
111
118
  if ((0, fs_1.existsSync)(managerCachePath)) {
112
119
  return {
113
120
  present: true,
@@ -140,7 +147,9 @@ function resolvePersonalLearningFileTiers(repoBase, managerCacheBase, managerCac
140
147
  });
141
148
  }
142
149
  const globalPath = (0, path_1.join)(globalBase, fileName);
143
- if ((0, fs_1.existsSync)(globalPath)) {
150
+ if (portablePersonalBaseIsLive(globalBase) &&
151
+ (0, fs_1.existsSync)(globalPath) &&
152
+ !tiers.some((tier) => sameResolvedPath(tier.path, globalPath))) {
144
153
  tiers.push({
145
154
  level: 'manager',
146
155
  path: globalPath,
@@ -405,10 +414,25 @@ function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
405
414
  }
406
415
  return sources.sort((a, b) => b.sortKey.localeCompare(a.sortKey) || a.displayPath.localeCompare(b.displayPath));
407
416
  }
417
+ /**
418
+ * Issue #1078 fix: derive the org display path from contentRoot rather than
419
+ * hardcoding the pre-migration flat layout. After migrateOrgFlatContent runs,
420
+ * contentRoot is ~/.fraim/org/personalized-employee/ — not ~/.fraim/org/.
421
+ * The relative portion after the ~/.fraim/ prefix is computed dynamically so
422
+ * it cannot drift from the read path again.
423
+ */
424
+ function orgCacheDisplayPath(fileName) {
425
+ const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
426
+ const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', fileName);
427
+ const rel = cachePath.startsWith(fraimDir)
428
+ ? cachePath.slice(fraimDir.length).replace(/\\/g, '/').replace(/^\//, '')
429
+ : cachePath.replace(/\\/g, '/');
430
+ return (0, project_fraim_paths_1.getUserFraimDisplayPath)(rel);
431
+ }
408
432
  /**
409
433
  * Resolve an L2 org-scope learning file (issue #563): a repo-local override
410
434
  * (`fraim/personalized-employee/learnings/`) wins, then the synced org cache
411
- * (`~/.fraim/org/learnings/`). Org learnings are part of the shared org pack,
435
+ * (contentRoot/learnings/). Org learnings are part of the shared org pack,
412
436
  * so a synced copy must be readable by the agent even with no repo-local copy.
413
437
  * Mirrors the org context/rules resolution order. Returns `present:false` with
414
438
  * the repo path/display when neither tier has the file.
@@ -420,7 +444,7 @@ function resolveOrgLearningFile(repoLearningsBase, fileName) {
420
444
  }
421
445
  const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', fileName);
422
446
  if ((0, fs_1.existsSync)(cachePath)) {
423
- return { present: true, path: cachePath, displayPath: (0, project_fraim_paths_1.getUserFraimDisplayPath)(`org/learnings/${fileName}`) };
447
+ return { present: true, path: cachePath, displayPath: orgCacheDisplayPath(fileName) };
424
448
  }
425
449
  return { present: false, path: repoPath, displayPath: `${REPO_LEARNINGS_REL}/${fileName}` };
426
450
  }
@@ -436,7 +460,7 @@ function resolveOrgLearningFileTiers(repoLearningsBase, fileName) {
436
460
  tiers.push({
437
461
  level: 'org',
438
462
  path: cachePath,
439
- displayPath: (0, project_fraim_paths_1.getUserFraimDisplayPath)(`org/learnings/${fileName}`)
463
+ displayPath: orgCacheDisplayPath(fileName)
440
464
  });
441
465
  }
442
466
  const repoPath = (0, path_1.join)(repoLearningsBase, fileName);
@@ -1158,11 +1182,20 @@ function collectBrainLearningDots(workspaceRoot, userId) {
1158
1182
  if (coldEntries.length) {
1159
1183
  processed.push({ type: fileType, typeLabel, region, scope: 'cold', displayPath: coldResolved.displayPath, entries: coldEntries });
1160
1184
  }
1161
- // L2 org
1162
- const org = resolveOrgLearningFile(roots.repoLearningsBase, `org-${fileType}.md`);
1163
- const orgEntries = parseBrainLearningEntries(org.path, fileType);
1164
- if (orgEntries.length) {
1165
- processed.push({ type: fileType, typeLabel, region, scope: 'org', displayPath: org.displayPath, entries: orgEntries });
1185
+ // L2 org — issue #1078 fix: use tiered resolver + merge so synced org home
1186
+ // and repo-local org-*.md are both included, same as buildLearningContextSection.
1187
+ const orgTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, `org-${fileType}.md`);
1188
+ if (orgTiers.length > 0) {
1189
+ // Merge entries across tiers; deduplicate by title (last tier wins = repo-local wins).
1190
+ const allOrgEntries = orgTiers.flatMap(t => parseBrainLearningEntries(t.path, fileType));
1191
+ const byTitle = new Map();
1192
+ for (const e of allOrgEntries)
1193
+ byTitle.set(e.title.trim().toLowerCase(), e);
1194
+ const mergedOrgEntries = [...byTitle.values()];
1195
+ if (mergedOrgEntries.length) {
1196
+ // Use the first (highest-precedence = synced org home) tier's displayPath.
1197
+ processed.push({ type: fileType, typeLabel, region, scope: 'org', displayPath: orgTiers[0].displayPath, entries: mergedOrgEntries });
1198
+ }
1166
1199
  }
1167
1200
  }
1168
1201
  return { pending: { count: pendingSignals.length, signals: pendingSignals }, processed };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.264",
3
+ "version": "2.0.266",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -163,7 +163,7 @@
163
163
  "electron": "^41.2.2",
164
164
  "electron-updater": "^6.8.9",
165
165
  "express": "^5.2.1",
166
- "fraim": "2.0.264",
166
+ "fraim": "2.0.266",
167
167
  "mongodb": "^7.0.0",
168
168
  "node-cron": "4.2.1",
169
169
  "node-edge-tts": "^1.2.10",
@@ -204,19 +204,6 @@
204
204
  </div>
205
205
  </section>
206
206
 
207
- <!-- Issue #512: Brain area (reached from the account menu) -->
208
- <section class="hub-area" id="area-brain" hidden>
209
- <div class="hub-area-page">
210
- <div class="eyebrow">FRAIM · Brain</div>
211
- <div class="area-h1">Brain</div>
212
- <p class="area-lede">The full picture of what your team knows: every preserved learning across scopes, plus the skills and rules they draw on.</p>
213
- <div class="sec-label" style="margin-top:22px;">Preserved learnings</div>
214
- <div class="stat-grid" id="brain-learnings"></div>
215
- <div class="sec-label" style="margin-top:22px;">What your team draws on</div>
216
- <div class="stat-grid" id="brain-catalog"></div>
217
- </div>
218
- </section>
219
-
220
207
  <section class="hub-area" id="area-connected" hidden>
221
208
  <div class="connected-shell">
222
209
  <div class="connected-toolbar">
@@ -343,7 +343,6 @@ function tfRefreshPersonaDependentSurfaces() {
343
343
  renderManagerTeamPool();
344
344
  if (tf.area === 'company') tfRenderCompany();
345
345
  else if (tf.area === 'manager') tfRenderManager();
346
- else if (tf.area === 'brain') tfRenderBrain();
347
346
  if (tf.area === 'projects') {
348
347
  if (tf.projectView === 'overview') tfRenderOverview();
349
348
  else {
@@ -2532,13 +2531,15 @@ function statusLabel(s) {
2532
2531
  function conversationUiState(conv) {
2533
2532
  if (!conv) return 'idle';
2534
2533
  if (conv.blocked) return 'blocked';
2534
+ // Issue #1081: a submit-phase review handoff is a manager gate even if the
2535
+ // employee process is still alive for post-submit bookkeeping.
2536
+ if (conv.pauseReason === 'awaiting_review') return 'waiting';
2535
2537
  if (conv.status === 'running') return 'working';
2536
2538
  // Issue #904: read pauseReason first for non-running records so the pill
2537
2539
  // reflects the exit classification rather than mapping all completed -> 'waiting'.
2538
2540
  if (conv.pauseReason === 'working') return 'working';
2539
2541
  if (conv.pauseReason === 'done') return 'complete';
2540
2542
  if (conv.pauseReason === 'error') return 'error';
2541
- if (conv.pauseReason === 'awaiting_review') return 'waiting';
2542
2543
  if (conv.pauseReason === 'awaiting_user') return 'waiting';
2543
2544
  if (conv.pauseReason === 'stopped') return 'stopped';
2544
2545
  // Legacy fallback for records written before #904 (no pauseReason field).
@@ -4833,6 +4834,9 @@ function defaultCoachNote(conv) {
4833
4834
  if (uiState === 'error') {
4834
4835
  return 'The employee encountered an error. Review the run log and send a corrective instruction to retry.';
4835
4836
  }
4837
+ if (conv && conv.pauseReason === 'awaiting_user') {
4838
+ return 'The employee is waiting on you. Send the next instruction to continue this run.';
4839
+ }
4836
4840
  if (conv && conv.status === 'running') {
4837
4841
  return 'The employee is still working. Add coaching here to tighten the next step without losing context.';
4838
4842
  }
@@ -4939,6 +4943,10 @@ function convAwaitingReview(conv) {
4939
4943
  if (!conv) return false;
4940
4944
  if (conv.pauseReason === 'done') return false;
4941
4945
  if (conv.reviewApproved) return false;
4946
+ const handoff = reviewHandoffForConversation(conv);
4947
+ if (conv.pauseReason === 'awaiting_review') {
4948
+ return handoff ? handoff.reviewRequired === true : false;
4949
+ }
4942
4950
  // #770 R2/R4: never ask for a decision while the run is actively working a
4943
4951
  // (possibly later, no-decision) phase. conv.status is maintained by
4944
4952
  // foldRunIntoConversation. While 'running' the manager is coaching, not
@@ -4950,7 +4958,6 @@ function convAwaitingReview(conv) {
4950
4958
  // happens to have produced a file (it appears in the Deliverables panel, not
4951
4959
  // an approve/reject bar).
4952
4960
  if (conv.jobId === '__freeform__') return false;
4953
- const handoff = reviewHandoffForConversation(conv);
4954
4961
  if (handoff) return handoff.reviewRequired === true;
4955
4962
  // Delegation orchestration runs (fully-delegate with an active ledger) only enter review
4956
4963
  // via an explicit review_handoff — Mandy's delegation artifacts are orchestration working
@@ -7647,6 +7654,7 @@ function foldRunIntoConversation(conv, run) {
7647
7654
  conv.title = conversationTitle(conv);
7648
7655
  // Track session for resumption.
7649
7656
  if (run.sessionId) conv.sessionId = run.sessionId;
7657
+ if (run.resumeCommand !== undefined) conv.resumeCommand = run.resumeCommand;
7650
7658
  if (run.hostSessions && typeof run.hostSessions === 'object') {
7651
7659
  conv.hostSessions = { ...(conv.hostSessions || {}), ...run.hostSessions };
7652
7660
  }
@@ -10079,7 +10087,6 @@ function tfShowArea(area) {
10079
10087
  }
10080
10088
  if (area === 'company') tfRenderCompany();
10081
10089
  if (area === 'manager') tfRenderManager();
10082
- if (area === 'brain') tfRenderBrain();
10083
10090
  // Issue #1065: the scope is derived from the active surface, so it changes with the tab (R3).
10084
10091
  if (typeof srchOnSurfaceChanged === 'function') srchOnSurfaceChanged();
10085
10092
  window.scrollTo(0, 0);
@@ -13468,44 +13475,6 @@ async function tfPromoteProposal(originScope, proposal, targetScope) {
13468
13475
  showStatus(`Promoted “${proposal.title}” to ${label} learnings. Run Share learnings to let sleep-on-learnings persist it.`, false);
13469
13476
  }
13470
13477
  }
13471
- function tfStatCard(num, label, color) {
13472
- const card = document.createElement('div');
13473
- card.className = 'stat-card';
13474
- const n = document.createElement('div');
13475
- n.className = 'stat-num';
13476
- n.textContent = String(num);
13477
- if (color) n.style.color = color;
13478
- const l = document.createElement('div');
13479
- l.className = 'stat-lbl';
13480
- l.textContent = label;
13481
- card.appendChild(n); card.appendChild(l);
13482
- return card;
13483
- }
13484
- function tfRenderBrain() {
13485
- const brain = state.bootstrap && state.bootstrap.brain;
13486
- const learnHost = document.getElementById('brain-learnings');
13487
- const catHost = document.getElementById('brain-catalog');
13488
- if (learnHost) {
13489
- learnHost.innerHTML = '';
13490
- // Issue #1002 R10/R11: three tiles for the three LEVELS, in one vocabulary,
13491
- // then the two things that are not levels. "Manager" used to label the
13492
- // reverse-mentoring count, which collided with the manager level once that
13493
- // became a real level; it now has its own tile.
13494
- const l = (brain && brain.learnings) || { organization: 0, manager: 0, project: 0, reverseMentoring: 0, rawSignals: 0 };
13495
- learnHost.appendChild(tfStatCard(l.organization || 0, 'Company', 'var(--accent)'));
13496
- learnHost.appendChild(tfStatCard(l.manager || 0, 'Manager', '#6b4c92'));
13497
- learnHost.appendChild(tfStatCard(l.project || 0, 'Project', 'var(--done)'));
13498
- learnHost.appendChild(tfStatCard(l.reverseMentoring || 0, 'Reverse mentoring', '#6b4c92'));
13499
- learnHost.appendChild(tfStatCard(l.rawSignals || 0, 'Pending signals', '#f57c00'));
13500
- }
13501
- if (catHost) {
13502
- catHost.innerHTML = '';
13503
- const c = (brain && brain.catalog) || { skills: 0, rules: 0, jobs: 0 };
13504
- catHost.appendChild(tfStatCard(c.skills || 0, 'Skills available'));
13505
- catHost.appendChild(tfStatCard(c.rules || 0, 'Rules & guardrails'));
13506
- catHost.appendChild(tfStatCard(c.jobs || 0, 'Jobs in catalog'));
13507
- }
13508
- }
13509
13478
 
13510
13479
  // ---------------------------------------------------------------------------
13511
13480
  // Account menu
@@ -15081,7 +15050,6 @@ function tfRefreshAfterBootstrap() {
15081
15050
  tfRenderProjectTabs();
15082
15051
  if (tf.area === 'company') tfRenderCompany();
15083
15052
  else if (tf.area === 'manager') tfRenderManager();
15084
- else if (tf.area === 'brain') tfRenderBrain();
15085
15053
  if (tf.projectView === 'overview') tfRenderOverview();
15086
15054
  else tfRenderTree();
15087
15055
  // Issue #1005: the bootstrap we just applied missed the first-paint persona budget and
@@ -3451,7 +3451,8 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
3451
3451
  .pending-banner { background: var(--warn-soft); border: 1px solid rgba(176,132,66,.25); border-radius: 12px; padding: 12px 16px; display: flex; align-items: flex-start; gap: 10px; margin-bottom: 12px; }
3452
3452
 
3453
3453
  /* Projects sub-tabs — HTML uses .proj-tabs (not .proj-tabs-bar) and .ptab-add (not .ptab-add-btn) */
3454
- .proj-tabs, .proj-tabs-bar { display: flex; align-items: center; background: var(--surface); border-bottom: 1px solid var(--line); padding: 0 8px 0 0; flex-shrink: 0; overflow-x: auto; }
3454
+ .proj-tabs, .proj-tabs-bar { display: flex; align-items: center; background: var(--surface); border-bottom: 1px solid var(--line); padding: 0 8px 0 0; flex-shrink: 0; overflow: hidden; }
3455
+ #proj-tab-list { display: flex; align-items: center; min-width: 0; flex: 1 1 auto; overflow-x: auto; overflow-y: hidden; white-space: nowrap; }
3455
3456
  .ptab { padding: 9px 16px; font-size: 13px; font-weight: 500; color: var(--muted); border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; flex-shrink: 0; margin-bottom: -1px; }
3456
3457
  .ptab.on { color: var(--text); border-bottom-color: var(--text); font-weight: 600; }
3457
3458
  .ptab:hover { color: var(--text); }