@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.
- package/CHANGELOG.md +326 -0
- package/CLAUDE.md +5 -1
- package/README.md +3 -0
- package/cli.js +109 -16
- package/daemon.js +399 -39
- package/docs/superpowers/specs/2026-04-26-inject-submit-enter-reliability.md +447 -0
- package/docs/superpowers/specs/2026-04-26-prompt-symbol-render-gate.md +571 -0
- package/docs/superpowers/specs/2026-04-26-submit-gate-fixes-v2.md +608 -0
- package/docs/superpowers/specs/2026-05-02-submit-force-and-retry.md +139 -0
- package/package.json +4 -4
- package/specs/enforce-report-spec.md +237 -0
- package/src/prompt-symbol-registry.js +97 -0
- package/src/report-enforcement.js +86 -0
- package/src/submit-gate.js +269 -0
|
@@ -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
|
+
};
|