@dmsdc-ai/aigentry-telepty 0.6.8 → 0.6.10

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
@@ -4,6 +4,8 @@ All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.6.10] - 2026-07-05
8
+
7
9
  ### Added — seamless cross-machine on Tailscale (auto bind + auto trust) (#672)
8
10
 
9
11
  - **On a Tailscale host, a fresh install is cross-machine-ready with zero manual env.** At
@@ -31,6 +33,22 @@ All notable changes to `@dmsdc-ai/aigentry-telepty` are documented here.
31
33
  Zero-config cross-machine is Tailscale-specific; other topologies use the manual
32
34
  `TELEPTY_BIND` path. Addressing stays IP-free — use MagicDNS names with `<id>@<host>`.
33
35
 
36
+ ### Fixed — busy-target `--submit` latency (busy-dispatch fast-path) (#694)
37
+
38
+ - **A gated `--submit` to a BUSY (mid-turn) recipient no longer burns the full gate timeout.**
39
+ A busy claude sits in `working`/`thinking` — neither is a ready state — so the render-gate
40
+ (`awaitReplReady`) could never pass mid-turn and waited out the entire `gate_timeout_ms`
41
+ (default 10s) before dispatching best-effort. A new busy-dispatch fast-path detects a
42
+ **genuine ongoing turn** (`working`/`thinking` held ≥ `TELEPTY_SUBMIT_BUSY_GRACE_MS`, default
43
+ 250ms — which excludes the transient `working` a target emits while echoing our own
44
+ just-injected text) and dispatches after only the existing echo+micro-quiet settle
45
+ (`awaitInputSettled`), cutting the busy path from ~10s to **sub-second**.
46
+ - **Never fires blindly, idle path unchanged.** The downstream `gatedTerminalSubmit` still
47
+ runs its own echo+quiet gate before the `\r`; a CR into a busy composer merely queues and the
48
+ #617 hold-and-redeliver loop re-fires it on idle (delivery reliability unchanged). `idle`/
49
+ `waiting` targets never match the fast-path, so the proven idle behavior is byte-for-byte
50
+ identical. Rollback via `TELEPTY_SUBMIT_BUSY_DISPATCH=off`.
51
+
34
52
  ## [0.6.6] - 2026-06-14
35
53
 
36
54
  ### Fixed — duplicate same-id `allow` flap loop + `kill` doesn't stick (#56)
package/README.md CHANGED
@@ -299,4 +299,4 @@ npm run test:watch # Watch mode
299
299
 
300
300
  ## License
301
301
 
