@dmsdc-ai/aigentry-telepty 0.1.98 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,447 @@
1
+ # SPEC: `inject --submit` Enter reliability — render-gated submit
2
+
3
+ **Date:** 2026-04-26
4
+ **Author:** aigentry-telepty-coder
5
+ **Status:** SPEC — Phase 1, awaiting orchestrator approval
6
+ **Track:** orchestrator UX recurring trap (parallel to #329 Track E27)
7
+ **Related prior specs:**
8
+ - `specs/codex-inject-spec.md` (Phase 1, established kitty/cmux priority chain — landed)
9
+ - `specs/enforce-report-spec.md` (in-flight, REPORT enforcement, orthogonal)
10
+
11
+ ---
12
+
13
+ ## 0. Problem statement
14
+
15
+ `telepty inject --ref --submit --from <orch> <target> "<body>"` reports
16
+ `✅ Submitted via cmux (3 attempts).` but the body remains in the target
17
+ session's input prompt with Enter NOT applied. The target then idles
18
+ without ever processing the inject. Most reproducible against
19
+ freshly-spawned `claude` sessions where the REPL is still rendering its
20
+ welcome / model-select / trust-prompt UI when telepty fires Enter.
21
+
22
+ Workaround in production: orchestrator follows every inject with
23
+ `sleep N && telepty send-key <id> enter` (4–6s). This is the trap
24
+ this spec exists to remove.
25
+
26
+ ---
27
+
28
+ ## 1. Root cause analysis
29
+
30
+ ### 1.1 Code path for `inject --submit`
31
+
32
+ CLI side (`cli.js:1540-1660`):
33
+
34
+ ```js
35
+ // cli.js:1626-1635 — inject body with no_enter:true
36
+ const body = buildInjectRequestBody(injectPrompt, {
37
+ fromId, replyTo, replyExpected,
38
+ noEnter: useSubmit // <-- daemon won't send CR after text
39
+ });
40
+ const res = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/inject`, {
41
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
42
+ });
43
+ // cli.js:1641-1659 — terminal-level submit
44
+ if (useSubmit) {
45
+ await new Promise(resolve => setTimeout(resolve, 500)); // (A) 500ms gap CLI-side
46
+ try {
47
+ const submitRes = await fetchWithAuth(`http://${target.host}:${PORT}/api/sessions/${encodeURIComponent(target.id)}/submit`, {
48
+ method: 'POST',
49
+ headers: { 'Content-Type': 'application/json' },
50
+ body: JSON.stringify({ pre_delay_ms: 600, retries: 2, retry_delay_ms: 500 }) // (B)
51
+ });
52
+ const submitData = await submitRes.json();
53
+ if (submitRes.ok) {
54
+ console.log(`✅ Submitted via ${submitData.strategy}${submitData.attempts > 1 ? ` (${submitData.attempts} attempts)` : ''}.`);
55
+ }
56
+ ...
57
+ ```
58
+
59
+ Daemon side (`daemon.js:1474-1520`, full body verbatim):
60
+
61
+ ```js
62
+ // daemon.js:1474 — POST /api/sessions/:id/submit
63
+ app.post('/api/sessions/:id/submit', async (req, res) => {
64
+ const requestedId = req.params.id;
65
+ const resolvedId = resolveSessionAlias(requestedId);
66
+ if (!resolvedId) return res.status(404).json({ error: 'Session not found', requested: requestedId });
67
+ const session = sessions[resolvedId];
68
+ const id = resolvedId;
69
+
70
+ const retries = Math.min(Math.max(Number(req.body?.retries) || 0, 0), 3);
71
+ const retryDelayMs = Math.min(Math.max(Number(req.body?.retry_delay_ms) || 500, 100), 2000);
72
+ const preDelayMs = Math.min(Math.max(Number(req.body?.pre_delay_ms) || 0, 0), 1000);
73
+
74
+ console.log(`[SUBMIT] Session ${id} (${session.command})${retries > 0 ? `, retries: ${retries}, pre_delay: ${preDelayMs}ms` : ''}`);
75
+
76
+ // Pre-delay: wait for paste rendering to complete before sending CR
77
+ if (preDelayMs > 0) {
78
+ await new Promise(resolve => setTimeout(resolve, preDelayMs));
79
+ }
80
+
81
+ let strategy = terminalLevelSubmit(id, session);
82
+ let attempts = 1;
83
+
84
+ // Retry: resend CR if paste may have absorbed the first one
85
+ for (let i = 0; i < retries && strategy; i++) {
86
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs));
87
+ terminalLevelSubmit(id, session); // ← BUG: return value discarded
88
+ attempts++;
89
+ }
90
+
91
+ if (strategy) {
92
+ ...
93
+ res.json({ success: true, strategy, attempts });
94
+ } else {
95
+ res.status(503).json({ error: 'Submit failed via all strategies (kitty/cmux/pty)', strategy: 'none', attempts });
96
+ }
97
+ });
98
+ ```
99
+
100
+ `terminalLevelSubmit` (`daemon.js:635-643`):
101
+
102
+ ```js
103
+ function terminalLevelSubmit(id, session) {
104
+ if (session.type === 'wrapped' && sendViaKitty(id, '\r')) return 'kitty';
105
+ if (session.backend === 'cmux' && session.cmuxWorkspaceId && submitViaCmux(id)) return 'cmux';
106
+ if (submitViaPty(session)) return 'pty_cr';
107
+ return null;
108
+ }
109
+ ```
110
+
111
+ `submitViaCmux` (`daemon.js:1458-1472`):
112
+
113
+ ```js
114
+ function submitViaCmux(sessionId) {
115
+ const { execSync } = require('child_process');
116
+ const session = sessions[sessionId];
117
+ if (!session || !session.cmuxWorkspaceId) return false;
118
+ try {
119
+ execSync(`cmux send-key --workspace ${session.cmuxWorkspaceId} return`, {
120
+ timeout: 5000, stdio: ['pipe', 'pipe', 'pipe']
121
+ });
122
+ console.log(`[SUBMIT] cmux send-key return for ${sessionId} (workspace ${session.cmuxWorkspaceId})`);
123
+ return true; // ← "true" only confirms cmux process exit code 0,
124
+ // NOT that the REPL consumed the key
125
+ } catch (err) { ... return false; }
126
+ }
127
+ ```
128
+
129
+ ### 1.2 Timing assumptions (the actual root cause)
130
+
131
+ The CLI / daemon path does not check whether the target REPL is ready.
132
+ It blindly fires Enter with bounded delays:
133
+
134
+ | Step | Wall time after `/inject` returned |
135
+ |---|---|
136
+ | CLI sleeps before /submit (cli.js:1643) | +500ms |
137
+ | Daemon `pre_delay_ms` (cli sends 600) | +1100ms |
138
+ | Submit attempt 1 (`terminalLevelSubmit` returns) | +1100ms |
139
+ | `retry_delay_ms` (cli sends 500) → attempt 2 | +1600ms |
140
+ | `retry_delay_ms` → attempt 3 | +2100ms |
141
+
142
+ Total wall budget: **~2.1 s** to complete all 3 Enter dispatches.
143
+
144
+ A freshly-spawned `claude` REPL takes **3–6 s** before its input
145
+ loop is ready to accept Enter. Empirically observed phases on a fresh
146
+ spawn:
147
+
148
+ 1. Process launch + Node bootstrap (~0.8–1.5 s, no terminal output yet)
149
+ 2. Welcome banner / `Welcome to Claude Code!` render
150
+ 3. Trust-this-folder dialog OR model-select dialog (consumes 1 Enter to dismiss)
151
+ 4. Initial empty prompt rendered with cursor in input box
152
+ 5. Inject text appears in input box (delivered via mailbox/PTY in step 4)
153
+ 6. Ready for submit-Enter
154
+
155
+ If telepty's 3 Enter attempts land in phases 2–3, they are absorbed by
156
+ the welcome screen / dialog. By the time phase 6 starts (>2.1s later),
157
+ no further Enter is queued — the body sits in the input box, claude
158
+ goes idle waiting for keystroke.
159
+
160
+ ### 1.3 Compounding bugs (in addition to the timing miss)
161
+
162
+ 1. **Retry return value discarded** (`daemon.js:1500`). The retry loop
163
+ ignores the result of `terminalLevelSubmit`, so attempts 2 and 3 are
164
+ not actually verified. The reported `attempts` count includes silent
165
+ no-ops if cmux reports failure or kitty socket disappears mid-loop.
166
+ 2. **`true` does not mean "consumed"**. `submitViaCmux` returns `true`
167
+ on `cmux send-key return` exit-0 — that confirms the cmux process
168
+ accepted the request, **not** that the target REPL processed the key.
169
+ Same for `sendViaKitty` (kitty `send-text` exit-0) and `submitViaPty`
170
+ (PTY write syscall succeeded).
171
+ 3. **No render-completion check**. There is no observation of the
172
+ target's screen state, no use of the `sessionStateManager` `idle`
173
+ transition, no OSC 133 watch. The submit decision is purely
174
+ open-loop.
175
+
176
+ ### 1.4 Why this is most reproducible on fresh sessions
177
+
178
+ - Long-running claude sessions are already at phase 6 → first Enter
179
+ attempt at +1.1 s is consumed correctly. Submit "feels" reliable.
180
+ - Fresh sessions are mid phase 2–3 at +1.1 s → all 3 attempts wasted.
181
+ - The orchestrator's "spawn-then-immediately-inject" pattern (used
182
+ for parallel session fan-out) maximally exposes the trap.
183
+
184
+ ### 1.5 Existing telepty primitives we can use (no new deps)
185
+
186
+ - `sessionStateManager.getState(id)` → `{ state, since, confidence, ... }`
187
+ with states `starting | idle | working | thinking | waiting | error | restarting | dead`.
188
+ `idle` with confidence ≥ 0.9 is a high-quality "ready for Enter" signal
189
+ (OSC 133;A/B mark or matched prompt pattern).
190
+ (`session-state.js:225-238`, `daemon.js:54,767,1123`)
191
+ - `sessionStateManager.onTransition(cb)` → callback on state changes
192
+ (`session-state.js:570`, `daemon.js:54-117` already wires this).
193
+ - `GET /api/sessions/:id/screen?lines=N` → returns ANSI-stripped recent
194
+ screen text from `outputRing` (`daemon.js:1748-1802`). Usable for
195
+ detecting the body text in the input box if state machine is
196
+ inconclusive.
197
+ - `outputRing` is already maintained per-session, capped at 200KB
198
+ (`daemon.js:729-738`).
199
+
200
+ ---
201
+
202
+ ## 2. Decision matrix
203
+
204
+ Every option below preserves the `/submit` HTTP contract, the
205
+ `✅ Submitted via <strategy>` CLI output, and the kitty / cmux / PTY
206
+ priority chain. Only the *internal* "when do we fire Enter, and how do
207
+ we know it landed" logic changes.
208
+
209
+ | Approach | What it does | Reliability | Latency (typical) | Latency (worst) | Code changes | Cross-OS | Verdict |
210
+ |---|---|---|---|---|---|---|---|
211
+ | **A. Bigger blind delays** | Raise `pre_delay_ms` default to e.g. 4000ms; raise `retry_delay_ms`. | Better but still open-loop. Will miss slower spawns; will pay 4 s on already-ready sessions. | +4 s | +5+ s | trivial (constants) | OK | ❌ Brittle. Pure regression on warm sessions. |
212
+ | **B. Render-completion gate via `sessionStateManager`** | Before firing Enter, await `getState(id).state === 'idle'` (or `working` returning to `idle`) with bounded timeout. | High when state machine sees prompt or OSC 133. Lower (silence-fallback @ 0.6 conf) for CLIs that don't emit OSC 133 and have unusual prompts (claude UI is one). | +1.1 s typical | +5 s + idle_timeout (bounded) | ~30 LOC daemon + helper | ✅ pure JS, no shell-out | ✅ Best primary. Already-existing primitive. |
213
+ | **C. Cursor-position / input-box check via `read-screen`** | Poll `outputRing` after inject. Look for the body text in the last screen. Once visible, fire Enter. After Enter, wait for body to disappear from screen → success. | High but fragile. Body text may render across line wraps; ANSI box-drawing complicates matching; long bodies truncated. | +1–2 s | +5 s | ~80 LOC matcher + edge cases | ✅ pure JS | ⚠️ Reasonable as fallback when state machine is inconclusive. Heavy alone. |
214
+ | **D. Hybrid (B + C-as-fallback + bounded retries)** | Primary: wait for `idle` state with conf ≥ 0.85 (timeout 5 s). If timeout, fallback: confirm body present in screen (timeout 2 s). Then fire Enter via existing kitty→cmux→PTY chain. Then verify via screen poll: body left input → success. Else one bounded retry, then surface a structured failure. | Highest. Closes the loop on both render and consumption. | +1.2 s typical | +8 s | ~70 LOC daemon + 1 helper module | ✅ pure JS | ✅ **Recommended.** |
215
+
216
+ ### 2.1 Recommendation: **Approach D (hybrid)**
217
+
218
+ **Rationale tied to constitutional rules:**
219
+
220
+ - **Rule 1 (경량)**: Reuses `sessionStateManager` and `outputRing` —
221
+ primitives already shipped and exercised. New code is one
222
+ ~70-LOC daemon helper + targeted edits in two functions. No new
223
+ module imports, no new long-running goroutines/timers.
224
+ - **Rule 17 (무의존)**: Zero new external dependencies. Uses only
225
+ Node built-ins, existing `child_process` paths, and existing
226
+ internal modules.
227
+ - **Rule 26 (cross-OS)**: All logic is pure JS evaluating the same
228
+ in-memory `outputRing` and state machine that already runs on every
229
+ supported OS. The OS-specific shell-outs (`kitty`, `cmux`,
230
+ `osascript`) are unchanged — only the *gate* before invoking them
231
+ is added.
232
+ - **Reliability vs latency**: Warm sessions (state already `idle`)
233
+ pay ~0 ms extra gate cost — they short-circuit the wait immediately.
234
+ Cold sessions pay an honest 1–5 s wait that matches actual REPL
235
+ readiness, instead of guessing 600 ms × 3.
236
+
237
+ ### 2.2 Approaches explicitly rejected
238
+
239
+ - **A** alone — raises latency floor for the common case without
240
+ closing the open-loop hole.
241
+ - **C** alone — fragile against ANSI box-drawing / line-wrap; high
242
+ maintenance burden as CLIs evolve their input UI.
243
+ - "Send Enter twice" — pure brute force, see §5.
244
+ - "Hardcode 10 s sleep" — see §5.
245
+ - "Force tmux fallback" — see §5.
246
+
247
+ ---
248
+
249
+ ## 3. Cross-OS abstraction (Rule 26)
250
+
251
+ The change does **not** introduce per-OS code paths. The new gate is
252
+ pure JS reading in-memory state. The existing `kitty` / `cmux` /
253
+ `osascript` shell-outs already encode OS behavior and are unchanged.
254
+
255
+ `telepty` does not have a `lib/platform.sh` style abstraction layer —
256
+ backend selection currently happens via:
257
+ - `terminal-backend.js:detectTerminal` (env vars + file probes)
258
+ - `daemon.js:terminalLevelSubmit` (priority chain)
259
+ - `daemon.js:getSubmitStrategy` (CLI binary → strategy table)
260
+
261
+ This spec adds **no new OS conditionals**. If a future spec wants to
262
+ unify these three sites into a single platform module, that is
263
+ out-of-scope here and should be raised as a follow-up.
264
+
265
+ ---
266
+
267
+ ## 4. Test plan
268
+
269
+ All new tests use `node:test` (existing harness). No new dev deps.
270
+
271
+ ### 4.1 Unit-style (test/submit-gate.test.js — new file)
272
+
273
+ 1. `awaitReplReady(id, sm, opts)` resolves immediately when state is
274
+ already `idle` with conf ≥ 0.85.
275
+ 2. Resolves on transition to `idle` from `starting`/`working` within
276
+ timeout.
277
+ 3. Times out after `opts.timeout_ms` and resolves with
278
+ `{ ready: false, reason: 'timeout', last_state }`.
279
+ 4. Honors `waiting` state — does not gate (waiting *is* a prompt, just
280
+ for y/n) — resolves immediately so a body-Enter still fires.
281
+ 5. `verifyBodyConsumed(session, bodyText, opts)` returns `{ consumed: true }`
282
+ when last `outputRing` screen no longer contains `bodyText`.
283
+ 6. Returns `{ consumed: false, reason: 'still_visible' }` when body
284
+ still in screen after `opts.timeout_ms`.
285
+ 7. Tolerates ANSI in the screen (uses existing `stripAnsi` from
286
+ daemon.js:1766) and tolerates whitespace collapsing.
287
+
288
+ ### 4.2 Daemon endpoint integration (test/daemon.test.js additions)
289
+
290
+ 8. `POST /submit` with the new gate path, simulated state machine
291
+ already `idle` → exactly 1 Enter dispatch, response
292
+ `{ success: true, strategy: ..., attempts: 1, gated: true, gate_wait_ms: <small> }`.
293
+ 9. `POST /submit` with simulated never-idle session → response
294
+ `{ success: false, error: 'gate_timeout', attempts: 0, gated: true, gate_wait_ms: <opts.timeout_ms> }`,
295
+ HTTP 504 (new), CLI prints structured warning.
296
+ 10. `POST /submit` retry: simulated body-still-visible after first
297
+ Enter → attempts=2, eventual success.
298
+ 11. **Regression: response shape compatibility.** When `gated: true`
299
+ is omitted (legacy path / opt-out env var), response remains
300
+ exactly the current `{ success, strategy, attempts }` shape.
301
+
302
+ ### 4.3 End-to-end reliability harness (test/e2e-submit.manual.js — new opt-in)
303
+
304
+ Not run in `npm test` (kept gated behind `TELEPTY_E2E=1`):
305
+
306
+ 12. **100× spawn-and-inject loop on fresh `claude`** (the failure mode
307
+ cited in the bug report). For each iteration:
308
+ - `telepty allow --id e2e-claude-NN claude` (background)
309
+ - `telepty inject --submit --from e2e e2e-claude-NN "ECHO: <nonce>"`
310
+ - Within 30 s, expect `<nonce>` to appear in claude's response.
311
+ - Pass criterion: ≥ 99/100 iterations succeed without manual
312
+ `send-key` follow-up. Current baseline: ~0/100.
313
+ 13. Same harness against `codex` and `gemini` sessions — ≥ 99/100.
314
+
315
+ ### 4.4 Regression coverage
316
+
317
+ 14. All 170 existing tests pass unchanged.
318
+ 15. `inject --ref` (without `--submit`) — unchanged daemon path
319
+ (`deliverInjectionToSession` mailbox+CR), unchanged tests.
320
+ 16. `send-key <id> enter` — same `/submit` endpoint, same gate,
321
+ confirms latency budget acceptable for solo-Enter use case.
322
+ 17. Non-cmux strategies (kitty-only, PTY-only) — gate still applies,
323
+ chain unchanged.
324
+ 18. Aterm sessions — `terminalLevelSubmit` already short-circuits via
325
+ `session.type === 'aterm'` guards in `deliverInjectionToSession`;
326
+ aterm path is skipped. Verified by existing test
327
+ `test/daemon.test.js:135 'inject endpoint accepts an empty prompt
328
+ and still submits enter'`.
329
+
330
+ ---
331
+
332
+ ## 5. Failed approaches (must NOT propose)
333
+
334
+ | Anti-approach | Why rejected |
335
+ |---|---|
336
+ | "Just send Enter twice" | Fresh-session welcome screens consume one Enter and may not preserve the input body across the resulting UI transition. Brute-forcing risks data loss, not just latency. |
337
+ | Hardcode `setTimeout(10000)` in `/submit` before firing | Imposes 10 s latency on every warm-session inject (regression; orchestrator fan-out runs at high frequency). Violates Rule 1 (경량). |
338
+ | Drop cmux strategy, force tmux fallback | Cross-environment regression. The orchestrator and most aigentry sessions run inside cmux; this would break their primary submit path. |
339
+ | Add a new external dependency (e.g. `xdotool`, `node-keypress`, `terminus-screen`) | Violates Rule 17 (무의존). Existing primitives suffice. |
340
+ | Spam Enter in a tight loop until the body disappears | Risks submitting partial state if claude's input echoes mid-Enter. Pollutes input on UI transitions. Indistinguishable from genuine user keyboard mashing. |
341
+ | Move the gate to the CLI (`cli.js`) | Couples CLI to in-process daemon state. Breaks remote injects (`crossMachine.remoteInject` — `cli.js:1606`). Daemon is the only component that owns `sessionStateManager` + `outputRing`, so the gate must live there. |
342
+
343
+ ---
344
+
345
+ ## 6. Constitution check
346
+
347
+ | Rule | Compliance |
348
+ |---|---|
349
+ | **Rule 1 — 경량** | ✅ Reuses existing `sessionStateManager`, `outputRing`, kitty/cmux/PTY chain. ~70 LOC net add. |
350
+ | **Rule 5 — 최선** | ✅ Closes the open-loop instead of widening the blind delay. No "차선책 workaround" of `sleep N && send-key`. |
351
+ | **Rule 13 — 비판적+건설적+객관적** | ✅ Anti-approaches and risks listed verbatim with reasons; no rhetoric. |
352
+ | **Rule 17 — 무의존** | ✅ Zero new external dependencies. |
353
+ | **Rule 26 — cross-OS** | ✅ No new per-OS branches. New code is OS-neutral pure JS. |
354
+ | **Constitution Rule 1 (AI gap)** | ✅ Removes a recurring orchestrator UX trap that wastes parallel-fanout latency budget. |
355
+ | **Constitution Rule 5 (best-first)** | ✅ Identifies and fixes the actual cause; refuses brittle workarounds. |
356
+
357
+ ---
358
+
359
+ ## 7. Invariants preserved
360
+
361
+ - ✅ `telepty list`, `telepty allow` semantics unchanged.
362
+ - ✅ `inject --ref` without `--submit` behavior unchanged (mailbox-text +
363
+ deferred `\r` path remains the default for non-`--submit` injects).
364
+ - ✅ `send-key <id> enter` still works; uses the same gated `/submit`
365
+ endpoint. Fresh-session use case for `send-key` *also* benefits.
366
+ - ✅ Non-cmux strategies (kitty, daemon PTY \r, osascript fallback)
367
+ unchanged in dispatch order.
368
+ - ✅ Output contract: `✅ Submitted via <strategy>` line preserved on
369
+ success. New optional fields (`gated`, `gate_wait_ms`, `attempts`) are
370
+ additive in the daemon JSON response; CLI may surface them only when
371
+ `--verbose` is set (out of scope; default CLI output unchanged).
372
+ - ✅ HTTP status codes: `200` on success, `503` on dispatch-failure
373
+ preserved. New `504 gate_timeout` distinguishes "we never even tried
374
+ to fire Enter because the REPL never readied" from "we tried and
375
+ failed".
376
+ - ✅ Aterm sessions unaffected (gate is bypassed for `session.type === 'aterm'`).
377
+ - ✅ Bus event `submit` still emitted on success with the same shape;
378
+ optional `gated`/`gate_wait_ms` fields added.
379
+ - ✅ Existing 170-test suite passes unchanged.
380
+
381
+ ---
382
+
383
+ ## 8. Implementation outline (for Phase 2 — orchestrator approval required first)
384
+
385
+ **Files to modify:**
386
+
387
+ | File | Change |
388
+ |---|---|
389
+ | `daemon.js` (new helper, ~40 LOC near line 643) | `awaitReplReady(sessionId, opts)` — promise that resolves when `sessionStateManager.getState(id)` reports `idle` (conf ≥ 0.85), `waiting`, or already had OSC 133 within last `idle_timeout_ms`. Bounded by `opts.timeout_ms` (default 5000). Listens via `sessionStateManager.onTransition`; immediate-resolve fast path when already ready. |
390
+ | `daemon.js` (new helper, ~25 LOC) | `verifyBodyConsumed(session, bodyText, opts)` — reads last N lines of `session.outputRing` via the existing `stripAnsi` (extract or import from line 1766). Returns truthy when normalized body text no longer present in screen tail. Bounded poll (default 1500 ms, 200 ms interval). |
391
+ | `daemon.js:1474-1520` (POST `/submit`) | Replace blind retry loop with: (a) `await awaitReplReady`, (b) single `terminalLevelSubmit`, (c) if injected body provided in POST (new optional field), `await verifyBodyConsumed` → if not consumed, one bounded retry; (d) return augmented JSON. Preserve existing shape on legacy callers (no `injected_body` field). |
392
+ | `cli.js:1641-1659` (inject --submit) | Pass `{ injected_body: injectPrompt }` in the `/submit` POST body so the daemon can verify consumption. Remove the CLI-side 500 ms `setTimeout` (gate handles timing). Keep `pre_delay_ms`/`retries`/`retry_delay_ms` accepted for back-compat but treat them as upper bounds, not floors. |
393
+ | `cli.js:1706-1730` (send-key) | Optional cosmetic: surface `gated`/`gate_wait_ms` on `--verbose`. Default output unchanged. |
394
+ | `daemon.js` env-var opt-out | `TELEPTY_SUBMIT_GATE=off` reverts to the legacy 3-attempt blind path (escape hatch for parity testing). Default: `on`. |
395
+ | `test/submit-gate.test.js` (new) | Unit tests §4.1, daemon integration §4.2. |
396
+ | `test/e2e-submit.manual.js` (new, opt-in) | §4.3 reliability harness. |
397
+ | `CHANGELOG.md` | Entry under upcoming patch (`0.2.1` or `0.3.0` depending on whether the new HTTP 504 path is judged breaking — see §10). |
398
+
399
+ **LOC estimate:** ~70 net add (helpers) + ~30 modified (endpoint refactor) + ~120 new test LOC. Total ≤ 250 LOC.
400
+
401
+ **Risk surface:** confined to one HTTP endpoint and the CLI submit branch. No bus-event schema changes, no persistence-layer changes, no state-machine changes — only *reading* state.
402
+
403
+ ---
404
+
405
+ ## 9. Reliability target
406
+
407
+ Current baseline (estimated from 2026-04-26 orchestrator evidence
408
+ across ≥ 5 incidents in one session-day): **~0%** of fresh-session
409
+ `inject --submit` calls land Enter without manual `send-key`
410
+ follow-up.
411
+
412
+ Target: **≥ 99%** in the 100× spawn-and-inject E2E harness (§4.3 #12).
413
+ Failure cases above 1% must surface a structured `gate_timeout` /
414
+ `body_not_consumed` reason for orchestrator visibility.
415
+
416
+ ---
417
+
418
+ ## 10. Open questions (Phase 2 input requested)
419
+
420
+ 1. **HTTP 504 introduction**: Adding a new `504 gate_timeout` status to
421
+ `/submit` is technically an additive change but consumers may treat
422
+ any non-2xx as fatal. Is this a patch (0.2.1) or minor (0.3.0)?
423
+ Recommendation: minor (0.3.0) since it is observable new behavior.
424
+ 2. **CLI default verbosity**: Should the gate timing (`gate_wait_ms`)
425
+ be surfaced in the default `✅ Submitted via cmux ...` line, or
426
+ only on `--verbose`? Default-on increases noise during fan-out;
427
+ default-off hides the new diagnostic value. Recommendation: hide
428
+ by default, expose via `--verbose`.
429
+ 3. **Per-CLI tuning**: claude takes 3–6 s to ready; codex and gemini
430
+ are usually faster. Should `awaitReplReady` timeout be CLI-aware
431
+ (5 s for claude, 3 s for others)? Recommendation: single 5 s
432
+ default for now; revisit only if E2E harness §4.3 shows latency
433
+ regressions on warm sessions.
434
+ 4. **Interaction with REPORT enforcement (`specs/enforce-report-spec.md`)**:
435
+ None — that spec governs what happens *after* a session goes idle
436
+ post-inject. This spec governs whether the inject ever submitted
437
+ in the first place. Orthogonal.
438
+
439
+ ---
440
+
441
+ ## 11. Phase 2 entry criteria
442
+
443
+ - Orchestrator approves Approach D.
444
+ - Open question §10.1 (semver) decided.
445
+ - Phase 2 implementation budget ≤ 250 LOC, ≤ 4 h wall.
446
+ - Phase 2 success: §4.3 harness ≥ 99/100, full suite green, no
447
+ regression on `inject --ref` (no-submit) or aterm paths.