@adhdev/daemon-core 0.9.82-rc.510 → 0.9.82-rc.511

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.
@@ -1,4 +1,12 @@
1
1
  import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
2
+ export declare function bootstrapQueueTaskCountsAsHandled(task: {
3
+ status: string;
4
+ targetNodeId?: string | null;
5
+ autoLaunch?: {
6
+ status: string;
7
+ updatedAt: string;
8
+ } | null;
9
+ }, bootstrapNodeId: string, nowMs: number): boolean;
2
10
  export declare function recoverMeshIdByCoordinatorAndNode(coordinatorDaemonId: string, nodeId: string): string;
3
11
  export declare function resolveForwardEventMeshId(components: DaemonComponents, payload: Record<string, unknown>): string;
4
12
  export declare function __resetMeshWorkspaceCacheForTests(): void;
@@ -19,10 +19,12 @@
19
19
  * Design invariants:
20
20
  * - Only fires when the mesh has ≥1 `active` mission AND is fully idle. Fully idle =
21
21
  * buildMeshActiveWork over queue + direct dispatches reports totalActiveCount === 0
22
- * && generatingCount === 0. We intentionally do NOT probe remote node sessions here
23
- * (expensive per-tick RPC): any non-terminal queue/direct work already makes
24
- * totalActiveCount > 0, so the idle check is conservative — it suppresses the
25
- * reminder whenever any work is outstanding, which is the safe direction.
22
+ * && generatingCount === 0 AND no async refine job is accepted/running. We intentionally
23
+ * do NOT probe remote node sessions here (expensive per-tick RPC): any non-terminal
24
+ * queue/direct work already makes totalActiveCount > 0, so the idle check is
25
+ * conservative — it suppresses the reminder whenever any work is outstanding, which is
26
+ * the safe direction. Async refine jobs (`mesh_refine_node`) are a separate class that
27
+ * buildMeshActiveWork does not count, so they are checked explicitly from the ledger.
26
28
  * - NEVER transitions a mission's status. It only surfaces a hint; the coordinator
27
29
  * decides via mesh_mission_upsert.
28
30
  * - Debounced per mission-set. Re-fires only when the debounce window has elapsed OR
@@ -3,6 +3,7 @@ import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
3
3
  export declare function __resetIdleAutoFastForwardForTests(): void;
4
4
  export declare function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined;
5
5
  export declare function tryAssignQueueTask(components: DaemonComponents, meshId: string, nodeId: string, sessionId: string, providerType: string): boolean;
