@adhdev/daemon-core 1.0.28-rc.8 → 1.0.28-rc.9

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.
@@ -39,6 +39,7 @@ export type ExternalTranscriptProbe = {
39
39
  export declare const COMPLETED_FINALIZATION_RETRY_MS = 1000;
40
40
  export declare const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30000;
41
41
  export declare const CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS = 20000;
42
+ export declare const MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS = 60000;
42
43
  export declare const NATIVE_HISTORY_MESH_IDLE_SETTLE_MS = 4000;
43
44
  export declare const PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS = 1200;
44
45
  export declare const ANTIGRAVITY_HOLD_QUIET_DWELL_MS = 3000;
@@ -475,4 +475,17 @@ export declare class FsmDriver implements ISpecDriver {
475
475
  * not register as a region change. No filter → lines returned unchanged.
476
476
  * Exported for unit tests of the stable_ms `ignore_lines` change-detection. */
477
477
  export declare function filterIgnoredLines(lines: string[], ignoreRe: RegExp | undefined): string[];
478
+ /** Compute the [start, end) line window a cursor_above stable region measures,
479
+ * reconciling the backend's raw cursor row with the blank-trimmed viewport
480
+ * line array (see the CODEX-FSM-DEGENERATE-STABLE note in trackRegionChanges).
481
+ * The window end is clamped to the content length so an overshooting cursor
482
+ * row measures the content tail instead of slicing past the array into a
483
+ * permanently-empty — and therefore permanently "unchanged" — window.
484
+ * Returns null when no measurable window exists (cursor at/above row 0, or no
485
+ * content): the caller must treat the region as CHANGED, never stable.
486
+ * Exported for unit tests. */
487
+ export declare function stableCursorWindow(lineCount: number, cursorRow: number, cursorAbove: number): {
488
+ start: number;
489
+ end: number;
490
+ } | null;
478
491
  export {};
@@ -268,6 +268,31 @@ export interface SectionDef {
268
268
  * for that anchor. A scalar `anchor` ignores array form beyond index 0.
269
269
  */
270
270
  anchor_context?: AnchorContext | (AnchorContext | null)[];
271
+ /**
272
+ * Extends an anchored section N lines ABOVE its anchor line. The default
273
+ * anchored geometry starts the section AT the anchor line, which cannot
274
+ * express a status block that sits directly ABOVE its landmark (e.g.
275
+ * codex's live `Working (…)` spinner above the `› ` composer). With
276
+ * `above: K` the section starts at max(0, anchorLine - K), so the window
277
+ * stays pinned to the landmark regardless of output volume — unlike
278
+ * `from_bottom`, which counts from the LAST NON-BLANK line of a
279
+ * blank-trimmed viewport and lets the live status block escape the window
280
+ * once long output fills the tail (CODEX-FSM-DEGENERATE-STABLE RCA,
281
+ * defect 1: the spinner left the from_bottom:12 window while the worker
282
+ * was still generating).
283
+ */
284
+ above?: number;
285
+ /**
286
+ * Anchor-miss policy. Default (absent): an anchored section whose anchor
287
+ * matches NOTHING falls back to the whole screen (from=0, to=total) — the
288
+ * historical behavior modal sections rely on ("whole-screen fallback in
289
+ * non-modal frames is harmless"). 'empty' resolves the section to EMPTY
290
+ * instead, for sections whose guard regexes must never see unrelated body
291
+ * text: status_tail's spinner cues, whose whole-screen fallback would
292
+ * re-open the SPINNER-BODY-SELFMATCH defect on any frame where the
293
+ * composer landmark is momentarily absent (mid-redraw).
294
+ */
295
+ anchor_miss?: 'empty';
271
296
  lines?: number;
272
297
  until_regex?: string;
273
298
  until_regex_flags?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.28-rc.8",
3
+ "version": "1.0.28-rc.9",
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",
@@ -49,8 +49,8 @@
49
49
  "author": "vilmire",
50
50
  "license": "AGPL-3.0-or-later",
51
51
  "dependencies": {
52
- "@adhdev/mesh-shared": "1.0.28-rc.8",
53
- "@adhdev/session-host-core": "1.0.28-rc.8",
52
+ "@adhdev/mesh-shared": "1.0.28-rc.9",
53
+ "@adhdev/session-host-core": "1.0.28-rc.9",
54
54
  "@agentclientprotocol/sdk": "^0.16.1",
55
55
  "ajv": "^8.20.0",
56
56
  "ajv-formats": "^3.0.1",
@@ -677,6 +677,20 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
677
677
  ...(capabilities && capabilities.length ? { capabilities } : {}),
678
678
  });
679
679
  if (!node) return { success: false, error: 'Mesh not found' };
680
+ // MESH-MEMBERSHIP-INLINE-CACHE-SYNC: addNode() above only wrote the new
681
+ // node to the file-backed meshes.json. getMeshForCommand's inline-cache-
682
+ // preferred read (the default for mesh_status/mesh_list_nodes/get_mesh)
683
+ // resolves this SAME meshId from `inlineMeshCache` whenever a prior
684
+ // command warmed it (e.g. a cloud coordinator launch with inlineMesh —
685
+ // see mesh-coordinator-launch.ts). Without pushing the new node into
686
+ // that cache too, the live view keeps serving the pre-add snapshot until
687
+ // the daemon restarts and the cache is re-emptied. Only touch the cache
688
+ // when it already holds this mesh (nothing to fix for a pure local-config
689
+ // mesh that no caller has ever warmed).
690
+ const cachedMesh = ctx.getCachedInlineMesh(meshId);
691
+ if (cachedMesh) {
692
+ ctx.updateInlineMeshNode(meshId, cachedMesh, node);
693
+ }
680
694
  // mesh_status hands back a coordinator-memory aggregate
681
695
  // snapshot keyed on (meshId, queueRevision). Adding a
682
696
  // node touches neither, so without an explicit cache
@@ -1036,6 +1050,19 @@ export const meshCrudHandlers: Record<string, MedFamilyHandler> = {
1036
1050
  // the response is accurate.
1037
1051
  if (!removed && !node) removed = true;
1038
1052
  if (removed) ctx.invalidateAggregateMeshStatus(meshId);
1053
+ // MESH-MEMBERSHIP-INLINE-CACHE-SYNC: mirror-image of the inline
1054
+ // branch above. This mesh resolved from local_config (nothing had
1055
+ // warmed inlineMeshCache for it YET at getMeshForCommand time), but
1056
+ // another command (e.g. a cloud coordinator launch with inlineMesh)
1057
+ // may have already warmed the cache for this same meshId from an
1058
+ // earlier read. Splice the node out of the cached copy too, and
1059
+ // tombstone it, so a dashboard's stale inlineMesh echo cannot merge
1060
+ // the removed node back in (see removeInlineMeshNode's tombstone
1061
+ // comment).
1062
+ if (removed) {
1063
+ const cachedMesh = ctx.getCachedInlineMesh(meshId);
1064
+ if (cachedMesh) ctx.removeInlineMeshNode(meshId, cachedMesh, nodeId);
1065
+ }
1039
1066
  }
1040
1067
 
1041
1068
  // Record in task ledger
@@ -1583,6 +1583,40 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
1583
1583
  }
1584
1584
  }
