diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 8db15b6f6..04439c840 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -201,8 +201,18 @@ def handle_request(req: dict) -> dict | None:
def _sess(params, rid):
+ """Resolve session from params + block until its agent is ready.
+
+ `session.create` builds the agent on a background thread (~500–1500ms
+ cold) so the placeholder session may exist before `session["agent"]`
+ is populated. Any handler that dereferences `session["agent"]` should
+ go through `_sess` — the wait is a free no-op when the event is
+ already set or absent (e.g. `session.resume` builds the agent inline).
+ """
s = _sessions.get(params.get("session_id") or "")
- return (s, None) if s else (None, _err(rid, 4001, "session not found"))
+ if not s:
+ return None, _err(rid, 4001, "session not found")
+ return s, _wait_agent(s, rid)
def _normalize_completion_path(path_part: str) -> str:
@@ -1031,26 +1041,103 @@ def _history_to_messages(history: list[dict]) -> list[dict]:
# ── Methods: session ─────────────────────────────────────────────────
+def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None:
+ """Block until the session's agent has been built, returning a JSON-RPC
+ error dict if initialization failed or timed out — or ``None`` when the
+ agent is live and ready for use. Cheap no-op when there is no
+ `agent_ready` event (already-ready sessions from `session.resume`, etc.).
+ """
+ ready = session.get("agent_ready")
+ if ready is not None and not ready.is_set():
+ if not ready.wait(timeout=timeout):
+ return _err(rid, 5032, "agent initialization timed out")
+ if session.get("agent_error"):
+ return _err(rid, 5032, session["agent_error"])
+ return None
+
+
@method("session.create")
def _(rid, params: dict) -> dict:
+ """Non-blocking session creation. Returns the sid + minimal info right
+ away; the heavy agent init runs on a background thread and broadcasts a
+ `session.info` event when tools/skills are ready. Handlers that touch
+ `session["agent"]` must call `_wait_agent(session, rid)` first."""
sid = uuid.uuid4().hex[:8]
key = _new_session_key()
+ cols = int(params.get("cols", 80))
os.environ["HERMES_INTERACTIVE"] = "1"
- try:
- tokens = _set_session_context(key)
+
+ ready_event = threading.Event()
+
+ # Placeholder session so subsequent RPCs find the sid and can wait on
+ # `agent_ready`. Fields mirror `_init_session`; anything derived from
+ # the agent is filled once the build thread completes.
+ _sessions[sid] = {
+ "agent": None,
+ "agent_error": None,
+ "agent_ready": ready_event,
+ "attached_images": [],
+ "cols": cols,
+ "edit_snapshots": {},
+ "history": [],
+ "history_lock": threading.Lock(),
+ "history_version": 0,
+ "image_counter": 0,
+ "running": False,
+ "session_key": key,
+ "show_reasoning": _load_show_reasoning(),
+ "slash_worker": None,
+ "tool_progress_mode": _load_tool_progress_mode(),
+ "tool_started_at": {},
+ }
+
+ def _build() -> None:
try:
- agent = _make_agent(sid, key)
+ tokens = _set_session_context(key)
+ try:
+ agent = _make_agent(sid, key)
+ finally:
+ _clear_session_context(tokens)
+
+ _get_db().create_session(key, source="tui", model=_resolve_model())
+ _sessions[sid]["agent"] = agent
+
+ try:
+ _sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model()))
+ except Exception:
+ pass # slash.exec will surface the real failure
+
+ try:
+ from tools.approval import register_gateway_notify, load_permanent_allowlist
+ register_gateway_notify(key, lambda data: _emit("approval.request", sid, data))
+ load_permanent_allowlist()
+ except Exception:
+ pass
+
+ _wire_callbacks(sid)
+
+ info = _session_info(agent)
+ warn = _probe_credentials(agent)
+ if warn:
+ info["credential_warning"] = warn
+ _emit("session.info", sid, info)
+ except Exception as e:
+ _sessions[sid]["agent_error"] = str(e)
+ _emit("error", sid, {"message": f"agent init failed: {e}"})
finally:
- _clear_session_context(tokens)
- _get_db().create_session(key, source="tui", model=_resolve_model())
- _init_session(sid, key, agent, [], cols=int(params.get("cols", 80)))
- except Exception as e:
- return _err(rid, 5000, f"agent init failed: {e}")
- info = _session_info(agent)
- warn = _probe_credentials(agent)
- if warn:
- info["credential_warning"] = warn
- return _ok(rid, {"session_id": sid, "info": info})
+ ready_event.set()
+
+ threading.Thread(target=_build, daemon=True).start()
+
+ return _ok(rid, {
+ "session_id": sid,
+ "info": {
+ "model": _resolve_model(),
+ "tools": {},
+ "skills": {},
+ "cwd": os.getenv("TERMINAL_CWD", os.getcwd()),
+ },
+ })
@method("session.list")
@@ -1272,9 +1359,11 @@ def _(rid, params: dict) -> dict:
@method("terminal.resize")
def _(rid, params: dict) -> dict:
- session, err = _sess(params, rid)
- if err:
- return err
+ # Direct dict lookup — no agent needed; skip `_sess`'s wait-for-agent
+ # gate so TUI's initial resize doesn't block on cold session.create.
+ session = _sessions.get(params.get("session_id") or "")
+ if not session:
+ return _err(rid, 4001, "session not found")
session["cols"] = int(params.get("cols", 80))
return _ok(rid, {"cols": session["cols"]})
@@ -1284,9 +1373,9 @@ def _(rid, params: dict) -> dict:
@method("prompt.submit")
def _(rid, params: dict) -> dict:
sid, text = params.get("session_id", ""), params.get("text", "")
- session = _sessions.get(sid)
- if not session:
- return _err(rid, 4001, "session not found")
+ session, err = _sess(params, rid)
+ if err:
+ return err
with session["history_lock"]:
if session.get("running"):
return _err(rid, 4009, "session busy")
@@ -1450,9 +1539,12 @@ def _(rid, params: dict) -> dict:
@method("input.detect_drop")
def _(rid, params: dict) -> dict:
- session, err = _sess(params, rid)
- if err:
- return err
+ # Pattern-matching on text — no agent needed. Skip `_sess`'s wait so
+ # the first post-startup message doesn't add the agent-build window
+ # on top of `prompt.submit`'s own wait.
+ session = _sessions.get(params.get("session_id") or "")
+ if not session:
+ return _err(rid, 4001, "session not found")
try:
from cli import _detect_file_drop
diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts
index f2e08765e..63a306b04 100644
--- a/ui-tui/src/app/createGatewayEventHandler.ts
+++ b/ui-tui/src/app/createGatewayEventHandler.ts
@@ -167,6 +167,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
info: ev.payload,
usage: ev.payload.usage ? { ...state.usage, ...ev.payload.usage } : state.usage
}))
+ // Agent init is async in session.create, so the intro message may
+ // have been seeded with partial info (just model/cwd). Upgrade it
+ // in-place when the real session.info lands.
+ setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info: ev.payload } : m)))
return
case 'thinking.delta': {
diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx
index 48cf1c5b8..e8ae95b1e 100644
--- a/ui-tui/src/components/appLayout.tsx
+++ b/ui-tui/src/components/appLayout.tsx
@@ -37,7 +37,7 @@ const TranscriptPane = memo(function TranscriptPane({
- {row.msg.info && }
+ {row.msg.info?.version && }
) : row.msg.kind === 'panel' && row.msg.panelData ? (