atris 3.2.0 → 3.11.0
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.
- package/GETTING_STARTED.md +65 -131
- package/README.md +18 -2
- package/atris/GETTING_STARTED.md +65 -131
- package/atris/PERSONA.md +5 -1
- package/atris/atris.md +122 -153
- package/atris/skills/aeo/SKILL.md +117 -0
- package/atris/skills/atris/SKILL.md +49 -25
- package/atris/skills/create-member/SKILL.md +29 -9
- package/atris/skills/endgame/SKILL.md +9 -0
- package/atris/skills/research-search/SKILL.md +167 -0
- package/atris/skills/research-search/arxiv_search.py +157 -0
- package/atris/skills/research-search/program.md +48 -0
- package/atris/skills/research-search/results.tsv +6 -0
- package/atris/skills/research-search/scholar_search.py +154 -0
- package/atris/skills/tidy/SKILL.md +36 -21
- package/atris/team/_template/MEMBER.md +2 -0
- package/atris/team/validator/MEMBER.md +35 -1
- package/atris.md +118 -178
- package/bin/atris.js +46 -12
- package/cli/__pycache__/atris_code.cpython-314.pyc +0 -0
- package/cli/__pycache__/runtime_guard.cpython-312.pyc +0 -0
- package/cli/__pycache__/runtime_guard.cpython-314.pyc +0 -0
- package/cli/atris_code.py +889 -0
- package/cli/runtime_guard.py +693 -0
- package/commands/align.js +16 -0
- package/commands/app.js +316 -0
- package/commands/autopilot.js +863 -23
- package/commands/brainstorm.js +7 -5
- package/commands/business.js +677 -2
- package/commands/clean.js +19 -3
- package/commands/computer.js +2022 -43
- package/commands/context-sync.js +5 -0
- package/commands/integrations.js +14 -9
- package/commands/lifecycle.js +12 -0
- package/commands/plugin.js +24 -0
- package/commands/pull.js +86 -11
- package/commands/push.js +153 -9
- package/commands/serve.js +1 -0
- package/commands/sync.js +272 -76
- package/commands/verify.js +50 -1
- package/commands/wiki.js +27 -2
- package/commands/workflow.js +24 -9
- package/lib/file-ops.js +13 -1
- package/lib/journal.js +23 -0
- package/lib/manifest.js +3 -0
- package/lib/scorecard.js +42 -4
- package/lib/sync-telemetry.js +59 -0
- package/lib/todo.js +6 -0
- package/lib/wiki.js +150 -6
- package/lib/workspace-safety.js +87 -0
- package/package.json +2 -1
- package/utils/api.js +19 -0
- package/utils/auth.js +25 -1
- package/utils/config.js +24 -0
- package/utils/update-check.js +16 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Persistent Atris Claude SDK terminal with local shell access.
|
|
4
|
+
|
|
5
|
+
Goals:
|
|
6
|
+
- Work from the installed atris-cli package without the backend repo.
|
|
7
|
+
- Keep SDK imports lazy so help and /run work even before Python deps exist.
|
|
8
|
+
- Make the workspace root explicit and local shell access first-class.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
|
+
|
|
25
|
+
from runtime_guard import RuntimeGuard, ActionType
|
|
26
|
+
|
|
27
|
+
logging.getLogger("runtime_guard").setLevel(logging.ERROR)
|
|
28
|
+
|
|
29
|
+
DIM = "\033[90m"
|
|
30
|
+
BOLD = "\033[1m"
|
|
31
|
+
RESET = "\033[0m"
|
|
32
|
+
CYAN = "\033[96m"
|
|
33
|
+
GREEN = "\033[92m"
|
|
34
|
+
YELLOW = "\033[93m"
|
|
35
|
+
RED = "\033[91m"
|
|
36
|
+
|
|
37
|
+
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
|
|
38
|
+
TEAM_DIR = PACKAGE_ROOT / "atris" / "team"
|
|
39
|
+
STATE_DIR = Path.home() / ".atris"
|
|
40
|
+
SESSION_STATE_FILE = STATE_DIR / "computer_sessions.json"
|
|
41
|
+
AUDIT_LOG_FILE = STATE_DIR / "computer_audit.jsonl"
|
|
42
|
+
RESUME_MAX_AGE_SECONDS = 6 * 60 * 60
|
|
43
|
+
KNOWN_MODELS = [
|
|
44
|
+
"claude-sonnet-4-6",
|
|
45
|
+
"claude-sonnet-4-5",
|
|
46
|
+
"claude-opus-4-1",
|
|
47
|
+
"claude-3-7-sonnet-latest",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
_SDK: Dict[str, Any] = {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def one_line(text: str, limit: int) -> str:
|
|
54
|
+
return " ".join(str(text).split())[:limit]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def maybe_load_dotenv(cwd: Path) -> None:
|
|
58
|
+
try:
|
|
59
|
+
from dotenv import load_dotenv # type: ignore
|
|
60
|
+
except Exception:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
candidates = [
|
|
64
|
+
cwd / ".env",
|
|
65
|
+
cwd / "backend" / ".env",
|
|
66
|
+
PACKAGE_ROOT / ".env",
|
|
67
|
+
]
|
|
68
|
+
for candidate in candidates:
|
|
69
|
+
if candidate.exists():
|
|
70
|
+
load_dotenv(candidate)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def ensure_sdk_runtime() -> Dict[str, Any]:
|
|
74
|
+
if _SDK:
|
|
75
|
+
return _SDK
|
|
76
|
+
|
|
77
|
+
def _load() -> Dict[str, Any]:
|
|
78
|
+
from claude_agent_sdk import ( # type: ignore
|
|
79
|
+
AssistantMessage,
|
|
80
|
+
ClaudeAgentOptions,
|
|
81
|
+
ClaudeSDKClient,
|
|
82
|
+
ResultMessage,
|
|
83
|
+
SystemMessage,
|
|
84
|
+
TextBlock,
|
|
85
|
+
ToolUseBlock,
|
|
86
|
+
)
|
|
87
|
+
from claude_agent_sdk.types import StreamEvent # type: ignore
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
"AssistantMessage": AssistantMessage,
|
|
91
|
+
"ClaudeAgentOptions": ClaudeAgentOptions,
|
|
92
|
+
"ClaudeSDKClient": ClaudeSDKClient,
|
|
93
|
+
"ResultMessage": ResultMessage,
|
|
94
|
+
"SystemMessage": SystemMessage,
|
|
95
|
+
"TextBlock": TextBlock,
|
|
96
|
+
"ToolUseBlock": ToolUseBlock,
|
|
97
|
+
"StreamEvent": StreamEvent,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
_SDK.update(_load())
|
|
102
|
+
return _SDK
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
auto_install = os.getenv("ATRIS_COMPUTER_AUTO_INSTALL", "1").lower() not in {"0", "false", "no"}
|
|
107
|
+
if not auto_install:
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
"Missing Python runtime for local computer. Install: "
|
|
110
|
+
f"{sys.executable} -m pip install --user claude-agent-sdk python-dotenv"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
print(" Bootstrapping local Atris SDK runtime...", flush=True)
|
|
114
|
+
cmd = [sys.executable, "-m", "pip", "install", "--user", "claude-agent-sdk", "python-dotenv"]
|
|
115
|
+
proc = subprocess.run(cmd, capture_output=True, text=True)
|
|
116
|
+
if proc.returncode != 0:
|
|
117
|
+
msg = (proc.stderr or proc.stdout or "").strip()
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
"Failed to install local Atris SDK runtime. "
|
|
120
|
+
f"Install manually with: {' '.join(cmd)}\n{msg}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
_SDK.update(_load())
|
|
124
|
+
return _SDK
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_persona(name: str) -> str:
|
|
128
|
+
for fname in ("AGENT.md", "MEMBER.md"):
|
|
129
|
+
path = TEAM_DIR / name / fname
|
|
130
|
+
if path.exists():
|
|
131
|
+
return path.read_text()
|
|
132
|
+
flat = TEAM_DIR / f"{name}.md"
|
|
133
|
+
return flat.read_text() if flat.exists() else ""
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def list_agents() -> List[str]:
|
|
137
|
+
if not TEAM_DIR.exists():
|
|
138
|
+
return []
|
|
139
|
+
out: List[str] = []
|
|
140
|
+
for item in sorted(TEAM_DIR.iterdir()):
|
|
141
|
+
if item.name.startswith(".") or item.name == "TEAM.md":
|
|
142
|
+
continue
|
|
143
|
+
if item.is_dir() and any((item / f).exists() for f in ("AGENT.md", "MEMBER.md")):
|
|
144
|
+
out.append(item.name)
|
|
145
|
+
elif item.suffix == ".md":
|
|
146
|
+
out.append(item.stem)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def usage_breakdown(usage: Dict[str, Any]) -> Dict[str, int]:
|
|
151
|
+
def coerce(value: Any) -> int:
|
|
152
|
+
try:
|
|
153
|
+
if value is None:
|
|
154
|
+
return 0
|
|
155
|
+
if isinstance(value, bool):
|
|
156
|
+
return int(value)
|
|
157
|
+
if isinstance(value, (int, float)):
|
|
158
|
+
return int(value)
|
|
159
|
+
return int(float(str(value).strip()))
|
|
160
|
+
except Exception:
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
prompt_input_tokens = coerce(usage.get("input_tokens") or usage.get("inputTokens"))
|
|
164
|
+
cache_creation_tokens = coerce(
|
|
165
|
+
usage.get("cache_creation_input_tokens") or usage.get("cacheCreationInputTokens")
|
|
166
|
+
)
|
|
167
|
+
cache_read_tokens = coerce(
|
|
168
|
+
usage.get("cache_read_input_tokens") or usage.get("cacheReadInputTokens")
|
|
169
|
+
)
|
|
170
|
+
output_tokens = coerce(usage.get("output_tokens") or usage.get("outputTokens"))
|
|
171
|
+
return {
|
|
172
|
+
"prompt_input_tokens": prompt_input_tokens,
|
|
173
|
+
"cache_creation_tokens": cache_creation_tokens,
|
|
174
|
+
"cache_read_tokens": cache_read_tokens,
|
|
175
|
+
"total_input_tokens": prompt_input_tokens + cache_creation_tokens + cache_read_tokens,
|
|
176
|
+
"output_tokens": output_tokens,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _load_session_state() -> Dict[str, Any]:
|
|
181
|
+
if not SESSION_STATE_FILE.exists():
|
|
182
|
+
return {}
|
|
183
|
+
try:
|
|
184
|
+
raw = json.loads(SESSION_STATE_FILE.read_text())
|
|
185
|
+
return raw if isinstance(raw, dict) else {}
|
|
186
|
+
except Exception:
|
|
187
|
+
return {}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _save_session_state(state: Dict[str, Any]) -> None:
|
|
191
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
SESSION_STATE_FILE.write_text(json.dumps(state, indent=2, sort_keys=True))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def append_audit_entry(entry: Dict[str, Any]) -> None:
|
|
196
|
+
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
with AUDIT_LOG_FILE.open("a", encoding="utf-8") as fh:
|
|
198
|
+
fh.write(json.dumps(entry, sort_keys=True) + "\n")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def read_recent_audit_entries(limit: int = 10, cwd: Optional[Path] = None) -> List[Dict[str, Any]]:
|
|
202
|
+
if limit <= 0 or not AUDIT_LOG_FILE.exists():
|
|
203
|
+
return []
|
|
204
|
+
try:
|
|
205
|
+
lines = AUDIT_LOG_FILE.read_text(encoding="utf-8").splitlines()
|
|
206
|
+
except Exception:
|
|
207
|
+
return []
|
|
208
|
+
entries: List[Dict[str, Any]] = []
|
|
209
|
+
cwd_str = str(cwd.resolve()) if cwd else None
|
|
210
|
+
for line in reversed(lines):
|
|
211
|
+
try:
|
|
212
|
+
entry = json.loads(line)
|
|
213
|
+
except Exception:
|
|
214
|
+
continue
|
|
215
|
+
if cwd_str and entry.get("cwd") != cwd_str:
|
|
216
|
+
continue
|
|
217
|
+
entries.append(entry)
|
|
218
|
+
if len(entries) >= limit:
|
|
219
|
+
break
|
|
220
|
+
return list(reversed(entries))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def session_key(agent_name: str, model: str, cwd: Path) -> str:
|
|
224
|
+
return f"{cwd.resolve()}::{agent_name or 'default'}::{model}"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_saved_session(agent_name: str, model: str, cwd: Path, max_total_input_tokens: int) -> Optional[str]:
|
|
228
|
+
state = _load_session_state()
|
|
229
|
+
entry = state.get(session_key(agent_name, model, cwd))
|
|
230
|
+
if not isinstance(entry, dict):
|
|
231
|
+
return None
|
|
232
|
+
sid = entry.get("session_id")
|
|
233
|
+
if not isinstance(sid, str) or not sid:
|
|
234
|
+
return None
|
|
235
|
+
updated_at = int(entry.get("updated_at", 0) or 0)
|
|
236
|
+
if updated_at and (int(time.time()) - updated_at) > RESUME_MAX_AGE_SECONDS:
|
|
237
|
+
return None
|
|
238
|
+
last_total_input_tokens = int(entry.get("last_total_input_tokens", 0) or 0)
|
|
239
|
+
if max_total_input_tokens > 0 and last_total_input_tokens > max(20000, int(max_total_input_tokens * 0.5)):
|
|
240
|
+
return None
|
|
241
|
+
return sid
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def set_saved_session(
|
|
245
|
+
agent_name: str,
|
|
246
|
+
model: str,
|
|
247
|
+
cwd: Path,
|
|
248
|
+
session_id: Optional[str],
|
|
249
|
+
last_total_input_tokens: int = 0,
|
|
250
|
+
) -> None:
|
|
251
|
+
state = _load_session_state()
|
|
252
|
+
key = session_key(agent_name, model, cwd)
|
|
253
|
+
if session_id:
|
|
254
|
+
state[key] = {
|
|
255
|
+
"session_id": session_id,
|
|
256
|
+
"last_total_input_tokens": int(last_total_input_tokens),
|
|
257
|
+
"updated_at": int(time.time()),
|
|
258
|
+
}
|
|
259
|
+
else:
|
|
260
|
+
state.pop(key, None)
|
|
261
|
+
_save_session_state(state)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class Spinner:
|
|
265
|
+
def __init__(self) -> None:
|
|
266
|
+
self.running = False
|
|
267
|
+
self.label = "Thinking"
|
|
268
|
+
self.start_time = 0.0
|
|
269
|
+
self.task: Optional[asyncio.Task[Any]] = None
|
|
270
|
+
self.is_tty = sys.stdout.isatty()
|
|
271
|
+
|
|
272
|
+
async def start(self, label: str = "Thinking") -> None:
|
|
273
|
+
self.running = True
|
|
274
|
+
self.label = label
|
|
275
|
+
self.start_time = time.monotonic()
|
|
276
|
+
self.task = asyncio.create_task(self._run())
|
|
277
|
+
|
|
278
|
+
async def _run(self) -> None:
|
|
279
|
+
frames = (".", "..", "...", "....")
|
|
280
|
+
idx = 0
|
|
281
|
+
next_emit = 0.0
|
|
282
|
+
while self.running:
|
|
283
|
+
elapsed = time.monotonic() - self.start_time
|
|
284
|
+
dots = frames[idx % len(frames)]
|
|
285
|
+
if self.is_tty:
|
|
286
|
+
print(f"\r {DIM}{self.label} {elapsed:.1f}s{dots}{RESET}", end="", flush=True)
|
|
287
|
+
elif elapsed >= next_emit:
|
|
288
|
+
print(f" {DIM}{self.label} {elapsed:.1f}s{dots}{RESET}", flush=True)
|
|
289
|
+
next_emit = elapsed + 1.5
|
|
290
|
+
idx += 1
|
|
291
|
+
await asyncio.sleep(0.2)
|
|
292
|
+
if self.is_tty:
|
|
293
|
+
print("\r" + (" " * 120) + "\r", end="", flush=True)
|
|
294
|
+
|
|
295
|
+
def set_label(self, label: str) -> None:
|
|
296
|
+
if self.running:
|
|
297
|
+
self.label = label
|
|
298
|
+
|
|
299
|
+
async def stop(self) -> None:
|
|
300
|
+
if not self.running:
|
|
301
|
+
return
|
|
302
|
+
self.running = False
|
|
303
|
+
if self.task:
|
|
304
|
+
await self.task
|
|
305
|
+
self.task = None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _extract_stream_text(ev: Dict[str, Any]) -> str:
|
|
309
|
+
ev_type = str(ev.get("type") or "")
|
|
310
|
+
if ev_type == "content_block_delta":
|
|
311
|
+
delta = ev.get("delta")
|
|
312
|
+
if isinstance(delta, dict) and delta.get("type") == "text_delta":
|
|
313
|
+
return str(delta.get("text") or "")
|
|
314
|
+
if isinstance(delta, dict) and "text" in delta:
|
|
315
|
+
return str(delta.get("text") or "")
|
|
316
|
+
if ev_type in {"text_delta", "assistant_text_delta"} and isinstance(ev.get("text"), str):
|
|
317
|
+
return str(ev.get("text"))
|
|
318
|
+
if isinstance(ev.get("text"), str):
|
|
319
|
+
return str(ev.get("text"))
|
|
320
|
+
delta = ev.get("delta")
|
|
321
|
+
return delta if isinstance(delta, str) else ""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _extract_stream_tool_name(ev: Dict[str, Any]) -> Optional[str]:
|
|
325
|
+
ev_type = str(ev.get("type") or "")
|
|
326
|
+
if ev_type == "content_block_start":
|
|
327
|
+
cb = ev.get("content_block")
|
|
328
|
+
if isinstance(cb, dict) and cb.get("type") == "tool_use":
|
|
329
|
+
return str(cb.get("name") or "tool")
|
|
330
|
+
if ev_type == "tool_use":
|
|
331
|
+
return str(ev.get("name") or "tool")
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def tool_summary(inp: Any) -> str:
|
|
336
|
+
if not isinstance(inp, dict):
|
|
337
|
+
return str(inp)[:120]
|
|
338
|
+
for key in ("file_path", "path", "pattern", "query", "command"):
|
|
339
|
+
if key in inp:
|
|
340
|
+
return str(inp[key])[:120]
|
|
341
|
+
return str(inp)[:120]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@dataclass
|
|
345
|
+
class ChatState:
|
|
346
|
+
session_id: str
|
|
347
|
+
turns: int = 0
|
|
348
|
+
last_total_input_tokens: int = 0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class AtrisTerminal:
|
|
352
|
+
def __init__(
|
|
353
|
+
self,
|
|
354
|
+
cwd: Path,
|
|
355
|
+
agent_name: str,
|
|
356
|
+
model: str,
|
|
357
|
+
max_turns: Optional[int],
|
|
358
|
+
max_budget_usd: float,
|
|
359
|
+
autoreset_queries: int,
|
|
360
|
+
autotokens_limit: int,
|
|
361
|
+
resume_last: bool,
|
|
362
|
+
) -> None:
|
|
363
|
+
self.cwd = cwd.resolve()
|
|
364
|
+
self.agent_name = agent_name
|
|
365
|
+
self.model = model
|
|
366
|
+
self.max_turns = max_turns
|
|
367
|
+
self.max_budget_usd = max_budget_usd
|
|
368
|
+
self.autoreset_queries = autoreset_queries
|
|
369
|
+
self.autotokens_limit = autotokens_limit
|
|
370
|
+
self.resume_last = resume_last
|
|
371
|
+
self.spinner = Spinner()
|
|
372
|
+
self.client: Optional[Any] = None
|
|
373
|
+
self.client_ready = False
|
|
374
|
+
self.start_task: Optional[asyncio.Task[Any]] = None
|
|
375
|
+
self.start_error: Optional[BaseException] = None
|
|
376
|
+
self.total_cost = 0.0
|
|
377
|
+
self.persona = load_persona(agent_name)
|
|
378
|
+
self.workspace_prompt = os.getenv("ATRIS_COMPUTER_SYSTEM_PROMPT", "").strip()
|
|
379
|
+
self.guard_enabled = os.getenv("ATRIS_COMPUTER_GUARD", "1").lower() not in {"0", "false", "no"}
|
|
380
|
+
self.guard = RuntimeGuard() if self.guard_enabled else None
|
|
381
|
+
self.audit_enabled = os.getenv("ATRIS_COMPUTER_AUDIT", "1").lower() not in {"0", "false", "no"}
|
|
382
|
+
|
|
383
|
+
session_id = f"main-{int(time.time() * 1000)}"
|
|
384
|
+
if resume_last:
|
|
385
|
+
resumed = get_saved_session(agent_name, model, self.cwd, autotokens_limit)
|
|
386
|
+
if resumed:
|
|
387
|
+
session_id = resumed
|
|
388
|
+
self.chat = ChatState(session_id=session_id)
|
|
389
|
+
self.audit("session_open", "ok", f"session opened at {self.cwd}")
|
|
390
|
+
|
|
391
|
+
def _system_prompt(self) -> Optional[str]:
|
|
392
|
+
parts = [p for p in (self.workspace_prompt, self.persona) if p]
|
|
393
|
+
return "\n\n".join(parts) if parts else None
|
|
394
|
+
|
|
395
|
+
def audit(self, event: str, status: str, summary: str, metadata: Optional[Dict[str, Any]] = None) -> None:
|
|
396
|
+
if not self.audit_enabled:
|
|
397
|
+
return
|
|
398
|
+
entry: Dict[str, Any] = {
|
|
399
|
+
"ts": int(time.time()),
|
|
400
|
+
"event": event,
|
|
401
|
+
"status": status,
|
|
402
|
+
"summary": one_line(summary, 240),
|
|
403
|
+
"cwd": str(self.cwd),
|
|
404
|
+
"agent": self.agent_name,
|
|
405
|
+
"model": self.model,
|
|
406
|
+
"session_id": self.chat.session_id,
|
|
407
|
+
"guard": self.guard_enabled,
|
|
408
|
+
}
|
|
409
|
+
if metadata:
|
|
410
|
+
entry["metadata"] = metadata
|
|
411
|
+
append_audit_entry(entry)
|
|
412
|
+
|
|
413
|
+
def print_audit(self, limit: int = 10) -> None:
|
|
414
|
+
entries = read_recent_audit_entries(limit=limit, cwd=self.cwd)
|
|
415
|
+
if not entries:
|
|
416
|
+
print(f"{DIM}No audit entries for this workspace yet.{RESET}")
|
|
417
|
+
return
|
|
418
|
+
print(f"{BOLD}AUDIT{RESET} last {len(entries)} event(s)")
|
|
419
|
+
for entry in entries:
|
|
420
|
+
stamp = time.strftime("%H:%M:%S", time.localtime(int(entry.get("ts") or time.time())))
|
|
421
|
+
event = str(entry.get("event") or "event")
|
|
422
|
+
status = str(entry.get("status") or "")
|
|
423
|
+
summary = one_line(entry.get("summary") or "", 140)
|
|
424
|
+
print(f" {stamp} {event:<16} {status:<7} {summary}")
|
|
425
|
+
|
|
426
|
+
def _options(self) -> Any:
|
|
427
|
+
sdk = ensure_sdk_runtime()
|
|
428
|
+
return sdk["ClaudeAgentOptions"](
|
|
429
|
+
model=self.model,
|
|
430
|
+
permission_mode="bypassPermissions",
|
|
431
|
+
continue_conversation=True,
|
|
432
|
+
max_turns=self.max_turns,
|
|
433
|
+
max_budget_usd=self.max_budget_usd,
|
|
434
|
+
cwd=str(self.cwd),
|
|
435
|
+
system_prompt=self._system_prompt(),
|
|
436
|
+
include_partial_messages=True,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
async def _start_client(self, quiet: bool = False) -> None:
|
|
440
|
+
sdk = ensure_sdk_runtime()
|
|
441
|
+
if not quiet:
|
|
442
|
+
await self.spinner.start("Starting SDK")
|
|
443
|
+
try:
|
|
444
|
+
self.client = sdk["ClaudeSDKClient"](self._options())
|
|
445
|
+
await self.client.__aenter__()
|
|
446
|
+
self.client_ready = True
|
|
447
|
+
finally:
|
|
448
|
+
if not quiet:
|
|
449
|
+
await self.spinner.stop()
|
|
450
|
+
|
|
451
|
+
def warm_start(self) -> None:
|
|
452
|
+
if self.client_ready and self.client is not None:
|
|
453
|
+
return
|
|
454
|
+
if self.start_task is None or self.start_task.done():
|
|
455
|
+
self.start_task = asyncio.create_task(self._start_client(quiet=True))
|
|
456
|
+
self.start_task.add_done_callback(self._capture_start_error)
|
|
457
|
+
|
|
458
|
+
def _capture_start_error(self, task: asyncio.Task[Any]) -> None:
|
|
459
|
+
try:
|
|
460
|
+
task.result()
|
|
461
|
+
except asyncio.CancelledError:
|
|
462
|
+
return
|
|
463
|
+
except BaseException as exc:
|
|
464
|
+
self.start_error = exc
|
|
465
|
+
|
|
466
|
+
async def start(self) -> None:
|
|
467
|
+
if self.client_ready and self.client is not None:
|
|
468
|
+
return
|
|
469
|
+
if self.start_task is not None:
|
|
470
|
+
task = self.start_task
|
|
471
|
+
if not task.done():
|
|
472
|
+
await self.spinner.start("Finishing SDK start")
|
|
473
|
+
try:
|
|
474
|
+
await task
|
|
475
|
+
return
|
|
476
|
+
finally:
|
|
477
|
+
await self.spinner.stop()
|
|
478
|
+
if self.start_task is task:
|
|
479
|
+
self.start_task = None
|
|
480
|
+
|
|
481
|
+
self.start_task = asyncio.create_task(self._start_client(quiet=False))
|
|
482
|
+
try:
|
|
483
|
+
await self.start_task
|
|
484
|
+
finally:
|
|
485
|
+
self.start_task = None
|
|
486
|
+
|
|
487
|
+
async def close(self) -> None:
|
|
488
|
+
if self.start_task is not None and not self.start_task.done():
|
|
489
|
+
self.start_task.cancel()
|
|
490
|
+
try:
|
|
491
|
+
await self.start_task
|
|
492
|
+
except asyncio.CancelledError:
|
|
493
|
+
pass
|
|
494
|
+
self.start_task = None
|
|
495
|
+
if self.client is None:
|
|
496
|
+
return
|
|
497
|
+
try:
|
|
498
|
+
await self.client.__aexit__(None, None, None)
|
|
499
|
+
except Exception:
|
|
500
|
+
pass
|
|
501
|
+
self.client = None
|
|
502
|
+
self.client_ready = False
|
|
503
|
+
|
|
504
|
+
async def restart_client(self) -> None:
|
|
505
|
+
await self.close()
|
|
506
|
+
await self.start()
|
|
507
|
+
|
|
508
|
+
def reset(self) -> None:
|
|
509
|
+
self.chat = ChatState(session_id=f"main-{int(time.time() * 1000)}")
|
|
510
|
+
set_saved_session(self.agent_name, self.model, self.cwd, None, 0)
|
|
511
|
+
self.audit("session_reset", "ok", "reset active chat")
|
|
512
|
+
|
|
513
|
+
def maybe_autoreset(self) -> Optional[str]:
|
|
514
|
+
if self.autoreset_queries > 0 and self.chat.turns >= self.autoreset_queries:
|
|
515
|
+
self.reset()
|
|
516
|
+
return f"query limit reached ({self.autoreset_queries})"
|
|
517
|
+
if self.autotokens_limit > 0 and self.chat.last_total_input_tokens >= self.autotokens_limit:
|
|
518
|
+
self.reset()
|
|
519
|
+
return f"input token limit reached ({self.autotokens_limit:,})"
|
|
520
|
+
return None
|
|
521
|
+
|
|
522
|
+
async def switch_agent(self, new_agent: str) -> str:
|
|
523
|
+
if new_agent not in list_agents():
|
|
524
|
+
return f"{RED}Unknown agent: {new_agent}{RESET}"
|
|
525
|
+
self.agent_name = new_agent
|
|
526
|
+
self.persona = load_persona(new_agent)
|
|
527
|
+
self.reset()
|
|
528
|
+
await self.restart_client()
|
|
529
|
+
self.audit("switch_agent", "ok", f"switched agent to {new_agent}")
|
|
530
|
+
return f"{GREEN}Agent switched: {new_agent}{RESET}"
|
|
531
|
+
|
|
532
|
+
async def switch_model(self, new_model: str) -> str:
|
|
533
|
+
self.model = new_model
|
|
534
|
+
self.reset()
|
|
535
|
+
await self.restart_client()
|
|
536
|
+
self.audit("switch_model", "ok", f"switched model to {new_model}")
|
|
537
|
+
return f"{GREEN}Model switched: {new_model}{RESET}"
|
|
538
|
+
|
|
539
|
+
async def run_shell(self, command: str) -> None:
|
|
540
|
+
if not command.strip():
|
|
541
|
+
print(f"{YELLOW}Usage: /run <cmd>{RESET}")
|
|
542
|
+
return
|
|
543
|
+
if self.guard_enabled and self.guard is not None:
|
|
544
|
+
event = self.guard.check_tool_call(
|
|
545
|
+
agent_id=f"local-{self.agent_name}",
|
|
546
|
+
user_id=os.getenv("USER") or "local-user",
|
|
547
|
+
tool_name="run_bash",
|
|
548
|
+
tool_input={"command": command},
|
|
549
|
+
)
|
|
550
|
+
if event.action_taken == ActionType.BLOCK:
|
|
551
|
+
self.audit(
|
|
552
|
+
"shell_blocked",
|
|
553
|
+
"blocked",
|
|
554
|
+
command,
|
|
555
|
+
{"reason": event.description},
|
|
556
|
+
)
|
|
557
|
+
print(f"{RED}blocked by atris-guard: {event.description}{RESET}")
|
|
558
|
+
return
|
|
559
|
+
proc = await asyncio.create_subprocess_shell(
|
|
560
|
+
command,
|
|
561
|
+
cwd=str(self.cwd),
|
|
562
|
+
stdout=asyncio.subprocess.PIPE,
|
|
563
|
+
stderr=asyncio.subprocess.PIPE,
|
|
564
|
+
)
|
|
565
|
+
stdout, stderr = await proc.communicate()
|
|
566
|
+
if stdout:
|
|
567
|
+
sys.stdout.write(stdout.decode(errors="replace"))
|
|
568
|
+
if not stdout.endswith(b"\n"):
|
|
569
|
+
sys.stdout.write("\n")
|
|
570
|
+
if stderr:
|
|
571
|
+
sys.stderr.write(stderr.decode(errors="replace"))
|
|
572
|
+
if not stderr.endswith(b"\n"):
|
|
573
|
+
sys.stderr.write("\n")
|
|
574
|
+
self.audit(
|
|
575
|
+
"shell_executed",
|
|
576
|
+
"ok" if proc.returncode in (0, None) else "error",
|
|
577
|
+
command,
|
|
578
|
+
{"exit_code": proc.returncode},
|
|
579
|
+
)
|
|
580
|
+
if proc.returncode not in (0, None):
|
|
581
|
+
print(f"{DIM}exit {proc.returncode}{RESET}")
|
|
582
|
+
|
|
583
|
+
async def ask(self, prompt: str, retry_on_overflow: bool = True) -> None:
|
|
584
|
+
if not prompt.strip():
|
|
585
|
+
return
|
|
586
|
+
if self.guard_enabled and self.guard is not None:
|
|
587
|
+
event = self.guard.check_tool_call(
|
|
588
|
+
agent_id=f"local-{self.agent_name}",
|
|
589
|
+
user_id=os.getenv("USER") or "local-user",
|
|
590
|
+
tool_name="chat",
|
|
591
|
+
tool_input={"message": prompt},
|
|
592
|
+
)
|
|
593
|
+
if event.action_taken == ActionType.BLOCK:
|
|
594
|
+
self.audit(
|
|
595
|
+
"prompt_blocked",
|
|
596
|
+
"blocked",
|
|
597
|
+
prompt,
|
|
598
|
+
{"reason": event.description},
|
|
599
|
+
)
|
|
600
|
+
print(f"{RED}blocked by atris-guard: {event.description}{RESET}")
|
|
601
|
+
return
|
|
602
|
+
|
|
603
|
+
reset_reason = self.maybe_autoreset()
|
|
604
|
+
if reset_reason:
|
|
605
|
+
print(f" {YELLOW}Auto-reset: {reset_reason}{RESET}")
|
|
606
|
+
|
|
607
|
+
await self.start()
|
|
608
|
+
if self.client is None:
|
|
609
|
+
print(f"{RED}SDK client not available{RESET}")
|
|
610
|
+
return
|
|
611
|
+
|
|
612
|
+
sdk = ensure_sdk_runtime()
|
|
613
|
+
usage: Dict[str, Any] = {}
|
|
614
|
+
session_id = self.chat.session_id
|
|
615
|
+
cost_usd = 0.0
|
|
616
|
+
printed_text = False
|
|
617
|
+
saw_stream_text = False
|
|
618
|
+
overflow_error_text = ""
|
|
619
|
+
recoverable_stream_error = ""
|
|
620
|
+
|
|
621
|
+
await self.spinner.start("Thinking")
|
|
622
|
+
try:
|
|
623
|
+
await self.client.query(prompt=prompt, session_id=self.chat.session_id)
|
|
624
|
+
try:
|
|
625
|
+
async for message in self.client.receive_response():
|
|
626
|
+
if isinstance(message, sdk["StreamEvent"]):
|
|
627
|
+
ev = message.event if isinstance(message.event, dict) else {}
|
|
628
|
+
tool_name = _extract_stream_tool_name(ev)
|
|
629
|
+
if tool_name:
|
|
630
|
+
self.spinner.set_label(f"Using {tool_name}")
|
|
631
|
+
delta = _extract_stream_text(ev)
|
|
632
|
+
if delta:
|
|
633
|
+
if not printed_text:
|
|
634
|
+
print()
|
|
635
|
+
printed_text = True
|
|
636
|
+
print(delta, end="", flush=True)
|
|
637
|
+
saw_stream_text = True
|
|
638
|
+
self.spinner.set_label("Responding")
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
if isinstance(message, sdk["AssistantMessage"]):
|
|
642
|
+
for block in message.content:
|
|
643
|
+
if isinstance(block, sdk["ToolUseBlock"]):
|
|
644
|
+
print(f"\n {DIM}[{block.name}] {tool_summary(getattr(block, 'input', {}))}{RESET}")
|
|
645
|
+
self.spinner.set_label(f"Using {block.name}")
|
|
646
|
+
elif isinstance(block, sdk["TextBlock"]) and not saw_stream_text:
|
|
647
|
+
text = block.text or ""
|
|
648
|
+
if text:
|
|
649
|
+
if not printed_text:
|
|
650
|
+
print()
|
|
651
|
+
printed_text = True
|
|
652
|
+
print(text, end="", flush=True)
|
|
653
|
+
continue
|
|
654
|
+
|
|
655
|
+
if isinstance(message, sdk["SystemMessage"]):
|
|
656
|
+
continue
|
|
657
|
+
|
|
658
|
+
if isinstance(message, sdk["ResultMessage"]):
|
|
659
|
+
session_id = getattr(message, "session_id", session_id) or session_id
|
|
660
|
+
usage = getattr(message, "usage", {}) or {}
|
|
661
|
+
cost_usd = float(getattr(message, "total_cost_usd", 0.0) or 0.0)
|
|
662
|
+
if getattr(message, "is_error", False):
|
|
663
|
+
result_text = getattr(message, "result", None)
|
|
664
|
+
if result_text:
|
|
665
|
+
print(f"\n{RED}{result_text}{RESET}")
|
|
666
|
+
lowered = str(result_text).lower()
|
|
667
|
+
if "prompt is too long" in lowered or "context window" in lowered:
|
|
668
|
+
overflow_error_text = str(result_text)
|
|
669
|
+
break
|
|
670
|
+
except Exception as exc:
|
|
671
|
+
message = str(exc)
|
|
672
|
+
if "Unknown message type:" in message:
|
|
673
|
+
recoverable_stream_error = message
|
|
674
|
+
else:
|
|
675
|
+
raise
|
|
676
|
+
finally:
|
|
677
|
+
await self.spinner.stop()
|
|
678
|
+
|
|
679
|
+
if printed_text:
|
|
680
|
+
print()
|
|
681
|
+
|
|
682
|
+
if recoverable_stream_error:
|
|
683
|
+
print(f" {DIM}Ignored SDK event: {recoverable_stream_error}{RESET}")
|
|
684
|
+
|
|
685
|
+
if overflow_error_text and retry_on_overflow:
|
|
686
|
+
self.reset()
|
|
687
|
+
print(f" {YELLOW}Reset context due overflow. Retrying once...{RESET}")
|
|
688
|
+
await self.ask(prompt, retry_on_overflow=False)
|
|
689
|
+
return
|
|
690
|
+
|
|
691
|
+
u = usage_breakdown(usage)
|
|
692
|
+
self.total_cost += cost_usd
|
|
693
|
+
self.chat.session_id = session_id
|
|
694
|
+
self.chat.turns += 1
|
|
695
|
+
self.chat.last_total_input_tokens = u["total_input_tokens"]
|
|
696
|
+
set_saved_session(
|
|
697
|
+
self.agent_name,
|
|
698
|
+
self.model,
|
|
699
|
+
self.cwd,
|
|
700
|
+
self.chat.session_id if self.resume_last else None,
|
|
701
|
+
self.chat.last_total_input_tokens,
|
|
702
|
+
)
|
|
703
|
+
self.audit(
|
|
704
|
+
"prompt_completed",
|
|
705
|
+
"ok",
|
|
706
|
+
prompt,
|
|
707
|
+
{
|
|
708
|
+
"cost_usd": round(cost_usd, 6),
|
|
709
|
+
"prompt_input_tokens": u["prompt_input_tokens"],
|
|
710
|
+
"cache_creation_tokens": u["cache_creation_tokens"],
|
|
711
|
+
"cache_read_tokens": u["cache_read_tokens"],
|
|
712
|
+
"output_tokens": u["output_tokens"],
|
|
713
|
+
"ignored_sdk_event": recoverable_stream_error or "",
|
|
714
|
+
},
|
|
715
|
+
)
|
|
716
|
+
print(
|
|
717
|
+
f" ${cost_usd:.4f} | prompt {u['prompt_input_tokens']:,} in | "
|
|
718
|
+
f"cache {u['cache_creation_tokens']:,} write / {u['cache_read_tokens']:,} read | "
|
|
719
|
+
f"out {u['output_tokens']:,} | {self.chat.turns} turns"
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def print_header(term: AtrisTerminal) -> None:
|
|
724
|
+
print(f"{BOLD}ATRIS{RESET} {CYAN}[COMPUTER]{RESET} agent={term.agent_name} model={term.model}")
|
|
725
|
+
print(f"Root: {term.cwd}")
|
|
726
|
+
print(f"Guard: {'ON' if term.guard_enabled else 'OFF'}")
|
|
727
|
+
print("SDK: warming in background; /run is instant")
|
|
728
|
+
print("Commands: /help /agents /agent <name> /model <name> /run <cmd> /audit [n] /reset /exit")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def parse_int_or_off(token: str) -> int:
|
|
732
|
+
if token.lower() in {"off", "none", "0"}:
|
|
733
|
+
return 0
|
|
734
|
+
return max(0, int(token))
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
async def handle_command(term: AtrisTerminal, raw: str) -> bool:
|
|
738
|
+
parts = raw.strip().split()
|
|
739
|
+
cmd = parts[0].lower()
|
|
740
|
+
|
|
741
|
+
if cmd in {"/exit", "/quit"}:
|
|
742
|
+
return False
|
|
743
|
+
if cmd == "/help":
|
|
744
|
+
print_header(term)
|
|
745
|
+
return True
|
|
746
|
+
if cmd == "/agents":
|
|
747
|
+
print(" " + ", ".join(list_agents()))
|
|
748
|
+
return True
|
|
749
|
+
if cmd == "/agent":
|
|
750
|
+
if len(parts) < 2:
|
|
751
|
+
print(f"{YELLOW}Usage: /agent <name>{RESET}")
|
|
752
|
+
return True
|
|
753
|
+
print(await term.switch_agent(parts[1]))
|
|
754
|
+
return True
|
|
755
|
+
if cmd == "/model":
|
|
756
|
+
if len(parts) < 2:
|
|
757
|
+
print(f" current: {term.model}")
|
|
758
|
+
print(f" known: {', '.join(KNOWN_MODELS)}")
|
|
759
|
+
return True
|
|
760
|
+
print(await term.switch_model(parts[1]))
|
|
761
|
+
return True
|
|
762
|
+
if cmd == "/run":
|
|
763
|
+
await term.run_shell(raw.strip()[len("/run"):].strip())
|
|
764
|
+
return True
|
|
765
|
+
if cmd == "/audit":
|
|
766
|
+
limit = 10
|
|
767
|
+
if len(parts) >= 2:
|
|
768
|
+
try:
|
|
769
|
+
limit = max(1, min(50, int(parts[1])))
|
|
770
|
+
except ValueError:
|
|
771
|
+
print(f"{RED}Invalid audit count{RESET}")
|
|
772
|
+
return True
|
|
773
|
+
term.print_audit(limit)
|
|
774
|
+
return True
|
|
775
|
+
if cmd == "/reset":
|
|
776
|
+
term.reset()
|
|
777
|
+
print(f"{GREEN}Reset active chat{RESET}")
|
|
778
|
+
return True
|
|
779
|
+
if cmd == "/autoreset":
|
|
780
|
+
if len(parts) < 2:
|
|
781
|
+
print(f" current autoreset: {term.autoreset_queries or 'off'}")
|
|
782
|
+
return True
|
|
783
|
+
try:
|
|
784
|
+
term.autoreset_queries = parse_int_or_off(parts[1])
|
|
785
|
+
print(f"{GREEN}autoreset = {term.autoreset_queries or 'off'}{RESET}")
|
|
786
|
+
except ValueError:
|
|
787
|
+
print(f"{RED}Invalid value for /autoreset{RESET}")
|
|
788
|
+
return True
|
|
789
|
+
if cmd == "/autotokens":
|
|
790
|
+
if len(parts) < 2:
|
|
791
|
+
print(f" current autotokens: {term.autotokens_limit or 'off'}")
|
|
792
|
+
return True
|
|
793
|
+
try:
|
|
794
|
+
term.autotokens_limit = parse_int_or_off(parts[1])
|
|
795
|
+
print(f"{GREEN}autotokens = {term.autotokens_limit or 'off'}{RESET}")
|
|
796
|
+
except ValueError:
|
|
797
|
+
print(f"{RED}Invalid value for /autotokens{RESET}")
|
|
798
|
+
return True
|
|
799
|
+
if cmd == "/resume":
|
|
800
|
+
if len(parts) < 2:
|
|
801
|
+
print(f" resume-last: {'on' if term.resume_last else 'off'}")
|
|
802
|
+
return True
|
|
803
|
+
term.resume_last = parts[1].lower() in {"1", "on", "true", "yes"}
|
|
804
|
+
if not term.resume_last:
|
|
805
|
+
set_saved_session(term.agent_name, term.model, term.cwd, None, 0)
|
|
806
|
+
print(f"{GREEN}resume-last = {'on' if term.resume_last else 'off'}{RESET}")
|
|
807
|
+
return True
|
|
808
|
+
|
|
809
|
+
print(f"{YELLOW}Unknown command: {cmd}{RESET}")
|
|
810
|
+
return True
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
async def run_repl(args: argparse.Namespace) -> int:
|
|
814
|
+
cwd = Path(args.cwd or os.getcwd()).resolve()
|
|
815
|
+
maybe_load_dotenv(cwd)
|
|
816
|
+
term = AtrisTerminal(
|
|
817
|
+
cwd=cwd,
|
|
818
|
+
agent_name=args.agent,
|
|
819
|
+
model=args.model,
|
|
820
|
+
max_turns=args.max_turns,
|
|
821
|
+
max_budget_usd=args.max_budget_usd,
|
|
822
|
+
autoreset_queries=args.autoreset,
|
|
823
|
+
autotokens_limit=args.autotokens,
|
|
824
|
+
resume_last=args.resume_last,
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
print_header(term)
|
|
828
|
+
|
|
829
|
+
try:
|
|
830
|
+
if args.prompt:
|
|
831
|
+
await term.ask(args.prompt)
|
|
832
|
+
return 0
|
|
833
|
+
|
|
834
|
+
term.warm_start()
|
|
835
|
+
while True:
|
|
836
|
+
try:
|
|
837
|
+
raw = (await asyncio.to_thread(input, "\ncomputer> ")).strip()
|
|
838
|
+
except (EOFError, KeyboardInterrupt):
|
|
839
|
+
print()
|
|
840
|
+
break
|
|
841
|
+
if not raw:
|
|
842
|
+
continue
|
|
843
|
+
if raw.startswith("/"):
|
|
844
|
+
keep = await handle_command(term, raw)
|
|
845
|
+
if not keep:
|
|
846
|
+
break
|
|
847
|
+
continue
|
|
848
|
+
await term.ask(raw)
|
|
849
|
+
finally:
|
|
850
|
+
await term.close()
|
|
851
|
+
return 0
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def parse_args() -> argparse.Namespace:
|
|
855
|
+
parser = argparse.ArgumentParser(description="Persistent Atris Claude SDK terminal")
|
|
856
|
+
parser.add_argument("-p", "--prompt", help="One-shot prompt (non-interactive)")
|
|
857
|
+
parser.add_argument("--cwd", default=os.getcwd(), help="Workspace root")
|
|
858
|
+
parser.add_argument("--agent", default="navigator", help="Agent persona from atris/team")
|
|
859
|
+
parser.add_argument(
|
|
860
|
+
"--model",
|
|
861
|
+
default=os.getenv("ATRIS_CLAUDE_MODEL", "claude-sonnet-4-6"),
|
|
862
|
+
help="Claude model ID",
|
|
863
|
+
)
|
|
864
|
+
parser.add_argument("--max-turns", type=int, default=None, help="SDK max_turns guard")
|
|
865
|
+
parser.add_argument("--max-budget-usd", type=float, default=5.0, help="Per-query budget cap")
|
|
866
|
+
parser.add_argument("--autoreset", type=int, default=10, help="Auto reset after N turns (0 disables)")
|
|
867
|
+
parser.add_argument(
|
|
868
|
+
"--autotokens",
|
|
869
|
+
type=int,
|
|
870
|
+
default=80000,
|
|
871
|
+
help="Auto reset when total input tokens exceed N (0 disables)",
|
|
872
|
+
)
|
|
873
|
+
parser.add_argument("--resume-last", action="store_true", help="Resume last saved session")
|
|
874
|
+
return parser.parse_args()
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def main() -> int:
|
|
878
|
+
args = parse_args()
|
|
879
|
+
try:
|
|
880
|
+
return asyncio.run(run_repl(args))
|
|
881
|
+
except KeyboardInterrupt:
|
|
882
|
+
return 130
|
|
883
|
+
except RuntimeError as exc:
|
|
884
|
+
print(f"{RED}{exc}{RESET}")
|
|
885
|
+
return 1
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
if __name__ == "__main__":
|
|
889
|
+
raise SystemExit(main())
|