@dmsdc-ai/aigentry-telepty 0.6.3 → 0.6.5

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 CHANGED
@@ -2,6 +2,48 @@
2
2
 
3
3
  All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
+ ## [0.6.5] - 2026-06-13
6
+
7
+ ### Fixed — orchestrator REPORT loss: hold-and-redeliver queued injects until idle (#617)
8
+
9
+ - **A REPORT injected into a busy orchestrator TUI is no longer lost.** When `inject --submit`
10
+ is classified `queued` (consumed=false, recipient busy), the daemon now watches the
11
+ recipient's auto-state and **re-fires the CR when it transitions to idle** — bounded
12
+ (`MAX_REDELIVER=3` + total-time deadline; never an unbounded loop), and never-double-deliver
13
+ (re-fires only while the body is still parked, gated by the #615 consumption check before
14
+ AND after idle). Detached fire-and-forget; kill-switch `TELEPTY_REDELIVER=off`. Closes the
15
+ "`Submitted via pty_cr` succeeds but the busy recipient never starts a new turn" gap that
16
+ forced manual pull-fallback in every orchestration wave.
17
+
18
+ ### Fixed — TASK_IDLE_UNCONFIRMED cry-wolf on long-running Claude turns (#619, telepty#54)
19
+
20
+ - **A genuinely-completed long Claude TUI turn no longer reports `TASK_IDLE_UNCONFIRMED`.**
21
+ Consumption is an EARLY event (the turn fires ~T+2s after inject) but the #52/#545 idle-gate
22
+ evaluated it LATE (at idle, often 13–23 min later) by re-deriving from the output-ring /
23
+ OSC133 marks — by then a long turn's injected body has scrolled off and no fresh REPL-done
24
+ mark remains, so the gate fell back to UNCONFIRMED on every long completion (cry-wolf, which
25
+ trains the orchestrator to ignore the signal and defeats #52's own safety purpose). The
26
+ daemon now **persists the consumption fact at consumption-time** (`maybeRecordInjectConsumption()`
27
+ records `injectConsumedAt` on the first genuine non-busy→working/thinking turn after the CR,
28
+ reusing the #615 consumed signal) and the idle-gate reads that **decay-proof stored fact**
29
+ instead of re-deriving from a stale screen. **#52 guarantee preserved — never a false
30
+ COMPLETE**: the fact is only recorded for a real turn AFTER the CR (startup / sub-state flips
31
+ and busy-park excluded), so a never-consumed inject still yields UNCONFIRMED.
32
+
33
+ ## [0.6.4] - 2026-06-13
34
+
35
+ ### Added — inject consumption-evidence: consumed | queued | unknown (#53)
36
+
37
+ - **`telepty inject` now distinguishes "delivered" from "consumed".** After the CR
38
+ (`pty_cr`), the daemon captures an output-ring watermark and `classifyInjectConsumption()`
39
+ / `verifyBodyConsumed()` (reusing the #52 echo-watermark technique) classify the result:
40
+ **consumed** (composer cleared + new turn rendered), **queued** (injected text persists
41
+ in a busy TUI composer), or **unknown** (conservative). The `/submit` response and CLI
42
+ output now carry this status; a `queued` result on a busy orchestrator TUI prints a
43
+ pull-fallback hint. Closes the "`Submitted` reads as success but the busy recipient never
44
+ consumed it" gap (observed 3+ times in a single orchestration wave). Backward-compatible
45
+ (accepted/retryable semantics and exit codes unchanged; response is a superset).
46
+
5
47
  ## [0.6.3] - 2026-06-13
6
48
 
7
49
  ### ⚠️ BREAKING — daemon binds 127.0.0.1 by default (#50)
package/cli.js CHANGED
@@ -2423,7 +2423,21 @@ async function main() {
2423
2423
  : '';
2424
2424
  const attemptsNote = submitData.attempts > 1 ? ` (${submitData.attempts} attempts)` : '';
2425
2425
  const forcedNote = submitData.forced ? ' [forced]' : '';
2426
- console.log(`✅ Submitted via ${submitData.strategy}${attemptsNote}${gateNote}${lateNote}${forcedNote}.`);
2426
+ const tail = `${attemptsNote}${gateNote}${lateNote}${forcedNote}`;
2427
+ // #53: distinguish CONSUMED-as-a-turn from QUEUED-in-a-busy-composer. A bare
2428
+ // "Submitted via pty_cr" only proves bytes reached the PTY; a busy recipient TUI
2429
+ // parks the text without firing a turn, so report that instead of a false success.
2430
+ const consumption = submitData.consumption
2431
+ || (submitData.verify && submitData.verify.consumption) || null;
2432
+ if (consumption === 'queued') {
2433
+ console.log(`⚠️ Submitted via ${submitData.strategy}${tail}, but recipient is BUSY — text QUEUED, NOT consumed as a new turn. It will be processed after the current turn ends; if a reply is expected, fall back to pulling the recipient's state.`);
2434
+ } else if (consumption === 'consumed') {
2435
+ console.log(`✅ Submitted via ${submitData.strategy}${tail} — consumed as a new turn.`);
2436
+ } else if (consumption === 'unknown') {
2437
+ console.log(`✅ Submitted via ${submitData.strategy}${tail} (consumption=unknown — delivered to PTY; turn-consumption not observable).`);
2438
+ } else {
2439
+ console.log(`✅ Submitted via ${submitData.strategy}${tail}.`);
2440
+ }
2427
2441
  } else if (submitRes && submitRes.status === 504) {
2428
2442
  // Soft failure: REPL never readied. Orchestrator scripts depend on
2429
2443
  // exit 0 here — surface a clear remediation hint but do not exit
package/daemon.js CHANGED
@@ -43,6 +43,16 @@ const BOOTSTRAP_READY_TIMEOUT_MS = Math.max(500, Number(process.env.TELEPTY_BOOT
43
43
  // 2026-05-30 surface-ownership verdict — telepty no longer foregrounds surfaces.
44
44
  const WRAPPED_SUBMIT_DELAY_MS = 500;
45
45
 
46
+ // #617 hold-and-redeliver — when an inject(--submit) is classified `queued` (busy
47
+ // recipient parked the CR'd body in its composer, no turn fired), the daemon holds the
48
+ // parked body and re-fires the CR on the recipient's next busy→idle transition so the
49
+ // dropped REPORT turn finally starts. Bounded + never-double-deliver. Kill-switch
50
+ // TELEPTY_REDELIVER=off restores the pre-0.6.5 detect-only behavior (back-compat).
51
+ const REDELIVER_ENABLED = String(process.env.TELEPTY_REDELIVER || '').toLowerCase() !== 'off';
52
+ const REDELIVER_MAX_ATTEMPTS = Math.max(1, Number(process.env.TELEPTY_REDELIVER_MAX_ATTEMPTS || 3));
53
+ const REDELIVER_TOTAL_TIMEOUT_MS = Math.max(1000, Number(process.env.TELEPTY_REDELIVER_TOTAL_TIMEOUT_MS || 600000));
54
+ const REDELIVER_IDLE_WAIT_MS = Math.max(1000, Number(process.env.TELEPTY_REDELIVER_IDLE_WAIT_MS || 120000));
55
+
46
56
  // Session state machine manager — auto-detects session state from PTY output
47
57
  const sessionStateManager = new SessionStateManager({
48
58
  idle_timeout_ms: Number(process.env.TELEPTY_STATE_IDLE_TIMEOUT_MS || 5000),
@@ -82,6 +92,11 @@ sessionStateManager.onTransition((sessionId, from, to, detail) => {
82
92
  pendingReport.sawWorkingAfterInject = true;
83
93
  pendingReport.workingAfterInjectAt = new Date().toISOString();
84
94
  }
95
+ // #619: capture the durable early-consumption fact the instant a genuine fresh turn
96
+ // fires, so the idle-gate (evaluated minutes later on a scrolled-off ring) reads the
97
+ // stored fact instead of failing to re-derive it. since_ms is set at this transition.
98
+ const consumedSinceMs = sessionStateManager.getState(sessionId)?.since_ms;
99
+ maybeRecordInjectConsumption(pendingReport, from, to, consumedSinceMs);
85
100
  }
86
101
 
87
102
  // Fire TASK_IDLE_NO_REPORT on idle transition (for sessions with pendingReports).
@@ -329,6 +344,35 @@ function pendingReportHasSubmitEvidence(pendingReport) {
329
344
  ));
330
345
  }
331
346
 
347
+ // #619: persist inject-CONSUMPTION as a DURABLE FACT at consumption-time. The #52/#545 idle-
348
+ // gate re-derives consumption from the outputRing/OSC133 marks AT IDLE-TIME; on a long Claude
349
+ // turn (idle at T+13-23min) the injected body has scrolled off the ring, so the gate fails to
350
+ // re-derive a genuine completion → false TASK_IDLE_UNCONFIRMED. Recording the fact the instant
351
+ // the turn fires makes the idle-gate decay-proof (it reads the stored fact instead).
352
+ //
353
+ // never-false-complete (the #52 invariant) is preserved by recording ONLY the #615 `consumed`
354
+ // signal — a genuine FRESH turn that started at/after the inject CR:
355
+ // - the transition must enter a turn (→ working/thinking) FROM a non-busy state (idle/waiting);
356
+ // a `starting`→working startup flip (#537 pollution) and a working↔thinking mid-turn sub-
357
+ // state flip (an already-running turn, NOT ours) are both excluded;
358
+ // - the turn's since_ms must be ≥ the inject's submit-start (a turn that predates our CR is the
359
+ // #617 busy-park case — never our consumption);
360
+ // - a submit must have been attempted (submitStartedAt) — a non-submit text-inject records nothing.
361
+ // A never-consumed inject therefore never gets a fact and still signals UNCONFIRMED. Pure +
362
+ // idempotent (first genuine turn wins); mutates the passed pendingReport, returns whether it recorded.
363
+ function maybeRecordInjectConsumption(pendingReport, fromState, toState, transitionSinceMs) {
364
+ if (!pendingReport || pendingReport.injectConsumedAt) return false;
365
+ if (toState !== 'working' && toState !== 'thinking') return false;
366
+ if (fromState !== 'idle' && fromState !== 'waiting') return false;
367
+ if (!pendingReport.submitStartedAt) return false;
368
+ const submitStartedMs = new Date(pendingReport.submitStartedAt).getTime();
369
+ if (!Number.isFinite(submitStartedMs)) return false;
370
+ if (!Number.isFinite(transitionSinceMs) || transitionSinceMs < submitStartedMs) return false;
371
+ pendingReport.injectConsumedAt = new Date(transitionSinceMs).toISOString();
372
+ pendingReport.injectConsumedSinceMs = transitionSinceMs;
373
+ return true;
374
+ }
375
+
332
376
  // #52: the TASK_IDLE_UNCONFIRMED semantic is "inject may NOT have been processed" — gate it
333
377
  // on CONSUMPTION evidence the daemon already owns instead of screen idleness. Evidence:
334
378
  // - a screen-VERIFIED submit confirmation (body consumed from the composer, or a state
@@ -339,6 +383,12 @@ function pendingReportHasSubmitEvidence(pendingReport) {
339
383
  // no-land) is positive NON-consumption and can never be overridden by echo, so the
340
384
  // never-false-complete invariant of #48 holds: a genuinely unconsumed inject still signals.
341
385
  function observeConsumptionEvidence(pendingReport, session) {
386
+ // #619: a durable early-consumption fact (recorded at turn-start) is decay-proof — prefer it
387
+ // over re-deriving from the possibly scrolled-off outputRing at idle-time. This also covers the
388
+ // #48 settle re-entry path (where `confirmed` is force-false) as a suppression backstop.
389
+ if (pendingReport.injectConsumedAt) {
390
+ return { observed: true, reason: 'consumed_recorded' };
391
+ }
342
392
  const confirm = pendingReport.submitConfirm;
343
393
  if (confirm && confirm.accepted === false) {
344
394
  return { observed: false, reason: 'submit_failed' };
@@ -463,6 +513,11 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
463
513
  // as proof of processing — require positive submit confirmation (screen-poll verify /
464
514
  // honest force / gate-off). Paths with no submit expected keep the legacy floor/work rule.
465
515
  const strongSubmitConfirmed = !!(
516
+ // #619: a durable early-consumption fact (a genuine fresh turn fired by the inject) is
517
+ // the strongest completion proof there is — stronger than a screen-derived submit confirm
518
+ // and decay-proof at idle-time. Recorded conservatively (maybeRecordInjectConsumption), so
519
+ // this never promotes a never-consumed inject.
520
+ pendingReport.injectConsumedAt ||
466
521
  pendingReport.submitConfirmedAt ||
467
522
  (pendingReport.submitConfirm && pendingReport.submitConfirm.accepted === true)
468
523
  );
@@ -473,9 +528,14 @@ function fireAutoReport(targetId, targetSession, pendingReport, trigger, deps =
473
528
  // symptom — a submit-confirmed worker still thinking), consistent with the BUG-B confirm gate;
474
529
  // plain non-submit injects keep their existing floor-based completion. Absent flag / other
475
530
  // triggers preserve prior behavior.
531
+ // #619: a recorded early-consumption fact overrides the decayed at-idle evidence. The
532
+ // `idleEvidenceReliable === false` downgrade exists because the screen-derived evidence is
533
+ // weak; a stored consumption fact IS the (decay-proof) evidence, so the downgrade no longer
534
+ // applies. Without a recorded fact, behavior is unchanged (#545/#52 conservative UNCONFIRMED).
476
535
  const idleEvidenceUnreliable = trigger === 'real-idle'
477
536
  && pendingReport.submitExpected
478
- && deps.idleEvidenceReliable === false;
537
+ && deps.idleEvidenceReliable === false
538
+ && !pendingReport.injectConsumedAt;
479
539
  // #48: a settled recheck re-enters ONLY to emit the UNCONFIRMED label — pinned at arm time,
480
540
  // so elapsed growing past the floor during the settle window can never promote a stale idle
481
541
  // snapshot to TASK_COMPLETE (never a false complete).
@@ -984,6 +1044,97 @@ async function gatedTerminalSubmit(id, session, injectedBody, settleEnabled) {
984
1044
  return terminalLevelSubmit(id, session);
985
1045
  }
986
1046
 
1047
+ // #617 — detached hold-and-redeliver for a `queued` inject. The /submit response has
1048
+ // already returned (the worker's `telepty inject` got consumption='queued' / a plain
1049
+ // force success and EXITS), so the daemon owns delivery from here: it re-classifies
1050
+ // when the status is unknown (the force path skips the synchronous classify), and if
1051
+ // `queued`, runs the bounded submitGate.holdAndRedeliver loop — watching the recipient's
1052
+ // auto-state for busy→idle (awaitReplReady) and re-firing the bare CR (the body is still
1053
+ // parked in the composer) until it is consumed as a fresh turn. Fire-and-forget: never
1054
+ // awaited by the handler, so it cannot affect the response or block the caller.
1055
+ function scheduleQueuedRedeliver(id, session, injectedBody, opts = {}) {
1056
+ if (!REDELIVER_ENABLED) return;
1057
+ if (!injectedBody || injectedBody.length === 0) return;
1058
+ if (!session) return;
1059
+ // One in-flight redeliver per session — never stack idle-watchers for the same composer.
1060
+ if (session._redeliverInFlight) return;
1061
+ session._redeliverInFlight = true;
1062
+
1063
+ const emitSubmitBus = typeof opts.emitSubmitBus === 'function' ? opts.emitSubmitBus : () => {};
1064
+ const knownConsumption = opts.knownConsumption || null;
1065
+
1066
+ const isParked = () =>
1067
+ submitGate.observeBodyVisibility(session, injectedBody, { stripAnsi: stripAnsiState }).visible === true;
1068
+
1069
+ // Re-fire a bare CR (body already parked) then re-classify against a FRESH watermark:
1070
+ // the recipient is now idle, so a genuine idle→working/thinking turn is observable as
1071
+ // `consumed` (#53). A still-`queued` result means the CR did not fire the composer — retry.
1072
+ const fireCR = async () => {
1073
+ const strategy = await gatedTerminalSubmit(id, session, injectedBody, true);
1074
+ if (!strategy) return { redelivered: false, reason: 'strategy_failed' };
1075
+ const submittedAtMs = Date.now();
1076
+ const sinceBytes = session.outputRingTotalBytes || 0;
1077
+ const c = await submitGate.classifyInjectConsumption(session, injectedBody, {
1078
+ submittedAtMs,
1079
+ sinceBytes,
1080
+ getState: () => sessionStateManager.getState(id),
1081
+ stripAnsi: stripAnsiState,
1082
+ });
1083
+ return { redelivered: c.status === 'consumed', reason: c.reason };
1084
+ };
1085
+
1086
+ const waitForIdle = (remainingMs) =>
1087
+ submitGate.awaitReplReady(id, sessionStateManager, {
1088
+ timeoutMs: Math.min(remainingMs, REDELIVER_IDLE_WAIT_MS),
1089
+ });
1090
+
1091
+ const run = async () => {
1092
+ // Force path skips the synchronous classify — establish the `queued` precondition here
1093
+ // before holding an idle-watcher. Only a busy-parked body needs rescue.
1094
+ if (knownConsumption !== 'queued') {
1095
+ if (Number.isFinite(opts.submittedAtMs)) {
1096
+ const c = await submitGate.classifyInjectConsumption(session, injectedBody, {
1097
+ submittedAtMs: opts.submittedAtMs,
1098
+ sinceBytes: Number.isFinite(opts.ringBytesAtSubmit) ? opts.ringBytesAtSubmit : (session.outputRingTotalBytes || 0),
1099
+ getState: () => sessionStateManager.getState(id),
1100
+ stripAnsi: stripAnsiState,
1101
+ });
1102
+ if (c.status !== 'queued') return; // consumed / unknown — nothing to redeliver
1103
+ } else {
1104
+ return; // no watermark to classify against — cannot safely redeliver
1105
+ }
1106
+ }
1107
+
1108
+ console.log(`[REDELIVER] ${id} inject queued on busy recipient — holding for idle to re-fire`);
1109
+ const result = await submitGate.holdAndRedeliver({
1110
+ waitForIdle,
1111
+ isStillParked: isParked,
1112
+ fireCR,
1113
+ maxAttempts: REDELIVER_MAX_ATTEMPTS,
1114
+ totalTimeoutMs: REDELIVER_TOTAL_TIMEOUT_MS,
1115
+ onAttempt: ({ attempt }) =>
1116
+ console.log(`[REDELIVER] ${id} idle — re-firing queued inject (attempt ${attempt}/${REDELIVER_MAX_ATTEMPTS})`),
1117
+ onExhausted: ({ reason, attempts }) => {
1118
+ console.log(`[REDELIVER] ${id} redeliver-exhausted (${reason}, attempts=${attempts})`);
1119
+ emitSubmitBus({ redeliver: 'exhausted', redeliver_reason: reason, redeliver_attempts: attempts });
1120
+ },
1121
+ });
1122
+
1123
+ if (result.status === 'redelivered') {
1124
+ console.log(`[REDELIVER] ${id} queued inject redelivered after ${result.attempts} attempt(s)`);
1125
+ markPendingReportSubmitConfirmed(id, { reason: 'redelivered', attempts: result.attempts });
1126
+ emitSubmitBus({ redeliver: 'redelivered', redeliver_attempts: result.attempts });
1127
+ } else if (result.status === 'already_consumed') {
1128
+ console.log(`[REDELIVER] ${id} queued inject already consumed (${result.reason}) — no re-fire`);
1129
+ }
1130
+ };
1131
+
1132
+ Promise.resolve()
1133
+ .then(run)
1134
+ .catch((err) => console.log(`[REDELIVER] ${id} redeliver error: ${err && err.message}`))
1135
+ .finally(() => { session._redeliverInFlight = false; });
1136
+ }
1137
+
987
1138
  async function executeBootstrapSubmit(sessionId, session, op) {
988
1139
  const body = op.body || {};
989
1140
  const injectedBody = typeof body.injected_body === 'string' ? body.injected_body : null;
@@ -2689,7 +2840,9 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2689
2840
  if (injectedBody) {
2690
2841
  markPendingReportSubmitStarted(id, injectedBody);
2691
2842
  }
2843
+ const forceRingBytesAtSubmit = session.outputRingTotalBytes || 0;
2692
2844
  const strategy = terminalLevelSubmit(id, session);
2845
+ const forceSubmittedAtMs = Date.now();
2693
2846
  if (strategy) {
2694
2847
  // #537 / Bug B: force-confirm must reflect ACTUAL delivery. A pty_cr fallback on a
2695
2848
  // cmux surface means cmux send-key failed and Enter never reached the CLI — record
@@ -2702,6 +2855,17 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2702
2855
  markPendingReportSubmitUnconfirmed(id, { reason: 'cmux_send_failed', attempts: 1, retryable: true });
2703
2856
  }
2704
2857
  }
2858
+ // #617: the force path skips the synchronous consumption classify, so a busy-parked
2859
+ // body would silently drop (this IS the worker's `--submit-force` REPORT path). Hand it
2860
+ // to the detached redeliver — it classifies against the CR watermark and only re-fires
2861
+ // if `queued`. No-op when the body was consumed/unknown or absent.
2862
+ if (injectedBody && deliveredToSurface) {
2863
+ scheduleQueuedRedeliver(id, session, injectedBody, {
2864
+ submittedAtMs: forceSubmittedAtMs,
2865
+ ringBytesAtSubmit: forceRingBytesAtSubmit,
2866
+ emitSubmitBus,
2867
+ });
2868
+ }
2705
2869
  emitSubmitBus({ strategy, attempts: 1, gated: false, forced: true, submit_confirmed: deliveredToSurface });
2706
2870
  return res.json({ success: true, strategy, attempts: 1, gated: false, forced: true, submit_confirmed: deliveredToSurface });
2707
2871
  }
@@ -2815,6 +2979,9 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2815
2979
  const settleEnabled = req.body?.input_settle_gate !== false;
2816
2980
  let strategy = await gatedTerminalSubmit(id, session, injectedBody, settleEnabled);
2817
2981
  let submittedAtMs = Date.now();
2982
+ // #53: outputRing watermark at the CR — scopes consumption-evidence matching to frames
2983
+ // appended AFTER this submit (composer redraw / new-turn render), surviving ring trimming.
2984
+ let ringBytesAtSubmit = session.outputRingTotalBytes || 0;
2818
2985
  let attempts = strategy ? 1 : 0;
2819
2986
  if (!strategy) {
2820
2987
  if (injectedBody) {
@@ -2836,12 +3003,15 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2836
3003
  // shot is enough. A retry is idempotent only when the body is still visible.
2837
3004
  let verify = null;
2838
3005
  let confirm = null;
3006
+ let consumption = null; // #53: 'consumed' | 'queued' | 'unknown'
3007
+ let consumptionReason = null;
2839
3008
  if (injectedBody && injectedBody.length > 0) {
2840
3009
  confirm = await confirmSubmitAfterDispatch(id, session, injectedBody, submittedAtMs, verifyTimeoutMs);
2841
3010
  while (confirm && !confirm.accepted && confirm.retryable && attempts <= retries) {
2842
3011
  await new Promise(resolve => setTimeout(resolve, retryDelayMs));
2843
3012
  const retryStrategy = await gatedTerminalSubmit(id, session, injectedBody, settleEnabled);
2844
3013
  submittedAtMs = Date.now();
3014
+ ringBytesAtSubmit = session.outputRingTotalBytes || 0;
2845
3015
  if (!retryStrategy) break;
2846
3016
  strategy = retryStrategy;
2847
3017
  attempts++;
@@ -2849,6 +3019,38 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2849
3019
  }
2850
3020
  verify = buildSubmitVerify(confirm);
2851
3021
 
3022
+ // #53: consumption-evidence on the DELIVERY path. `confirm.accepted` can read a BUSY
3023
+ // recipient's mid-turn output as success (the isAcceptedSubmitState last_output_at leak),
3024
+ // so additionally classify whether the body was CONSUMED as a fresh turn vs QUEUED in a
3025
+ // busy composer vs UNKNOWN — and surface it to the caller. Advisory + additive: it does
3026
+ // NOT change accepted/retryable (back-compat); it only tells the sender what telepty can
3027
+ // actually observe past the PTY layer. Conservative (never-false-consumed).
3028
+ const consumptionResult = await submitGate.classifyInjectConsumption(session, injectedBody, {
3029
+ submittedAtMs,
3030
+ sinceBytes: ringBytesAtSubmit,
3031
+ getState: () => sessionStateManager.getState(id),
3032
+ stripAnsi: stripAnsiState,
3033
+ });
3034
+ consumption = consumptionResult.status;
3035
+ consumptionReason = consumptionResult.reason;
3036
+ if (verify) {
3037
+ verify.consumption = consumptionResult.status;
3038
+ verify.consumption_reason = consumptionResult.reason;
3039
+ }
3040
+
3041
+ // #617: a `queued` body was parked on a busy recipient and will never fire on its own.
3042
+ // Hand it to the detached hold-and-redeliver loop (re-fires the CR on busy→idle). This
3043
+ // runs independent of whether the handler returns 200 or 504 below — delivery is the
3044
+ // daemon's responsibility now that the worker no longer needs to poll the status.
3045
+ if (consumption === 'queued') {
3046
+ scheduleQueuedRedeliver(id, session, injectedBody, {
3047
+ knownConsumption: 'queued',
3048
+ submittedAtMs,
3049
+ ringBytesAtSubmit,
3050
+ emitSubmitBus,
3051
+ });
3052
+ }
3053
+
2852
3054
  if (confirm && !confirm.accepted) {
2853
3055
  const reason = gatedDispatchAfterTimeout ? 'gated_dispatch_unconsumed' : 'submit_unconfirmed';
2854
3056
  const failBody = {
@@ -2863,6 +3065,7 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2863
3065
  gate_wait_ms: gateResult.waited_ms,
2864
3066
  verify,
2865
3067
  confirm,
3068
+ ...(consumption ? { consumption, consumption_reason: consumptionReason } : {}),
2866
3069
  gated_dispatch_after_timeout: true,
2867
3070
  ...(promptSymbol ? { prompt_symbol: promptSymbol } : {}),
2868
3071
  };
@@ -2885,6 +3088,7 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
2885
3088
  gate_wait_ms: gateResult.waited_ms,
2886
3089
  verify,
2887
3090
  confirm,
3091
+ ...(consumption ? { consumption, consumption_reason: consumptionReason } : {}),
2888
3092
  ...(gatedDispatchAfterTimeout ? { gated_dispatch_after_timeout: true } : {}),
2889
3093
  ...(promptSymbol ? { prompt_symbol: promptSymbol } : {}),
2890
3094
  };
@@ -4226,6 +4430,7 @@ if (require.main === module) {
4226
4430
  // production call sites is unchanged. NOT a public API — internal/test use only.
4227
4431
  module.exports = {
4228
4432
  fireAutoReport, // #32: provenance-tagged auto-report (deps DI: now/deliver/...)
4433
+ maybeRecordInjectConsumption, // #619: durable early-consumption fact capture (idle-gate decay-proofing)
4229
4434
  forceSubmitDeliveredToSurface, // #544/#537/Bug B: PTY-native force-confirm (pty_cr = delivered)
4230
4435
  terminalLevelSubmit, // #544: PTY-only submit path (pty_cr | null)
4231
4436
  submitViaPty, // #544: bare-0x0D submit into the innermost node-pty
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.6.3",
3
+ "version": "0.6.5",
4
4
  "main": "daemon.js",
5
5
  "bin": {
6
6
  "aigentry-telepty": "install.js",
@@ -37,9 +37,9 @@
37
37
  "scripts": {
38
38
  "postinstall": "node scripts/postinstall.js",
39
39
  "preuninstall": "node scripts/preuninstall.js",
40
- "test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
41
- "test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js",
42
- "test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
40
+ "test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
41
+ "test:watch": "node --require ./test-support/setup-env.js --test --watch test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js",
42
+ "test:ci": "node --require ./test-support/setup-env.js --test --test-reporter=spec test/auth.test.js test/http-auth.test.js test/broker-protocol.test.js test/broker-auth.test.js test/broker-server.test.js test/broker-client.test.js test/daemon-broker-wiring.test.js test/broker-cli.test.js test/broker-integration.test.js test/daemon.test.js test/daemon-singleton.test.js test/daemon-harness-port.test.js test/daemon-bind-default.test.js test/integration/daemon-launch.test.js test/cli.test.js test/subcommand-help.test.js test/telepty-kill.test.js test/idle-ttl.test.js test/telepty-clean-older-than.test.js test/lifecycle-transport-agnostic.test.js test/skill-installer.test.js test/interactive-terminal.test.js test/runtime-info.test.js test/session-routing.test.js test/session-state.test.js test/session-store-persistence.test.js test/mailbox-lock.test.js test/report-enforcement.test.js test/enforce-report.test.js test/peer-inject-validator.test.js test/enforce-submit-gate.test.js test/submit-gate.test.js test/submit-via-pty.test.js test/submit-render-gate.test.js test/prompt-symbol-registry.test.js test/inject-submit-flags.test.js test/inject-submit-force-env.test.js test/inject-consumption-evidence.test.js test/host-spec.test.js test/cross-host-inject.test.js test/cross-machine-ssh-routing.test.js test/init.test.js test/install-service-generation.test.js test/install-broker-service.test.js test/uninstall.test.js test/win-resolve-executable.test.js test/version-handshake.test.js test/ensure-daemon-running.test.js test/daemon-restart-fallback-15.test.js test/win-kill-process.test.js test/daemon-control-port-owner.test.js test/banner-stderr-jq-safety.test.js test/bridge-supervisor-ipc.test.js test/bridge-j3-shim.test.js test/bridge-e2e.test.js test/release-0.4.5-bugfixes.test.js test/idle-unconfirmed-settle.test.js test/idle-unconfirmed-consumption.test.js test/idle-unconfirmed-decayed-619.test.js test/inject-redeliver.test.js test/provenance.test.js test/inject-audit-broker-seam.test.js test/inject-provenance-daemon.test.js test/inject-audit-log.test.js test/inject-audit-daemon.test.js test/inject-audit-cli.test.js && git diff --exit-code tests/snippet-protocol/v1/",
43
43
  "typecheck": "tsc --noEmit",
44
44
  "regen-fixtures": "node scripts/regen-snippet-fixtures.js"
45
45
  },
@@ -61,7 +61,7 @@
61
61
  ],
62
62
  "author": "dmsdc-ai",
63
63
  "license": "ISC",
64
- "description": "Universal terminal session bridge \u2014 connect any terminal to any terminal, any machine",
64
+ "description": "Universal terminal session bridge connect any terminal to any terminal, any machine",
65
65
  "repository": {
66
66
  "type": "git",
67
67
  "url": "git+https://github.com/dmsdc-ai/aigentry-telepty.git"
@@ -438,6 +438,171 @@ function observeInjectEcho(session, bodyText, opts = {}) {
438
438
  return { observed: false, reason: 'no_echo', windows_matched: hits };
439
439
  }
440
440
 
441
+ // #53: classify whether an injected+submitted body was CONSUMED as a new turn by the
442
+ // recipient TUI, vs QUEUED in a busy composer, vs UNKNOWN. This is the DELIVERY-side dual
443
+ // of #52 (which gates the IDLE signal on consumption evidence). A bare `Submitted via
444
+ // pty_cr` only proves bytes reached the PTY master; a BUSY Claude Code TUI parks the CR'd
445
+ // text in its composer ("Press up to edit queued messages") and never starts a turn, so the
446
+ // sender must be able to tell `queued` from `consumed`.
447
+ //
448
+ // Hard boundary (#53): telepty cannot see inside the TUI's turn loop, so `consumed` is only
449
+ // claimable from OBSERVABLE evidence, and never-false-consumed is conservative:
450
+ //
451
+ // consumed — the recipient was NOT already busy at the CR and then began a FRESH turn:
452
+ // an idle→working/thinking transition whose since_ms ≥ submittedAtMs (a genuine
453
+ // new turn, NOT mere continued output from a turn already running — the leak
454
+ // that made `last_output_at ≥ submittedAtMs` read a busy queue as success).
455
+ // queued — the injected body is still observably PARKED on screen after a short settle
456
+ // (windowed echo match — #52 technique — tolerates composer line-wrap/borders).
457
+ // A recipient already busy at the CR can only ever land here (fact 1: busy CR
458
+ // queues, never fires), never in `consumed`.
459
+ // unknown — neither positive signal within the window (conservative default).
460
+ //
461
+ // Pure: DI getState + outputRing-only, DI now/sleep/stripAnsi — no I/O, no daemon coupling.
462
+ async function classifyInjectConsumption(session, bodyText, opts = {}) {
463
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 1200;
464
+ const settleMs = Number.isFinite(opts.settleMs) ? opts.settleMs : 250;
465
+ const intervalMs = Number.isFinite(opts.intervalMs) ? opts.intervalMs : 80;
466
+ const minChars = Number.isFinite(opts.minChars) ? opts.minChars : 24;
467
+ const stripAnsi = typeof opts.stripAnsi === 'function' ? opts.stripAnsi : (s) => s;
468
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
469
+ const sleep = typeof opts.sleep === 'function' ? opts.sleep : (ms) => new Promise((r) => setTimeout(r, ms));
470
+ const getState = typeof opts.getState === 'function' ? opts.getState : null;
471
+ const submittedAtMs = Number.isFinite(opts.submittedAtMs) ? opts.submittedAtMs : now();
472
+ const sinceBytes = Number.isFinite(opts.sinceBytes) ? opts.sinceBytes : null;
473
+
474
+ const body = normalize(bodyText);
475
+ if (body.length === 0) return { status: 'unknown', reason: 'empty_body', waited_ms: 0 };
476
+ if (body.length < minChars) return { status: 'unknown', reason: 'body_too_short', waited_ms: 0 };
477
+ if (!session || !Array.isArray(session.outputRing)) {
478
+ return { status: 'unknown', reason: 'no_ring', waited_ms: 0 };
479
+ }
480
+
481
+ // Was the recipient ALREADY busy when the CR was written? A busy claude-code TUI parks the
482
+ // CR'd text and never starts a turn (#53 fact 1), so we may observe `queued` there but must
483
+ // NEVER claim `consumed` — that is the hard boundary that produced the false success.
484
+ const initial = getState ? getState() : null;
485
+ const startedBusy = !!(initial && ACCEPTED_AFTER_SUBMIT_STATES.has(initial.state)
486
+ && Number.isFinite(initial.since_ms) && initial.since_ms < submittedAtMs);
487
+
488
+ const observeParked = () => observeInjectEcho(session, bodyText, { stripAnsi, sinceBytes, minChars });
489
+
490
+ const start = now();
491
+ while (true) {
492
+ // consumed — only from a non-busy start that produced a fresh idle→working/thinking turn.
493
+ if (!startedBusy && getState) {
494
+ const st = getState();
495
+ if (st && ACCEPTED_AFTER_SUBMIT_STATES.has(st.state)
496
+ && Number.isFinite(st.since_ms) && st.since_ms >= submittedAtMs) {
497
+ return { status: 'consumed', reason: `turn_started_${st.state}`, waited_ms: now() - start };
498
+ }
499
+ }
500
+
501
+ const elapsed = now() - start;
502
+ // Busy recipient cannot consume — short-circuit to `queued` as soon as the parked body
503
+ // settles, instead of waiting out the full window for a turn that will never come.
504
+ if (startedBusy && elapsed >= settleMs) {
505
+ const echo = observeParked();
506
+ if (echo.observed) return { status: 'queued', reason: 'busy_parked', waited_ms: elapsed };
507
+ }
508
+
509
+ if (elapsed >= timeoutMs) {
510
+ const echo = observeParked();
511
+ if (echo.observed) {
512
+ return { status: 'queued', reason: startedBusy ? 'busy_parked' : 'body_parked', waited_ms: elapsed };
513
+ }
514
+ return { status: 'unknown', reason: startedBusy ? 'busy_no_evidence' : 'no_turn', waited_ms: elapsed };
515
+ }
516
+ await sleep(intervalMs);
517
+ }
518
+ }
519
+
520
+ // #617 hold-and-redeliver — close the gap between #53 DETECTION and ACTION.
521
+ //
522
+ // A recipient that is BUSY when the inject CR is written parks the body in its
523
+ // composer ("Press up to edit queued messages") and never starts a turn (#53 fact 1).
524
+ // classifyInjectConsumption already DETECTS this (`queued`), but 0.6.4 only reports
525
+ // the status — the worker's `telepty inject` never reads it and exits, so the parked
526
+ // REPORT turn is silently dropped. This loop is the ACTION: hold the parked body,
527
+ // wait for the recipient's busy→idle transition, then re-fire the CR so the queued
528
+ // turn finally fires. The body is ALREADY in the composer, so a bare CR (fireCR)
529
+ // fires it — never re-inject the body (that would duplicate text).
530
+ //
531
+ // Invariants:
532
+ // - bounded: at most `maxAttempts` CR re-fires AND a `totalTimeoutMs` deadline —
533
+ // never an unbounded redeliver loop (Rule 27: redelivery is the fix, not a
534
+ // forever-retry workaround).
535
+ // - never-double-deliver: re-fire ONLY while the body is still parked
536
+ // (`isStillParked`, reusing #52/#53 echo observation at the daemon seam). The
537
+ // gate is checked before the idle wait AND again right after idle — a turn that
538
+ // auto-consumed the queue as it ended must not get a second CR.
539
+ // - back-compat: the daemon runs this DETACHED from the /submit response; a caller
540
+ // that ignores `consumption` is wholly unaffected.
541
+ //
542
+ // Pure: every effect (idle watch, parked check, CR fire) is injected; DI now. No
543
+ // daemon coupling — the daemon wires real implementations, tests wire doubles.
544
+ //
545
+ // @param {{
546
+ // waitForIdle: (remainingMs: number) => Promise<{ ready: boolean, reason?: string }>,
547
+ // isStillParked: () => boolean,
548
+ // fireCR: () => Promise<{ redelivered: boolean, reason?: string }>,
549
+ // maxAttempts?: number, totalTimeoutMs?: number,
550
+ // onAttempt?: Function, onExhausted?: Function, now?: Function
551
+ // }} opts
552
+ // @returns {Promise<{ status: 'redelivered'|'already_consumed'|'exhausted', reason: string, attempts: number, waited_ms: number }>}
553
+ async function holdAndRedeliver(opts = {}) {
554
+ const maxAttempts = Number.isFinite(opts.maxAttempts) ? opts.maxAttempts : 3;
555
+ const totalTimeoutMs = Number.isFinite(opts.totalTimeoutMs) ? opts.totalTimeoutMs : 600000;
556
+ const now = typeof opts.now === 'function' ? opts.now : () => Date.now();
557
+ const waitForIdle = typeof opts.waitForIdle === 'function' ? opts.waitForIdle : null;
558
+ const isStillParked = typeof opts.isStillParked === 'function' ? opts.isStillParked : () => true;
559
+ const fireCR = typeof opts.fireCR === 'function' ? opts.fireCR : null;
560
+ const onAttempt = typeof opts.onAttempt === 'function' ? opts.onAttempt : () => {};
561
+ const onExhausted = typeof opts.onExhausted === 'function' ? opts.onExhausted : () => {};
562
+
563
+ const start = now();
564
+ if (!waitForIdle || !fireCR) {
565
+ return { status: 'exhausted', reason: 'no_dependencies', attempts: 0, waited_ms: 0 };
566
+ }
567
+
568
+ let attempts = 0;
569
+ while (attempts < maxAttempts) {
570
+ if (now() - start >= totalTimeoutMs) {
571
+ onExhausted({ reason: 'deadline', attempts });
572
+ return { status: 'exhausted', reason: 'deadline', attempts, waited_ms: now() - start };
573
+ }
574
+
575
+ // never-double-deliver (pre-wait): if the body already left the composer, stop.
576
+ if (!isStillParked()) {
577
+ return { status: 'already_consumed', reason: 'not_parked', attempts, waited_ms: now() - start };
578
+ }
579
+
580
+ const remaining = totalTimeoutMs - (now() - start);
581
+ const idle = await waitForIdle(remaining);
582
+ if (!idle || !idle.ready) {
583
+ onExhausted({ reason: (idle && idle.reason) || 'idle_timeout', attempts });
584
+ return { status: 'exhausted', reason: 'idle_timeout', attempts, waited_ms: now() - start };
585
+ }
586
+
587
+ // never-double-deliver (post-idle): the turn that just ended may have auto-fired
588
+ // the queued body. Re-check before committing a CR.
589
+ if (!isStillParked()) {
590
+ return { status: 'already_consumed', reason: 'consumed_on_idle', attempts, waited_ms: now() - start };
591
+ }
592
+
593
+ attempts++;
594
+ onAttempt({ attempt: attempts });
595
+ const fired = await fireCR();
596
+ if (fired && fired.redelivered) {
597
+ return { status: 'redelivered', reason: fired.reason || 'consumed', attempts, waited_ms: now() - start };
598
+ }
599
+ // still queued/unknown — loop to await the next idle window (bounded by maxAttempts).
600
+ }
601
+
602
+ onExhausted({ reason: 'max_attempts', attempts });
603
+ return { status: 'exhausted', reason: 'max_attempts', attempts, waited_ms: now() - start };
604
+ }
605
+
441
606
  function isAcceptedSubmitState(state, submittedAtMs) {
442
607
  if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
443
608
  if (!Number.isFinite(submittedAtMs)) {
@@ -613,6 +778,8 @@ module.exports = {
613
778
  confirmSubmitAccepted,
614
779
  observeBodyVisibility,
615
780
  observeInjectEcho,
781
+ classifyInjectConsumption,
782
+ holdAndRedeliver,
616
783
  awaitPromptSymbol,
617
784
  defaultReadScreen,
618
785
  isReady,