1585
1585
 
1586
+ // ── PHASE 0.5: merge file-config membership into the router's inline cache ─
1587
+ // MESH-MEMBERSHIP-INLINE-CACHE-SYNC: getMeshForCommand's inline-cache-preferred
1588
+ // read (mesh_status / mesh_list_nodes / get_mesh) resolves a meshId from
1589
+ // router.inlineMeshCache whenever ANYTHING has warmed it (e.g. a cloud
1590
+ // coordinator launch with inlineMesh — see mesh-coordinator-launch.ts). Once
1591
+ // warmed, that cache — not meshes.json — is what every live read serves.
1592
+ // add_mesh_node/remove_mesh_node now push their own writes into the cache
1593
+ // too, but a change made by ANOTHER daemon (or a direct meshes.json edit)
1594
+ // reaches only this daemon's file config, never its in-memory cache. This
1595
+ // loop already re-reads listMeshes() every tick for its own queue work, so
1596
+ // reuse that read to fold the current file-config membership into the cache
1597
+ // via the same reconcileInlineMeshCache path getMeshForCommand itself uses
1598
+ // (getCachedInlineMesh(meshId, incoming) merges — it does not overwrite —
1599
+ // preferring cache-only nodes when the cache is newer, e.g. a just-cloned
1600
+ // worktree not yet flushed to disk).
1601
+ //
1602
+ // Deliberately gated on `router.getCachedInlineMesh(mesh.id)` (no second arg)
1603
+ // returning a hit FIRST: calling the merging overload unconditionally would
1604
+ // warm the cache for every local-config mesh on every tick, flipping meshes
1605
+ // that have NEVER been inline-cached from always-fresh 'local_config' reads
1606
+ // to a resolution sourced from a snapshot that's only as fresh as the last
1607
+ // 4s tick. Only merge into a cache entry that already exists.
1608
+ if (components.router) {
1609
+ for (const mesh of listMeshes()) {
1610
+ try {
1611
+ if (components.router.getCachedInlineMesh(mesh.id)) {
1612
+ components.router.getCachedInlineMesh(mesh.id, mesh);
1613
+ }
1614
+ } catch (e: any) {
1615
+ LOG.warn('MeshReconcile', `Inline-cache membership merge failed for mesh ${mesh.id}: ${e?.message || e}`);
1616
+ }
1617
+ }
1618
+ }
1619
+
1586
1620
  // ── PHASE 1: pull remote node queues for every mesh this daemon hosts ──────
