fraim-hub 2.0.239 → 2.0.241

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.
@@ -734,7 +734,8 @@ function resolveHostInvocation(plan) {
734
734
  // cannot drift on how the managed-agent bin directories are put on PATH.
735
735
  const versionProbeEnv = () => ({
736
736
  ...process.env,
737
- PATH: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH),
737
+ PATH: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH ?? process.env.Path),
738
+ Path: (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH ?? process.env.Path),
738
739
  });
739
740
  const availableByVersionProbe = (command) => {
740
741
  const invocation = resolveHostInvocation({ command, args: ['--version'] });
@@ -806,10 +807,20 @@ const EMPLOYEE_DETECTION_TTL_MS = 5 * 60 * 1000;
806
807
  let employeeDetectionTtlMs = EMPLOYEE_DETECTION_TTL_MS;
807
808
  let cachedEmployees = null;
808
809
  let cachedEmployeesAtMs = 0;
810
+ let cachedEmployeesContext = null;
809
811
  let inFlightDetection = null;
812
+ function employeeDetectionContext() {
813
+ return JSON.stringify({
814
+ PATH: process.env.PATH || '',
815
+ Path: process.env.Path || '',
816
+ FRAIM_USER_DIR: process.env.FRAIM_USER_DIR || '',
817
+ });
818
+ }
810
819
  function cachedEmployeesIfFresh() {
811
820
  if (!cachedEmployees)
812
821
  return null;
822
+ if (cachedEmployeesContext !== employeeDetectionContext())
823
+ return null;
813
824
  if (Date.now() - cachedEmployeesAtMs > employeeDetectionTtlMs)
814
825
  return null;
815
826
  return cachedEmployees;
@@ -831,6 +842,7 @@ function cachedEmployeesIfFresh() {
831
842
  // TTL lapses. That is recoverable and self-correcting, and is preferable to freezing every
832
843
  // cold start for 2.2s. Installs performed THROUGH the Hub invalidate explicitly.
833
844
  const AGENT_AVAILABILITY_FILE = 'hub-agent-availability.json';
845
+ const AGENT_AVAILABILITY_SCHEMA_VERSION = 2;
834
846
  // Overridable so tests do not mutate the developer's real ~/.fraim state. These guards run
835
847
  // in the smoke suite now, and invalidation DELETES this file, so pointing them at a temp
836
848
  // path keeps a frequent test run from repeatedly clearing real agent-availability data.
@@ -850,6 +862,10 @@ function loadPersistedEmployees() {
850
862
  try {
851
863
  const raw = fs_1.default.readFileSync(agentAvailabilityFilePath(), 'utf8');
852
864
  const parsed = JSON.parse(raw);
865
+ if (parsed.schemaVersion !== AGENT_AVAILABILITY_SCHEMA_VERSION)
866
+ return null;
867
+ if (parsed.detectionContext !== employeeDetectionContext())
868
+ return null;
853
869
  if (!Array.isArray(parsed.employees) || parsed.employees.length === 0)
854
870
  return null;
855
871
  // Only accept entries that still match the current known agent ids, so a stale file
@@ -880,6 +896,7 @@ function loadPersistedEmployees() {
880
896
  function adoptPersistedEmployees(persisted) {
881
897
  cachedEmployees = persisted.employees;
882
898
  cachedEmployeesAtMs = persisted.detectedAtMs;
899
+ cachedEmployeesContext = employeeDetectionContext();
883
900
  if (Date.now() - persisted.detectedAtMs > employeeDetectionTtlMs) {
884
901
  void detectEmployeesAsync({ force: true }).catch(() => undefined);
885
902
  }
@@ -889,7 +906,12 @@ function persistEmployees(employees) {
889
906
  try {
890
907
  const file = agentAvailabilityFilePath();
891
908
  fs_1.default.mkdirSync(path_1.default.dirname(file), { recursive: true });
892
- fs_1.default.writeFileSync(file, JSON.stringify({ employees, detectedAt: new Date().toISOString() }, null, 2), 'utf8');
909
+ fs_1.default.writeFileSync(file, JSON.stringify({
910
+ employees,
911
+ detectedAt: new Date().toISOString(),
912
+ detectionContext: employeeDetectionContext(),
913
+ schemaVersion: AGENT_AVAILABILITY_SCHEMA_VERSION,
914
+ }, null, 2), 'utf8');
893
915
  }
894
916
  catch {
895
917
  // Best effort only: failing to persist must never break detection.
@@ -898,6 +920,7 @@ function persistEmployees(employees) {
898
920
  function storeDetectedEmployees(employees) {
899
921
  cachedEmployees = employees;
900
922
  cachedEmployeesAtMs = Date.now();
923
+ cachedEmployeesContext = employeeDetectionContext();
901
924
  persistEmployees(employees);
902
925
  return employees;
903
926
  }
@@ -909,6 +932,7 @@ function storeDetectedEmployees(employees) {
909
932
  function invalidateEmployeeDetectionCache() {
910
933
  cachedEmployees = null;
911
934
  cachedEmployeesAtMs = 0;
935
+ cachedEmployeesContext = null;
912
936
  inFlightDetection = null;
913
937
  try {
914
938
  fs_1.default.rmSync(agentAvailabilityFilePath(), { force: true });
@@ -53,9 +53,10 @@ function buildSameJobContinueMessage(coaching) {
53
53
  ? `Continue the active FRAIM job with this manager coaching:\n\n${remainder}`
54
54
  : 'Continue the active FRAIM job using the current session context.';
55
55
  }
56
- function buildManagerMessage(employeeId, jobId, kind, instructions, stubPath) {
56
+ function buildManagerMessage(employeeId, jobId, kind, instructions, options) {
57
+ const stubPath = options?.stubPath;
57
58
  const trimmed = String(instructions || '').trim();
58
- const explicit = extractExplicitFraimInvocation(trimmed);
59
+ const explicit = options?.ignoreEmbeddedInvocation ? null : extractExplicitFraimInvocation(trimmed);
59
60
  const effectiveJobId = explicit?.jobId || jobId;
60
61
  const invocation = fraimInvocationFor(employeeId, effectiveJobId);
61
62
  if (!invocation && effectiveJobId === '__freeform__' && kind === 'start') {
@@ -341,6 +341,26 @@ function scheduledFireBucketMs(cronExpr, nowMs = Date.now()) {
341
341
  const bucketMs = fieldCount === 6 ? 1000 : 60 * 1000;
342
342
  return Math.floor(nowMs / bucketMs) * bucketMs;
343
343
  }
344
+ function exactSixFieldCronDateMs(cronExpr, nowMs = Date.now()) {
345
+ const fields = cronExpr.trim().split(/\s+/).filter(Boolean);
346
+ if (fields.length !== 6 || fields[5] !== '*')
347
+ return null;
348
+ const [second, minute, hour, day, month] = fields.slice(0, 5).map((field) => Number(field));
349
+ if (![second, minute, hour, day, month].every(Number.isInteger))
350
+ return null;
351
+ const now = new Date(nowMs);
352
+ const candidate = new Date(now.getFullYear(), month - 1, day, hour, minute, second, 0);
353
+ if (candidate.getMonth() !== month - 1 ||
354
+ candidate.getDate() !== day ||
355
+ candidate.getHours() !== hour ||
356
+ candidate.getMinutes() !== minute ||
357
+ candidate.getSeconds() !== second)
358
+ return null;
359
+ if (candidate.getTime() >= nowMs - SCHEDULED_FIRE_LEASE_TTL_MS)
360
+ return candidate.getTime();
361
+ const nextYear = new Date(now.getFullYear() + 1, month - 1, day, hour, minute, second, 0);
362
+ return nextYear.getTime();
363
+ }
344
364
  class DeploymentStore {
345
365
  constructor(filePath) {
346
366
  this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
@@ -729,6 +749,9 @@ function normalizeDelegationLedger(raw) {
729
749
  status,
730
750
  personaKey: cleanNullableString((rawTask.personaKey || rawTask.persona)),
731
751
  jobId: cleanNullableString((rawTask.jobId || rawTask.job || rawTask.job_id)),
752
+ // Issue #1021: accept the same alias tolerance the rest of this normalizer applies,
753
+ // and coerce a numeric issue to a string so `#${issueNumber}` never renders `[object Object]`.
754
+ issueNumber: cleanNullableString((rawTask.issueNumber ?? rawTask.issue_number ?? rawTask.issue)?.toString()),
732
755
  reviewJobId: cleanNullableString(rawTask.reviewJobId),
733
756
  reviewType: cleanNullableString(rawTask.reviewType),
734
757
  instructions: cleanString((rawTask.instructions || rawTask.briefing)) || undefined,
@@ -1635,6 +1658,7 @@ class AiHubServer {
1635
1658
  // never touches a database. Persona and manager-team state resolve from the hosted server
1636
1659
  // through the remote gateway; the hosted server is the sole owner of DB access.
1637
1660
  this.remoteGateway = options.remoteGateway ?? new remote_hub_gateway_1.HttpHubRemoteGateway();
1661
+ this.deploymentStoreProvided = Boolean(options.deploymentStore);
1638
1662
  this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
1639
1663
  this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
1640
1664
  this.app.use(express_1.default.json({ limit: '10mb' }));
@@ -1846,8 +1870,12 @@ class AiHubServer {
1846
1870
  void (0, hosts_1.detectEmployeesAsync)({ force: true }).catch((error) => {
1847
1871
  console.warn('[ai-hub] agent availability priming failed:', error?.message || error);
1848
1872
  });
1849
- // Issue #578: rehydrate active scheduled deployments from disk.
1850
- this.rehydrateScheduledDeployments();
1873
+ // Issue #578: rehydrate active scheduled deployments from disk. Test and preview
1874
+ // servers that inject a fake host but not a deployment store should not run the
1875
+ // user's real scheduled deployments from the default store.
1876
+ if (this.deploymentStoreProvided || this.hostRuntime instanceof hosts_1.CliHostRuntime) {
1877
+ this.rehydrateScheduledDeployments();
1878
+ }
1851
1879
  // Start HTTPS server when a cert bundle and port are provided.
1852
1880
  // Word Online requires HTTPS; the HTTPS server shares the same Express app
1853
1881
  // so all routes (including /word-taskpane/*) are available over both protocols.
@@ -2686,7 +2714,17 @@ class AiHubServer {
2686
2714
  continue;
2687
2715
  if (task.status && task.status !== 'planned')
2688
2716
  continue;
2689
- if (!task.jobId || !this.delegationDependenciesSatisfied(ledger, task.dependsOn || []))
2717
+ // Issue #1021: a task that names no job is a manager-visible defect, not a
2718
+ // silent skip. The unresolvable-job path below already blocks with a reason;
2719
+ // this makes the no-job-at-all case behave identically instead of vanishing.
2720
+ // Dependency waiting stays a silent continue — that task is pending, not broken.
2721
+ if (!task.jobId) {
2722
+ this.markDelegationTaskBlocked(managerRun, task, `Delegation blocked: task "${task.taskId}" does not name a job.`);
2723
+ started.add(task.taskId);
2724
+ managerRun.orchestratedDelegationTaskIds.push(task.taskId);
2725
+ continue;
2726
+ }
2727
+ if (!this.delegationDependenciesSatisfied(ledger, task.dependsOn || []))
2690
2728
  continue;
2691
2729
  if (!task.personaKey)
2692
2730
  task.personaKey = getProtectedPersonaForHubJob(task.jobId);
@@ -2695,6 +2733,21 @@ class AiHubServer {
2695
2733
  managerRun.orchestratedDelegationTaskIds.push(task.taskId);
2696
2734
  }
2697
2735
  }
2736
+ /**
2737
+ * Issue #1021: one place that marks a delegation task blocked. Extracted so the
2738
+ * no-job and unresolvable-job paths cannot drift apart — two hand-written copies
2739
+ * of this transition is how the original inconsistency arose.
2740
+ *
2741
+ * Sets a status and a one-line reason only. Per issue #826 a protocol defect must
2742
+ * not surface as manager decision UI, so this deliberately creates no review
2743
+ * controls and no correction affordance.
2744
+ */
2745
+ markDelegationTaskBlocked(managerRun, task, reason) {
2746
+ task.status = 'blocked';
2747
+ task.latestSummary = reason;
2748
+ managerRun.events.push((0, hosts_1.createHubEvent)('system', reason));
2749
+ this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
2750
+ }
2698
2751
  delegationDependenciesSatisfied(ledger, dependsOn) {
2699
2752
  if (!dependsOn.length)
2700
2753
  return true;
@@ -2713,27 +2766,40 @@ class AiHubServer {
2713
2766
  task.personaKey = getProtectedPersonaForHubJob(task.jobId);
2714
2767
  const resolvedJob = this.resolveHubJob(managerRun.projectPath, task.jobId);
2715
2768
  if (!resolvedJob) {
2716
- task.status = 'blocked';
2717
- task.latestSummary = `Delegation blocked: job "${task.jobId}" is not available in this project.`;
2718
- managerRun.events.push((0, hosts_1.createHubEvent)('system', task.latestSummary));
2719
- this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
2769
+ this.markDelegationTaskBlocked(managerRun, task, `Delegation blocked: job "${task.jobId}" is not available in this project.`);
2720
2770
  return;
2721
2771
  }
2722
2772
  const childConversationId = task.conversationId || `${managerRun.id}-${task.taskId}`;
2723
2773
  const now = new Date().toISOString();
2724
- const childInstructions = [
2725
- task.instructions || task.latestSummary || `Complete the delegated workstream: ${task.title}.`,
2726
- '',
2774
+ // Issue #1021: this brief adds context the job cannot know and subtracts nothing.
2775
+ // It previously carried "This is a delegated subtask, not an end-to-end FRAIM
2776
+ // submission workflow ... Do not create evidence docs, open or update PRs, update
2777
+ // GitHub issues" plus a "submit a concise summary back to Mandy" substitute for the
2778
+ // job's real submit phase. Both are gone: the child runs its named job in full, and
2779
+ // delegation changes who reviews the output, not what the output is.
2780
+ //
2781
+ // Nothing is added in their place. The Hub does not restate what the job already
2782
+ // instructs (#563), and it does not tell the child who reviews it — routing is
2783
+ // handled entirely by notifyManagerOfDelegatedChild and, on the client, by
2784
+ // syncManagedDelegationAccess. The retained "Mandy is your manager" line is the only
2785
+ // one the child's behavior actually depends on: it stops the child blocking on a human.
2786
+ const contextLines = [
2727
2787
  task.personaKey
2728
2788
  ? `You are working as ${task.personaKey} for the manager job.`
2729
2789
  : 'You are working as the specialist assigned by this delegated job.',
2730
2790
  `Parent objective: ${ledger.objective}.`,
2731
- task.reviewJobId ? `Manager review route: this output should be reviewed using ${task.reviewJobId}.` : '',
2732
- 'Submit a concise deliverable summary and any artifact references back to Mandy.',
2733
- 'This is a delegated subtask, not an end-to-end FRAIM submission workflow. After you provide the deliverable for Mandy, stop. Do not create evidence docs, open or update PRs, update GitHub issues, or ask the human for review.',
2791
+ // The issue the child works, carried as ledger data rather than left to prose, so
2792
+ // set-up-workspace can provision the right branch and the job can label the issue.
2793
+ task.issueNumber ? `Issue: #${task.issueNumber}` : null,
2794
+ task.reviewJobId ? `Manager review route: this output should be reviewed using ${task.reviewJobId}.` : null,
2734
2795
  'Do not ask the human for coaching; Mandy is your manager for this workstream.',
2796
+ ].filter((line) => Boolean(line));
2797
+ const childInstructions = [
2798
+ task.instructions || task.latestSummary || `Complete the delegated workstream: ${task.title}.`,
2799
+ '',
2800
+ ...contextLines,
2735
2801
  ].join('\n');
2736
- const prepared = this.prepareStartPayload(managerRun.projectPath, managerRun.hostId, task.jobId, childInstructions);
2802
+ const prepared = this.prepareStartPayload(managerRun.projectPath, managerRun.hostId, task.jobId, childInstructions, { ignoreEmbeddedInvocation: true });
2737
2803
  const childRun = {
2738
2804
  id: (0, crypto_1.randomUUID)(),
2739
2805
  conversationId: childConversationId,
@@ -3067,8 +3133,15 @@ class AiHubServer {
3067
3133
  // Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
3068
3134
  // content) export routes so a conversational deliverable with no on-disk file
3069
3135
  // can still be downloaded for Word annotation.
3070
- prepareStartPayload(projectPath, hostId, selectedJobId, instructions) {
3071
- const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
3136
+ prepareStartPayload(projectPath, hostId, selectedJobId, instructions,
3137
+ // Issue #1021: an explicit invocation inside `instructions` normally wins, which is
3138
+ // correct when a human typed `/fraim <job>` into the coach box. For an
3139
+ // orchestration-initiated launch there is no human typing: the instructions are
3140
+ // agent-generated prose, and the delegation ledger is the only authority on which
3141
+ // job runs. Without this, a stray `/fraim other-job` in Mandy's brief would silently
3142
+ // launch a different job than the ledger declares and stamp it onto childRun.jobId.
3143
+ options) {
3144
+ const explicit = options?.ignoreEmbeddedInvocation ? null : (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
3072
3145
  const resolvedJobId = explicit?.jobId || selectedJobId;
3073
3146
  if (!resolvedJobId) {
3074
3147
  throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
@@ -3082,11 +3155,12 @@ class AiHubServer {
3082
3155
  // available (env published at boot).
3083
3156
  const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
3084
3157
  const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
3158
+ const ignoreEmbedded = options?.ignoreEmbeddedInvocation === true;
3085
3159
  if (resolvedJobId === '__freeform__') {
3086
- const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
3160
+ const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded });
3087
3161
  return {
3088
3162
  jobId: resolvedJobId,
3089
- message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions) + browserNote + styleNote,
3163
+ message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded }) + browserNote + styleNote,
3090
3164
  display,
3091
3165
  };
3092
3166
  }
@@ -3094,10 +3168,10 @@ class AiHubServer {
3094
3168
  const absoluteStubPath = resolvedJob?.stubPath
3095
3169
  ? [projectPath, resolvedJob.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
3096
3170
  : undefined;
3097
- const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
3171
+ const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { ignoreEmbeddedInvocation: ignoreEmbedded });
3098
3172
  return {
3099
3173
  jobId: resolvedJobId,
3100
- message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath) + browserNote + styleNote,
3174
+ message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, { stubPath: absoluteStubPath, ignoreEmbeddedInvocation: ignoreEmbedded }) + browserNote + styleNote,
3101
3175
  display,
3102
3176
  };
3103
3177
  }
@@ -4560,12 +4634,17 @@ class AiHubServer {
4560
4634
  if (!run) {
4561
4635
  return res.status(404).json({ error: 'Run not found.' });
4562
4636
  }
4563
- if (!run.sessionId) {
4564
- return res.status(409).json({ error: 'This run does not have a resumable host session yet.' });
4637
+ // When sessionId is null the host session is gone (agent crashed, Hub restart with no
4638
+ // resume path, etc.). A non-managed direct run must still accept coaching: fall back to a
4639
+ // fresh startRun with the conversation's handoff context + the manager's coaching text,
4640
+ // exactly as the agent-switch path does. Managed delegation children are still blocked —
4641
+ // their coaching flows through the parent manager run, not through the human.
4642
+ if (!run.sessionId && !run.humanCoachingDisabled && run.status === 'running') {
4643
+ return res.status(409).json({ error: 'This run has not established a host session yet. Wait a moment and try again.' });
4565
4644
  }
4566
4645
  const instructions = (req.body.instructions || '').trim();
4567
4646
  const coachingJobId = req.body.coachingJobId?.trim() || undefined;
4568
- // When coachingJobId is present (user picked a manager template via the UI),
4647
+ // When coachingJobId is present (user picked a manager coaching template via the UI),
4569
4648
  // it overrides the run's own jobId in the invocation. The server always adds
4570
4649
  // the correct $fraim / /fraim prefix — the UI never passes raw invocation syntax.
4571
4650
  const prepared = instructions
@@ -4577,6 +4656,70 @@ class AiHubServer {
4577
4656
  if (!message) {
4578
4657
  return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
4579
4658
  }
4659
+ // No resumable session — start fresh using a handoff prompt so the agent
4660
+ // picks up from the preserved conversation state + manager coaching.
4661
+ if (!run.sessionId) {
4662
+ if (run.humanCoachingDisabled) {
4663
+ return res.status(403).json({ error: 'Human coaching is disabled for this delegated workstream. Coach the parent manager run instead.' });
4664
+ }
4665
+ const conversation = this.conversationStore.loadConversation((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id);
4666
+ const handoffSummary = conversation
4667
+ ? buildAgentSwitchHandoffSummary(conversation)
4668
+ : null;
4669
+ const freshPayload = handoffSummary
4670
+ ? this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, renderAgentSwitchHandoffPrompt(handoffSummary, prepared.display || message))
4671
+ : this.prepareStartPayload(run.projectPath, run.hostId, run.jobId, prepared.display || message);
4672
+ const reviewApprovalSystemEventTextFresh = buildReviewApprovalSystemEventText(prepared.display || message);
4673
+ this.runRegistry.update(run.id, (current) => {
4674
+ current.status = 'running';
4675
+ current.sessionId = undefined;
4676
+ current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
4677
+ if (reviewApprovalSystemEventTextFresh)
4678
+ current.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventTextFresh));
4679
+ current.events.push((0, hosts_1.createHubEvent)('system', 'No resumable session — starting a fresh agent turn from conversation context.'));
4680
+ });
4681
+ const startedFresh = this.runRegistry.get(run.id);
4682
+ if (startedFresh)
4683
+ this.persistRunConversation(startedFresh, startedFresh.conversationId || startedFresh.id);
4684
+ this.runRegistry.create(run, {});
4685
+ const freshLaunch = this.resolveLaunchAgent(run.configuredAgentId, run.hostId);
4686
+ const freshChild = this.hostRuntime.startRun(run.hostId, run.projectPath, freshPayload.message, {
4687
+ onEvent: (event, channel) => {
4688
+ this.runRegistry.update(run.id, (current) => {
4689
+ if (event.sessionId) {
4690
+ current.sessionId = event.sessionId;
4691
+ current.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(run.hostId, event.sessionId);
4692
+ }
4693
+ appendHostMessage(current, run.hostId, event, channel);
4694
+ if (event.raw) {
4695
+ current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
4696
+ applyReviewProjection(current, event.raw);
4697
+ }
4698
+ if (event.agentIdentity)
4699
+ applyAgentIdentitySignal(current, event.agentIdentity);
4700
+ if (event.fraimJob)
4701
+ this.applyFraimJobSignalToRun(current, event.fraimJob);
4702
+ if (event.seekMentoring)
4703
+ this.applySeekMentoringSignalToRun(current, event.seekMentoring);
4704
+ if (event.usage)
4705
+ applyUsageSignal(current, event.usage);
4706
+ });
4707
+ const updated = this.runRegistry.get(run.id);
4708
+ if (updated) {
4709
+ this.maybeStartDelegatedChildRuns(updated);
4710
+ this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
4711
+ }
4712
+ },
4713
+ onExit: (exitCode) => {
4714
+ this.handleRunExit(run.id, exitCode, (updated) => {
4715
+ this.maybeStartDelegatedChildRuns(updated);
4716
+ });
4717
+ },
4718
+ }, startSessionSeedForHost(run.hostId, run.id), freshLaunch.launchContext);
4719
+ this.runRegistry.attachChildIfRunning(run.id, freshChild);
4720
+ const refreshedFresh = this.runRegistry.get(run.id);
4721
+ return res.json(refreshedFresh ? this.enrichRunForResponse(refreshedFresh) : refreshedFresh);
4722
+ }
4580
4723
  const reviewApprovalSystemEventText = buildReviewApprovalSystemEventText(prepared.display || message);
4581
4724
  this.runRegistry.update(run.id, (current) => {
4582
4725
  current.status = 'running';
@@ -5394,9 +5537,8 @@ class AiHubServer {
5394
5537
  console.warn(`[ai-hub] invalid cronExpr for deployment ${deployment.id}: ${deployment.cronExpr}`);
5395
5538
  return;
5396
5539
  }
5397
- const task = cron.schedule(deployment.cronExpr, async () => {
5540
+ const fireScheduledDeployment = async (fireTimeMs = scheduledFireBucketMs(deployment.cronExpr || '')) => {
5398
5541
  try {
5399
- const fireTimeMs = scheduledFireBucketMs(deployment.cronExpr || '');
5400
5542
  if (!this.deploymentStore.claimScheduledFire(deployment.id, fireTimeMs)) {
5401
5543
  console.log(`[ai-hub] scheduled deployment ${deployment.id} skipped - scheduled fire already claimed`);
5402
5544
  return;
@@ -5406,8 +5548,34 @@ class AiHubServer {
5406
5548
  catch (err) {
5407
5549
  console.warn(`[ai-hub] scheduled deployment ${deployment.id} fire failed:`, err);
5408
5550
  }
5551
+ };
5552
+ const task = cron.schedule(deployment.cronExpr, () => { void fireScheduledDeployment(); });
5553
+ const exactFireMs = exactSixFieldCronDateMs(deployment.cronExpr);
5554
+ const exactFireNearDue = exactFireMs !== null && exactFireMs - Date.now() <= 5_000;
5555
+ if (exactFireNearDue) {
5556
+ setTimeout(() => { void fireScheduledDeployment(exactFireMs); }, 1_000);
5557
+ }
5558
+ const exactTimer = exactFireMs !== null && !exactFireNearDue
5559
+ ? setTimeout(() => { void fireScheduledDeployment(exactFireMs); }, Math.max(0, exactFireMs - Date.now()))
5560
+ : null;
5561
+ const exactInterval = exactFireMs !== null && !exactFireNearDue
5562
+ ? setInterval(() => {
5563
+ if (Date.now() < exactFireMs)
5564
+ return;
5565
+ if (exactInterval)
5566
+ clearInterval(exactInterval);
5567
+ void fireScheduledDeployment(exactFireMs);
5568
+ }, 250)
5569
+ : null;
5570
+ this.cronHandles.set(deployment.id, {
5571
+ stop: () => {
5572
+ task.stop();
5573
+ if (exactTimer)
5574
+ clearTimeout(exactTimer);
5575
+ if (exactInterval)
5576
+ clearInterval(exactInterval);
5577
+ },
5409
5578
  });
5410
- this.cronHandles.set(deployment.id, task);
5411
5579
  }
5412
5580
  catch (err) {
5413
5581
  console.warn('[ai-hub] node-cron not available — scheduled deployments require node-cron:', err);
@@ -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'],
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'],
121
121
  protectedAliases: ['executive-assistant', 'operations-assistant'],
122
122
  defaultHireMode: 'job',
123
123
  lockCopy: 'Hire AshLey to unlock executive-assistant work for this request.'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.239",
3
+ "version": "2.0.241",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -161,7 +161,7 @@
161
161
  "electron": "^41.2.2",
162
162
  "electron-updater": "^6.8.9",
163
163
  "express": "^5.2.1",
164
- "fraim": "2.0.239",
164
+ "fraim": "2.0.241",
165
165
  "mongodb": "^7.0.0",
166
166
  "node-cron": "4.2.1",
167
167
  "node-edge-tts": "^1.2.10",
@@ -1242,7 +1242,14 @@ function panelStateFor(convId) {
1242
1242
  }
1243
1243
 
1244
1244
  function defaultCoachOpen(conv) {
1245
- if (isManagerOversightConversation(conv)) return false;
1245
+ // Issue #1021 (R11): an oversight run collapses the coach panel by default so the
1246
+ // delegation boards get the vertical space. But the review actions live inside that
1247
+ // panel, so once Mandy submits, the human had to expand a collapsed panel to find
1248
+ // Approve while every other employee shows it immediately. Parity is the whole point
1249
+ // of this issue: when the run is actually awaiting a decision, the decision is visible.
1250
+ if (isManagerOversightConversation(conv)) {
1251
+ return typeof convAwaitingReview === 'function' ? convAwaitingReview(conv) : false;
1252
+ }
1246
1253
  return true;
1247
1254
  }
1248
1255
 
@@ -3649,6 +3656,32 @@ function delegatedTaskArtifacts(task, childConv) {
3649
3656
  return task.artifacts || [];
3650
3657
  }
3651
3658
 
3659
+ // Issue #1021: a delegated child's pull request is its review surface. The URL is
3660
+ // AI-derived (it originates in an agent-emitted review handoff), so it must pass the
3661
+ // shared safeHttpUrl gate before it can become an href — the same trust boundary
3662
+ // window-open-decision.ts enforces for the desktop shell. Never hand-roll this check.
3663
+ function delegatedTaskPullRequestTarget(task, childConv) {
3664
+ const target = childConv?.reviewHandoff?.reviewTarget || task?.reviewHandoff?.reviewTarget;
3665
+ if (!target || target.type !== 'pull_request') return null;
3666
+ const url = safeHttpUrl(target.url);
3667
+ return url ? { target, url } : null;
3668
+ }
3669
+
3670
+ // Label for a child's pull request chip. Prefers the normalized `prNumber` the review
3671
+ // handoff already carries, matching the existing `PR #${target.prNumber}` convention used
3672
+ // by the delivery-action summary, rather than re-deriving it. Falls back to parsing the
3673
+ // URL for a provider that did not supply a number, then to the target's own label.
3674
+ function pullRequestChipLabel(target, url) {
3675
+ const prNumber = target && typeof target.prNumber === 'number' && Number.isFinite(target.prNumber)
3676
+ ? target.prNumber
3677
+ : null;
3678
+ if (prNumber) return `Pull request #${prNumber}`;
3679
+ const match = /\/(?:pull|pull-requests|merge_requests)\/(\d+)/.exec(String(url || ''));
3680
+ if (match) return `Pull request #${match[1]}`;
3681
+ const label = target && typeof target.label === 'string' ? target.label.trim() : '';
3682
+ return label || 'Pull request';
3683
+ }
3684
+
3652
3685
  function renderDelegationLedger(conv) {
3653
3686
  const host = ensureDelegationHost();
3654
3687
  if (!host) return;
@@ -3732,7 +3765,26 @@ function renderDelegationLedger(conv) {
3732
3765
  if (detail.textContent) row.appendChild(detail);
3733
3766
  row.appendChild(summary);
3734
3767
 
3768
+ // Issue #1021: a child that submits a pull request carries `artifacts: []` by
3769
+ // contract (a PR target IS the whole review surface), so the artifact strip below
3770
+ // was empty and the row fell through to "no file artifact reported" — actively
3771
+ // wrong once delegated children open real PRs. Surface the PR as the child's
3772
+ // review surface in the same strip, using the same component class.
3773
+ const childPr = delegatedTaskPullRequestTarget(task, childConv);
3735
3774
  const artifacts = delegatedTaskArtifacts(task, childConv);
3775
+ if (childPr) {
3776
+ const prStrip = document.createElement('div');
3777
+ prStrip.className = 'delegation-artifacts';
3778
+ const link = document.createElement('a');
3779
+ link.className = 'delegation-artifact';
3780
+ link.href = childPr.url;
3781
+ link.target = '_blank';
3782
+ link.rel = 'noopener noreferrer';
3783
+ link.textContent = pullRequestChipLabel(childPr.target, childPr.url);
3784
+ link.title = childPr.url;
3785
+ prStrip.appendChild(link);
3786
+ row.appendChild(prStrip);
3787
+ }
3736
3788
  if (artifacts.length) {
3737
3789
  const artifactStrip = document.createElement('div');
3738
3790
  artifactStrip.className = 'delegation-artifacts';
@@ -3752,7 +3804,7 @@ function renderDelegationLedger(conv) {
3752
3804
  artifactStrip.appendChild(chip);
3753
3805
  }
3754
3806
  row.appendChild(artifactStrip);
3755
- } else if (childConv?.status === 'completed' || taskStatus === 'submitted' || taskStatus === 'reviewed') {
3807
+ } else if (!childPr && (childConv?.status === 'completed' || taskStatus === 'submitted' || taskStatus === 'reviewed')) {
3756
3808
  const inline = document.createElement('div');
3757
3809
  inline.className = 'delegation-review-note';
3758
3810
  inline.textContent = 'Inline deliverable; no file artifact reported';
@@ -6841,10 +6893,11 @@ async function continueRun(text, options) {
6841
6893
  body: JSON.stringify({ instructions: text, ...(coachingJobId ? { coachingJobId } : {}) }),
6842
6894
  });
6843
6895
  } catch (e) {
6896
+ const isNotFound = /not found/i.test((e && e.message) || '');
6844
6897
  // #521: the Hub run is in-memory and is lost on a server restart, but the
6845
6898
  // agent session persists on disk. If the run is gone and we have a sessionId,
6846
6899
  // resume the conversation rather than failing — carries it forward intact.
6847
- if (conv.sessionId && /not found/i.test((e && e.message) || '')) {
6900
+ if (conv.sessionId && isNotFound) {
6848
6901
  run = await requestJson('/api/ai-hub/runs/resume', {
6849
6902
  method: 'POST',
6850
6903
  headers: { 'Content-Type': 'application/json' },
@@ -6862,6 +6915,26 @@ async function continueRun(text, options) {
6862
6915
  }),
6863
6916
  });
6864
6917
  conv.runId = run.id; // bind the conversation to the freshly-resumed run
6918
+ } else if (!conv.sessionId && (isNotFound || /409/.test((e && e.message) || ''))) {
6919
+ // Run is gone from the registry AND there is no resumable session (agent crashed
6920
+ // or Hub restarted after a failed run). Start a completely fresh run bound to
6921
+ // this conversation so the manager's coaching is not lost.
6922
+ run = await requestJson('/api/ai-hub/runs', {
6923
+ method: 'POST',
6924
+ headers: { 'Content-Type': 'application/json' },
6925
+ body: JSON.stringify({
6926
+ projectPath: state.projectPath,
6927
+ hostId: baseHostIdForAgent(conversationAgentName(conv) || state.selectedEmployeeId || 'claude'),
6928
+ configuredAgentId: conversationAgentName(conv) || state.selectedEmployeeId || 'claude',
6929
+ jobId: conv.jobId,
6930
+ jobTitle: conv.jobTitle || conv.jobId,
6931
+ conversationId: conv.id,
6932
+ conversationTitle: conv.title,
6933
+ instructions: text,
6934
+ ...(coachingJobId ? { coachingJobId } : {}),
6935
+ }),
6936
+ });
6937
+ conv.runId = run.id; // bind the conversation to the new run
6865
6938
  } else {
6866
6939
  throw e;
6867
6940
  }
@@ -1277,6 +1277,17 @@ img.conv-employee-avatar {
1277
1277
  .delegation-artifact:hover {
1278
1278
  background: color-mix(in srgb, var(--accent) 16%, transparent);
1279
1279
  }
1280
+ /* Issue #1021: a child's pull request renders as an <a> in this strip, so the chip is
1281
+ now keyboard-navigable where it was previously only a <button> relying on the UA
1282
+ ring. Match the focus-visible convention used across this stylesheet. */
1283
+ .delegation-artifact:focus-visible,
1284
+ .delegation-view-work:focus-visible {
1285
+ outline: 2px solid var(--accent-strong);
1286
+ outline-offset: 2px;
1287
+ }
1288
+ .delegation-artifact {
1289
+ text-decoration: none;
1290
+ }
1280
1291
  .delegation-review-note {
1281
1292
  background: var(--state-working-soft);
1282
1293
  color: var(--state-working);