virtuai-cli 0.9.0__tar.gz → 1.0.0__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 (32) hide show
  1. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/PKG-INFO +1 -1
  2. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/pyproject.toml +1 -1
  3. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/__init__.py +1 -1
  4. virtuai_cli-1.0.0/src/virtuai_cli/pull.py +335 -0
  5. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/runner.py +59 -36
  6. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/PKG-INFO +1 -1
  7. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/SOURCES.txt +1 -0
  8. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/tests/test_browser.py +3 -0
  9. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/README.md +0 -0
  10. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/setup.cfg +0 -0
  11. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/browser.py +0 -0
  12. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/__init__.py +0 -0
  13. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/agents.py +0 -0
  14. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/ask.py +0 -0
  15. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/command.py +0 -0
  16. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/history.py +0 -0
  17. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/sse.py +0 -0
  18. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/tui.py +0 -0
  19. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/chat/widgets.py +0 -0
  20. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/config.py +0 -0
  21. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/executor.py +0 -0
  22. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/main.py +0 -0
  23. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/opencode.py +0 -0
  24. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli/security.py +0 -0
  25. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/dependency_links.txt +0 -0
  26. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/entry_points.txt +0 -0
  27. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/requires.txt +0 -0
  28. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/src/virtuai_cli.egg-info/top_level.txt +0 -0
  29. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/tests/test_frame_concurrency.py +0 -0
  30. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/tests/test_process_group_kill.py +0 -0
  31. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/tests/test_run_single_instance.py +0 -0
  32. {virtuai_cli-0.9.0 → virtuai_cli-1.0.0}/tests/test_session_isolation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.9.0
