virtuai-cli 0.8.1__tar.gz → 0.8.3__tar.gz

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 (23) hide show
  1. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/PKG-INFO +1 -1
  2. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/pyproject.toml +1 -1
  3. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/__init__.py +1 -1
  4. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/tui.py +57 -7
  5. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/widgets.py +25 -0
  6. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/runner.py +109 -12
  7. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
  8. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/README.md +0 -0
  9. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/setup.cfg +0 -0
  10. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/__init__.py +0 -0
  11. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/ask.py +0 -0
  12. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/command.py +0 -0
  13. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/history.py +0 -0
  14. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/sse.py +0 -0
  15. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/config.py +0 -0
  16. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/executor.py +0 -0
  17. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/main.py +0 -0
  18. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli/security.py +0 -0
  19. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
  20. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  21. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  22. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/requires.txt +0 -0
  23. {virtuai_cli-0.8.1 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "virtuai-cli"
7
- version = "0.8.1"
7
+ version = "0.8.3"
8
8
  description = "Run VirtuAI deep agents on your local machine"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,2 +1,2 @@
1
1
  """VirtuAI local CLI."""
2
- __version__ = "0.8.1"
2
+ __version__ = "0.8.3"
@@ -131,6 +131,9 @@ class ChatApp(App):
131
131
  # Pending attachments to include with the next message — flipped to the
132
132
  # multimodal endpoint when non-empty, cleared after a successful send.
133
133
  self._pending_attachments: list[Path] = []
134
+ # Buffer for execute_output chunks that arrive before the matching
135
+ # tool_start SSE event has mounted the tool card.
136
+ self._streaming_output: dict[str, list[str]] = {}
134
137
 
135
138
  # ── Layout ────────────────────────────────────────────────────────────
136
139
  def compose(self) -> ComposeResult:
@@ -228,8 +231,22 @@ class ChatApp(App):
228
231
  self._status_message = msg
229
232
  self._refresh_status_bar()
230
233
 
234
+ def on_output(frame_id: str, chunk: str) -> None:
235
+ # Buffer the chunk — the SSE tool_start may not have mounted the
236
+ # card yet if the subprocess produces output almost immediately.
237
+ buf = self._streaming_output.setdefault(frame_id, [])
238
+ buf.append(chunk)
239
+ # Route directly to the tool card if it already exists.
240
+ if self._current_turn is not None:
241
+ card = self._current_turn._tools.get(frame_id)
242
+ if card is not None and hasattr(card, "append_output"):
243
+ card.append_output(chunk)
244
+
231
245
  try:
232
- await ws_runner.run_forever(self.server_url, self.token, self.workdir, on_status=on_status)
246
+ await ws_runner.run_forever(
247
+ self.server_url, self.token, self.workdir,
248
+ on_status=on_status, on_output=on_output,
249
+ )
233
250
  except asyncio.CancelledError:
234
251
  self._connection_state = "offline"
235
252
  self._refresh_status_bar()
@@ -738,14 +755,38 @@ class ChatApp(App):
738
755
  model_id=self.current_model_id,
739
756
  )
740
757
 
758
+ # Pump the SSE stream from a background task into a queue.
759
+ # When the user cancels (Esc), we abandon the pump task without
760
+ # awaiting it — httpx teardown happens in the background instead of
761
+ # blocking the event loop while the user is trying to type.
762
+ _DONE: object = object()
763
+ queue: asyncio.Queue = asyncio.Queue()
764
+
765
+ async def _pump() -> None:
766
+ try:
767
+ async for event in stream_iter:
768
+ queue.put_nowait(event)
769
+ except asyncio.CancelledError:
770
+ pass
771
+ except Exception as exc:
772
+ queue.put_nowait({"type": "error", "error": str(exc)})
773
+ finally:
774
+ queue.put_nowait(_DONE)
775
+
776
+ pump_task = asyncio.create_task(_pump())
777
+
741
778
  try:
742
- async for event in stream_iter:
743
- await self._handle_event(event, turn)
779
+ while True:
780
+ item = await queue.get()
781
+ if item is _DONE:
782
+ break
783
+ await self._handle_event(item, turn)
744
784
  self._follow_bottom()
745
785
  except asyncio.CancelledError:
746
- # User hit Esc (or app is shutting down). Skip the expensive
747
- # mark_final flush see widgets.AssistantTurn.mark_final.
786
+ # User hit Esc. Cancel the pump but don't await it — let httpx
787
+ # close the connection in the background.
748
788
  cancelled = True