302
- ISC
302
+ MIT
package/cli.js CHANGED
@@ -2375,7 +2375,14 @@ async function main() {
2375
2375
  const target = await resolveSessionTarget(sessionId);
2376
2376
  if (!target) {
2377
2377
  console.error(`❌ Session '${sessionId}' was not found on any discovered host.`);
2378
- process.exit(1);
2378
+ // #691: drain-exit, do NOT process.exit() here. resolveSessionTarget →
2379
+ // discoverSessions() leaves undici keep-alive sockets and AbortSignal.timeout
2380
+ // timers still mid-close; a hard process.exit() races that teardown and trips a
2381
+ // libuv double-close assertion (src/win/async.c:76 UV_HANDLE_CLOSING) on Windows,
2382
+ // which wedges the session. Setting exitCode + returning lets the event loop close
2383
+ // each handle exactly once. Same exit code (1); success path unchanged.
2384
+ process.exitCode = 1;
2385
+ return;
2379
2386
  }
2380
2387
 
2381
2388
  let injectPrompt = prompt;
@@ -2388,7 +2395,8 @@ async function main() {
2388
2395
  const ent = checkEntitlement({ feature: 'telepty.remote_sessions' });
2389
2396
  if (!ent.allowed) {
2390
2397
  console.error(`⚠️ ${ent.reason}\n Upgrade: ${ent.upgrade_url}`);
2391
- process.exit(1);
2398
+ process.exitCode = 1; // #691: drain-exit (see not-found above), not process.exit() mid-teardown
2399
+ return;
2392
2400
  }
2393
2401
 
2394
2402
  if (useRef) {
package/daemon.js CHANGED
@@ -53,6 +53,15 @@ const REDELIVER_ENABLED = String(process.env.TELEPTY_REDELIVER || '').toLowerCas
53
53
  const REDELIVER_MAX_ATTEMPTS = Math.max(1, Number(process.env.TELEPTY_REDELIVER_MAX_ATTEMPTS || 3));
54
54
  const REDELIVER_TOTAL_TIMEOUT_MS = Math.max(1000, Number(process.env.TELEPTY_REDELIVER_TOTAL_TIMEOUT_MS || 600000));
55
55
  const REDELIVER_IDLE_WAIT_MS = Math.max(1000, Number(process.env.TELEPTY_REDELIVER_IDLE_WAIT_MS || 120000));
56
+ // #694: busy-dispatch fast-path — on a busy (mid-turn) recipient the render-gate (awaitReplReady)
57
+ // can never reach idle/waiting, so it burns the full gate timeout before best-effort dispatch.
58
+ // When enabled, a genuine ongoing turn (working/thinking held ≥ grace) dispatches after only the
59
+ // echo+micro-quiet settle instead of the full timeout. `off` restores the pure-idle-gate behavior.
60
+ const SUBMIT_BUSY_DISPATCH_ENABLED = String(process.env.TELEPTY_SUBMIT_BUSY_DISPATCH || '').toLowerCase() !== 'off';
61
+ // Grace floor separating a REAL ongoing turn from the transient `working` a target emits while
62
+ // echoing our OWN just-injected text (duration_ms ≈ 0). Default 250ms: above the echo transient,
63
+ // below any human-perceptible latency.
64
+ const SUBMIT_BUSY_GRACE_MS = Math.max(0, Number(process.env.TELEPTY_SUBMIT_BUSY_GRACE_MS || 250));
56
65
 
57
66
  // Session state machine manager — auto-detects session state from PTY output
58
67
  const sessionStateManager = new SessionStateManager({
@@ -1790,6 +1799,26 @@ function forceSubmitDeliveredToSurface(session, strategy) {
1790
1799
  return !!strategy;
1791
1800
  }
1792
1801
 
1802
+ // #678: which submit render-gate outcomes are TERMINAL (hard-fail 504, never dispatch the CR)
1803
+ // vs best-effort DISPATCH (fire the CR anyway). A missing render-state — `no_state` (no state
1804
+ // machine registered for the session) or `no_state_manager` — is NOT terminal: it says nothing
1805
+ // about PTY/ownerWs liveness. A wrapped session restored across a daemon restart has no machine
1806
+ // yet a perfectly live bridge/PTY, so we dispatch best-effort exactly like `timeout` (and if the
1807
+ // PTY/ownerWs really is gone, submitViaPty returns false → a clean 503, not a false success).
1808
+ // Only genuinely-terminal PTY states — session_dead / session_error / session_restarting — still
1809
+ // short-circuit, because writing a CR to a dead/errored/restarting PTY is pointless.
1810
+ // #694: `busy_settled` (busy-dispatch fast-path) joins the best-effort DISPATCH set — the target is
1811
+ // alive and mid-turn, so firing the CR is correct (it queues + #617 redelivers), never a hard-fail.
1812
+ const NON_TERMINAL_GATE_REASONS = new Set(['timeout', 'no_state', 'no_state_manager', 'busy_settled']);
1813
+ function isTerminalGateFailure(gateResult) {
1814
+ return !!(
1815
+ gateResult &&
1816
+ gateResult.ready !== true &&
1817
+ gateResult.reason &&
1818
+ !NON_TERMINAL_GATE_REASONS.has(gateResult.reason)
1819
+ );
1820
+ }
1821
+
1793
1822
  async function deliverInjectionToSession(id, session, prompt, options = {}) {
1794
1823
  const now = Date.now();
1795
1824
  if (!options.bypassBootstrapQueue && shouldQueueBootstrapOperation(session)) {
@@ -2066,6 +2095,11 @@ for (const [id, meta] of Object.entries(_persisted)) {
2066
2095
  if (!restored) continue;
2067
2096
  sessions[id] = restored;
2068
2097
  initializeBootstrapState(sessions[id]);
2098
+ // #678: a session restored across a daemon restart must get a render-state machine,
2099
+ // else the submit gate reads getState()=null → no_state and never fires the CR. The
2100
+ // machine is otherwise created only at first register(), which the reconnecting bridge's
2101
+ // idempotent re-register skips (see the /api/sessions/register early-return below).
2102
+ sessionStateManager.register(id);
2069
2103
  console.log(`[PERSIST] Restored session ${id} (awaiting reconnect)`);
2070
2104
  }
2071
2105
  const STRIPPED_SESSION_ENV_KEYS = [
@@ -2282,6 +2316,12 @@ app.post('/api/sessions/register', (req, res) => {
2282
2316
  // on a metadata re-register, or a session's delivered bytes would change mid-flight.
2283
2317
  if (req.body.provenance_capable === true) existing.provenanceCapable = true;
2284
2318
  existing.provenanceNonce = ensureSessionNonce(session_id);
2319
+ // #678: a bridge reconnecting to an already-known session (e.g. one restored across a
2320
+ // daemon restart, or a re-`allow` of the same id) must still end up with a render-state
2321
+ // machine. register() is idempotent (returns the existing machine untouched), so this is
2322
+ // safe for the normal re-register too — and it is the ONLY thing that recreates the
2323
+ // machine for a same-id reconnect, since a daemon restart is not required to reach here.
2324
+ sessionStateManager.register(session_id);
2285
2325
  console.log(`[REGISTER] Re-registered session ${session_id} (type: ${existing.type}, updated metadata)`);
2286
2326
  return res.status(200).json({ session_id, type: existing.type, command: existing.command, cwd: existing.cwd, reregistered: true, session_token: mintSessionToken(session_id), session_nonce: existing.provenanceNonce, provenance_capable: !!existing.provenanceCapable });
2287
2327
  }
@@ -2814,8 +2854,10 @@ function submitViaOsascript(sessionId, keyCombo) {
2814
2854
  // POST /api/sessions/:id/submit — render-gated CLI-aware submit
2815
2855
  //
2816
2856
  // Default behavior (0.3.0+): wait for the target REPL to be ready (sessionStateManager
2817
- // reports `idle`/`waiting` with confidence ≥ 0.85) before firing Enter. When the
2818
- // caller passes `injected_body`, also verify the body has been consumed (i.e.
2857
+ // reports `idle`/`waiting` with confidence ≥ 0.5, the awaitReplReady default) before firing
2858
+ // Enter. #694: a BUSY recipient (working/thinking) is never idle/waiting, so the busy-dispatch
2859
+ // fast-path below dispatches best-effort after an echo+quiet settle instead of burning the gate
2860
+ // timeout. When the caller passes `injected_body`, also verify the body has been consumed (i.e.
2819
2861
  // disappeared from the input box) by polling the session output ring; if still
2820
2862
  // visible, perform one bounded retry.
2821
2863
  //
@@ -3015,12 +3057,40 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
3015
3057
  // Hard-fail reasons (session_dead/error/restarting/no_state/no_state_manager)
3016
3058
  // still short-circuit to 504 because dispatching to a dead/missing PTY is
3017
3059
  // pointless. See spec §1.3 / §3.3.
3018
- const gateResult = await submitGate.awaitReplReady(id, sessionStateManager, {
3019
- timeoutMs: gateTimeoutMs,
3020
- ...(minConfidence !== undefined ? { minConfidence } : {}),
3021
- });
3060
+ //
3061
+ // #694 busy-dispatch fast-path: a BUSY (mid-turn) recipient sits in working/thinking — neither
3062
+ // is a READY_STATE so awaitReplReady can never pass mid-turn and would burn the FULL
3063
+ // gateTimeoutMs (up to 10s) before best-effort dispatch. When the target is a GENUINE ongoing
3064
+ // turn (isBusyDispatchState: working/thinking held ≥ SUBMIT_BUSY_GRACE_MS, which excludes the
3065
+ // transient `working` from echoing our OWN just-injected text, duration_ms ≈ 0), wait ONLY for
3066
+ // the input to settle (body echoed + micro-quiet, via awaitInputSettled) and dispatch
3067
+ // best-effort — the same downstream path as a gate timeout, but in ~hundreds of ms. idle/waiting
3068
+ // never match isBusyDispatchState, so the idle path is byte-unchanged. gatedTerminalSubmit below
3069
+ // still runs its OWN echo+quiet gate before the \r (never fires blindly), and a CR into a busy
3070
+ // composer merely queues → #617 redeliver fires it on idle. Rollback: TELEPTY_SUBMIT_BUSY_DISPATCH=off.
3071
+ let gateResult = null;
3072
+ if (injectedBody && SUBMIT_BUSY_DISPATCH_ENABLED) {
3073
+ const cur = sessionStateManager.getState(id);
3074
+ if (submitGate.isBusyDispatchState(cur, SUBMIT_BUSY_GRACE_MS)) {
3075
+ const settle = await submitGate.awaitInputSettled(session, injectedBody, {
3076
+ timeoutMs: 1200,
3077
+ quietWindowMs: 100,
3078
+ echoGraceMs: 400,
3079
+ pollIntervalMs: 30,
3080
+ stripAnsi: stripAnsiState,
3081
+ });
3082
+ gateResult = { ready: false, reason: 'busy_settled', last_state: cur.state, waited_ms: settle.waited_ms };
3083
+ console.log(`[SUBMIT] busy-dispatch fast-path ${id}: state=${cur.state} (${cur.duration_ms}ms) settle=${settle.reason} (${settle.waited_ms}ms) — dispatching without idle-gate burn`);
3084
+ }
3085
+ }
3086
+ if (!gateResult) {
3087
+ gateResult = await submitGate.awaitReplReady(id, sessionStateManager, {
3088
+ timeoutMs: gateTimeoutMs,
3089
+ ...(minConfidence !== undefined ? { minConfidence } : {}),
3090
+ });
3091
+ }
3022
3092
  const gatedDispatchAfterTimeout = !gateResult.ready;
3023
- if (gatedDispatchAfterTimeout && gateResult.reason && gateResult.reason !== 'timeout') {
3093
+ if (isTerminalGateFailure(gateResult)) {
3024
3094
  console.log(`[SUBMIT] gate hard-fail ${id}: ${gateResult.reason} (last_state=${gateResult.last_state})`);
3025
3095
  return res.status(504).json({
3026
3096
  error: 'Submit gated-timeout — target REPL not in a dispatchable state',
@@ -3034,7 +3104,9 @@ app.post('/api/sessions/:id/submit', async (req, res) => {
3034
3104
  });
3035
3105
  }
3036
3106
  if (gatedDispatchAfterTimeout) {
3037
- console.log(`[SUBMIT] gate timeout ${id}: dispatching anyway (last_state=${gateResult.last_state})`);
3107
+ // #678: `no_state`/`no_state_manager` join `timeout` here dispatch the CR best-effort
3108
+ // rather than hard-fail, so a live-bridge session with no render-state machine still submits.
3109
+ console.log(`[SUBMIT] gate ${gateResult.reason || 'not-ready'} ${id}: dispatching anyway (last_state=${gateResult.last_state})`);
3038
3110
  }
3039
3111
 
3040
3112
  // Step 2: dispatch Enter via the PTY/context path, render-gated (#568).
@@ -4523,6 +4595,7 @@ module.exports = {
4523
4595
  fireAutoReport, // #32: provenance-tagged auto-report (deps DI: now/deliver/...)
4524
4596
  maybeRecordInjectConsumption, // #619: durable early-consumption fact capture (idle-gate decay-proofing)
4525
4597
  forceSubmitDeliveredToSurface, // #544/#537/Bug B: PTY-native force-confirm (pty_cr = delivered)
4598
+ isTerminalGateFailure, // #678: submit-gate disposition — no_state is best-effort dispatch, not hard-fail
4526
4599
  terminalLevelSubmit, // #544: PTY-only submit path (pty_cr | null)
4527
4600
  submitViaPty, // #544: bare-0x0D submit into the innermost node-pty
4528
4601
  runSubmitAll, // #546: submit-all via PTY for every backend (no cmux send-key)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-telepty",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
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/tailnet-autobind.test.js test/win-firewall.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/dupid-flap-kill-stick-56.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/daemon-lifecycle-55.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/tailnet-autobind.test.js test/win-firewall.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/dupid-flap-kill-stick-56.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/daemon-lifecycle-55.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/tailnet-autobind.test.js test/win-firewall.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/dupid-flap-kill-stick-56.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/daemon-lifecycle-55.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/",
40
+ "test": "node --require ./test-support/setup-env.js --test test/auth.test.js test/http-auth.test.js test/tailnet-autobind.test.js test/win-firewall.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/dupid-flap-kill-stick-56.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/submit-gate-restore-register-678.test.js test/submit-busy-dispatch-694.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/daemon-lifecycle-55.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 test/inject-notfound-teardown-691.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/tailnet-autobind.test.js test/win-firewall.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/dupid-flap-kill-stick-56.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/submit-gate-restore-register-678.test.js test/submit-busy-dispatch-694.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/daemon-lifecycle-55.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 test/inject-notfound-teardown-691.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/tailnet-autobind.test.js test/win-firewall.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/dupid-flap-kill-stick-56.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/submit-gate-restore-register-678.test.js test/submit-busy-dispatch-694.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/daemon-lifecycle-55.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 test/inject-notfound-teardown-691.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
  },
@@ -35,6 +35,19 @@ function isFailed(state) {
35
35
  return !!(state && FAIL_STATES.has(state.state));
36
36
  }
37
37
 
38
+ // #694: a "busy-dispatch" state — the recipient is mid-turn (working/thinking, the
39
+ // ACCEPTED_AFTER_SUBMIT_STATES) and has been for at least graceMs. Used by the submit gate to
40
+ // short-circuit the idle-wait on a genuinely-busy target: working/thinking are NOT READY_STATES,
41
+ // so awaitReplReady can never pass mid-turn and would burn its full timeout before best-effort
42
+ // dispatch. The graceMs floor is what distinguishes a REAL ongoing turn from the transient
43
+ // `working` a target emits while echoing our OWN just-injected text (duration_ms ≈ 0) — so a
44
+ // fresh inject never trips the fast-path prematurely, and idle/waiting never match at all.
45
+ function isBusyDispatchState(state, graceMs) {
46
+ if (!state || !ACCEPTED_AFTER_SUBMIT_STATES.has(state.state)) return false;
47
+ const grace = Number.isFinite(graceMs) ? graceMs : 0;
48
+ return Number.isFinite(state.duration_ms) && state.duration_ms >= grace;
49
+ }
50
+
38
51
  /**
39
52
  * Wait until the session's REPL is ready to accept Enter.
40
53
  *
@@ -784,6 +797,7 @@ module.exports = {
784
797
  defaultReadScreen,
785
798
  isReady,
786
799
  isFailed,
800
+ isBusyDispatchState,
787
801
  READY_STATES,
788
802
  FAIL_STATES,
789
803
  ACCEPTED_AFTER_SUBMIT_STATES,