3
+ Version: 1.0.0
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.9.0"
7
+ version = "1.0.0"
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.9.0"
2
+ __version__ = "1.0.0"
@@ -0,0 +1,335 @@
1
+ """The runner pulls its work over HTTPS instead of holding a WebSocket.
2
+
3
+ CLI 1.0, phase 3 of docs/workitems/cli-http-transport.md, after the worker in
4
+ Claude Managed Agents' self-hosted sandboxes: the server never calls this
5
+ machine. The runner says hello, keeps one request open asking for work,
6
+ acknowledges each job as it starts it, heartbeats while it runs — carrying
7
+ the output so far and learning whether the caller gave up — and posts the
8
+ result.
9
+
10
+ A job is the same frame the socket used to carry, so it runs through the same
11
+ handler (runner._handle_frame / runner._spawn) with the same session order,
12
+ concurrency limits and path jails. Only where its replies go changes: a
13
+ _JobSocket collects what the handler would have sent down the WebSocket.
14
+
15
+ A server that does not offer this answers the hello with 404; run_pull then
16
+ raises PullUnavailable and the caller uses the WebSocket, so a 1.0 runner
17
+ works against any server.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import logging
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Callable, Optional
28
+
29
+ import httpx
30
+
31
+ from virtuai_cli import runner
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ _HEARTBEAT_SECONDS = 5.0
36
+ #: Output is sent this often while there is some, so a stream reads as one.
37
+ _FLUSH_SECONDS = 0.25
38
+ _RETRY_DELAYS = (1, 2, 5, 10, 20, 30)
39
+ #: How long a finished job keeps trying to hand in its result.
40
+ _RESULT_RETRY_SECONDS = 120.0
41
+ #: Past this many jobs in hand, wait for some to finish before asking for more.
42
+ _MAX_IN_HAND = 16
43
+
44
+ #: Output that goes back while a job runs. Only model tokens: the server
45
+ #: reads a model's stream as it grows, but nothing reads execute_output (the
46
+ #: socket router drops it too), and forwarding it put every command's output
47
+ #: through Redis twice — 1 MB commands filled dev's Redis in the benchmark.
48
+ _STREAM_TYPES = {"model_token"}
49
+ #: Output a handler sends that is dropped rather than forwarded.
50
+ _LOCAL_ONLY_TYPES = {"execute_output"}
51
+ #: This many 404s in a row on the work endpoint means the server turned pull
52
+ #: off (VIRTUAI_CLI_PULL unset); fall back to the socket rather than retry
53
+ #: forever. One or two can be a rolling deploy reaching an older pod.
54
+ _GONE_AFTER_404S = 5
55
+ #: Job ids remembered, so a job delivered twice is only taken once.
56
+ _SEEN_MAX = 2_000
57
+
58
+
59
+ class PullUnavailable(Exception):
60
+ """The server does not offer pull; use the WebSocket."""
61
+
62
+
63
+ class TokenRejected(Exception):
64
+ """The server rejected this runner's token.
65
+
66
+ Not PermissionError: that is an OSError, which the network retries catch —
67
+ a revoked token was retried forever.
68
+ """
69
+
70
+
71
+ class _JobSocket:
72
+ """Stands in for the WebSocket: output is kept for the heartbeat, the reply ends the job."""
73
+
74
+ def __init__(self):
75
+ self.events: list[dict] = []
76
+ self.final: Optional[dict] = None
77
+ self.done = asyncio.Event()
78
+ self.flush = asyncio.Event()
79
+
80
+ async def send(self, raw: str) -> None:
81
+ msg = json.loads(raw)
82
+ if msg.get("type") in _LOCAL_ONLY_TYPES:
83
+ return
84
+ if msg.get("type") in _STREAM_TYPES:
85
+ self.events.append(msg)
86
+ self.flush.set()
87
+ elif self.final is None:
88
+ self.final = msg
89
+ self.done.set()
90
+
91
+ def take_events(self) -> list[dict]:
92
+ events, self.events = self.events, []
93
+ self.flush.clear()
94
+ return events
95
+
96
+
97
+ async def _wait(event: asyncio.Event, timeout: float) -> None:
98
+ try:
99
+ await asyncio.wait_for(event.wait(), timeout=timeout)
100
+ except asyncio.TimeoutError:
101
+ pass
102
+
103
+
104
+ async def _wait_any(events, timeout: float) -> None:
105
+ waiters = [asyncio.ensure_future(e.wait()) for e in events]
106
+ try:
107
+ await asyncio.wait(waiters, timeout=timeout, return_when=asyncio.FIRST_COMPLETED)
108
+ finally:
109
+ for w in waiters:
110
+ w.cancel()
111
+
112
+
113
+ class _Session:
114
+ def __init__(self, server_url: str, token: str, workdir: Path,
115
+ on_status: Callable, on_output: Optional[Callable], kind: str):
116
+ import certifi
117
+
118
+ self.workdir = workdir
119
+ self.on_status = on_status
120
+ self.on_output = on_output
121
+ self.kind = kind
122
+ self.runner_id: Optional[str] = None
123
+ self.wait = 25
124
+ self.heartbeat = _HEARTBEAT_SECONDS
125
+ self.jobs: set = set()
126
+ self.seen: dict = {} # job id -> time seen; insertion-ordered, trimmed
127
+ self.stopping: Optional[BaseException] = None
128
+ self.stop = asyncio.Event()
129
+ self.http = httpx.AsyncClient(
130
+ base_url=server_url.rstrip("/"),
131
+ headers={"Authorization": f"Bearer {token}"},
132
+ timeout=httpx.Timeout(10.0, read=40.0),
133
+ verify=certifi.where(),
134
+ )
135
+
136
+ # -- registration ------------------------------------------------------
137
+
138
+ async def hello(self) -> None:
139
+ body = {
140
+ "kind": self.kind,
141
+ "previous_runner_id": self.runner_id,
142
+ "ready": await runner._ready_payload(self.workdir),
143
+ }
144
+ resp = await self.http.post("/api/cli/v2/hello", json=body)
145
+ if resp.status_code == 404:
146
+ raise PullUnavailable()
147
+ if resp.status_code == 401:
148
+ raise TokenRejected("the server rejected this runner's token; pair it again")
149
+ resp.raise_for_status()
150
+ data = resp.json()
151
+ self.runner_id = data["runner_id"]
152
+ self.wait = int(data.get("wait_seconds", self.wait))
153
+ self.heartbeat = float(data.get("heartbeat_seconds", self.heartbeat))
154
+ self.on_status("info", f"Connected (pull). Workdir: {self.workdir}")
155
+
156
+ async def bye(self) -> None:
157
+ if not self.runner_id:
158
+ return
159
+ try:
160
+ await self.http.post(f"/api/cli/v2/runners/{self.runner_id}/bye", timeout=5.0)
161
+ except Exception:
162
+ pass
163
+
164
+ # -- the loop ----------------------------------------------------------
165
+
166
+ async def serve(self) -> None:
167
+ attempt = 0
168
+ not_found = 0
169
+ while True:
170
+ if self.stopping is not None:
171
+ raise self.stopping
172
+ if len(self.jobs) >= _MAX_IN_HAND:
173
+ await asyncio.wait(self.jobs, return_when=asyncio.FIRST_COMPLETED)
174
+ continue
175
+ try:
176
+ # The poll can wait 25 s; a shutdown must not wait with it.
177
+ poll = asyncio.ensure_future(self.http.get(
178
+ f"/api/cli/v2/runners/{self.runner_id}/work",
179
+ params={"max": max(1, _MAX_IN_HAND - len(self.jobs)), "wait": self.wait},
180
+ ))
181
+ stopper = asyncio.ensure_future(self.stop.wait())
182
+ await asyncio.wait({poll, stopper}, return_when=asyncio.FIRST_COMPLETED)
183
+ stopper.cancel()
184
+ if self.stopping is not None:
185
+ poll.cancel()
186
+ raise self.stopping
187
+ resp = poll.result()
188
+ if resp.status_code == 410:
189
+ # The server no longer knows this runner (it expired, or
190
+ # Redis lost it): register again and carry on.
191
+ await self.hello()
192
+ continue
193
+ if resp.status_code == 401:
194
+ raise TokenRejected("the server rejected this runner's token; pair it again")
195
+ if resp.status_code == 404:
196
+ not_found += 1
197
+ if not_found >= _GONE_AFTER_404S:
198
+ raise PullUnavailable()
199
+ await asyncio.sleep(1)
200
+ continue
201
+ resp.raise_for_status()
202
+ attempt = 0
203
+ not_found = 0
204
+ except (httpx.HTTPError, OSError) as exc:
205
+ delay = _RETRY_DELAYS[min(attempt, len(_RETRY_DELAYS) - 1)]
206
+ self.on_status("error", f"Could not reach the server: {exc}. Retrying in {delay}s ...")
207
+ await asyncio.sleep(delay)
208
+ attempt += 1
209
+ continue
210
+ for job in resp.json().get("jobs", []):
211
+ key = job.get("job_id") or job.get("id")
212
+ if key in self.seen:
213
+ # Delivered again while its first copy is still ours: the
214
+ # server's acknowledgement decides, and ours already went.
215
+ continue
216
+ self._remember(key)
217
+ task = asyncio.ensure_future(self._run(job))
218
+ self.jobs.add(task)
219
+ task.add_done_callback(self.jobs.discard)
220
+
221
+ def _remember(self, job_id) -> None:
222
+ self.seen[job_id] = time.monotonic()
223
+ while len(self.seen) > _SEEN_MAX:
224
+ self.seen.pop(next(iter(self.seen)))
225
+
226
+ async def _run(self, job: dict) -> None:
227
+ # The job's id addresses the queue; the frame's id is what the
228
+ # handler answers with, as it would over a socket.
229
+ job_id, entry = job.pop("job_id", None) or job.get("id"), job.pop("_entry", None)
230
+ base = f"/api/cli/v2/runners/{self.runner_id}/jobs/{job_id}"
231
+ start = None
232
+ for attempt in range(3):
233
+ try:
234
+ resp = await self.http.post(f"{base}/ack", json={"entry": entry})
235
+ if resp.status_code == 200:
236
+ start = bool(resp.json().get("start"))
237
+ break
238
+ except Exception as exc:
239
+ logger.warning("Could not acknowledge job %s: %s", job_id, exc)
240
+ await asyncio.sleep(0.5 * (attempt + 1))
241
+ if not start:
242
+ # Not acknowledged: the server delivers it again. Acknowledged by
243
+ # another delivery: that one runs it. Either way, not this one.
244
+ if start is None:
245
+ self.seen.pop(job_id, None) # let its redelivery through
246
+ return
247
+
248
+ sock = _JobSocket()
249
+ task: Optional[asyncio.Task] = None
250
+ if job.get("type") in runner._INLINE_TYPES:
251
+ # browser_action starts its own task. shutdown raises SystemExit,
252
+ # which inside this task would end nothing (a task keeps its
253
+ # exception): hand it to the loop in serve() instead.
254
+ try:
255
+ await runner._handle_frame(job, self.workdir, sock, self.on_status, self.on_output)
256
+ except SystemExit as exc:
257
+ sock.final = {"v": 1, "id": job.get("id"), "type": "error", "error": "the runner is shutting down"}
258
+ sock.done.set()
259
+ self.stopping = exc
260
+ self.stop.set()
261
+ else:
262
+ task = runner._spawn(job, self.workdir, sock, self.on_status, self.on_output)
263
+
264
+ last_beat = time.monotonic()
265
+ while not sock.done.is_set():
266
+ if task is not None and task.done():
267
+ break # the handler ended without a reply; answer for it below
268
+ if sock.events:
269
+ # Let a little more output gather — but the moment the job
270
+ # ends, its output goes with the result instead.
271
+ await _wait(sock.done, _FLUSH_SECONDS)
272
+ else:
273
+ await _wait_any((sock.done, sock.flush),
274
+ max(0.05, self.heartbeat - (time.monotonic() - last_beat)))
275
+ if not sock.events and time.monotonic() - last_beat < self.heartbeat:
276
+ continue
277
+ if sock.done.is_set():
278
+ break
279
+ try:
280
+ reply = await self.http.post(f"{base}/heartbeat", json={"events": sock.take_events()})
281
+ last_beat = time.monotonic()
282
+ if reply.status_code == 200 and reply.json().get("cancel"):
283
+ self.on_status("warn", f"Job {job_id} cancelled by the server")
284
+ if task is not None:
285
+ task.cancel()
286
+ sock.final = sock.final or runner._busy_reply(job, "cancelled by the server") or {
287
+ "v": 1, "id": job.get("id"), "type": "error", "error": "cancelled by the server"}
288
+ sock.done.set()
289
+ except Exception as exc:
290
+ logger.debug("Heartbeat for %s failed: %s", job_id, exc)
291
+
292
+ final = sock.final or runner._busy_reply(job, "the runner finished this request without a reply") or {
293
+ "v": 1, "id": job.get("id"), "type": "error", "error": "no reply"}
294
+ # The work is done; losing its result now would waste it. Keep trying
295
+ # through a deploy or a network blip — every replica can take it —
296
+ # for as long as the caller could still be waiting.
297
+ deadline = time.monotonic() + _RESULT_RETRY_SECONDS
298
+ events = sock.take_events()
299
+ attempt = 0
300
+ while True:
301
+ try:
302
+ resp = await self.http.post(f"{base}/result", json={"result": final, "events": events})
303
+ if resp.status_code < 500:
304
+ return
305
+ except Exception as exc:
306
+ logger.warning("Could not post the result of %s: %s", job_id, exc)
307
+ if time.monotonic() >= deadline:
308
+ logger.error("Gave up posting the result of %s after %.0f s", job_id, _RESULT_RETRY_SECONDS)
309
+ return
310
+ await asyncio.sleep(min(5.0, 0.5 * 2 ** attempt))
311
+ attempt += 1
312
+
313
+
314
+ async def run_pull(server_url: str, token: str, workdir: Path,
315
+ on_status: Callable, on_output: Optional[Callable] = None,
316
+ *, kind: str = "host") -> None:
317
+ """Serve work until the process ends. Raises PullUnavailable on an old server."""
318
+ session = _Session(server_url, token, workdir, on_status, on_output, kind)
319
+ try:
320
+ attempt = 0
321
+ while True:
322
+ try:
323
+ await session.hello()
324
+ break
325
+ except (PullUnavailable, TokenRejected):
326
+ raise
327
+ except (httpx.HTTPError, OSError) as exc:
328
+ delay = _RETRY_DELAYS[min(attempt, len(_RETRY_DELAYS) - 1)]
329
+ on_status("error", f"Could not reach the server: {exc}. Retrying in {delay}s ...")
330
+ await asyncio.sleep(delay)
331
+ attempt += 1
332
+ await session.serve()
333
+ finally:
334
+ await session.bye()
335
+ await session.http.aclose()
@@ -470,6 +470,40 @@ async def _reap(proc) -> None:
470
470
  exec_.untrack_group(proc.pid)
471
471
 
472
472
 
473
+ async def _ready_payload(workdir: Path) -> dict:
474
+ """What this runner reports about itself: in its ready frame, or its hello."""
475
+ return {
476
+ "cli_version": __version__,
477
+ "os": platform.system(),
478
+ "arch": platform.machine(),
479
+ # The server only ever recorded a fingerprint at pairing time, so a
480
+ # container reusing a stored token reported the machine that paired
481
+ # months ago. Reporting it on every ready frame keeps the row honest.
482
+ "host": socket.gethostname(),
483
+ "python": f"{sys.version_info.major}.{sys.version_info.minor}",
484
+ "workdir": str(workdir),
485
+ # Lets the server and the portal tell a shared runner apart from a
486
+ # personal one without guessing from the hostname.
487
+ "isolate_sessions": _isolation_enabled(),
488
+ # Which person this runner serves, when it serves exactly one.
489
+ #
490
+ # A browser session is one person's — their Chrome, their profile,
491
+ # their signed-in accounts. The server used to pick the newest
492
+ # connection for a workspace, which is right while there is one
493
+ # runner and wrong the moment a workspace has a sandbox per person:
494
+ # the action lands in whoever connected last. Reporting this lets
495
+ # the server route by owner instead of by recency.
496
+ #
497
+ # Empty for a laptop or a shared container, which serve anyone —
498
+ # that is the pre-per-person deployment and stays supported.
499
+ "sandbox_owner": _sandbox_owner(),
500
+ "local_skills": _read_local_skills(workdir),
501
+ "local_memory": _read_local_memory(workdir),
502
+ "ollama": await _probe_ollama(),
503
+ "browser": _probe_browser(),
504
+ }
505
+
506
+
473
507
  async def _handle_frame(
474
508
  frame: dict,
475
509
  workdir: Path,
@@ -482,39 +516,7 @@ async def _handle_frame(
482
516
  fid = frame.get("id")
483
517
 
484
518
  if ftype == "hello":
485
- reply = {
486
- "v": 1,
487
- "id": fid,
488
- "type": "ready",
489
- "cli_version": __version__,
490
- "os": platform.system(),
491
- "arch": platform.machine(),
492
- # The server only ever recorded a fingerprint at pairing time, so a
493
- # container reusing a stored token reported the machine that paired
494
- # months ago. Reporting it on every ready frame keeps the row honest.
495
- "host": socket.gethostname(),
496
- "python": f"{sys.version_info.major}.{sys.version_info.minor}",
497
- "workdir": str(workdir),
498
- # Lets the server and the portal tell a shared runner apart from a
499
- # personal one without guessing from the hostname.
500
- "isolate_sessions": _isolation_enabled(),
501
- # Which person this runner serves, when it serves exactly one.
502
- #
503
- # A browser session is one person's — their Chrome, their profile,
504
- # their signed-in accounts. The server used to pick the newest
505
- # connection for a workspace, which is right while there is one
506
- # runner and wrong the moment a workspace has a sandbox per person:
507
- # the action lands in whoever connected last. Reporting this lets
508
- # the server route by owner instead of by recency.
509
- #
510
- # Empty for a laptop or a shared container, which serve anyone —
511
- # that is the pre-per-person deployment and stays supported.
512
- "sandbox_owner": _sandbox_owner(),
513
- "local_skills": _read_local_skills(workdir),
514
- "local_memory": _read_local_memory(workdir),
515
- "ollama": await _probe_ollama(),
516
- "browser": _probe_browser(),
517
- }
519
+ reply = {"v": 1, "id": fid, "type": "ready", **(await _ready_payload(workdir))}
518
520
  await ws.send(json.dumps(reply))
519
521
  on_status("info", f"Connected. Workdir: {workdir}")
520
522
  return
@@ -895,19 +897,24 @@ def _work_task_done(task: asyncio.Task) -> None:
895
897
  logger.warning("Frame handling error: %s", exc)
896
898
 
897
899
 
898
- def _spawn(frame: dict, workdir: Path, ws, on_status, on_output) -> None:
899
- """Hand a frame to a task, or refuse it while we still can answer."""
900
+ def _spawn(frame: dict, workdir: Path, ws, on_status, on_output) -> Optional[asyncio.Task]:
901
+ """Hand a frame to a task, or refuse it while we still can answer.
902
+
903
+ Returns the task, or None when refused: the pull transport cancels it
904
+ when the server says the caller gave up (virtuai_cli/pull.py).
905
+ """
900
906
  if len(_WORK_TASKS) >= _int_env("VIRTUAI_MAX_QUEUED_FRAMES", _MAX_QUEUED_FRAMES):
901
907
  reply = _busy_reply(frame, "the runner has too many requests queued")
902
908
  if reply is not None:
903
909
  asyncio.ensure_future(_safe_send(ws, reply))
904
910
  logger.warning("Refused a %r frame: %d already queued",
905
911
  frame.get("type"), len(_WORK_TASKS))
906
- return
912
+ return None
907
913
  task = asyncio.ensure_future(_dispatch(frame, workdir, ws, on_status, on_output))
908
914
  _WORK_TASKS.add(task)
909
915
  _TASK_PHASE[task] = "queued"
910
916
  task.add_done_callback(_work_task_done)
917
+ return task
911
918
 
912
919
 
913
920
  async def _run_browser_action(frame: dict, ws, on_status: StatusCallback) -> None:
@@ -1079,6 +1086,22 @@ async def run_forever(
1079
1086
 
1080
1087
  attempt = 0
1081
1088
  try:
1089
+ # Pull first (virtuai_cli/pull.py): the server never has to reach
1090
+ # this machine. A server that does not offer it answers 404, and the
1091
+ # socket below is the fallback. VIRTUAI_TRANSPORT=socket skips pull.
1092
+ if os.environ.get("VIRTUAI_TRANSPORT", "auto").strip().lower() != "socket":
1093
+ from virtuai_cli import pull
1094
+
1095
+ try:
1096
+ await pull.run_pull(server_url, token, workdir, on_status, on_output)
1097
+ return
1098
+ except pull.PullUnavailable:
1099
+ on_status("info", "The server does not offer pull; using the WebSocket.")
1100
+ except pull.TokenRejected as exc:
1101
+ # The socket below retries with backoff, as it always has.
1102
+ on_status("error", str(exc))
1103
+ except SystemExit:
1104
+ return
1082
1105
  while True:
1083
1106
  try:
1084
1107
  await run_session(ws_url, token, workdir, on_status, on_output)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: virtuai-cli
3
- Version: 0.9.0
3
+ Version: 1.0.0
4
4
  Summary: Run VirtuAI deep agents on your local machine
5
5
  Author-email: uCloudStore <lmoreno@ucloudstore.com>
6
6
  License: Proprietary
@@ -6,6 +6,7 @@ src/virtuai_cli/config.py
6
6
  src/virtuai_cli/executor.py
7
7
  src/virtuai_cli/main.py
8
8
  src/virtuai_cli/opencode.py
9
+ src/virtuai_cli/pull.py
9
10
  src/virtuai_cli/runner.py
10
11
  src/virtuai_cli/security.py
11
12
  src/virtuai_cli.egg-info/PKG-INFO
@@ -1215,6 +1215,9 @@ def test_cli_shutdown_closes_the_browser(monkeypatch):
1215
1215
  closed.append(True)
1216
1216
 
1217
1217
  monkeypatch.setattr(B, "close_all_sessions", track)
1218
+ # The socket path: CLI 1.0 otherwise tries pull first, against a server
1219
+ # that does not exist, and keeps retrying.
1220
+ monkeypatch.setenv("VIRTUAI_TRANSPORT", "socket")
1218
1221
 
1219
1222
  async def go():
1220
1223
  async def bye(*a, **kw):
File without changes
File without changes