789
+ pump_task.cancel()
749
790
  raise
750
791
  except Exception as exc:
751
792
  try:
@@ -792,11 +833,12 @@ class ChatApp(App):
792
833
  # story. Empty == "", "{}", or anything that strips to nothing.
793
834
  input_val = event.get("input", "")
794
835
  input_str = input_val if isinstance(input_val, str) else json.dumps(input_val, default=str)
836
+ tool_id = event.get("id", "")
795
837
  if input_str.strip() in ("", "{}", "[]"):
796
- turn._tools[event.get("id", "")] = None # type: ignore[assignment]
838
+ turn._tools[tool_id] = None # type: ignore[assignment]
797
839
  else:
798
840
  await turn.start_tool(
799
- event.get("id", ""),
841
+ tool_id,
800
842
  event.get("name", "tool"),
801
843
  input_val,
802
844
  display=event.get("display", ""),
@@ -805,6 +847,14 @@ class ChatApp(App):
805
847
  read_offset=event.get("read_offset"),
806
848
  read_limit=event.get("read_limit"),
807
849
  )
850
+ # Flush any execute_output chunks that raced ahead of this
851
+ # tool_start event (can happen when a subprocess starts
852
+ # printing immediately, e.g. a fast SSH connection).
853
+ card = turn._tools.get(tool_id)
854
+ if card is not None:
855
+ for buffered_chunk in self._streaming_output.pop(tool_id, []):
856
+ if hasattr(card, "append_output"):
857
+ card.append_output(buffered_chunk)
808
858
  elif etype == "tool_end":
809
859
  # If we skipped the matching tool_start (empty input), don't try
810
860
  # to finalize a card that doesn't exist.
@@ -221,6 +221,7 @@ class TextSegment(Static):
221
221
  self._last_render_t: float = 0.0
222
222
  self._render_pending: bool = False
223
223
  self._render_timer: Any = None
224
+ self._cancelled: bool = False
224
225
 
225
226
  def append(self, chunk: str) -> None:
226
227
  self._buf += chunk
@@ -244,6 +245,7 @@ class TextSegment(Static):
244
245
  def cancel_pending(self) -> None:
245
246
  """Drop any scheduled render — used on stream cancel so the cancel
246
247
  doesn't trigger another expensive Markdown reparse a moment later."""
248
+ self._cancelled = True
247
249
  if self._render_timer is not None:
248
250
  try:
249
251
  self._render_timer.stop()
@@ -253,6 +255,11 @@ class TextSegment(Static):
253
255
  self._render_pending = False
254
256
 
255
257
  def _flush(self) -> None:
258
+ # Guard: the timer may have already fired and queued this callback
259
+ # before cancel_pending() ran. Bail out so we don't do an expensive
260
+ # Markdown parse right as the user is trying to type.
261
+ if self._cancelled:
262
+ return
256
263
  self._render_pending = False
257
264
  self._render_timer = None
258
265
  self._last_render_t = time.monotonic()
@@ -439,6 +446,8 @@ class ToolCallCard(Static):
439
446
  self.tool_name = tool_name
440
447
  self.input_str = input_str or ""
441
448
  self.output_preview: Optional[str] = None
449
+ self._stream_buf: str = ""
450
+ self._last_stream_t: float = 0.0
442
451
  self.add_class("-running")
443
452
  self._redraw()
444
453
 
@@ -452,8 +461,24 @@ class ToolCallCard(Static):
452
461
  if self.output_preview:
453
462
  for line in self.output_preview.strip().splitlines()[:8]:
454
463
  body_lines.append(Text(f" {line[:160]}", style="dim"))
464
+ elif self._stream_buf:
465
+ # Show the last 8 lines of live streaming output while the tool runs.
466
+ for line in self._stream_buf.splitlines()[-8:]:
467
+ body_lines.append(Text(f" {line[:160]}", style="dim cyan"))
455
468
  self.update(Text("\n").join(body_lines))
456
469
 
470
+ def append_output(self, chunk: str) -> None:
471
+ """Append a live streaming chunk from the WS execute_output frame."""
472
+ if "-done" in self.classes or "-error" in self.classes:
473
+ return
474
+ self._stream_buf += chunk
475
+ # Throttle redraws to ~10 Hz — fast enough to feel live, cheap enough
476
+ # not to stall the event loop on a command that floods stdout.
477
+ now = time.monotonic()
478
+ if now - self._last_stream_t >= 0.1:
479
+ self._last_stream_t = now
480
+ self._redraw()
481
+
457
482
  def finish(self, output_preview: Optional[str], errored: bool = False) -> None:
