anvil-dev-framework 0.1.6
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/README.md +719 -0
- package/VERSION +1 -0
- package/docs/ANVIL-REPO-IMPLEMENTATION-PLAN.md +441 -0
- package/docs/FIRST-SKILL-TUTORIAL.md +408 -0
- package/docs/INSTALLATION-RETRO-NOTES.md +458 -0
- package/docs/INSTALLATION.md +984 -0
- package/docs/anvil-hud.md +469 -0
- package/docs/anvil-init.md +255 -0
- package/docs/anvil-state.md +210 -0
- package/docs/boris-cherny-ralph-wiggum-insights.md +608 -0
- package/docs/command-reference.md +2022 -0
- package/docs/hooks-tts.md +368 -0
- package/docs/implementation-guide.md +810 -0
- package/docs/linear-github-integration.md +247 -0
- package/docs/local-issues.md +677 -0
- package/docs/patterns/README.md +419 -0
- package/docs/planning-responsibilities.md +139 -0
- package/docs/session-workflow.md +573 -0
- package/docs/simplification-plan-template.md +297 -0
- package/docs/simplification-principles.md +129 -0
- package/docs/specifications/CCS-RALPH-INTEGRATION-DESIGN.md +633 -0
- package/docs/specifications/CCS-RESEARCH-REPORT.md +169 -0
- package/docs/specifications/PLAN-ANV-verification-ralph-wiggum.md +403 -0
- package/docs/specifications/PLAN-parallel-tracks-anvil-memory-ccs.md +494 -0
- package/docs/specifications/SPEC-ANV-VRW/component-01-verify.md +208 -0
- package/docs/specifications/SPEC-ANV-VRW/component-02-stop-gate.md +226 -0
- package/docs/specifications/SPEC-ANV-VRW/component-03-posttooluse.md +209 -0
- package/docs/specifications/SPEC-ANV-VRW/component-04-ralph-wiggum.md +604 -0
- package/docs/specifications/SPEC-ANV-VRW/component-05-atomic-actions.md +311 -0
- package/docs/specifications/SPEC-ANV-VRW/component-06-verify-subagent.md +264 -0
- package/docs/specifications/SPEC-ANV-VRW/component-07-claude-md.md +363 -0
- package/docs/specifications/SPEC-ANV-VRW/index.md +182 -0
- package/docs/specifications/SPEC-ANV-anvil-memory.md +573 -0
- package/docs/specifications/SPEC-ANV-context-checkpoints.md +781 -0
- package/docs/specifications/SPEC-ANV-verification-ralph-wiggum.md +789 -0
- package/docs/sync.md +122 -0
- package/global/CLAUDE.md +140 -0
- package/global/agents/verify-app.md +164 -0
- package/global/commands/anvil-settings.md +527 -0
- package/global/commands/anvil-sync.md +121 -0
- package/global/commands/change.md +197 -0
- package/global/commands/clarify.md +252 -0
- package/global/commands/cleanup.md +292 -0
- package/global/commands/commit-push-pr.md +207 -0
- package/global/commands/decay-review.md +127 -0
- package/global/commands/discover.md +158 -0
- package/global/commands/doc-coverage.md +122 -0
- package/global/commands/evidence.md +307 -0
- package/global/commands/explore.md +121 -0
- package/global/commands/force-exit.md +135 -0
- package/global/commands/handoff.md +191 -0
- package/global/commands/healthcheck.md +302 -0
- package/global/commands/hud.md +84 -0
- package/global/commands/insights.md +319 -0
- package/global/commands/linear-setup.md +184 -0
- package/global/commands/lint-fix.md +198 -0
- package/global/commands/orient.md +510 -0
- package/global/commands/plan.md +228 -0
- package/global/commands/ralph.md +346 -0
- package/global/commands/ready.md +182 -0
- package/global/commands/release.md +305 -0
- package/global/commands/retro.md +96 -0
- package/global/commands/shard.md +166 -0
- package/global/commands/spec.md +227 -0
- package/global/commands/sprint.md +184 -0
- package/global/commands/tasks.md +228 -0
- package/global/commands/test-and-commit.md +151 -0
- package/global/commands/validate.md +132 -0
- package/global/commands/verify.md +251 -0
- package/global/commands/weekly-review.md +156 -0
- package/global/hooks/__pycache__/ralph_context_monitor.cpython-314.pyc +0 -0
- package/global/hooks/__pycache__/statusline_agent_sync.cpython-314.pyc +0 -0
- package/global/hooks/anvil_memory_observe.ts +322 -0
- package/global/hooks/anvil_memory_session.ts +166 -0
- package/global/hooks/anvil_memory_stop.ts +187 -0
- package/global/hooks/parse_transcript.py +116 -0
- package/global/hooks/post_merge_cleanup.sh +132 -0
- package/global/hooks/post_tool_format.sh +215 -0
- package/global/hooks/ralph_context_monitor.py +240 -0
- package/global/hooks/ralph_stop.sh +502 -0
- package/global/hooks/statusline.sh +1110 -0
- package/global/hooks/statusline_agent_sync.py +224 -0
- package/global/hooks/stop_gate.sh +250 -0
- package/global/lib/.claude/anvil-state.json +21 -0
- package/global/lib/__pycache__/agent_registry.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/claim_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/coderabbit_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/config_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/coordination_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/doc_coverage_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/gate_logger.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/github_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/hygiene_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/issue_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/linear_data_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/local_provider.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/quality_service.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/transcript_parser.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verification_runner.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verify_iteration.cpython-314.pyc +0 -0
- package/global/lib/__pycache__/verify_subagent.cpython-314.pyc +0 -0
- package/global/lib/agent_registry.py +995 -0
- package/global/lib/anvil-state.sh +435 -0
- package/global/lib/claim_service.py +515 -0
- package/global/lib/coderabbit_service.py +314 -0
- package/global/lib/config_service.py +423 -0
- package/global/lib/coordination_service.py +331 -0
- package/global/lib/doc_coverage_service.py +1305 -0
- package/global/lib/gate_logger.py +316 -0
- package/global/lib/github_service.py +310 -0
- package/global/lib/handoff_generator.py +775 -0
- package/global/lib/hygiene_service.py +712 -0
- package/global/lib/issue_models.py +257 -0
- package/global/lib/issue_provider.py +339 -0
- package/global/lib/linear_data_service.py +210 -0
- package/global/lib/linear_provider.py +987 -0
- package/global/lib/linear_provider.py.backup +671 -0
- package/global/lib/local_provider.py +486 -0
- package/global/lib/orient_fast.py +457 -0
- package/global/lib/quality_service.py +470 -0
- package/global/lib/ralph_prompt_generator.py +563 -0
- package/global/lib/ralph_state.py +1202 -0
- package/global/lib/state_manager.py +417 -0
- package/global/lib/transcript_parser.py +597 -0
- package/global/lib/verification_runner.py +557 -0
- package/global/lib/verify_iteration.py +490 -0
- package/global/lib/verify_subagent.py +250 -0
- package/global/skills/README.md +155 -0
- package/global/skills/quality-gates/SKILL.md +252 -0
- package/global/skills/skill-template/SKILL.md +109 -0
- package/global/skills/testing-strategies/SKILL.md +337 -0
- package/global/templates/CHANGE-template.md +105 -0
- package/global/templates/HANDOFF-template.md +63 -0
- package/global/templates/PLAN-template.md +111 -0
- package/global/templates/SPEC-template.md +93 -0
- package/global/templates/ralph/PROMPT.md.template +89 -0
- package/global/templates/ralph/fix_plan.md.template +31 -0
- package/global/templates/ralph/progress.txt.template +23 -0
- package/global/tests/__pycache__/test_doc_coverage.cpython-314.pyc +0 -0
- package/global/tests/test_doc_coverage.py +520 -0
- package/global/tests/test_issue_models.py +299 -0
- package/global/tests/test_local_provider.py +323 -0
- package/global/tools/README.md +178 -0
- package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
- package/global/tools/anvil-hud.py +3622 -0
- package/global/tools/anvil-hud.py.bak +3318 -0
- package/global/tools/anvil-issue.py +432 -0
- package/global/tools/anvil-memory/CLAUDE.md +49 -0
- package/global/tools/anvil-memory/README.md +42 -0
- package/global/tools/anvil-memory/bun.lock +25 -0
- package/global/tools/anvil-memory/bunfig.toml +9 -0
- package/global/tools/anvil-memory/package.json +23 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +535 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/edge-cases.test.ts +645 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +363 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +8 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/integration.test.ts +417 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +571 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +440 -0
- package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +252 -0
- package/global/tools/anvil-memory/src/__tests__/commands.test.ts +657 -0
- package/global/tools/anvil-memory/src/__tests__/db.test.ts +641 -0
- package/global/tools/anvil-memory/src/__tests__/hooks.test.ts +272 -0
- package/global/tools/anvil-memory/src/__tests__/performance.test.ts +427 -0
- package/global/tools/anvil-memory/src/__tests__/test-utils.ts +113 -0
- package/global/tools/anvil-memory/src/commands/checkpoint.ts +197 -0
- package/global/tools/anvil-memory/src/commands/get.ts +115 -0
- package/global/tools/anvil-memory/src/commands/init.ts +94 -0
- package/global/tools/anvil-memory/src/commands/observe.ts +163 -0
- package/global/tools/anvil-memory/src/commands/search.ts +112 -0
- package/global/tools/anvil-memory/src/db.ts +638 -0
- package/global/tools/anvil-memory/src/index.ts +205 -0
- package/global/tools/anvil-memory/src/types.ts +122 -0
- package/global/tools/anvil-memory/tsconfig.json +29 -0
- package/global/tools/ralph-loop.sh +359 -0
- package/package.json +45 -0
- package/scripts/anvil +822 -0
- package/scripts/extract_patterns.py +222 -0
- package/scripts/init-project.sh +541 -0
- package/scripts/install.sh +229 -0
- package/scripts/postinstall.js +41 -0
- package/scripts/rollback.sh +188 -0
- package/scripts/sync.sh +623 -0
- package/scripts/test-statusline.sh +248 -0
- package/scripts/update_claude_md.py +224 -0
- package/scripts/verify.sh +255 -0
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
agent_registry.py - Global agent state management for Anvil HUD
|
|
4
|
+
|
|
5
|
+
Manages the shared agent registry at ~/.anvil/agents.json that enables
|
|
6
|
+
cross-terminal visibility of all active Claude Code sessions.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from agent_registry import AgentRegistry
|
|
10
|
+
|
|
11
|
+
# Register an agent
|
|
12
|
+
registry = AgentRegistry()
|
|
13
|
+
registry.register(agent_id="agent-alpha", project="/path/to/project", ...)
|
|
14
|
+
|
|
15
|
+
# Update an agent
|
|
16
|
+
registry.update(agent_id="agent-alpha", context_usage=32, phase="implement")
|
|
17
|
+
|
|
18
|
+
# Deregister an agent
|
|
19
|
+
registry.deregister(agent_id="agent-alpha")
|
|
20
|
+
|
|
21
|
+
# Get all agents
|
|
22
|
+
agents = registry.get_all()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import fcntl
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import signal
|
|
29
|
+
import tempfile
|
|
30
|
+
import time
|
|
31
|
+
from contextlib import contextmanager
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, Dict, Generator, List, Optional, TypedDict
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def process_exists(pid: int) -> bool:
|
|
38
|
+
"""Check if a process with the given PID is still running.
|
|
39
|
+
|
|
40
|
+
Uses signal 0 which doesn't actually send a signal but checks
|
|
41
|
+
if the process exists and we have permission to send signals to it.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
pid: Process ID to check
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
True if process exists, False otherwise
|
|
48
|
+
"""
|
|
49
|
+
if pid <= 0:
|
|
50
|
+
return False
|
|
51
|
+
try:
|
|
52
|
+
os.kill(pid, 0) # Signal 0 just checks existence
|
|
53
|
+
return True
|
|
54
|
+
except ProcessLookupError:
|
|
55
|
+
return False # Process doesn't exist
|
|
56
|
+
except PermissionError:
|
|
57
|
+
return True # Process exists but we can't signal it
|
|
58
|
+
except OSError:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# =============================================================================
|
|
63
|
+
# File Locking (ANV-223)
|
|
64
|
+
# =============================================================================
|
|
65
|
+
|
|
66
|
+
# Global lock file path
|
|
67
|
+
LOCK_FILE = Path.home() / ".anvil" / "agents.lock"
|
|
68
|
+
CODENAME_MAP_FILE = Path.home() / ".anvil" / "codename_map.json"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@contextmanager
|
|
72
|
+
def registry_lock(timeout: float = 5.0) -> Generator[None, None, None]:
|
|
73
|
+
"""Context manager for atomic registry operations using file locking.
|
|
74
|
+
|
|
75
|
+
Uses fcntl.flock for advisory locking. This ensures that multiple
|
|
76
|
+
agents registering simultaneously won't get the same codename.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
timeout: Maximum seconds to wait for lock (default 5s)
|
|
80
|
+
|
|
81
|
+
Yields:
|
|
82
|
+
None when lock is acquired
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
TimeoutError: If lock cannot be acquired within timeout
|
|
86
|
+
"""
|
|
87
|
+
LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
|
|
89
|
+
lock_fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_RDWR)
|
|
90
|
+
try:
|
|
91
|
+
start_time = time.time()
|
|
92
|
+
while True:
|
|
93
|
+
try:
|
|
94
|
+
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
95
|
+
break # Lock acquired
|
|
96
|
+
except BlockingIOError:
|
|
97
|
+
if time.time() - start_time > timeout:
|
|
98
|
+
raise TimeoutError(f"Could not acquire registry lock within {timeout}s")
|
|
99
|
+
time.sleep(0.05) # Wait 50ms and retry
|
|
100
|
+
|
|
101
|
+
yield
|
|
102
|
+
finally:
|
|
103
|
+
fcntl.flock(lock_fd, fcntl.LOCK_UN)
|
|
104
|
+
os.close(lock_fd)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _load_codename_map() -> Dict[str, str]:
|
|
108
|
+
"""Load the persistent codename mapping (agent_id -> codename).
|
|
109
|
+
|
|
110
|
+
This ensures agents keep the same codename across re-registrations
|
|
111
|
+
(e.g., after context compaction causes SessionStart to re-run).
|
|
112
|
+
"""
|
|
113
|
+
if not CODENAME_MAP_FILE.exists():
|
|
114
|
+
return {}
|
|
115
|
+
try:
|
|
116
|
+
return json.loads(CODENAME_MAP_FILE.read_text())
|
|
117
|
+
except (json.JSONDecodeError, IOError):
|
|
118
|
+
return {}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _save_codename_map(mapping: Dict[str, str]) -> None:
|
|
122
|
+
"""Save the persistent codename mapping."""
|
|
123
|
+
CODENAME_MAP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
CODENAME_MAP_FILE.write_text(json.dumps(mapping, indent=2))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _cleanup_codename_map(active_agents: Dict[str, Any]) -> None:
|
|
128
|
+
"""Remove stale entries from codename map.
|
|
129
|
+
|
|
130
|
+
Called during registration to clean up codenames for agents
|
|
131
|
+
that are no longer in the registry.
|
|
132
|
+
"""
|
|
133
|
+
codename_map = _load_codename_map()
|
|
134
|
+
active_ids = set(active_agents.keys())
|
|
135
|
+
|
|
136
|
+
# Only keep mappings for currently active agents
|
|
137
|
+
cleaned = {aid: cn for aid, cn in codename_map.items() if aid in active_ids}
|
|
138
|
+
|
|
139
|
+
if len(cleaned) != len(codename_map):
|
|
140
|
+
_save_codename_map(cleaned)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# =============================================================================
|
|
144
|
+
# Cost Tracking Schema (ANV-90)
|
|
145
|
+
# =============================================================================
|
|
146
|
+
|
|
147
|
+
class TokenUsage(TypedDict, total=False):
|
|
148
|
+
"""Token usage breakdown by type."""
|
|
149
|
+
input: int # Input tokens (prompts)
|
|
150
|
+
output: int # Output tokens (responses)
|
|
151
|
+
cache_read: int # Cached tokens read (lower cost)
|
|
152
|
+
cache_write: int # Cached tokens written
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class SessionCost(TypedDict, total=False):
|
|
156
|
+
"""Cost information for a session."""
|
|
157
|
+
tokens: TokenUsage # Token breakdown
|
|
158
|
+
cost_usd: float # Total calculated cost in USD
|
|
159
|
+
last_updated: str # ISO timestamp of last update
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class CostAttribution(TypedDict, total=False):
|
|
163
|
+
"""Cost attributed to a specific issue."""
|
|
164
|
+
issue_id: str # Linear issue ID (e.g., "ANV-45")
|
|
165
|
+
cost_usd: float # Cost attributed to this issue
|
|
166
|
+
tokens: int # Tokens used for this issue
|
|
167
|
+
started_at: str # When work on issue started
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class CostData(TypedDict, total=False):
|
|
171
|
+
"""Complete cost tracking data for an agent."""
|
|
172
|
+
session: SessionCost # Current session costs
|
|
173
|
+
attributed: Dict[str, CostAttribution] # Cost by issue ID
|
|
174
|
+
daily_total: float # Accumulated daily cost
|
|
175
|
+
weekly_total: float # Accumulated weekly cost
|
|
176
|
+
last_issue: Optional[str] # Last active issue for attribution
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# =============================================================================
|
|
180
|
+
# Context Tracking Schema (ANV-98)
|
|
181
|
+
# =============================================================================
|
|
182
|
+
|
|
183
|
+
class ContextMetrics(TypedDict, total=False):
|
|
184
|
+
"""Context window metrics for compaction estimation."""
|
|
185
|
+
usage_history: List[int] # Rolling history of context usage values
|
|
186
|
+
delta_history: List[int] # Rolling history of usage deltas per turn
|
|
187
|
+
avg_tokens_per_turn: float # Calculated average tokens per turn
|
|
188
|
+
estimated_turns: Optional[int] # Estimated turns until compaction
|
|
189
|
+
last_usage: int # Last recorded context usage
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def estimate_turns_until_compaction(
|
|
193
|
+
context_usage: int,
|
|
194
|
+
context_limit: int,
|
|
195
|
+
delta_history: List[int],
|
|
196
|
+
min_samples: int = 3,
|
|
197
|
+
) -> Optional[int]:
|
|
198
|
+
"""Estimate turns until context compaction.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
context_usage: Current context token usage
|
|
202
|
+
context_limit: Maximum context window size
|
|
203
|
+
delta_history: List of recent usage deltas per turn
|
|
204
|
+
min_samples: Minimum samples needed for estimation
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Estimated turns until compaction, or None if insufficient data
|
|
208
|
+
"""
|
|
209
|
+
if len(delta_history) < min_samples:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
# Use last 10 deltas for rolling average
|
|
213
|
+
recent_deltas = delta_history[-10:]
|
|
214
|
+
positive_deltas = [d for d in recent_deltas if d > 0]
|
|
215
|
+
|
|
216
|
+
if not positive_deltas:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
avg_per_turn = sum(positive_deltas) / len(positive_deltas)
|
|
220
|
+
if avg_per_turn <= 0:
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
remaining = context_limit - context_usage
|
|
224
|
+
if remaining <= 0:
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
return int(remaining / avg_per_turn)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Claude API Pricing (per million tokens) - Updated Jan 2026
|
|
231
|
+
CLAUDE_PRICING = {
|
|
232
|
+
"Opus": {
|
|
233
|
+
"input": 15.00,
|
|
234
|
+
"output": 75.00,
|
|
235
|
+
"cache_write": 18.75,
|
|
236
|
+
"cache_read": 1.50,
|
|
237
|
+
},
|
|
238
|
+
"Sonnet": {
|
|
239
|
+
"input": 3.00,
|
|
240
|
+
"output": 15.00,
|
|
241
|
+
"cache_write": 3.75,
|
|
242
|
+
"cache_read": 0.30,
|
|
243
|
+
},
|
|
244
|
+
"Haiku": {
|
|
245
|
+
"input": 0.80,
|
|
246
|
+
"output": 4.00,
|
|
247
|
+
"cache_write": 1.00,
|
|
248
|
+
"cache_read": 0.08,
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def calculate_cost(tokens: TokenUsage, model: str = "Sonnet") -> float:
|
|
254
|
+
"""Calculate cost in USD from token usage.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
tokens: Token usage breakdown
|
|
258
|
+
model: Model name (Opus, Sonnet, Haiku)
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Cost in USD
|
|
262
|
+
"""
|
|
263
|
+
pricing = CLAUDE_PRICING.get(model, CLAUDE_PRICING["Sonnet"])
|
|
264
|
+
|
|
265
|
+
cost = 0.0
|
|
266
|
+
cost += (tokens.get("input", 0) / 1_000_000) * pricing["input"]
|
|
267
|
+
cost += (tokens.get("output", 0) / 1_000_000) * pricing["output"]
|
|
268
|
+
cost += (tokens.get("cache_write", 0) / 1_000_000) * pricing["cache_write"]
|
|
269
|
+
cost += (tokens.get("cache_read", 0) / 1_000_000) * pricing["cache_read"]
|
|
270
|
+
|
|
271
|
+
return round(cost, 4)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def get_next_codename(existing_agents: Dict[str, Any], agent_id: Optional[str] = None) -> str:
|
|
275
|
+
"""Assign next available codename (A1, A2, A3...) with persistence (ANV-223).
|
|
276
|
+
|
|
277
|
+
Uses a two-layer approach:
|
|
278
|
+
1. Check persistent codename map - if agent has a saved codename, reuse it
|
|
279
|
+
2. If new agent, find lowest unused number and persist the mapping
|
|
280
|
+
|
|
281
|
+
This ensures agents keep stable identities across re-registrations
|
|
282
|
+
(e.g., after context compaction).
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
existing_agents: Current agents dict from registry
|
|
286
|
+
agent_id: Optional agent ID for persistent codename lookup
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Codename like "A1", "A2", etc.
|
|
290
|
+
"""
|
|
291
|
+
# ANV-223: Check persistent mapping first
|
|
292
|
+
if agent_id:
|
|
293
|
+
codename_map = _load_codename_map()
|
|
294
|
+
if agent_id in codename_map:
|
|
295
|
+
return codename_map[agent_id]
|
|
296
|
+
|
|
297
|
+
# Collect used codenames from both active agents and persistent map
|
|
298
|
+
used_numbers: set[int] = set()
|
|
299
|
+
|
|
300
|
+
# From active agents
|
|
301
|
+
for agent in existing_agents.values():
|
|
302
|
+
codename = agent.get("codename", "")
|
|
303
|
+
if codename.startswith("A") and codename[1:].isdigit():
|
|
304
|
+
used_numbers.add(int(codename[1:]))
|
|
305
|
+
|
|
306
|
+
# From persistent map (in case an agent is mid-registration)
|
|
307
|
+
codename_map = _load_codename_map()
|
|
308
|
+
for codename in codename_map.values():
|
|
309
|
+
if codename.startswith("A") and codename[1:].isdigit():
|
|
310
|
+
used_numbers.add(int(codename[1:]))
|
|
311
|
+
|
|
312
|
+
# Find lowest available number starting from 1
|
|
313
|
+
next_num = 1
|
|
314
|
+
while next_num in used_numbers:
|
|
315
|
+
next_num += 1
|
|
316
|
+
|
|
317
|
+
new_codename = f"A{next_num}"
|
|
318
|
+
|
|
319
|
+
# ANV-223: Persist the mapping
|
|
320
|
+
if agent_id:
|
|
321
|
+
codename_map[agent_id] = new_codename
|
|
322
|
+
_save_codename_map(codename_map)
|
|
323
|
+
|
|
324
|
+
return new_codename
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def create_empty_cost_data() -> CostData:
|
|
328
|
+
"""Create an empty cost data structure."""
|
|
329
|
+
return {
|
|
330
|
+
"session": {
|
|
331
|
+
"tokens": {
|
|
332
|
+
"input": 0,
|
|
333
|
+
"output": 0,
|
|
334
|
+
"cache_read": 0,
|
|
335
|
+
"cache_write": 0,
|
|
336
|
+
},
|
|
337
|
+
"cost_usd": 0.0,
|
|
338
|
+
"last_updated": datetime.now(timezone.utc).isoformat(),
|
|
339
|
+
},
|
|
340
|
+
"attributed": {},
|
|
341
|
+
"daily_total": 0.0,
|
|
342
|
+
"weekly_total": 0.0,
|
|
343
|
+
"last_issue": None,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# =============================================================================
|
|
348
|
+
# Agent Registry
|
|
349
|
+
# =============================================================================
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class AgentRegistry:
|
|
353
|
+
"""Manages the global agent registry for multi-agent visibility."""
|
|
354
|
+
|
|
355
|
+
VERSION = "1.0"
|
|
356
|
+
STALE_TIMEOUT_MINUTES = 1 # Reduced from 5 for accurate agent counts (ANV-221)
|
|
357
|
+
|
|
358
|
+
def __init__(self, base_dir: Optional[str] = None):
|
|
359
|
+
"""Initialize the registry.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
base_dir: Override base directory (default: ~/.anvil)
|
|
363
|
+
"""
|
|
364
|
+
if base_dir:
|
|
365
|
+
self.base_dir = Path(base_dir)
|
|
366
|
+
else:
|
|
367
|
+
self.base_dir = Path.home() / ".anvil"
|
|
368
|
+
|
|
369
|
+
self.registry_file = self.base_dir / "agents.json"
|
|
370
|
+
self.state_dir = self.base_dir / "state"
|
|
371
|
+
|
|
372
|
+
# Ensure directories exist
|
|
373
|
+
self.base_dir.mkdir(parents=True, exist_ok=True)
|
|
374
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
|
|
376
|
+
def _atomic_write(self, filepath: Path, data: Dict[str, Any]) -> None:
|
|
377
|
+
"""Write data to file atomically (write temp, then rename)."""
|
|
378
|
+
dir_path = filepath.parent
|
|
379
|
+
dir_path.mkdir(parents=True, exist_ok=True)
|
|
380
|
+
|
|
381
|
+
with tempfile.NamedTemporaryFile(
|
|
382
|
+
mode='w',
|
|
383
|
+
dir=str(dir_path),
|
|
384
|
+
delete=False,
|
|
385
|
+
suffix='.tmp'
|
|
386
|
+
) as tmp:
|
|
387
|
+
json.dump(data, tmp, indent=2)
|
|
388
|
+
tmp_path = tmp.name
|
|
389
|
+
|
|
390
|
+
os.rename(tmp_path, str(filepath))
|
|
391
|
+
|
|
392
|
+
def _read_registry(self) -> Dict[str, Any]:
|
|
393
|
+
"""Read the registry file, creating if it doesn't exist."""
|
|
394
|
+
if not self.registry_file.exists():
|
|
395
|
+
return {
|
|
396
|
+
"version": self.VERSION,
|
|
397
|
+
"lastUpdated": datetime.now(timezone.utc).isoformat(),
|
|
398
|
+
"agents": {}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
with open(self.registry_file, 'r') as f:
|
|
403
|
+
return json.load(f)
|
|
404
|
+
except (json.JSONDecodeError, IOError):
|
|
405
|
+
# Corrupted file, return fresh
|
|
406
|
+
return {
|
|
407
|
+
"version": self.VERSION,
|
|
408
|
+
"lastUpdated": datetime.now(timezone.utc).isoformat(),
|
|
409
|
+
"agents": {}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
def _write_registry(self, data: Dict[str, Any]) -> None:
|
|
413
|
+
"""Write the registry file atomically."""
|
|
414
|
+
data["lastUpdated"] = datetime.now(timezone.utc).isoformat()
|
|
415
|
+
self._atomic_write(self.registry_file, data)
|
|
416
|
+
|
|
417
|
+
def _update_cost_data(
|
|
418
|
+
self,
|
|
419
|
+
agent: Dict[str, Any],
|
|
420
|
+
tokens_input: int,
|
|
421
|
+
tokens_output: int,
|
|
422
|
+
tokens_cache_read: int,
|
|
423
|
+
tokens_cache_write: int,
|
|
424
|
+
model: str,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""Update agent's cost tracking data with new token usage.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
agent: Agent data dict to update (mutated in place)
|
|
430
|
+
tokens_input: Input tokens from this request
|
|
431
|
+
tokens_output: Output tokens from this request
|
|
432
|
+
tokens_cache_read: Cache read tokens
|
|
433
|
+
tokens_cache_write: Cache write tokens
|
|
434
|
+
model: Model name for pricing lookup
|
|
435
|
+
"""
|
|
436
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
437
|
+
|
|
438
|
+
# Ensure cost structure exists (backward compat for old agents)
|
|
439
|
+
if "cost" not in agent:
|
|
440
|
+
agent["cost"] = create_empty_cost_data()
|
|
441
|
+
|
|
442
|
+
cost_data = agent["cost"]
|
|
443
|
+
|
|
444
|
+
# Ensure session structure exists
|
|
445
|
+
if "session" not in cost_data:
|
|
446
|
+
cost_data["session"] = {
|
|
447
|
+
"tokens": {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0},
|
|
448
|
+
"cost_usd": 0.0,
|
|
449
|
+
"last_updated": now,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
session = cost_data["session"]
|
|
453
|
+
tokens = session.get("tokens", {})
|
|
454
|
+
|
|
455
|
+
# Accumulate token counts
|
|
456
|
+
tokens["input"] = tokens.get("input", 0) + tokens_input
|
|
457
|
+
tokens["output"] = tokens.get("output", 0) + tokens_output
|
|
458
|
+
tokens["cache_read"] = tokens.get("cache_read", 0) + tokens_cache_read
|
|
459
|
+
tokens["cache_write"] = tokens.get("cache_write", 0) + tokens_cache_write
|
|
460
|
+
session["tokens"] = tokens
|
|
461
|
+
|
|
462
|
+
# Calculate total session cost
|
|
463
|
+
session["cost_usd"] = calculate_cost(tokens, model)
|
|
464
|
+
session["last_updated"] = now
|
|
465
|
+
|
|
466
|
+
# Update backward-compat sessionCost field
|
|
467
|
+
agent["sessionCost"] = session["cost_usd"]
|
|
468
|
+
|
|
469
|
+
# Attribute cost to current issue if set
|
|
470
|
+
current_issue = cost_data.get("last_issue") or agent.get("issue")
|
|
471
|
+
if current_issue:
|
|
472
|
+
if "attributed" not in cost_data:
|
|
473
|
+
cost_data["attributed"] = {}
|
|
474
|
+
|
|
475
|
+
if current_issue not in cost_data["attributed"]:
|
|
476
|
+
cost_data["attributed"][current_issue] = {
|
|
477
|
+
"issue_id": current_issue,
|
|
478
|
+
"cost_usd": 0.0,
|
|
479
|
+
"tokens": 0,
|
|
480
|
+
"started_at": now,
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
attr = cost_data["attributed"][current_issue]
|
|
484
|
+
request_tokens = tokens_input + tokens_output + tokens_cache_read + tokens_cache_write
|
|
485
|
+
request_cost = calculate_cost({
|
|
486
|
+
"input": tokens_input,
|
|
487
|
+
"output": tokens_output,
|
|
488
|
+
"cache_read": tokens_cache_read,
|
|
489
|
+
"cache_write": tokens_cache_write,
|
|
490
|
+
}, model)
|
|
491
|
+
|
|
492
|
+
attr["tokens"] = attr.get("tokens", 0) + request_tokens
|
|
493
|
+
attr["cost_usd"] = round(attr.get("cost_usd", 0) + request_cost, 4)
|
|
494
|
+
|
|
495
|
+
def _cleanup_stale(self, agents: Dict[str, Any]) -> Dict[str, Any]:
|
|
496
|
+
"""Remove agents that are stale or whose process has died (ANV-221).
|
|
497
|
+
|
|
498
|
+
Uses a multi-layered approach:
|
|
499
|
+
1. PID check: If agent has a PID and that process is dead, remove it
|
|
500
|
+
2. Heartbeat check: If lastHeartbeat is older than timeout, remove it
|
|
501
|
+
3. Fallback to lastActivity if no heartbeat (backward compat)
|
|
502
|
+
"""
|
|
503
|
+
now = datetime.now(timezone.utc)
|
|
504
|
+
cleaned = {}
|
|
505
|
+
|
|
506
|
+
for agent_id, agent in agents.items():
|
|
507
|
+
# ANV-221: Check if process is still alive first
|
|
508
|
+
pid = agent.get('pid')
|
|
509
|
+
if pid and not process_exists(pid):
|
|
510
|
+
# Process has died - this is a ghost agent
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
# Check heartbeat/activity timeout
|
|
514
|
+
try:
|
|
515
|
+
# Prefer lastHeartbeat (ANV-221), fall back to lastActivity
|
|
516
|
+
timestamp_str = agent.get('lastHeartbeat') or agent.get('lastActivity', '')
|
|
517
|
+
if not timestamp_str:
|
|
518
|
+
# No timestamp at all, keep the agent
|
|
519
|
+
cleaned[agent_id] = agent
|
|
520
|
+
continue
|
|
521
|
+
|
|
522
|
+
last_seen = datetime.fromisoformat(
|
|
523
|
+
timestamp_str.replace('Z', '+00:00')
|
|
524
|
+
)
|
|
525
|
+
age_minutes = (now - last_seen).total_seconds() / 60
|
|
526
|
+
|
|
527
|
+
if age_minutes < self.STALE_TIMEOUT_MINUTES:
|
|
528
|
+
cleaned[agent_id] = agent
|
|
529
|
+
except (ValueError, TypeError):
|
|
530
|
+
# Invalid timestamp, keep the agent
|
|
531
|
+
cleaned[agent_id] = agent
|
|
532
|
+
|
|
533
|
+
return cleaned
|
|
534
|
+
|
|
535
|
+
def register(
|
|
536
|
+
self,
|
|
537
|
+
agent_id: str,
|
|
538
|
+
project: str,
|
|
539
|
+
session_id: Optional[str] = None,
|
|
540
|
+
model: str = "Claude",
|
|
541
|
+
issue: Optional[str] = None,
|
|
542
|
+
phase: Optional[str] = None,
|
|
543
|
+
) -> None:
|
|
544
|
+
"""Register a new agent or update existing.
|
|
545
|
+
|
|
546
|
+
Uses file locking (ANV-223) to ensure atomic codename assignment
|
|
547
|
+
and prevent race conditions when multiple agents register simultaneously.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
agent_id: Unique identifier for this agent
|
|
551
|
+
project: Absolute path to the project directory
|
|
552
|
+
session_id: Claude Code session ID
|
|
553
|
+
model: Model name (Opus, Sonnet, etc.)
|
|
554
|
+
issue: Active Linear issue key
|
|
555
|
+
phase: Current workflow phase
|
|
556
|
+
"""
|
|
557
|
+
# ANV-223: Use file locking for atomic registration
|
|
558
|
+
with registry_lock():
|
|
559
|
+
registry = self._read_registry()
|
|
560
|
+
|
|
561
|
+
# Cleanup stale agents
|
|
562
|
+
registry["agents"] = self._cleanup_stale(registry["agents"])
|
|
563
|
+
|
|
564
|
+
# ANV-223: Clean up codename map for removed agents
|
|
565
|
+
_cleanup_codename_map(registry["agents"])
|
|
566
|
+
|
|
567
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
568
|
+
project_name = Path(project).name if project else "unknown"
|
|
569
|
+
|
|
570
|
+
# ANV-223: Assign codename with persistence (pass agent_id for stable identity)
|
|
571
|
+
codename = get_next_codename(registry["agents"], agent_id)
|
|
572
|
+
|
|
573
|
+
# Initialize cost data structure
|
|
574
|
+
cost_data = create_empty_cost_data()
|
|
575
|
+
if issue:
|
|
576
|
+
cost_data["last_issue"] = issue
|
|
577
|
+
|
|
578
|
+
# Initialize context metrics for compaction estimation (ANV-98)
|
|
579
|
+
context_metrics: ContextMetrics = {
|
|
580
|
+
"usage_history": [],
|
|
581
|
+
"delta_history": [],
|
|
582
|
+
"avg_tokens_per_turn": 0.0,
|
|
583
|
+
"estimated_turns": None,
|
|
584
|
+
"last_usage": 0,
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
registry["agents"][agent_id] = {
|
|
588
|
+
"id": agent_id,
|
|
589
|
+
"codename": codename, # Short identifier (A1, A2, A3...) for multi-agent visibility
|
|
590
|
+
"project": project,
|
|
591
|
+
"projectName": project_name,
|
|
592
|
+
"sessionId": session_id,
|
|
593
|
+
"issue": issue,
|
|
594
|
+
"phase": phase,
|
|
595
|
+
"model": model,
|
|
596
|
+
"contextUsage": 0,
|
|
597
|
+
"contextLimit": 200000,
|
|
598
|
+
"sessionCost": 0.0, # Backward compat: simple total
|
|
599
|
+
"cost": cost_data, # New: detailed cost tracking (ANV-90)
|
|
600
|
+
"context": context_metrics, # New: compaction estimation (ANV-98)
|
|
601
|
+
"estimatedTurns": None, # Convenience field for HUD
|
|
602
|
+
"pid": os.getpid(), # ANV-221: Track process ID for crash detection
|
|
603
|
+
"lastHeartbeat": now, # ANV-221: Separate heartbeat tracking
|
|
604
|
+
"lastActivity": now,
|
|
605
|
+
"startedAt": now,
|
|
606
|
+
"status": "active"
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
self._write_registry(registry)
|
|
610
|
+
|
|
611
|
+
# Also create detailed state file
|
|
612
|
+
self._write_detailed_state(agent_id, {
|
|
613
|
+
"version": self.VERSION,
|
|
614
|
+
"agentId": agent_id,
|
|
615
|
+
"codename": codename,
|
|
616
|
+
"session": {
|
|
617
|
+
"id": session_id,
|
|
618
|
+
"startedAt": now,
|
|
619
|
+
},
|
|
620
|
+
"workflow": {
|
|
621
|
+
"phase": phase,
|
|
622
|
+
"activeIssue": issue,
|
|
623
|
+
},
|
|
624
|
+
"metrics": {
|
|
625
|
+
"model": model,
|
|
626
|
+
"contextUsage": 0,
|
|
627
|
+
"contextLimit": 200000,
|
|
628
|
+
"sessionCost": 0.0,
|
|
629
|
+
},
|
|
630
|
+
"recentTools": [],
|
|
631
|
+
"git": {}
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
def update(
|
|
635
|
+
self,
|
|
636
|
+
agent_id: str,
|
|
637
|
+
context_usage: Optional[int] = None,
|
|
638
|
+
context_limit: Optional[int] = None,
|
|
639
|
+
session_cost: Optional[float] = None,
|
|
640
|
+
phase: Optional[str] = None,
|
|
641
|
+
issue: Optional[str] = None,
|
|
642
|
+
tool_name: Optional[str] = None,
|
|
643
|
+
tool_input: Optional[Dict] = None,
|
|
644
|
+
tool_duration: Optional[int] = None,
|
|
645
|
+
model: Optional[str] = None,
|
|
646
|
+
git_branch: Optional[str] = None,
|
|
647
|
+
# New cost tracking parameters (ANV-90)
|
|
648
|
+
tokens_input: Optional[int] = None,
|
|
649
|
+
tokens_output: Optional[int] = None,
|
|
650
|
+
tokens_cache_read: Optional[int] = None,
|
|
651
|
+
tokens_cache_write: Optional[int] = None,
|
|
652
|
+
# Transcript path for tool activity (ANV-126)
|
|
653
|
+
transcript_path: Optional[str] = None,
|
|
654
|
+
**kwargs
|
|
655
|
+
) -> None:
|
|
656
|
+
"""Update an existing agent's state.
|
|
657
|
+
|
|
658
|
+
Args:
|
|
659
|
+
agent_id: Agent to update
|
|
660
|
+
context_usage: Current context tokens used
|
|
661
|
+
context_limit: Maximum context window size
|
|
662
|
+
session_cost: Total session cost in USD (deprecated, use tokens_*)
|
|
663
|
+
phase: Current workflow phase
|
|
664
|
+
issue: Active Linear issue key
|
|
665
|
+
tool_name: Name of tool just used
|
|
666
|
+
tool_input: Tool input parameters
|
|
667
|
+
tool_duration: Tool execution time in ms
|
|
668
|
+
model: Model name
|
|
669
|
+
git_branch: Current git branch
|
|
670
|
+
tokens_input: Input tokens from this request
|
|
671
|
+
tokens_output: Output tokens from this request
|
|
672
|
+
tokens_cache_read: Cache read tokens from this request
|
|
673
|
+
tokens_cache_write: Cache write tokens from this request
|
|
674
|
+
"""
|
|
675
|
+
registry = self._read_registry()
|
|
676
|
+
|
|
677
|
+
if agent_id not in registry["agents"]:
|
|
678
|
+
# Agent not registered, skip update
|
|
679
|
+
return
|
|
680
|
+
|
|
681
|
+
agent = registry["agents"][agent_id]
|
|
682
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
683
|
+
|
|
684
|
+
# Update fields if provided
|
|
685
|
+
if context_limit is not None:
|
|
686
|
+
agent["contextLimit"] = context_limit
|
|
687
|
+
if phase is not None:
|
|
688
|
+
agent["phase"] = phase
|
|
689
|
+
if model is not None:
|
|
690
|
+
agent["model"] = model
|
|
691
|
+
if transcript_path is not None:
|
|
692
|
+
agent["transcriptPath"] = transcript_path
|
|
693
|
+
|
|
694
|
+
# Update context usage and track deltas for compaction estimation (ANV-98)
|
|
695
|
+
if context_usage is not None:
|
|
696
|
+
old_usage = agent.get("contextUsage", 0)
|
|
697
|
+
agent["contextUsage"] = context_usage
|
|
698
|
+
|
|
699
|
+
# Track context metrics
|
|
700
|
+
ctx = agent.get("context")
|
|
701
|
+
if ctx is None:
|
|
702
|
+
ctx = {
|
|
703
|
+
"usage_history": [],
|
|
704
|
+
"delta_history": [],
|
|
705
|
+
"avg_tokens_per_turn": 0.0,
|
|
706
|
+
"estimated_turns": None,
|
|
707
|
+
"last_usage": 0,
|
|
708
|
+
}
|
|
709
|
+
agent["context"] = ctx
|
|
710
|
+
|
|
711
|
+
# Only track positive deltas (compaction resets to lower value)
|
|
712
|
+
if context_usage > old_usage:
|
|
713
|
+
delta = context_usage - old_usage
|
|
714
|
+
ctx["delta_history"].append(delta)
|
|
715
|
+
# Keep last 20 deltas
|
|
716
|
+
ctx["delta_history"] = ctx["delta_history"][-20:]
|
|
717
|
+
|
|
718
|
+
ctx["last_usage"] = context_usage
|
|
719
|
+
ctx["usage_history"].append(context_usage)
|
|
720
|
+
ctx["usage_history"] = ctx["usage_history"][-20:]
|
|
721
|
+
|
|
722
|
+
# Calculate estimation
|
|
723
|
+
limit = agent.get("contextLimit", 200000)
|
|
724
|
+
estimated = estimate_turns_until_compaction(
|
|
725
|
+
context_usage, limit, ctx["delta_history"]
|
|
726
|
+
)
|
|
727
|
+
ctx["estimated_turns"] = estimated
|
|
728
|
+
agent["estimatedTurns"] = estimated
|
|
729
|
+
|
|
730
|
+
# Calculate average
|
|
731
|
+
if ctx["delta_history"]:
|
|
732
|
+
positive = [d for d in ctx["delta_history"] if d > 0]
|
|
733
|
+
if positive:
|
|
734
|
+
ctx["avg_tokens_per_turn"] = sum(positive) / len(positive)
|
|
735
|
+
|
|
736
|
+
# Handle issue change with cost attribution
|
|
737
|
+
if issue is not None:
|
|
738
|
+
old_issue = agent.get("issue")
|
|
739
|
+
agent["issue"] = issue
|
|
740
|
+
|
|
741
|
+
# Track issue change for cost attribution
|
|
742
|
+
cost_data = agent.get("cost")
|
|
743
|
+
if cost_data and issue != old_issue:
|
|
744
|
+
cost_data["last_issue"] = issue
|
|
745
|
+
# Initialize attribution entry for new issue if not exists
|
|
746
|
+
if issue and issue not in cost_data.get("attributed", {}):
|
|
747
|
+
cost_data["attributed"][issue] = {
|
|
748
|
+
"issue_id": issue,
|
|
749
|
+
"cost_usd": 0.0,
|
|
750
|
+
"tokens": 0,
|
|
751
|
+
"started_at": now,
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
# Update detailed cost tracking (ANV-90)
|
|
755
|
+
has_token_data = any([
|
|
756
|
+
tokens_input is not None,
|
|
757
|
+
tokens_output is not None,
|
|
758
|
+
tokens_cache_read is not None,
|
|
759
|
+
tokens_cache_write is not None,
|
|
760
|
+
])
|
|
761
|
+
|
|
762
|
+
if has_token_data:
|
|
763
|
+
self._update_cost_data(
|
|
764
|
+
agent,
|
|
765
|
+
tokens_input=tokens_input or 0,
|
|
766
|
+
tokens_output=tokens_output or 0,
|
|
767
|
+
tokens_cache_read=tokens_cache_read or 0,
|
|
768
|
+
tokens_cache_write=tokens_cache_write or 0,
|
|
769
|
+
model=model or agent.get("model", "Sonnet"),
|
|
770
|
+
)
|
|
771
|
+
elif session_cost is not None:
|
|
772
|
+
# Backward compat: accept simple session_cost if no token data
|
|
773
|
+
agent["sessionCost"] = session_cost
|
|
774
|
+
|
|
775
|
+
# ANV-221: Update heartbeat timestamp on every update
|
|
776
|
+
agent["lastHeartbeat"] = now
|
|
777
|
+
agent["lastActivity"] = now
|
|
778
|
+
|
|
779
|
+
self._write_registry(registry)
|
|
780
|
+
|
|
781
|
+
# Update detailed state if we have tool info
|
|
782
|
+
if tool_name:
|
|
783
|
+
self._update_detailed_state(
|
|
784
|
+
agent_id,
|
|
785
|
+
tool_name=tool_name,
|
|
786
|
+
tool_input=tool_input,
|
|
787
|
+
tool_duration=tool_duration,
|
|
788
|
+
context_usage=context_usage,
|
|
789
|
+
context_limit=context_limit,
|
|
790
|
+
session_cost=session_cost,
|
|
791
|
+
phase=phase,
|
|
792
|
+
git_branch=git_branch
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
def deregister(self, agent_id: str) -> None:
|
|
796
|
+
"""Remove an agent from the registry.
|
|
797
|
+
|
|
798
|
+
Also cleans up:
|
|
799
|
+
- Persistent codename mapping (ANV-223)
|
|
800
|
+
- Issue claims held by this agent (ANV-224)
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
agent_id: Agent to remove
|
|
804
|
+
"""
|
|
805
|
+
registry = self._read_registry()
|
|
806
|
+
|
|
807
|
+
if agent_id in registry["agents"]:
|
|
808
|
+
del registry["agents"][agent_id]
|
|
809
|
+
self._write_registry(registry)
|
|
810
|
+
|
|
811
|
+
# ANV-223: Clean up codename mapping
|
|
812
|
+
codename_map = _load_codename_map()
|
|
813
|
+
if agent_id in codename_map:
|
|
814
|
+
del codename_map[agent_id]
|
|
815
|
+
_save_codename_map(codename_map)
|
|
816
|
+
|
|
817
|
+
# ANV-224: Release all issue claims held by this agent
|
|
818
|
+
try:
|
|
819
|
+
from claim_service import release_all_claims
|
|
820
|
+
release_all_claims(agent_id)
|
|
821
|
+
except ImportError:
|
|
822
|
+
pass # claim_service not available yet
|
|
823
|
+
|
|
824
|
+
# Remove detailed state file
|
|
825
|
+
state_file = self.state_dir / f"{agent_id}.json"
|
|
826
|
+
if state_file.exists():
|
|
827
|
+
state_file.unlink()
|
|
828
|
+
|
|
829
|
+
def get_all(self) -> Dict[str, Any]:
|
|
830
|
+
"""Get all registered agents.
|
|
831
|
+
|
|
832
|
+
Returns:
|
|
833
|
+
Dictionary of agent_id -> agent data
|
|
834
|
+
"""
|
|
835
|
+
registry = self._read_registry()
|
|
836
|
+
return self._cleanup_stale(registry.get("agents", {}))
|
|
837
|
+
|
|
838
|
+
def get_agent(self, agent_id: str) -> Optional[Dict[str, Any]]:
|
|
839
|
+
"""Get a specific agent's data.
|
|
840
|
+
|
|
841
|
+
Args:
|
|
842
|
+
agent_id: Agent to retrieve
|
|
843
|
+
|
|
844
|
+
Returns:
|
|
845
|
+
Agent data or None if not found
|
|
846
|
+
"""
|
|
847
|
+
agents = self.get_all()
|
|
848
|
+
return agents.get(agent_id)
|
|
849
|
+
|
|
850
|
+
def get_detailed_state(self, agent_id: str) -> Optional[Dict[str, Any]]:
|
|
851
|
+
"""Get detailed state for an agent.
|
|
852
|
+
|
|
853
|
+
Args:
|
|
854
|
+
agent_id: Agent to retrieve
|
|
855
|
+
|
|
856
|
+
Returns:
|
|
857
|
+
Detailed state data or None if not found
|
|
858
|
+
"""
|
|
859
|
+
state_file = self.state_dir / f"{agent_id}.json"
|
|
860
|
+
if not state_file.exists():
|
|
861
|
+
return None
|
|
862
|
+
|
|
863
|
+
try:
|
|
864
|
+
with open(state_file, 'r') as f:
|
|
865
|
+
return json.load(f)
|
|
866
|
+
except (json.JSONDecodeError, IOError):
|
|
867
|
+
return None
|
|
868
|
+
|
|
869
|
+
def _write_detailed_state(self, agent_id: str, data: Dict[str, Any]) -> None:
|
|
870
|
+
"""Write detailed state file for an agent."""
|
|
871
|
+
state_file = self.state_dir / f"{agent_id}.json"
|
|
872
|
+
self._atomic_write(state_file, data)
|
|
873
|
+
|
|
874
|
+
def _update_detailed_state(
|
|
875
|
+
self,
|
|
876
|
+
agent_id: str,
|
|
877
|
+
tool_name: Optional[str] = None,
|
|
878
|
+
tool_input: Optional[Dict] = None,
|
|
879
|
+
tool_duration: Optional[int] = None,
|
|
880
|
+
**kwargs
|
|
881
|
+
) -> None:
|
|
882
|
+
"""Update detailed state with new tool call."""
|
|
883
|
+
state = self.get_detailed_state(agent_id)
|
|
884
|
+
if not state:
|
|
885
|
+
return
|
|
886
|
+
|
|
887
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
888
|
+
|
|
889
|
+
# Update metrics
|
|
890
|
+
for key in ['context_usage', 'context_limit', 'session_cost', 'phase']:
|
|
891
|
+
snake_key = key
|
|
892
|
+
camel_key = ''.join(
|
|
893
|
+
word.capitalize() if i > 0 else word
|
|
894
|
+
for i, word in enumerate(key.split('_'))
|
|
895
|
+
)
|
|
896
|
+
if kwargs.get(snake_key) is not None:
|
|
897
|
+
if 'metrics' in state:
|
|
898
|
+
state['metrics'][camel_key] = kwargs[snake_key]
|
|
899
|
+
if key == 'phase' and 'workflow' in state:
|
|
900
|
+
state['workflow']['phase'] = kwargs[snake_key]
|
|
901
|
+
|
|
902
|
+
# Add tool to recent tools
|
|
903
|
+
if tool_name:
|
|
904
|
+
tool_entry = {
|
|
905
|
+
"name": tool_name,
|
|
906
|
+
"timestamp": now,
|
|
907
|
+
}
|
|
908
|
+
if tool_input:
|
|
909
|
+
# Truncate large inputs
|
|
910
|
+
tool_entry["input"] = {
|
|
911
|
+
k: (v[:100] + "..." if isinstance(v, str) and len(v) > 100 else v)
|
|
912
|
+
for k, v in list(tool_input.items())[:3]
|
|
913
|
+
}
|
|
914
|
+
if tool_duration:
|
|
915
|
+
tool_entry["duration"] = tool_duration
|
|
916
|
+
|
|
917
|
+
recent_tools = state.get("recentTools", [])
|
|
918
|
+
recent_tools.insert(0, tool_entry)
|
|
919
|
+
state["recentTools"] = recent_tools[:10] # Keep last 10
|
|
920
|
+
|
|
921
|
+
# Update git info
|
|
922
|
+
if kwargs.get('git_branch'):
|
|
923
|
+
state["git"] = state.get("git", {})
|
|
924
|
+
state["git"]["branch"] = kwargs['git_branch']
|
|
925
|
+
|
|
926
|
+
self._write_detailed_state(agent_id, state)
|
|
927
|
+
|
|
928
|
+
def count(self) -> int:
|
|
929
|
+
"""Get count of active agents.
|
|
930
|
+
|
|
931
|
+
Returns:
|
|
932
|
+
Number of active agents
|
|
933
|
+
"""
|
|
934
|
+
return len(self.get_all())
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
# Convenience functions for use in hooks
|
|
938
|
+
_registry = None
|
|
939
|
+
|
|
940
|
+
def get_registry() -> AgentRegistry:
|
|
941
|
+
"""Get singleton registry instance."""
|
|
942
|
+
global _registry
|
|
943
|
+
if _registry is None:
|
|
944
|
+
_registry = AgentRegistry()
|
|
945
|
+
return _registry
|
|
946
|
+
|
|
947
|
+
def register_agent(**kwargs) -> None:
|
|
948
|
+
"""Register an agent (convenience function)."""
|
|
949
|
+
get_registry().register(**kwargs)
|
|
950
|
+
|
|
951
|
+
def update_agent(**kwargs) -> None:
|
|
952
|
+
"""Update an agent (convenience function)."""
|
|
953
|
+
get_registry().update(**kwargs)
|
|
954
|
+
|
|
955
|
+
def deregister_agent(agent_id: str) -> None:
|
|
956
|
+
"""Deregister an agent (convenience function)."""
|
|
957
|
+
get_registry().deregister(agent_id)
|
|
958
|
+
|
|
959
|
+
def get_agent_count() -> int:
|
|
960
|
+
"""Get count of active agents (convenience function)."""
|
|
961
|
+
return get_registry().count()
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
if __name__ == "__main__":
|
|
965
|
+
# Simple test
|
|
966
|
+
import sys
|
|
967
|
+
|
|
968
|
+
registry = AgentRegistry()
|
|
969
|
+
|
|
970
|
+
if len(sys.argv) > 1:
|
|
971
|
+
cmd = sys.argv[1]
|
|
972
|
+
|
|
973
|
+
if cmd == "register":
|
|
974
|
+
agent_id = sys.argv[2] if len(sys.argv) > 2 else f"test-agent-{int(time.time())}"
|
|
975
|
+
project = sys.argv[3] if len(sys.argv) > 3 else os.getcwd()
|
|
976
|
+
registry.register(agent_id=agent_id, project=project, model="Opus")
|
|
977
|
+
print(f"Registered: {agent_id}")
|
|
978
|
+
|
|
979
|
+
elif cmd == "list":
|
|
980
|
+
agents = registry.get_all()
|
|
981
|
+
if not agents:
|
|
982
|
+
print("No active agents")
|
|
983
|
+
else:
|
|
984
|
+
for agent_id, agent in agents.items():
|
|
985
|
+
print(f" {agent_id}: {agent['projectName']} ({agent.get('phase', 'unknown')})")
|
|
986
|
+
|
|
987
|
+
elif cmd == "deregister":
|
|
988
|
+
agent_id = sys.argv[2]
|
|
989
|
+
registry.deregister(agent_id)
|
|
990
|
+
print(f"Deregistered: {agent_id}")
|
|
991
|
+
|
|
992
|
+
elif cmd == "count":
|
|
993
|
+
print(registry.count())
|
|
994
|
+
else:
|
|
995
|
+
print("Usage: agent_registry.py [register|list|deregister|count] [args...]")
|