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 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())}