@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.
- package/CHANGELOG.md +326 -0
- package/CLAUDE.md +5 -1
- package/README.md +3 -0
- package/cli.js +109 -16
- package/daemon.js +431 -42
- 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/codex-inject-spec.md +201 -0
- 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,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
|
+
};
|