@adhdev/daemon-core 0.9.82-rc.415 → 0.9.82-rc.417

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.
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
- export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed' | 'coordinator_operating_note' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated';
17
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed' | 'coordinator_operating_note' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis';
18
18
  export interface MeshLedgerEntry {
19
19
  id: string;
20
20
  meshId: string;
@@ -0,0 +1,63 @@
1
+ import type { MeshLedgerEntry } from './mesh-ledger.js';
2
+ import type { MagiGitSkew } from '@adhdev/mesh-shared';
3
+ export type MeshMagiActivityStatus = 'running' | 'synthesized';
4
+ /** A bounded needs_verification preview item (claim text + category only). */
5
+ export interface MeshMagiNeedsVerificationItem {
6
+ claim: string;
7
+ category: string;
8
+ }
9
+ export interface MeshMagiActivitySummary {
10
+ consensusGroupId: string;
11
+ status: MeshMagiActivityStatus;
12
+ missionId?: string;
13
+ panel?: string;
14
+ question?: string;
15
+ replicaCount?: number;
16
+ answered?: number;
17
+ missing?: number;
18
+ staleReplicas?: number;
19
+ needsVerificationCount?: number;
20
+ agreedCount?: number;
21
+ independenceBanner?: string | null;
22
+ gitSkew?: MagiGitSkew;
23
+ /** Bounded sample of the needs_verification clusters (claim + category). */
24
+ needsVerification?: MeshMagiNeedsVerificationItem[];
25
+ openQuestions?: string[];
26
+ lastLedgerKind?: string;
27
+ lastUpdatedAt?: string;
28
+ }
29
+ /** Cap on inlined needs_verification preview items per group (keeps the payload bounded). */
30
+ export declare const MAGI_NEEDS_VERIFICATION_PREVIEW_CAP = 8;
31
+ /**
32
+ * Reconstruct per-consensusGroup MAGI activity from a ledger window. The newest
33
+ * `magi_synthesis` for a group supplies the synthesis fields; a `magi_dispatched`
34
+ * with no later synthesis stays `running`. Deduped by consensusGroupId, newest first.
35
+ */
36
+ export declare function buildMeshMagiActivity(args: {
37
+ meshId?: string;
38
+ ledgerEntries?: MeshLedgerEntry[];
39
+ }): MeshMagiActivitySummary[];
40
+ /** Synthesized groups older than this (relative to the newest activity in the set) are
41
+ * folded out of the active list — already-resolved historical runs that should not keep
42
+ * inflating mesh_status. 6h covers a long working session. */
43
+ export declare const STALE_MAGI_WINDOW_MS: number;
44
+ /** Cap on recent synthesized groups kept in the active list even if all are fresh. */
45
+ export declare const RECENT_MAGI_CAP = 6;
46
+ export interface MeshMagiActivitySummaryFold {
47
+ total: number;
48
+ byStatus: Record<string, number>;
49
+ /** Synthesized groups dropped from the active list as stale historical residue. */
50
+ staleSynthesized: number;
51
+ /** Bounded set of recent/active groups (running first, then recent synthesized). */
52
+ groups: MeshMagiActivitySummary[];
53
+ }
54
+ /**
55
+ * Bound the MAGI activity list for mesh_status: running groups are always kept; synthesized
56
+ * groups are kept only when recent (within STALE_MAGI_WINDOW_MS of the newest activity AND
57
+ * among the RECENT_MAGI_CAP most-recent). Freshness is measured relative to the newest group
58
+ * (not wall-clock) so the result is deterministic for a given input. Mirrors
59
+ * summarizeMeshAsyncRefineJobs.
60
+ */
61
+ export declare function summarizeMeshMagiActivity(activity: MeshMagiActivitySummary[]): MeshMagiActivitySummaryFold;
62
+ /** Latest persisted synthesis activity for one consensusGroupId, or undefined. */
63
+ export declare function getMeshMagiActivityByGroup(ledgerEntries: MeshLedgerEntry[], consensusGroupId: string): MeshMagiActivitySummary | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.415",
3
+ "version": "0.9.82-rc.417",
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",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.415",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.417",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -604,7 +604,7 @@ function normalizeReplicaCount(value: unknown): number | undefined {
604
604
  * structurally invalid input so the calling tool returns a clear error rather than
605
605
  * writing a malformed panel.
606
606
  */
607
- function normalizeMagiPanel(config: unknown): MagiPanel {
607
+ export function normalizeMagiPanel(config: unknown): MagiPanel {
608
608
  if (!config || typeof config !== 'object' || Array.isArray(config)) {
609
609
  throw new Error('invalid_magi_panel: config must be an object');
610
610
  }
package/src/index.ts CHANGED
@@ -192,7 +192,7 @@ export type { SavedProviderSessionEntry } from './config/saved-sessions.js';
192
192
  export {
193
193
  listMeshes, getMesh, getMeshByRepo, createMesh, updateMesh, deleteMesh,
194
194
  addNode, removeNode, updateNode, normalizeRepoIdentity,
195
- listMagiPanels, getMagiPanel, upsertMagiPanel, removeMagiPanel,
195
+ listMagiPanels, getMagiPanel, upsertMagiPanel, removeMagiPanel, normalizeMagiPanel,
196
196
  } from './config/mesh-config.js';
197
197
  export type { CreateMeshOptions, UpdateMeshOptions, AddNodeOptions } from './config/mesh-config.js';
198
198
  // MAGI panel / common-output / synthesis types (re-exported from the mesh-shared
@@ -201,7 +201,7 @@ export type { CreateMeshOptions, UpdateMeshOptions, AddNodeOptions } from './con
201
201
  export type {
202
202
  MagiPanel, MagiPanelMember, MagiPanelMap, MagiMode,
203
203
  MagiClaim, MagiClaimStance, MagiAgentResponse,
204
- MagiResponseSource, MagiSynthesizedResponse,
204
+ MagiResponseSource, MagiReplicaGitRef, MagiGitSkew, MagiSynthesizedResponse,
205
205
  MagiClusterCategory, MagiClusterMember, MagiClaimCluster, MagiSynthesis,
206
206
  } from '@adhdev/mesh-shared';
207
207
 
@@ -269,6 +269,8 @@ export type { StaleDirectPruneClassification, StaleDirectPruneResult, PruneStale
269
269
  export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
270
270
  export { buildMeshAsyncRefineJobs, summarizeMeshAsyncRefineJobs, STALE_TERMINAL_REFINE_WINDOW_MS, RECENT_TERMINAL_REFINE_CAP } from './mesh/mesh-refine-status.js';
271
271
  export type { MeshAsyncRefineJobStatus, MeshAsyncRefineJobSummary, MeshAsyncRefineJobsSummary } from './mesh/mesh-refine-status.js';
272
+ export { buildMeshMagiActivity, summarizeMeshMagiActivity, getMeshMagiActivityByGroup, STALE_MAGI_WINDOW_MS, RECENT_MAGI_CAP, MAGI_NEEDS_VERIFICATION_PREVIEW_CAP } from './mesh/mesh-magi-status.js';
273
+ export type { MeshMagiActivityStatus, MeshMagiActivitySummary, MeshMagiActivitySummaryFold, MeshMagiNeedsVerificationItem } from './mesh/mesh-magi-status.js';
272
274
 
273
275
  // ── Mesh Scheduling Runtime (observability projection) ──
274
276
  export { buildMeshSchedulingRuntime } from './mesh/mesh-scheduling-runtime.js';
@@ -59,6 +59,13 @@ export type MeshLedgerKind =
59
59
  | 'mission_created'
60
60
  | 'mission_status_changed'
61
61
  | 'mission_goal_updated'
62
+ // MAGI (Multi-Agent Ground-truth Insight) cross-verification activity. Persisted
63
+ // so a wait=false fan-out and its later synthesis survive coordinator restarts and
64
+ // are foldable into mesh_status (keyed by consensusGroupId).
65
+ // magi_dispatched payload: { source:'magi', consensusGroupId, missionId?, panel?, question?, replicaCount }
66
+ // magi_synthesis payload: { source:'magi', consensusGroupId, missionId?, panel?, question?, synthesis }
67
+ | 'magi_dispatched'
68
+ | 'magi_synthesis'
62
69
  ;
63
70
 
64
71
  export interface MeshLedgerEntry {
@@ -0,0 +1,223 @@
1
+ // MAGI (Multi-Agent Ground-truth Insight) activity reconstruction for mesh_status.
2
+ //
3
+ // MAGI cross-verification runs are persisted in the mesh ledger as `magi_dispatched`
4
+ // (fan-out enqueued) and `magi_synthesis` (collected + synthesized) entries, keyed by
5
+ // consensusGroupId. This module reconstructs the latest per-group activity from a
6
+ // ledger window and folds it into a BOUNDED mesh_status section so a coordinator (and
7
+ // the web dashboard's extractMagiActivity) can read the synthesis fields — the
8
+ // needs_verification counts, independence banner, and git skew — without re-running
9
+ // collection. Mirrors mesh-refine-status.ts (buildMeshAsyncRefineJobs / summarize…).
10
+ //
11
+ // Pure: operates on a ledger-entry array. No I/O, no Node/DOM APIs.
12
+
13
+ import type { MeshLedgerEntry } from './mesh-ledger.js';
14
+ import type { MagiGitSkew } from '@adhdev/mesh-shared';
15
+
16
+ export type MeshMagiActivityStatus = 'running' | 'synthesized';
17
+
18
+ /** A bounded needs_verification preview item (claim text + category only). */
19
+ export interface MeshMagiNeedsVerificationItem {
20
+ claim: string;
21
+ category: string;
22
+ }
23
+
24
+ export interface MeshMagiActivitySummary {
25
+ consensusGroupId: string;
26
+ status: MeshMagiActivityStatus;
27
+ missionId?: string;
28
+ panel?: string;
29
+ question?: string;
30
+ replicaCount?: number;
31
+ answered?: number;
32
+ missing?: number;
33
+ staleReplicas?: number;
34
+ needsVerificationCount?: number;
35
+ agreedCount?: number;
36
+ independenceBanner?: string | null;
37
+ gitSkew?: MagiGitSkew;
38
+ /** Bounded sample of the needs_verification clusters (claim + category). */
39
+ needsVerification?: MeshMagiNeedsVerificationItem[];
40
+ openQuestions?: string[];
41
+ lastLedgerKind?: string;
42
+ lastUpdatedAt?: string;
43
+ }
44
+
45
+ function readString(value: unknown): string | undefined {
46
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
47
+ }
48
+
49
+ function readRecord(value: unknown): Record<string, unknown> | undefined {
50
+ return value && typeof value === 'object' && !Array.isArray(value)
51
+ ? value as Record<string, unknown>
52
+ : undefined;
53
+ }
54
+
55
+ function readNumber(value: unknown): number | undefined {
56
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
57
+ }
58
+
59
+ /** Cap on inlined needs_verification preview items per group (keeps the payload bounded). */
60
+ export const MAGI_NEEDS_VERIFICATION_PREVIEW_CAP = 8;
61
+
62
+ function summarizeNeedsVerification(synthesis: Record<string, unknown> | undefined): MeshMagiNeedsVerificationItem[] | undefined {
63
+ const list = Array.isArray(synthesis?.needsVerification) ? synthesis!.needsVerification : undefined;
64
+ if (!list) return undefined;
65
+ const items: MeshMagiNeedsVerificationItem[] = [];
66
+ for (const raw of list.slice(0, MAGI_NEEDS_VERIFICATION_PREVIEW_CAP)) {
67
+ const r = readRecord(raw);
68
+ const claim = readString(r?.claim);
69
+ if (!claim) continue;
70
+ items.push({ claim, category: readString(r?.category) || 'needs_verification' });
71
+ }
72
+ return items;
73
+ }
74
+
75
+ function mergeGroup(
76
+ groups: Map<string, MeshMagiActivitySummary>,
77
+ patch: Partial<MeshMagiActivitySummary> & { consensusGroupId?: string },
78
+ ): void {
79
+ const consensusGroupId = readString(patch.consensusGroupId);
80
+ if (!consensusGroupId) return;
81
+ const previous = groups.get(consensusGroupId);
82
+ // synthesized is terminal-ish and must not be downgraded back to running by an
83
+ // out-of-order dispatch entry.
84
+ const status: MeshMagiActivityStatus = patch.status === 'synthesized' || previous?.status === 'synthesized'
85
+ ? 'synthesized'
86
+ : 'running';
87
+ const definedPatch = Object.fromEntries(
88
+ Object.entries(patch).filter(([, v]) => v !== undefined),
89
+ ) as Partial<MeshMagiActivitySummary>;
90
+ groups.set(consensusGroupId, { ...previous, ...definedPatch, consensusGroupId, status });
91
+ }
92
+
93
+ /**
94
+ * Reconstruct per-consensusGroup MAGI activity from a ledger window. The newest
95
+ * `magi_synthesis` for a group supplies the synthesis fields; a `magi_dispatched`
96
+ * with no later synthesis stays `running`. Deduped by consensusGroupId, newest first.
97
+ */
98
+ export function buildMeshMagiActivity(args: {
99
+ meshId?: string;
100
+ ledgerEntries?: MeshLedgerEntry[];
101
+ }): MeshMagiActivitySummary[] {
102
+ const groups = new Map<string, MeshMagiActivitySummary>();
103
+
104
+ for (const entry of args.ledgerEntries || []) {
105
+ const payload = readRecord(entry.payload);
106
+ if (payload?.source !== 'magi') continue;
107
+ const consensusGroupId = readString(payload.consensusGroupId);
108
+ if (!consensusGroupId) continue;
109
+
110
+ if (entry.kind === 'magi_synthesis') {
111
+ const synthesis = readRecord(payload.synthesis);
112
+ mergeGroup(groups, {
113
+ consensusGroupId,
114
+ status: 'synthesized',
115
+ missionId: readString(payload.missionId),
116
+ panel: readString(payload.panel),
117
+ question: readString(payload.question),
118
+ replicaCount: readNumber(synthesis?.replicasExpected) ?? readNumber(payload.replicaCount),
119
+ answered: readNumber(synthesis?.replicasAnswered),
120
+ missing: readNumber(synthesis?.replicasMissing),
121
+ staleReplicas: readNumber(payload.staleReplicas) ?? readNumber(synthesis?.staleReplicas),
122
+ needsVerificationCount: Array.isArray(synthesis?.needsVerification) ? synthesis!.needsVerification.length : undefined,
123
+ agreedCount: Array.isArray(synthesis?.agreed) ? synthesis!.agreed.length : undefined,
124
+ independenceBanner: synthesis && 'independenceBanner' in synthesis ? (synthesis.independenceBanner as string | null) : undefined,
125
+ gitSkew: readRecord(synthesis?.gitSkew) as unknown as MagiGitSkew | undefined,
126
+ needsVerification: summarizeNeedsVerification(synthesis),
127
+ openQuestions: Array.isArray(synthesis?.openQuestions) ? (synthesis!.openQuestions as string[]).slice(0, 10) : undefined,
128
+ lastLedgerKind: entry.kind,
129
+ lastUpdatedAt: entry.timestamp,
130
+ });
131
+ } else if (entry.kind === 'magi_dispatched') {
132
+ mergeGroup(groups, {
133
+ consensusGroupId,
134
+ status: 'running',
135
+ missionId: readString(payload.missionId),
136
+ panel: readString(payload.panel),
137
+ question: readString(payload.question),
138
+ replicaCount: readNumber(payload.replicaCount),
139
+ lastLedgerKind: entry.kind,
140
+ lastUpdatedAt: entry.timestamp,
141
+ });
142
+ }
143
+ }
144
+
145
+ return Array.from(groups.values()).sort((a, b) => {
146
+ const at = new Date(a.lastUpdatedAt || '').getTime();
147
+ const bt = new Date(b.lastUpdatedAt || '').getTime();
148
+ return (Number.isFinite(bt) ? bt : 0) - (Number.isFinite(at) ? at : 0);
149
+ });
150
+ }
151
+
152
+ /** Synthesized groups older than this (relative to the newest activity in the set) are
153
+ * folded out of the active list — already-resolved historical runs that should not keep
154
+ * inflating mesh_status. 6h covers a long working session. */
155
+ export const STALE_MAGI_WINDOW_MS = 6 * 60 * 60 * 1000;
156
+
157
+ /** Cap on recent synthesized groups kept in the active list even if all are fresh. */
158
+ export const RECENT_MAGI_CAP = 6;
159
+
160
+ export interface MeshMagiActivitySummaryFold {
161
+ total: number;
162
+ byStatus: Record<string, number>;
163
+ /** Synthesized groups dropped from the active list as stale historical residue. */
164
+ staleSynthesized: number;
165
+ /** Bounded set of recent/active groups (running first, then recent synthesized). */
166
+ groups: MeshMagiActivitySummary[];
167
+ }
168
+
169
+ function activityTime(g: MeshMagiActivitySummary): number {
170
+ const t = new Date(g.lastUpdatedAt || '').getTime();
171
+ return Number.isFinite(t) ? t : 0;
172
+ }
173
+
174
+ /**
175
+ * Bound the MAGI activity list for mesh_status: running groups are always kept; synthesized
176
+ * groups are kept only when recent (within STALE_MAGI_WINDOW_MS of the newest activity AND
177
+ * among the RECENT_MAGI_CAP most-recent). Freshness is measured relative to the newest group
178
+ * (not wall-clock) so the result is deterministic for a given input. Mirrors
179
+ * summarizeMeshAsyncRefineJobs.
180
+ */
181
+ export function summarizeMeshMagiActivity(
182
+ activity: MeshMagiActivitySummary[],
183
+ ): MeshMagiActivitySummaryFold {
184
+ const running: MeshMagiActivitySummary[] = [];
185
+ const synthesized: MeshMagiActivitySummary[] = [];
186
+ for (const g of activity) {
187
+ if (g.status === 'synthesized') synthesized.push(g);
188
+ else running.push(g);
189
+ }
190
+
191
+ let newest = 0;
192
+ for (const g of activity) newest = Math.max(newest, activityTime(g));
193
+ const cutoff = newest - STALE_MAGI_WINDOW_MS;
194
+
195
+ const synthesizedByRecency = [...synthesized].sort((a, b) => activityTime(b) - activityTime(a));
196
+ const freshSynthesized = synthesizedByRecency
197
+ .filter(g => activityTime(g) >= cutoff)
198
+ .slice(0, RECENT_MAGI_CAP);
199
+
200
+ const byStatus: Record<string, number> = {};
201
+ for (const g of [...running, ...freshSynthesized]) {
202
+ byStatus[g.status] = (byStatus[g.status] ?? 0) + 1;
203
+ }
204
+
205
+ // Running first (most actionable), then recent synthesized — both newest-first.
206
+ const runningByRecency = [...running].sort((a, b) => activityTime(b) - activityTime(a));
207
+ return {
208
+ total: running.length + freshSynthesized.length,
209
+ byStatus,
210
+ staleSynthesized: synthesized.length - freshSynthesized.length,
211
+ groups: [...runningByRecency, ...freshSynthesized],
212
+ };
213
+ }
214
+
215
+ /** Latest persisted synthesis activity for one consensusGroupId, or undefined. */
216
+ export function getMeshMagiActivityByGroup(
217
+ ledgerEntries: MeshLedgerEntry[],
218
+ consensusGroupId: string,
219
+ ): MeshMagiActivitySummary | undefined {
220
+ const key = readString(consensusGroupId);
221
+ if (!key) return undefined;
222
+ return buildMeshMagiActivity({ ledgerEntries }).find(g => g.consensusGroupId === key);
223
+ }
@@ -1041,15 +1041,36 @@ export class FsmDriver implements ISpecDriver {
1041
1041
  if (this.win32SubmitTimer) { clearTimeout(this.win32SubmitTimer); this.win32SubmitTimer = null; }
1042
1042
  const startedAt = Date.now();
1043
1043
 
1044
- // Distinctive whitespace-collapsed TAIL of the body what we look for in the
1045
- // composer to confirm the body landed. The tail (not the head) because a long
1046
- // multiline body scrolls its leading lines out of the composer viewport while
1047
- // the cursor and therefore the body's end stays visible. Empty body
1048
- // nothing to confirm.
1049
- const probe = normalizeForEcho(body).slice(-WIN32_ECHO_PROBE_CHARS);
1044
+ // FULL-BODY echo confirmation. A tail-only probe (slice(-N)) was insufficient
1045
+ // for multi-line prompts: a multiline body echoes line-by-line, so its TAIL can
1046
+ // appear in the composer (and the screen settle) while the HEAD / middle is still
1047
+ // arriving. Releasing the first CR on the tail alone then submits a PARTIAL body:
1048
+ // the early CR commits whatever has accumulated and the remainder is lost, so only
1049
+ // the trailing segment survives (the cross-machine MAGI replica receiving only the
1050
+ // prompt's boilerplate suffix). We now require BOTH the head probe AND the tail
1051
+ // probe to be present in the echo before releasing the CR.
1052
+ //
1053
+ // Crucially, the head check runs against snapshotWithScrollback(), NOT the visible
1054
+ // viewport: a tall body scrolls its leading lines off-screen (the very reason the
1055
+ // original gate used the tail), so the head is only ever observable in the
1056
+ // scrollback-inclusive buffer. The tail check stays on the visible snapshot (the
1057
+ // cursor / body end is always on screen). Both present ⇒ the whole body, head
1058
+ // through tail, has echoed — not just its end with a still-streaming middle.
1059
+ // Single-line / short bodies have overlapping head and tail probes and the
1060
+ // scrollback buffer is a superset of the viewport, so behaviour is unchanged. The
1061
+ // WIN32_ECHO_MAX_WAIT_MS blind-fire backstop below still bounds the wait so a body
1062
+ // that never fully confirms cannot hang.
1063
+ const normBody = normalizeForEcho(body);
1064
+ const headProbe = normBody.slice(0, WIN32_ECHO_PROBE_CHARS);
1065
+ const tailProbe = normBody.slice(-WIN32_ECHO_PROBE_CHARS);
1050
1066
  const bodyEchoed = (): boolean => {
1051
- if (!probe) return true;
1052
- return normalizeForEcho(this.adapter.snapshot()).includes(probe);
1067
+ if (!normBody) return true;
1068
+ // Tail on the visible viewport (cursor end is always on screen); head on the
1069
+ // scrollback-inclusive buffer (leading lines of a tall body scroll off-screen).
1070
+ const visible = normalizeForEcho(this.adapter.snapshot());
1071
+ if (!visible.includes(tailProbe)) return false;
1072
+ const full = normalizeForEcho(this.adapter.snapshotWithScrollback());
1073
+ return full.includes(headProbe);
1053
1074
  };
1054
1075
 
1055
1076
  const fire = (attempt: number): void => {