@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.
- package/dist/index.js +69 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +69 -8
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance-types.d.ts +1 -0
- package/dist/providers/spec/fsm-driver.d.ts +13 -0
- package/dist/providers/spec/types.d.ts +25 -0
- package/package.json +3 -3
- package/src/commands/med-family/mesh-crud.ts +27 -0
- package/src/mesh/mesh-reconcile-loop.ts +34 -0
- package/src/providers/cli-provider-instance-types.ts +25 -0
- package/src/providers/cli-provider-instance.ts +51 -0
- package/src/providers/spec/evaluator.ts +9 -1
- package/src/providers/spec/fsm-driver.ts +43 -3
- package/src/providers/spec/types.ts +25 -0
|
@@ -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.
|
|
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.
|
|
53
|
-
"@adhdev/session-host-core": "1.0.28-rc.
|
|
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 =
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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;
|