agentlatch 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AgentLatch Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentlatch
3
+ Version: 0.1.0
4
+ Summary: Zero-dependency, terminal-native Python middleware for resilient and observable AI agents.
5
+ Author: AgentLatch Contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/arav7781/AgentLatch
8
+ Project-URL: Repository, https://github.com/arav7781/AgentLatch
9
+ Project-URL: Issues, https://github.com/arav7781/AgentLatch/issues
10
+ Keywords: agents,llm,observability,middleware,terminal
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: rich>=13.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=7.0; extra == "dev"
26
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
27
+ Provides-Extra: server
28
+ Requires-Dist: starlette>=0.27; extra == "server"
29
+ Requires-Dist: httpx>=0.24; extra == "server"
30
+ Dynamic: license-file
31
+
32
+ ![AgentLatch Banner](banner.png)
33
+
34
+ **Terminal-native agent resilience middleware for Python.**
35
+
36
+ AgentLatch is a zero-dependency framework that makes AI agents resilient and observable. It solves two massive pain points in agent development:
37
+
38
+ 1. **Silent Tool Failures** — When an LLM executes a tool that crashes, AgentLatch intercepts the Python exception, prevents a runtime crash, and feeds a structured JSON error back to the LLM so it can self-correct.
39
+
40
+ 2. **Blind Latency** — It tracks millisecond execution time of LLM vs. tools and prints a color-coded ASCII flamegraph directly in the terminal using the `rich` library. No API keys, no dashboards, no cloud.
41
+
42
+ ## Quick Install
43
+
44
+ ```bash
45
+ uv pip install -e ".[dev]"
46
+ ```
47
+
48
+ ## Usage
49
+
50
+ ```python
51
+ from agentlatch import profile_agent, safe_tool
52
+
53
+ @safe_tool
54
+ def query_database(sql: str) -> str:
55
+ """This tool is now protected — exceptions become JSON errors."""
56
+ import sqlite3
57
+ conn = sqlite3.connect("my.db")
58
+ return str(conn.execute(sql).fetchall())
59
+
60
+ @safe_tool(timeout=5.0)
61
+ def call_api(url: str) -> str:
62
+ """This tool has a 5-second timeout."""
63
+ import requests
64
+ return requests.get(url).text
65
+
66
+ @profile_agent
67
+ def run_agent():
68
+ """The agent loop — traced and visualized automatically."""
69
+ result = query_database("SELECT * FROM users")
70
+ weather = call_api("https://api.weather.com/sf")
71
+ return f"Got {result} and {weather}"
72
+
73
+ run_agent()
74
+ ```
75
+
76
+ ## What Happens
77
+
78
+ 1. **Execution**: Every `@safe_tool` call is timed and protected.
79
+ 2. **On Error**: Instead of crashing, the tool returns a JSON error string:
80
+ ```json
81
+ {
82
+ "status": "error",
83
+ "error_type": "ProgrammingError",
84
+ "message": "column 'age' does not exist",
85
+ "instruction": "The tool execution failed. Review your parameters and retry with corrected inputs."
86
+ }
87
+ ```
88
+ 3. **On Completion**: A rich flamegraph is printed to the terminal:
89
+ ```
90
+ ┌─────────────────────────────────────────────────────────┐
91
+ │ ⚡ AGENTLATCH EXECUTION PROFILE │
92
+ │ Total: 1.23s │ Tools: 0.85s │ LLM Reasoning: 0.38s │
93
+ ├─────────────────────────────────────────────────────────┤
94
+ │ ████████████████████████████████████████████ 1.23s │
95
+ │ ░░░░████████████░░░████░░░░░░░░░░░░░░░░░░░░ │
96
+ │ query_db 0.5s call_api 0.2s │
97
+ │ ▲ ERROR │
98
+ │ Legend: █ LLM █ Tool (OK) █ Tool (ERROR) │
99
+ └─────────────────────────────────────────────────────────┘
100
+ ```
101
+
102
+ ## Features
103
+
104
+ | Feature | Description |
105
+ |---------|-------------|
106
+ | `@safe_tool` | Wraps any function — catches exceptions, returns JSON errors |
107
+ | `@safe_tool(timeout=N)` | Adds a thread-based timeout (cross-platform) |
108
+ | `@profile_agent` | Traces the full agent loop and renders the flamegraph |
109
+ | Async support | Both decorators work with `async def` functions |
110
+ | Framework agnostic | Works with LangGraph, AutoGen, CrewAI, or vanilla scripts |
111
+ | CI-safe | Banner auto-disables in non-TTY environments |
112
+
113
+ ## Running Examples
114
+
115
+ ```bash
116
+ # Vanilla agent with a forced failure + self-correction
117
+ python examples/vanilla_agent.py
118
+
119
+ # LangGraph-style state machine
120
+ python examples/langgraph_agent.py
121
+ ```
122
+
123
+ ## Running Tests
124
+
125
+ ```bash
126
+ uv pip install -e ".[dev]"
127
+ pytest tests/ -v
128
+ ```
129
+
130
+ ## Architecture
131
+
132
+ - **`contextvars`** — Thread-safe trace propagation without manual trace IDs
133
+ - **`concurrent.futures`** — Cross-platform timeouts (no `signal.alarm`)
134
+ - **`rich`** — Premium terminal rendering (the only external dependency)
135
+
136
+ ## License
137
+
138
+ MIT
@@ -0,0 +1,107 @@
1
+ ![AgentLatch Banner](banner.png)
2
+
3
+ **Terminal-native agent resilience middleware for Python.**
4
+
5
+ AgentLatch is a zero-dependency framework that makes AI agents resilient and observable. It solves two massive pain points in agent development:
6
+
7
+ 1. **Silent Tool Failures** — When an LLM executes a tool that crashes, AgentLatch intercepts the Python exception, prevents a runtime crash, and feeds a structured JSON error back to the LLM so it can self-correct.
8
+
9
+ 2. **Blind Latency** — It tracks millisecond execution time of LLM vs. tools and prints a color-coded ASCII flamegraph directly in the terminal using the `rich` library. No API keys, no dashboards, no cloud.
10
+
11
+ ## Quick Install
12
+
13
+ ```bash
14
+ uv pip install -e ".[dev]"
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```python
20
+ from agentlatch import profile_agent, safe_tool
21
+
22
+ @safe_tool
23
+ def query_database(sql: str) -> str:
24
+ """This tool is now protected — exceptions become JSON errors."""
25
+ import sqlite3
26
+ conn = sqlite3.connect("my.db")
27
+ return str(conn.execute(sql).fetchall())
28
+
29
+ @safe_tool(timeout=5.0)
30
+ def call_api(url: str) -> str:
31
+ """This tool has a 5-second timeout."""
32
+ import requests
33
+ return requests.get(url).text
34
+
35
+ @profile_agent
36
+ def run_agent():
37
+ """The agent loop — traced and visualized automatically."""
38
+ result = query_database("SELECT * FROM users")
39
+ weather = call_api("https://api.weather.com/sf")
40
+ return f"Got {result} and {weather}"
41
+
42
+ run_agent()
43
+ ```
44
+
45
+ ## What Happens
46
+
47
+ 1. **Execution**: Every `@safe_tool` call is timed and protected.
48
+ 2. **On Error**: Instead of crashing, the tool returns a JSON error string:
49
+ ```json
50
+ {
51
+ "status": "error",
52
+ "error_type": "ProgrammingError",
53
+ "message": "column 'age' does not exist",
54
+ "instruction": "The tool execution failed. Review your parameters and retry with corrected inputs."
55
+ }
56
+ ```
57
+ 3. **On Completion**: A rich flamegraph is printed to the terminal:
58
+ ```
59
+ ┌─────────────────────────────────────────────────────────┐
60
+ │ ⚡ AGENTLATCH EXECUTION PROFILE │
61
+ │ Total: 1.23s │ Tools: 0.85s │ LLM Reasoning: 0.38s │
62
+ ├─────────────────────────────────────────────────────────┤
63
+ │ ████████████████████████████████████████████ 1.23s │
64
+ │ ░░░░████████████░░░████░░░░░░░░░░░░░░░░░░░░ │
65
+ │ query_db 0.5s call_api 0.2s │
66
+ │ ▲ ERROR │
67
+ │ Legend: █ LLM █ Tool (OK) █ Tool (ERROR) │
68
+ └─────────────────────────────────────────────────────────┘
69
+ ```
70
+
71
+ ## Features
72
+
73
+ | Feature | Description |
74
+ |---------|-------------|
75
+ | `@safe_tool` | Wraps any function — catches exceptions, returns JSON errors |
76
+ | `@safe_tool(timeout=N)` | Adds a thread-based timeout (cross-platform) |
77
+ | `@profile_agent` | Traces the full agent loop and renders the flamegraph |
78
+ | Async support | Both decorators work with `async def` functions |
79
+ | Framework agnostic | Works with LangGraph, AutoGen, CrewAI, or vanilla scripts |
80
+ | CI-safe | Banner auto-disables in non-TTY environments |
81
+
82
+ ## Running Examples
83
+
84
+ ```bash
85
+ # Vanilla agent with a forced failure + self-correction
86
+ python examples/vanilla_agent.py
87
+
88
+ # LangGraph-style state machine
89
+ python examples/langgraph_agent.py
90
+ ```
91
+
92
+ ## Running Tests
93
+
94
+ ```bash
95
+ uv pip install -e ".[dev]"
96
+ pytest tests/ -v
97
+ ```
98
+
99
+ ## Architecture
100
+
101
+ - **`contextvars`** — Thread-safe trace propagation without manual trace IDs
102
+ - **`concurrent.futures`** — Cross-platform timeouts (no `signal.alarm`)
103
+ - **`rich`** — Premium terminal rendering (the only external dependency)
104
+
105
+ ## License
106
+
107
+ MIT
@@ -0,0 +1,34 @@
1
+ """AgentLatch — Terminal-native agent resilience middleware.
2
+
3
+ Two decorators are all you need::
4
+
5
+ from agentlatch import profile_agent, safe_tool
6
+
7
+ @safe_tool
8
+ def query_db(sql: str) -> str:
9
+ ...
10
+
11
+ @profile_agent
12
+ def run_agent():
13
+ result = query_db("SELECT ...")
14
+ ...
15
+ """
16
+
17
+ from agentlatch.config import is_dev_mode, set_dev_mode
18
+ from agentlatch.decorators import profile_agent, safe_tool
19
+ from agentlatch.renderer import render_flamegraph
20
+ from agentlatch.sampler import sample_response
21
+ from agentlatch.tracker import TraceEvent, get_trace
22
+
23
+ __version__ = "0.1.0"
24
+ __all__ = [
25
+ "profile_agent",
26
+ "safe_tool",
27
+ "render_flamegraph",
28
+ "sample_response",
29
+ "is_dev_mode",
30
+ "set_dev_mode",
31
+ "TraceEvent",
32
+ "get_trace",
33
+ "__version__",
34
+ ]
@@ -0,0 +1,26 @@
1
+ """Shared types and enumerations for AgentLatch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from typing import Any, TypeAlias
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Enums
10
+ # ---------------------------------------------------------------------------
11
+
12
+
13
+ class EventStatus(enum.Enum):
14
+ """Status of a traced execution event."""
15
+
16
+ SUCCESS = "success"
17
+ ERROR = "error"
18
+ TIMEOUT = "timeout"
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Type Aliases
23
+ # ---------------------------------------------------------------------------
24
+
25
+ ErrorPayload: TypeAlias = dict[str, Any]
26
+ """Structured error information returned to the LLM instead of raising."""
@@ -0,0 +1,336 @@
1
+ """Startup banner animation — Claude Code-style cosmic reveal.
2
+
3
+ Displays a large ASCII art banner spelling AGENT / LATCH in bold block
4
+ letters, surrounded by cosmic cloud formations and scattered stars.
5
+ The entire scene progressively "decrypts" from noise into the final art
6
+ via a diagonal sweep, followed by a typing effect for the welcome message.
7
+
8
+ Gracefully degrades in non-TTY environments (CI, Docker, piped output).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import random
15
+ import sys
16
+ import time
17
+
18
+ from rich.console import Console
19
+ from rich.live import Live
20
+ from rich.text import Text
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Block Letter Definitions (each char is exactly 6 columns wide)
24
+ # ---------------------------------------------------------------------------
25
+
26
+ _LETTER_DATA: dict[str, list[str]] = {
27
+ "A": [" ████ ", "██ ██", "██████", "██ ██", "██ ██"],
28
+ "G": [" █████", "██ ", "██ ███", "██ ██", " █████"],
29
+ "E": ["██████", "██ ", "████ ", "██ ", "██████"],
30
+ "N": ["██ ██", "███ ██", "██████", "██ ███", "██ ██"],
31
+ "T": ["██████", " ██ ", " ██ ", " ██ ", " ██ "],
32
+ "L": ["██ ", "██ ", "██ ", "██ ", "██████"],
33
+ "C": [" █████", "██ ", "██ ", "██ ", " █████"],
34
+ "H": ["██ ██", "██ ██", "██████", "██ ██", "██ ██"],
35
+ }
36
+
37
+
38
+ def _build_word_lines(word: str, indent: int = 4, gap: int = 2) -> list[str]:
39
+ """Render a word as 5 lines of block-letter ASCII art."""
40
+ pad = " " * indent
41
+ sep = " " * gap
42
+ return [pad + sep.join(_LETTER_DATA[ch][row] for ch in word) for row in range(5)]
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Build the Full Art Scene
47
+ # ---------------------------------------------------------------------------
48
+
49
+ _ATMOS_TOP: list[str] = [
50
+ "...............................................................",
51
+ "",
52
+ " * ░░░░░░ █████▓▓░ ",
53
+ " ░░░░░░░░░░ * ███▓░ ░░ ",
54
+ " ░░░░░░░░░░░░░░░░ ███▓░ ",
55
+ " * * ██▓░░ ▓ ",
56
+ " ░▓▓██▓▓░ ",
57
+ "",
58
+ ]
59
+
60
+ _AGENT_LINES: list[str] = _build_word_lines("AGENT")
61
+ _DIVIDER: list[str] = [" ⚡"]
62
+ _LATCH_LINES: list[str] = _build_word_lines("LATCH")
63
+
64
+ _ATMOS_BOTTOM: list[str] = [
65
+ "",
66
+ " * ░░░░ * ",
67
+ " ░░░░░░░░ ",
68
+ " ░░░░░░░░░░░░░░ ",
69
+ " * ",
70
+ "...............................................................",
71
+ ]
72
+
73
+ _ART_LINES: list[str] = (
74
+ _ATMOS_TOP + _AGENT_LINES + _DIVIDER + _LATCH_LINES + _ATMOS_BOTTOM
75
+ )
76
+
77
+ # Pre-compute which rows are ASCII-art text (not atmosphere).
78
+ _TEXT_ROW_START = len(_ATMOS_TOP)
79
+ _TEXT_ROW_END = _TEXT_ROW_START + len(_AGENT_LINES) + len(_DIVIDER) + len(_LATCH_LINES)
80
+ _TEXT_ROWS: set[int] = set(range(_TEXT_ROW_START, _TEXT_ROW_END))
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Welcome / Tagline
84
+ # ---------------------------------------------------------------------------
85
+
86
+ _WELCOME_LINE = " Terminal-native agent resilience middleware v0.1.0"
87
+ _READY_LINE = " Let's get started."
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # Noise / Animation Config
91
+ # ---------------------------------------------------------------------------
92
+
93
+ _NOISE_CHARS: list[str] = [
94
+ "█",
95
+ "▓",
96
+ "▒",
97
+ "░",
98
+ "#",
99
+ "@",
100
+ "%",
101
+ "&",
102
+ "╬",
103
+ "╠",
104
+ "╣",
105
+ "╋",
106
+ "┃",
107
+ "┫",
108
+ ]
109
+
110
+ _TOTAL_FRAMES = 32 # total decryption frames
111
+ _FRAME_DELAY = 0.015 # ~15ms per frame → ~480ms total
112
+ _TYPING_DELAY = 0.025 # per character for welcome text
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # State
116
+ # ---------------------------------------------------------------------------
117
+
118
+ _banner_shown: bool = False
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Style Mapping
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def _char_style(char: str, row: int) -> str:
126
+ """Determine the Rich style for a resolved character."""
127
+ if char == "⚡":
128
+ return "bold bright_yellow"
129
+ if char == "*":
130
+ return "bright_yellow"
131
+ if char == ".":
132
+ return "dim white"
133
+
134
+ # ASCII-art text rows → bold block letters
135
+ if row in _TEXT_ROWS:
136
+ return "bold bright_cyan"
137
+
138
+ # Atmosphere gradient (clouds with depth)
139
+ if char == "█":
140
+ return "bright_cyan"
141
+ if char == "▓":
142
+ return "cyan"
143
+ if char == "▒":
144
+ return "dim cyan"
145
+ if char == "░":
146
+ return "dim bright_white"
147
+
148
+ return "white"
149
+
150
+
151
+ # ---------------------------------------------------------------------------
152
+ # Resolve Map — when each character "decrypts"
153
+ # ---------------------------------------------------------------------------
154
+
155
+
156
+ def _build_resolve_map(
157
+ lines: list[str],
158
+ total_frames: int,
159
+ ) -> list[list[int]]:
160
+ """Assign a resolve-frame to every non-space character.
161
+
162
+ Dot borders flicker in first (frames 0–3).
163
+ The rest decrypts in a diagonal sweep (top-left → bottom-right)
164
+ with random jitter for an organic feel.
165
+ """
166
+ max_row = len(lines)
167
+ max_col = max((len(ln) for ln in lines), default=1)
168
+
169
+ rmap: list[list[int]] = []
170
+ for row, line in enumerate(lines):
171
+ row_map: list[int] = []
172
+ for col, ch in enumerate(line):
173
+ if ch == " ":
174
+ row_map.append(-1) # always transparent
175
+ elif ch == ".":
176
+ row_map.append(random.randint(0, 3)) # dots early
177
+ else:
178
+ row_pct = row / max(max_row - 1, 1)
179
+ col_pct = col / max(max_col - 1, 1)
180
+ progress = row_pct * 0.55 + col_pct * 0.45
181
+ base = int(progress * (total_frames - 6)) + 3
182
+ jitter = random.randint(-3, 3)
183
+ frame = max(2, min(total_frames - 1, base + jitter))
184
+ row_map.append(frame)
185
+ rmap.append(row_map)
186
+ return rmap
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # Frame Renderer
191
+ # ---------------------------------------------------------------------------
192
+
193
+
194
+ def _render_frame(
195
+ frame: int,
196
+ lines: list[str],
197
+ resolve_map: list[list[int]],
198
+ ) -> Text:
199
+ """Produce one animation frame as a Rich Text object."""
200
+ output = Text()
201
+
202
+ for row, line in enumerate(lines):
203
+ for col, ch in enumerate(line):
204
+ if ch == " ":
205
+ output.append(" ")
206
+ continue
207
+
208
+ resolve_at = resolve_map[row][col]
209
+
210
+ if frame >= resolve_at:
211
+ style = _char_style(ch, row)
212
+ output.append(ch, style=style)
213
+ else:
214
+ noise = random.choice(_NOISE_CHARS)
215
+ output.append(noise, style="dim bright_white")
216
+
217
+ output.append("\n")
218
+
219
+ return output
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Typing Effect
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ def _type_text(console: Console, text: str, style: str, delay: float) -> None:
228
+ """Print text character-by-character with a typing effect."""
229
+ for ch in text:
230
+ console.print(ch, end="", style=style, highlight=False)
231
+ time.sleep(delay)
232
+ console.print() # newline
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Interactive Animation
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ def _play_animation(console: Console) -> None:
241
+ """Run the full cosmic reveal sequence."""
242
+ resolve_map = _build_resolve_map(_ART_LINES, _TOTAL_FRAMES)
243
+
244
+ # Phase 1: Decryption sweep of the ASCII art.
245
+ with Live(
246
+ _render_frame(0, _ART_LINES, resolve_map),
247
+ console=console,
248
+ refresh_per_second=62,
249
+ transient=True,
250
+ ) as live:
251
+ for frame in range(_TOTAL_FRAMES):
252
+ live.update(_render_frame(frame, _ART_LINES, resolve_map))
253
+ time.sleep(_FRAME_DELAY)
254
+
255
+ # Print the final, fully-resolved art (stays on screen).
256
+ final_art = _render_frame(_TOTAL_FRAMES, _ART_LINES, resolve_map)
257
+ console.print(final_art, end="")
258
+
259
+ # Phase 2: Welcome text types in.
260
+ console.print()
261
+ _type_text(console, _WELCOME_LINE, "bright_white", _TYPING_DELAY)
262
+ time.sleep(0.12)
263
+ _type_text(console, _READY_LINE, "dim bright_green", _TYPING_DELAY * 0.7)
264
+ console.print()
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Non-TTY Fallback
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ def _print_fallback(console: Console) -> None:
273
+ """Clean output for CI / Docker / piped environments."""
274
+ # Still show the ASCII art, just without animation.
275
+ for row, line in enumerate(_ART_LINES):
276
+ styled = Text()
277
+ for ch in line:
278
+ if ch == " ":
279
+ styled.append(" ")
280
+ else:
281
+ styled.append(ch, style=_char_style(ch, row))
282
+ console.print(styled)
283
+
284
+ console.print()
285
+ console.print(_WELCOME_LINE, style="bright_white")
286
+ console.print(_READY_LINE, style="dim bright_green")
287
+ console.print()
288
+
289
+
290
+ # ---------------------------------------------------------------------------
291
+ # Environment Detection
292
+ # ---------------------------------------------------------------------------
293
+
294
+
295
+ def _is_interactive() -> bool:
296
+ """Return True only if stdout is a real interactive terminal."""
297
+ if not sys.stdout.isatty():
298
+ return False
299
+ if os.environ.get("CI", "").lower() in ("true", "1"):
300
+ return False
301
+ if os.environ.get("TERM", "") == "dumb":
302
+ return False
303
+ return True
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Public API
308
+ # ---------------------------------------------------------------------------
309
+
310
+
311
+ def initialize_latch(console: Console | None = None) -> None:
312
+ """Display the AgentLatch startup banner (once per process).
313
+
314
+ - **Interactive terminal**: plays the full cosmic decryption animation
315
+ with block-letter ASCII art reveal and typing effect.
316
+ - **Non-TTY / CI / Docker**: prints the static colored art without animation.
317
+
318
+ Calling this more than once per process is a no-op.
319
+ """
320
+ global _banner_shown
321
+ if _banner_shown:
322
+ return
323
+ _banner_shown = True
324
+
325
+ con = console or Console()
326
+
327
+ if _is_interactive():
328
+ _play_animation(con)
329
+ else:
330
+ _print_fallback(con)
331
+
332
+
333
+ def reset_banner() -> None:
334
+ """Reset the banner flag. Useful for tests."""
335
+ global _banner_shown
336
+ _banner_shown = False