monkeybot-cli 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. monkeybot_cli/__init__.py +3 -0
  2. monkeybot_cli/chat_renderer.py +87 -0
  3. monkeybot_cli/chat_session.py +911 -0
  4. monkeybot_cli/chat_status_bar.py +205 -0
  5. monkeybot_cli/chat_theme.py +91 -0
  6. monkeybot_cli/chat_tool_display.py +334 -0
  7. monkeybot_cli/chat_tui.py +1491 -0
  8. monkeybot_cli/chat_tui_widgets.py +996 -0
  9. monkeybot_cli/commands/__init__.py +1 -0
  10. monkeybot_cli/commands/chat.py +817 -0
  11. monkeybot_cli/commands/doctor.py +293 -0
  12. monkeybot_cli/commands/loop.py +207 -0
  13. monkeybot_cli/commands/new.py +207 -0
  14. monkeybot_cli/commands/run_cmd.py +41 -0
  15. monkeybot_cli/commands/talk.py +102 -0
  16. monkeybot_cli/commands/validate.py +385 -0
  17. monkeybot_cli/compat.py +7 -0
  18. monkeybot_cli/config_resolve.py +55 -0
  19. monkeybot_cli/exit_commands.py +13 -0
  20. monkeybot_cli/extras_catalog.py +95 -0
  21. monkeybot_cli/gateway_health.py +34 -0
  22. monkeybot_cli/main.py +38 -0
  23. monkeybot_cli/opensandbox_lifecycle.py +314 -0
  24. monkeybot_cli/output.py +110 -0
  25. monkeybot_cli/providers.py +112 -0
  26. monkeybot_cli/realtime/__init__.py +13 -0
  27. monkeybot_cli/realtime/audio_io.py +147 -0
  28. monkeybot_cli/realtime/client.py +17 -0
  29. monkeybot_cli/realtime/gateway_manager.py +142 -0
  30. monkeybot_cli/realtime/push_to_talk.py +128 -0
  31. monkeybot_cli/realtime/session.py +256 -0
  32. monkeybot_cli/realtime/session_controller.py +501 -0
  33. monkeybot_cli/realtime/talk_ui.py +243 -0
  34. monkeybot_cli/realtime/wire_encode.py +39 -0
  35. monkeybot_cli/runtime_python.py +91 -0
  36. monkeybot_cli/scaffold.py +287 -0
  37. monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
  38. monkeybot_cli/scaffold_defaults/__init__.py +1 -0
  39. monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
  40. monkeybot_cli/scaffold_defaults/env.example +35 -0
  41. monkeybot_cli/scaffold_defaults/mcp.json +49 -0
  42. monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
  43. monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
  44. monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
  45. monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
  46. monkeybot_cli/session_controller.py +7 -0
  47. monkeybot_cli/terminal_markdown.py +48 -0
  48. monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
  49. monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
  50. monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
  51. monkeybot_cli-0.2.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,911 @@
