@adhdev/daemon-core 1.0.28-rc.7 → 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.
@@ -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;