458
483
  self.output_preview = output_preview
459
484
  self.remove_class("-running")
@@ -9,7 +9,7 @@ import platform
9
9
  import sys
10
10
  import time
11
11
  from pathlib import Path
12
- from typing import Awaitable, Callable, Optional
12
+ from typing import Callable, Optional
13
13
 
14
14
  _AUDIT_DIR = Path.home() / ".virtuai"
15
15
 
@@ -23,6 +23,8 @@ from virtuai_cli.security import resolve_workdir
23
23
  # Callback the TUI uses to capture connection events (level, message) instead
24
24
  # of printing to stdout (which would scramble the Textual screen).
25
25
  StatusCallback = Callable[[str, str], None]
26
+ # Callback for streaming execute output: (frame_id, chunk)
27
+ OutputCallback = Callable[[str, str], None]
26
28
 
27
29
  logger = logging.getLogger(__name__)
28
30
  console = Console()
@@ -40,6 +42,7 @@ def _audit(workdir: Path, command: str, exit_code: int, elapsed: float) -> None:
40
42
  pass
41
43
 
42
44
  _RECONNECT_DELAYS = [1, 2, 4, 8, 16, 30] # backoff steps
45
+ _RECV_TIMEOUT = 60.0 # seconds of silence before declaring the connection stale
43
46
 
44
47
 
45
48
  def _default_on_status(level: str, msg: str) -> None:
@@ -47,7 +50,13 @@ def _default_on_status(level: str, msg: str) -> None:
47
50
  console.print(f"[{style}]{msg}[/{style}]" if style else msg)
48
51
 
49
52
 
50
- async def _handle_frame(frame: dict, workdir: Path, ws, on_status: StatusCallback) -> None:
53
+ async def _handle_frame(
54
+ frame: dict,
55
+ workdir: Path,
56
+ ws,
57
+ on_status: StatusCallback,
58
+ on_output: Optional[OutputCallback] = None,
59
+ ) -> None:
51
60
  """Dispatch a server frame and send the result back."""
52
61
  ftype = frame.get("type")
53
62
  fid = frame.get("id")
