@dmsdc-ai/aigentry-telepty 0.1.98 → 0.3.3

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.
@@ -0,0 +1,269 @@
1
+ // src/submit-gate.js — Render-gated submit helpers (0.3.0)
2
+ // See docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md
3
+ //
4
+ // Pure helpers (no I/O, no module-level state) used by daemon.js POST /submit
5
+ // to close the open-loop trap where Enter is fired before the target REPL is
6
+ // ready to consume it.
7
+ //
8
+ // Exports:
9
+ // - awaitReplReady(sessionId, stateManager, opts) → Promise<{ ready, last_state, waited_ms, reason? }>
10
+ // - verifyBodyConsumed(session, bodyText, opts) → Promise<{ consumed, waited_ms, reason? }>
11
+ // - isReady(state, minConfidence) (test surface)
12
+ // - isFailed(state) (test surface)
13
+ // - READY_STATES, FAIL_STATES (test surface)
14
+
15
+ 'use strict';
16
+
17
+ // States where the REPL is willing to accept a keystroke.
18
+ // `idle` — prompt detected, silence + (OSC 133 OR matched prompt pattern)
19
+ // `waiting` — interactive prompt (y/n, password, etc.) — Enter still applies
20
+ const READY_STATES = new Set(['idle', 'waiting']);
21
+
22
+ // States where waiting will never produce readiness; resolve immediately.
23
+ const FAIL_STATES = new Set(['dead', 'error', 'restarting']);
24
+
25
+ function isReady(state, minConfidence) {
26
+ if (!state) return false;
27
+ if (!READY_STATES.has(state.state)) return false;
28
+ if (typeof state.confidence === 'number' && state.confidence < minConfidence) return false;
29
+ return true;
30
+ }
31
+
32
+ function isFailed(state) {
33
+ return !!(state && FAIL_STATES.has(state.state));
34
+ }
35
+
36
+ /**
37
+ * Wait until the session's REPL is ready to accept Enter.
38
+ *
39
+ * Resolves immediately when the session is already in a READY_STATES with
40
+ * confidence ≥ minConfidence, or when the state is unrecoverable (FAIL_STATES).
41
+ * Otherwise listens for transitions until the session reaches readiness or
42
+ * the bounded timeout elapses.
43
+ *
44
+ * @param {string} sessionId
45
+ * @param {{ getState: Function, onTransition: Function }} stateManager
46
+ * @param {{ timeoutMs?: number, minConfidence?: number }} [opts]
47
+ * @returns {Promise<{ ready: boolean, last_state: string|null, waited_ms: number, reason?: string }>}
48
+ */
49
+ function awaitReplReady(sessionId, stateManager, opts = {}) {
50
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 5000;
51
+ // Default 0.5: below the lowest legitimate IDLE confidence (0.6, the
52
+ // silence-fallback emit at session-state.js:380) with explicit margin.
53
+ // Admits AI-CLI TUIs that emit no OSC 133 and whose Unicode-box input
54
+ // line does not match PROMPT_PATTERNS — the dominant fresh-spawn case.
55
+ // Per-request override via `min_confidence` body field on POST /submit.
56
+ // See: docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md §2.2
57
+ const minConfidence = Number.isFinite(opts.minConfidence) ? opts.minConfidence : 0.5;
58
+ const start = Date.now();
59
+
60
+ if (!stateManager || typeof stateManager.getState !== 'function') {
61
+ return Promise.resolve({ ready: false, reason: 'no_state_manager', last_state: null, waited_ms: 0 });
62
+ }
63
+
64
+ const initial = stateManager.getState(sessionId);
65
+ if (!initial) {
66
+ return Promise.resolve({ ready: false, reason: 'no_state', last_state: null, waited_ms: 0 });
67
+ }
68
+ if (isReady(initial, minConfidence)) {
69
+ return Promise.resolve({ ready: true, last_state: initial.state, waited_ms: 0 });
70
+ }
71
+ if (isFailed(initial)) {
72
+ return Promise.resolve({
73
+ ready: false,
74
+ reason: `session_${initial.state}`,
75
+ last_state: initial.state,
76
+ waited_ms: 0,
77
+ });
78
+ }
79
+
80
+ return new Promise((resolve) => {
81
+ let settled = false;
82
+
83
+ const finish = (result) => {
84
+ if (settled) return;
85
+ settled = true;
86
+ clearTimeout(timer);
87
+ resolve({ ...result, waited_ms: Date.now() - start });
88
+ };
89
+
90
+ // stateManager.onTransition is add-only (no removal API). We make the
91
+ // listener idempotent via the `settled` flag so it harmlessly no-ops
92
+ // after this call resolves.
93
+ const handler = (id, _from, to) => {
94
+ if (settled) return;
95
+ if (id !== sessionId) return;
96
+ if (READY_STATES.has(to)) {
97
+ const cur = stateManager.getState(sessionId);
98
+ if (isReady(cur, minConfidence)) {
99
+ finish({ ready: true, last_state: to });
100
+ }
101
+ } else if (FAIL_STATES.has(to)) {
102
+ finish({ ready: false, reason: `session_${to}`, last_state: to });
103
+ }
104
+ };
105
+
106
+ stateManager.onTransition(handler);
107
+
108
+ const timer = setTimeout(() => {
109
+ const cur = stateManager.getState(sessionId);
110
+ finish({ ready: false, reason: 'timeout', last_state: cur ? cur.state : null });
111
+ }, timeoutMs);
112
+ });
113
+ }
114
+
115
+ /**
116
+ * Verify that the inject body has been consumed (i.e., disappeared from the
117
+ * input box) by polling the session's outputRing tail.
118
+ *
119
+ * Semantics:
120
+ * - body never visible in tail (ANSI-heavy render, line wrap, truncation):
121
+ * return { consumed: true, waited_ms: 0, reason: 'never_visible' }
122
+ * This is optimistic — without screen evidence we trust the dispatch.
123
+ * - body visible, then disappears: { consumed: true }
124
+ * - body visible for the entire timeout: { consumed: false, reason: 'still_visible' }
125
+ *
126
+ * @param {{ outputRing?: string[] }} session
127
+ * @param {string} bodyText
128
+ * @param {{ timeoutMs?: number, intervalMs?: number, tailBytes?: number, stripAnsi?: Function, now?: Function, sleep?: Function }} [opts]
129
+ * @returns {Promise<{ consumed: boolean, waited_ms: number, reason?: string }>}
130
+ */
131
+ async function verifyBodyConsumed(session, bodyText, opts = {}) {
132
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 1500;
133
+ const intervalMs = Number.isFinite(opts.intervalMs) ? opts.intervalMs : 200;
134
+ const tailBytes = Number.isFinite(opts.tailBytes) ? opts.tailBytes : 8192;
135
+ const stripAnsi = typeof opts.stripAnsi === 'function' ? opts.stripAnsi : (s) => s;
136
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
137
+ const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
138
+
139
+ if (!session || !Array.isArray(session.outputRing)) {
140
+ return { consumed: false, reason: 'no_ring', waited_ms: 0 };
141
+ }
142
+
143
+ const needle = normalize(bodyText);
144
+ if (!needle) {
145
+ return { consumed: true, reason: 'empty_body', waited_ms: 0 };
146
+ }
147
+
148
+ const start = now();
149
+ let everSeen = false;
150
+
151
+ while (true) {
152
+ const haystack = normalize(stripAnsi(readTail(session, tailBytes)));
153
+ const visible = haystack.indexOf(needle) !== -1;
154
+ if (visible) {
155
+ everSeen = true;
156
+ } else {
157
+ return {
158
+ consumed: true,
159
+ waited_ms: now() - start,
160
+ reason: everSeen ? 'consumed' : 'never_visible',
161
+ };
162
+ }
163
+ if (now() - start >= timeoutMs) {
164
+ return { consumed: false, reason: 'still_visible', waited_ms: now() - start };
165
+ }
166
+ await sleep(intervalMs);
167
+ }
168
+ }
169
+
170
+ function normalize(s) {
171
+ return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
172
+ }
173
+
174
+ function readTail(session, maxBytes) {
175
+ if (!session.outputRing || session.outputRing.length === 0) return '';
176
+ let total = 0;
177
+ const parts = [];
178
+ for (let i = session.outputRing.length - 1; i >= 0 && total < maxBytes; i--) {
179
+ const chunk = session.outputRing[i];
180
+ parts.unshift(chunk);
181
+ total += chunk.length;
182
+ }
183
+ return parts.join('');
184
+ }
185
+
186
+ /**
187
+ * Layer 3 (0.3.2+): poll the rendered terminal screen via `cmux read-screen`
188
+ * for the per-CLI prompt symbol and resolve only when the symbol has been
189
+ * stably rendered for ≥ stabilityMs. Layered ABOVE awaitReplReady — strictly
190
+ * additive: skips cleanly on non-cmux backends and unknown CLIs.
191
+ *
192
+ * Resolution shape:
193
+ * - { ready: true, last_seen_at, waited_ms }
194
+ * - { ready: false, reason: 'no_screen_primitive', waited_ms: 0 } // skip
195
+ * - { ready: false, reason: 'unknown_cli', waited_ms: 0 } // skip
196
+ * - { ready: false, reason: 'no_prompt_symbol_seen', waited_ms } // best-effort fall-through
197
+ *
198
+ * @param {{ backend?: string, cmuxWorkspaceId?: string|null, command?: string }} session
199
+ * @param {{ timeoutMs?: number, pollIntervalMs?: number, stabilityMs?: number, tailLines?: number, readScreen?: Function, registry?: { lookup: Function }, now?: Function, sleep?: Function }} [opts]
200
+ * @returns {Promise<{ ready: boolean, waited_ms: number, last_seen_at?: number, reason?: string }>}
201
+ */
202
+ async function awaitPromptSymbol(session, opts = {}) {
203
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 8000;
204
+ const pollIntervalMs = Number.isFinite(opts.pollIntervalMs) ? opts.pollIntervalMs : 150;
205
+ const stabilityMs = Number.isFinite(opts.stabilityMs) ? opts.stabilityMs : 200;
206
+ const tailLines = Number.isFinite(opts.tailLines) ? opts.tailLines : 30;
207
+ const readScreen = typeof opts.readScreen === 'function' ? opts.readScreen : defaultReadScreen;
208
+ const registry = opts.registry || require('./prompt-symbol-registry');
209
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
210
+ const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
211
+
212
+ if (!session || session.backend !== 'cmux' || !session.cmuxWorkspaceId) {
213
+ return { ready: false, reason: 'no_screen_primitive', waited_ms: 0 };
214
+ }
215
+ const entry = registry.lookup(session.command);
216
+ if (!entry) {
217
+ return { ready: false, reason: 'unknown_cli', waited_ms: 0 };
218
+ }
219
+
220
+ const start = now();
221
+ let lastSeenAt = null;
222
+ while (true) {
223
+ const screen = readScreen(session.cmuxWorkspaceId, tailLines);
224
+ if (screen) {
225
+ const match = entry.detect(screen);
226
+ if (match && match.found) {
227
+ if (lastSeenAt === null) {
228
+ lastSeenAt = now();
229
+ } else if (now() - lastSeenAt >= stabilityMs) {
230
+ return { ready: true, last_seen_at: lastSeenAt, waited_ms: now() - start };
231
+ }
232
+ } else {
233
+ // symbol disappeared — reset the stability streak
234
+ lastSeenAt = null;
235
+ }
236
+ }
237
+ if (now() - start >= timeoutMs) {
238
+ return { ready: false, reason: 'no_prompt_symbol_seen', waited_ms: now() - start };
239
+ }
240
+ await sleep(pollIntervalMs);
241
+ }
242
+ }
243
+
244
+ function defaultReadScreen(workspaceId, lines) {
245
+ const { execSync } = require('child_process');
246
+ try {
247
+ const out = execSync(
248
+ `cmux read-screen --workspace ${workspaceId} --lines ${lines}`,
249
+ { timeout: 1000, stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 1 << 20 }
250
+ );
251
+ return out.toString('utf8');
252
+ } catch (_err) {
253
+ // cmux missing, workspace closed, permission denied — skip silently and
254
+ // let the caller decide (typically: poll again until timeout, then fall
255
+ // through to Layer 1).
256
+ return '';
257
+ }
258
+ }
259
+
260
+ module.exports = {
261
+ awaitReplReady,
262
+ verifyBodyConsumed,
263
+ awaitPromptSymbol,
264
+ defaultReadScreen,
265
+ isReady,
266
+ isFailed,
267
+ READY_STATES,
268
+ FAIL_STATES,
269
+ };