agentpool-cli 0.1.0__py3-none-any.whl

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 (60) hide show
  1. agentpool/__init__.py +3 -0
  2. agentpool/agent_io.py +134 -0
  3. agentpool/artifacts.py +151 -0
  4. agentpool/cli.py +1199 -0
  5. agentpool/config.py +373 -0
  6. agentpool/docs/agentpool-skill.md +85 -0
  7. agentpool/docs/onboarding.md +169 -0
  8. agentpool/event_detection.py +150 -0
  9. agentpool/fixtures/__init__.py +1 -0
  10. agentpool/fixtures/fake_agents/__init__.py +1 -0
  11. agentpool/fixtures/fake_agents/fake_approval_agent.py +16 -0
  12. agentpool/fixtures/fake_agents/fake_common.py +44 -0
  13. agentpool/fixtures/fake_agents/fake_completed_agent.py +13 -0
  14. agentpool/fixtures/fake_agents/fake_idle_agent.py +16 -0
  15. agentpool/fixtures/fake_agents/fake_limit_agent.py +14 -0
  16. agentpool/fixtures/fake_agents/fake_patch_agent.py +17 -0
  17. agentpool/fixtures/fake_agents/fake_question_agent.py +16 -0
  18. agentpool/git_worktree.py +144 -0
  19. agentpool/mcp/__init__.py +1 -0
  20. agentpool/mcp/resources.py +64 -0
  21. agentpool/mcp/tools.py +259 -0
  22. agentpool/mcp_server.py +487 -0
  23. agentpool/models.py +310 -0
  24. agentpool/onboarding.py +1279 -0
  25. agentpool/policy.py +63 -0
  26. agentpool/provider_model_catalog.json +997 -0
  27. agentpool/providers/__init__.py +3 -0
  28. agentpool/providers/base.py +411 -0
  29. agentpool/providers/registry.py +139 -0
  30. agentpool/redaction.py +30 -0
  31. agentpool/runtimes/__init__.py +3 -0
  32. agentpool/runtimes/base.py +36 -0
  33. agentpool/runtimes/tmux.py +133 -0
  34. agentpool/session_manager.py +1061 -0
  35. agentpool/stats/__init__.py +6 -0
  36. agentpool/stats/card.py +74 -0
  37. agentpool/stats/compute.py +496 -0
  38. agentpool/stats/queries.py +138 -0
  39. agentpool/stats/render.py +103 -0
  40. agentpool/stats/window.py +85 -0
  41. agentpool/store.py +478 -0
  42. agentpool/usage/__init__.py +1 -0
  43. agentpool/usage/_common.py +223 -0
  44. agentpool/usage/ccusage.py +130 -0
  45. agentpool/usage/claude.py +23 -0
  46. agentpool/usage/codex.py +210 -0
  47. agentpool/usage/codexbar.py +186 -0
  48. agentpool/usage/combine.py +71 -0
  49. agentpool/usage/copilot.py +146 -0
  50. agentpool/usage/devin.py +265 -0
  51. agentpool/usage/parsers.py +41 -0
  52. agentpool/usage/probes.py +52 -0
  53. agentpool/usage/provider_parsers.py +276 -0
  54. agentpool/usage/summary.py +166 -0
  55. agentpool/utils.py +59 -0
  56. agentpool_cli-0.1.0.dist-info/METADATA +292 -0
  57. agentpool_cli-0.1.0.dist-info/RECORD +60 -0
  58. agentpool_cli-0.1.0.dist-info/WHEEL +4 -0
  59. agentpool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  60. agentpool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agentpool/store.py ADDED
