@dmsdc-ai/aigentry-telepty 0.5.9 → 0.6.1

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.
@@ -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
  };
@@ -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: everVisible ? 'body_consumed' : 'body_absent',
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: false,
251
- retryable: true,
296
+ accepted: true,
297
+ retryable: false,
252
298
  waited_ms: now() - start,
253
- reason: 'body_still_visible',
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,