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 +1134 -0
- rendez/__main__.py +7 -0
- rendez_cli-0.1.0.dist-info/METADATA +170 -0
- rendez_cli-0.1.0.dist-info/RECORD +8 -0
- rendez_cli-0.1.0.dist-info/WHEEL +5 -0
- rendez_cli-0.1.0.dist-info/entry_points.txt +2 -0
- rendez_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- rendez_cli-0.1.0.dist-info/top_level.txt +1 -0
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,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,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
|