@dmsdc-ai/aigentry-telepty 0.1.97 → 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,97 @@
1
+ // src/prompt-symbol-registry.js — Per-CLI prompt-symbol detection (0.3.2)
2
+ // See docs/superpowers/specs/2026-04-26-prompt-symbol-render-gate.md
3
+ //
4
+ // Maps `session.command` (e.g. 'claude', 'codex', 'gemini') to a
5
+ // { symbol, byteSeq, detect(screen) → { found, line_index?, col? } }
6
+ // entry. The detect() function takes the rendered screen text from
7
+ // `cmux read-screen` (already terminal-state-applied; no ANSI stripping
8
+ // needed) and returns the LAST occurrence (closest to the bottom) so
9
+ // transcript echoes earlier in the viewport do not produce false positives.
10
+ //
11
+ // Adding a new CLI: append a new entry + write a unit test against a
12
+ // captured `cmux read-screen` sample.
13
+
14
+ 'use strict';
15
+
16
+ const ENTRIES = {
17
+ // claude renders an empty input row as "❯" + spaces, sandwiched between
18
+ // two horizontal-rule lines made of U+2500 ('─').
19
+ claude: {
20
+ symbol: '❯',
21
+ byteSeq: Buffer.from([0xE2, 0x9D, 0xAF]),
22
+ detect(screen) {
23
+ const lines = String(screen == null ? '' : screen).split('\n');
24
+ for (let i = lines.length - 1; i >= 1; i--) {
25
+ const line = lines[i];
26
+ if (!/^❯\s*$/.test(line)) continue;
27
+ const above = lines[i - 1] || '';
28
+ const below = lines[i + 1] || '';
29
+ if (above.includes('─') || below.includes('─')) {
30
+ return { found: true, line_index: i, col: line.indexOf('❯') + 1 };
31
+ }
32
+ }
33
+ return { found: false };
34
+ },
35
+ },
36
+ // codex renders idle as " › <placeholder>" (column 2). Status footer
37
+ // ("gpt-5.5 …" or "gpt-5 …") sits 1–2 lines below.
38
+ codex: {
39
+ symbol: '›',
40
+ byteSeq: Buffer.from([0xE2, 0x80, 0xBA]),
41
+ detect(screen) {
42
+ const lines = String(screen == null ? '' : screen).split('\n');
43
+ for (let i = lines.length - 1; i >= 0; i--) {
44
+ const line = lines[i];
45
+ if (!/^ › /.test(line)) continue;
46
+ const footer = (lines[i + 1] || '') + '\n' + (lines[i + 2] || '');
47
+ if (/gpt-\d/.test(footer)) {
48
+ return { found: true, line_index: i, col: 2 };
49
+ }
50
+ }
51
+ return { found: false };
52
+ },
53
+ },
54
+ // gemini empty input: " * Type your message or @path/to/file"
55
+ // gemini non-empty: " * <user typed text>"
56
+ // Geometry: bracketed by U+2580 ('▀') above and U+2584 ('▄') below.
57
+ gemini: {
58
+ symbol: '*',
59
+ byteSeq: Buffer.from([0x2A]),
60
+ detect(screen) {
61
+ const lines = String(screen == null ? '' : screen).split('\n');
62
+ for (let i = lines.length - 1; i >= 1; i--) {
63
+ const line = lines[i];
64
+ if (!/^ \* {2,}/.test(line)) continue;
65
+ const above = lines[i - 1] || '';
66
+ const below = lines[i + 1] || '';
67
+ if (above.includes('▀') || below.includes('▄')) {
68
+ return { found: true, line_index: i, col: 2 };
69
+ }
70
+ }
71
+ return { found: false };
72
+ },
73
+ },
74
+ };
75
+
76
+ // Normalize: strip path and args
77
+ // '/usr/local/bin/claude --resume' → 'claude'
78
+ // 'codex resume' → 'resume' (false negative — see note)
79
+ //
80
+ // The naive split/pop returns the LAST whitespace-or-slash-delimited token,
81
+ // which is correct for absolute paths but wrong for `<bin> <subcmd>` forms.
82
+ // We compensate by also trying the FIRST path-stripped token before falling
83
+ // back to the last token, matching whichever ENTRIES key exists.
84
+ function lookup(command) {
85
+ if (!command) return null;
86
+ const raw = String(command).trim();
87
+ if (!raw) return null;
88
+ const tokens = raw.split(/\s+/).filter(Boolean);
89
+ for (const tok of tokens) {
90
+ const base = tok.split('/').filter(Boolean).pop() || '';
91
+ const key = base.toLowerCase();
92
+ if (ENTRIES[key]) return ENTRIES[key];
93
+ }
94
+ return null;
95
+ }
96
+
97
+ module.exports = { lookup, ENTRIES };
@@ -0,0 +1,86 @@
1
+ // src/report-enforcement.js — REPORT enforcement helpers (0.2.0)
2
+ // See specs/enforce-report-spec.md
3
+ //
4
+ // Exports pure, testable helpers:
5
+ // - classifyReportPrompt(prompt): categorize an inject prompt
6
+ // - buildAutoSummary(session, opts): scrape last lines of output with redaction
7
+ // - ANSI_STRIPPER_RE, SECRET_DENYLIST_RE: regex constants (exported for tests)
8
+ // - REPORT_PREFIX_RE, REPORT_STATUS_*_RE: classification regexes
9
+
10
+ 'use strict';
11
+
12
+ // Prefix patterns that identify a content REPORT inject (reverse-match required)
13
+ const REPORT_PREFIX_RE = /^\s*(REPORT|STATUS|SPEC|OWNER-DIAGNOSIS|ENFORCE-SPEC|LOG-FIX-SPEC|LOG-FIX-IMPLEMENTED|FIX-SPEC|FIX-IMPLEMENTED|SPEC-SYNC|DIAGNOSIS|ENFORCE-IMPLEMENTED)[:\s]/;
14
+ const REPORT_STATUS_BLOCKED_RE = /^\s*STATUS:\s*blocked\b/i;
15
+ const REPORT_STATUS_DISMISSED_RE = /^\s*STATUS:\s*dismissed\b/i;
16
+ const REPORT_STATUS_ERROR_RE = /^\s*STATUS:\s*error\b/i;
17
+
18
+ // ANSI stripper (matches session-state.js)
19
+ const ANSI_STRIPPER_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b\[[\?]?[0-9;]*[hlm]/g;
20
+
21
+ // Secret denylist — redact common credential patterns
22
+ const SECRET_DENYLIST_RE = /(api[_-]?key\s*[:=]\s*\S+|password\s*[:=]\s*\S+|token\s*[:=]\s*\S+|secret\s*[:=]\s*\S+)/gi;
23
+
24
+ // Default config (overridable via options)
25
+ const DEFAULT_AUTO_SUMMARY_LINES = 40;
26
+ const DEFAULT_AUTO_SUMMARY_MAX_BYTES = 4096;
27
+
28
+ /**
29
+ * Classify incoming inject prompt for REPORT enforcement.
30
+ * Returns one of: 'report_dismissed', 'report_blocked', 'report_error',
31
+ * 'report_complete', or null (not a report).
32
+ *
33
+ * Order matters: STATUS variants checked before generic prefix.
34
+ */
35
+ function classifyReportPrompt(prompt) {
36
+ if (typeof prompt !== 'string') return null;
37
+ if (REPORT_STATUS_DISMISSED_RE.test(prompt)) return 'report_dismissed';
38
+ if (REPORT_STATUS_BLOCKED_RE.test(prompt)) return 'report_blocked';
39
+ if (REPORT_STATUS_ERROR_RE.test(prompt)) return 'report_error';
40
+ if (REPORT_PREFIX_RE.test(prompt)) return 'report_complete';
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Build an auto_summary from a session's output ring.
46
+ * - Strips ANSI sequences
47
+ * - Filters blank lines
48
+ * - Takes last N non-blank lines
49
+ * - Redacts secrets via denylist regex
50
+ * - Caps at max_bytes total (UTF-8 byte length)
51
+ *
52
+ * @param {Object} session — { outputRing: string[] }
53
+ * @param {Object} [options]
54
+ * @param {number} [options.maxLines] — default 40
55
+ * @param {number} [options.maxBytes] — default 4096
56
+ * @returns {string}
57
+ */
58
+ function buildAutoSummary(session, options = {}) {
59
+ const maxLines = options.maxLines || DEFAULT_AUTO_SUMMARY_LINES;
60
+ const maxBytes = options.maxBytes || DEFAULT_AUTO_SUMMARY_MAX_BYTES;
61
+ if (!session || !session.outputRing || session.outputRing.length === 0) return '';
62
+
63
+ const raw = session.outputRing.join('');
64
+ const stripped = raw.replace(ANSI_STRIPPER_RE, '');
65
+ const lines = stripped.split(/\r?\n/).map(l => l.trim()).filter(l => l.length > 0);
66
+ const tail = lines.slice(-maxLines);
67
+ let joined = tail.join('\n');
68
+ joined = joined.replace(SECRET_DENYLIST_RE, '[REDACTED]');
69
+ if (Buffer.byteLength(joined, 'utf8') > maxBytes) {
70
+ joined = joined.slice(0, maxBytes);
71
+ }
72
+ return joined;
73
+ }
74
+
75
+ module.exports = {
76
+ classifyReportPrompt,
77
+ buildAutoSummary,
78
+ REPORT_PREFIX_RE,
79
+ REPORT_STATUS_BLOCKED_RE,
80
+ REPORT_STATUS_DISMISSED_RE,
81
+ REPORT_STATUS_ERROR_RE,
82
+ ANSI_STRIPPER_RE,
83
+ SECRET_DENYLIST_RE,
84
+ DEFAULT_AUTO_SUMMARY_LINES,
85
+ DEFAULT_AUTO_SUMMARY_MAX_BYTES,
86
+ };
@@ -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
+ };