diff --git a/src/adapters/cli/hermes.ts b/src/adapters/cli/hermes.ts index 1665f2d88..9fb8ab189 100644 --- a/src/adapters/cli/hermes.ts +++ b/src/adapters/cli/hermes.ts @@ -61,12 +61,25 @@ export function createHermesAdapter(pathOverride?: string): CliAdapter { readyPattern: /❯/, completionPattern: undefined, systemHints: BOTMUX_SHELL_HINTS, - // Hermes emits an explicit BOTMUX_READY_COMMAND once prompt_toolkit's real - // input composer has rendered. Arm Botmux's ready-gate so the first queued - // Lark prompt waits for that true-ready signal instead of relying on the - // screen-level ❯ marker alone. Do not opt into type-ahead: before the first - // prompt Hermes can silently drop input typed during TUI initialization. - injectsReadyHook: true, + // Do NOT arm Botmux's ready-gate for Hermes. #353 set injectsReadyHook here + // on the premise that Hermes shell-executes BOTMUX_READY_COMMAND once its + // prompt_toolkit composer renders — a cross-repo contract the shipped Hermes + // never honored: `grep BOTMUX_READY_COMMAND` across hermes-agent 0.18.x is + // empty, and Hermes exposes no composer-ready hook at all (its shell-hooks + // fire only at turn boundaries — on_session_start emits from + // conversation_loop AFTER the first prompt is already submitted, too late to + // gate the first prompt on). So the signal never arrives and the gate always + // falls through its 45s READY_SIGNAL_TIMEOUT_MS, delaying the FIRST cold-start + // message by ~45s even though the real ❯ composer is up in ~3.6s. + // + // Empirically the ❯ readyPattern above IS the earliest reliable readiness + // signal: a PTY probe of the real binary shows the fully-chromed input box + // (border + status bar + "/help for commands") at ~3.6s, and Hermes has NO + // cjadk-style startup selector that would make ❯ a false positive. So we rely + // on the IdleDetector's ❯ match + deferFirstPromptTimeoutUntilReady (queue + // the first message until the real ❯ appears, 90s hard cap). Do NOT opt into + // type-ahead: before the first prompt Hermes can silently drop input typed + // during TUI initialization (see #342). deferFirstPromptTimeoutUntilReady: true, altScreen: false, }; diff --git a/src/worker.ts b/src/worker.ts index 911909e8b..dea3c19fe 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1301,13 +1301,15 @@ let isSettlingFirstFlush = false; * later markPromptReady call would return early with the first prompt stranded. */ let promptReadyDetectedDuringSettle = false; /** While the ready-gate is holding, the IdleDetector may still fire on a real - * readyPattern (e.g. Hermes's ❯) — proving the input box exists — but + * readyPattern (e.g. grok's ❯) — proving the input box exists — but * markPromptReady() returns early because the gate is armed. Record that the * pattern was seen so the gate's timeout-fallback settle can mark the prompt * ready immediately instead of delivering into a !isPromptReady state that - * flushPending() rejects for non-type-ahead adapters. Without this, a Hermes - * spawn that renders ❯ but never fires BOTMUX_READY_COMMAND waits the full - * hard timeout (and previously never delivered at all). */ + * flushPending() rejects for non-type-ahead adapters. Without this, a ready- + * gated spawn that renders ❯ but never fires its SessionStart signal waits the + * full hard timeout (and previously never delivered at all). (Hermes used to be + * the example here; it no longer arms the gate — see hermes.ts — because the + * shipped binary never emitted BOTMUX_READY_COMMAND.) */ let readyPatternSeenDuringHold = false; /** Claude's SessionStart hooks run in parallel. Its botmux hook proves the * startup selector is behind us, but sibling project hooks may still be @@ -8209,7 +8211,11 @@ async function spawnCli( lastPtyOutputAtMs = Date.now(); const readyHookAvailable = effectiveReadyHookInstall ? hasInstalledSessionReadyHook(effectiveReadyHookInstall) - : true; // Hermes emits BOTMUX_READY_COMMAND directly instead of a config hook. + : true; // No config-file hook to verify → assume a direct ready-command + // integration (env-injected BOTMUX_READY_COMMAND). No current adapter + // takes this branch: claude-code and grok both ship a hookInstall + // config. (Hermes formerly did, on a BOTMUX_READY_COMMAND contract the + // shipped binary never honored — it no longer sets injectsReadyHook.) const isolatedReadyTransportRequired = sandboxRequested || credentialBoundaryActive; const readyPortAvailable = !isolatedReadyTransportRequired || parseDaemonIpcPort(childEnv.BOTMUX_DAEMON_IPC_PORT) !== undefined; diff --git a/test/cli-adapters.test.ts b/test/cli-adapters.test.ts index 00a2ef660..51ca47bed 100644 --- a/test/cli-adapters.test.ts +++ b/test/cli-adapters.test.ts @@ -1060,8 +1060,15 @@ describe('hermes buildArgs', () => { expect(adapter.passesInitialPromptViaArgs).toBeFalsy(); }); - it('uses explicit ready signal gate without type-ahead', () => { - expect(adapter.injectsReadyHook).toBe(true); + it('relies on ❯ readyPattern without ready-gate or type-ahead', () => { + // #353 armed the ready-gate via injectsReadyHook on the premise that Hermes + // shell-executes BOTMUX_READY_COMMAND at composer-ready. The shipped Hermes + // never honored that contract (no composer-ready hook exists), so the gate + // always fell through its 45s timeout, delaying the first cold-start message. + // Hermes must NOT arm the gate; its ❯ readyPattern (input box up in ~3.6s) is + // the earliest reliable readiness signal. + expect(adapter.injectsReadyHook).toBeFalsy(); + expect(adapter.readyPattern?.source).toBe('❯'); expect(adapter.deferFirstPromptTimeoutUntilReady).toBe(true); expect(adapter.supportsTypeAhead).toBeFalsy(); }); diff --git a/test/tmux-backend-env.test.ts b/test/tmux-backend-env.test.ts index bf7a5b6b9..38a806b45 100644 --- a/test/tmux-backend-env.test.ts +++ b/test/tmux-backend-env.test.ts @@ -123,7 +123,7 @@ describe('buildBotmuxEnvAssignments()', () => { expect(out).not.toContain('PATH=/usr/bin'); }); - it('forwards BOTMUX_READY_COMMAND so Hermes can release the first-prompt ready gate', () => { + it('forwards BOTMUX_READY_COMMAND so ready-hook CLIs can release the first-prompt ready gate', () => { const out = buildBotmuxEnvAssignments({ BOTMUX: '1', BOTMUX_READY_COMMAND: '"/usr/local/bin/node" "/opt/botmux/dist/cli.js" session-ready', diff --git a/test/worker-pipe-initial-screen-order.test.ts b/test/worker-pipe-initial-screen-order.test.ts index 4ab2de602..56d3cab00 100644 --- a/test/worker-pipe-initial-screen-order.test.ts +++ b/test/worker-pipe-initial-screen-order.test.ts @@ -174,10 +174,13 @@ describe('worker pipe initial screen ordering', () => { }); it('forces the first prompt for non-type-ahead adapters at the hard timeout', () => { - // THE HERMES FIX (hard-timeout path): previously the hard cap only logged - // "forcing queued message flush" and flushed for type-ahead adapters only; - // non-type-ahead adapters (Hermes) never delivered. The release must now - // route non-type-ahead adapters to markPromptReady() (which then flushes). + // Hard-timeout path (originally "THE HERMES FIX"): previously the hard cap + // only logged "forcing queued message flush" and flushed for type-ahead + // adapters only; non-type-ahead adapters never delivered. The release must + // now route non-type-ahead adapters to markPromptReady() (which then + // flushes). (Hermes drove this originally; it no longer arms the gate — see + // hermes.ts — but the mechanism still guards any non-type-ahead ready-gated + // adapter.) const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); const fallbackStart = source.indexOf('const releaseFirstPromptTimeout ='); const decideIdx = source.indexOf("decideHardTimeoutAction(cliAdapter?.supportsTypeAhead === true)", fallbackStart); @@ -194,13 +197,14 @@ describe('worker pipe initial screen ordering', () => { it('honors a true ready signal that arrives AFTER the timeout fallback (slow cold start)', () => { // ReadyGate.receive() is one-shot: once the 45s fallback fires, a later - // releaseReadyGate from the real signal is skipped entirely. A CLI whose - // cold start exceeds READY_SIGNAL_TIMEOUT_MS (Hermes: 2-3 min) would then - // never take the authoritative markPromptReady path. The session_ready case - // must detect the late arrival (gate armed + already received) and mark - // prompt-ready directly for authoritative non-Claude signals. Claude waits - // for post-hook prompt evidence instead. Both paths are limited to the - // first-prompt phase, so clear/compact SessionStart stays a no-op. + // releaseReadyGate from the real signal is skipped entirely. A ready-gated + // CLI whose cold start exceeds READY_SIGNAL_TIMEOUT_MS would then never take + // the authoritative markPromptReady path. The session_ready case must detect + // the late arrival (gate armed + already received) and mark prompt-ready + // directly for authoritative non-Claude signals. Claude waits for post-hook + // prompt evidence instead. Both paths are limited to the first-prompt phase, + // so clear/compact SessionStart stays a no-op. (Hermes drove this originally + // via its 2-3 min cold start; it no longer arms the gate — see hermes.ts.) const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); const sessionReadyCase = source.indexOf("case 'session_ready'"); const lateCheckIdx = source.indexOf('readyGate.isArmed && readyGate.isReceived', sessionReadyCase);