# NousResearch/hermes-agent · Dashboard TUI async-init and PTY startup issues

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #56207. Verify it addresses your case.
- **Recommended:** read PR #56207 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-56207`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #31227: TUI hangs silently at startup — esbuild __esm helper deadlocks on @hermes/ink async init

## Prior & competing PRs

- **#56207: fix(tui): unhang dashboard TUI by breaking circular async-init cycle in @hermes/ink (#31227)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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`
  - fix hunks:
  - `ui-tui/packages/hermes-ink/index.d.ts`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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"\(\)/)
    +  })
    +})
    ```
- **#31243: fix(tui): unhang dashboard TUI by breaking circular async-init cycle in @hermes/ink (#31227)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#56207`, `#31227`

## Fix status

- Merged to main in PR #56207 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-01`.
- Subsystem: `dashboard`
