rendez-cli 0.1.0__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.
rendez/__init__.py ADDED
@@ -0,0 +1,1134 @@
1
+ #!/usr/bin/env python3
2
+ """rendez — put the repository you are already in into a Rendez session.
3
+
4
+ $ rendez connect
5
+ Session rate limit signups
6
+ Share http://localhost:3000/sessions/8f3a...
7
+ Workspace ~/code/Hive (main, clean)
8
+
9
+ [Priya] the counter should be in Postgres, we run several processes
10
+ [agent] Reading signup-service/main.py
11
+ ...
12
+
13
+ **What it is.** A second client, not a second system. It speaks the same
14
+ websocket protocol as the web app -- same `?user=` identity, same `since=`
15
+ replay -- and adds one capability the browser cannot have: it offers the
16
+ local filesystem as the session's workspace. The agent's `read_file`,
17
+ `write_file` and `list_dir` then run here, against the real repository, with
18
+ the developer's own branch and credentials.
19
+
20
+ Everything else stays on the server: the agent, the plan, conflict detection,
21
+ permissions, the event log. This process only has hands.
22
+
23
+ **Why one machine.** The agent can reason about one filesystem. Three people
24
+ have three different trees, so "whose disk?" needs an answer, and the honest
25
+ one is "whoever ran this". Everyone else watches in the browser. See
26
+ `server/app/host.py`.
27
+
28
+ **Shell asks first** (`DEC-022`). Commands that obviously only read run
29
+ unprompted; anything else is put to you, here, and only you can answer -- it
30
+ is your machine. A redirect kills the command's whole process group, so an
31
+ interrupted `npm test` does not leave its runner behind.
32
+
33
+ **What it deliberately does not do.**
34
+
35
+ * No git operations. It reports the branch for display and never commits,
36
+ pushes or checks out.
37
+ * No second host. One machine per session.
38
+ * No sandbox. Paths are confined to the repository root, which stops `../`
39
+ and nothing else: a `.env` inside the root is inside the root.
40
+ """
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import asyncio
45
+ import getpass
46
+ from http.cookies import SimpleCookie
47
+ import json
48
+ import os
49
+ import signal
50
+ import subprocess
51
+ import sys
52
+ import threading
53
+ import urllib.error
54
+ import urllib.parse
55
+ import urllib.request
56
+ from pathlib import Path
57
+
58
+ try:
59
+ import websockets
60
+ except ImportError: # pragma: no cover - guidance beats a stack trace
61
+ sys.exit("rendez needs `websockets`: pip install websockets")
62
+
63
+ # The deployed product, because this is an installed package: someone who runs
64
+ # `pipx install rendez-cli` and then `rendez login` means the real service, not
65
+ # an empty localhost. Developing against a local stack is the case that sets
66
+ # `RENDEZ_API`/`RENDEZ_WEB`, or passes `--api`/`--web` -- it knows it is the
67
+ # exception. The old defaults were `localhost:8100` and `localhost:3000`, and
68
+ # the API one did not even match the server's own port (`R-102`).
69
+ DEFAULT_API = os.getenv("RENDEZ_API", "https://rendez-server.onrender.com")
70
+ DEFAULT_WEB = os.getenv("RENDEZ_WEB", "https://rendez.space")
71
+ AUTH_FILE = Path(os.environ["RENDEZ_AUTH_FILE"]) if os.environ.get("RENDEZ_AUTH_FILE") else Path.home() / ".config" / "rendez" / "auth.json"
72
+
73
+
74
+ def saved_token(api: str) -> str:
75
+ try:
76
+ return json.loads(AUTH_FILE.read_text()).get(api, "")
77
+ except (OSError, ValueError):
78
+ return ""
79
+
80
+
81
+ def save_token(api: str, token: str) -> None:
82
+ try:
83
+ data = json.loads(AUTH_FILE.read_text())
84
+ except (OSError, ValueError):
85
+ data = {}
86
+ if token:
87
+ data[api] = token
88
+ else:
89
+ data.pop(api, None)
90
+ AUTH_FILE.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
91
+ fd = os.open(AUTH_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
92
+ with os.fdopen(fd, "w") as out:
93
+ json.dump(data, out)
94
+ AUTH_FILE.chmod(0o600)
95
+
96
+
97
+ def api_json(url: str, token: str = "", body: dict | None = None) -> dict | list:
98
+ headers = {"authorization": f"Bearer {token}"} if token else {}
99
+ if body is not None:
100
+ headers["content-type"] = "application/json"
101
+ req = urllib.request.Request(url, json.dumps(body).encode() if body is not None else None,
102
+ headers)
103
+ with urllib.request.urlopen(req, timeout=15) as response:
104
+ return json.load(response)
105
+
106
+
107
+ # The protocol this client speaks. The server advertises its own range on
108
+ # `/health`; see `DEC-092`. This is not the package version: a release that
109
+ # changes nothing on the wire leaves it alone.
110
+ CLIENT_PROTOCOL = 1
111
+
112
+
113
+ def check_protocol(api: str) -> str:
114
+ """Compare wire versions with the server. Returns a warning, or "".
115
+
116
+ Never fatal on its own. A server that predates the handshake answers
117
+ without the fields, and an unreachable one is the connect path's problem
118
+ to report -- neither is a reason for this to stop the run.
119
+ """
120
+ try:
121
+ health = api_json(f"{api}/health")
122
+ except Exception: # noqa: BLE001
123
+ return ""
124
+ if not isinstance(health, dict) or "protocol" not in health:
125
+ return "" # older server
126
+ try:
127
+ server_protocol = int(health["protocol"])
128
+ min_client = int(health.get("min_client", server_protocol))
129
+ except (TypeError, ValueError):
130
+ return ""
131
+ if CLIENT_PROTOCOL < min_client:
132
+ return (f"This rendez is too old for {api}: it speaks protocol "
133
+ f"{CLIENT_PROTOCOL}, the server needs {min_client} or newer.\n"
134
+ "Upgrade with: pipx upgrade rendez-cli")
135
+ if CLIENT_PROTOCOL > server_protocol:
136
+ return (f"This rendez speaks protocol {CLIENT_PROTOCOL} and {api} "
137
+ f"serves {server_protocol}. Newer features may be missing.")
138
+ return ""
139
+
140
+
141
+ def login_cli(api: str) -> int:
142
+ # Checked here too, so a client that has fallen out of range says so at
143
+ # the first command rather than signing in and failing at the second.
144
+ warning = check_protocol(api)
145
+ if warning:
146
+ print(warning, file=sys.stderr)
147
+ if warning.startswith("This rendez is too old"):
148
+ return 1
149
+ # The username, not the email (`DEC-067` narrowed sign-in to one
150
+ # identifier). The request field is still called `email` because that is
151
+ # what the server's `Credentials` model names it; it carries a username.
152
+ # Prompting for an address here produced "Invalid username or password"
153
+ # with nothing to say why.
154
+ username = input("Username: ").strip()
155
+ password = getpass.getpass("Password: ")
156
+ req = urllib.request.Request(f"{api}/auth/login",
157
+ json.dumps({"email": username, "password": password}).encode(),
158
+ {"content-type": "application/json"})
159
+ try:
160
+ with urllib.request.urlopen(req, timeout=15) as response:
161
+ cookie = SimpleCookie()
162
+ cookie.load(response.headers.get("Set-Cookie", ""))
163
+ token = cookie["rendez_session"].value
164
+ except (urllib.error.URLError, KeyError) as exc:
165
+ print(f"Sign in failed: {exc}", file=sys.stderr)
166
+ return 1
167
+ save_token(api, token)
168
+ print("Signed in. You can now run rendez connect.")
169
+ return 0
170
+
171
+ # Refuse to read or write anything larger than this. A tool result is re-sent
172
+ # to the model on every subsequent request (the text runtime carries its own
173
+ # history), so one large file does not cost once -- it costs every turn for
174
+ # the rest of the session.
175
+ MAX_BYTES = 256 * 1024
176
+ # A diff is for reading. A regenerated lockfile is a legitimate change and
177
+ # thousands of useless lines, so the per-file diff and the number of files
178
+ # diffed are both capped; the file list itself is never truncated.
179
+ MAX_DIFF_BYTES = 64 * 1024
180
+ MAX_DIFF_FILES = 50
181
+
182
+
183
+ class Workspace:
184
+ """The local repository, and the only part of the disk this exposes.
185
+
186
+ Every path is resolved and checked against the root. A path that escapes
187
+ is refused rather than clamped, so the failure is visible in the
188
+ transcript instead of quietly reading the wrong file.
189
+
190
+ This is a confinement check, not a sandbox. It stops the agent walking up
191
+ out of the repository with `../`; it does not stop anything else running
192
+ on this machine, and it does not make the repository's own secrets safe
193
+ -- a `.env` inside the root is inside the root.
194
+ """
195
+
196
+ def __init__(self, root: Path):
197
+ self.root = root.resolve()
198
+
199
+ def _resolve(self, rel: str) -> Path:
200
+ p = (self.root / rel).resolve()
201
+ if not p.is_relative_to(self.root):
202
+ raise ValueError(f"path escapes the workspace: {rel}")
203
+ return p
204
+
205
+ def read_file(self, path: str) -> str:
206
+ p = self._resolve(path)
207
+ if not p.is_file():
208
+ raise FileNotFoundError(f"no such file: {path}")
209
+ if p.stat().st_size > MAX_BYTES:
210
+ raise ValueError(
211
+ f"{path} is {p.stat().st_size // 1024}KB; the limit is "
212
+ f"{MAX_BYTES // 1024}KB")
213
+ return p.read_text(errors="replace")
214
+
215
+ def write_file(self, path: str, content: str) -> str:
216
+ if len(content.encode()) > MAX_BYTES:
217
+ raise ValueError(f"refusing to write more than {MAX_BYTES} bytes")
218
+ p = self._resolve(path)
219
+ p.parent.mkdir(parents=True, exist_ok=True)
220
+ existed = p.is_file()
221
+ p.write_text(content)
222
+ return (f"{'updated' if existed else 'created'} {path} "
223
+ f"({len(content)} bytes)")
224
+
225
+ def list_dir(self, path: str = ".") -> list[str]:
226
+ p = self._resolve(path or ".")
227
+ if not p.is_dir():
228
+ raise NotADirectoryError(f"not a directory: {path}")
229
+ # Skip the noise that makes a listing useless in a real repository.
230
+ skip = {".git", "node_modules", "__pycache__", ".next", ".venv"}
231
+ return sorted(e.name + ("/" if e.is_dir() else "")
232
+ for e in p.iterdir() if e.name not in skip)
233
+
234
+ def remote(self) -> str:
235
+ """The origin URL, or "" if there is no remote.
236
+
237
+ Used to identify the repository across machines (`DEC-023`), so a
238
+ teammate's clone of the same project matches this one.
239
+ """
240
+ try:
241
+ out = subprocess.run(("git", "remote", "get-url", "origin"),
242
+ cwd=self.root, capture_output=True,
243
+ text=True, timeout=5)
244
+ return out.stdout.strip() if out.returncode == 0 else ""
245
+ except (OSError, subprocess.SubprocessError):
246
+ return ""
247
+
248
+ def root_commit(self) -> str:
249
+ """The first commit's hash, or "" if there isn't a usable one.
250
+
251
+ A repository with no remote has no identity to share, so `DEC-023`
252
+ files it under its absolute path -- and renaming the directory then
253
+ hides every session for it. The root commit is computed locally by
254
+ git, needs no server, and does not change when the checkout moves.
255
+
256
+ **Refused for a shallow clone.** `--max-parents=0` returns the commit
257
+ history was truncated at, which has no visible parents and is not the
258
+ root: exit code 0 and a plausible hash that changes with every new
259
+ commit. Measured -- a `--depth 1` clone answers with its own HEAD.
260
+ That is worse than the path it would replace, and silent, so a
261
+ shallow repository keeps the path fallback.
262
+ """
263
+ def run(*args: str) -> str:
264
+ try:
265
+ out = subprocess.run(("git", *args), cwd=self.root,
266
+ capture_output=True, text=True, timeout=5)
267
+ return out.stdout.strip() if out.returncode == 0 else ""
268
+ except (OSError, subprocess.SubprocessError):
269
+ return ""
270
+
271
+ if run("rev-parse", "--is-shallow-repository") != "false":
272
+ return ""
273
+ # `rev-list` lists every root on a repository with grafted or
274
+ # unrelated histories. The oldest is the one that does not move.
275
+ roots = run("rev-list", "--max-parents=0", "HEAD").split()
276
+ return roots[-1] if roots else ""
277
+
278
+ def git_changes(self) -> dict:
279
+ """What `git status` says, plus a diff per changed file.
280
+
281
+ The room reviews the agent's work from the browser; without this they
282
+ read a transcript saying "wrote app/cart.py" and have to open an IDE
283
+ to see what that meant. `--porcelain=v1 -z` so paths with spaces or
284
+ quotes survive, and the diff is capped: a vendored lockfile is a real
285
+ change and a useless thing to render.
286
+ """
287
+ def run(*args: str, ok=(0,)) -> str | None:
288
+ try:
289
+ out = subprocess.run(("git", *args), cwd=self.root,
290
+ capture_output=True, text=True,
291
+ timeout=15, errors="replace")
292
+ return out.stdout if out.returncode in ok else None
293
+ except (OSError, subprocess.SubprocessError):
294
+ return None
295
+
296
+ status = run("status", "--porcelain=v1", "-z")
297
+ if status is None:
298
+ # `git status` failed, which says nothing about whether this
299
+ # machine is hosting the session -- it is, or this call could not
300
+ # have arrived. Reporting `attached: False` here made the server
301
+ # spread it over its own answer and the Files tab tell someone
302
+ # with a running CLI to go and run `rendez connect`.
303
+ #
304
+ # No git installed, or a directory that is not a repository: the
305
+ # file tools work normally, there is simply no change list to
306
+ # show. Say which, because "no changes" and "cannot see changes"
307
+ # look identical in an empty panel.
308
+ return {"attached": True, "files": [], "branch": "",
309
+ "root": str(self.root), "no_git": True}
310
+ files, parts = [], [p for p in status.split("\0") if p]
311
+ i = 0
312
+ while i < len(parts):
313
+ entry = parts[i]
314
+ code, path = entry[:2], entry[3:]
315
+ # A rename's old path is the next NUL-separated field, not part
316
+ # of this one -- splitting on " -> " instead loses files whose
317
+ # names contain that sequence.
318
+ if "R" in code and i + 1 < len(parts):
319
+ i += 1
320
+ files.append({"status": code.strip() or "?", "path": path})
321
+ i += 1
322
+
323
+ for f in files[:MAX_DIFF_FILES]:
324
+ # `--no-index` exits 1 when the files differ, which is the normal
325
+ # case here and not an error; treating it as one silently dropped
326
+ # every new file's diff.
327
+ diff = (run("diff", "--no-color", "--no-index", "/dev/null", f["path"], ok=(0, 1))
328
+ if f["status"] == "??" else
329
+ run("diff", "--no-color", "--", f["path"]))
330
+ if diff and len(diff) > MAX_DIFF_BYTES:
331
+ diff = diff[:MAX_DIFF_BYTES] + "\n... diff truncated ..."
332
+ f["diff"] = diff or ""
333
+ branch, _ = self.git()
334
+ return {"attached": True, "branch": branch, "root": str(self.root),
335
+ "files": files, "truncated": len(files) > MAX_DIFF_FILES}
336
+
337
+ def git(self) -> tuple[str, bool]:
338
+ """Branch and whether the tree is dirty. For display only."""
339
+ def run(*args: str) -> str | None:
340
+ try:
341
+ out = subprocess.run(("git", *args), cwd=self.root,
342
+ capture_output=True, text=True, timeout=5)
343
+ return out.stdout.strip() if out.returncode == 0 else None
344
+ except (OSError, subprocess.SubprocessError):
345
+ return None
346
+ branch = run("rev-parse", "--abbrev-ref", "HEAD")
347
+ status = run("status", "--porcelain")
348
+ return (branch or "", bool(status))
349
+
350
+
351
+
352
+ class Watcher:
353
+ """Notice files the developer changed, so the agent can be told.
354
+
355
+ Polls mtimes rather than using an OS watch API. The comparison is
356
+ deliberate: `watchdog` is a dependency, needs a thread, and behaves
357
+ differently on three platforms, all to improve a resolution that does
358
+ not matter here. The agent acts between turns, so "changed since we last
359
+ looked" is the only granularity that can affect anything.
360
+
361
+ Only files the session has actually touched are watched. A repository
362
+ has thousands of files and the agent cares about a handful; polling the
363
+ whole tree would burn CPU to detect edits nobody is racing.
364
+ """
365
+
366
+ def __init__(self, workspace: "Workspace"):
367
+ self._ws = workspace
368
+ self._mtimes: dict[str, float] = {}
369
+
370
+ def track(self, rel: str) -> None:
371
+ """Start watching a path the agent just touched."""
372
+ try:
373
+ self._mtimes[rel] = self._ws._resolve(rel).stat().st_mtime
374
+ except (OSError, ValueError):
375
+ self._mtimes[rel] = 0.0
376
+
377
+ def changed(self) -> list[str]:
378
+ """Paths modified since we last looked. Updates the baseline."""
379
+ out = []
380
+ for rel, was in list(self._mtimes.items()):
381
+ try:
382
+ now = self._ws._resolve(rel).stat().st_mtime
383
+ except (OSError, ValueError):
384
+ continue
385
+ if now != was:
386
+ self._mtimes[rel] = now
387
+ out.append(rel)
388
+ return out
389
+
390
+
391
+
392
+ async def do_file(sock, workspace, watcher, msg) -> None:
393
+ """Run one file operation and answer. Always answers, even on failure:
394
+ an unanswered request stalls the turn until the server's timeout and the
395
+ room sees nothing."""
396
+ op, a = msg.get("op"), msg.get("args") or {}
397
+ try:
398
+ fn = OPS.get(op)
399
+ if fn is None:
400
+ raise ValueError(f"unsupported operation: {op}")
401
+ result, err = fn(workspace, a), None
402
+ print(dim(f" {op} {a.get('path', '')}"))
403
+ # Watch what the agent touched, so a developer's edit to the same
404
+ # file is noticed rather than silently overwritten. A teammate's
405
+ # private assistant reads with `watch: false`: it is not the agent,
406
+ # and a watch would turn a private question into a shared
407
+ # `file.changed` event (`DEC-046`).
408
+ if a.get("path") and a.get("watch", True):
409
+ watcher.track(a["path"])
410
+ except Exception as e: # noqa: BLE001 -- report, never crash
411
+ result, err = None, f"{type(e).__name__}: {e}"
412
+ print(dim(f" {op} {a.get('path', '')} -> {err}"))
413
+ await sock.send(json.dumps({"type": "host_result",
414
+ "call_id": msg.get("call_id"),
415
+ "result": result, "error": err}))
416
+
417
+
418
+ async def do_shell(sock, workspace, call_id, command) -> None:
419
+ """Run an approved command and answer.
420
+
421
+ A cancelled run answers nothing: the server is no longer waiting, and
422
+ `Host.call` has already unwound. Reporting a result here would arrive
423
+ against a call id nobody holds.
424
+ """
425
+ print(f" $ {command}")
426
+ try:
427
+ result = await run_shell(workspace, command)
428
+ tail = (result["output"] or "").strip().splitlines()
429
+ for line in tail[-8:]:
430
+ print(dim(f" {line[:110]}"))
431
+ err = None
432
+ except asyncio.CancelledError:
433
+ return
434
+ except Exception as e: # noqa: BLE001
435
+ result, err = None, f"{type(e).__name__}: {e}"
436
+ print(dim(f" {err}"))
437
+ await sock.send(json.dumps({"type": "host_result", "call_id": call_id,
438
+ "result": result, "error": err}))
439
+
440
+
441
+ # ---- the terminal ----------------------------------------------------------
442
+ #
443
+ # Reading a person's answer is where four separate session failures came
444
+ # from, and every one traced to the same root: `input()` on a worker thread
445
+ # cannot be cancelled. A prompt that was answered in the browser, or whose
446
+ # question was cancelled by a barge-in, or whose connection dropped, left a
447
+ # thread blocked on stdin forever. The next prompt then started a *second*
448
+ # reader on the same file descriptor, two threads raced for one line, and
449
+ # output held for the first prompt never flushed. The session looked frozen.
450
+ #
451
+ # The fix is structural rather than another guard: **one** reader thread for
452
+ # the life of the process, pushing lines onto a queue. Nothing ever cancels
453
+ # it, because nothing ever needs to. A prompt takes the next line off the
454
+ # queue and can be abandoned freely -- the reader neither knows nor cares.
455
+
456
+ _lines: asyncio.Queue[str] | None = None
457
+
458
+
459
+ def _start_stdin_reader(loop: asyncio.AbstractEventLoop) -> asyncio.Queue:
460
+ """One daemon thread, one queue, for the whole process.
461
+
462
+ A daemon so it cannot keep the process alive at exit, which a blocked
463
+ `input()` otherwise does.
464
+ """
465
+ queue: asyncio.Queue[str] = asyncio.Queue()
466
+
467
+ def pump() -> None:
468
+ while True:
469
+ try:
470
+ line = sys.stdin.readline()
471
+ except (ValueError, OSError):
472
+ line = ""
473
+ if line == "": # EOF: stdin closed
474
+ loop.call_soon_threadsafe(queue.put_nowait, "")
475
+ return
476
+ loop.call_soon_threadsafe(queue.put_nowait, line.strip())
477
+
478
+ threading.Thread(target=pump, daemon=True, name="stdin").start()
479
+ return queue
480
+
481
+
482
+ # Output is held while a question is on screen, so a reply cannot be printed
483
+ # over a half-typed answer, and released when it is settled -- however it was
484
+ # settled.
485
+ _prompting = asyncio.Event()
486
+ _prompting.set() # set == nobody is being asked
487
+ _held: list[str] = []
488
+
489
+
490
+ def say(line: str) -> None:
491
+ """Print, unless someone is mid-answer -- then queue it."""
492
+ if _prompting.is_set():
493
+ print(line)
494
+ else:
495
+ _held.append(line)
496
+
497
+
498
+ def _flush() -> None:
499
+ for line in _held:
500
+ print(line)
501
+ _held.clear()
502
+
503
+
504
+ # The question currently on screen, so it can be retired from elsewhere.
505
+ _open_request: dict[str, object] = {}
506
+
507
+
508
+ def _close_prompt(note: str = "") -> None:
509
+ """Take the question off screen and let output flow again.
510
+
511
+ Called when the question is settled anywhere: answered here, answered in
512
+ the browser, cancelled by a redirect, or orphaned by a disconnect. The
513
+ waiting task is cancelled, which is safe now that it is waiting on a
514
+ queue rather than on stdin.
515
+ """
516
+ task = _open_request.pop("task", None)
517
+ _open_request.pop("id", None)
518
+ if note:
519
+ print(dim(f"\n {note}"))
520
+ if task is not None and not task.done():
521
+ task.cancel()
522
+ _prompting.set()
523
+ _flush()
524
+
525
+
526
+ def resolve_elsewhere(request_id: str, approved: bool) -> None:
527
+ """Someone answered in the browser. Retire the terminal's prompt."""
528
+ if _open_request.get("id") != request_id:
529
+ return
530
+ _close_prompt(f"answered in the browser: "
531
+ f"{'allowed' if approved else 'refused'}")
532
+
533
+
534
+ def cancel_prompt(request_id: str = "") -> None:
535
+ """The question no longer needs an answer: redirected, or disconnected."""
536
+ if request_id and _open_request.get("id") != request_id:
537
+ return
538
+ if _open_request.get("id"):
539
+ _close_prompt("that question no longer needs an answer")
540
+
541
+
542
+ def abandon_prompt(why: str) -> None:
543
+ """The connection went away while a question was open."""
544
+ if _open_request.get("id"):
545
+ _close_prompt(f"{why} — that question is gone")
546
+
547
+
548
+ async def ask_human(sock, msg) -> None:
549
+ """Put the agent's command to the person whose machine this is.
550
+
551
+ Waits on the shared stdin queue, never on stdin itself, so this task can
552
+ be cancelled the moment the question stops mattering. That is the whole
553
+ difference from the version that caused four failed sessions.
554
+ """
555
+ command = msg.get("command", "")
556
+ request_id = msg.get("request_id")
557
+ _open_request["id"] = request_id
558
+ _open_request["task"] = asyncio.current_task()
559
+ _prompting.clear()
560
+ print(f"\n {bold('The agent wants to run:')} {command}")
561
+ print(dim(" [y] once [a] always this command [n] no"), end="", flush=True)
562
+ print(" > ", end="", flush=True)
563
+ try:
564
+ answer = (await _lines.get()).strip().lower()
565
+ except asyncio.CancelledError:
566
+ # Settled elsewhere. `_close_prompt` has already released the gate.
567
+ raise
568
+ _open_request.pop("id", None)
569
+ _open_request.pop("task", None)
570
+ _prompting.set()
571
+ _flush()
572
+ approved = answer in ("y", "yes", "a", "always")
573
+ remember = answer in ("a", "always")
574
+ print(dim(f" {'approved' if approved else 'refused'}"
575
+ f"{' (remembered)' if remember else ''}\n"))
576
+ await sock.send(json.dumps({
577
+ "type": "approve", "request_id": request_id,
578
+ "approved": approved, "remember": remember}))
579
+
580
+
581
+ async def watch_loop(sock, watcher: "Watcher", workspace: "Workspace",
582
+ user: str, interval: float = 1.5) -> None:
583
+ """Report the developer's own edits, and any change of branch.
584
+
585
+ Runs alongside the message loop. Without it the agent only discovers a
586
+ human edit by reading the file again, which it has no reason to do --
587
+ the silent-overwrite case in `R-023`.
588
+
589
+ Branch and dirty state are re-read here rather than only at attach.
590
+ Otherwise checking out a branch mid-session leaves the agent being told,
591
+ every turn, about the branch you were on when you started -- a stale
592
+ fact stated confidently, which is worse than no fact at all.
593
+ """
594
+ last_git = workspace.git()
595
+ while True:
596
+ await asyncio.sleep(interval)
597
+ try:
598
+ for rel in watcher.changed():
599
+ print(dim(f" you edited {rel}"))
600
+ await sock.send(json.dumps({
601
+ "type": "file_changed", "path": rel, "actor": user}))
602
+ now = workspace.git()
603
+ if now != last_git:
604
+ last_git = now
605
+ branch, dirty = now
606
+ if branch:
607
+ print(dim(f" now on {branch}"
608
+ f"{' (dirty)' if dirty else ''}"))
609
+ await sock.send(json.dumps({
610
+ "type": "host_update", "branch": branch, "dirty": dirty}))
611
+ except Exception: # noqa: BLE001 -- socket closing; the loop ends
612
+ return
613
+
614
+
615
+ OPS = {
616
+ "read_file": lambda ws, a: ws.read_file(a["path"]),
617
+ "write_file": lambda ws, a: ws.write_file(a["path"], a.get("content", "")),
618
+ "list_dir": lambda ws, a: ws.list_dir(a.get("path") or "."),
619
+ "git_changes": lambda ws, a: ws.git_changes(),
620
+ }
621
+
622
+
623
+ async def run_shell(workspace: "Workspace", command: str) -> dict:
624
+ """Run a command in the repository, killable as a group.
625
+
626
+ `start_new_session=True` puts the child in its own process group, so a
627
+ cancel kills the whole tree rather than just the shell. Without it,
628
+ `npm test` leaves the test runner alive: the orphaned-process failure in
629
+ `R-023`, which happened five times on the server before anyone noticed.
630
+ """
631
+ proc = await asyncio.create_subprocess_shell(
632
+ command, cwd=workspace.root,
633
+ stdout=asyncio.subprocess.PIPE,
634
+ stderr=asyncio.subprocess.STDOUT,
635
+ start_new_session=True)
636
+ try:
637
+ out, _ = await proc.communicate()
638
+ return {"exit_code": proc.returncode,
639
+ "output": out.decode(errors="replace")[:20000]}
640
+ except asyncio.CancelledError:
641
+ try:
642
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
643
+ except (ProcessLookupError, PermissionError):
644
+ proc.kill()
645
+ await proc.wait()
646
+ raise
647
+
648
+
649
+ def create_session(api: str, goal: str, user: str, token: str = "") -> str:
650
+ return api_json(f"{api}/sessions", token,
651
+ {"goal": goal, "created_by": user})["id"]
652
+
653
+
654
+ def repo_sessions(api: str, remote: str, root: str, token: str = "",
655
+ root_commit: str = "") -> list[dict]:
656
+ """Sessions already running against this repository (`DEC-023`).
657
+
658
+ Best-effort: a server too old to know the route, or unreachable, simply
659
+ means we offer nothing and start fresh.
660
+
661
+ `root_commit` has to be sent here as well as on attach: the two must
662
+ key the same way, or the picker searches under one identity and the
663
+ attach files under another.
664
+ """
665
+ q = urllib.parse.urlencode({"remote": remote, "root": root,
666
+ "root_commit": root_commit})
667
+ try:
668
+ return api_json(f"{api}/repo-sessions?{q}", token)
669
+ except Exception:
670
+ return []
671
+
672
+
673
+ def session_goal(api: str, sid: str, token: str = "") -> str:
674
+ """What this session is called, for a header a person has to read.
675
+
676
+ Joining with `--session` means nobody typed a goal here, and the id is
677
+ the one thing on screen that identifies nothing: the terminal would say
678
+ `Session f5576f29` while the browser called it "add rate limiting".
679
+
680
+ Best-effort: a session is perfectly usable without a name, and failing to
681
+ fetch one must not stop the connection the user asked for.
682
+ """
683
+ try:
684
+ found = api_json(f"{api}/sessions/{sid}", token)
685
+ except Exception:
686
+ return ""
687
+ return str((found or {}).get("goal") or "") if isinstance(found, dict) else ""
688
+
689
+
690
+ def delete_session(api: str, sid: str, token: str = "") -> None:
691
+ """Remove a session for good. Raises HTTPError if the server refuses."""
692
+ req = urllib.request.Request(f"{api}/sessions/{sid}", method="DELETE")
693
+ if token:
694
+ req.add_header("authorization", f"Bearer {token}")
695
+ urllib.request.urlopen(req, timeout=10).close()
696
+
697
+
698
+ class PickerStopped(Exception):
699
+ """Ctrl-C at the session picker: not a choice, and not a new session."""
700
+
701
+
702
+ def _prompt_line() -> str:
703
+ """Read one answered line, turning Ctrl-C into `PickerStopped`.
704
+
705
+ `asyncio.run()` installs its own SIGINT handling, and under it a blocking
706
+ `sys.stdin.readline()` inside a coroutine is *not* interrupted: no
707
+ `KeyboardInterrupt` is raised and the process simply stays blocked.
708
+ Measured -- the same read catches it fine outside asyncio, which is what
709
+ made this look like it already worked. The picker runs inside
710
+ `asyncio.run(run(args))`, so it installs a handler of its own for the
711
+ duration of the read and restores whatever was there afterwards.
712
+ """
713
+ def interrupted(_sig, _frame):
714
+ raise PickerStopped()
715
+ try:
716
+ previous = signal.signal(signal.SIGINT, interrupted)
717
+ except ValueError: # not the main thread; nothing to install
718
+ previous = None
719
+ try:
720
+ return sys.stdin.readline().strip()
721
+ except (KeyboardInterrupt, EOFError):
722
+ raise PickerStopped() from None
723
+ finally:
724
+ if previous is not None:
725
+ signal.signal(signal.SIGINT, previous)
726
+
727
+
728
+ def show_sessions(found: list[dict]) -> None:
729
+ """Print the numbered list the picker is answered against.
730
+
731
+ One function rather than two, because there were two: the list printed
732
+ after a deletion was a second, simpler renderer that lost the grouping,
733
+ the branch names and the completed markers -- so deleting a session
734
+ turned "sessions with no repository" into a bare "2 session(s) left"
735
+ with an unfiled session sitting under a heading that implied otherwise.
736
+ """
737
+ # The two groups answer different questions, and the heading counts only
738
+ # the first: "5 sessions on this repo" was a lie when four of them were
739
+ # unrelated sessions that merely had no repository of their own.
740
+ mine = sum(1 for s in found if s.get("repo_key"))
741
+ spare = len(found) - mine
742
+ print(bold(f"\n {mine} recent session(s) on this repo:" if mine
743
+ else "\n No sessions on this repo yet."))
744
+ for i, s in enumerate(found, 1):
745
+ when = s.get("created_at", "")[:16].replace("T", " ")
746
+ # Branching leaves the completed source and its children sharing one
747
+ # goal. Shown as bare goals they are the same line several times over,
748
+ # and joining the source attaches this machine to a session that
749
+ # cannot act until someone reopens it.
750
+ #
751
+ # A session with no goal has no name to show. The bare id read as one
752
+ # -- `b1025a6e` looks like a label rather than the absence of one --
753
+ # so say what it is and keep the id as the thing you type after `d`.
754
+ label = s.get("goal") or f"(unnamed · {s['id'][:8]})"
755
+ if s.get("branch_name"):
756
+ label += f" [{s['branch_name']}]"
757
+ if s.get("session_status") == "completed":
758
+ label += " (completed)"
759
+ # Sessions with no repository are a tail under their own heading, not
760
+ # peers of this repo's own (`DEC-023`): joining one files it under
761
+ # this directory for good, and an unseparated list made that
762
+ # permanent choice look like the reversible one above it.
763
+ if not s.get("repo_key") and i == mine + 1:
764
+ print(dim(f"\n your {spare} most recent session(s) with no "
765
+ f"repository — joining one connects it here:"))
766
+ print(f" {i}. {label} "
767
+ + dim(f"{s.get('created_name') or s.get('created_by','')} · {when}"))
768
+
769
+
770
+ def choose_session(api: str, remote: str, root: str, token: str = "",
771
+ root_commit: str = "") -> str:
772
+ """Offer to join a session already open on this repo, or start a new one.
773
+
774
+ Without this, running the CLI twice in one repository silently produces two
775
+ unrelated sessions against the same files -- each unaware of the other's
776
+ edits, since collision detection is per-session.
777
+
778
+ Non-interactive stdin (a script, a pipe) always starts fresh: prompting
779
+ something that cannot answer would hang.
780
+ """
781
+ if not sys.stdin.isatty():
782
+ return ""
783
+ found = repo_sessions(api, remote, root, token, root_commit)
784
+ if not found:
785
+ return ""
786
+ show_sessions(found)
787
+ while True:
788
+ # "d<number>" invited `d<2c056f8c>` -- the angle brackets read as
789
+ # "put the thing here", and an id is the thing in front of you. The
790
+ # example is literal instead, and anything unrecognised says so rather
791
+ # than quietly starting a new session.
792
+ print(dim("\n Number to join (e.g. 1), d then a number to delete "
793
+ "(e.g. d1), or Enter for a new session: "), end="")
794
+ sys.stdout.flush()
795
+ # Not `return ""` on an interrupt: that is the caller's word for
796
+ # "start a new session", so Ctrl-C at this prompt created the thing it
797
+ # was pressed to avoid -- and the next run then offered that session
798
+ # back as something to clean up.
799
+ answer = _prompt_line()
800
+ if answer.isdigit() and 1 <= int(answer) <= len(found):
801
+ return found[int(answer) - 1]["id"]
802
+ # Deleting is offered here because this list is where its absence is
803
+ # felt: every abandoned attempt stays in it forever, and the list is
804
+ # what someone reads before starting work (`DEC-023`).
805
+ if answer[:1].lower() == "d":
806
+ target = answer[1:].strip().strip("<>")
807
+ if not target.isdigit():
808
+ print(dim(f" '{answer}' is not a number. To delete the first "
809
+ f"session type d1."))
810
+ continue
811
+ index = int(target)
812
+ if not 1 <= index <= len(found):
813
+ print(dim(" no such session"))
814
+ continue
815
+ victim = found[index - 1]
816
+ label = victim.get("goal") or victim["id"][:8]
817
+ print(dim(f" delete \"{label}\"? this cannot be undone [y/N]: "), end="")
818
+ sys.stdout.flush()
819
+ if _prompt_line().lower() not in ("y", "yes"):
820
+ print(dim(" kept"))
821
+ continue
822
+ try:
823
+ delete_session(api, victim["id"], token)
824
+ except urllib.error.HTTPError as exc:
825
+ reason = ("only its owner can delete it" if exc.code == 403
826
+ else f"could not delete it: {exc}")
827
+ print(dim(f" {reason}"))
828
+ continue
829
+ found.pop(index - 1)
830
+ print(dim(f" deleted \"{label}\""))
831
+ if not found:
832
+ # Deleting the last one used to fall straight through to
833
+ # `return ""`, which means "start a new session" -- so tidying
834
+ # up silently created the thing you were tidying. Say the list
835
+ # is empty and let the next Enter be a decision.
836
+ print(dim(" no sessions left on this repo."))
837
+ print(dim(" Enter to start one, or Ctrl-C to stop: "), end="")
838
+ sys.stdout.flush()
839
+ try:
840
+ _prompt_line()
841
+ except PickerStopped:
842
+ raise
843
+ return ""
844
+ show_sessions(found)
845
+ continue
846
+ if answer:
847
+ # Typing something unrecognised is a mistake, not a request for a
848
+ # new session. Saying so costs one line; starting a session nobody
849
+ # asked for costs a row in this list and the confusion of not
850
+ # knowing why.
851
+ print(dim(f" '{answer}' is not an option here."))
852
+ continue
853
+ return ""
854
+
855
+
856
+ def dim(s: str) -> str:
857
+ return f"\033[2m{s}\033[0m" if sys.stdout.isatty() else s
858
+
859
+
860
+ def bold(s: str) -> str:
861
+ return f"\033[1m{s}\033[0m" if sys.stdout.isatty() else s
862
+
863
+
864
+ async def run(args: argparse.Namespace) -> int:
865
+ root = Path(args.dir).resolve()
866
+ if not root.is_dir():
867
+ print(f"not a directory: {root}", file=sys.stderr)
868
+ return 1
869
+ workspace = Workspace(root)
870
+ branch, dirty = workspace.git()
871
+
872
+ api = args.api.rstrip("/")
873
+ # Before anything else, so a client that cannot work with this server says
874
+ # so in one line rather than failing later with something less legible.
875
+ warning = check_protocol(api)
876
+ if warning.startswith("This rendez is too old"):
877
+ print(warning, file=sys.stderr)
878
+ return 1
879
+ if warning:
880
+ print(dim(warning))
881
+ try:
882
+ token = saved_token(api)
883
+ if not token:
884
+ print("Sign in first with rendez login.", file=sys.stderr)
885
+ return 1
886
+ args.user = api_json(f"{api}/auth/me", token)["name"]
887
+ except urllib.error.URLError as exc:
888
+ print(f"Authentication check failed: {exc}", file=sys.stderr)
889
+ return 1
890
+ try:
891
+ sid = args.session
892
+ # Before the stdin reader exists: `choose_session` reads the fd
893
+ # directly, and a second reader on one fd is what `_start_stdin_reader`
894
+ # was introduced to stop (`R-023`). The daemon would win the race and
895
+ # the prompt would hang forever.
896
+ if not sid and not args.new:
897
+ try:
898
+ sid = choose_session(api, workspace.remote(), str(root),
899
+ token, workspace.root_commit())
900
+ except PickerStopped:
901
+ # Ctrl-C at the picker means "none of these, and not a new one
902
+ # either". It used to fall through to `create_session`, so
903
+ # abandoning the prompt created a session -- which the next run
904
+ # then listed as something to clean up.
905
+ print(dim("\n stopped. No session was started."))
906
+ return 130
907
+ # One stdin reader for the rest of the process, shared by every prompt.
908
+ global _lines
909
+ _lines = _start_stdin_reader(asyncio.get_running_loop())
910
+ sid = sid or create_session(api, args.goal or "", args.user, token)
911
+ except urllib.error.URLError as e:
912
+ print(f"cannot reach the server at {api}: {e}", file=sys.stderr)
913
+ return 1
914
+
915
+ ws_url = api.replace("http://", "ws://").replace("https://", "wss://")
916
+ url = f"{ws_url}/ws/{sid}"
917
+
918
+ # `args.goal` is only set when this invocation created the session. Joining
919
+ # one -- with `--session`, or from the picker -- leaves it empty, and the
920
+ # id identifies nothing to the person reading it.
921
+ label = args.goal or session_goal(api, sid, token) or sid[:8]
922
+ print(bold("Session ") + label)
923
+ print(bold("Open ") +
924
+ f"{args.web.rstrip('/')}/sessions/{sid}")
925
+ print(bold("Workspace") + f" {root}" +
926
+ (f" ({branch}{', dirty' if dirty else ''})" if branch else ""))
927
+ # A workspace with no remote and no root commit is filed under its path
928
+ # (`DEC-023`), and renaming the directory then hides every session for
929
+ # it. That is a weaker promise than the one above it, and the moment to
930
+ # say so is before any work depends on it -- the alternative is someone
931
+ # renaming a folder months later and finding their sessions gone with no
932
+ # error anywhere. A shallow clone lands here too, deliberately.
933
+ if not workspace.remote() and not workspace.root_commit():
934
+ print(dim("Filed under this path: renaming the folder will hide its "
935
+ "sessions.\n `git init` here, or a git remote, "
936
+ "makes them survive a rename."))
937
+ print(dim("The agent reads and writes files here. Ctrl-C to stop.\n"))
938
+
939
+ # Reconnect rather than exit. A dropped socket used to end the session's
940
+ # workspace for good: measured once, the host went away mid-turn and the
941
+ # agent carried on against a filesystem that was not the repository. The
942
+ # server now refuses those calls (`tools_for(had_host=...)`), but the
943
+ # right answer is to come back.
944
+ attempt = 0
945
+ while True:
946
+ try:
947
+ connect_url = url
948
+ try:
949
+ issued = api_json(f"{api}/auth/ws-ticket", token, {})
950
+ except urllib.error.HTTPError as exc:
951
+ # The server answered and refused: the token is expired or
952
+ # revoked, and no amount of retrying will change that.
953
+ if exc.code in (401, 403):
954
+ print(f"Session expired: {exc}. Run rendez login again.",
955
+ file=sys.stderr)
956
+ return 1
957
+ raise OSError(f"ticket request failed: {exc}") from exc
958
+ except urllib.error.URLError as exc:
959
+ # The server could not be reached. That is the same
960
+ # condition the reconnect loop below already handles -- a
961
+ # restart, a dropped network -- so it must not end the
962
+ # session. Measured: a four-second server restart used to
963
+ # kill the client for good, telling the user to log in
964
+ # again when their token was perfectly valid.
965
+ raise OSError(f"cannot reach {api}: {exc.reason}") from exc
966
+ connect_url += "?ticket=" + urllib.parse.quote(issued["ticket"])
967
+ if args.share_token:
968
+ connect_url += "&share=" + urllib.parse.quote(args.share_token)
969
+ await _session(connect_url, root, branch, dirty, workspace, args)
970
+ return 0
971
+ except (websockets.ConnectionClosed, OSError) as e:
972
+ # An open approval prompt is waiting on an answer the server can
973
+ # no longer receive. Retire it, or the message loop never runs
974
+ # again and the reconnect below is invisible.
975
+ abandon_prompt("connection lost")
976
+ attempt += 1
977
+ if attempt > 6:
978
+ print(f"\n lost the connection: {e}")
979
+ print(dim(" giving up. Re-run to re-attach this workspace."))
980
+ return 1
981
+ wait = min(2 ** attempt, 20)
982
+ print(dim(f"\n connection lost; reattaching in {wait}s "
983
+ f"(attempt {attempt}/6)"))
984
+ await asyncio.sleep(wait)
985
+ branch, dirty = workspace.git()
986
+
987
+
988
+ async def _session(url, root, branch, dirty, workspace, args) -> None:
989
+ """One connection's lifetime. Returns when the user stops it.
990
+
991
+ Keepalive: the library and uvicorn both default to a 20s ping with a 20s
992
+ timeout. Too tight here -- an approval prompt waits on a person reading a
993
+ command, and a 22s pause to answer was followed ten seconds later by a
994
+ dropped socket. A wider window makes thinking time normal.
995
+ """
996
+ async with websockets.connect(url, max_size=2 ** 22,
997
+ ping_interval=30, ping_timeout=90,
998
+ close_timeout=10) as sock:
999
+ await sock.send(json.dumps({
1000
+ "type": "host_attach", "root": str(root),
1001
+ "remote": workspace.remote(),
1002
+ "root_commit": workspace.root_commit(),
1003
+ "branch": branch, "dirty": dirty}))
1004
+ # Unbuffered, because this client is usually watched in a terminal
1005
+ # beside an editor and a silent reconnect reads as a hang.
1006
+ sys.stdout.flush()
1007
+
1008
+ watcher = Watcher(workspace)
1009
+ watching = asyncio.create_task(
1010
+ watch_loop(sock, watcher, workspace, args.user))
1011
+ # call id -> running shell task, so `host.cancel` can reach it.
1012
+ inflight: dict[str, asyncio.Task] = {}
1013
+ # Our identity as the server knows it, for matching approval
1014
+ # requests addressed to this host. Filled in from `hello`.
1015
+ me = args.user
1016
+
1017
+ async for raw in sock:
1018
+ msg = json.loads(raw)
1019
+ kind = msg.get("type")
1020
+
1021
+ if kind == "host.request":
1022
+ # Shell runs as a task so `host.cancel` can stop it; file
1023
+ # operations are fast enough to run inline.
1024
+ call_id = msg.get("call_id")
1025
+ if msg.get("op") == "run_shell":
1026
+ inflight[call_id] = asyncio.create_task(
1027
+ do_shell(sock, workspace, call_id,
1028
+ (msg.get("args") or {}).get("command", "")))
1029
+ else:
1030
+ await do_file(sock, workspace, watcher, msg)
1031
+
1032
+ elif kind == "host.cancel":
1033
+ # The turn was redirected. Kill the work rather than letting
1034
+ # it finish unattended.
1035
+ task = inflight.pop(msg.get("call_id"), None)
1036
+ if task is not None and not task.done():
1037
+ task.cancel()
1038
+ print(dim(" cancelled"))
1039
+
1040
+ elif kind in ("tool.cancelled", "agent.interrupted"):
1041
+ # A barge-in cancelled the turn, so any question it raised is
1042
+ # moot. This is the path in the session that reported a host
1043
+ # detaching: the approval was still on screen when the turn
1044
+ # went away, and the prompt outlived its own question.
1045
+ cancel_prompt()
1046
+
1047
+ elif kind == "approval.answered":
1048
+ # Answered somewhere -- possibly in the browser. If a prompt
1049
+ # is still on this terminal it is now dead; say so rather
1050
+ # than leaving a question nobody is waiting on.
1051
+ resolve_elsewhere(str(msg.get("request_id") or ""),
1052
+ bool(msg.get("approved")))
1053
+
1054
+ elif kind == "approval.requested" and msg.get("asked_of") == me:
1055
+ # Only asked of the host. Answering blocks the agent, so it
1056
+ # runs as a task and the message loop keeps flowing.
1057
+ asyncio.create_task(ask_human(sock, msg))
1058
+
1059
+ elif kind == "hello":
1060
+ me = msg.get("you_id") or msg.get("you") or args.user
1061
+ if msg.get("role") not in ("owner",):
1062
+ print(dim(f" note: you joined as "
1063
+ f"{msg.get('role')}; only an owner may attach "
1064
+ f"a workspace"))
1065
+
1066
+ elif kind == "denied":
1067
+ print(f" refused: {msg.get('detail')}")
1068
+ # A refused *attach* leaves this process connected to a
1069
+ # session whose files it will never serve: every tool call
1070
+ # fails and the room sees a workspace that is not there.
1071
+ # Retrying cannot help -- the session is bound to another
1072
+ # repository -- so stop rather than hold a dead connection.
1073
+ if msg.get("action") == "host_attach":
1074
+ raise SystemExit(1)
1075
+
1076
+ elif kind == "message.posted":
1077
+ say(f"[{msg.get('actor')}] {msg.get('text', '')}")
1078
+
1079
+ elif kind == "agent.turn.complete":
1080
+ text = (msg.get("text") or "").strip()
1081
+ if text:
1082
+ say(f"[agent] {text}\n")
1083
+
1084
+ elif kind == "host.attached" and msg.get("user") == args.user:
1085
+ print(dim(" workspace attached\n"))
1086
+
1087
+ watching.cancel()
1088
+
1089
+ return 0
1090
+
1091
+
1092
+ def main() -> int:
1093
+ # Line-buffered: this client is watched in a terminal beside an editor,
1094
+ # and Python block-buffers stdout whenever it is not a tty. A piped or
1095
+ # redirected run would otherwise show nothing until it exits.
1096
+ try:
1097
+ sys.stdout.reconfigure(line_buffering=True)
1098
+ except (AttributeError, ValueError):
1099
+ pass
1100
+ p = argparse.ArgumentParser(
1101
+ prog="rendez",
1102
+ description="Share this repository with a Rendez session.")
1103
+ p.add_argument("command", choices=["connect", "login", "logout"])
1104
+ p.add_argument("-d", "--dir", default=".", help="repository root")
1105
+ p.add_argument("-g", "--goal", default="", help="what the session is for")
1106
+ p.add_argument("-s", "--session", default="", help="join an existing id")
1107
+ p.add_argument("-n", "--new", action="store_true",
1108
+ help="start a new session without offering existing ones")
1109
+ p.add_argument("-u", "--user", default=os.getenv("USER", "dev"))
1110
+ p.add_argument("--api", default=DEFAULT_API)
1111
+ p.add_argument("--web", default=DEFAULT_WEB)
1112
+ p.add_argument("--share-token", default="", help="access token from a shared session link")
1113
+ args = p.parse_args()
1114
+ if args.command == "login":
1115
+ return login_cli(args.api.rstrip("/"))
1116
+ if args.command == "logout":
1117
+ token = saved_token(args.api.rstrip("/"))
1118
+ if token:
1119
+ try:
1120
+ api_json(f"{args.api.rstrip('/')}/auth/logout", token, {})
1121
+ except urllib.error.URLError:
1122
+ pass
1123
+ save_token(args.api.rstrip("/"), "")
1124
+ print("Signed out.")
1125
+ return 0
1126
+ try:
1127
+ return asyncio.run(run(args))
1128
+ except KeyboardInterrupt:
1129
+ print("\ndisconnected")
1130
+ return 0
1131
+
1132
+
1133
+ if __name__ == "__main__":
1134
+ sys.exit(main())
rendez/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """`python -m rendez` — the same entry point as the `rendez` command."""
2
+ import sys
3
+
4
+ from . import main
5
+
6
+ if __name__ == "__main__":
7
+ sys.exit(main())
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: rendez-cli
3
+ Version: 0.1.0
4
+ Summary: Put your repository into a Rendez session, from your terminal
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://rendez.space
7
+ Project-URL: Documentation, https://rendez.space
8
+ Keywords: rendez,agent,collaboration,terminal
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: websockets>=12
18
+ Dynamic: license-file
19
+
20
+ # rendez CLI — put your repository into a session
21
+
22
+ Status: **experimental** (`DEC-021`). Files, plus shell with your
23
+ consent per command (`DEC-022`).
24
+
25
+ The web app is the control plane: the plan, the transcript, who is steering.
26
+ This puts the *files* somewhere useful — the repository already checked out on
27
+ your machine, instead of an empty directory on the server.
28
+
29
+ ```bash
30
+ pipx install "git+https://github.com/rendez-engine/hive.git#subdirectory=cli"
31
+
32
+ cd ~/code/your-repo
33
+ rendez connect -g "add rate limiting"
34
+ ```
35
+
36
+ Not on PyPI: the name `rendez` there belongs to an unrelated project, so the
37
+ distribution would be `rendez-cli` (the installed command stays `rendez`).
38
+ Authentication works and the server is deployed; what is still open before
39
+ publishing is a protocol-version handshake, so an installed CLI and a
40
+ redeployed server cannot drift apart silently (`DEPLOYMENT.md` §10).
41
+ Installing from the repository needs read access to it.
42
+
43
+ **HTTPS rather than `git+ssh`**, which assumes a GitHub SSH key that a
44
+ developer may not have — `gh auth login` covers HTTPS and is already how most
45
+ people here reach this repo. Found by installing on a machine that had no SSH
46
+ key. `pipx reinstall rendez-cli` picks up later commits. From a checkout,
47
+ `PYTHONPATH=cli python3 -m rendez connect` runs the working tree.
48
+
49
+ **A plain `pipx install` copies the source, so it silently shadows your
50
+ checkout.** Editing `cli/rendez/__init__.py` then changes nothing about what
51
+ `rendez connect` runs, and the symptom is a feature that works in tests and is
52
+ absent in the app -- with no error anywhere, because the installed copy is
53
+ older but perfectly valid. If you are developing the CLI:
54
+
55
+ ```bash
56
+ pipx install --force --editable ./cli # runs your checkout, not a copy
57
+ ```
58
+
59
+ To tell which one is live:
60
+
61
+ ```bash
62
+ "$(pipx environment --value PIPX_LOCAL_VENVS)/rendez-cli/bin/python" \
63
+ -c "import rendez, inspect; print(inspect.getfile(rendez))"
64
+ ```
65
+
66
+ ```
67
+ 2 recent session(s) on this repo:
68
+ 1. add rate limiting you · 2026-09-29 14:02
69
+ 2. fix the cart bug sam · 2026-09-28 11:40
70
+
71
+ your 1 most recent session(s) with no repository — joining one connects it here:
72
+ 3. plan the migration you · 2026-09-27 09:15
73
+
74
+ Number to join, or Enter for a new session:
75
+
76
+ Session add rate limiting
77
+ Open http://localhost:3000/sessions/8f3a...
78
+ Workspace /Users/you/code/your-repo (main, clean)
79
+ ```
80
+
81
+ Sessions are filed under the repository they were attached to, keyed on the
82
+ git remote (`DEC-023`), so a teammate's clone at a different path matches
83
+ yours. A repository with no remote keys on its first commit instead, which
84
+ means renaming or moving the checkout keeps its sessions. A folder that is
85
+ not a git repository has nothing stable to key on and is filed under its
86
+ path -- renaming it hides its sessions, and `rendez connect` says so. Your own sessions that have **no** repository follow underneath, up to
87
+ two of them: a session started in the browser is filed under nothing, and
88
+ joining one from a checkout connects it to that repository for good. They are
89
+ a tail rather than peers — this repository's own sessions come first and are
90
+ never displaced by them, because "what was already going on here?" is the
91
+ question the list answers. To join
92
+ a specific session directly — the app shows this command on its Files tab —
93
+ use `rendez connect --session <id>`. The header then shows the session's
94
+ goal rather than its id -- the id identifies the session, but it is not what
95
+ anyone calls it. A goal is a label, not an identifier: two sessions may share
96
+ one, and `--session` always takes the id. Without this, two people in one repo start parallel sessions that
97
+ cannot see each other's edits. Press Enter to start fresh, or `--new` to skip
98
+ the question. It is skipped automatically when stdin is not a terminal, so
99
+ scripts do not hang.
100
+
101
+ Authentication is mandatory. First sign up and verify your email in the browser,
102
+ then run `rendez login --api https://YOUR-API`. The CLI prompts for your
103
+ **username** -- not your email address (`DEC-067`) -- and your password, and
104
+ stores a revocable token at `~/.config/rendez/auth.json` with
105
+ mode 0600. `rendez logout` revokes it. `rendez connect` uses the same account
106
+ name as the browser. The terminal's printed session URL identifies a session;
107
+ use the browser Share panel to create a link or invite teammates. For a
108
+ link-only CLI join, supply `--session` and the link's `access` value as
109
+ `--share-token`.
110
+
111
+ Your teammates open an invited session in a browser — nothing to install,
112
+ nothing to clone. They see the transcript and the plan; the owner can grant
113
+ collaborator access to steer the agent. When the agent reads or writes a file, it
114
+ happens **here**, in your working tree, and your editor updates.
115
+
116
+ ## Options
117
+
118
+ | | |
119
+ |---|---|
120
+ | `-d, --dir` | repository root (default: current directory) |
121
+ | `-g, --goal` | what the session is for |
122
+ | `-s, --session` | join an existing session instead of creating one |
123
+ | `-n, --new` | start a new session without being offered existing ones |
124
+ | `-u, --user` | your name in the room (default: `$USER`) |
125
+ | `--share-token` | access value from a revocable share URL |
126
+ | `--api` | server (default `http://localhost:8100`, or `RENDEZ_API`) |
127
+ | `--web` | web app, for the share link (or `RENDEZ_WEB`) |
128
+
129
+ Requires `websockets`. Only the session **owner** may attach a workspace —
130
+ normally the person who created it, which `connect` does for you.
131
+
132
+ ## What it does, exactly
133
+
134
+ Three operations, forwarded from the server and run against your tree:
135
+
136
+ - `read_file` · `write_file` · `list_dir`
137
+
138
+ Paths are resolved and refused if they leave the root. `.git`,
139
+ `node_modules`, `__pycache__`, `.next` and `.venv` are hidden from listings.
140
+ Files over 256KB are refused in both directions — a tool result re-enters the
141
+ model's history on every later request (`DEC-020`), so a large file is paid
142
+ for every turn, not once.
143
+
144
+ ## What it does not do
145
+
146
+ - **Shell asks first** (`DEC-022`). `ls`, `cat`, `grep` and similar run
147
+ unprompted; anything else prompts you in this terminal — `y` once, `a`
148
+ always for that exact command this session, `n` no. Only you can answer:
149
+ it is your machine. A redirect kills the command's whole process group, so
150
+ an interrupted `npm test` does not leave its runner behind.
151
+ - **The watcher warns, it does not prevent.** Edit a file the agent is
152
+ working on and the session notices within ~1.5s and tells the agent on its
153
+ next turn. It does not stop the write that already happened, and only files
154
+ the agent has touched are watched.
155
+ - **No git.** Branch and dirty state are shown for context. Nothing commits,
156
+ pushes or checks out.
157
+ - **No second host.** One machine per session. Close your laptop and the
158
+ session has no filesystem until you reconnect; in-flight calls fail
159
+ immediately rather than hanging.
160
+
161
+ ## Be aware
162
+
163
+ Anyone who can steer the session can make the agent read and write files in
164
+ this directory. Confinement to the root is a path check, not a sandbox: a
165
+ `.env` **inside** the repository is inside the repository. Run this on repos
166
+ you would screen-share, with people you would screen-share to.
167
+
168
+ For isolated automation, `RENDEZ_AUTH_FILE` overrides the default CLI login
169
+ cache path (`~/.config/rendez/auth.json`). The managed test runner uses a private
170
+ temporary cache so tests cannot replace a developer's saved credentials.
@@ -0,0 +1,8 @@
1
+ rendez/__init__.py,sha256=vWgkFJIcUvj_K1o3eUuGby06ZRf94PKJ5F7jdm32VFk,49774
2
+ rendez/__main__.py,sha256=fKj6oEuhI4Lj9fEtE3MFWvUH9XIDEjz1zjPyWciL2q4,155
3
+ rendez_cli-0.1.0.dist-info/licenses/LICENSE,sha256=-ghzE3eh3bqZGHr2m0LQh6m1NKs_x4CYzXkYbJvvuB0,1070
4
+ rendez_cli-0.1.0.dist-info/METADATA,sha256=NH6wrWUJbD30WUtj1-TCPZg61ZxtzS6hlDh6Kb6HxgI,8000
5
+ rendez_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ rendez_cli-0.1.0.dist-info/entry_points.txt,sha256=hT1PccrfG_pZUFjyvq6ZktstO0gJT4v6lKslkneZ8Lk,39
7
+ rendez_cli-0.1.0.dist-info/top_level.txt,sha256=JzJl43aXMlN5fi1yoPZbV2X17Bg4Z6fQpOhTvBwohB4,7
8
+ rendez_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rendez = rendez:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rendez Engine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ rendez