tare-cli 0.2.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.
- tare/__init__.py +1 -0
- tare/agents.py +672 -0
- tare/calibrate.py +98 -0
- tare/capsule.py +246 -0
- tare/cli.py +312 -0
- tare/cliff.py +183 -0
- tare/dashboard.html +269 -0
- tare/dashboard.py +216 -0
- tare/fake.py +183 -0
- tare/journal.py +35 -0
- tare/judge.py +108 -0
- tare/overview.html +78 -0
- tare/probe.py +273 -0
- tare/recipe.py +102 -0
- tare/room.py +67 -0
- tare/swap.py +219 -0
- tare/trail.py +203 -0
- tare/usage.py +237 -0
- tare_cli-0.2.0.dist-info/METADATA +505 -0
- tare_cli-0.2.0.dist-info/RECORD +23 -0
- tare_cli-0.2.0.dist-info/WHEEL +4 -0
- tare_cli-0.2.0.dist-info/entry_points.txt +2 -0
- tare_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
tare/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tare: a clean room for coding agents, proven before the run."""
|
tare/agents.py
ADDED
|
@@ -0,0 +1,672 @@
|
|
|
1
|
+
"""What tare has to know about each agent CLI (CONCEPT.md, "Adapters").
|
|
2
|
+
|
|
3
|
+
An adapter says where the user's real setup lives, how its login is copied into a room,
|
|
4
|
+
how the CLI is pointed at the fake endpoint, how the captured request is read, and which
|
|
5
|
+
of the user's files mark their context.
|
|
6
|
+
"""
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import contextmanager, nullcontext
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import trail as trails
|
|
18
|
+
from .fake import FAKE_MODEL
|
|
19
|
+
from .room import ROOM_HOME, ROOM_PROJECT, TareError
|
|
20
|
+
from .usage import Usage
|
|
21
|
+
|
|
22
|
+
PROMPT = "say ok"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Real:
|
|
27
|
+
"""The user's real setup for one agent, outside the room."""
|
|
28
|
+
|
|
29
|
+
home: Path
|
|
30
|
+
config: Path # the agent's config directory (~/.claude, ~/.codex)
|
|
31
|
+
binary: Path # the CLI, resolved
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Capture:
|
|
36
|
+
text: str # every string of the first model request
|
|
37
|
+
tools: set[str]
|
|
38
|
+
init: dict # the CLI's own report of what it loaded, where it gives one
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def strings(obj):
|
|
42
|
+
if isinstance(obj, str):
|
|
43
|
+
yield obj
|
|
44
|
+
elif isinstance(obj, dict):
|
|
45
|
+
for value in obj.values():
|
|
46
|
+
yield from strings(value)
|
|
47
|
+
elif isinstance(obj, list):
|
|
48
|
+
for value in obj:
|
|
49
|
+
yield from strings(value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def events(stdout: str):
|
|
53
|
+
for line in stdout.splitlines():
|
|
54
|
+
try:
|
|
55
|
+
yield json.loads(line)
|
|
56
|
+
except ValueError:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _which(name: str) -> Path:
|
|
61
|
+
found = shutil.which(name)
|
|
62
|
+
if not found:
|
|
63
|
+
raise TareError(f"{name} is not on PATH")
|
|
64
|
+
return Path(found).resolve()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _absolute_links(original: Path, copy: Path):
|
|
68
|
+
"""A relative symlink copied elsewhere points nowhere (a skill linked as ../../../.agents/skills/x).
|
|
69
|
+
Point each one in the copy at what the original link reaches."""
|
|
70
|
+
for root, dirs, files in os.walk(copy):
|
|
71
|
+
for name in dirs + files:
|
|
72
|
+
link = Path(root) / name
|
|
73
|
+
if link.is_symlink() and not os.path.isabs(target := os.readlink(link)):
|
|
74
|
+
reached = os.path.normpath((original / link.relative_to(copy)).parent / target)
|
|
75
|
+
link.unlink()
|
|
76
|
+
link.symlink_to(reached)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _json(path: Path) -> dict | None:
|
|
80
|
+
"""A JSON file of the agent's setup, read for its field names and flags, never printed."""
|
|
81
|
+
try:
|
|
82
|
+
return json.loads(path.read_text())
|
|
83
|
+
except (OSError, ValueError):
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _one_run(u: Usage) -> Usage:
|
|
88
|
+
"""A run's usage is summed from several events, but it is one run."""
|
|
89
|
+
u.runs, u.unpriced = 1, 1 if u.cost_usd is None else 0
|
|
90
|
+
return u
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _lifetime_error(path: Path) -> TareError:
|
|
94
|
+
return TareError(f"no login at {path}: log in with the agent first")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Claude:
|
|
98
|
+
name = "claude"
|
|
99
|
+
credentials = ".credentials.json"
|
|
100
|
+
room_config = f"{ROOM_HOME}/.claude-config"
|
|
101
|
+
# Login-carried connectors and account skills arrive unless these are passed (CONCEPT.md, layer 1).
|
|
102
|
+
room_flags = ["--strict-mcp-config", "--setting-sources", "project,local"]
|
|
103
|
+
yolo = ["--dangerously-skip-permissions"]
|
|
104
|
+
native_resume = True # Cliff resumes its own session; Swap prices handoffs against it
|
|
105
|
+
|
|
106
|
+
def discover(self) -> Real:
|
|
107
|
+
home = Path.home()
|
|
108
|
+
custom = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
109
|
+
return Real(home, Path(custom) if custom else home / ".claude", _which("claude"))
|
|
110
|
+
|
|
111
|
+
def state(self, real: Real) -> Path:
|
|
112
|
+
# .claude.json moves with CLAUDE_CONFIG_DIR, otherwise it sits in the home directory
|
|
113
|
+
return real.config / ".claude.json" if os.environ.get("CLAUDE_CONFIG_DIR") else real.home / ".claude.json"
|
|
114
|
+
|
|
115
|
+
def token_lifetime(self, real: Real) -> float:
|
|
116
|
+
path = real.config / self.credentials
|
|
117
|
+
try:
|
|
118
|
+
return json.loads(path.read_text())["claudeAiOauth"]["expiresAt"] / 1000 - time.time()
|
|
119
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
120
|
+
raise _lifetime_error(path) from None
|
|
121
|
+
|
|
122
|
+
def seed(self, real: Real, config: Path):
|
|
123
|
+
shutil.copyfile(real.config / self.credentials, config / self.credentials)
|
|
124
|
+
(config / self.credentials).chmod(0o600)
|
|
125
|
+
(config / ".claude.json").write_text(json.dumps(
|
|
126
|
+
{"hasCompletedOnboarding": True, "projects": {ROOM_PROJECT: {"hasTrustDialogAccepted": True}}}))
|
|
127
|
+
|
|
128
|
+
def room_env(self) -> dict[str, str]:
|
|
129
|
+
# A custom base URL turns tool search off; keep probe and run in the same form.
|
|
130
|
+
return {"CLAUDE_CONFIG_DIR": self.room_config, "ENABLE_TOOL_SEARCH": "true"}
|
|
131
|
+
|
|
132
|
+
def binds(self, real: Real) -> tuple[list[str], str]:
|
|
133
|
+
"""bwrap arguments that make the CLI available, and the command inside the room."""
|
|
134
|
+
return ["--ro-bind", str(real.binary), "/opt/agent/claude"], "/opt/agent/claude"
|
|
135
|
+
|
|
136
|
+
def probe(self, fake_url: str) -> tuple[list[str], dict[str, str]]:
|
|
137
|
+
"""Arguments for one probe turn, and the environment that points the CLI at the fake."""
|
|
138
|
+
args = ["-p", "--output-format", "stream-json", "--verbose", "--no-session-persistence", PROMPT]
|
|
139
|
+
return args, {"ANTHROPIC_BASE_URL": fake_url, "ENABLE_TOOL_SEARCH": "true"}
|
|
140
|
+
|
|
141
|
+
def twin(self, real: Real, fake_url: str):
|
|
142
|
+
return nullcontext({})
|
|
143
|
+
|
|
144
|
+
def prepare_probe(self, config: Path, fake_url: str):
|
|
145
|
+
pass
|
|
146
|
+
|
|
147
|
+
def capture(self, stdout: str, requests: list[dict]) -> Capture | None:
|
|
148
|
+
init = next((e for e in events(stdout) if e.get("type") == "system" and e.get("subtype") == "init"), None)
|
|
149
|
+
main = next((r for r in requests if r.get("tools")), None)
|
|
150
|
+
if init is None or main is None:
|
|
151
|
+
return None
|
|
152
|
+
text = "\n".join(strings({k: main.get(k) for k in ("system", "messages", "tools")}))
|
|
153
|
+
return Capture(text, {t["name"] for t in main["tools"]}, init)
|
|
154
|
+
|
|
155
|
+
def instructions(self, real: Real) -> Path:
|
|
156
|
+
return real.config / "CLAUDE.md"
|
|
157
|
+
|
|
158
|
+
# unattended runs (Cliff, Swap): args after the room flags; `hook` archives /work after every tool call
|
|
159
|
+
def run_args(self, prompt: str, extra: list[str], hook: str | None = None) -> list[str]:
|
|
160
|
+
args = ["--dangerously-skip-permissions"]
|
|
161
|
+
if hook:
|
|
162
|
+
args += ["--settings", json.dumps({"hooks": {"PostToolUse": [
|
|
163
|
+
{"matcher": "", "hooks": [{"type": "command", "command": hook}]}]}})]
|
|
164
|
+
return args + extra + ["-p", prompt, "--output-format", "stream-json", "--verbose"]
|
|
165
|
+
|
|
166
|
+
def prepare_hook(self, config: Path, hook: str):
|
|
167
|
+
pass # passed with --settings in run_args
|
|
168
|
+
|
|
169
|
+
def resume_args(self, session: str, prompt: str, extra: list[str]) -> list[str] | None:
|
|
170
|
+
# the same event stream as a fresh start, so a resumed tail shows what it did
|
|
171
|
+
return ["--dangerously-skip-permissions", *extra, "-p", "--resume", session, prompt,
|
|
172
|
+
"--output-format", "stream-json", "--verbose"]
|
|
173
|
+
|
|
174
|
+
def session_file(self, home: Path) -> Path | None:
|
|
175
|
+
sessions = sorted((home / ".claude-config" / "projects").rglob("*.jsonl"), key=lambda p: p.stat().st_size)
|
|
176
|
+
return sessions[-1] if sessions else None
|
|
177
|
+
|
|
178
|
+
def place_session(self, home: Path, session: str, lines: list[str]):
|
|
179
|
+
target = home / ".claude-config" / "projects" / "-work"
|
|
180
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
(target / f"{session}.jsonl").write_text("\n".join(lines) + "\n")
|
|
182
|
+
|
|
183
|
+
def trail(self, lines: list[str]) -> trails.Trail:
|
|
184
|
+
return trails.claude(lines)
|
|
185
|
+
|
|
186
|
+
def usage(self, stdout: str) -> Usage:
|
|
187
|
+
"""From the `result` events: input_tokens leaves out the cache, so the cache is added."""
|
|
188
|
+
u = Usage(cost_usd=None)
|
|
189
|
+
for e in events(stdout):
|
|
190
|
+
if e.get("type") != "result":
|
|
191
|
+
continue
|
|
192
|
+
n = e.get("usage") or {}
|
|
193
|
+
models = e.get("modelUsage") or {}
|
|
194
|
+
top = max(models.values(), key=lambda m: m.get("costUSD") or 0, default={})
|
|
195
|
+
u = u + Usage(n.get("input_tokens", 0) + n.get("cache_creation_input_tokens", 0)
|
|
196
|
+
+ n.get("cache_read_input_tokens", 0), n.get("cache_read_input_tokens", 0),
|
|
197
|
+
n.get("cache_creation_input_tokens", 0), n.get("output_tokens", 0),
|
|
198
|
+
sum(m.get("thinkingTokens", 0) for m in models.values()), e.get("total_cost_usd"),
|
|
199
|
+
top.get("canonicalModel"))
|
|
200
|
+
return _one_run(u)
|
|
201
|
+
|
|
202
|
+
def activity(self, event: dict) -> str | None:
|
|
203
|
+
"""What one line of the live stream (stream-json) shows, for the dashboard."""
|
|
204
|
+
if event.get("type") == "result":
|
|
205
|
+
return "finished"
|
|
206
|
+
if event.get("type") == "assistant":
|
|
207
|
+
for block in reversed((event.get("message") or {}).get("content") or []):
|
|
208
|
+
if block.get("type") == "tool_use":
|
|
209
|
+
args = block.get("input", {})
|
|
210
|
+
detail = args.get("command") or args.get("file_path") or args.get("pattern") or ""
|
|
211
|
+
return f"{block.get('name')}: {str(detail).splitlines()[0][:90] if detail else ''}"
|
|
212
|
+
if block.get("type") == "text" and block.get("text", "").strip():
|
|
213
|
+
return "says: " + block["text"].strip().splitlines()[0][:90]
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
def skill_dirs(self, real: Real) -> list[Path]:
|
|
217
|
+
return [real.config / "skills"]
|
|
218
|
+
|
|
219
|
+
def extra_markers(self, real: Real) -> list[tuple[str, Path]]:
|
|
220
|
+
return []
|
|
221
|
+
|
|
222
|
+
def email(self, real: Real) -> str | None:
|
|
223
|
+
try:
|
|
224
|
+
return json.loads(self.state(real).read_text())["oauthAccount"]["emailAddress"]
|
|
225
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
def billing(self, real: Real) -> str | None:
|
|
229
|
+
"""How the login pays: a subscription makes the reported cost notional."""
|
|
230
|
+
data = _json(real.config / self.credentials)
|
|
231
|
+
return None if data is None else "subscription" if "claudeAiOauth" in data else "api key"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class Codex:
|
|
235
|
+
name = "codex"
|
|
236
|
+
credentials = "auth.json"
|
|
237
|
+
room_config = f"{ROOM_HOME}/.codex"
|
|
238
|
+
# Account plugins can restore personal skills after login (CONCEPT.md, adapters).
|
|
239
|
+
room_flags = ["--disable", "remote_plugin"]
|
|
240
|
+
yolo = ["--dangerously-bypass-approvals-and-sandbox"]
|
|
241
|
+
native_resume = True
|
|
242
|
+
|
|
243
|
+
def discover(self) -> Real:
|
|
244
|
+
home = Path.home()
|
|
245
|
+
custom = os.environ.get("CODEX_HOME")
|
|
246
|
+
return Real(home, Path(custom) if custom else home / ".codex", _which("codex"))
|
|
247
|
+
|
|
248
|
+
def token_lifetime(self, real: Real) -> float:
|
|
249
|
+
path = real.config / self.credentials
|
|
250
|
+
try:
|
|
251
|
+
auth = json.loads(path.read_text())
|
|
252
|
+
if auth.get("OPENAI_API_KEY"):
|
|
253
|
+
return float("inf")
|
|
254
|
+
payload = auth["tokens"]["access_token"].split(".")[1]
|
|
255
|
+
claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
|
|
256
|
+
return claims["exp"] - time.time()
|
|
257
|
+
except (OSError, ValueError, KeyError, IndexError, TypeError):
|
|
258
|
+
raise _lifetime_error(path) from None
|
|
259
|
+
|
|
260
|
+
def seed(self, real: Real, config: Path):
|
|
261
|
+
shutil.copyfile(real.config / self.credentials, config / self.credentials)
|
|
262
|
+
(config / self.credentials).chmod(0o600)
|
|
263
|
+
(config / "config.toml").write_text(f'[projects."{ROOM_PROJECT}"]\ntrust_level = "trusted"\n')
|
|
264
|
+
|
|
265
|
+
def room_env(self) -> dict[str, str]:
|
|
266
|
+
return {"CODEX_HOME": self.room_config}
|
|
267
|
+
|
|
268
|
+
def binds(self, real: Real) -> tuple[list[str], str]:
|
|
269
|
+
# the npm package and node live under /usr, which every room mounts read-only
|
|
270
|
+
if not real.binary.is_relative_to("/usr"):
|
|
271
|
+
raise TareError(f"codex resolves to {real.binary}; tare needs a codex installed under /usr for now")
|
|
272
|
+
return [], "codex"
|
|
273
|
+
|
|
274
|
+
def probe(self, fake_url: str) -> tuple[list[str], dict[str, str]]:
|
|
275
|
+
# Codex keeps its ChatGPT login against a custom openai_base_url. Uncompressed bodies,
|
|
276
|
+
# so the fake can read them; that changes the transport, not the context.
|
|
277
|
+
args = ["exec", "--json", "--ephemeral", "--skip-git-repo-check",
|
|
278
|
+
"-c", f"openai_base_url={fake_url}/v1", "--disable", "enable_request_compression", PROMPT]
|
|
279
|
+
return args, {}
|
|
280
|
+
|
|
281
|
+
def twin(self, real: Real, fake_url: str):
|
|
282
|
+
return nullcontext({})
|
|
283
|
+
|
|
284
|
+
def prepare_probe(self, config: Path, fake_url: str):
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
def capture(self, stdout: str, requests: list[dict]) -> Capture | None:
|
|
288
|
+
main = next((r for r in requests if "input" in r), None)
|
|
289
|
+
if main is None:
|
|
290
|
+
return None
|
|
291
|
+
tools = {t.get("name") for t in main.get("tools", []) if t.get("name")}
|
|
292
|
+
for item in main.get("input", []):
|
|
293
|
+
tools |= {t.get("name") for t in item.get("tools", []) or [] if isinstance(t, dict) and t.get("name")}
|
|
294
|
+
text = "\n".join(strings({k: main.get(k) for k in ("instructions", "input", "tools")}))
|
|
295
|
+
return Capture(text, tools, {})
|
|
296
|
+
|
|
297
|
+
def instructions(self, real: Real) -> Path:
|
|
298
|
+
return real.config / "AGENTS.md"
|
|
299
|
+
|
|
300
|
+
def run_args(self, prompt: str, extra: list[str], hook: str | None = None) -> list[str]:
|
|
301
|
+
args = ["--dangerously-bypass-approvals-and-sandbox", "exec", "--skip-git-repo-check", "--json"]
|
|
302
|
+
if hook:
|
|
303
|
+
args.append("--dangerously-bypass-hook-trust") # exec runs no untrusted hook otherwise
|
|
304
|
+
return args + extra + [prompt]
|
|
305
|
+
|
|
306
|
+
def prepare_hook(self, config: Path, hook: str):
|
|
307
|
+
(config / "hooks.json").write_text(json.dumps({"hooks": {"PostToolUse": [
|
|
308
|
+
{"matcher": "", "hooks": [{"type": "command", "command": hook}]}]}}))
|
|
309
|
+
|
|
310
|
+
def resume_args(self, session: str, prompt: str, extra: list[str]) -> list[str] | None:
|
|
311
|
+
return ["exec", "resume", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "--json",
|
|
312
|
+
*extra, session, prompt]
|
|
313
|
+
|
|
314
|
+
def session_file(self, home: Path) -> Path | None:
|
|
315
|
+
rollouts = sorted((home / ".codex" / "sessions").rglob("rollout-*.jsonl"))
|
|
316
|
+
return rollouts[-1] if rollouts else None
|
|
317
|
+
|
|
318
|
+
def place_session(self, home: Path, session: str, lines: list[str]):
|
|
319
|
+
# Codex finds a session by the id at the end of its rollout's file name
|
|
320
|
+
target = home / ".codex" / "sessions" / "2026" / "01" / "01"
|
|
321
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
322
|
+
(target / f"rollout-2026-01-01T00-00-00-{session}.jsonl").write_text("\n".join(lines) + "\n")
|
|
323
|
+
|
|
324
|
+
def trail(self, lines: list[str]) -> trails.Trail:
|
|
325
|
+
return trails.codex(lines)
|
|
326
|
+
|
|
327
|
+
def usage(self, stdout: str) -> Usage:
|
|
328
|
+
"""From the `turn.completed` events: input_tokens includes the cached ones. No cost."""
|
|
329
|
+
u = Usage(cost_usd=None)
|
|
330
|
+
for e in events(stdout):
|
|
331
|
+
if e.get("type") == "turn.completed":
|
|
332
|
+
n = e.get("usage") or {}
|
|
333
|
+
u = u + Usage(n.get("input_tokens", 0), n.get("cached_input_tokens", 0),
|
|
334
|
+
n.get("cache_write_input_tokens", 0), n.get("output_tokens", 0),
|
|
335
|
+
n.get("reasoning_output_tokens", 0))
|
|
336
|
+
return _one_run(u)
|
|
337
|
+
|
|
338
|
+
def activity(self, event: dict) -> str | None:
|
|
339
|
+
"""What one line of the live stream (--json) shows, for the dashboard."""
|
|
340
|
+
if event.get("type") == "turn.completed":
|
|
341
|
+
return "finished"
|
|
342
|
+
item = event.get("item") or {}
|
|
343
|
+
if item.get("type") == "command_execution" and item.get("command"):
|
|
344
|
+
return "runs: " + str(item["command"]).splitlines()[0][:90]
|
|
345
|
+
if item.get("type") == "agent_message" and item.get("text", "").strip():
|
|
346
|
+
return "says: " + item["text"].strip().splitlines()[0][:90]
|
|
347
|
+
if item.get("type") == "file_change":
|
|
348
|
+
return "edits files"
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
def skill_dirs(self, real: Real) -> list[Path]:
|
|
352
|
+
return [real.config / "skills", real.home / ".agents" / "skills"]
|
|
353
|
+
|
|
354
|
+
def extra_markers(self, real: Real) -> list[tuple[str, Path]]:
|
|
355
|
+
return [("memories", real.config / "memories" / "memory_summary.md")]
|
|
356
|
+
|
|
357
|
+
def email(self, real: Real) -> str | None:
|
|
358
|
+
return None
|
|
359
|
+
|
|
360
|
+
def billing(self, real: Real) -> str | None:
|
|
361
|
+
data = _json(real.config / self.credentials)
|
|
362
|
+
return None if data is None else "api key" if data.get("OPENAI_API_KEY") else "subscription"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class Pi:
|
|
366
|
+
name = "pi"
|
|
367
|
+
credentials = "auth.json"
|
|
368
|
+
room_config = f"{ROOM_HOME}/.pi/agent"
|
|
369
|
+
# A fresh agent directory in a room is clean on its own and keeps the project's
|
|
370
|
+
# AGENTS.md; --no-context-files would drop it (experiments/dirty-twin-pi).
|
|
371
|
+
room_flags: list[str] = []
|
|
372
|
+
yolo: list[str] = [] # Pi has no permission prompts
|
|
373
|
+
native_resume = True
|
|
374
|
+
bare_flags = ["--no-extensions"] # a room run that shows Pi's own tools only
|
|
375
|
+
secret_files = ["auth.json", "models.json"] # models.json holds the providers' API keys
|
|
376
|
+
snapshot = "tare-snapshot.ts"
|
|
377
|
+
|
|
378
|
+
def discover(self) -> Real:
|
|
379
|
+
home = Path.home()
|
|
380
|
+
custom = os.environ.get("PI_CODING_AGENT_DIR")
|
|
381
|
+
return Real(home, Path(custom) if custom else home / ".pi" / "agent", _which("pi"))
|
|
382
|
+
|
|
383
|
+
def _settings(self, real: Real) -> dict:
|
|
384
|
+
try:
|
|
385
|
+
return json.loads((real.config / "settings.json").read_text())
|
|
386
|
+
except (OSError, ValueError):
|
|
387
|
+
return {}
|
|
388
|
+
|
|
389
|
+
def token_lifetime(self, real: Real) -> float:
|
|
390
|
+
"""Lifetime of the default provider's login; API keys do not expire."""
|
|
391
|
+
path = real.config / self.credentials
|
|
392
|
+
try:
|
|
393
|
+
auth = json.loads(path.read_text())
|
|
394
|
+
except (OSError, ValueError):
|
|
395
|
+
raise _lifetime_error(path) from None
|
|
396
|
+
entry = auth.get(self._settings(real).get("defaultProvider", ""), {})
|
|
397
|
+
if entry.get("type") != "oauth":
|
|
398
|
+
return float("inf")
|
|
399
|
+
return entry["expires"] / 1000 - time.time()
|
|
400
|
+
|
|
401
|
+
def seed(self, real: Real, config: Path):
|
|
402
|
+
# the login, the providers (with their keys) and the default model; nothing else of the setup
|
|
403
|
+
for name in (self.credentials, "models.json"):
|
|
404
|
+
if (real.config / name).exists():
|
|
405
|
+
shutil.copyfile(real.config / name, config / name)
|
|
406
|
+
(config / name).chmod(0o600)
|
|
407
|
+
settings = self._settings(real)
|
|
408
|
+
(config / "settings.json").write_text(json.dumps(
|
|
409
|
+
{k: settings[k] for k in ("defaultProvider", "defaultModel") if k in settings}))
|
|
410
|
+
|
|
411
|
+
def room_env(self) -> dict[str, str]:
|
|
412
|
+
return {"PI_CODING_AGENT_DIR": self.room_config}
|
|
413
|
+
|
|
414
|
+
def binds(self, real: Real) -> tuple[list[str], str]:
|
|
415
|
+
# the npm package lives in the home directory, which the room does not have: mount it
|
|
416
|
+
package = real.binary.parents[2] # <package>/dist/bundle/cli.js
|
|
417
|
+
return ["--ro-bind", str(package), "/opt/agent/pi"], "/opt/agent/pi/dist/bundle/cli.js"
|
|
418
|
+
|
|
419
|
+
# the probe: Pi has no base-URL variable, so the fake is a provider of its own
|
|
420
|
+
def _provider(self, models: Path, fake_url: str):
|
|
421
|
+
data = json.loads(models.read_text()) if models.exists() else {}
|
|
422
|
+
data.setdefault("providers", {})["tare"] = {"api": "anthropic-messages", "apiKey": "tare", "baseUrl": fake_url,
|
|
423
|
+
"models": [{"id": "fake", "name": "fake"}]}
|
|
424
|
+
models.write_text(json.dumps(data))
|
|
425
|
+
|
|
426
|
+
def probe(self, fake_url: str) -> tuple[list[str], dict[str, str]]:
|
|
427
|
+
return ["-p", "--mode", "json", "--no-session", "--provider", "tare", "--model", "fake", PROMPT], {}
|
|
428
|
+
|
|
429
|
+
@contextmanager
|
|
430
|
+
def twin(self, real: Real, fake_url: str):
|
|
431
|
+
"""The dirty twin runs on a copy of the user's agent directory; the real one is never written."""
|
|
432
|
+
with tempfile.TemporaryDirectory(prefix="tare-pi-twin-") as copy:
|
|
433
|
+
agent_dir = Path(copy) / "agent"
|
|
434
|
+
shutil.copytree(real.config, agent_dir, symlinks=True, ignore=shutil.ignore_patterns("sessions"))
|
|
435
|
+
_absolute_links(real.config, agent_dir)
|
|
436
|
+
self._provider(agent_dir / "models.json", fake_url)
|
|
437
|
+
yield {"PI_CODING_AGENT_DIR": str(agent_dir)}
|
|
438
|
+
|
|
439
|
+
def prepare_probe(self, config: Path, fake_url: str):
|
|
440
|
+
self._provider(config / "models.json", fake_url)
|
|
441
|
+
|
|
442
|
+
def capture(self, stdout: str, requests: list[dict]) -> Capture | None:
|
|
443
|
+
main = next((r for r in requests if r.get("tools")), None)
|
|
444
|
+
if main is None:
|
|
445
|
+
return None
|
|
446
|
+
text = "\n".join(strings({k: main.get(k) for k in ("system", "messages", "tools")}))
|
|
447
|
+
return Capture(text, {t["name"] for t in main["tools"]}, {})
|
|
448
|
+
|
|
449
|
+
def instructions(self, real: Real) -> Path:
|
|
450
|
+
for name in ("AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"):
|
|
451
|
+
if (real.config / name).exists():
|
|
452
|
+
return real.config / name
|
|
453
|
+
return real.config / "AGENTS.md"
|
|
454
|
+
|
|
455
|
+
def skill_dirs(self, real: Real) -> list[Path]:
|
|
456
|
+
return [real.config / "skills"]
|
|
457
|
+
|
|
458
|
+
def extra_markers(self, real: Real) -> list[tuple[str, Path]]:
|
|
459
|
+
return []
|
|
460
|
+
|
|
461
|
+
def email(self, real: Real) -> str | None:
|
|
462
|
+
return None
|
|
463
|
+
|
|
464
|
+
def billing(self, real: Real) -> str | None:
|
|
465
|
+
auth = _json(real.config / self.credentials) or {}
|
|
466
|
+
entry = auth.get(self._settings(real).get("defaultProvider", ""))
|
|
467
|
+
return None if not entry else "subscription" if entry.get("type") == "oauth" else "api key"
|
|
468
|
+
|
|
469
|
+
# unattended runs (Cliff, Swap)
|
|
470
|
+
def run_args(self, prompt: str, extra: list[str], hook: str | None = None) -> list[str]:
|
|
471
|
+
loaded = ["-e", f"{self.room_config}/{self.snapshot}"] if hook else []
|
|
472
|
+
return loaded + extra + ["-p", "--mode", "json", prompt]
|
|
473
|
+
|
|
474
|
+
def prepare_hook(self, config: Path, hook: str):
|
|
475
|
+
# an extension that hands every top-level tool call to the snapshot hook
|
|
476
|
+
(config / self.snapshot).write_text(f'''import {{ execFileSync }} from "node:child_process";
|
|
477
|
+
export default function (pi: any) {{
|
|
478
|
+
pi.on("tool_result", async (event: any) => {{
|
|
479
|
+
if (event.parentToolCallId) return; // nested calls belong to the call that issued them
|
|
480
|
+
execFileSync("/bin/sh", ["-c", {json.dumps(hook)}], {{ input: JSON.stringify({{ tool_use_id: event.toolCallId }}) }});
|
|
481
|
+
}});
|
|
482
|
+
}}
|
|
483
|
+
''')
|
|
484
|
+
|
|
485
|
+
def resume_args(self, session: str, prompt: str, extra: list[str]) -> list[str] | None:
|
|
486
|
+
return ["--session", session, *extra, "-p", "--mode", "json", prompt]
|
|
487
|
+
|
|
488
|
+
def session_file(self, home: Path) -> Path | None:
|
|
489
|
+
sessions = sorted((home / ".pi" / "agent" / "sessions").rglob("*.jsonl"), key=lambda p: p.stat().st_mtime)
|
|
490
|
+
return sessions[-1] if sessions else None
|
|
491
|
+
|
|
492
|
+
def place_session(self, home: Path, session: str, lines: list[str]):
|
|
493
|
+
target = home / ".pi" / "agent" / "sessions" / "--work--"
|
|
494
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
495
|
+
(target / f"2026-01-01T00-00-00-000Z_{session}.jsonl").write_text("\n".join(lines) + "\n")
|
|
496
|
+
|
|
497
|
+
def trail(self, lines: list[str]) -> trails.Trail:
|
|
498
|
+
return trails.pi(lines)
|
|
499
|
+
|
|
500
|
+
def usage(self, stdout: str) -> Usage:
|
|
501
|
+
"""From the assistant messages: input leaves out the cache, and each message names its cost."""
|
|
502
|
+
u = Usage(cost_usd=None)
|
|
503
|
+
for e in events(stdout):
|
|
504
|
+
message = e.get("message") or {}
|
|
505
|
+
if e.get("type") != "message_end" or message.get("role") != "assistant":
|
|
506
|
+
continue
|
|
507
|
+
n = message.get("usage") or {}
|
|
508
|
+
cost = (n.get("cost") or {}).get("total")
|
|
509
|
+
u = u + Usage(n.get("input", 0) + n.get("cacheRead", 0) + n.get("cacheWrite", 0), n.get("cacheRead", 0),
|
|
510
|
+
n.get("cacheWrite", 0), n.get("output", 0), n.get("reasoning", 0), cost, message.get("model"))
|
|
511
|
+
return _one_run(u)
|
|
512
|
+
|
|
513
|
+
def activity(self, event: dict) -> str | None:
|
|
514
|
+
"""What one line of the live stream (--mode json) shows, for the dashboard."""
|
|
515
|
+
kind = event.get("type")
|
|
516
|
+
if kind == "agent_end":
|
|
517
|
+
return "finished"
|
|
518
|
+
if kind == "tool_execution_start":
|
|
519
|
+
args = event.get("args") or {}
|
|
520
|
+
detail = args.get("command") or args.get("path") or ""
|
|
521
|
+
return f"{event.get('toolName')}: {str(detail).splitlines()[0][:90] if detail else ''}"
|
|
522
|
+
if kind == "message_end" and (event.get("message") or {}).get("role") == "assistant":
|
|
523
|
+
for block in (event["message"].get("content") or []):
|
|
524
|
+
if block.get("type") == "text" and block.get("text", "").strip():
|
|
525
|
+
return "says: " + block["text"].strip().splitlines()[0][:90]
|
|
526
|
+
return None
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class Antigravity:
|
|
530
|
+
"""The Antigravity CLI (`agy`). Its context classes come from experiments/dirty-twin-agy:
|
|
531
|
+
global rules in ~/.gemini/GEMINI.md, global skills in ~/.gemini/config/skills/; files above
|
|
532
|
+
the project, ~/.agents/ and ~/.gemini/skills/ were not read."""
|
|
533
|
+
|
|
534
|
+
name = "agy"
|
|
535
|
+
credentials = "antigravity-oauth-token"
|
|
536
|
+
room_config = f"{ROOM_HOME}/.gemini/antigravity-cli"
|
|
537
|
+
room_flags: list[str] = []
|
|
538
|
+
yolo = ["--dangerously-skip-permissions"]
|
|
539
|
+
# agy keeps a conversation as protobuf; a cut after a step cannot be placed, so Cliff and
|
|
540
|
+
# Swap continue it by handoff
|
|
541
|
+
native_resume = False
|
|
542
|
+
|
|
543
|
+
def discover(self) -> Real:
|
|
544
|
+
home = Path.home()
|
|
545
|
+
return Real(home, home / ".gemini" / "antigravity-cli", _which("agy"))
|
|
546
|
+
|
|
547
|
+
def token_lifetime(self, real: Real) -> float:
|
|
548
|
+
"""A Google OAuth refresh does not rotate the refresh token (measured: digests before and
|
|
549
|
+
after a refresh in a room), so a room may refresh its own copy without harm."""
|
|
550
|
+
if not (real.config / self.credentials).exists():
|
|
551
|
+
raise _lifetime_error(real.config / self.credentials)
|
|
552
|
+
return float("inf")
|
|
553
|
+
|
|
554
|
+
def seed(self, real: Real, config: Path):
|
|
555
|
+
# the login and the chosen model; nothing else of the setup
|
|
556
|
+
shutil.copyfile(real.config / self.credentials, config / self.credentials)
|
|
557
|
+
(config / self.credentials).chmod(0o600)
|
|
558
|
+
try:
|
|
559
|
+
model = json.loads((real.config / "settings.json").read_text()).get("model")
|
|
560
|
+
except (OSError, ValueError):
|
|
561
|
+
model = None
|
|
562
|
+
(config / "settings.json").write_text(json.dumps({"model": model} if model else {}))
|
|
563
|
+
|
|
564
|
+
def room_env(self) -> dict[str, str]:
|
|
565
|
+
return {} # agy finds its setup under HOME
|
|
566
|
+
|
|
567
|
+
def binds(self, real: Real) -> tuple[list[str], str]:
|
|
568
|
+
return ["--ro-bind", str(real.binary), "/opt/agent/agy"], "/opt/agent/agy"
|
|
569
|
+
|
|
570
|
+
# the probe: CLOUD_CODE_URL points agy at the fake, which offers one model
|
|
571
|
+
def probe(self, fake_url: str) -> tuple[list[str], dict[str, str]]:
|
|
572
|
+
return (["-p", PROMPT, "--output-format", "stream-json", "--model", FAKE_MODEL],
|
|
573
|
+
{"CLOUD_CODE_URL": fake_url})
|
|
574
|
+
|
|
575
|
+
def prepare_probe(self, config: Path, fake_url: str):
|
|
576
|
+
pass
|
|
577
|
+
|
|
578
|
+
@contextmanager
|
|
579
|
+
def twin(self, real: Real, fake_url: str):
|
|
580
|
+
"""The dirty twin sees the real setup, but agy writes into ~/.gemini on every run (its
|
|
581
|
+
conversations, caches, onboarding state). So it runs with an overlay over ~/.gemini
|
|
582
|
+
whose writes go to a tmpfs that dies with the run; see twin_argv."""
|
|
583
|
+
mount = tempfile.mkdtemp(prefix="tare-agy-twin-")
|
|
584
|
+
try:
|
|
585
|
+
yield {"TARE_TWIN_MOUNT": mount}
|
|
586
|
+
finally:
|
|
587
|
+
shutil.rmtree(mount, ignore_errors=True)
|
|
588
|
+
|
|
589
|
+
def twin_argv(self, argv: list[str], env: dict[str, str], plant: str = "") -> list[str]:
|
|
590
|
+
"""Wrap the twin's command: as mapped root in a user and mount namespace, put a tmpfs on
|
|
591
|
+
the mount point and an overlay over ~/.gemini, then run agy as the user's own uid.
|
|
592
|
+
`plant` is shell run inside the overlay first (experiments only)."""
|
|
593
|
+
mount, gemini = env.pop("TARE_TWIN_MOUNT"), Path.home() / ".gemini"
|
|
594
|
+
script = (f'mount -t tmpfs tmpfs "{mount}" && mkdir "{mount}/u" "{mount}/w" && '
|
|
595
|
+
f'mount -t overlay overlay -o lowerdir="{gemini}",upperdir="{mount}/u",workdir="{mount}/w" "{gemini}" && '
|
|
596
|
+
f'{plant + " && " if plant else ""}'
|
|
597
|
+
f'exec unshare --user --map-user={os.getuid()} --map-group={os.getgid()} -- "$@"')
|
|
598
|
+
return ["unshare", "--user", "--map-root-user", "--mount", "/bin/sh", "-c", script, "sh", *argv]
|
|
599
|
+
|
|
600
|
+
def capture(self, stdout: str, requests: list[dict]) -> Capture | None:
|
|
601
|
+
main = next((r for r in requests if (r.get("request") or {}).get("tools")), None)
|
|
602
|
+
if main is None:
|
|
603
|
+
return None
|
|
604
|
+
request = main["request"]
|
|
605
|
+
text = "\n".join(strings({k: request.get(k) for k in ("systemInstruction", "contents", "tools")}))
|
|
606
|
+
tools = {d["name"] for t in request["tools"] for d in t.get("functionDeclarations", [])}
|
|
607
|
+
init = next((e.get("init", {}) for e in events(stdout) if e.get("event") == "init"), {})
|
|
608
|
+
return Capture(text, tools, init)
|
|
609
|
+
|
|
610
|
+
def instructions(self, real: Real) -> Path:
|
|
611
|
+
return real.home / ".gemini" / "GEMINI.md" # arrives as <RULE[user_global]>
|
|
612
|
+
|
|
613
|
+
def skill_dirs(self, real: Real) -> list[Path]:
|
|
614
|
+
return [real.home / ".gemini" / "config" / "skills"]
|
|
615
|
+
|
|
616
|
+
def extra_markers(self, real: Real) -> list[tuple[str, Path]]:
|
|
617
|
+
return []
|
|
618
|
+
|
|
619
|
+
def email(self, real: Real) -> str | None:
|
|
620
|
+
return None # the account's email and name did not reach the prompt
|
|
621
|
+
|
|
622
|
+
def billing(self, real: Real) -> str | None:
|
|
623
|
+
data = _json(real.config / self.credentials)
|
|
624
|
+
return None if data is None else "subscription" if data.get("auth_method") == "consumer" else "api key"
|
|
625
|
+
|
|
626
|
+
# unattended runs (Cliff, Swap)
|
|
627
|
+
def run_args(self, prompt: str, extra: list[str], hook: str | None = None) -> list[str]:
|
|
628
|
+
return ["--dangerously-skip-permissions", *extra, "-p", prompt, "--output-format", "stream-json"]
|
|
629
|
+
|
|
630
|
+
def prepare_hook(self, config: Path, hook: str):
|
|
631
|
+
"""A global PostToolUse hook in the room's ~/.gemini/config. agy hands it the result's
|
|
632
|
+
stepIdx, which becomes the snapshot's id; agy expects {} back."""
|
|
633
|
+
command = f"""sed 's/"stepIdx": *\\([0-9]*\\)/"tool_use_id": "step-\\1"/' | {hook}; echo '{{}}'"""
|
|
634
|
+
(config.parent / "config").mkdir(parents=True, exist_ok=True)
|
|
635
|
+
(config.parent / "config" / "hooks.json").write_text(json.dumps({"tare-snapshot": {"PostToolUse": [
|
|
636
|
+
{"matcher": "", "hooks": [{"type": "command", "command": command, "timeout": 120}]}]}}))
|
|
637
|
+
|
|
638
|
+
def resume_args(self, session: str, prompt: str, extra: list[str]) -> list[str] | None:
|
|
639
|
+
return None
|
|
640
|
+
|
|
641
|
+
def session_file(self, home: Path) -> Path | None:
|
|
642
|
+
logs = sorted((home / ".gemini" / "antigravity-cli" / "brain").glob("*/.system_generated/logs/transcript_full.jsonl"),
|
|
643
|
+
key=lambda p: p.stat().st_mtime)
|
|
644
|
+
return logs[-1] if logs else None
|
|
645
|
+
|
|
646
|
+
def trail(self, lines: list[str]) -> trails.Trail:
|
|
647
|
+
return trails.agy(lines)
|
|
648
|
+
|
|
649
|
+
def usage(self, stdout: str) -> Usage:
|
|
650
|
+
"""From the `result` event, as the CLI reports it. No cost, no model."""
|
|
651
|
+
u = Usage(cost_usd=None)
|
|
652
|
+
for e in events(stdout):
|
|
653
|
+
if e.get("event") == "result":
|
|
654
|
+
n = (e.get("result") or {}).get("usage") or {}
|
|
655
|
+
u = u + Usage(n.get("input_tokens", 0), n.get("cache_read_tokens", 0), 0, n.get("output_tokens", 0),
|
|
656
|
+
n.get("thinking_tokens", 0))
|
|
657
|
+
return _one_run(u)
|
|
658
|
+
|
|
659
|
+
def activity(self, event: dict) -> str | None:
|
|
660
|
+
"""What one line of the live stream (--output-format stream-json) shows, for the dashboard."""
|
|
661
|
+
if event.get("event") == "result":
|
|
662
|
+
return "finished"
|
|
663
|
+
update = event.get("step_update") or {}
|
|
664
|
+
if update.get("step_type") == "tool" and update.get("state") == "ACTIVE":
|
|
665
|
+
detail = next(iter(((update.get("tool_info") or {}).get("parameters") or {}).values()), "")
|
|
666
|
+
return f"{update.get('tool_name')}: {str(detail).splitlines()[0][:90] if detail else ''}"
|
|
667
|
+
if update.get("step_type") == "agent_response" and (update.get("text_delta") or "").strip():
|
|
668
|
+
return "says: " + update["text_delta"].strip().splitlines()[0][:90]
|
|
669
|
+
return None
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
AGENTS = {agent.name: agent for agent in (Claude(), Codex(), Pi(), Antigravity())}
|