1587
1621
  // Cloud-only (dispatchMeshCommand present). Runs whether or not a live CLI
1588
1622
  // coordinator exists — this is what lets an MCP/LLM coordinator ever see a
@@ -115,6 +115,31 @@ export const COMPLETED_FINALIZATION_MAX_WAIT_MS = 30_000;
115
115
  // Sized so a short transcript-write lag resolves under it while staying well below the 30s
116
116
  // COMPLETED_FINALIZATION_MAX_WAIT_MS cap that force-releases the weak completion regardless.
117
117
  export const CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS = 20_000;
118
+ // (TRANSCRIPT-GROWTH-HOLD — CODEX-FSM-DEGENERATE-STABLE RCA, upper safety net)
119
+ // Minimum QUIET time required on the provider's native transcript file before a
120
+ // missing_final_assistant + noExternalTranscriptSource completion (the FLOOR
121
+ // class: codex-cli / kimi / cursor-cli / opencode) may release its weak emit.
122
+ // While the transcript file has been appended within this window, the turn is
123
+ // demonstrably alive regardless of what the screen parser says — the FSM's
124
+ // busy→idle that armed the completion was a lie (spinner escaped the status
125
+ // window / degenerate stable region), so the flush HOLDS instead of firing.
126
+ //
127
+ // Conservative by construction: the hold engages ONLY on positive growth
128
+ // evidence (a fresh source mtime). A provider with no native source, an
129
+ // unresolvable transcript, or a transcript that has gone quiet for this window
130
+ // falls through to the unchanged floor/cap logic — missing information never
131
+ // blocks an idle verdict (no false-busy wedge), and each hold cycle is
132
+ // re-verified against a FRESH sample, so the hold releases at most this long
133
+ // after the transcript's last append.
134
+ //
135
+ // Sized from the live RCA: during the defect the rollout transcript advanced
136
+ // ~once per 45s (msgCount 82→87 over ~4min) while the PTY was quiet for 365s,
137
+ // so the window must exceed that append cadence or the hold leaks between
138
+ // appends; 60s covers it with margin while staying well under the 180s
139
+ // mesh-worker stall watchdog threshold, which keeps ownership of genuine long
140
+ // stalls. It only ever delays the already-degraded missing_final_assistant
141
+ // path — completions WITH an in-turn final assistant never see this hold.
142
+ export const MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS = 60_000;
118
143
  // (FALSEIDLE-BGCHILD-a) Minimum generating→idle settle window for native-history mesh worker