1
+ """Gateway session controller for ``monkeybot chat`` (no terminal I/O)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import logging
9
+ import os
10
+ import uuid
11
+ from collections import deque
12
+ from collections.abc import AsyncIterator, Callable
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Literal
15
+
16
+ import httpx
17
+ from monkeybot.core.runtime.events import (
18
+ ActionRequiredEvent,
19
+ AssistantDelta,
20
+ ContextSummarized,
21
+ ContextSummarizing,
22
+ Error,
23
+ FrontendToolRequestEvent,
24
+ GroundingEvent,
25
+ ThinkingBlockComplete,
26
+ ThinkingBlockDelta,
27
+ ToolCallResult,
28
+ ToolCallStarted,
29
+ ToolConfirmationRequestEvent,
30
+ TurnComplete,
31
+ event_from_json,
32
+ )
33
+
34
+ from monkeybot_cli.chat_status_bar import UsageStore, parse_usage_response
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ EmitFn = Callable[["ChatUiEvent"], None]
39
+ HitlReaderFn = Callable[["HitlRequest"], Any] # async (HitlRequest) -> HitlAnswer
40
+
41
+ HitlKind = Literal["confirm", "elicit", "frontend_unsupported"]
42
+
43
+ _HITL_TYPES = (ToolConfirmationRequestEvent, ActionRequiredEvent, FrontendToolRequestEvent)
44
+
45
+ _RECONNECT_INITIAL_DELAY = 0.5
46
+ _RECONNECT_MAX_DELAY = 15.0
47
+ _RECONNECT_MAX_ATTEMPTS = 20
48
+ _ARGS_PREVIEW_LIMIT = 200
49
+
50
+
51
+ def pending_response_timeout_sec() -> float:
52
+ """Mirror gateway ``PENDING_RESPONSE_TIMEOUT_SEC`` (default 300)."""
53
+ try:
54
+ return float(os.environ.get("PENDING_RESPONSE_TIMEOUT_SEC", "300"))
55
+ except ValueError:
56
+ return 300.0
57
+
58
+
59
+ def format_schema_field_lines(schema: dict[str, Any]) -> list[str]:
60
+ """One labeled line per JSON-schema property for HITL display."""
61
+ props = schema.get("properties")
62
+ if not isinstance(props, dict) or not props:
63
+ return []
64
+ lines: list[str] = []
65
+ for name, raw in props.items():
66
+ if not isinstance(name, str):
67
+ continue
68
+ spec = raw if isinstance(raw, dict) else {}
69
+ typ = spec.get("type")
70
+ type_s = str(typ) if typ is not None else "any"
71
+ desc = spec.get("description")
72
+ label = f" {name} ({type_s})"
73
+ if isinstance(desc, str) and desc.strip():
74
+ label = f"{label} — {desc.strip()}"
75
+ lines.append(label)
76
+ return lines
77
+
78
+
79
+ def format_args_preview(arguments: dict[str, Any], *, limit: int = _ARGS_PREVIEW_LIMIT) -> str:
80
+ try:
81
+ raw = json.dumps(arguments, default=str, ensure_ascii=False)
82
+ except TypeError:
83
+ raw = str(arguments)
84
+ if len(raw) > limit:
85
+ return raw[: limit - 1] + "…"
86
+ return raw
87
+
88
+
89
+ def extract_elicitation_fields(payload: dict[str, Any]) -> tuple[str | None, dict[str, Any] | None]:
90
+ """Return (message, schema) from an ActionRequiredEvent payload."""
91
+ message = payload.get("message")
92
+ if not isinstance(message, str) or not message.strip():
93
+ message = None
94
+ else:
95
+ message = message.strip()
96
+ schema_raw = (
97
+ payload.get("requestedSchema")
98
+ or payload.get("requested_schema")
99
+ or payload.get("schema")
100
+ )
101
+ schema = dict(schema_raw) if isinstance(schema_raw, dict) else None
102
+ return message, schema
103
+
104
+
105
+ def format_hitl_plain_prompt(req: "HitlRequest") -> str:
106
+ """Multi-line prompt for the plain (non-TTY) HITL reader."""
107
+ parts: list[str] = [req.prompt]
108
+ if req.kind == "confirm" and req.tool_name:
109
+ parts.append(f" tool: {req.tool_name}")
110
+ if req.arguments:
111
+ parts.append(f" args: {format_args_preview(req.arguments)}")
112
+ if req.schema:
113
+ field_lines = format_schema_field_lines(req.schema)
114
+ if field_lines:
115
+ parts.append(" fields:")
116
+ parts.extend(field_lines)
117
+ if req.timeout_sec is not None and req.timeout_sec > 0:
118
+ parts.append(f" (timeout {int(req.timeout_sec)}s)")
119
+ if req.kind == "confirm":
120
+ parts.append(" [y/n]")
121
+ return "\n".join(parts)
122
+
123
+
124
+ @dataclass(frozen=True)
125
+ class HitlRequest:
126
+ kind: HitlKind
127
+ prompt: str
128
+ tool_call_id: str = ""
129
+ elicitation_id: str = ""
130
+ tool_name: str = ""
131
+ schema: dict[str, Any] | None = None
132
+ arguments: dict[str, Any] = field(default_factory=dict)
133
+ timeout_sec: float | None = None
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class HitlAnswer:
138
+ cancelled: bool = False
139
+ approved: bool | None = None
140
+ text: str = ""
141
+ user_data: Any = None
142
+
143
+
144
+ @dataclass
145
+ class ChatUiEvent:
146
+ kind: str
147
+ payload: dict[str, Any] = field(default_factory=dict)
148
+
149
+
150
+ @dataclass
151
+ class _TurnState:
152
+ assistant_started: bool = False
153
+ done: bool = False
154
+
155
+
156
+ @dataclass
157
+ class SseFrame:
158
+ """One SSE data payload plus optional event id from an ``id:`` line."""
159
+
160
+ data: str
161
+ event_id: int | None = None
162
+
163
+
164
+ def format_http_error(operation: str, exc: BaseException) -> str:
165
+ return f"{operation} failed: {exc}"
166
+
167
+
168
+ async def iter_sse_frames(resp: httpx.Response) -> AsyncIterator[SseFrame]:
169
+ """Yield SSE data frames, tracking ``id:`` lines for Last-Event-ID reconnect."""
170
+ data_lines: list[str] = []
171
+ event_id: int | None = None
172
+ async for line in resp.aiter_lines():
173
+ if line.startswith(":"):
174
+ continue
175
+ if line.startswith("id:"):
176
+ raw = line[3:].strip()
177
+ with contextlib.suppress(ValueError):
178
+ event_id = int(raw)
179
+ continue
180
+ if line.startswith("data:"):
181
+ data_lines.append(line[5:].lstrip())
182
+ continue
183
+ if line == "" and data_lines:
184
+ yield SseFrame(data="\n".join(data_lines), event_id=event_id)
185
+ data_lines = []
186
+ event_id = None
187
+ if data_lines:
188
+ yield SseFrame(data="\n".join(data_lines), event_id=event_id)
189
+
190
+
191
+ async def iter_sse_lines(resp: httpx.Response) -> AsyncIterator[str]:
192
+ """Yield SSE data payloads (compat wrapper around :func:`iter_sse_frames`)."""
193
+ async for frame in iter_sse_frames(resp):
194
+ yield frame.data
195
+
196
+
197
+ class ChatSessionController:
198
+ """Owns HTTP session + SSE turn loop. Emits UI events; never writes stdout."""
199
+
200
+ turn_based: bool = True
201
+
202
+ def __init__(
203
+ self,
204
+ *,
205
+ base: str,
206
+ model_provider: str | None = None,
207
+ model_name: str | None = None,
208
+ show_thinking: bool = False,
209
+ verbose: bool = False,
210
+ show_usage: bool = False,
211
+ emit: EmitFn | None = None,
212
+ hitl_reader: HitlReaderFn | None = None,
213
+ resume_session_id: str | None = None,
214
+ ) -> None:
215
+ self.base = base.rstrip("/")
216
+ self.model_provider = model_provider
217
+ self.model_name = model_name
218
+ self.show_thinking = show_thinking
219
+ self.verbose = verbose
220
+ self.show_usage = show_usage
221
+ self._emit_fn = emit or (lambda _e: None)
222
+ self._hitl_reader = hitl_reader
223
+ self._resume_session_id = (
224
+ resume_session_id.strip()
225
+ if isinstance(resume_session_id, str) and resume_session_id.strip()
226
+ else None
227
+ )
228
+ self.usage = UsageStore()
229
+ self.session_id: str | None = None
230
+ self.stream_error = False
231
+ self._client: httpx.AsyncClient | None = None
232
+ self._event_queue: asyncio.Queue[str | None] = asyncio.Queue()
233
+ self._stream_task: asyncio.Task[None] | None = None
234
+ self._stream_alive = True
235
+ self._reconnecting = False
236
+ self._last_event_id: int | None = None
237
+ self._abandoned: deque[str] = deque(maxlen=64)
238
+ self._turn_abort = asyncio.Event()
239
+ self._active_request_id: str | None = None
240
+ self._hitl_future: asyncio.Future[HitlAnswer] | None = None
241
+ self._closed = False
242
+ self._last_cancel_ok: bool | None = None
243
+ self._cancel_tasks: set[asyncio.Task[None]] = set()
244
+
245
+ def _emit(self, kind: str, **payload: Any) -> None:
246
+ self._emit_fn(ChatUiEvent(kind=kind, payload=payload))
247
+
248
+ def set_emit(self, emit: EmitFn) -> None:
249
+ """Replace the UI event sink (used when the TUI takes ownership)."""
250
+ self._emit_fn = emit
251
+
252
+ def _warn(self, message: str) -> None:
253
+ logger.warning("%s", message)
254
+
255
+ def _session_body(self, *, session_id: str | None = None) -> dict[str, Any]:
256
+ body: dict[str, Any] = {}
257
+ if session_id:
258
+ body["session_id"] = session_id
259
+ if self.model_provider:
260
+ body["model_provider"] = self.model_provider
261
+ if self.model_name:
262
+ body["model_name"] = self.model_name
263
+ return body
264
+
265
+ async def connect(self, resume_session_id: str | None = None) -> None:
266
+ """Open HTTP client (if needed), create/attach session, start SSE + backfill."""
267
+ timeout = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0)
268
+ if self._client is None:
269
+ self._client = httpx.AsyncClient(timeout=timeout)
270
+ assert self._client is not None
271
+ self._closed = False
272
+ try:
273
+ health = await self._client.get(f"{self.base}/health")
274
+ health.raise_for_status()
275
+ except httpx.HTTPError as exc:
276
+ raise RuntimeError(f"Gateway not reachable at {self.base}: {exc}") from exc
277
+
278
+ sid = resume_session_id or self._resume_session_id
279
+ if isinstance(sid, str) and sid.strip():
280
+ await self._attach_or_create_session(sid.strip())
281
+ else:
282
+ await self._create_session()
283
+
284
+ self._stream_alive = True
285
+ self.stream_error = False
286
+ self._reconnecting = False
287
+ self._last_event_id = None
288
+ self._stream_task = asyncio.create_task(self._consume_stream())
289
+ self._emit("session_ready", session_id=self.session_id)
290
+ self._emit("connection_state", state="connected")
291
+ await self._emit_transcript_backfill()
292
+
293
+ async def _create_session(self) -> None:
294
+ assert self._client is not None
295
+ try:
296
+ create = await self._client.post(f"{self.base}/sessions", json=self._session_body())
297
+ create.raise_for_status()
298
+ except httpx.HTTPError as exc:
299
+ raise RuntimeError(format_http_error("Session creation", exc)) from exc
300
+ self.session_id = create.json()["session_id"]
301
+
302
+ async def _attach_or_create_session(self, session_id: str) -> None:
303
+ assert self._client is not None
304
+ live = False
305
+ try:
306
+ probe = await self._client.get(f"{self.base}/sessions/{session_id}/usage")
307
+ if probe.status_code == 200:
308
+ live = True
309
+ elif probe.status_code != 404:
310
+ probe.raise_for_status()
311
+ except httpx.HTTPError as exc:
312
+ if not isinstance(exc, httpx.HTTPStatusError) or exc.response.status_code != 404:
313
+ raise RuntimeError(format_http_error("Session probe", exc)) from exc
314
+
315
+ if live:
316
+ self.session_id = session_id
317
+ return
318
+
319
+ try:
320
+ create = await self._client.post(
321
+ f"{self.base}/sessions",
322
+ json=self._session_body(session_id=session_id),
323
+ )
324
+ if create.status_code == 409:
325
+ self.session_id = session_id
326
+ return
327
+ create.raise_for_status()
328
+ except httpx.HTTPError as exc:
329
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 409:
330
+ self.session_id = session_id
331
+ return
332
+ raise RuntimeError(format_http_error("Session creation", exc)) from exc
333
+ self.session_id = create.json().get("session_id") or session_id
334
+
335
+ async def _emit_transcript_backfill(self) -> None:
336
+ assert self._client is not None and self.session_id is not None
337
+ try:
338
+ resp = await self._client.get(f"{self.base}/api/chat-history/{self.session_id}")
339
+ if resp.status_code in (404, 503):
340
+ return
341
+ resp.raise_for_status()
342
+ data = resp.json()
343
+ except (httpx.HTTPError, ValueError) as exc:
344
+ self._warn(f"chat history backfill skipped: {exc}")
345
+ return
346
+ messages = data.get("messages") or []
347
+ if not isinstance(messages, list) or not messages:
348
+ return
349
+ wire: list[dict[str, str]] = []
350
+ for item in messages:
351
+ if not isinstance(item, dict):
352
+ continue
353
+ role = str(item.get("role") or "")
354
+ text = str(item.get("text") or "")
355
+ if role in ("user", "assistant") and text:
356
+ wire.append({"role": role, "text": text})
357
+ if wire:
358
+ self._emit("transcript_backfill", messages=wire)
359
+
360
+ async def _consume_stream(self) -> None:
361
+ """Read SSE forever, reconnecting with backoff until closed or session gone."""
362
+ assert self._client is not None and self.session_id is not None
363
+ delay = _RECONNECT_INITIAL_DELAY
364
+ attempts = 0
365
+ first_connect = True
366
+
367
+ while not self._closed:
368
+ outcome = await self._pump_sse_connection(first_connect=first_connect)
369
+ if outcome == "fatal":
370
+ return
371
+ if outcome == "open":
372
+ first_connect = False
373
+ attempts = 0
374
+ delay = _RECONNECT_INITIAL_DELAY
375
+ if self._closed:
376
+ return
377
+ attempts += 1
378
+ if attempts > _RECONNECT_MAX_ATTEMPTS:
379
+ self._stream_alive = False
380
+ self.stream_error = True
381
+ self._reconnecting = False
382
+ self._emit(
383
+ "stream_failed",
384
+ message="Event stream lost after repeated reconnect attempts",
385
+ fatal=True,
386
+ )
387
+ await self._event_queue.put(None)
388
+ return
389
+ delay = await self._reconnect_backoff(attempts, delay)
390
+
391
+ async def _pump_sse_connection(
392
+ self, *, first_connect: bool
393
+ ) -> Literal["open", "retry", "fatal"]:
394
+ """Open one SSE connection and pump frames until EOF, retryable error, or fatal."""
395
+ assert self._client is not None and self.session_id is not None
396
+ headers: dict[str, str] = {}
397
+ if self._last_event_id is not None:
398
+ headers["Last-Event-ID"] = str(self._last_event_id)
399
+ opened = False
400
+ try:
401
+ async with self._client.stream(
402
+ "GET",
403
+ f"{self.base}/sessions/{self.session_id}/events",
404
+ headers=headers,
405
+ ) as resp:
406
+ if resp.status_code == 404:
407
+ self._stream_alive = False
408
+ self.stream_error = True
409
+ self._reconnecting = False
410
+ self._emit(
411
+ "stream_failed",
412
+ message="Session not found on gateway",
413
+ fatal=True,
414
+ )
415
+ await self._event_queue.put(None)
416
+ return "fatal"
417
+ resp.raise_for_status()
418
+ opened = True
419
+ if not first_connect:
420
+ self.stream_error = False
421
+ self._reconnecting = False
422
+ self._emit("connection_state", state="connected")
423
+ self._emit("session_ready", session_id=self.session_id)
424
+ async for frame in iter_sse_frames(resp):
425
+ if frame.event_id is not None:
426
+ self._last_event_id = frame.event_id
427
+ await self._event_queue.put(frame.data)
428
+ return "open" if opened else "fatal"
429
+ except asyncio.CancelledError:
430
+ raise
431
+ except Exception as exc:
432
+ if self._closed:
433
+ return "fatal"
434
+ status = None
435
+ if isinstance(exc, httpx.HTTPStatusError):
436
+ status = exc.response.status_code
437
+ if status == 404:
438
+ self._stream_alive = False
439
+ self.stream_error = True
440
+ self._reconnecting = False
441
+ self._emit(
442
+ "stream_failed",
443
+ message=format_http_error("Event stream", exc),
444
+ fatal=True,
445
+ )
446
+ await self._event_queue.put(None)
447
+ return "fatal"
448
+ logger.warning("SSE stream error; will reconnect: %s", exc)
449
+ return "open" if opened else "retry"
450
+
451
+ async def _reconnect_backoff(self, attempts: int, delay: float) -> float:
452
+ self._reconnecting = True
453
+ # Keep stream_alive True so the REPL/TUI stay up during backoff.
454
+ self._emit(
455
+ "connection_state",
456
+ state="reconnecting",
457
+ attempt=attempts,
458
+ delay=delay,
459
+ )
460
+ try:
461
+ await asyncio.sleep(delay)
462
+ except asyncio.CancelledError:
463
+ raise
464
+ return min(_RECONNECT_MAX_DELAY, delay * 2)
465
+
466
+ def abort_turn(self) -> None:
467
+ self._turn_abort.set()
468
+ if self._hitl_future is not None and not self._hitl_future.done():
469
+ self._hitl_future.set_result(HitlAnswer(cancelled=True))
470
+ request_id = self._active_request_id
471
+ if request_id and self._client is not None and self.session_id is not None:
472
+ task = asyncio.create_task(self._post_cancel(request_id))
473
+ self._cancel_tasks.add(task)
474
+ task.add_done_callback(self._cancel_tasks.discard)
475
+
476
+ async def _post_cancel(self, request_id: str) -> None:
477
+ assert self._client is not None and self.session_id is not None
478
+ try:
479
+ resp = await self._client.post(
480
+ f"{self.base}/sessions/{self.session_id}/cancel",
481
+ json={"request_id": request_id},
482
+ )
483
+ resp.raise_for_status()
484
+ self._last_cancel_ok = True
485
+ except httpx.HTTPError as exc:
486
+ self._last_cancel_ok = False
487
+ logger.warning("cancel POST failed request_id=%s: %s", request_id, exc)
488
+
489
+ def provide_hitl_answer(self, answer: HitlAnswer) -> None:
490
+ if self._hitl_future is not None and not self._hitl_future.done():
491
+ self._hitl_future.set_result(answer)
492
+
493
+ async def submit(self, message: str) -> None:
494
+ if self._client is None or self.session_id is None:
495
+ raise RuntimeError("Session not connected")
496
+ if not self._stream_alive or self._reconnecting:
497
+ self._emit("error", message="Not connected — wait for reconnect")
498
+ return
499
+
500
+ request_id = str(uuid.uuid4())
501
+ self._active_request_id = request_id
502
+ self._last_cancel_ok = None
503
+ self._turn_abort.clear()
504
+ try:
505
+ reply = await self._client.post(
506
+ f"{self.base}/sessions/{self.session_id}/reply",
507
+ json={"request_id": request_id, "message": message},
508
+ )
509
+ if reply.status_code == 409:
510
+ self._emit("session_busy")
511
+ return
512
+ reply.raise_for_status()
513
+ except httpx.HTTPError as exc:
514
+ self._emit("error", message=format_http_error("Reply", exc))
515
+ return
516
+
517
+ self._emit("turn_started", request_id=request_id)
518
+ await self._run_turn(request_id)
519
+
520
+ async def _dequeue_payload(self) -> str | None | Literal[False]:
521
+ """Return payload, None for stream end, False on poll timeout."""
522
+ try:
523
+ return await asyncio.wait_for(self._event_queue.get(), timeout=0.15)
524
+ except TimeoutError:
525
+ return False
526
+
527
+ def _decode_event(self, payload: str) -> Any | None:
528
+ try:
529
+ data = json.loads(payload)
530
+ except json.JSONDecodeError as exc:
531
+ self._warn(f"skipping malformed SSE JSON: {exc}")
532
+ return None
533
+ if data.get("type") == "ActiveRequests":
534
+ return None
535
+ try:
536
+ return event_from_json(payload)
537
+ except Exception as exc:
538
+ self._warn(f"skipping unparseable event: {exc}")
539
+ return None
540
+
541
+ async def _run_turn(self, request_id: str) -> None:
542
+ assert self._client is not None and self.session_id is not None
543
+ state = _TurnState()
544
+ while not state.done and not self._turn_abort.is_set():
545
+ payload = await self._dequeue_payload()
546
+ if payload is False:
547
+ continue
548
+ if payload is None:
549
+ self._emit("thinking_clear")
550
+ break
551
+ evt = self._decode_event(payload)
552
+ if evt is None:
553
+ continue
554
+ evt_rid = getattr(evt, "request_id", "") or ""
555
+ if evt_rid and evt_rid in self._abandoned:
556
+ continue
557
+ await self._dispatch_turn_event(evt, request_id, state)
558
+ if self._turn_abort.is_set() and isinstance(evt, _HITL_TYPES):
559
+ break
560
+
561
+ if self._turn_abort.is_set() and not state.done:
562
+ self._abandoned.append(request_id)
563
+ self._emit("thinking_clear")
564
+ # Wait briefly for cancel POST to settle so the UI message is accurate.
565
+ if self._cancel_tasks:
566
+ with contextlib.suppress(TimeoutError, asyncio.TimeoutError):
567
+ await asyncio.wait(self._cancel_tasks, timeout=0.4)
568
+ cancel_ok = self._last_cancel_ok
569
+ self._emit("turn_aborted", cancel_ok=cancel_ok)
570
+ self._active_request_id = None
571
+ if not self._stream_alive and not self._reconnecting:
572
+ self._emit("stream_ended")
573
+
574
+ async def _dispatch_turn_event(self, evt: Any, request_id: str, state: _TurnState) -> None:
575
+ evt_rid = getattr(evt, "request_id", "") or ""
576
+
577
+ if isinstance(evt, _HITL_TYPES):
578
+ if evt_rid and evt_rid != request_id:
579
+ return
580
+ self._emit("thinking_clear")
581
+ await self._handle_hitl(evt)
582
+ if not self._turn_abort.is_set():
583
+ self._emit("thinking", text="thinking…")
584
+ return
585
+
586
+ if evt_rid != request_id:
587
+ self._maybe_thinking_trace(evt, request_id)
588
+ return
589
+
590
+ if isinstance(evt, GroundingEvent):
591
+ self._emit(
592
+ "grounding",
593
+ sources=list(evt.sources),
594
+ search_queries=list(evt.search_queries),
595
+ )
596
+ return
597
+ if isinstance(evt, ToolCallStarted):
598
+ state.assistant_started = False
599
+ self._emit("thinking_clear")
600
+ self._emit(
601
+ "tool_started",
602
+ tool=evt.tool,
603
+ label=evt.label,
604
+ args=dict(evt.args),
605
+ call_id=evt.call_id,
606
+ )
607
+ return
608
+ if isinstance(evt, ToolCallResult):
609
+ state.assistant_started = False
610
+ self._emit(
611
+ "tool_finished",
612
+ tool=evt.tool,
613
+ error=evt.error,
614
+ result=evt.result or "",
615
+ verbose=self.verbose,
616
+ call_id=evt.call_id,
617
+ )
618
+ return
619
+ if isinstance(evt, ContextSummarizing):
620
+ self._on_context_summarizing(evt)
621
+ return
622
+ if isinstance(evt, ContextSummarized):
623
+ self._emit("summarized", turns=evt.turns_summarized)
624
+ await self._fetch_usage()
625
+ return
626
+ if isinstance(evt, AssistantDelta):
627
+ self._on_assistant_delta(evt, state)
628
+ return
629
+ if isinstance(evt, ThinkingBlockDelta):
630
+ self._on_thinking_block_delta(evt)
631
+ return
632
+ if isinstance(evt, ThinkingBlockComplete):
633
+ self._emit("thinking_block_complete")
634
+ return
635
+ if isinstance(evt, Error):
636
+ self._emit("thinking_clear")
637
+ self._emit("turn_error", error=evt.error)
638
+ state.done = True
639
+ return
640
+ if isinstance(evt, TurnComplete):
641
+ await self._emit_turn_complete(evt)
642
+ state.done = True
643
+ return
644
+
645
+ self._maybe_thinking_trace(evt, request_id)
646
+
647
+ def _on_context_summarizing(self, evt: ContextSummarizing) -> None:
648
+ self.usage.update_context_hint(
649
+ estimated_prompt_tokens=evt.estimated_tokens,
650
+ context_window_tokens=evt.context_window_tokens,
651
+ )
652
+ self._emit("usage_updated", usage=self.usage.usage)
653
+ self._emit("summarizing", tokens=evt.estimated_tokens)
654
+
655
+ def _on_assistant_delta(self, evt: AssistantDelta, state: _TurnState) -> None:
656
+ if not state.assistant_started:
657
+ self._emit("thinking_clear")
658
+ self._emit("assistant_start")
659
+ state.assistant_started = True
660
+ self._emit("assistant_delta", delta=evt.delta)
661
+
662
+ def _on_thinking_block_delta(self, evt: ThinkingBlockDelta) -> None:
663
+ text = evt.text or ""
664
+ if not text:
665
+ return
666
+ # Clear the transient spinner once real thinking text arrives.
667
+ self._emit("thinking_clear")
668
+ self._emit("thinking_block_delta", text=text)
669
+ if self.show_thinking:
670
+ self._emit("thinking_trace", text=text)
671
+
672
+ def _maybe_thinking_trace(self, evt: Any, request_id: str) -> None:
673
+ if not self.show_thinking:
674
+ return
675
+ evt_rid = getattr(evt, "request_id", "") or ""
676
+ if evt_rid != request_id:
677
+ return
678
+ kind = getattr(evt, "kind", "")
679
+ if kind not in ("Thinking", "ThinkingBlockDelta"):
680
+ return
681
+ text = getattr(evt, "text", "") or getattr(evt, "delta", "")
682
+ if text:
683
+ self._emit("thinking_trace", text=text)
684
+
685
+ async def _emit_turn_complete(self, evt: TurnComplete) -> None:
686
+ self._emit("thinking_clear")
687
+ usage_payload = None
688
+ if self.show_usage and evt.usage is not None:
689
+ u = evt.usage
690
+ usage_payload = {
691
+ "input_tokens": u.input_tokens,
692
+ "output_tokens": u.output_tokens,
693
+ "cost_usd": u.cost_usd,
694
+ "duration_ms": u.duration_ms,
695
+ }
696
+ self._emit("turn_complete", usage=usage_payload)
697
+ await self._fetch_usage()
698
+
699
+ async def _handle_hitl(self, evt: Any) -> None:
700
+ assert self._client is not None and self.session_id is not None
701
+ if isinstance(evt, FrontendToolRequestEvent):
702
+ self._emit("hitl_frontend_unsupported", name=evt.name)
703
+ return
704
+ if isinstance(evt, ToolConfirmationRequestEvent):
705
+ await self._handle_tool_confirm(evt)
706
+ return
707
+ if isinstance(evt, ActionRequiredEvent):
708
+ await self._handle_elicit(evt)
709
+
710
+ async def _handle_tool_confirm(self, evt: ToolConfirmationRequestEvent) -> None:
711
+ assert self.session_id is not None
712
+ prompt = evt.prompt or f"Approve tool {evt.tool_name}?"
713
+ args = dict(evt.arguments) if evt.arguments else {}
714
+ answer = await self._await_hitl(
715
+ HitlRequest(
716
+ kind="confirm",
717
+ prompt=prompt,
718
+ tool_call_id=evt.tool_call_id,
719
+ tool_name=evt.tool_name,
720
+ arguments=args,
721
+ timeout_sec=pending_response_timeout_sec(),
722
+ )
723
+ )
724
+ approved = (not answer.cancelled) and (
725
+ answer.approved is True
726
+ or (answer.text.strip().lower() in ("y", "yes") if answer.approved is None else False)
727
+ )
728
+ if answer.cancelled:
729
+ approved = False
730
+ reason = (
731
+ "cancelled by user"
732
+ if answer.cancelled
733
+ else (None if approved else "denied by user")
734
+ )
735
+ ok = await self._post_hitl(
736
+ f"{self.base}/sessions/{self.session_id}/tool-confirmations/{evt.tool_call_id}",
737
+ {"approved": approved, "reason": reason},
738
+ label="Tool confirmation",
739
+ )
740
+ if answer.cancelled or not ok:
741
+ self._turn_abort.set()
742
+
743
+ async def _handle_elicit(self, evt: ActionRequiredEvent) -> None:
744
+ assert self.session_id is not None
745
+ payload = dict(evt.payload) if evt.payload else {}
746
+ message, schema = extract_elicitation_fields(payload)
747
+ prompt = message or f"Agent requests input (id={evt.id or '?'})"
748
+ answer = await self._await_hitl(
749
+ HitlRequest(
750
+ kind="elicit",
751
+ prompt=prompt,
752
+ elicitation_id=evt.id,
753
+ schema=schema,
754
+ timeout_sec=pending_response_timeout_sec(),
755
+ )
756
+ )
757
+ if answer.cancelled:
758
+ user_data: Any = {"cancelled": True}
759
+ elif answer.user_data is not None:
760
+ user_data = answer.user_data
761
+ else:
762
+ raw = answer.text.strip()
763
+ user_data = raw
764
+ if raw.startswith("{"):
765
+ with contextlib.suppress(json.JSONDecodeError):
766
+ user_data = json.loads(raw)
767
+ ok = await self._post_hitl(
768
+ f"{self.base}/sessions/{self.session_id}/elicitations/{evt.id}",
769
+ {"user_data": user_data},
770
+ label="Elicitation",
771
+ )
772
+ if answer.cancelled or not ok:
773
+ self._turn_abort.set()
774
+
775
+ async def _await_hitl(self, req: HitlRequest) -> HitlAnswer:
776
+ timeout = req.timeout_sec if req.timeout_sec is not None else pending_response_timeout_sec()
777
+ self._emit(
778
+ "hitl_required",
779
+ hitl_kind=req.kind,
780
+ prompt=req.prompt,
781
+ tool_call_id=req.tool_call_id,
782
+ elicitation_id=req.elicitation_id,
783
+ tool_name=req.tool_name,
784
+ schema=req.schema,
785
+ arguments=req.arguments,
786
+ timeout_sec=timeout,
787
+ )
788
+ if self._hitl_reader is not None:
789
+ result = self._hitl_reader(req)
790
+ if asyncio.iscoroutine(result):
791
+ return await result
792
+ return result # type: ignore[return-value]
793
+ loop = asyncio.get_running_loop()
794
+ self._hitl_future = loop.create_future()
795
+ try:
796
+ return await self._hitl_future
797
+ finally:
798
+ self._hitl_future = None
799
+
800
+ async def _post_hitl(self, url: str, payload: dict[str, Any], *, label: str) -> bool:
801
+ assert self._client is not None
802
+ try:
803
+ resp = await self._client.post(url, json=payload)
804
+ resp.raise_for_status()
805
+ return True
806
+ except httpx.HTTPError as exc:
807
+ status = ""
808
+ if isinstance(exc, httpx.HTTPStatusError):
809
+ status = f" (HTTP {exc.response.status_code})"
810
+ self._emit(
811
+ "hitl_failed",
812
+ message=(
813
+ f"{label} failed{status}: {exc}. "
814
+ "The agent may still be waiting server-side — the CLI did not "
815
+ "acknowledge this action."
816
+ ),
817
+ )
818
+ return False
819
+
820
+ async def _fetch_usage(self) -> None:
821
+ assert self._client is not None and self.session_id is not None
822
+ try:
823
+ resp = await self._client.get(f"{self.base}/sessions/{self.session_id}/usage")
824
+ resp.raise_for_status()
825
+ except httpx.HTTPError as exc:
826
+ if self.verbose or self.show_usage:
827
+ self._warn(format_http_error("Usage", exc))
828
+ return
829
+ view = parse_usage_response(resp.json())
830
+ self.usage.update(view)
831
+ self._emit("usage_updated", usage=view)
832
+
833
+ async def refresh_usage(self) -> None:
834
+ if self._client is None or self.session_id is None:
835
+ return
836
+ await self._fetch_usage()
837
+
838
+ async def _teardown_stream(self) -> None:
839
+ if self._hitl_future is not None and not self._hitl_future.done():
840
+ self._hitl_future.set_result(HitlAnswer(cancelled=True))
841
+ self._hitl_future = None
842
+ self._turn_abort.set()
843
+ if self._active_request_id is not None:
844
+ self._abandoned.append(self._active_request_id)
845
+ self._active_request_id = None
846
+
847
+ if self._stream_task is not None:
848
+ self._stream_task.cancel()
849
+ with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError, TimeoutError):
850
+ await asyncio.wait_for(self._stream_task, timeout=0.5)
851
+ self._stream_task = None
852
+
853
+ while True:
854
+ try:
855
+ self._event_queue.get_nowait()
856
+ except asyncio.QueueEmpty:
857
+ break
858
+ self._event_queue = asyncio.Queue()
859
+ self._abandoned.clear()
860
+ self._last_event_id = None
861
+ self._reconnecting = False
862
+ self._stream_alive = True
863
+ self.stream_error = False
864
+ self.usage = UsageStore()
865
+ self._turn_abort.clear()
866
+
867
+ async def restart_session(self) -> None:
868
+ """Close the current gateway session and open a fresh one (same HTTP client)."""
869
+ if self._client is None or self._closed:
870
+ raise RuntimeError("Session not connected")
871
+ await self._teardown_stream()
872
+ await self._create_session()
873
+ self._stream_task = asyncio.create_task(self._consume_stream())
874
+ self._emit("session_ready", session_id=self.session_id)
875
+ self._emit("connection_state", state="connected")
876
+
877
+ async def resume_session(self, session_id: str) -> None:
878
+ """Switch to an existing (or recreated) session id and backfill transcript."""
879
+ if self._client is None or self._closed:
880
+ raise RuntimeError("Session not connected")
881
+ sid = session_id.strip()
882
+ if not sid:
883
+ raise RuntimeError("Session id required")
884
+ await self._teardown_stream()
885
+ await self._attach_or_create_session(sid)
886
+ self._stream_task = asyncio.create_task(self._consume_stream())
887
+ self._emit("session_ready", session_id=self.session_id)
888
+ self._emit("connection_state", state="connected")
889
+ await self._emit_transcript_backfill()
890
+
891
+ @property
892
+ def stream_alive(self) -> bool:
893
+ return self._stream_alive
894
+
895
+ @property
896
+ def reconnecting(self) -> bool:
897
+ return self._reconnecting
898
+
899
+ async def close(self) -> None:
900
+ if self._closed:
901
+ return
902
+ self._closed = True
903
+ if self._stream_task is not None:
904
+ self._stream_task.cancel()
905
+ with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError, TimeoutError):
906
+ await asyncio.wait_for(self._stream_task, timeout=0.5)
907
+ for task in list(self._cancel_tasks):
908
+ task.cancel()
909
+ if self._client is not None:
910
+ await self._client.aclose()
911
+ self._client = None