code-coordinator 0.5.46__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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/usage_limits.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Max-plan 5h/weekly usage-window probe and dispatch gate (#1466).
|
|
2
|
+
|
|
3
|
+
Unattended driving (``coord drive``, ``coord approve``) previously had no
|
|
4
|
+
idea how much of the account's Max-plan session/weekly usage was left, so it
|
|
5
|
+
would happily dispatch straight into a 5-hour or weekly wall — the first
|
|
6
|
+
sign was a worker dying mid-task with the branch stranded (see
|
|
7
|
+
``coord.worker_events.detect_usage_limit_kill`` for the *mid-flight* half of
|
|
8
|
+
that signal). This module is the *pre-flight* half: probe the plan bars
|
|
9
|
+
before dispatching, so ``coord drive``'s ``preflight()`` and ``coord
|
|
10
|
+
approve`` can warn (or, once trusted, refuse) instead of finding out the
|
|
11
|
+
hard way.
|
|
12
|
+
|
|
13
|
+
THE PROBE. ``claude -p "/usage" --output-format json`` returns the same
|
|
14
|
+
live plan bars the interactive statusline shows, at essentially no cost
|
|
15
|
+
(``total_cost_usd: 0``, ``num_turns: 0``, ~700ms measured). Its ``.result``
|
|
16
|
+
field is prose, not a stable contract, e.g.::
|
|
17
|
+
|
|
18
|
+
Current session: 57% used · resets Jul 27, 1:30am (America/Chicago)
|
|
19
|
+
Current week (all models): 29% used · resets Aug 1, 12pm (America/Chicago)
|
|
20
|
+
Current week (Fable): 0% used
|
|
21
|
+
|
|
22
|
+
:func:`parse_usage_probe_output` parses that defensively — a garbled or
|
|
23
|
+
reworded string yields ``PlanLimits(status="unknown")``, never a raised
|
|
24
|
+
exception, and never something that looks like an "ok" read of the wrong
|
|
25
|
+
numbers.
|
|
26
|
+
|
|
27
|
+
SCOPE OF THE PROBE. The bars are **server-side and account-wide** — one
|
|
28
|
+
probe on the daemon host covers every agent machine sharing that login. (The
|
|
29
|
+
"what's contributing" breakdown underneath ``/usage``'s prose is
|
|
30
|
+
local-machine-only and is not parsed here — it answers a different
|
|
31
|
+
question than "how much is left".) The endpoint is itself rate-limited, and
|
|
32
|
+
Claude Code then serves the bars up to 60 minutes stale, so callers MUST NOT
|
|
33
|
+
poll it per-dispatch — :func:`get_plan_limits` caches for
|
|
34
|
+
:data:`CACHE_TTL_SECS`.
|
|
35
|
+
|
|
36
|
+
AUTH SCOPE. This only means anything under subscription OAuth. Under an API
|
|
37
|
+
key, Bedrock, or Vertex the plan windows do not apply and ``/usage`` returns
|
|
38
|
+
nothing useful — :func:`parse_usage_probe_output` returns ``status="unknown"``
|
|
39
|
+
for that prose too (it doesn't match the session/week bars), so callers
|
|
40
|
+
degrade to skipping the gate automatically, with no separate auth check
|
|
41
|
+
needed here.
|
|
42
|
+
|
|
43
|
+
CAVEAT (leave this comment wherever the gate is wired in — the issue asked
|
|
44
|
+
for it explicitly). Anthropic announced that ``claude -p`` / Agent SDK usage
|
|
45
|
+
moves *off* the subscription windows onto a separate monthly credit pool
|
|
46
|
+
($100 Max 5x / $200 Max 20x). That rollout is **paused as of 2026-06-15** —
|
|
47
|
+
the help article currently states nothing has changed, so today's headless
|
|
48
|
+
workers still draw the same pool ``/usage`` reports and this gate is
|
|
49
|
+
predictive of a worker running into it. If that rollout resumes, the 5h/
|
|
50
|
+
weekly bars stop predicting worker blocks and the gate would need to track
|
|
51
|
+
credit balance instead.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import json
|
|
57
|
+
import re
|
|
58
|
+
import subprocess
|
|
59
|
+
import time
|
|
60
|
+
from dataclasses import dataclass, field
|
|
61
|
+
from typing import TYPE_CHECKING
|
|
62
|
+
|
|
63
|
+
# Reuse the #859 absolute-path resolution (systemd --user strips PATH) rather
|
|
64
|
+
# than re-deriving it — see coord.test_orchestrator.resolve_claude_bin's
|
|
65
|
+
# docstring for why a bare "claude" silently breaks under coord-serve.
|
|
66
|
+
from coord.test_orchestrator import resolve_claude_bin
|
|
67
|
+
|
|
68
|
+
if TYPE_CHECKING:
|
|
69
|
+
from coord.config import UsageGateConfig
|
|
70
|
+
|
|
71
|
+
# How long a cached probe stays valid. The `/usage` endpoint is itself
|
|
72
|
+
# rate-limited and Claude Code serves bars up to 60 minutes stale anyway, so
|
|
73
|
+
# there is no benefit to probing more often than this — only more risk of
|
|
74
|
+
# tripping that same rate limit ourselves.
|
|
75
|
+
CACHE_TTL_SECS = 60.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── data classes ─────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class ModelWeekUsage:
|
|
83
|
+
"""One ``Current week (<model>): NN% used`` row (per-model breakdown)."""
|
|
84
|
+
|
|
85
|
+
label: str
|
|
86
|
+
used_pct: float
|
|
87
|
+
resets_at: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class PlanLimits:
|
|
92
|
+
"""Parsed ``/usage`` bars, or the reason we don't have them.
|
|
93
|
+
|
|
94
|
+
``status`` is the load-bearing field: ``"ok"`` means *both* percentages
|
|
95
|
+
were parsed from a probe that actually ran; anything else is
|
|
96
|
+
``"unknown"`` and callers must treat it as "no information", never as
|
|
97
|
+
"usage is fine" — a probe failure silently degrading a gate to "always
|
|
98
|
+
proceed" is fine (that's the intended fail-open behaviour); a probe
|
|
99
|
+
failure being mistaken for "0% used" is not.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
status: str # "ok" | "unknown"
|
|
103
|
+
session_pct: float | None = None
|
|
104
|
+
session_resets_at: str | None = None
|
|
105
|
+
week_pct: float | None = None
|
|
106
|
+
week_resets_at: str | None = None
|
|
107
|
+
week_by_model: tuple[ModelWeekUsage, ...] = ()
|
|
108
|
+
error: str | None = None
|
|
109
|
+
raw: str = ""
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def ok(self) -> bool:
|
|
113
|
+
return self.status == "ok"
|
|
114
|
+
|
|
115
|
+
def to_dict(self) -> dict:
|
|
116
|
+
return {
|
|
117
|
+
"status": self.status,
|
|
118
|
+
"session_pct": self.session_pct,
|
|
119
|
+
"session_resets_at": self.session_resets_at,
|
|
120
|
+
"week_pct": self.week_pct,
|
|
121
|
+
"week_resets_at": self.week_resets_at,
|
|
122
|
+
"week_by_model": [
|
|
123
|
+
{"label": m.label, "used_pct": m.used_pct, "resets_at": m.resets_at}
|
|
124
|
+
for m in self.week_by_model
|
|
125
|
+
],
|
|
126
|
+
"error": self.error,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
_UNKNOWN = PlanLimits(status="unknown")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ── parsing (defensive — `.result` is prose, not a contract) ────────────────
|
|
134
|
+
|
|
135
|
+
_PCT = r"(\d+(?:\.\d+)?)\s*%\s*used"
|
|
136
|
+
_RESETS = r"(?:\s*[·•]\s*resets\s+(.+))?"
|
|
137
|
+
|
|
138
|
+
_SESSION_RE = re.compile(
|
|
139
|
+
r"Current\s+session:\s*" + _PCT + _RESETS, re.IGNORECASE
|
|
140
|
+
)
|
|
141
|
+
_WEEK_LINE_RE = re.compile(
|
|
142
|
+
r"Current\s+week\s*\(([^)]*)\):\s*" + _PCT + _RESETS, re.IGNORECASE
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def parse_usage_probe_output(text: str) -> PlanLimits:
|
|
147
|
+
"""Parse ``claude -p "/usage"``'s ``.result`` prose into a
|
|
148
|
+
:class:`PlanLimits`.
|
|
149
|
+
|
|
150
|
+
Never raises — a string that doesn't contain a recognisable "Current
|
|
151
|
+
session" or "Current week" bar (wrong CLI version, changed wording,
|
|
152
|
+
empty, garbage) yields ``PlanLimits(status="unknown", ...)`` with the
|
|
153
|
+
original text attached for diagnostics.
|
|
154
|
+
"""
|
|
155
|
+
if not isinstance(text, str) or not text.strip():
|
|
156
|
+
return PlanLimits(status="unknown", error="empty /usage output", raw=text or "")
|
|
157
|
+
|
|
158
|
+
session_pct: float | None = None
|
|
159
|
+
session_resets: str | None = None
|
|
160
|
+
week_pct: float | None = None
|
|
161
|
+
week_resets: str | None = None
|
|
162
|
+
week_by_model: list[ModelWeekUsage] = []
|
|
163
|
+
|
|
164
|
+
m = _SESSION_RE.search(text)
|
|
165
|
+
if m:
|
|
166
|
+
try:
|
|
167
|
+
session_pct = float(m.group(1))
|
|
168
|
+
except ValueError:
|
|
169
|
+
session_pct = None
|
|
170
|
+
resets = m.group(2)
|
|
171
|
+
session_resets = resets.strip() if resets else None
|
|
172
|
+
|
|
173
|
+
for m in _WEEK_LINE_RE.finditer(text):
|
|
174
|
+
label = (m.group(1) or "").strip()
|
|
175
|
+
try:
|
|
176
|
+
pct = float(m.group(2))
|
|
177
|
+
except ValueError:
|
|
178
|
+
continue
|
|
179
|
+
resets = m.group(3)
|
|
180
|
+
resets = resets.strip() if resets else None
|
|
181
|
+
if label.lower() == "all models":
|
|
182
|
+
week_pct = pct
|
|
183
|
+
week_resets = resets
|
|
184
|
+
elif label:
|
|
185
|
+
week_by_model.append(ModelWeekUsage(label=label, used_pct=pct, resets_at=resets))
|
|
186
|
+
|
|
187
|
+
if session_pct is None and week_pct is None:
|
|
188
|
+
return PlanLimits(
|
|
189
|
+
status="unknown",
|
|
190
|
+
error="no recognisable usage bars in /usage output",
|
|
191
|
+
raw=text,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return PlanLimits(
|
|
195
|
+
status="ok",
|
|
196
|
+
session_pct=session_pct,
|
|
197
|
+
session_resets_at=session_resets,
|
|
198
|
+
week_pct=week_pct,
|
|
199
|
+
week_resets_at=week_resets,
|
|
200
|
+
week_by_model=tuple(week_by_model),
|
|
201
|
+
raw=text,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ── the probe (I/O) ──────────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def probe_plan_limits(*, timeout: float = 15.0) -> PlanLimits:
|
|
209
|
+
"""Run ``claude -p "/usage" --output-format json`` and parse the result.
|
|
210
|
+
|
|
211
|
+
Never raises: a missing binary, a non-OAuth auth mode (API key / Bedrock
|
|
212
|
+
/ Vertex — the plan windows simply don't apply there), a timeout, or
|
|
213
|
+
malformed JSON all come back as ``PlanLimits(status="unknown")`` rather
|
|
214
|
+
than propagating, so a probe failure can never fail a dispatch by
|
|
215
|
+
accident. Costs one ``claude -p`` round trip (~700ms, $0, 0 turns,
|
|
216
|
+
verified against the real CLI) — callers that don't want that cost on
|
|
217
|
+
every call should go through :func:`get_plan_limits` instead.
|
|
218
|
+
"""
|
|
219
|
+
cmd = [resolve_claude_bin(), "-p", "/usage", "--output-format", "json"]
|
|
220
|
+
try:
|
|
221
|
+
result = subprocess.run(
|
|
222
|
+
cmd,
|
|
223
|
+
input="",
|
|
224
|
+
capture_output=True,
|
|
225
|
+
text=True,
|
|
226
|
+
timeout=timeout,
|
|
227
|
+
)
|
|
228
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
229
|
+
return PlanLimits(status="unknown", error=f"{type(exc).__name__}: {exc}")
|
|
230
|
+
|
|
231
|
+
if result.returncode != 0:
|
|
232
|
+
return PlanLimits(
|
|
233
|
+
status="unknown",
|
|
234
|
+
error=f"claude -p /usage exited {result.returncode}: "
|
|
235
|
+
f"{(result.stderr or '').strip()[:300]}",
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
outer = json.loads(result.stdout)
|
|
240
|
+
except (json.JSONDecodeError, TypeError):
|
|
241
|
+
return PlanLimits(status="unknown", error="non-JSON output from claude -p /usage")
|
|
242
|
+
|
|
243
|
+
if not isinstance(outer, dict):
|
|
244
|
+
return PlanLimits(status="unknown", error="unexpected JSON shape from claude -p /usage")
|
|
245
|
+
|
|
246
|
+
text = outer.get("result")
|
|
247
|
+
if not isinstance(text, str):
|
|
248
|
+
return PlanLimits(status="unknown", error="no .result field in claude -p /usage output")
|
|
249
|
+
|
|
250
|
+
return parse_usage_probe_output(text)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dataclass
|
|
254
|
+
class _Cache:
|
|
255
|
+
at: float = 0.0
|
|
256
|
+
limits: PlanLimits = field(default_factory=lambda: _UNKNOWN)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
_cache = _Cache()
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def get_plan_limits(*, force: bool = False, timeout: float = 15.0) -> PlanLimits:
|
|
263
|
+
"""Cached wrapper around :func:`probe_plan_limits` — the entry point
|
|
264
|
+
dispatch gates should use.
|
|
265
|
+
|
|
266
|
+
Cached for :data:`CACHE_TTL_SECS`: the endpoint is itself rate-limited
|
|
267
|
+
and Claude Code already serves stale (up to 60m) bars, so a ``coord
|
|
268
|
+
drive`` poll loop or a ``coord approve`` batch of several proposals must
|
|
269
|
+
share one probe rather than shelling out to ``claude`` per call.
|
|
270
|
+
"""
|
|
271
|
+
now = time.monotonic()
|
|
272
|
+
if not force and (now - _cache.at) < CACHE_TTL_SECS:
|
|
273
|
+
return _cache.limits
|
|
274
|
+
limits = probe_plan_limits(timeout=timeout)
|
|
275
|
+
_cache.at = now
|
|
276
|
+
_cache.limits = limits
|
|
277
|
+
return limits
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def reset_cache() -> None:
|
|
281
|
+
"""Test/CLI-only escape hatch — drop the cached probe."""
|
|
282
|
+
_cache.at = 0.0
|
|
283
|
+
_cache.limits = _UNKNOWN
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ── the gate (pure) ──────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass(frozen=True)
|
|
290
|
+
class UsageGateResult:
|
|
291
|
+
"""What :func:`evaluate_usage_gate` decided, and why."""
|
|
292
|
+
|
|
293
|
+
action: str # "proceed" | "warn" | "block"
|
|
294
|
+
message: str = ""
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def blocks(self) -> bool:
|
|
298
|
+
return self.action == "block"
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _format_trigger(label: str, pct: float, resets_at: str | None) -> str:
|
|
302
|
+
reset_note = f", resets {resets_at}" if resets_at else ""
|
|
303
|
+
return f"{label} {pct:.0f}% used{reset_note}"
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def evaluate_usage_gate(limits: PlanLimits, gate_cfg: "UsageGateConfig") -> UsageGateResult:
|
|
307
|
+
"""Pure decision: given a probed :class:`PlanLimits` and a
|
|
308
|
+
``coord.config.UsageGateConfig``, what should dispatch do?
|
|
309
|
+
|
|
310
|
+
- ``gate_cfg.mode == "disabled"`` (the on/off switch) → always proceed, no
|
|
311
|
+
message — the gate is not consulted at all.
|
|
312
|
+
- ``limits`` not ``ok`` (probe unavailable/unknown, including non-OAuth
|
|
313
|
+
auth) → always proceed. A probe we can't trust must never block or
|
|
314
|
+
even warn — see the module docstring.
|
|
315
|
+
- Below both thresholds → proceed, no message.
|
|
316
|
+
- At/above a threshold → ``"warn"`` or ``"block"`` per ``gate_cfg.mode``
|
|
317
|
+
(default ``"warn"`` — see ``UsageGateConfig`` for why), with a message
|
|
318
|
+
naming which window(s) tripped and their reset time(s).
|
|
319
|
+
"""
|
|
320
|
+
if gate_cfg.mode == "disabled":
|
|
321
|
+
return UsageGateResult("proceed")
|
|
322
|
+
if not limits.ok:
|
|
323
|
+
return UsageGateResult("proceed")
|
|
324
|
+
|
|
325
|
+
triggers: list[str] = []
|
|
326
|
+
if limits.session_pct is not None and limits.session_pct >= gate_cfg.session_threshold_pct:
|
|
327
|
+
triggers.append(_format_trigger("session", limits.session_pct, limits.session_resets_at))
|
|
328
|
+
if limits.week_pct is not None and limits.week_pct >= gate_cfg.week_threshold_pct:
|
|
329
|
+
triggers.append(_format_trigger("week", limits.week_pct, limits.week_resets_at))
|
|
330
|
+
|
|
331
|
+
if not triggers:
|
|
332
|
+
return UsageGateResult("proceed")
|
|
333
|
+
|
|
334
|
+
message = "Max-plan usage near limit: " + "; ".join(triggers)
|
|
335
|
+
action = "block" if gate_cfg.mode == "block" else "warn"
|
|
336
|
+
return UsageGateResult(action, message)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ── human-readable rendering (``coord usage --limits``) ────────────────────
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def format_plan_limits(limits: PlanLimits) -> str:
|
|
343
|
+
"""Render *limits* for ``coord usage --limits``."""
|
|
344
|
+
if not limits.ok:
|
|
345
|
+
detail = f": {limits.error}" if limits.error else ""
|
|
346
|
+
return f"Plan limits: unknown (probe unavailable{detail})"
|
|
347
|
+
|
|
348
|
+
lines = ["Plan limits (subscription 5h/weekly windows):"]
|
|
349
|
+
if limits.session_pct is not None:
|
|
350
|
+
reset = f" (resets {limits.session_resets_at})" if limits.session_resets_at else ""
|
|
351
|
+
lines.append(f" session : {limits.session_pct:.0f}% used{reset}")
|
|
352
|
+
if limits.week_pct is not None:
|
|
353
|
+
reset = f" (resets {limits.week_resets_at})" if limits.week_resets_at else ""
|
|
354
|
+
lines.append(f" week : {limits.week_pct:.0f}% used{reset}")
|
|
355
|
+
for m in limits.week_by_model:
|
|
356
|
+
reset = f" (resets {m.resets_at})" if m.resets_at else ""
|
|
357
|
+
lines.append(f" week ({m.label}): {m.used_pct:.0f}% used{reset}")
|
|
358
|
+
return "\n".join(lines)
|