119
144
  // sessions. Native-history providers (e.g. claude-cli) normally flush the completion with
120
145
  // flushDelay=0 — the transcript is authoritative, so there is no reason to wait. But a worker
@@ -59,6 +59,7 @@ import {
59
59
  COMPLETED_FINALIZATION_RETRY_MS,
60
60
  COMPLETED_FINALIZATION_MAX_WAIT_MS,
61
61
  CANON_C_MISSING_ASSISTANT_MIN_ELAPSED_MS,
62
+ MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS,
62
63
  NATIVE_HISTORY_MESH_IDLE_SETTLE_MS,
63
64
  PTY_PARSED_FINAL_ASSISTANT_QUIET_DWELL_MS,
64
65
  ANTIGRAVITY_HOLD_QUIET_DWELL_MS,
@@ -3140,6 +3141,56 @@ export class CliProviderInstance implements ProviderInstance {
3140
3141
  // from ever being emitted during the valley, without depending on the valley's length.
3141
3142
  const isTranscriptEvidenceGate = block.allowTimeout === true;
3142
3143
  LOG.debug('CLI', `[${this.type}] finalization block: reason=${blockReason} terminal=${block.terminal} allowTimeout=${isTranscriptEvidenceGate} waitedMs=${waitedMs} maxWait=${COMPLETED_FINALIZATION_MAX_WAIT_MS}`);
3144
+ // (TRANSCRIPT-GROWTH-HOLD — CODEX-FSM-DEGENERATE-STABLE RCA, upper safety net)
3145
+ // The FLOOR class (missing_final_assistant + noExternalTranscriptSource:
3146
+ // codex-cli / kimi / cursor-cli / opencode) releases its weak emit once
3147
+ // the idle has been CONTINUOUSLY quiet past the floor/cap — "quiet" as
3148
+ // judged by the SAME screen parsing whose lie armed this completion
3149
+ // (spinner escaped the status window, or a degenerate stable region
3150
+ // accumulated a false 353s "quiet"). The daemon already observes the
3151
+ // independent liveness signal that proves otherwise — the native
3152
+ // transcript advancing (the stall watchdog logs exactly this: "PTY
3153
+ // quiet 365s but transcript advancing") — but it was never wired into
3154
+ // the idle/completion judgment. Wire it here: if the provider's native
3155
+ // transcript file was appended within the growth-quiet window, the
3156
+ // turn is demonstrably alive; HOLD (re-probe each retry — the hold
3157
+ // releases at most MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS after
3158
+ // the transcript's last append) instead of emitting a completion the
3159
+ // coordinator can never correct.
3160
+ //
3161
+ // Conservative by construction: this only ever DELAYS an emit on
3162
+ // positive growth evidence. A pure-PTY provider (no native source →
3163
+ // sample null), an unresolvable transcript, or a transcript whose
3164
+ // mtime is unknown/quiet falls through to the unchanged floor/cap
3165
+ // logic — missing information NEVER blocks an idle verdict (no
3166
+ // false-busy wedge). Clean completions (in-turn final assistant
3167
+ // present) never reach this branch, so genuine completion latency is
3168
+ // untouched; claude-cli's write-lag CANON-C immediate emit is likewise
3169
+ // untouched (its block deliberately omits noExternalTranscriptSource).
3170
+ if (blockReason === 'missing_final_assistant' && block.noExternalTranscriptSource === true) {
3171
+ let nativeSample: { msgCount: number; sourceMtimeMs: number } | null = null;
3172
+ try { nativeSample = this.sampleNativeTranscriptProgress(); } catch { nativeSample = null; }
3173
+ const sourceMtimeMs = nativeSample?.sourceMtimeMs ?? 0;
3174
+ if (nativeSample && sourceMtimeMs > 0 && Date.now() - sourceMtimeMs < MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS) {
3175
+ if (pending.loggedBlockReason !== 'native_transcript_advancing') {
3176
+ LOG.info('CLI', `[${this.type}] holding pending completed (native_transcript_advancing: msgCount=${nativeSample.msgCount} mtimeAge=${Date.now() - sourceMtimeMs}ms < ${MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS}ms) — transcript still growing, screen-idle verdict not trusted`);
3177
+ if (this.isMeshWorkerSession()) {
3178
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `native_transcript_advancing msgCount=${nativeSample.msgCount} mtimeAge=${Date.now() - sourceMtimeMs}ms`);
3179
+ }
3180
+ if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
3181
+ blockReason: 'native_transcript_advancing',
3182
+ latestVisibleStatus,
3183
+ msgCount: nativeSample.msgCount,
3184
+ sourceMtimeAgeMs: Date.now() - sourceMtimeMs,
3185
+ growthQuietMs: MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS,
3186
+ waitedMs,
3187
+ });
3188
+ pending.loggedBlockReason = 'native_transcript_advancing';
3189
+ }
3190
+ this.scheduleCompletedDebounceFlush(COMPLETED_FINALIZATION_RETRY_MS);
3191
+ return;
3192
+ }
3193
+ }
3143
3194
  if (!isTranscriptEvidenceGate && (block.terminal || waitedMs < COMPLETED_FINALIZATION_MAX_WAIT_MS)) {
3144
3195
  if (pending.loggedBlockReason !== blockReason) {
3145
3196
  LOG.info('CLI', `[${this.type}] waiting to emit completed until transcript finalizes (${blockReason})`);
@@ -107,7 +107,9 @@ export function resolveSections(
107
107
  if (candIdx !== -1 && (idx === -1 || candIdx < idx)) idx = candIdx;
108
108
  }
109
109
  if (idx !== -1) {
110
- from = idx;
110
+ from = typeof sec.above === 'number' && sec.above > 0
111
+ ? Math.max(0, idx - Math.floor(sec.above))
112
+ : idx;
111
113
  to = total;
112
114
  if (sec.until_regex !== undefined) {
113
115
  try {
@@ -118,6 +120,12 @@ export function resolveSections(
118
120
  } else if (sec.lines !== undefined) {
119
121
  to = Math.min(total, from + sec.lines);
120
122
  }
123
+ } else if (sec.anchor_miss === 'empty') {
124
+ // Explicit empty-on-miss (see SectionDef.anchor_miss): the
125
+ // section resolves to EMPTY rather than the whole-screen
126
+ // fallback, so narrow-cue guards never see unrelated body.
127
+ from = 0;
128
+ to = 0;
121
129
  }
122
130
  } catch { /* bad anchor regex */ }
123
131
  } else if (sec.from_top !== undefined) {
@@ -876,9 +876,33 @@ export class FsmDriver implements ISpecDriver {
876
876
  curLines = currentLines;
877
877
  prevLines = this.prevScreenLines;
878
878
  } else {
879
- const start = Math.max(0, cursor.row - d.cursor_above);
880
- curLines = currentLines.slice(start, cursor.row);
881
- prevLines = this.prevScreenLines.slice(start, cursor.row);
879
+ // CODEX-FSM-DEGENERATE-STABLE fix: cursor.row is the backend's RAW
880
+ // row coordinate (ghostty getCursorPosition(), un-normalized), while
881
+ // currentLines is the VIEWPORT snapshot with blank ends trimmed
882
+ // (ghostty-vt-backend getText → trimBlankEnds). The two coordinate
883
+ // spaces diverge whenever trailing blank rows are trimmed away (or
884
+ // the backend counts scrollback rows): cursor.row then overshoots
885
+ // the array and slice(start, cursor.row) returns an EMPTY window.
886
+ // Two empty windows compare equal on every frame, so
887
+ // regionLastChangedAt never advances and stable_ms accumulates
888
+ // forever — the FSM read a generating screen as
889
+ // "stable cursor_above=4 353833ms / 1500ms" and committed a false
890
+ // busy→idle (live RCA: generating_completed at duration=402s while
891
+ // the native transcript kept growing).
892
+ //
893
+ // Invariant: an unmeasurable window must NEVER read as "stable".
894
+ // Clamp the window end to the content length (when the cursor sits
895
+ // in the trimmed blank region, the lines directly above it in
896
+ // content terms are the content tail), and when the window is still
897
+ // empty (no measurable content at all) mark the region CHANGED so
898
+ // the stable clock restarts instead of accumulating.
899
+ const window = stableCursorWindow(currentLines.length, cursor.row, d.cursor_above);
900
+ if (!window) {
901
+ this.regionLastChangedAt.set(d.key, now);
902
+ continue;
903
+ }
904
+ curLines = currentLines.slice(window.start, window.end);
905
+ prevLines = this.prevScreenLines.slice(window.start, window.end);
882
906
  }
883
907
  const cur = filterIgnoredLines(curLines, d.ignoreRe).join('\n');
884
908
  const prev = filterIgnoredLines(prevLines, d.ignoreRe).join('\n');
@@ -1570,3 +1594,19 @@ export function filterIgnoredLines(lines: string[], ignoreRe: RegExp | undefined
1570
1594
  if (!ignoreRe) return lines;
1571
1595
  return lines.filter(l => !ignoreRe.test(l));
1572
1596
  }
1597
+
1598
+ /** Compute the [start, end) line window a cursor_above stable region measures,
1599
+ * reconciling the backend's raw cursor row with the blank-trimmed viewport
1600
+ * line array (see the CODEX-FSM-DEGENERATE-STABLE note in trackRegionChanges).
1601
+ * The window end is clamped to the content length so an overshooting cursor
1602
+ * row measures the content tail instead of slicing past the array into a
1603
+ * permanently-empty — and therefore permanently "unchanged" — window.
1604
+ * Returns null when no measurable window exists (cursor at/above row 0, or no
1605
+ * content): the caller must treat the region as CHANGED, never stable.
1606
+ * Exported for unit tests. */
1607
+ export function stableCursorWindow(lineCount: number, cursorRow: number, cursorAbove: number): { start: number; end: number } | null {
1608
+ const end = Math.min(Math.max(0, cursorRow), Math.max(0, lineCount));
1609
+ const start = Math.max(0, end - cursorAbove);
1610
+ if (end <= start) return null;
1611
+ return { start, end };
1612
+ }
@@ -293,6 +293,31 @@ export interface SectionDef {
293
293
  * for that anchor. A scalar `anchor` ignores array form beyond index 0.
294
294
  */
295
295
  anchor_context?: AnchorContext | (AnchorContext | null)[];
296
+ /**
297
+ * Extends an anchored section N lines ABOVE its anchor line. The default
298
+ * anchored geometry starts the section AT the anchor line, which cannot
299
+ * express a status block that sits directly ABOVE its landmark (e.g.
300
+ * codex's live `Working (…)` spinner above the `› ` composer). With
301
+ * `above: K` the section starts at max(0, anchorLine - K), so the window
302
+ * stays pinned to the landmark regardless of output volume — unlike
303
+ * `from_bottom`, which counts from the LAST NON-BLANK line of a
304
+ * blank-trimmed viewport and lets the live status block escape the window
305
+ * once long output fills the tail (CODEX-FSM-DEGENERATE-STABLE RCA,
306
+ * defect 1: the spinner left the from_bottom:12 window while the worker
307
+ * was still generating).
308
+ */
309
+ above?: number;
310
+ /**
311
+ * Anchor-miss policy. Default (absent): an anchored section whose anchor
312
+ * matches NOTHING falls back to the whole screen (from=0, to=total) — the
313
+ * historical behavior modal sections rely on ("whole-screen fallback in
314
+ * non-modal frames is harmless"). 'empty' resolves the section to EMPTY
315
+ * instead, for sections whose guard regexes must never see unrelated body
316
+ * text: status_tail's spinner cues, whose whole-screen fallback would
317
+ * re-open the SPINNER-BODY-SELFMATCH defect on any frame where the
318
+ * composer landmark is momentarily absent (mid-redraw).
319
+ */
320
+ anchor_miss?: 'empty';
296
321
  lines?: number;
297
322
  until_regex?: string;
298
323
  until_regex_flags?: string;