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