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.
Files changed (55) hide show
  1. package/GETTING_STARTED.md +65 -131
  2. package/README.md +18 -2
  3. package/atris/GETTING_STARTED.md +65 -131
  4. package/atris/PERSONA.md +5 -1
  5. package/atris/atris.md +122 -153
  6. package/atris/skills/aeo/SKILL.md +117 -0
  7. package/atris/skills/atris/SKILL.md +49 -25
  8. package/atris/skills/create-member/SKILL.md +29 -9
  9. package/atris/skills/endgame/SKILL.md +9 -0
  10. package/atris/skills/research-search/SKILL.md +167 -0
  11. package/atris/skills/research-search/arxiv_search.py +157 -0
  12. package/atris/skills/research-search/program.md +48 -0
  13. package/atris/skills/research-search/results.tsv +6 -0
  14. package/atris/skills/research-search/scholar_search.py +154 -0
  15. package/atris/skills/tidy/SKILL.md +36 -21
  16. package/atris/team/_template/MEMBER.md +2 -0
  17. package/atris/team/validator/MEMBER.md +35 -1
  18. package/atris.md +118 -178
  19. package/bin/atris.js +46 -12
  20. package/cli/__pycache__/atris_code.cpython-314.pyc +0 -0
  21. package/cli/__pycache__/runtime_guard.cpython-312.pyc +0 -0
  22. package/cli/__pycache__/runtime_guard.cpython-314.pyc +0 -0
  23. package/cli/atris_code.py +889 -0
  24. package/cli/runtime_guard.py +693 -0
  25. package/commands/align.js +16 -0
  26. package/commands/app.js +316 -0
  27. package/commands/autopilot.js +863 -23
  28. package/commands/brainstorm.js +7 -5
  29. package/commands/business.js +677 -2
  30. package/commands/clean.js +19 -3
  31. package/commands/computer.js +2022 -43
  32. package/commands/context-sync.js +5 -0
  33. package/commands/integrations.js +14 -9
  34. package/commands/lifecycle.js +12 -0
  35. package/commands/plugin.js +24 -0
  36. package/commands/pull.js +86 -11
  37. package/commands/push.js +153 -9
  38. package/commands/serve.js +1 -0
  39. package/commands/sync.js +272 -76
  40. package/commands/verify.js +50 -1
  41. package/commands/wiki.js +27 -2
  42. package/commands/workflow.js +24 -9
  43. package/lib/file-ops.js +13 -1
  44. package/lib/journal.js +23 -0
  45. package/lib/manifest.js +3 -0
  46. package/lib/scorecard.js +42 -4
  47. package/lib/sync-telemetry.js +59 -0
  48. package/lib/todo.js +6 -0
  49. package/lib/wiki.js +150 -6
  50. package/lib/workspace-safety.js +87 -0
  51. package/package.json +2 -1
  52. package/utils/api.js +19 -0
  53. package/utils/auth.js +25 -1
  54. package/utils/config.js +24 -0
  55. 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())