@dmsdc-ai/aigentry-telepty 0.5.8 → 0.6.0
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 +82 -0
- package/cli.js +392 -30
- package/cross-machine.js +124 -1
- package/daemon-control.js +9 -0
- package/daemon.js +415 -17
- package/install.js +367 -62
- package/package.json +5 -5
- package/src/audit/inject-log.js +234 -0
- package/src/protocol/http-auth.js +36 -1
- package/src/submit-gate.js +130 -5
- package/src/transport/broker-client.js +498 -0
- package/src/transport/broker-protocol.js +155 -0
- package/src/transport/broker-server.js +505 -0
- package/src/win-resolve-executable.js +6 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// #43 P1 — inject audit spine.
|
|
4
|
+
//
|
|
5
|
+
// Component A in the spec (docs/specs/2026-06-09-inject-audit-provenance.md §3, §5, §8).
|
|
6
|
+
// Three concerns, pure Node only (§17 무의존 — `crypto`/`fs`/`events`, no external deps):
|
|
7
|
+
// 1. buildAuditLine(record) — pure builder for the JSONL schema v1 (one line/delivery).
|
|
8
|
+
// 2. createAuditWriter({...}) — bounded async append, size+age rotation, 0700 dir / 0600
|
|
9
|
+
// file, overflow→drop-oldest+event, NEVER fsync-block delivery.
|
|
10
|
+
// 3. readInjectLog(path, filters) — read/filter/paginate helper backing the P3 GET /api/injects.
|
|
11
|
+
//
|
|
12
|
+
// This is an AUDIT log, not a transactional ledger: no per-line fsync, bounded loss of the last
|
|
13
|
+
// in-flight batch on hard crash is accepted (spec §8). Rotation/prune is best-effort and never
|
|
14
|
+
// blocks the delivery hot path (append() is fire-and-forget; the writer drains on a timer).
|
|
15
|
+
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { EventEmitter } = require('events');
|
|
20
|
+
|
|
21
|
+
const SCHEMA_VERSION = 1;
|
|
22
|
+
const DEFAULT_PREVIEW_BYTES = 200;
|
|
23
|
+
const DEFAULT_QUEUE_MAX = 10000;
|
|
24
|
+
const DEFAULT_FLUSH_MS = 250;
|
|
25
|
+
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
26
|
+
const DEFAULT_MAX_FILES = 5;
|
|
27
|
+
const DEFAULT_MAX_AGE_DAYS = 30;
|
|
28
|
+
|
|
29
|
+
// Build one compact JSON line for a single delivery (schema v1, spec §5). Pure: the only
|
|
30
|
+
// inputs are `record` fields; sha256/byte-length/spoof_suspected are derived here so the
|
|
31
|
+
// builder is the single testable source of truth. Redaction default = hash-only: payload
|
|
32
|
+
// content is NEVER written unless `record.preview === true`, and then only truncated.
|
|
33
|
+
function buildAuditLine(record = {}) {
|
|
34
|
+
const payload = typeof record.payload === 'string' ? record.payload : '';
|
|
35
|
+
const claimed = record.claimed_from != null ? record.claimed_from : null;
|
|
36
|
+
const verified = record.verified_sender_sid != null ? record.verified_sender_sid : null;
|
|
37
|
+
const previewOn = record.preview === true;
|
|
38
|
+
const previewBytes = Number.isFinite(record.previewBytes) ? record.previewBytes : DEFAULT_PREVIEW_BYTES;
|
|
39
|
+
|
|
40
|
+
const line = {
|
|
41
|
+
v: SCHEMA_VERSION,
|
|
42
|
+
ts: record.ts || new Date().toISOString(),
|
|
43
|
+
inject_id: record.inject_id != null ? record.inject_id : null,
|
|
44
|
+
kind: record.kind || 'inject',
|
|
45
|
+
source: record.source || record.kind || 'inject',
|
|
46
|
+
claimed_from: claimed,
|
|
47
|
+
verified_sender_sid: verified,
|
|
48
|
+
spoof_suspected: !!(claimed && verified && claimed !== verified),
|
|
49
|
+
to: record.to != null ? record.to : null,
|
|
50
|
+
to_alias: record.to_alias != null ? record.to_alias : null,
|
|
51
|
+
origin: record.origin || 'trusted-local',
|
|
52
|
+
origin_host: record.origin_host != null ? record.origin_host : null,
|
|
53
|
+
ref_path: record.ref_path != null ? record.ref_path : null,
|
|
54
|
+
payload_sha256: crypto.createHash('sha256').update(payload).digest('hex'),
|
|
55
|
+
payload_bytes: Buffer.byteLength(payload),
|
|
56
|
+
payload_preview: previewOn ? truncatePreview(payload, previewBytes) : null,
|
|
57
|
+
delivery_result: record.delivery_result || 'success'
|
|
58
|
+
};
|
|
59
|
+
return JSON.stringify(line);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Truncate a preview to at most `previewBytes` characters — never the full payload. Slicing
|
|
63
|
+
// by character (not raw bytes) avoids splitting a multibyte sequence into invalid UTF-8.
|
|
64
|
+
function truncatePreview(payload, previewBytes) {
|
|
65
|
+
if (payload.length <= previewBytes) return payload;
|
|
66
|
+
return payload.slice(0, previewBytes);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Bounded async writer. append() pushes onto an in-memory FIFO and returns immediately; a
|
|
70
|
+
// timer drains the batch with a single appendFile (spec §8). Overflow drops the OLDEST and
|
|
71
|
+
// emits `audit_overflow` (silent truncation forbidden). Rotation/prune runs inside the drain,
|
|
72
|
+
// off the delivery hot path. Returns an EventEmitter-like object ({ append, flush, close, on }).
|
|
73
|
+
function createAuditWriter(options = {}) {
|
|
74
|
+
const filePath = options.path;
|
|
75
|
+
if (!filePath) throw new Error('createAuditWriter requires a path');
|
|
76
|
+
const queueMax = Number.isFinite(options.queueMax) ? options.queueMax : DEFAULT_QUEUE_MAX;
|
|
77
|
+
const flushMs = Number.isFinite(options.flushMs) ? options.flushMs : DEFAULT_FLUSH_MS;
|
|
78
|
+
const preview = options.preview === true;
|
|
79
|
+
const previewBytes = Number.isFinite(options.previewBytes) ? options.previewBytes : DEFAULT_PREVIEW_BYTES;
|
|
80
|
+
const maxBytes = Number.isFinite(options.maxBytes) ? options.maxBytes : DEFAULT_MAX_BYTES;
|
|
81
|
+
const maxFiles = Number.isFinite(options.maxFiles) ? options.maxFiles : DEFAULT_MAX_FILES;
|
|
82
|
+
const maxAgeDays = Number.isFinite(options.maxAgeDays) ? options.maxAgeDays : DEFAULT_MAX_AGE_DAYS;
|
|
83
|
+
|
|
84
|
+
const emitter = new EventEmitter();
|
|
85
|
+
let queue = [];
|
|
86
|
+
let timer = null;
|
|
87
|
+
let writing = false;
|
|
88
|
+
let closed = false;
|
|
89
|
+
let droppedTotal = 0;
|
|
90
|
+
|
|
91
|
+
ensureDir();
|
|
92
|
+
|
|
93
|
+
function ensureDir() {
|
|
94
|
+
const dir = path.dirname(filePath);
|
|
95
|
+
try {
|
|
96
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
97
|
+
fs.chmodSync(dir, 0o700); // tighten even if the dir pre-existed with a looser mode
|
|
98
|
+
} catch { /* best-effort; surfaced as audit_error at first write if truly broken */ }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fire-and-forget: NEVER returns a promise to the caller — the delivery path must not await.
|
|
102
|
+
function append(record) {
|
|
103
|
+
if (closed) return;
|
|
104
|
+
if (queue.length >= queueMax) {
|
|
105
|
+
queue.shift(); // drop oldest
|
|
106
|
+
droppedTotal += 1;
|
|
107
|
+
emitter.emit('audit_overflow', { dropped: droppedTotal, queueMax });
|
|
108
|
+
}
|
|
109
|
+
queue.push({
|
|
110
|
+
...record,
|
|
111
|
+
preview: record.preview != null ? record.preview : preview,
|
|
112
|
+
previewBytes: record.previewBytes != null ? record.previewBytes : previewBytes
|
|
113
|
+
});
|
|
114
|
+
scheduleFlush();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function scheduleFlush() {
|
|
118
|
+
if (timer || writing || queue.length === 0) return;
|
|
119
|
+
timer = setTimeout(() => { timer = null; flush().catch(() => {}); }, flushMs);
|
|
120
|
+
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function flush() {
|
|
124
|
+
if (writing || queue.length === 0) return;
|
|
125
|
+
writing = true;
|
|
126
|
+
const batch = queue;
|
|
127
|
+
queue = [];
|
|
128
|
+
try {
|
|
129
|
+
await rotateIfNeeded();
|
|
130
|
+
const data = batch.map((r) => buildAuditLine(r)).join('\n') + '\n';
|
|
131
|
+
await fs.promises.appendFile(filePath, data, { mode: 0o600 });
|
|
132
|
+
// appendFile's mode only applies on create — chmod each flush so a pre-existing file
|
|
133
|
+
// (or umask-loosened create) is forced to 0600 (the file may carry preview content).
|
|
134
|
+
try { await fs.promises.chmod(filePath, 0o600); } catch { /* best-effort */ }
|
|
135
|
+
} catch (err) {
|
|
136
|
+
emitter.emit('audit_error', err);
|
|
137
|
+
} finally {
|
|
138
|
+
writing = false;
|
|
139
|
+
if (queue.length > 0) scheduleFlush();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function rotateIfNeeded() {
|
|
144
|
+
let size = 0;
|
|
145
|
+
try { size = (await fs.promises.stat(filePath)).size; } catch { size = 0; }
|
|
146
|
+
if (size >= maxBytes) {
|
|
147
|
+
// Shift .{maxFiles-1}→.{maxFiles} … .1→.2, dropping the oldest beyond maxFiles, then
|
|
148
|
+
// active→.1. rename overwrites, so the file falling off the top is discarded.
|
|
149
|
+
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
150
|
+
try { await fs.promises.rename(`${filePath}.${i}`, `${filePath}.${i + 1}`); } catch { /* gap */ }
|
|
151
|
+
}
|
|
152
|
+
try { await fs.promises.rename(filePath, `${filePath}.1`); } catch { /* nothing to rotate */ }
|
|
153
|
+
}
|
|
154
|
+
await pruneByAge();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function pruneByAge() {
|
|
158
|
+
if (!(maxAgeDays > 0)) return;
|
|
159
|
+
const cutoff = Date.now() - maxAgeDays * 86400000;
|
|
160
|
+
for (let i = 1; i <= maxFiles; i++) {
|
|
161
|
+
const rotated = `${filePath}.${i}`;
|
|
162
|
+
try {
|
|
163
|
+
const st = await fs.promises.stat(rotated);
|
|
164
|
+
if (st.mtimeMs < cutoff) await fs.promises.unlink(rotated);
|
|
165
|
+
} catch { /* absent or busy — best-effort */ }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function close() {
|
|
170
|
+
closed = true;
|
|
171
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
172
|
+
await flush();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
append,
|
|
177
|
+
flush,
|
|
178
|
+
close,
|
|
179
|
+
on: emitter.on.bind(emitter),
|
|
180
|
+
off: emitter.off.bind(emitter)
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function toMs(value) {
|
|
185
|
+
if (value == null) return null;
|
|
186
|
+
if (typeof value === 'number') return value < 1e12 ? value * 1000 : value;
|
|
187
|
+
const s = String(value).trim();
|
|
188
|
+
if (/^\d+$/.test(s)) {
|
|
189
|
+
const n = Number(s);
|
|
190
|
+
return n < 1e12 ? n * 1000 : n;
|
|
191
|
+
}
|
|
192
|
+
const parsed = Date.parse(s);
|
|
193
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Filter an array of parsed records (spec §7). `from` matches claimed_from OR
|
|
197
|
+
// verified_sender_sid; `to` matches the resolved sid OR the pre-resolution alias.
|
|
198
|
+
function matchInjects(records, filters = {}) {
|
|
199
|
+
let out = records;
|
|
200
|
+
const since = toMs(filters.since);
|
|
201
|
+
const until = toMs(filters.until);
|
|
202
|
+
if (since != null) out = out.filter((r) => { const t = toMs(r.ts); return t != null && t >= since; });
|
|
203
|
+
if (until != null) out = out.filter((r) => { const t = toMs(r.ts); return t != null && t <= until; });
|
|
204
|
+
if (filters.to) out = out.filter((r) => r.to === filters.to || r.to_alias === filters.to);
|
|
205
|
+
if (filters.from) out = out.filter((r) => r.claimed_from === filters.from || r.verified_sender_sid === filters.from);
|
|
206
|
+
if (filters.spoof) out = out.filter((r) => r.spoof_suspected === true);
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Read the jsonl, filter, return newest-first, paginate by cursor (line offset into the
|
|
211
|
+
// filtered newest-first list). Bounded by `limit` (default 200). Absent file → empty result.
|
|
212
|
+
function readInjectLog(filePath, options = {}) {
|
|
213
|
+
const limit = Math.min(Math.max(Number(options.limit) || 200, 1), 1000);
|
|
214
|
+
const cursor = Number(options.cursor) > 0 ? Number(options.cursor) : 0;
|
|
215
|
+
let raw = '';
|
|
216
|
+
try { raw = fs.readFileSync(filePath, 'utf8'); } catch { return { injects: [], next_cursor: null }; }
|
|
217
|
+
|
|
218
|
+
const records = raw.split('\n').filter(Boolean).map((l) => {
|
|
219
|
+
try { return JSON.parse(l); } catch { return null; }
|
|
220
|
+
}).filter(Boolean);
|
|
221
|
+
|
|
222
|
+
const filtered = matchInjects(records, options);
|
|
223
|
+
filtered.reverse(); // newest first
|
|
224
|
+
const page = filtered.slice(cursor, cursor + limit);
|
|
225
|
+
const next = cursor + limit < filtered.length ? cursor + limit : null;
|
|
226
|
+
return { injects: page, next_cursor: next };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
buildAuditLine,
|
|
231
|
+
createAuditWriter,
|
|
232
|
+
readInjectLog,
|
|
233
|
+
matchInjects
|
|
234
|
+
};
|
|
@@ -21,6 +21,38 @@ function createVerifyJwt(JWT_SECRET) {
|
|
|
21
21
|
return verifyJwt;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
function createBrokerAcl(aclTable = {}) {
|
|
25
|
+
return {
|
|
26
|
+
canInject(fromNode, toNode) {
|
|
27
|
+
if (!fromNode || !toNode) return false;
|
|
28
|
+
const allowedTargets = aclTable[fromNode];
|
|
29
|
+
if (Array.isArray(allowedTargets)) return allowedTargets.includes(toNode);
|
|
30
|
+
if (allowedTargets instanceof Set) return allowedTargets.has(toNode);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function signNodeJwt(secret, claims) {
|
|
37
|
+
if (!secret) throw new Error('JWT secret is required');
|
|
38
|
+
if (!claims || typeof claims !== 'object') throw new Error('JWT claims are required');
|
|
39
|
+
|
|
40
|
+
const headerB64 = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
|
|
41
|
+
const payloadB64 = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
42
|
+
const sigB64 = crypto.createHmac('sha256', secret)
|
|
43
|
+
.update(`${headerB64}.${payloadB64}`)
|
|
44
|
+
.digest('base64url');
|
|
45
|
+
return `${headerB64}.${payloadB64}.${sigB64}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isRevokedNode(revokedNodes, decodedJwtOrSub) {
|
|
49
|
+
const sub = typeof decodedJwtOrSub === 'string' ? decodedJwtOrSub : decodedJwtOrSub && decodedJwtOrSub.sub;
|
|
50
|
+
if (!sub || !revokedNodes) return false;
|
|
51
|
+
if (Array.isArray(revokedNodes)) return revokedNodes.includes(sub);
|
|
52
|
+
if (revokedNodes instanceof Set) return revokedNodes.has(sub);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
24
56
|
function createIsAllowedPeer(PEER_ALLOWLIST) {
|
|
25
57
|
function isAllowedPeer(ip) {
|
|
26
58
|
if (!ip) return false;
|
|
@@ -66,6 +98,9 @@ function createAuthMiddleware(options) {
|
|
|
66
98
|
|
|
67
99
|
module.exports = {
|
|
68
100
|
createAuthMiddleware,
|
|
101
|
+
createBrokerAcl,
|
|
69
102
|
createIsAllowedPeer,
|
|
70
|
-
createVerifyJwt
|
|
103
|
+
createVerifyJwt,
|
|
104
|
+
isRevokedNode,
|
|
105
|
+
signNodeJwt
|
|
71
106
|
};
|
package/src/submit-gate.js
CHANGED
|
@@ -198,6 +198,7 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
|
|
|
198
198
|
|
|
199
199
|
let lastVisibility = null;
|
|
200
200
|
let everVisible = false;
|
|
201
|
+
let everObservedOutput = false;
|
|
201
202
|
|
|
202
203
|
while (true) {
|
|
203
204
|
const state = getState ? getState() : null;
|
|
@@ -214,6 +215,7 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
|
|
|
214
215
|
|
|
215
216
|
const visibility = observeBodyVisibility(session, bodyText, opts);
|
|
216
217
|
lastVisibility = visibility;
|
|
218
|
+
if (visibility.observable && visibility.empty === false) everObservedOutput = true;
|
|
217
219
|
if (visibility.reason === 'empty_body') {
|
|
218
220
|
return {
|
|
219
221
|
accepted: true,
|
|
@@ -235,22 +237,67 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
|
|
|
235
237
|
}
|
|
236
238
|
if (visibility.visible) {
|
|
237
239
|
everVisible = true;
|
|
238
|
-
} else {
|
|
240
|
+
} else if (everVisible) {
|
|
241
|
+
// Body was visible then disappeared — the CR consumed the input line.
|
|
242
|
+
return {
|
|
243
|
+
accepted: true,
|
|
244
|
+
retryable: false,
|
|
245
|
+
waited_ms: now() - start,
|
|
246
|
+
reason: 'body_consumed',
|
|
247
|
+
visibility,
|
|
248
|
+
};
|
|
249
|
+
} else if (!getState) {
|
|
250
|
+
// No state probe → preserve the optimistic body-absent accept so callers
|
|
251
|
+
// without a sessionStateManager keep the prior screen-free behavior.
|
|
239
252
|
return {
|
|
240
253
|
accepted: true,
|
|
241
254
|
retryable: false,
|
|
242
255
|
waited_ms: now() - start,
|
|
243
|
-
reason:
|
|
256
|
+
reason: 'body_absent',
|
|
244
257
|
visibility,
|
|
245
258
|
};
|
|
246
259
|
}
|
|
260
|
+
// else: body never observably present AND a state probe IS available — do NOT
|
|
261
|
+
// optimistically accept on absence. A dropped CR on codex alt-screen renders
|
|
262
|
+
// the body OFF the outputRing tail, so absence is not positive submit evidence
|
|
263
|
+
// (#568 FM3). Keep polling within the window for the primary signal — a state
|
|
264
|
+
// transition idle→working/thinking. If none arrives, fall through to no_land.
|
|
247
265
|
|
|
248
266
|
if (now() - start >= timeoutMs) {
|
|
267
|
+
if (visibility.visible) {
|
|
268
|
+
// Body stayed in the input box the whole window — the CR was not consumed.
|
|
269
|
+
return {
|
|
270
|
+
accepted: false,
|
|
271
|
+
retryable: true,
|
|
272
|
+
waited_ms: now() - start,
|
|
273
|
+
reason: 'body_still_visible',
|
|
274
|
+
visibility,
|
|
275
|
+
state: state || undefined,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (everObservedOutput) {
|
|
279
|
+
// The terminal produced output (it is alive) but the body was never consumed
|
|
280
|
+
// AND no state transition occurred — the CR did not land (#568 FM3, e.g. a
|
|
281
|
+
// dropped CR on codex alt-screen). Truthful retryable failure, never a false
|
|
282
|
+
// success on an unsent CR.
|
|
283
|
+
return {
|
|
284
|
+
accepted: false,
|
|
285
|
+
retryable: true,
|
|
286
|
+
waited_ms: now() - start,
|
|
287
|
+
reason: 'no_land',
|
|
288
|
+
visibility,
|
|
289
|
+
state: state || undefined,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
// No observable terminal output at all for the whole window — we have zero
|
|
293
|
+
// screen evidence either way. Preserve the long-standing optimistic accept
|
|
294
|
+
// (back-compat: a wrapped session that never echoes), but mark it ambiguous.
|
|
249
295
|
return {
|
|
250
|
-
accepted:
|
|
251
|
-
retryable:
|
|
296
|
+
accepted: true,
|
|
297
|
+
retryable: false,
|
|
252
298
|
waited_ms: now() - start,
|
|
253
|
-
reason: '
|
|
299
|
+
reason: 'no_observable',
|
|
300
|
+
ambiguous: true,
|
|
254
301
|
visibility,
|
|
255
302
|
state: state || undefined,
|
|
256
303
|
};
|
|
@@ -260,6 +307,78 @@ async function confirmSubmitAccepted(session, bodyText, opts = {}) {
|
|
|
260
307
|
}
|
|
261
308
|
}
|
|
262
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Render-gate the submit CR (#568). Resolve `ready` only when the input is
|
|
312
|
+
* settled enough to safely receive a bare 0x0D:
|
|
313
|
+
* - the injected body is echoed in the outputRing tail (the input is present), AND
|
|
314
|
+
* - the render has gone quiet — the tail is unchanged for ≥ quietWindowMs.
|
|
315
|
+
*
|
|
316
|
+
* This closes the FM1 busy-render race: the pre-#568 submit fired the CR with no
|
|
317
|
+
* readiness gate, so under load the CR landed mid-render and the TUI dropped it.
|
|
318
|
+
* The daemon applies the same gate before each retry CR (FM2).
|
|
319
|
+
*
|
|
320
|
+
* Bounded + best-effort: if the render never goes quiet within timeoutMs (e.g. a
|
|
321
|
+
* continuous spinner), resolve { ready:false, reason:'timeout' } so the caller
|
|
322
|
+
* STILL writes the CR — never worse than the pre-gate behavior. When the body
|
|
323
|
+
* never echoes into the tail (alt-screen / non-echoing TUI), settle on the quiet
|
|
324
|
+
* window alone once echoGraceMs has elapsed (reason:'settled_no_echo').
|
|
325
|
+
*
|
|
326
|
+
* Pure: outputRing-only, DI now/sleep — no I/O, no daemon coupling.
|
|
327
|
+
*
|
|
328
|
+
* @param {{ outputRing?: string[] }} session
|
|
329
|
+
* @param {string} bodyText
|
|
330
|
+
* @param {{ timeoutMs?: number, quietWindowMs?: number, echoGraceMs?: number, pollIntervalMs?: number, tailBytes?: number, stripAnsi?: Function, now?: Function, sleep?: Function }} [opts]
|
|
331
|
+
* @returns {Promise<{ ready: boolean, reason: string, echoed: boolean, settled: boolean, waited_ms: number }>}
|
|
332
|
+
*/
|
|
333
|
+
async function awaitInputSettled(session, bodyText, opts = {}) {
|
|
334
|
+
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 1500;
|
|
335
|
+
const quietWindowMs = Number.isFinite(opts.quietWindowMs) ? opts.quietWindowMs : 100;
|
|
336
|
+
const echoGraceMs = Number.isFinite(opts.echoGraceMs) ? opts.echoGraceMs : 400;
|
|
337
|
+
const pollIntervalMs = Number.isFinite(opts.pollIntervalMs) ? opts.pollIntervalMs : 30;
|
|
338
|
+
const tailBytes = Number.isFinite(opts.tailBytes) ? opts.tailBytes : 8192;
|
|
339
|
+
const stripAnsi = typeof opts.stripAnsi === 'function' ? opts.stripAnsi : (s) => s;
|
|
340
|
+
const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
|
|
341
|
+
const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
|
|
342
|
+
|
|
343
|
+
const needle = normalize(bodyText);
|
|
344
|
+
if (!needle) {
|
|
345
|
+
return { ready: true, reason: 'empty_body', echoed: false, settled: true, waited_ms: 0 };
|
|
346
|
+
}
|
|
347
|
+
if (!session || !Array.isArray(session.outputRing)) {
|
|
348
|
+
// No ring to observe — cannot gate; stay optimistic and never block the CR.
|
|
349
|
+
return { ready: true, reason: 'no_ring', echoed: false, settled: false, waited_ms: 0 };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const start = now();
|
|
353
|
+
let lastTail = null;
|
|
354
|
+
let lastChangeAt = start;
|
|
355
|
+
let everEchoed = false;
|
|
356
|
+
|
|
357
|
+
while (true) {
|
|
358
|
+
const tail = normalize(stripAnsi(readTail(session, tailBytes)));
|
|
359
|
+
if (tail.indexOf(needle) !== -1) everEchoed = true;
|
|
360
|
+
|
|
361
|
+
if (lastTail === null || tail !== lastTail) {
|
|
362
|
+
// Render still active — reset the quiet-window timer.
|
|
363
|
+
lastTail = tail;
|
|
364
|
+
lastChangeAt = now();
|
|
365
|
+
} else if (now() - lastChangeAt >= quietWindowMs) {
|
|
366
|
+
// Tail unchanged for the full quiet window → render settled.
|
|
367
|
+
if (everEchoed) {
|
|
368
|
+
return { ready: true, reason: 'settled', echoed: true, settled: true, waited_ms: now() - start };
|
|
369
|
+
}
|
|
370
|
+
if (now() - start >= echoGraceMs) {
|
|
371
|
+
return { ready: true, reason: 'settled_no_echo', echoed: false, settled: true, waited_ms: now() - start };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (now() - start >= timeoutMs) {
|
|
376
|
+
return { ready: false, reason: 'timeout', echoed: everEchoed, settled: false, waited_ms: now() - start };
|
|
377
|
+
}
|
|
378
|
+
await sleep(pollIntervalMs);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
263
382
|
function isAcceptedSubmitState(state, submittedAtMs) {
|
|
264
383
|
if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
|
|
265
384
|
if (!Number.isFinite(submittedAtMs)) {
|
|
@@ -292,6 +411,7 @@ function observeBodyVisibility(session, bodyText, opts = {}) {
|
|
|
292
411
|
observable: true,
|
|
293
412
|
visible: haystack.indexOf(needle) !== -1,
|
|
294
413
|
source: 'screen',
|
|
414
|
+
empty: haystack.length === 0,
|
|
295
415
|
};
|
|
296
416
|
}
|
|
297
417
|
|
|
@@ -305,6 +425,10 @@ function observeBodyVisibility(session, bodyText, opts = {}) {
|
|
|
305
425
|
observable: true,
|
|
306
426
|
visible: haystack.indexOf(needle) !== -1,
|
|
307
427
|
source: 'output_ring',
|
|
428
|
+
// #568: distinguish "terminal alive but body off-screen" (empty=false → a
|
|
429
|
+
// dropped CR is no_land) from "no screen evidence at all" (empty=true → stay
|
|
430
|
+
// optimistic). Used by confirmSubmitAccepted's bounded timeout fallback.
|
|
431
|
+
empty: haystack.length === 0,
|
|
308
432
|
};
|
|
309
433
|
}
|
|
310
434
|
|
|
@@ -425,6 +549,7 @@ function defaultReadScreen(workspaceId, lines) {
|
|
|
425
549
|
|
|
426
550
|
module.exports = {
|
|
427
551
|
awaitReplReady,
|
|
552
|
+
awaitInputSettled,
|
|
428
553
|
verifyBodyConsumed,
|
|
429
554
|
confirmSubmitAccepted,
|
|
430
555
|
observeBodyVisibility,
|