Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions src/adapters/cli/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
16 changes: 11 additions & 5 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 9 additions & 2 deletions test/cli-adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
2 changes: 1 addition & 1 deletion test/tmux-backend-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
26 changes: 15 additions & 11 deletions test/worker-pipe-initial-screen-order.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down