@@ -0,0 +1,478 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from agentpool.models import AgentSession, ArtifactRecord, CapacitySnapshot, FileLease, SessionState, TmuxSessionRef, ToolError
9
+ from agentpool.utils import utc_now_iso
10
+
11
+
12
+ SCHEMA = """
13
+ CREATE TABLE IF NOT EXISTS sessions (
14
+ id TEXT PRIMARY KEY,
15
+ provider_id TEXT NOT NULL,
16
+ model TEXT,
17
+ harness TEXT NOT NULL,
18
+ account TEXT,
19
+ role TEXT NOT NULL,
20
+ task TEXT NOT NULL,
21
+ repo_path TEXT NOT NULL,
22
+ worktree_path TEXT,
23
+ runtime TEXT NOT NULL,
24
+ state TEXT NOT NULL,
25
+ tmux_session TEXT,
26
+ tmux_window TEXT,
27
+ tmux_pane TEXT,
28
+ artifact_dir TEXT NOT NULL,
29
+ transcript_path TEXT NOT NULL,
30
+ events_path TEXT NOT NULL,
31
+ created_at TEXT NOT NULL,
32
+ updated_at TEXT NOT NULL,
33
+ ended_at TEXT,
34
+ metadata_json TEXT NOT NULL DEFAULT '{}'
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS events (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ session_id TEXT NOT NULL,
40
+ ts TEXT NOT NULL,
41
+ event_type TEXT NOT NULL,
42
+ state TEXT,
43
+ screen_hash TEXT,
44
+ excerpt TEXT,
45
+ metadata_json TEXT NOT NULL DEFAULT '{}',
46
+ FOREIGN KEY(session_id) REFERENCES sessions(id)
47
+ );
48
+
49
+ CREATE TABLE IF NOT EXISTS usage_snapshots (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ provider_id TEXT NOT NULL,
52
+ ts TEXT NOT NULL,
53
+ status TEXT NOT NULL,
54
+ confidence TEXT NOT NULL,
55
+ raw_json TEXT NOT NULL
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS artifacts (
59
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
60
+ session_id TEXT NOT NULL,
61
+ kind TEXT NOT NULL,
62
+ path TEXT NOT NULL,
63
+ sha256 TEXT,
64
+ metadata_json TEXT NOT NULL DEFAULT '{}',
65
+ created_at TEXT NOT NULL,
66
+ FOREIGN KEY(session_id) REFERENCES sessions(id)
67
+ );
68
+
69
+ CREATE TABLE IF NOT EXISTS file_leases (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ session_id TEXT NOT NULL,
72
+ repo_path TEXT NOT NULL,
73
+ file_path TEXT NOT NULL,
74
+ mode TEXT NOT NULL,
75
+ expires_at TEXT,
76
+ created_at TEXT NOT NULL,
77
+ released_at TEXT,
78
+ metadata_json TEXT NOT NULL DEFAULT '{}'
79
+ );
80
+
81
+ CREATE INDEX IF NOT EXISTS idx_sessions_provider_state ON sessions(provider_id, state);
82
+ CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at);
83
+ CREATE INDEX IF NOT EXISTS idx_events_session_id ON events(session_id);
84
+ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
85
+ CREATE INDEX IF NOT EXISTS idx_usage_snapshots_provider_id_desc ON usage_snapshots(provider_id, id DESC);
86
+ CREATE INDEX IF NOT EXISTS idx_file_leases_repo_file ON file_leases(repo_path, file_path);
87
+ """
88
+
89
+
90
+ class Store:
91
+ def __init__(self, db_path: Path):
92
+ self.db_path = db_path
93
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
94
+ self.init_schema()
95
+
96
+ def connect(self) -> sqlite3.Connection:
97
+ conn = sqlite3.connect(self.db_path)
98
+ conn.row_factory = sqlite3.Row
99
+ return conn
100
+
101
+ def init_schema(self) -> None:
102
+ with self.connect() as conn:
103
+ conn.executescript(SCHEMA)
104
+
105
+ def save_session(self, session: AgentSession) -> None:
106
+ tmux = session.tmux
107
+ with self.connect() as conn:
108
+ conn.execute(
109
+ """
110
+ INSERT INTO sessions (
111
+ id, provider_id, model, harness, account, role, task, repo_path,
112
+ worktree_path, runtime, state, tmux_session, tmux_window, tmux_pane,
113
+ artifact_dir, transcript_path, events_path, created_at, updated_at,
114
+ ended_at, metadata_json
115
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
116
+ ON CONFLICT(id) DO UPDATE SET
117
+ model=excluded.model,
118
+ account=excluded.account,
119
+ state=excluded.state,
120
+ worktree_path=excluded.worktree_path,
121
+ tmux_session=excluded.tmux_session,
122
+ tmux_window=excluded.tmux_window,
123
+ tmux_pane=excluded.tmux_pane,
124
+ updated_at=excluded.updated_at,
125
+ ended_at=excluded.ended_at,
126
+ metadata_json=excluded.metadata_json
127
+ """,
128
+ (
129
+ session.id,
130
+ session.provider_id,
131
+ session.model,
132
+ session.harness,
133
+ session.account,
134
+ session.role,
135
+ session.task,
136
+ session.repo_path,
137
+ session.worktree_path,
138
+ session.runtime.value if hasattr(session.runtime, "value") else session.runtime,
139
+ session.state.value if hasattr(session.state, "value") else session.state,
140
+ tmux.session_name if tmux else None,
141
+ tmux.window if tmux else None,
142
+ tmux.pane if tmux else None,
143
+ session.artifact_dir,
144
+ session.transcript_path,
145
+ session.events_path,
146
+ session.created_at.isoformat(),
147
+ session.updated_at.isoformat(),
148
+ session.ended_at.isoformat() if session.ended_at else None,
149
+ json.dumps(session.metadata),
150
+ ),
151
+ )
152
+
153
+ def get_session(self, session_id: str) -> AgentSession | None:
154
+ with self.connect() as conn:
155
+ row = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
156
+ return self._row_to_session(row) if row else None
157
+
158
+ def list_sessions(
159
+ self,
160
+ states: list[str] | None = None,
161
+ provider_id: str | None = None,
162
+ limit: int | None = None,
163
+ offset: int = 0,
164
+ ) -> list[AgentSession]:
165
+ query, args = self._session_query(states, provider_id)
166
+ if limit is not None:
167
+ query = f"{query} LIMIT ? OFFSET ?"
168
+ args.extend([limit, offset])
169
+ with self.connect() as conn:
170
+ rows = conn.execute(query, args).fetchall()
171
+ return [self._row_to_session(row) for row in rows]
172
+
173
+ def count_sessions(self, states: list[str] | None = None, provider_id: str | None = None) -> int:
174
+ query, args = self._session_query(states, provider_id, select="COUNT(*)")
175
+ with self.connect() as conn:
176
+ return int(conn.execute(query, args).fetchone()[0])
177
+
178
+ def _session_query(
179
+ self,
180
+ states: list[str] | None = None,
181
+ provider_id: str | None = None,
182
+ select: str = "*",
183
+ ) -> tuple[str, list[Any]]:
184
+ clauses: list[str] = []
185
+ args: list[Any] = []
186
+ if states:
187
+ clauses.append(f"state IN ({','.join('?' for _ in states)})")
188
+ args.extend(states)
189
+ if provider_id:
190
+ clauses.append("provider_id = ?")
191
+ args.append(provider_id)
192
+ where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
193
+ order = "" if select != "*" else " ORDER BY created_at DESC"
194
+ return f"SELECT {select} FROM sessions{where}{order}", args
195
+
196
+ def update_session_state(self, session_id: str, state: SessionState, ended_at: str | None = None) -> None:
197
+ with self.connect() as conn:
198
+ conn.execute(
199
+ "UPDATE sessions SET state = ?, updated_at = ?, ended_at = COALESCE(?, ended_at) WHERE id = ?",
200
+ (state.value, utc_now_iso(), ended_at, session_id),
201
+ )
202
+
203
+ def append_event(
204
+ self,
205
+ session_id: str,
206
+ event_type: str,
207
+ state: str | None = None,
208
+ screen_hash: str | None = None,
209
+ excerpt: str | None = None,
210
+ metadata: dict[str, Any] | None = None,
211
+ ) -> int:
212
+ with self.connect() as conn:
213
+ cursor = conn.execute(
214
+ """
215
+ INSERT INTO events (session_id, ts, event_type, state, screen_hash, excerpt, metadata_json)
216
+ VALUES (?, ?, ?, ?, ?, ?, ?)
217
+ """,
218
+ (
219
+ session_id,
220
+ utc_now_iso(),
221
+ event_type,
222
+ state,
223
+ screen_hash,
224
+ excerpt,
225
+ json.dumps(metadata or {}),
226
+ ),
227
+ )
228
+ return int(cursor.lastrowid)
229
+
230
+ def list_events(self, session_id: str) -> list[dict[str, Any]]:
231
+ with self.connect() as conn:
232
+ rows = conn.execute(
233
+ "SELECT * FROM events WHERE session_id = ? ORDER BY id ASC", (session_id,)
234
+ ).fetchall()
235
+ return [
236
+ {
237
+ "id": row["id"],
238
+ "session_id": row["session_id"],
239
+ "ts": row["ts"],
240
+ "event_type": row["event_type"],
241
+ "state": row["state"],
242
+ "screen_hash": row["screen_hash"],
243
+ "excerpt": row["excerpt"],
244
+ "metadata": json.loads(row["metadata_json"]),
245
+ }
246
+ for row in rows
247
+ ]
248
+
249
+ def save_usage_snapshot(self, snapshot: CapacitySnapshot) -> None:
250
+ with self.connect() as conn:
251
+ conn.execute(
252
+ """
253
+ INSERT INTO usage_snapshots (provider_id, ts, status, confidence, raw_json)
254
+ VALUES (?, ?, ?, ?, ?)
255
+ """,
256
+ (
257
+ snapshot.provider_id,
258
+ snapshot.checked_at.isoformat(),
259
+ snapshot.status.value if hasattr(snapshot.status, "value") else snapshot.status,
260
+ snapshot.confidence.value if hasattr(snapshot.confidence, "value") else snapshot.confidence,
261
+ snapshot.model_dump_json(),
262
+ ),
263
+ )
264
+
265
+ def latest_usage_snapshots(self, provider_id: str | None = None) -> list[CapacitySnapshot]:
266
+ clauses: list[str] = []
267
+ args: list[Any] = []
268
+ if provider_id:
269
+ clauses.append("provider_id = ?")
270
+ args.append(provider_id)
271
+ where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
272
+ with self.connect() as conn:
273
+ rows = conn.execute(
274
+ f"""
275
+ SELECT u.*
276
+ FROM usage_snapshots u
277
+ JOIN (
278
+ SELECT provider_id, MAX(id) AS max_id
279
+ FROM usage_snapshots{where}
280
+ GROUP BY provider_id
281
+ ) latest
282
+ ON u.provider_id = latest.provider_id AND u.id = latest.max_id
283
+ ORDER BY u.provider_id ASC
284
+ """,
285
+ args,
286
+ ).fetchall()
287
+ return [CapacitySnapshot.model_validate_json(row["raw_json"]) for row in rows]
288
+
289
+ def save_artifact(self, session_id: str, artifact: ArtifactRecord) -> int:
290
+ with self.connect() as conn:
291
+ cursor = conn.execute(
292
+ """
293
+ INSERT INTO artifacts (session_id, kind, path, sha256, metadata_json, created_at)
294
+ VALUES (?, ?, ?, ?, ?, ?)
295
+ """,
296
+ (
297
+ session_id,
298
+ artifact.kind,
299
+ artifact.path,
300
+ artifact.sha256,
301
+ json.dumps(artifact.metadata),
302
+ utc_now_iso(),
303
+ ),
304
+ )
305
+ return int(cursor.lastrowid)
306
+
307
+ def list_artifacts(self, session_id: str) -> list[ArtifactRecord]:
308
+ with self.connect() as conn:
309
+ rows = conn.execute(
310
+ "SELECT * FROM artifacts WHERE session_id = ? ORDER BY id ASC", (session_id,)
311
+ ).fetchall()
312
+ return [
313
+ ArtifactRecord(
314
+ kind=row["kind"],
315
+ path=row["path"],
316
+ sha256=row["sha256"],
317
+ metadata=json.loads(row["metadata_json"]),
318
+ )
319
+ for row in rows
320
+ ]
321
+
322
+ def acquire_file_lease(
323
+ self,
324
+ session_id: str,
325
+ repo_path: str,
326
+ file_path: str,
327
+ mode: str = "write",
328
+ expires_at: str | None = None,
329
+ metadata: dict[str, Any] | None = None,
330
+ ) -> FileLease:
331
+ now = utc_now_iso()
332
+ with self.connect() as conn:
333
+ existing = conn.execute(
334
+ """
335
+ SELECT * FROM file_leases
336
+ WHERE repo_path = ?
337
+ AND file_path = ?
338
+ AND released_at IS NULL
339
+ AND (expires_at IS NULL OR expires_at > ?)
340
+ AND session_id = ?
341
+ ORDER BY id ASC
342
+ LIMIT 1
343
+ """,
344
+ (repo_path, file_path, now, session_id),
345
+ ).fetchone()
346
+ if existing:
347
+ return self._row_to_lease(existing)
348
+ conflict = conn.execute(
349
+ """
350
+ SELECT * FROM file_leases
351
+ WHERE repo_path = ?
352
+ AND file_path = ?
353
+ AND released_at IS NULL
354
+ AND (expires_at IS NULL OR expires_at > ?)
355
+ AND session_id != ?
356
+ ORDER BY id ASC
357
+ LIMIT 1
358
+ """,
359
+ (repo_path, file_path, now, session_id),
360
+ ).fetchone()
361
+ if conflict:
362
+ lease = self._row_to_lease(conflict)
363
+ raise ToolError(
364
+ "LEASE_CONFLICT",
365
+ f"File is already leased by session {lease.session_id}.",
366
+ {"file_path": file_path, "lease": lease.model_dump(mode="json")},
367
+ )
368
+ cursor = conn.execute(
369
+ """
370
+ INSERT INTO file_leases (
371
+ session_id, repo_path, file_path, mode, expires_at, created_at, released_at, metadata_json
372
+ ) VALUES (?, ?, ?, ?, ?, ?, NULL, ?)
373
+ """,
374
+ (
375
+ session_id,
376
+ repo_path,
377
+ file_path,
378
+ mode,
379
+ expires_at,
380
+ now,
381
+ json.dumps(metadata or {}),
382
+ ),
383
+ )
384
+ row = conn.execute("SELECT * FROM file_leases WHERE id = ?", (cursor.lastrowid,)).fetchone()
385
+ return self._row_to_lease(row)
386
+
387
+ def list_file_leases(
388
+ self,
389
+ session_id: str | None = None,
390
+ repo_path: str | None = None,
391
+ active_only: bool = True,
392
+ ) -> list[FileLease]:
393
+ clauses: list[str] = []
394
+ args: list[Any] = []
395
+ if session_id:
396
+ clauses.append("session_id = ?")
397
+ args.append(session_id)
398
+ if repo_path:
399
+ clauses.append("repo_path = ?")
400
+ args.append(repo_path)
401
+ if active_only:
402
+ clauses.append("released_at IS NULL")
403
+ clauses.append("(expires_at IS NULL OR expires_at > ?)")
404
+ args.append(utc_now_iso())
405
+ where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
406
+ with self.connect() as conn:
407
+ rows = conn.execute(f"SELECT * FROM file_leases{where} ORDER BY id ASC", args).fetchall()
408
+ return [self._row_to_lease(row) for row in rows]
409
+
410
+ def release_file_lease(
411
+ self,
412
+ lease_id: int | None = None,
413
+ session_id: str | None = None,
414
+ file_path: str | None = None,
415
+ ) -> int:
416
+ clauses: list[str] = ["released_at IS NULL"]
417
+ args: list[Any] = []
418
+ if lease_id is not None:
419
+ clauses.append("id = ?")
420
+ args.append(lease_id)
421
+ if session_id:
422
+ clauses.append("session_id = ?")
423
+ args.append(session_id)
424
+ if file_path:
425
+ clauses.append("file_path = ?")
426
+ args.append(file_path)
427
+ if lease_id is None and not session_id:
428
+ raise ValueError("release_file_lease requires lease_id or session_id")
429
+ args.append(utc_now_iso())
430
+ with self.connect() as conn:
431
+ cursor = conn.execute(
432
+ f"UPDATE file_leases SET released_at = ? WHERE {' AND '.join(clauses)}",
433
+ [args[-1], *args[:-1]],
434
+ )
435
+ return cursor.rowcount
436
+
437
+ def _row_to_session(self, row: sqlite3.Row) -> AgentSession:
438
+ tmux = None
439
+ if row["tmux_session"]:
440
+ tmux = TmuxSessionRef(
441
+ session_name=row["tmux_session"],
442
+ window=row["tmux_window"] or "0",
443
+ pane=row["tmux_pane"] or "0",
444
+ )
445
+ return AgentSession(
446
+ id=row["id"],
447
+ provider_id=row["provider_id"],
448
+ model=row["model"],
449
+ harness=row["harness"],
450
+ account=row["account"],
451
+ role=row["role"],
452
+ task=row["task"],
453
+ repo_path=row["repo_path"],
454
+ worktree_path=row["worktree_path"],
455
+ runtime=row["runtime"],
456
+ state=row["state"],
457
+ created_at=row["created_at"],
458
+ updated_at=row["updated_at"],
459
+ ended_at=row["ended_at"],
460
+ tmux=tmux,
461
+ artifact_dir=row["artifact_dir"],
462
+ transcript_path=row["transcript_path"],
463
+ events_path=row["events_path"],
464
+ metadata=json.loads(row["metadata_json"]),
465
+ )
466
+
467
+ def _row_to_lease(self, row: sqlite3.Row) -> FileLease:
468
+ return FileLease(
469
+ id=row["id"],
470
+ session_id=row["session_id"],
471
+ repo_path=row["repo_path"],
472
+ file_path=row["file_path"],
473
+ mode=row["mode"],
474
+ expires_at=row["expires_at"],
475
+ created_at=row["created_at"],
476
+ released_at=row["released_at"],
477
+ metadata=json.loads(row["metadata_json"]),
478
+ )
@@ -0,0 +1 @@
1
+ """Usage snapshot helpers."""
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import tempfile
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+ from collections.abc import Callable
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from agentpool.models import CapacitySnapshot, Confidence, TmuxSessionRef, UsageStatus, UsageWindow, UsageWindowKind
16
+ from agentpool.runtimes.tmux import TmuxRuntime
17
+
18
+
19
+ class ProbeError(Exception):
20
+ pass
21
+
22
+
23
+ def unavailable(provider_id: str, warning: str) -> CapacitySnapshot:
24
+ return CapacitySnapshot(
25
+ provider_id=provider_id,
26
+ status=UsageStatus.UNAVAILABLE,
27
+ confidence=Confidence.UNKNOWN,
28
+ warnings=[warning],
29
+ )
30
+
31
+
32
+ def unknown(provider_id: str, warning: str, source: str) -> CapacitySnapshot:
33
+ return CapacitySnapshot(
34
+ provider_id=provider_id,
35
+ status=UsageStatus.UNKNOWN,
36
+ confidence=Confidence.UNKNOWN,
37
+ warnings=[warning],
38
+ raw={"source": source},
39
+ )
40
+
41
+
42
+ def _extract_json_payload(text: str) -> Any:
43
+ decoder = json.JSONDecoder()
44
+ errors: list[str] = []
45
+ for index, char in enumerate(text):
46
+ if char not in "{[":
47
+ continue
48
+ try:
49
+ payload, _ = decoder.raw_decode(text[index:])
50
+ return payload
51
+ except json.JSONDecodeError as exc:
52
+ errors.append(str(exc))
53
+ raise ProbeError("No JSON payload found." if not errors else f"No parseable JSON payload found: {errors[-1]}")
54
+
55
+
56
+ def _request_json(request: urllib.request.Request) -> dict[str, Any]:
57
+ try:
58
+ with urllib.request.urlopen(request, timeout=10) as response:
59
+ data = response.read()
60
+ except urllib.error.HTTPError as exc:
61
+ body = exc.read().decode("utf-8", errors="replace")[:500]
62
+ raise ProbeError(f"HTTP {exc.code}: {body}") from exc
63
+ except urllib.error.URLError as exc:
64
+ raise ProbeError(str(exc.reason)) from exc
65
+ try:
66
+ payload = json.loads(data.decode("utf-8"))
67
+ except json.JSONDecodeError as exc:
68
+ raise ProbeError(f"Invalid JSON response: {exc}") from exc
69
+ if not isinstance(payload, dict):
70
+ raise ProbeError("JSON response was not an object.")
71
+ return payload
72
+
73
+
74
+ def _tmux_slash_usage_probe(
75
+ provider_id: str,
76
+ command: list[str],
77
+ slash_command: str,
78
+ parser: Callable[[str, str], CapacitySnapshot | None],
79
+ source: str,
80
+ startup_delay: float,
81
+ timeout: float,
82
+ pre_keys: list[list[str]] | None = None,
83
+ extra_keys_after_match: list[list[str]] | None = None,
84
+ prefer_text: str | None = None,
85
+ ) -> CapacitySnapshot:
86
+ runtime = TmuxRuntime()
87
+ session_name = f"agentpool-usage-{provider_id.replace('-', '')}-{os.getpid()}-{int(time.time() * 1000) % 100000}"
88
+ with tempfile.TemporaryDirectory(prefix="agentpool-usage-") as tmp:
89
+ ref: TmuxSessionRef | None = None
90
+ try:
91
+ ref = runtime.spawn(command, Path(tmp), {}, session_name)
92
+ time.sleep(startup_delay)
93
+ for keys in pre_keys or []:
94
+ runtime.send_keys(ref, keys)
95
+ time.sleep(0.5)
96
+ runtime.send_message(ref, slash_command, submit=True)
97
+ deadline = time.monotonic() + timeout
98
+ latest = ""
99
+ captures: list[str] = []
100
+ fallback_snapshot: CapacitySnapshot | None = None
101
+ while time.monotonic() < deadline:
102
+ latest = runtime.capture(ref, 260)
103
+ captures.append(latest)
104
+ joined = "\n".join(captures)
105
+ snapshot = parser(provider_id, joined)
106
+ if snapshot:
107
+ if prefer_text and prefer_text not in joined:
108
+ fallback_snapshot = snapshot
109
+ time.sleep(0.75)
110
+ continue
111
+ for keys in extra_keys_after_match or []:
112
+ time.sleep(1.0)
113
+ runtime.send_keys(ref, keys)
114
+ time.sleep(0.8)
115
+ captures.append(runtime.capture(ref, 260))
116
+ if extra_keys_after_match:
117
+ enriched = parser(provider_id, "\n".join(captures))
118
+ if enriched:
119
+ snapshot = enriched
120
+ snapshot.raw["source"] = source
121
+ return snapshot
122
+ time.sleep(0.75)
123
+ if fallback_snapshot:
124
+ fallback_snapshot.raw["source"] = source
125
+ fallback_snapshot.warnings.append(f"Returned fallback before seeing `{prefer_text}`.")
126
+ return fallback_snapshot
127
+ return unknown(
128
+ provider_id,
129
+ f"{slash_command} did not yield parseable usage within {int(timeout)}s.",
130
+ source=source,
131
+ )
132
+ except Exception as exc:
133
+ return unknown(provider_id, f"Interactive usage probe failed: {exc}", source=source)
134
+ finally:
135
+ if ref and runtime.exists(ref):
136
+ runtime.terminate(ref)
137
+
138
+
139
+ def _duration_window_kind(duration_mins: int | None) -> UsageWindowKind:
140
+ if duration_mins == 300:
141
+ return UsageWindowKind.FIVE_HOUR
142
+ if duration_mins == 1440:
143
+ return UsageWindowKind.DAILY
144
+ if duration_mins == 10080:
145
+ return UsageWindowKind.WEEKLY
146
+ if duration_mins and 27 * 1440 <= duration_mins <= 32 * 1440:
147
+ return UsageWindowKind.MONTHLY
148
+ return UsageWindowKind.UNKNOWN
149
+
150
+
151
+ def _status_from_windows(windows: list[UsageWindow]) -> UsageStatus:
152
+ remaining_values = [w.remaining_percent for w in windows if w.remaining_percent is not None]
153
+ if not remaining_values:
154
+ return UsageStatus.UNKNOWN
155
+ remaining = min(remaining_values)
156
+ if remaining <= 0:
157
+ return UsageStatus.LIMIT_REACHED
158
+ if remaining <= 15:
159
+ return UsageStatus.NEAR_LIMIT
160
+ return UsageStatus.AVAILABLE
161
+
162
+
163
+ def _clamp_percent(value: float) -> float:
164
+ return max(0.0, min(100.0, float(value)))
165
+
166
+
167
+ def _number(value: object) -> float | None:
168
+ if isinstance(value, bool):
169
+ return None
170
+ if isinstance(value, int | float):
171
+ return float(value)
172
+ if isinstance(value, str):
173
+ try:
174
+ return float(value)
175
+ except ValueError:
176
+ return None
177
+ return None
178
+
179
+
180
+ def _int_number(value: object) -> int | None:
181
+ number = _number(value)
182
+ return int(number) if number is not None else None
183
+
184
+
185
+ def _epoch_seconds(value: object) -> datetime | None:
186
+ seconds = _int_number(value)
187
+ if not seconds:
188
+ return None
189
+ return datetime.fromtimestamp(seconds, tz=UTC)
190
+
191
+
192
+ def _parse_datetime(value: object) -> datetime | None:
193
+ if not isinstance(value, str) or not value:
194
+ return None
195
+ try:
196
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
197
+ except ValueError:
198
+ return None
199
+
200
+
201
+ def _clean_optional_string(value: object) -> str | None:
202
+ if not isinstance(value, str):
203
+ return None
204
+ cleaned = value.strip()
205
+ return cleaned or None
206
+
207
+
208
+ def _terminate_process(proc: subprocess.Popen[str]) -> None:
209
+ if proc.poll() is None:
210
+ proc.terminate()
211
+ try:
212
+ proc.wait(timeout=1)
213
+ except subprocess.TimeoutExpired:
214
+ proc.kill()
215
+
216
+
217
+ def _safe_read_pipe(pipe: Any) -> str:
218
+ if pipe is None:
219
+ return ""
220
+ try:
221
+ return pipe.read()[:1000]
222
+ except Exception:
223
+ return ""