6
+ export declare const AUTO_LAUNCH_AWAIT_CLAIM_MS = 90000;
6
7
  interface AwaitClaimBackoffState {
7
8
  cycles: number;
8
9
  nextAttemptAtMs: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.510",
3
+ "version": "0.9.82-rc.511",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.510",
51
- "@adhdev/session-host-core": "0.9.82-rc.510",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.511",
51
+ "@adhdev/session-host-core": "0.9.82-rc.511",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -1,3 +1,4 @@
1
+ import { join } from 'node:path';
1
2
  import type { DaemonBuildBehind, GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
2
3
  import { GIT_STATUS_TIMEOUT_MS, GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
3
4
  import { getDaemonBuildInfo, type DaemonBuildInfo } from '../build-info.js';
@@ -444,6 +445,18 @@ export interface ChangedPackageClassification {
444
445
  affectedPackages: string[];
445
446
  }
446
447
 
448
+ /**
449
+ * Same as {@link ChangedPackageClassification} but also carries the runtime-ambiguous
450
+ * non-package paths that forced (or would force) a daemon-affecting verdict. Callers
451
+ * with git access (classifyChangedPackages) inspect these to descend into submodule
452
+ * gitlinks — a bare submodule path (e.g. `oss`) is runtime-ambiguous from the root's
453
+ * point of view, but its *content* diff may be entirely web-only.
454
+ */
455
+ interface ChangedFileListClassification extends ChangedPackageClassification {
456
+ /** Non-package paths that were not recognized as benign root files. */
457
+ ambiguousNonPackageFiles: string[];
458
+ }
459
+
447
460
  /**
448
461
  * Pure bucketer: classify an already-collected changed-file list into the coarse
449
462
  * daemon-vs-web verdict, per the resolved policy. Shared by classifyDaemonBuildChange
@@ -454,21 +467,21 @@ export interface ChangedPackageClassification {
454
467
  function classifyChangedFileList(
455
468
  files: string[],
456
469
  policy: ResolvedChangeImpactPolicy,
457
- ): ChangedPackageClassification {
470
+ ): ChangedFileListClassification {
458
471
  if (files.length === 0) {
459
472
  // No file diff (e.g. only merge metadata) — nothing actionable, but stay
460
473
  // conservative and treat as daemon-affecting so we don't suppress a real warning.
461
- return { isDaemonAffecting: true, affectedPackages: [] };
474
+ return { isDaemonAffecting: true, affectedPackages: [], ambiguousNonPackageFiles: [] };
462
475
  }
463
476
  const pkgs = new Set<string>();
464
- // A non-package path that is NOT a recognized benign root file (marker/doc).
477
+ // Non-package paths that are NOT recognized benign root files (marker/doc).
465
478
  // Only these force daemon-affecting; benign markers/docs are ignored so a
466
479
  // gitlink-moving root commit over a marker-only oss commit no longer over-warns.
467
- let sawRuntimeAmbiguousNonPackage = false;
480
+ const ambiguousNonPackageFiles: string[] = [];
468
481
  for (const file of files) {
469
482
  const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
470
483
  if (!match) {
471
- if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
484
+ if (!isNonRuntimeRootFile(file, policy)) ambiguousNonPackageFiles.push(file);
472
485
  continue;
473
486
  }
474
487
  pkgs.add(match[1]);
@@ -481,9 +494,9 @@ function classifyChangedFileList(
481
494
  // file changed) — i.e. nothing runtime-ambiguous remains. Unlisted/new packages
482
495
  // therefore stay daemon-affecting (fail-safe default preserved).
483
496
  const allBenign =
484
- !sawRuntimeAmbiguousNonPackage &&
497
+ ambiguousNonPackageFiles.length === 0 &&
485
498
  affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
486
- return { isDaemonAffecting: !allBenign, affectedPackages };
499
+ return { isDaemonAffecting: !allBenign, affectedPackages, ambiguousNonPackageFiles };
487
500
  }
488
501
 
489
502
  /**
@@ -542,7 +555,138 @@ export async function classifyChangedPackages(
542
555
  .split('\n')
543
556
  .map((line) => line.trim())
544
557
  .filter(Boolean);
545
- return classifyChangedFileList(files, policy);
558
+ const rootVerdict = classifyChangedFileList(files, policy);
559
+ return refineVerdictThroughSubmodules(repoPath, fromRef, toRef, options, policy, rootVerdict);
560
+ }
561
+
562
+ /**
563
+ * A `.gitmodules`-registered submodule root path (e.g. `oss`) appears in the root diff
564
+ * as a bare gitlink — no `packages/...` segment — so classifyChangedFileList treats it
565
+ * as runtime-ambiguous and the root verdict is daemon-affecting. But the submodule's
566
+ * *content* diff may be entirely web-only (a web-core-only oss commit) — in which case
567
+ * the daemon genuinely isn't affected and the refine gate should skip daemon-scoped
568
+ * validation. This descends into each submodule that is the *only* thing blocking a
569
+ * benign verdict, classifies its own gitlink-range content diff with the SAME policy,
570
+ * and folds the result back.
571
+ *
572
+ * Strictly conservative — a submodule verdict can only ever KEEP the root benign or
573
+ * flip an otherwise-benign root back to daemon-affecting; it never overrides a root
574
+ * that was daemon-affecting for its own reasons (a runtime package / unknown package /
575
+ * a runtime-ambiguous non-submodule file). If ANY blocking non-package path is not a
576
+ * registered submodule, or any submodule probe fails, we keep the conservative root
577
+ * verdict (fail-safe: never silence a real warning on uncertainty).
578
+ */
579
+ async function refineVerdictThroughSubmodules(
580
+ repoPath: string,
581
+ fromRef: string,
582
+ toRef: string,
583
+ options: GitStatusOptions,
584
+ policy: ResolvedChangeImpactPolicy,
585
+ rootVerdict: ChangedFileListClassification,
586
+ ): Promise<ChangedPackageClassification> {
587
+ const strip = ({ isDaemonAffecting, affectedPackages }: ChangedPackageClassification) => ({ isDaemonAffecting, affectedPackages });
588
+ const ambiguous = rootVerdict.ambiguousNonPackageFiles;
589
+ // Fast path: nothing to descend into, or the root is daemon-affecting for a reason
590
+ // other than an ambiguous path (an unknown/daemon package). Submodule descent only
591
+ // ever addresses the ambiguous-non-package reason, so it cannot help here.
592
+ if (ambiguous.length === 0) return strip(rootVerdict);
593
+
594
+ let submodulePaths: Set<string>;
595
+ try {
596
+ submodulePaths = await listSubmodulePaths(repoPath, options);
597
+ } catch {
598
+ return strip(rootVerdict); // can't read .gitmodules → stay conservative.
599
+ }
600
+ // Every blocking ambiguous path must be a registered submodule for descent to be able
601
+ // to clear the verdict — otherwise a non-submodule ambiguous file keeps it daemon.
602
+ if (ambiguous.length === 0 || !ambiguous.every((f) => submodulePaths.has(f))) {
603
+ return strip(rootVerdict);
604
+ }
605
+
606
+ const submoduleAffectedPackages: string[] = [];
607
+ for (const subPath of ambiguous) {
608
+ let range: { from: string; to: string };
609
+ try {
610
+ range = await resolveSubmoduleGitlinkRange(repoPath, fromRef, toRef, subPath, options);
611
+ } catch {
612
+ return strip(rootVerdict); // couldn't read the gitlink SHAs → conservative.
613
+ }
614
+ // A recursive classify inside the submodule reuses the SAME repo config resolution:
615
+ // the submodule has its own packages/ layout and may carry its own change-impact
616
+ // config; classifyChangedPackages(subRepo, ...) resolves it there.
617
+ let subVerdict: ChangedPackageClassification;
618
+ try {
619
+ subVerdict = await classifyChangedPackages(join(repoPath, subPath), range.from, range.to, {
620
+ ...options,
621
+ // Do not force the root's injected config onto the submodule — let it resolve
622
+ // its own .adhdev/change-impact.* (or fall back to defaults).
623
+ changeImpactConfig: undefined,
624
+ });
625
+ } catch {
626
+ return strip(rootVerdict); // submodule diff failed → conservative.
627
+ }
628
+ if (subVerdict.isDaemonAffecting) {
629
+ // The submodule content really does touch daemon runtime → keep daemon-affecting,
630
+ // surfacing the submodule packages so the reason is visible.
631
+ return {
632
+ isDaemonAffecting: true,
633
+ affectedPackages: [...new Set([...rootVerdict.affectedPackages, ...subVerdict.affectedPackages])].sort(),
634
+ };
635
+ }
636
+ submoduleAffectedPackages.push(...subVerdict.affectedPackages);
637
+ }
638
+
639
+ // Every ambiguous path was a submodule whose content is web-only. The remaining root
640
+ // packages (if any) must themselves be benign web-only for the whole change to be
641
+ // benign — reuse the exact same rule by re-checking the root package set.
642
+ const rootPackagesBenign = rootVerdict.affectedPackages.every(
643
+ (p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p),
644
+ );
645
+ return {
646
+ isDaemonAffecting: !rootPackagesBenign,
647
+ affectedPackages: [...new Set([...rootVerdict.affectedPackages, ...submoduleAffectedPackages])].sort(),
648
+ };
649
+ }
650
+
651
+ /** Registered submodule paths from `.gitmodules` (empty set if none / unreadable). */
652
+ async function listSubmodulePaths(repoPath: string, options: GitStatusOptions): Promise<Set<string>> {
653
+ // `git config -f .gitmodules --get-regexp path` lists `submodule.<name>.path <path>`.
654
+ const res = await runGit(repoPath, ['config', '-f', '.gitmodules', '--get-regexp', 'path'], options);
655
+ const paths = new Set<string>();
656
+ for (const line of res.stdout.split('\n')) {
657
+ const trimmed = line.trim();
658
+ if (!trimmed) continue;
659
+ const idx = trimmed.indexOf(' ');
660
+ if (idx === -1) continue;
661
+ const p = trimmed.slice(idx + 1).trim();
662
+ if (p) paths.add(p);
663
+ }
664
+ return paths;
665
+ }
666
+
667
+ /**
668
+ * Read the old/new subproject SHAs a root gitlink moved between over `fromRef..toRef`.
669
+ * `git diff <range> -- <subPath>` on a gitlink prints `-Subproject commit <old>` /
670
+ * `+Subproject commit <new>`. Throws if either SHA can't be resolved.
671
+ */
672
+ async function resolveSubmoduleGitlinkRange(
673
+ repoPath: string,
674
+ fromRef: string,
675
+ toRef: string,
676
+ subPath: string,
677
+ options: GitStatusOptions,
678
+ ): Promise<{ from: string; to: string }> {
679
+ const res = await runGit(repoPath, ['diff', `${fromRef}..${toRef}`, '--', subPath], options);
680
+ let from = '';
681
+ let to = '';
682
+ for (const line of res.stdout.split('\n')) {
683
+ const m = line.match(/^([+-])Subproject commit ([0-9a-f]{7,40})/);
684
+ if (!m) continue;
685
+ if (m[1] === '-') from = m[2];
686
+ else to = m[2];
687
+ }
688
+ if (!from || !to) throw new Error(`no gitlink range for submodule ${subPath}`);
689
+ return { from, to };
546
690
  }
547
691
 
548
692
  /**
@@ -44,8 +44,40 @@ import {
44
44
  runIdleMaintenanceThenAssignQueue,
45
45
  maybeAutoFastForwardIdleNode,
46
46
  sessionHasActiveAssignment,
47
+ AUTO_LAUNCH_AWAIT_CLAIM_MS,
47
48
  } from './mesh-queue-assignment.js';
48
49
 
50
+ // ---------------------------------------------------------------------------
51
+ // BOOTSTRAP-MSG: worktreeHasQueuedTask predicate (exported for unit testing)
52
+ // ---------------------------------------------------------------------------
53
+ // Returns true when a queue task entry should be counted as "this worktree node already
54
+ // has work being handled" — suppressing the misleading 'use mesh_launch_session' advice
55
+ // in the worktree_bootstrap_complete system message.
56
+ //
57
+ // Mirrors the autoLaunchPending logic in triggerMeshQueue (mesh-queue-assignment.ts):
58
+ // • assigned → true (session claimed it)
59
+ // • pending, no al → true (queue will auto-launch, no action needed)
60
+ // • pending, al started|completed within AUTO_LAUNCH_AWAIT_CLAIM_MS
61
+ // → true (session spun up, will claim soon)
62
+ // • pending, al started|completed but OUTSIDE the window
63
+ // → false (launch timed out, manual launch IS needed)
64
+ // • pending, other al → true (not yet tried, queue will handle)
65
+ export function bootstrapQueueTaskCountsAsHandled(
66
+ task: { status: string; targetNodeId?: string | null; autoLaunch?: { status: string; updatedAt: string } | null },
67
+ bootstrapNodeId: string,
68
+ nowMs: number,
69
+ ): boolean {
70
+ if (!meshNodeIdMatches({ id: task.targetNodeId } as MeshNodeIdentified, bootstrapNodeId)) return false;
71
+ if (task.status === 'assigned') return true;
72
+ const al = task.autoLaunch;
73
+ if (!al) return true;
74
+ if (al.status === 'started' || al.status === 'completed') {
75
+ const launchedAtMs = Date.parse(al.updatedAt);
76
+ return Number.isFinite(launchedAtMs) && nowMs - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS;
77
+ }
78
+ return true;
79
+ }
80
+
49
81
  // The set of coordinator-daemon ids this daemon answers to when draining the
50
82
  // pending-events queue. Mirrors resolveCoordinatorDaemonIds in mesh-reconcile-loop:
51
83
  // a unicast event may be stamped with the status id, the bare machineId, OR the
@@ -1319,8 +1351,9 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1319
1351
  // Only meaningful for the 'complete' transition (a failed bootstrap claims nothing).
1320
1352
  if (args.event === 'worktree_bootstrap_complete' && bootstrapNodeId) {
1321
1353
  try {
1354
+ const nowMs = Date.now();
1322
1355
  worktreeHasQueuedTask = getQueue(args.meshId, { status: ['pending', 'assigned'] })
1323
- .some((task) => meshNodeIdMatches({ id: task.targetNodeId } as MeshNodeIdentified, bootstrapNodeId));
1356
+ .some((task) => bootstrapQueueTaskCountsAsHandled(task, bootstrapNodeId, nowMs));
1324
1357
  } catch (e: any) {
1325
1358
  LOG.warn('MeshQueue', `Failed to check queued task for ${bootstrapNodeId} (mesh ${args.meshId}): ${e?.message || e}`);
1326
1359
  }
@@ -19,10 +19,12 @@
19
19
  * Design invariants:
20
20
  * - Only fires when the mesh has ≥1 `active` mission AND is fully idle. Fully idle =
21
21
  * buildMeshActiveWork over queue + direct dispatches reports totalActiveCount === 0
22
- * && generatingCount === 0. We intentionally do NOT probe remote node sessions here
23
- * (expensive per-tick RPC): any non-terminal queue/direct work already makes
24
- * totalActiveCount > 0, so the idle check is conservative — it suppresses the
25
- * reminder whenever any work is outstanding, which is the safe direction.
22
+ * && generatingCount === 0 AND no async refine job is accepted/running. We intentionally
23
+ * do NOT probe remote node sessions here (expensive per-tick RPC): any non-terminal
24
+ * queue/direct work already makes totalActiveCount > 0, so the idle check is
25
+ * conservative — it suppresses the reminder whenever any work is outstanding, which is
26
+ * the safe direction. Async refine jobs (`mesh_refine_node`) are a separate class that
27
+ * buildMeshActiveWork does not count, so they are checked explicitly from the ledger.
26
28
  * - NEVER transitions a mission's status. It only surfaces a hint; the coordinator
27
29
  * decides via mesh_mission_upsert.
28
30
  * - Debounced per mission-set. Re-fires only when the debounce window has elapsed OR
@@ -39,6 +41,7 @@ import { getMeshMissions, type MeshMissionRecord } from './mesh-missions.js';
39
41
  import { getQueue, getActiveDirectDispatches } from './mesh-work-queue.js';
40
42
  import { readLedgerEntries } from './mesh-ledger.js';
41
43
  import { buildMeshActiveWork } from './mesh-active-work.js';
44
+ import { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs } from './mesh-refine-status.js';
42
45
 
43
46
  /** Coordinator instance the reminder is injected into (the idle CLI session). */
44
47
  type CoordinatorInstance = ReturnType<DaemonComponents['instanceManager']['getInstance']>;
@@ -119,15 +122,28 @@ export function maybeInjectIdleActiveMissionReminder(
119
122
  // We pass no `nodes`: totalActiveCount already counts pending/assigned queue tasks
120
123
  // and un-acknowledged direct dispatches from the store alone, so the check stays
121
124
  // cheap (no per-node status RPC) and conservative.
125
+ const ledgerEntries = readLedgerEntries(meshId, { tail: 200 });
122
126
  const summary = buildMeshActiveWork({
123
127
  meshId,
124
128
  queue: getQueue(meshId),
125
129
  directDispatches: getActiveDirectDispatches(meshId),
126
- ledgerEntries: readLedgerEntries(meshId, { tail: 200 }),
130
+ ledgerEntries,
127
131
  now,
128
132
  }).summary;
129
133
  if (summary.totalActiveCount !== 0 || summary.generatingCount !== 0) return false;
130
134
 
135
+ // Async refine jobs are NOT modeled as queue/direct dispatches, so buildMeshActiveWork
136
+ // never counts them — an accepted/running `mesh_refine_node` job (each pass runs
137
+ // typecheck/test/build for minutes) would otherwise read as "no work in flight" and the
138
+ // reminder would push the coordinator to close a mission whose verification is still
139
+ // in progress. Derive in-flight refine jobs from the SAME ledger tail already read
140
+ // (buildMeshAsyncRefineJobs maps `task_dispatched` refine entries with no terminal to
141
+ // accepted/running) and suppress the reminder while any is non-terminal.
142
+ const activeRefineJobs = summarizeMeshAsyncRefineJobs(
143
+ buildMeshAsyncRefineJobs({ meshId, ledgerEntries }),
144
+ ).activeJobs;
145
+ if (activeRefineJobs.length > 0) return false;
146
+
131
147
  // Debounce — same mission set within the window is nudged at most once.
132
148
  const store = MeshRuntimeStore.getInstance();
133
149
  const hash = missionSetHash(activeMissions);
@@ -709,7 +709,7 @@ const AUTO_LAUNCH_COOLDOWN_MS = 5_000;
709
709
  // successfully-launched session whose claim we are still waiting on, do not launch it
710
710
  // again until the window lapses. It is generous (a slow remote spawn can take tens of
711
711
  // seconds) but bounded so a launch that silently never reaches idle is eventually retried.
712
- const AUTO_LAUNCH_AWAIT_CLAIM_MS = 90_000;
712
+ export const AUTO_LAUNCH_AWAIT_CLAIM_MS = 90_000;
713
713
 
714
714
  // AUTOLAUNCH-CLAIM-CHURN. For a REMOTE node the launch→claim handshake is purely
715
715
  // event-sourced: the worker's agent:ready must be pulled (reconcile PHASE 1) to run
@@ -790,7 +790,24 @@ export class FsmDriver implements ISpecDriver {
790
790
  if (!rule) return null;
791
791
  const hay = sectionText(sections, rule.section, fullScreen);
792
792
  const minCount = rule.min_count ?? 2;
793
- const buttons = extractButtonsFromRule(rule, hay);
793
+ let buttons = extractButtonsFromRule(rule, hay);
794
+ if (buttons.length < minCount && rule.section) {
795
+ // Whole-screen fallback: the modal `section` can resolve too short
796
+ // when a spec's `until` anchor clips the section BEFORE the choices
797
+ // (e.g. a claude-cli approval whose command preview carries a leading
798
+ // shell-redirect line — `>/dev/null 2>&1` — that an over-broad
799
+ // `[…>…]` modal-terminator anchor mistakes for the input prompt,
800
+ // stranding the `❯ 1. Yes / 2. No` buttons below the cut and wedging
801
+ // auto-approve forever). The buttons are still present in the full
802
+ // buffer, so re-extract from it. `lastContiguousNumberedBlock`
803
+ // (inside extractButtonsFromRule) already isolates the real
804
+ // bottom-most choice block from any stray body-numbered lines the
805
+ // wider scope pulls in, so this cannot bind the wrong rows. Guards
806
+ // it to the buttons-under-count case only, so a correctly-scoped
807
+ // spec pays nothing.
808
+ const whole = extractButtonsFromRule(rule, fullScreen);
809
+ if (whole.length >= minCount) buttons = whole;
810
+ }
794
811
  if (buttons.length < minCount) return null;
795
812
  const title = this.deriveTitle(state, sections, fullScreen);
796
813
  return { title, buttons };