virtuai-cli 0.8.2__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.2 → virtuai_cli-0.8.3}/PKG-INFO +1 -1
  2. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/pyproject.toml +1 -1
  3. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/__init__.py +1 -1
  4. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/tui.py +57 -7
  5. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/widgets.py +25 -0
  6. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/runner.py +100 -11
  7. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
  8. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/README.md +0 -0
  9. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/setup.cfg +0 -0
  10. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/__init__.py +0 -0
  11. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/ask.py +0 -0
  12. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/command.py +0 -0
  13. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/history.py +0 -0
  14. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/chat/sse.py +0 -0
  15. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/config.py +0 -0
  16. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/executor.py +0 -0
  17. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/main.py +0 -0
  18. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli/security.py +0 -0
  19. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/SOURCES.txt +0 -0
  20. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  21. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  22. {virtuai_cli-0.8.2 → virtuai_cli-0.8.3}/src/virtuai_cli.egg-info/requires.txt +0 -0
  23. {virtuai_cli-0.8.2 → 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.2
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.2"
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.2"
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()
@@ -48,7 +50,13 @@ def _default_on_status(level: str, msg: str) -> None:
48
50
  console.print(f"[{style}]{msg}[/{style}]" if style else msg)
49
51
 
50
52
 
51
- 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:
52
60
  """Dispatch a server frame and send the result back."""
53
61
  ftype = frame.get("type")
54
62
  fid = frame.get("id")
@@ -77,19 +85,97 @@ async def _handle_frame(frame: dict, workdir: Path, ws, on_status: StatusCallbac
77
85
  timeout = int(frame.get("timeout_secs", 300))
78
86
  preview = command[:80] + ("..." if len(command) > 80 else "")
79
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)
80
102
  t0 = time.monotonic()
81
- loop = asyncio.get_event_loop()
82
- result = await loop.run_in_executor(
83
- None, lambda: exec_.execute(command, workdir, timeout=timeout)
84
- )
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)
85
158
  elapsed = time.monotonic() - t0
86
- _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)
87
173
  await ws.send(json.dumps({
88
174
  "v": 1,
89
175
  "id": fid,
90
176
  "type": "execute_result",
91
- "output": result["output"],
92
- "exit_code": result["exit_code"],
177
+ "output": full_output,
178
+ "exit_code": exit_code,
93
179
  }))
94
180
  return
95
181
 
@@ -130,6 +216,7 @@ async def run_session(
130
216
  token: str,
131
217
  workdir: Path,
132
218
  on_status: Optional[StatusCallback] = None,
219
+ on_output: Optional[OutputCallback] = None,
133
220
  ) -> None:
134
221
  """Run a single connected session until the WebSocket closes."""
135
222
  on_status = on_status or _default_on_status
@@ -150,7 +237,7 @@ async def run_session(
150
237
  raise ConnectionError("No data received for 60 s — stale connection")
151
238
  try:
152
239
  frame = json.loads(raw)
153
- await _handle_frame(frame, workdir, ws, on_status)
240
+ await _handle_frame(frame, workdir, ws, on_status, on_output)
154
241
  except SystemExit:
155
242
  raise
156
243
  except Exception as exc:
@@ -162,11 +249,13 @@ async def run_forever(
162
249
  token: str,
163
250
  workdir: Path,
164
251
  on_status: Optional[StatusCallback] = None,
252
+ on_output: Optional[OutputCallback] = None,
165
253
  ) -> None:
166
254
  """Connect with exponential backoff, reconnecting on drop.
167
255
 
168
256
  Pass `on_status` to redirect connection messages to a TUI/log sink
169
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).
170
259
  """
171
260
  on_status = on_status or _default_on_status
172
261
  ws_url = server_url.rstrip("/").replace("https://", "wss://").replace("http://", "ws://")
@@ -178,7 +267,7 @@ async def run_forever(
178
267
  attempt = 0
179
268
  while True:
180
269
  try:
181
- await run_session(ws_url, token, workdir, on_status)
270
+ await run_session(ws_url, token, workdir, on_status, on_output)
182
271
  attempt = 0 # successful session resets backoff
183
272
  except SystemExit:
184
273
  return
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.8.2
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