@@ -76,19 +85,97 @@ async def _handle_frame(frame: dict, workdir: Path, ws, on_status: StatusCallbac
76
85
  timeout = int(frame.get("timeout_secs", 300))
77
86
  preview = command[:80] + ("..." if len(command) > 80 else "")
78
87
  on_status("exec", preview)
88
+
89
+ # Security checks — same policy as the sync executor.
90
+ from virtuai_cli.security import check_command, scrub_env
91
+ denial = check_command(command)
92
+ if denial:
93
+ await ws.send(json.dumps({
94
+ "v": 1, "id": fid, "type": "execute_result",
95
+ "output": f"blocked by local denylist: {denial}",
96
+ "exit_code": 126,
97
+ }))
98
+ return
99
+
100
+ env = scrub_env(workdir)
101
+ wrapped = exec_._jail_wrap(command, workdir)
79
102
  t0 = time.monotonic()
80
- loop = asyncio.get_event_loop()
81
- result = await loop.run_in_executor(
82
- None, lambda: exec_.execute(command, workdir, timeout=timeout)
83
- )
103
+
104
+ try:
105
+ proc = await asyncio.create_subprocess_shell(
106
+ wrapped,
107
+ stdout=asyncio.subprocess.PIPE,
108
+ stderr=asyncio.subprocess.STDOUT,
109
+ cwd=str(workdir),
110
+ env=env,
111
+ )
112
+ except Exception as exc:
113
+ await ws.send(json.dumps({
114
+ "v": 1, "id": fid, "type": "execute_result",
115
+ "output": f"executor error: {exc}",
116
+ "exit_code": 1,
117
+ }))
118
+ return
119
+
120
+ chunks: list[str] = []
121
+ total_bytes = 0
122
+ timed_out = False
123
+
124
+ try:
125
+ async with asyncio.timeout(timeout):
126
+ async for raw_line in proc.stdout:
127
+ chunk = raw_line.decode("utf-8", errors="replace")
128
+ chunks.append(chunk)
129
+ total_bytes += len(chunk)
130
+ if on_output and fid:
131
+ on_output(fid, chunk)
132
+ # Forward chunk to server — server may show it in the agent
133
+ # trace or buffer it; either way the agent gets the full
134
+ # execute_result below.
135
+ try:
136
+ await ws.send(json.dumps({
137
+ "v": 1, "id": fid, "type": "execute_output",
138
+ "output": chunk,
139
+ }))
140
+ except Exception:
141
+ pass
142
+ if total_bytes > exec_._MAX_OUTPUT_BYTES:
143
+ proc.kill()
144
+ break
145
+ except TimeoutError:
146
+ timed_out = True
147
+ try:
148
+ proc.kill()
149
+ except Exception:
150
+ pass
151
+
152
+ try:
153
+ await asyncio.wait_for(proc.wait(), timeout=5.0)
154
+ except Exception:
155
+ pass
156
+
157
+ exit_code = proc.returncode if proc.returncode is not None else (124 if timed_out else 1)
84
158
  elapsed = time.monotonic() - t0
85
- _audit(workdir, command, result["exit_code"], elapsed)
159
+
160
+ full_output = "".join(chunks)
161
+ if timed_out:
162
+ full_output += f"\ncommand timed out after {timeout}s"
163
+ exit_code = 124
164
+ elif total_bytes > exec_._MAX_OUTPUT_BYTES:
165
+ keep = exec_._MAX_OUTPUT_BYTES // 2
166
+ full_output = (
167
+ full_output[:keep]
168
+ + f"\n\n... [output truncated: {total_bytes:,} bytes total] ...\n\n"
169
+ + full_output[-keep:]
170
+ )
171
+
172
+ _audit(workdir, command, exit_code, elapsed)
86
173
  await ws.send(json.dumps({
87
174
  "v": 1,
88
175
  "id": fid,
89
176
  "type": "execute_result",
90
- "output": result["output"],
91
- "exit_code": result["exit_code"],
177
+ "output": full_output,
178
+ "exit_code": exit_code,
92
179
  }))
93
180
  return
94
181
 
@@ -129,6 +216,7 @@ async def run_session(
129
216
  token: str,
130
217
  workdir: Path,
131
218
  on_status: Optional[StatusCallback] = None,
219
+ on_output: Optional[OutputCallback] = None,
132
220
  ) -> None:
133
221
  """Run a single connected session until the WebSocket closes."""
134
222
  on_status = on_status or _default_on_status
@@ -139,10 +227,17 @@ async def run_session(
139
227
  # JSON pings every 20 s to keep the connection alive (cli_api/router.py).
140
228
  ssl_ctx = _ssl_context() if ws_url.startswith("wss://") else None
141
229
  async with websockets.connect(url, ping_interval=None, ssl=ssl_ctx) as ws:
142
- async for raw in ws:
230
+ while True:
231
+ try:
232
+ raw = await asyncio.wait_for(ws.recv(), timeout=_RECV_TIMEOUT)
233
+ except asyncio.TimeoutError:
234
+ # No frame received within the timeout window — the server
235
+ # sends a JSON ping every ~20 s, so 60 s of silence means the
236
+ # TCP connection is stale without a clean close.
237
+ raise ConnectionError("No data received for 60 s — stale connection")
143
238
  try:
144
239
  frame = json.loads(raw)
145
- await _handle_frame(frame, workdir, ws, on_status)
240
+ await _handle_frame(frame, workdir, ws, on_status, on_output)
146
241
  except SystemExit:
147
242
  raise
148
243
  except Exception as exc:
@@ -154,11 +249,13 @@ async def run_forever(
154
249
  token: str,
155
250
  workdir: Path,
156
251
  on_status: Optional[StatusCallback] = None,
252
+ on_output: Optional[OutputCallback] = None,
157
253
  ) -> None:
158
254
  """Connect with exponential backoff, reconnecting on drop.
159
255
 
160
256
  Pass `on_status` to redirect connection messages to a TUI/log sink
161
257
  instead of printing to stdout (which would scramble a Textual screen).
258
+ Pass `on_output` to receive streaming execute_output chunks (frame_id, chunk).
162
259
  """
163
260
  on_status = on_status or _default_on_status
164
261
  ws_url = server_url.rstrip("/").replace("https://", "wss://").replace("http://", "ws://")
@@ -170,7 +267,7 @@ async def run_forever(
170
267
  attempt = 0
171
268
  while True:
172
269
  try:
173
- await run_session(ws_url, token, workdir, on_status)
270
+ await run_session(ws_url, token, workdir, on_status, on_output)
174
271
  attempt = 0 # successful session resets backoff
175
272
  except SystemExit:
176
273
  return
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.8.1
3
+ Version: 0.8.3
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
File without changes
File without changes