NousResearch/hermes-agent

Dashboard TUI async-init and PTY startup issues

Cluster 31227 · dashboard. Data as of

Nothing open on GitHub. Fix merged in #56207 on 1 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #56207. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #56207. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items concern a circular async-initialization deadlock in @hermes/ink that hangs the TUI at startup, a blank render in PTY bridge contexts, and related PTY fixes including deferred chat startup and clamped resize dimensions for WSL2.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #56207 fix(tui): unhang dashboard TUI by breaking circular async-init cycle in @hermes/ink (#31227)

    merged to main

    files: ui-tui/packages/hermes-ink/index.d.ts, ui-tui/packages/hermes-ink/src/entry-exports.ts, ui-tui/scripts/build.mjs, ui-tui/src/__tests__/bundleNoAsyncEsmDeadlock.test.ts

    Show the diff
    • ui-tui/packages/hermes-ink/index.d.ts
      @@ -36,5 +36,6 @@ export type { Instance, RenderOptions, Root } from './src/ink/root.ts'
       export { stringWidth } from './src/ink/stringWidth.ts'
       export type { MouseTrackingMode } from './src/ink/termio/dec.ts'
       export { wrapAnsi } from './src/ink/wrapAnsi.ts'
      -export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
      -export type { Props as TextInputProps } from 'ink-text-input'
      +// 'ink-text-input' types deliberately not re-exported here; see
      +// src/entry-exports.ts for the full rationale (#31227). Use the
      +// '@hermes/ink/text-input' subpath when the upstream widget is needed.
    • ui-tui/packages/hermes-ink/src/entry-exports.ts
      @@ -29,4 +29,21 @@ export { stringWidth } from './ink/stringWidth.js'
       export { isXtermJs } from './ink/terminal.js'
       export type { MouseTrackingMode } from './ink/termio/dec.js'
       export { wrapAnsi } from './ink/wrapAnsi.js'
      -export { default as TextInput, UncontrolledTextInput } from 'ink-text-input'
      +
      +// NOTE: Do not re-export from 'ink-text-input' here.
      +//
      +// 'ink-text-input' depends on the npm 'ink' package; pulling it in from
      +// this re-export drags an entire second copy of ink (and its async
      +// top-level init chain) into any caller that bundles `@hermes/ink` from
      +// source. esbuild's `__esm` helper then deadlocks on the circular
      +// async init between the two ink graphs — the dashboard TUI bundle
      +// stalls at startup with only 141 bytes of ANSI reset output, blank
      +// screen forever (#31227).
      +//
      +// Consumers that actually want the upstream ink-text-input widget must
      +// import it via the dedicated subpath:
      +//
      +//     import TextInput from '@hermes/ink/text-input'
      +//
      +// which still resolves through this package's `./text-input` export,
      +// just outside the entry-exports surface that gets inlined by callers.
    • ui-tui/scripts/build.mjs
      @@ -35,9 +35,14 @@ await build({
         outfile: out,
         jsx: 'automatic',
         jsxImportSource: 'react',
      -  // Skip the prebuilt @hermes/ink bundle — esbuild's __esm helper doesn't
      -  // await nested async init, which breaks lazy-initialized exports like
      -  // `render`. Bundling from source sidesteps that.
      +  // Skip the prebuilt @hermes/ink bundle and inline the source instead:
      +  // (1) esbuild's `__esm` helper does not await nested async init, so the
      +  //     prebuilt bundle's lazy `render` would never resolve when nested in
      +  //     this top-level Promise.all; (2) bundling from source also lets us
      +  //     keep `ink-text-input` and the upstream `ink` graph OUT of the
      +  //     bundle entirely — re-exporting them from entry-exports created a
      +  //     circular async chain that hung the TUI at startup with only ANSI
      +  //     reset bytes on screen (#31227).
         alias: { '@hermes/ink': resolve(root, 'packages/hermes-ink/src/entry-exports.ts') },
         plugins: [stubDevtools],
         // Some transitive deps use CommonJS `require(...)` at runtime. ESM bundles
    • ui-tui/src/__tests__/bundleNoAsyncEsmDeadlock.test.ts
      @@ -0,0 +1,99 @@
      +/**
      + * Bundle-shape regression for issue #31227.
      + *
      + * The dashboard TUI ships as a single esbuild-bundled `dist/entry.js`.
      + * When the bundle contains an `async`-init `__esm` wrapper that participates
      + * in a circular module graph, esbuild's lightweight init helper deadlocks
      + * the top-level `await Promise.all([...])` in src/entry.tsx — the user
      + * sees only 141 bytes of ANSI reset sequences and a blank screen forever.
      + *
      + * Root cause: re-exporting `ink-text-input` from `@hermes/ink`'s
      + * entry-exports drags the upstream `ink` package into the bundle. That
      + * `ink` graph and our in-tree `@hermes/ink` graph reference each other
      + * via React/`ink-text-input`, producing the circular async cycle that
      + * `__esm` cannot resolve.
      + *
      + * These tests guard the two structural properties that, together,
      + * keep the bundle deadlock-free:
      + *
      + *  1. No `async` `__esm` modules in the bundle. As long as every init
      + *     runs synchronously, `__esm`'s closure-capture quirk is irrelevant.
      + *  2. No `ink-text-input` / `node_modules/ink/build` modules in the
      + *     bundle. Their absence is what makes #1 hold; if a future commit
      + *     re-introduces the re-export, it would reintroduce the cycle.
      + *
      + * The bundle is a build artifact, so the test builds it on demand and
      + * skips itself when esbuild can't be resolved (e.g. during a partial
      + * install). It does not need a TTY.
      + */
      +
      +import { execFileSync } from 'node:child_process'
      +import { existsSync, readFileSync, statSync } from 'node:fs'
      +import { dirname, resolve } from 'node:path'
      +import { fileURLToPath } from 'node:url'
      +
      +import { beforeAll, describe, expect, it } from 'vitest'
      +
      +const here = dirname(fileURLToPath(import.meta.url))
      +const uiTuiRoot = resolve(here, '..', '..')
      +const bundlePath = resolve(uiTuiRoot, 'dist', 'entry.js')
      +
      +function bundleIsFresh(): boolean {
      +  if (!existsSync(bundlePath)) return false
      +  try {
      +    const bundleMtime = statSync(bundlePath).mtimeMs
      +    const sourceMtime = statSync(
      +      resolve(uiTuiRoot, 'packages/hermes-ink/src/entry-exports.ts'),
      +    ).mtimeMs
      +    return bundleMtime >= sourceMtime
      +  } catch {
      +    return false
      +  }
      +}
      +
      +let bundleSrc = ''
      +
      +beforeAll(() => {
      +  if (!bundleIsFresh()) {
      +    // Refresh the bundle so the regression test runs against current
      +    // sources, not whatever was last committed by hand.
      +    execFileSync(
      +      process.execPath,
      +      [resolve(uiTuiRoot, 'scripts/build.mjs')],
      +      {
      +        cwd: uiTuiRoot,
      +        stdio: ['ignore', 'ignore', 'inherit'],
      +        timeout: 120_000,
      +      },
      +    )
      +  }
      +  bundleSrc = readFileSync(bundlePath, 'utf8')
      +}, 180_000)
      +
      +describe('TUI bundle (issue #31227)', () => {
      +  it('has no async __esm wrappers (would risk circular-await deadlock)', () => {
      +    // esbuild emits `async "<path>"() { ... }` as the first key of a
      +    // module's `__esm` definition when the module body contains
      +    // top-level await. The lightweight `__esm` helper at the top of
      +    // the bundle does NOT await nested inits, so any async __esm
      +    // module in a circular graph hangs forever the first time it's
      +    // entered.
      +    const matches = bundleSrc.match(/async "(packages|src|node_modules)\/[^"]+"\s*\(\)/g) ?? []
      +    expect(matches, `Found ${matches.length} async __esm wrappers — these can deadlock #31227. First few:\n${matches.slice(0, 3).join('\n')}`).toEqual([])
      +  })
      +
      +  it('does not bundle the upstream ink package or ink-text-input', () => {
      +    // Pulling either of these in re-creates the circular async chain
      +    // that #31227 was about. The in-tree fork at @hermes/ink replaces
      +    // all of `ink`; nothing in ui-tui imports `TextInput` from
      +    // `@hermes/ink` so the re-export is unused dead weight.
      +    expect(bundleSrc.includes('node_modules/ink/build/index.js')).toBe(false)
      +    expect(bundleSrc.includes('node_modules/ink-text-input/build/index.js')).toBe(false)
      +  })
      +
      +  it('has the @hermes/ink entry-exports module compiled to sync init', () => {
      +    // Sanity check that the alias swap to packages/hermes-ink/src/entry-exports.ts
      +    // is still active and producing the expected synchronous init shape.
      +    expect(bundleSrc).toMatch(/var init_entry_exports = __esm\(\{\s*"packages\/hermes-ink\/src\/entry-exports\.ts"\(\)/)
      +  })
      +})

1 PR closed without merging

From the close comments: merged via another change 1

  • #31243 fix(tui): unhang dashboard TUI by breaking circular async-init cycle in @hermes/ink (#31227) · merged via another change → #56207, #31227
Closed issues · 1
  • Closed#31227TUI hangs silently at startup — esbuild __esm helper deadlocks on @hermes/ink async init
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
dashboard
Merged PRs
#56207
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.