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.
Files changed (190) hide show
  1. package/README.md +719 -0
  2. package/VERSION +1 -0
  3. package/docs/ANVIL-REPO-IMPLEMENTATION-PLAN.md +441 -0
  4. package/docs/FIRST-SKILL-TUTORIAL.md +408 -0
  5. package/docs/INSTALLATION-RETRO-NOTES.md +458 -0
  6. package/docs/INSTALLATION.md +984 -0
  7. package/docs/anvil-hud.md +469 -0
  8. package/docs/anvil-init.md +255 -0
  9. package/docs/anvil-state.md +210 -0
  10. package/docs/boris-cherny-ralph-wiggum-insights.md +608 -0
  11. package/docs/command-reference.md +2022 -0
  12. package/docs/hooks-tts.md +368 -0
  13. package/docs/implementation-guide.md +810 -0
  14. package/docs/linear-github-integration.md +247 -0
  15. package/docs/local-issues.md +677 -0
  16. package/docs/patterns/README.md +419 -0
  17. package/docs/planning-responsibilities.md +139 -0
  18. package/docs/session-workflow.md +573 -0
  19. package/docs/simplification-plan-template.md +297 -0
  20. package/docs/simplification-principles.md +129 -0
  21. package/docs/specifications/CCS-RALPH-INTEGRATION-DESIGN.md +633 -0
  22. package/docs/specifications/CCS-RESEARCH-REPORT.md +169 -0
  23. package/docs/specifications/PLAN-ANV-verification-ralph-wiggum.md +403 -0
  24. package/docs/specifications/PLAN-parallel-tracks-anvil-memory-ccs.md +494 -0
  25. package/docs/specifications/SPEC-ANV-VRW/component-01-verify.md +208 -0
  26. package/docs/specifications/SPEC-ANV-VRW/component-02-stop-gate.md +226 -0
  27. package/docs/specifications/SPEC-ANV-VRW/component-03-posttooluse.md +209 -0
  28. package/docs/specifications/SPEC-ANV-VRW/component-04-ralph-wiggum.md +604 -0
  29. package/docs/specifications/SPEC-ANV-VRW/component-05-atomic-actions.md +311 -0
  30. package/docs/specifications/SPEC-ANV-VRW/component-06-verify-subagent.md +264 -0
  31. package/docs/specifications/SPEC-ANV-VRW/component-07-claude-md.md +363 -0
  32. package/docs/specifications/SPEC-ANV-VRW/index.md +182 -0
  33. package/docs/specifications/SPEC-ANV-anvil-memory.md +573 -0
  34. package/docs/specifications/SPEC-ANV-context-checkpoints.md +781 -0
  35. package/docs/specifications/SPEC-ANV-verification-ralph-wiggum.md +789 -0
  36. package/docs/sync.md +122 -0
  37. package/global/CLAUDE.md +140 -0
  38. package/global/agents/verify-app.md +164 -0
  39. package/global/commands/anvil-settings.md +527 -0
  40. package/global/commands/anvil-sync.md +121 -0
  41. package/global/commands/change.md +197 -0
  42. package/global/commands/clarify.md +252 -0
  43. package/global/commands/cleanup.md +292 -0
  44. package/global/commands/commit-push-pr.md +207 -0
  45. package/global/commands/decay-review.md +127 -0
  46. package/global/commands/discover.md +158 -0
  47. package/global/commands/doc-coverage.md +122 -0
  48. package/global/commands/evidence.md +307 -0
  49. package/global/commands/explore.md +121 -0
  50. package/global/commands/force-exit.md +135 -0
  51. package/global/commands/handoff.md +191 -0
  52. package/global/commands/healthcheck.md +302 -0
  53. package/global/commands/hud.md +84 -0
  54. package/global/commands/insights.md +319 -0
  55. package/global/commands/linear-setup.md +184 -0
  56. package/global/commands/lint-fix.md +198 -0
  57. package/global/commands/orient.md +510 -0
  58. package/global/commands/plan.md +228 -0
  59. package/global/commands/ralph.md +346 -0
  60. package/global/commands/ready.md +182 -0
  61. package/global/commands/release.md +305 -0
  62. package/global/commands/retro.md +96 -0
  63. package/global/commands/shard.md +166 -0
  64. package/global/commands/spec.md +227 -0
  65. package/global/commands/sprint.md +184 -0
  66. package/global/commands/tasks.md +228 -0
  67. package/global/commands/test-and-commit.md +151 -0
  68. package/global/commands/validate.md +132 -0
  69. package/global/commands/verify.md +251 -0
  70. package/global/commands/weekly-review.md +156 -0
  71. package/global/hooks/__pycache__/ralph_context_monitor.cpython-314.pyc +0 -0
  72. package/global/hooks/__pycache__/statusline_agent_sync.cpython-314.pyc +0 -0
  73. package/global/hooks/anvil_memory_observe.ts +322 -0
  74. package/global/hooks/anvil_memory_session.ts +166 -0
  75. package/global/hooks/anvil_memory_stop.ts +187 -0
  76. package/global/hooks/parse_transcript.py +116 -0
  77. package/global/hooks/post_merge_cleanup.sh +132 -0
  78. package/global/hooks/post_tool_format.sh +215 -0
  79. package/global/hooks/ralph_context_monitor.py +240 -0
  80. package/global/hooks/ralph_stop.sh +502 -0
  81. package/global/hooks/statusline.sh +1110 -0
  82. package/global/hooks/statusline_agent_sync.py +224 -0
  83. package/global/hooks/stop_gate.sh +250 -0
  84. package/global/lib/.claude/anvil-state.json +21 -0
  85. package/global/lib/__pycache__/agent_registry.cpython-314.pyc +0 -0
  86. package/global/lib/__pycache__/claim_service.cpython-314.pyc +0 -0
  87. package/global/lib/__pycache__/coderabbit_service.cpython-314.pyc +0 -0
  88. package/global/lib/__pycache__/config_service.cpython-314.pyc +0 -0
  89. package/global/lib/__pycache__/coordination_service.cpython-314.pyc +0 -0
  90. package/global/lib/__pycache__/doc_coverage_service.cpython-314.pyc +0 -0
  91. package/global/lib/__pycache__/gate_logger.cpython-314.pyc +0 -0
  92. package/global/lib/__pycache__/github_service.cpython-314.pyc +0 -0
  93. package/global/lib/__pycache__/hygiene_service.cpython-314.pyc +0 -0
  94. package/global/lib/__pycache__/issue_models.cpython-314.pyc +0 -0
  95. package/global/lib/__pycache__/issue_provider.cpython-314.pyc +0 -0
  96. package/global/lib/__pycache__/linear_data_service.cpython-314.pyc +0 -0
  97. package/global/lib/__pycache__/linear_provider.cpython-314.pyc +0 -0
  98. package/global/lib/__pycache__/local_provider.cpython-314.pyc +0 -0
  99. package/global/lib/__pycache__/quality_service.cpython-314.pyc +0 -0
  100. package/global/lib/__pycache__/ralph_state.cpython-314.pyc +0 -0
  101. package/global/lib/__pycache__/state_manager.cpython-314.pyc +0 -0
  102. package/global/lib/__pycache__/transcript_parser.cpython-314.pyc +0 -0
  103. package/global/lib/__pycache__/verification_runner.cpython-314.pyc +0 -0
  104. package/global/lib/__pycache__/verify_iteration.cpython-314.pyc +0 -0
  105. package/global/lib/__pycache__/verify_subagent.cpython-314.pyc +0 -0
  106. package/global/lib/agent_registry.py +995 -0
  107. package/global/lib/anvil-state.sh +435 -0
  108. package/global/lib/claim_service.py +515 -0
  109. package/global/lib/coderabbit_service.py +314 -0
  110. package/global/lib/config_service.py +423 -0
  111. package/global/lib/coordination_service.py +331 -0
  112. package/global/lib/doc_coverage_service.py +1305 -0
  113. package/global/lib/gate_logger.py +316 -0
  114. package/global/lib/github_service.py +310 -0
  115. package/global/lib/handoff_generator.py +775 -0
  116. package/global/lib/hygiene_service.py +712 -0
  117. package/global/lib/issue_models.py +257 -0
  118. package/global/lib/issue_provider.py +339 -0
  119. package/global/lib/linear_data_service.py +210 -0
  120. package/global/lib/linear_provider.py +987 -0
  121. package/global/lib/linear_provider.py.backup +671 -0
  122. package/global/lib/local_provider.py +486 -0
  123. package/global/lib/orient_fast.py +457 -0
  124. package/global/lib/quality_service.py +470 -0
  125. package/global/lib/ralph_prompt_generator.py +563 -0
  126. package/global/lib/ralph_state.py +1202 -0
  127. package/global/lib/state_manager.py +417 -0
  128. package/global/lib/transcript_parser.py +597 -0
  129. package/global/lib/verification_runner.py +557 -0
  130. package/global/lib/verify_iteration.py +490 -0
  131. package/global/lib/verify_subagent.py +250 -0
  132. package/global/skills/README.md +155 -0
  133. package/global/skills/quality-gates/SKILL.md +252 -0
  134. package/global/skills/skill-template/SKILL.md +109 -0
  135. package/global/skills/testing-strategies/SKILL.md +337 -0
  136. package/global/templates/CHANGE-template.md +105 -0
  137. package/global/templates/HANDOFF-template.md +63 -0
  138. package/global/templates/PLAN-template.md +111 -0
  139. package/global/templates/SPEC-template.md +93 -0
  140. package/global/templates/ralph/PROMPT.md.template +89 -0
  141. package/global/templates/ralph/fix_plan.md.template +31 -0
  142. package/global/templates/ralph/progress.txt.template +23 -0
  143. package/global/tests/__pycache__/test_doc_coverage.cpython-314.pyc +0 -0
  144. package/global/tests/test_doc_coverage.py +520 -0
  145. package/global/tests/test_issue_models.py +299 -0
  146. package/global/tests/test_local_provider.py +323 -0
  147. package/global/tools/README.md +178 -0
  148. package/global/tools/__pycache__/anvil-hud.cpython-314.pyc +0 -0
  149. package/global/tools/anvil-hud.py +3622 -0
  150. package/global/tools/anvil-hud.py.bak +3318 -0
  151. package/global/tools/anvil-issue.py +432 -0
  152. package/global/tools/anvil-memory/CLAUDE.md +49 -0
  153. package/global/tools/anvil-memory/README.md +42 -0
  154. package/global/tools/anvil-memory/bun.lock +25 -0
  155. package/global/tools/anvil-memory/bunfig.toml +9 -0
  156. package/global/tools/anvil-memory/package.json +23 -0
  157. package/global/tools/anvil-memory/src/__tests__/ccs/context-monitor.test.ts +535 -0
  158. package/global/tools/anvil-memory/src/__tests__/ccs/edge-cases.test.ts +645 -0
  159. package/global/tools/anvil-memory/src/__tests__/ccs/fixtures.ts +363 -0
  160. package/global/tools/anvil-memory/src/__tests__/ccs/index.ts +8 -0
  161. package/global/tools/anvil-memory/src/__tests__/ccs/integration.test.ts +417 -0
  162. package/global/tools/anvil-memory/src/__tests__/ccs/prompt-generator.test.ts +571 -0
  163. package/global/tools/anvil-memory/src/__tests__/ccs/ralph-stop.test.ts +440 -0
  164. package/global/tools/anvil-memory/src/__tests__/ccs/test-utils.ts +252 -0
  165. package/global/tools/anvil-memory/src/__tests__/commands.test.ts +657 -0
  166. package/global/tools/anvil-memory/src/__tests__/db.test.ts +641 -0
  167. package/global/tools/anvil-memory/src/__tests__/hooks.test.ts +272 -0
  168. package/global/tools/anvil-memory/src/__tests__/performance.test.ts +427 -0
  169. package/global/tools/anvil-memory/src/__tests__/test-utils.ts +113 -0
  170. package/global/tools/anvil-memory/src/commands/checkpoint.ts +197 -0
  171. package/global/tools/anvil-memory/src/commands/get.ts +115 -0
  172. package/global/tools/anvil-memory/src/commands/init.ts +94 -0
  173. package/global/tools/anvil-memory/src/commands/observe.ts +163 -0
  174. package/global/tools/anvil-memory/src/commands/search.ts +112 -0
  175. package/global/tools/anvil-memory/src/db.ts +638 -0
  176. package/global/tools/anvil-memory/src/index.ts +205 -0
  177. package/global/tools/anvil-memory/src/types.ts +122 -0
  178. package/global/tools/anvil-memory/tsconfig.json +29 -0
  179. package/global/tools/ralph-loop.sh +359 -0
  180. package/package.json +45 -0
  181. package/scripts/anvil +822 -0
  182. package/scripts/extract_patterns.py +222 -0
  183. package/scripts/init-project.sh +541 -0
  184. package/scripts/install.sh +229 -0
  185. package/scripts/postinstall.js +41 -0
  186. package/scripts/rollback.sh +188 -0
  187. package/scripts/sync.sh +623 -0
  188. package/scripts/test-statusline.sh +248 -0
  189. package/scripts/update_claude_md.py +224 -0
  190. 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...]")