agentic-runner 3.6.0__tar.gz → 3.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/activities.py +12 -0
  4. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/service.py +78 -1
  5. agentic_runner-3.7.0/src/agentic_runner/usage_windows.py +529 -0
  6. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/acp_runtime.py +15 -1
  7. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/agent_runtime.py +4 -0
  8. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/harness_outcome.py +5 -5
  9. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/.gitignore +0 -0
  10. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/LICENSE +0 -0
  11. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/README.md +0 -0
  12. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/pyproject.toml +0 -0
  13. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/attempts.py +0 -0
  14. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/auth_mode.py +0 -0
  15. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/build.py +0 -0
  16. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/callback.py +0 -0
  17. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/child_watcher.py +0 -0
  18. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/cli.py +0 -0
  19. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/cli_floor.py +0 -0
  20. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/config.py +0 -0
  21. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/credentials.py +0 -0
  22. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/device_login_activities.py +0 -0
  23. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/egress.py +0 -0
  24. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/harness_self_test.py +0 -0
  25. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/heartbeat_link.py +0 -0
  26. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/hooks.py +0 -0
  27. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/host_store.py +0 -0
  28. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/__init__.py +0 -0
  29. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  30. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  31. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  32. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  33. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  34. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  35. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  36. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  37. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  38. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/lifecycle.py +0 -0
  39. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/llm_proxy.py +0 -0
  40. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/mcp.py +0 -0
  41. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/message_store.py +0 -0
  42. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/private_state.py +0 -0
  43. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/py.typed +0 -0
  44. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  45. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/registration.py +0 -0
  46. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/runtime/__init__.py +0 -0
  47. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  48. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/sealed_box.py +0 -0
  49. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/__init__.py +0 -0
  50. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/__main__.py +0 -0
  51. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/control_plane.py +0 -0
  52. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/plugin.py +0 -0
  53. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  54. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/workflow.py +0 -0
  55. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/tiny_http.py +0 -0
  56. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/triage_activities.py +0 -0
  57. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/user_sources.py +0 -0
  58. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/__init__.py +0 -0
  59. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  60. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  61. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/claude_sign_in.py +0 -0
  62. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  63. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/command_policy.py +0 -0
  64. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
  65. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  66. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  67. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  68. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  69. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/settings.py +0 -0
  70. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/skills.py +0 -0
  71. {agentic_runner-3.6.0 → agentic_runner-3.7.0}/src/agentic_runner/workstation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentic-runner
3
- Version: 3.6.0
3
+ Version: 3.7.0
4
4
  Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
@@ -7,6 +7,6 @@ stay on the platform; this package imports none of them, and a CI partition test
7
7
  (``tests/test_package_partition.py``) fails the build if that changes.
8
8
  """
9
9
 
10
- __version__ = "3.6.0"
10
+ __version__ = "3.7.0"
11
11
 
12
12
  __all__ = ["__version__"]
@@ -118,6 +118,7 @@ from agentic_runner.message_store import MessageStore, WorkflowSignaller
118
118
  from agentic_runner.registration import RunnerRegistrationError
119
119
  from agentic_runner.runtime import verifier_command
120
120
  from agentic_runner.runtime.verifier_command import CommandValidationError
121
+ from agentic_runner.usage_windows import UsageWindows
121
122
  from agentic_runner.workers._runtime_support import (
122
123
  RESERVED_DIRECTIVE_ENV,
123
124
  bound_text,
@@ -174,6 +175,7 @@ from agentic_runner_contracts.activity_io import (
174
175
  GitHubCallError,
175
176
  GitHubCallFailure,
176
177
  HarnessHold,
178
+ HarnessHoldKind,
177
179
  LearningDirectiveInput,
178
180
  LearningDirectiveOutput,
179
181
  MemberDirectiveInput,
@@ -1616,10 +1618,13 @@ class RunnerRalphActivities:
1616
1618
  tool_server_health: ToolServerHealthLog | None = None,
1617
1619
  attempt_records: AttemptRecords | None = None,
1618
1620
  directives_in_flight: DirectivesInFlight | None = None,
1621
+ usage_windows: UsageWindows | None = None,
1619
1622
  ) -> None:
1620
1623
  self._fastapi_client = fastapi_client
1621
1624
  # Read by the harness self-test, which never runs beside a Contract's Directive.
1622
1625
  self._directives_in_flight = directives_in_flight or DirectivesInFlight()
1626
+ # Local-agents 10: None off a user-hosted Runner, which reports no usage windows.
1627
+ self._usage_windows = usage_windows
1623
1628
  # Wall-clock source for per-Directive runtime, accrued against the Budget's
1624
1629
  # wall-clock dimension (ADR-0007). Activities run outside the Temporal sandbox,
1625
1630
  # so a real monotonic clock is allowed here (it must never be used in the
@@ -5273,6 +5278,13 @@ class RunnerRalphActivities:
5273
5278
  error=result.error,
5274
5279
  now=datetime.now(UTC),
5275
5280
  )
5281
+ if self._usage_windows is not None and runtime_state.contract_id:
5282
+ self._usage_windows.after_directive(
5283
+ runtime_state.contract_id,
5284
+ runtime_state.cli_kind,
5285
+ result.usage_windows,
5286
+ usage_limit=hold is not None and hold.kind == HarnessHoldKind.USAGE_LIMIT,
5287
+ )
5276
5288
  if hold is None:
5277
5289
  return None
5278
5290
  await self._fastapi_client.append_evidence(
@@ -98,6 +98,12 @@ from agentic_runner.sealed_box import (
98
98
  )
99
99
  from agentic_runner.tiny_http import HttpRequest, read_request, write_json
100
100
  from agentic_runner.triage_activities import RunnerTriageActivities
101
+ from agentic_runner.usage_windows import (
102
+ ClaudeUsageScreenProbe,
103
+ CodexRateLimitsProbe,
104
+ UsageWindowProbe,
105
+ UsageWindows,
106
+ )
101
107
  from agentic_runner.user_sources import ProxyTriage, UserSourcePoller
102
108
  from agentic_runner.workers.acp_runtime import ACP_BRIDGES, AcpRuntime
103
109
  from agentic_runner.workers.agent_runtime import AgentRuntime
@@ -581,6 +587,8 @@ class ControlPlaneStream:
581
587
  runtimes: Mapping[str, AgentRuntime] = field(default_factory=dict)
582
588
  contracts: ContractIsolation | None = None
583
589
  self_tests: HarnessSelfTests | None = None
590
+ # Local-agents 10: each subscription's usage windows; None off a user-hosted Runner.
591
+ usage_windows: UsageWindows | None = None
584
592
  # Local-agents 04b: which delivered references fill the LLM proxy's slot, and where.
585
593
  llm_slots: Mapping[str, LlmProvider] = field(default_factory=lambda: dict(LLM_SLOT_REFERENCES))
586
594
  # Local-agents 05: the Claude Code sign-ins a relayed code is typed into.
@@ -653,6 +661,11 @@ class ControlPlaneStream:
653
661
  if self.self_tests is not None
654
662
  else None
655
663
  ),
664
+ usage_windows=(
665
+ self.usage_windows.windows(contract_id, cli_kind)
666
+ if self.usage_windows is not None and not delivered
667
+ else None
668
+ ),
656
669
  )
657
670
 
658
671
  async def directive_token(self, directive_id: str) -> str:
@@ -1016,6 +1029,9 @@ async def _serve_registered(
1016
1029
  served=runtimes,
1017
1030
  monotonic=time.monotonic,
1018
1031
  )
1032
+ usage_windows = build_usage_windows(
1033
+ host_party=state.host_party, runtimes=runtimes, isolation=isolation, in_flight=in_flight
1034
+ )
1019
1035
  stream = ControlPlaneStream(
1020
1036
  client=RunnerRegistrationClient(base_url=control_plane, client=http_client),
1021
1037
  state=state,
@@ -1043,6 +1059,7 @@ async def _serve_registered(
1043
1059
  runtimes=runtimes,
1044
1060
  contracts=isolation,
1045
1061
  self_tests=self_tests,
1062
+ usage_windows=usage_windows,
1046
1063
  )
1047
1064
  stream.sources = UserSourcePoller(
1048
1065
  stream=SignedIntakeStream(stream.client, state),
@@ -1104,6 +1121,7 @@ async def _serve_registered(
1104
1121
  messages=messages,
1105
1122
  tool_servers=tool_servers,
1106
1123
  sign_ins=sign_ins,
1124
+ usage_windows=usage_windows,
1107
1125
  # The wake signal rides the connection this process polls with: one
1108
1126
  # namespace, the Organisation's own (PRD issue 52, map ticket 15 §2).
1109
1127
  signaller=TemporalWorkflowSignaller(client),
@@ -1121,13 +1139,20 @@ async def _serve_registered(
1121
1139
  self_test = asyncio.create_task(
1122
1140
  _self_test_forever(self_tests, isolation, SELF_TEST_SWEEP_SECONDS)
1123
1141
  )
1142
+ sweeps = [beat, poll, self_test]
1143
+ if usage_windows is not None:
1144
+ sweeps.append(
1145
+ asyncio.create_task(
1146
+ _usage_windows_forever(usage_windows, isolation, proxy, SELF_TEST_SWEEP_SECONDS)
1147
+ )
1148
+ )
1124
1149
  readiness.ready = True
1125
1150
  await run_runner_lifecycle_hook(hooks, HookName.RUNNER_STARTUP)
1126
1151
  try:
1127
1152
  await run_worker_until_terminated(worker, stop=stop)
1128
1153
  finally:
1129
1154
  readiness.ready = False
1130
- for task in (beat, poll, self_test):
1155
+ for task in sweeps:
1131
1156
  task.cancel()
1132
1157
  with contextlib.suppress(asyncio.CancelledError):
1133
1158
  await task
@@ -1227,6 +1252,56 @@ async def _self_test_forever(
1227
1252
  await asyncio.sleep(interval)
1228
1253
 
1229
1254
 
1255
+ def build_usage_windows(
1256
+ *,
1257
+ host_party: str,
1258
+ runtimes: Mapping[str, AgentRuntime],
1259
+ isolation: ContractIsolation,
1260
+ in_flight: DirectivesInFlight,
1261
+ ) -> UsageWindows | None:
1262
+ """A probe per served harness, or None off a user-hosted Runner (local-agents 10).
1263
+
1264
+ Subscriptions follow the person (local-agents 04): only a user-hosted Runner runs one,
1265
+ so only there is a usage window anyone's to read.
1266
+ """
1267
+
1268
+ if host_party != "user":
1269
+ return None
1270
+ probes: dict[str, UsageWindowProbe] = {
1271
+ "codex_cli": CodexRateLimitsProbe(),
1272
+ "claude_code": ClaudeUsageScreenProbe(),
1273
+ }
1274
+ return UsageWindows(
1275
+ probes={kind: probe for kind, probe in probes.items() if kind in runtimes},
1276
+ sandbox_for=lambda contract_id, cli_kind: isolation.existing_sandbox(
1277
+ contract_id, runtime_kind=cli_kind
1278
+ ),
1279
+ in_flight=in_flight,
1280
+ monotonic=time.monotonic,
1281
+ )
1282
+
1283
+
1284
+ async def _usage_windows_forever(
1285
+ usage_windows: UsageWindows, isolation: ContractIsolation, proxy: LlmProxy, interval: float
1286
+ ) -> None:
1287
+ """Each signed-in harness root's usage windows, whenever its probe falls due
1288
+ (local-agents 10). A root with a delivered key runs on the proxy, not a subscription."""
1289
+
1290
+ while True:
1291
+ delivered = {
1292
+ (str(slot.contract_id), slot.runtime_kind)
1293
+ for slot in proxy.slots.statuses()
1294
+ if slot.present
1295
+ }
1296
+ try:
1297
+ await usage_windows.run_due(
1298
+ [root for root in isolation.harness_roots() if root not in delivered]
1299
+ )
1300
+ except Exception as error: # noqa: BLE001 - a lost sweep is retried on the next one
1301
+ _logger.warning("usage window sweep failed: %s", error)
1302
+ await asyncio.sleep(interval)
1303
+
1304
+
1230
1305
  def _activities(
1231
1306
  fastapi: RunnerFastApiClient,
1232
1307
  *,
@@ -1245,6 +1320,7 @@ def _activities(
1245
1320
  signaller: TemporalWorkflowSignaller | None = None,
1246
1321
  tool_servers: ToolServerHealthLog | None = None,
1247
1322
  sign_ins: ClaudeSignIns | None = None,
1323
+ usage_windows: UsageWindows | None = None,
1248
1324
  ) -> list[Callable[..., Any]]:
1249
1325
  github_client = GitHubAppClient(
1250
1326
  app_id=os.getenv("GITHUB_APP_ID"),
@@ -1292,6 +1368,7 @@ def _activities(
1292
1368
  tool_server_health=tool_servers,
1293
1369
  attempt_records=attempt_records,
1294
1370
  directives_in_flight=directives_in_flight,
1371
+ usage_windows=usage_windows,
1295
1372
  )
1296
1373
  return [
1297
1374
  *ralph.activity_callables(),
@@ -0,0 +1,529 @@
1
+ """How much of each subscription usage window a Contract has used (local-agents 10).
2
+
3
+ Asked of the vendor's own binary, as the Contract's uid in its harness root -- never of the
4
+ vendor's usage endpoint with the person's bearer (Anthropic's ``api/oauth/usage``,
5
+ OpenAI's ``wham/usage``), which would put that bearer in the Runner's hands. No adapter
6
+ spends model tokens. One adapter per harness behind one port (PRD decision 11):
7
+
8
+ - **Codex:** ``account/rateLimits/read`` over ``codex app-server``. codex-acp keeps rate
9
+ limits in its session state and says nothing structured about them over ACP.
10
+ - **Claude Code:** the rate-limit events the CLI emits from its responses' headers, which
11
+ claude-agent-acp (pinned 0.88.0, ``acp-agent.js``) forwards as a ``usage_update`` whose
12
+ ``_meta["_claude/rateLimit"]`` is the CLI's ``rate_limit_info``. The ACP runtime reads
13
+ them off the Directive's own stream, and a Contract whose stream has carried them is
14
+ never probed. Until then, the ``/usage`` screen under a PTY, as Paperclip reads it
15
+ (``claude-local/src/server/quota.ts``).
16
+
17
+ At most once per interval per Contract, never while it has a Directive running, and again
18
+ after a Directive that stopped on its usage limit. A probe that fails or times out reports
19
+ nothing until one succeeds; it never touches a Directive.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import asyncio
25
+ import contextlib
26
+ import fcntl
27
+ import json
28
+ import logging
29
+ import math
30
+ import os
31
+ import pty
32
+ import re
33
+ import signal
34
+ import struct
35
+ import subprocess
36
+ import termios
37
+ from collections.abc import Callable, Mapping, Sequence
38
+ from datetime import UTC, datetime
39
+ from typing import Any, Final, Protocol
40
+
41
+ from agentic_runner.harness_self_test import DirectivesInFlight, SandboxFor
42
+ from agentic_runner.workers._runtime_support import terminate_process_tree
43
+ from agentic_runner.workers.claude_sign_in import launcher_argv
44
+ from agentic_runner.workers.contract_isolation import ContractIsolationError, DirectiveSandbox
45
+ from agentic_runner.workers.harness_outcome import next_wall_clock, wall_clock_zone
46
+ from agentic_runner_contracts.runner_registration import UsageWindow
47
+
48
+ __all__ = [
49
+ "USAGE_PROBE_INTERVAL_SECONDS",
50
+ "ClaudeUsageScreenProbe",
51
+ "CodexRateLimitsProbe",
52
+ "UsageWindowProbe",
53
+ "UsageWindows",
54
+ "claude_rate_limit_windows",
55
+ "claude_usage_screen_windows",
56
+ "codex_rate_limit_windows",
57
+ ]
58
+
59
+ USAGE_PROBE_INTERVAL_SECONDS: Final = 15 * 60
60
+ _PROBE_TIMEOUT_SECONDS: Final = 20.0
61
+ _NAME: Final = re.compile(r"^[a-z][a-z0-9_]{0,31}$")
62
+
63
+ _logger = logging.getLogger(__name__)
64
+
65
+
66
+ class UsageWindowProbe(Protocol):
67
+ """One harness's way of saying how much of each window is used, run in ``sandbox``."""
68
+
69
+ async def __call__(self, sandbox: DirectiveSandbox) -> list[UsageWindow]: ...
70
+
71
+
72
+ class UsageWindows:
73
+ """The latest windows per ``(contract_id, cli_kind)``, and when the next probe is due."""
74
+
75
+ def __init__(
76
+ self,
77
+ *,
78
+ probes: Mapping[str, UsageWindowProbe],
79
+ sandbox_for: SandboxFor,
80
+ in_flight: DirectivesInFlight,
81
+ monotonic: Callable[[], float],
82
+ clock: Callable[[], datetime] = lambda: datetime.now(UTC),
83
+ interval_seconds: float = USAGE_PROBE_INTERVAL_SECONDS,
84
+ ) -> None:
85
+ self._probes = dict(probes)
86
+ self._sandbox_for = sandbox_for
87
+ self._in_flight = in_flight
88
+ self._monotonic = monotonic
89
+ self._clock = clock
90
+ self._interval = interval_seconds
91
+ self._windows: dict[tuple[str, str], list[UsageWindow]] = {}
92
+ self._last_run: dict[tuple[str, str], float] = {}
93
+ self._streamed: set[tuple[str, str]] = set()
94
+
95
+ def windows(self, contract_id: str, cli_kind: str) -> list[UsageWindow] | None:
96
+ """``None`` until a window is known; a window whose reset has passed is dropped,
97
+ since its percentage no longer describes anything."""
98
+
99
+ now = self._clock()
100
+ current = [
101
+ window
102
+ for window in self._windows.get((contract_id, cli_kind), [])
103
+ if window.resets_at is None or window.resets_at > now
104
+ ]
105
+ return current or None
106
+
107
+ def after_directive(
108
+ self,
109
+ contract_id: str,
110
+ cli_kind: str,
111
+ streamed: Sequence[UsageWindow],
112
+ *,
113
+ usage_limit: bool,
114
+ ) -> None:
115
+ """What a subscription Directive's own stream said, and whether issue 08 found it
116
+ stopped on its usage limit -- which makes the next probe due at once."""
117
+
118
+ key = (contract_id, cli_kind)
119
+ if streamed:
120
+ self._windows[key] = list(streamed)
121
+ self._streamed.add(key)
122
+ self._last_run[key] = self._monotonic()
123
+ if usage_limit:
124
+ self._last_run.pop(key, None)
125
+
126
+ async def run_due(self, targets: Sequence[tuple[str, str]]) -> None:
127
+ wanted = set(targets)
128
+ for gone in (self._windows.keys() | self._last_run.keys() | self._streamed) - wanted:
129
+ self._forget(gone)
130
+ for contract_id, cli_kind in targets:
131
+ key = (contract_id, cli_kind)
132
+ probe = self._probes.get(cli_kind)
133
+ if probe is None or key in self._streamed:
134
+ continue
135
+ last = self._last_run.get(key)
136
+ if last is not None and self._monotonic() - last < self._interval:
137
+ continue
138
+ if self._in_flight.busy(contract_id):
139
+ # Not stamped: it runs on the first sweep after the Directive ends.
140
+ continue
141
+ self._last_run[key] = self._monotonic()
142
+ self._windows[key] = await self._probe(probe, contract_id, cli_kind)
143
+
144
+ def _forget(self, key: tuple[str, str]) -> None:
145
+ self._windows.pop(key, None)
146
+ self._last_run.pop(key, None)
147
+ self._streamed.discard(key)
148
+
149
+ async def _probe(
150
+ self, probe: UsageWindowProbe, contract_id: str, cli_kind: str
151
+ ) -> list[UsageWindow]:
152
+ try:
153
+ sandbox = self._sandbox_for(contract_id, cli_kind)
154
+ if sandbox is None:
155
+ return []
156
+ return await probe(sandbox)
157
+ except (
158
+ ContractIsolationError,
159
+ OSError,
160
+ TimeoutError,
161
+ subprocess.SubprocessError,
162
+ ValueError,
163
+ ) as error:
164
+ # The type only: a harness's error text can carry the account it signed in as.
165
+ _logger.warning(
166
+ "usage window probe for %s/%s failed: %s",
167
+ contract_id,
168
+ cli_kind,
169
+ error.__class__.__name__,
170
+ )
171
+ return []
172
+
173
+
174
+ # ------------------------------------------------------------------ Codex
175
+
176
+
177
+ class CodexRateLimitsProbe:
178
+ """``account/rateLimits/read`` over ``codex app-server``, as the Contract's uid.
179
+
180
+ The app-server protocol is JSON-RPC without the ``jsonrpc`` member, one object per
181
+ line, and interleaves notifications with the responses (codex-cli 0.159.2).
182
+ """
183
+
184
+ def __init__(
185
+ self,
186
+ *,
187
+ argv: Sequence[str] = ("codex", "app-server"),
188
+ timeout_seconds: float = _PROBE_TIMEOUT_SECONDS,
189
+ ) -> None:
190
+ self._argv = list(argv)
191
+ self._timeout = timeout_seconds
192
+
193
+ async def __call__(self, sandbox: DirectiveSandbox) -> list[UsageWindow]:
194
+ process = await asyncio.create_subprocess_exec(
195
+ *self._argv,
196
+ cwd=sandbox.home_dir,
197
+ env=_harness_env(sandbox, "CODEX_HOME"),
198
+ stdin=asyncio.subprocess.PIPE,
199
+ stdout=asyncio.subprocess.PIPE,
200
+ stderr=asyncio.subprocess.DEVNULL,
201
+ start_new_session=True,
202
+ limit=1024 * 1024,
203
+ **sandbox.spawn_kwargs(),
204
+ )
205
+ assert process.stdin is not None and process.stdout is not None
206
+ stdin, stdout = process.stdin, process.stdout
207
+
208
+ async def request(request_id: int, method: str, params: object) -> Mapping[str, Any]:
209
+ stdin.write(_line({"id": request_id, "method": method, "params": params}))
210
+ await stdin.drain()
211
+ while line := await stdout.readline():
212
+ with contextlib.suppress(ValueError):
213
+ message = json.loads(line)
214
+ if isinstance(message, dict) and message.get("id") == request_id:
215
+ return message
216
+ raise ConnectionError("codex app-server closed its stdout")
217
+
218
+ try:
219
+ async with asyncio.timeout(self._timeout):
220
+ await request(
221
+ 1, "initialize", {"clientInfo": {"name": "agentic-runner", "version": "1"}}
222
+ )
223
+ stdin.write(_line({"method": "initialized"}))
224
+ answer = await request(2, "account/rateLimits/read", None)
225
+ finally:
226
+ await terminate_process_tree(process)
227
+ # An error -- ``-32600`` "codex account authentication required" when the root has
228
+ # no login -- has no ``result``, and so no windows.
229
+ return codex_rate_limit_windows(answer.get("result"))
230
+
231
+
232
+ _CODEX_WINDOW_NAMES: Final[Mapping[int, str]] = {300: "five_hour", 10_080: "seven_day"}
233
+
234
+
235
+ def codex_rate_limit_windows(result: object) -> list[UsageWindow]:
236
+ """``rateLimits.primary``/``secondary`` from a ``GetAccountRateLimitsResponse``.
237
+
238
+ Named by their duration where it is a window Codex is known to have, else by slot.
239
+ ``usedPercent`` is already a percentage, ``resetsAt`` epoch seconds.
240
+ """
241
+
242
+ snapshot = result.get("rateLimits") if isinstance(result, Mapping) else None
243
+ if not isinstance(snapshot, Mapping):
244
+ return []
245
+ windows: list[UsageWindow] = []
246
+ for slot in ("primary", "secondary"):
247
+ window = snapshot.get(slot)
248
+ if not isinstance(window, Mapping):
249
+ continue
250
+ used = _number(window.get("usedPercent"))
251
+ if used is None:
252
+ continue
253
+ minutes = window.get("windowDurationMins")
254
+ name = _CODEX_WINDOW_NAMES.get(minutes, slot) if isinstance(minutes, int) else slot
255
+ windows.append(
256
+ UsageWindow(
257
+ name=name, used_percent=_clamped(used), resets_at=_epoch(window.get("resetsAt"))
258
+ )
259
+ )
260
+ return windows
261
+
262
+
263
+ # ------------------------------------------------------------------ Claude Code
264
+
265
+
266
+ def claude_rate_limit_windows(info: object) -> list[UsageWindow]:
267
+ """The windows in one ``rate_limit_info`` (claude 2.1.295's ``rate_limit_event``).
268
+
269
+ ``unifiedWindows`` carries every window on every event; without it, the top-level
270
+ fields describe the one currently limiting. ``utilization`` is a fraction of the window
271
+ and can run past 1.
272
+ """
273
+
274
+ if not isinstance(info, Mapping):
275
+ return []
276
+ unified = info.get("unifiedWindows")
277
+ if isinstance(unified, Mapping) and unified:
278
+ pairs = list(unified.items())
279
+ else:
280
+ pairs = [(info.get("rateLimitType"), info)]
281
+ windows: list[UsageWindow] = []
282
+ for name, window in pairs:
283
+ if not isinstance(name, str) or not _NAME.match(name) or not isinstance(window, Mapping):
284
+ continue
285
+ utilization = _number(window.get("utilization"))
286
+ if utilization is None:
287
+ continue
288
+ windows.append(
289
+ UsageWindow(
290
+ name=name,
291
+ used_percent=_clamped(utilization * 100),
292
+ resets_at=_epoch(window.get("resetsAt")),
293
+ )
294
+ )
295
+ return windows
296
+
297
+
298
+ class ClaudeUsageScreenProbe:
299
+ """``/usage`` typed into an interactive ``claude`` on a PTY, as the Contract's uid.
300
+
301
+ Behind the sign-in launcher, so the CLI reads the Contract's own login and the Runner
302
+ reads nothing under the harness root. Nothing is prompted: ``/usage`` is a local
303
+ command and the session is killed once the screen has drawn, or at the timeout.
304
+ """
305
+
306
+ def __init__(
307
+ self,
308
+ *,
309
+ argv: Sequence[str] = ("claude",),
310
+ timeout_seconds: float = _PROBE_TIMEOUT_SECONDS,
311
+ settle_seconds: float = 2.0,
312
+ clock: Callable[[], datetime] = lambda: datetime.now(UTC),
313
+ ) -> None:
314
+ self._argv = list(argv)
315
+ self._timeout = timeout_seconds
316
+ self._settle = settle_seconds
317
+ self._clock = clock
318
+
319
+ async def __call__(self, sandbox: DirectiveSandbox) -> list[UsageWindow]:
320
+ master_fd, slave_fd = pty.openpty()
321
+ try:
322
+ fcntl.ioctl(slave_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 50, 200, 0, 0))
323
+ os.set_blocking(master_fd, False)
324
+ process = await asyncio.create_subprocess_exec(
325
+ *launcher_argv(sandbox.harness_config_dir, self._argv),
326
+ cwd=sandbox.home_dir,
327
+ env=_harness_env(sandbox, "CLAUDE_CONFIG_DIR") | {"TERM": "xterm-256color"},
328
+ stdin=slave_fd,
329
+ stdout=slave_fd,
330
+ stderr=slave_fd,
331
+ start_new_session=True,
332
+ **sandbox.spawn_kwargs(),
333
+ )
334
+ except BaseException:
335
+ os.close(master_fd)
336
+ raise
337
+ finally:
338
+ os.close(slave_fd)
339
+ screen = bytearray()
340
+ windows: list[UsageWindow] = []
341
+ try:
342
+ with contextlib.suppress(TimeoutError):
343
+ async with asyncio.timeout(self._timeout):
344
+ # The prompt must be up before it can take a command.
345
+ await asyncio.sleep(self._settle)
346
+ _drain(master_fd, screen)
347
+ os.write(master_fd, b"/usage")
348
+ # Ink reads a command and its Enter as one paste and drops the Enter.
349
+ await asyncio.sleep(0.15)
350
+ os.write(master_fd, b"\r")
351
+ while not _screen_complete(windows):
352
+ await asyncio.sleep(0.25)
353
+ _drain(master_fd, screen)
354
+ windows = claude_usage_screen_windows(
355
+ screen.decode("utf-8", errors="replace"), self._clock()
356
+ )
357
+ finally:
358
+ with contextlib.suppress(ProcessLookupError, PermissionError):
359
+ os.killpg(process.pid, signal.SIGKILL)
360
+ with contextlib.suppress(TimeoutError):
361
+ await asyncio.wait_for(process.wait(), timeout=2)
362
+ os.close(master_fd)
363
+ return windows
364
+
365
+
366
+ _SCREEN_LABELS: Final[Mapping[str, str]] = {
367
+ "currentsession": "five_hour",
368
+ "currentweekallmodels": "seven_day",
369
+ "currentweeksonnetonly": "seven_day_sonnet",
370
+ "currentweeksonnet": "seven_day_sonnet",
371
+ "currentweekopusonly": "seven_day_opus",
372
+ "currentweekopus": "seven_day_opus",
373
+ }
374
+ _ANSI: Final = re.compile(
375
+ r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC, e.g. a hyperlink
376
+ r"|\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])" # CSI and the two-byte escapes
377
+ )
378
+ _PERCENT: Final = re.compile(r"(\d{1,3}(?:\.\d+)?)\s*%\s*(used|left|remaining|available)?", re.I)
379
+ # ``Resets 5pm (America/Chicago)``, ``Resets Mar 18 at 7:59am (America/Chicago)``; Ink can
380
+ # drop the spaces between the parts.
381
+ _RESET: Final = re.compile(
382
+ r"^resets\s*(?:(?P<month>[a-z]{3})[a-z]*\s*(?P<day>\d{1,2}),?\s*(?:at\s*)?)?"
383
+ r"(?P<clock>\d{1,2}(?::\d{2})?\s*[ap]\.?\s*m\.?)\s*(?:\((?P<zone>[^)]+)\))?",
384
+ re.I,
385
+ )
386
+ _MONTHS: Final = (
387
+ "jan",
388
+ "feb",
389
+ "mar",
390
+ "apr",
391
+ "may",
392
+ "jun",
393
+ "jul",
394
+ "aug",
395
+ "sep",
396
+ "oct",
397
+ "nov",
398
+ "dec",
399
+ )
400
+
401
+
402
+ def claude_usage_screen_windows(text: str, now: datetime) -> list[UsageWindow]:
403
+ """The windows on the latest ``/usage`` screen in a PTY capture.
404
+
405
+ Each label is followed by its ``N% used`` line and its ``Resets …`` line. A window the
406
+ screen draws without a percentage -- ``Extra usage``, or one still loading -- is left
407
+ out, and so is a capture with no such screen at all.
408
+ """
409
+
410
+ lines = [line.strip() for line in _clean(text).split("\n")]
411
+ starts = [i for i, line in enumerate(lines) if _label(line) == "currentsession"]
412
+ if not starts:
413
+ return []
414
+ windows: dict[str, UsageWindow] = {}
415
+ name: str | None = None
416
+ used: float | None = None
417
+ for line in lines[starts[-1] :]:
418
+ label = _label(line)
419
+ if label in _SCREEN_LABELS:
420
+ name, used = _SCREEN_LABELS[label], None
421
+ elif name is None or not line:
422
+ continue
423
+ elif used is None and (percent := _PERCENT.search(line)):
424
+ value = float(percent.group(1))
425
+ remaining = (percent.group(2) or "").lower() in {"left", "remaining", "available"}
426
+ used = _clamped(100 - value if remaining else value)
427
+ windows[name] = UsageWindow(name=name, used_percent=used)
428
+ elif used is not None and (reset := _RESET.match(line)):
429
+ windows[name] = UsageWindow(
430
+ name=name, used_percent=used, resets_at=_screen_reset(reset, now)
431
+ )
432
+ name = None
433
+ return list(windows.values())
434
+
435
+
436
+ def _screen_complete(windows: Sequence[UsageWindow]) -> bool:
437
+ names = {window.name for window in windows}
438
+ return "five_hour" in names and "seven_day" in names
439
+
440
+
441
+ def _screen_reset(match: re.Match[str], now: datetime) -> datetime | None:
442
+ clock, zone = match.group("clock"), match.group("zone")
443
+ if zone:
444
+ clock = f"{clock} ({zone})"
445
+ if match.group("month") is None:
446
+ at = next_wall_clock(clock, now)
447
+ return datetime.fromisoformat(at) if at else None
448
+ month = match.group("month").lower()
449
+ if month not in _MONTHS:
450
+ return None
451
+ # The time on the screen's date: the next such time after the date's midnight.
452
+ local_now = now.astimezone(wall_clock_zone(zone))
453
+ try:
454
+ midnight = local_now.replace(
455
+ month=_MONTHS.index(month) + 1,
456
+ day=int(match.group("day")),
457
+ hour=0,
458
+ minute=0,
459
+ second=0,
460
+ microsecond=0,
461
+ )
462
+ except ValueError:
463
+ return None
464
+ if midnight.date() < local_now.date():
465
+ # A reset is never in the past: ``Jan 2`` seen on Dec 30 is next year's.
466
+ midnight = midnight.replace(year=midnight.year + 1)
467
+ at = next_wall_clock(clock, midnight)
468
+ return datetime.fromisoformat(at) if at else None
469
+
470
+
471
+ def _clean(text: str) -> str:
472
+ kept: list[str] = []
473
+ for char in text:
474
+ if char == "\b":
475
+ if kept:
476
+ kept.pop()
477
+ else:
478
+ kept.append(char)
479
+ return _ANSI.sub("", "".join(kept)).replace("\x00", "").replace("\r", "\n")
480
+
481
+
482
+ def _label(line: str) -> str:
483
+ return re.sub(r"[^a-z0-9]+", "", line.lower())
484
+
485
+
486
+ def _drain(master_fd: int, screen: bytearray) -> None:
487
+ with contextlib.suppress(BlockingIOError, OSError):
488
+ while chunk := os.read(master_fd, 65_536):
489
+ screen.extend(chunk)
490
+ # The latest screen is at the end; a CLI that redraws forever is bounded here.
491
+ del screen[:-262_144]
492
+
493
+
494
+ # ------------------------------------------------------------------ shared
495
+
496
+
497
+ def _harness_env(sandbox: DirectiveSandbox, root_env: str) -> dict[str, str]:
498
+ """The four names every probe gets and nothing else: no bearer, no API key, no proxy."""
499
+
500
+ return {
501
+ "PATH": os.environ.get("PATH") or os.defpath,
502
+ "HOME": str(sandbox.home_dir),
503
+ "TMPDIR": str(sandbox.tmp_dir),
504
+ root_env: str(sandbox.harness_config_dir),
505
+ }
506
+
507
+
508
+ def _line(message: Mapping[str, object]) -> bytes:
509
+ return (json.dumps(message) + "\n").encode()
510
+
511
+
512
+ def _number(value: object) -> float | None:
513
+ if isinstance(value, bool) or not isinstance(value, int | float):
514
+ return None
515
+ return float(value) if math.isfinite(value) else None
516
+
517
+
518
+ def _clamped(percent: float) -> float:
519
+ return round(min(100.0, max(0.0, percent)), 1)
520
+
521
+
522
+ def _epoch(value: object) -> datetime | None:
523
+ seconds = _number(value)
524
+ if seconds is None or seconds <= 0:
525
+ return None
526
+ try:
527
+ return datetime.fromtimestamp(seconds, UTC)
528
+ except (OverflowError, OSError, ValueError):
529
+ return None
@@ -61,6 +61,7 @@ from agentic_runner.attempts import (
61
61
  )
62
62
  from agentic_runner.callback import CALLBACK_SOCKET_ENV, CALLBACK_TOKEN_ENV, call
63
63
  from agentic_runner.llm_proxy import PROXY_API_KEY_ENV, PROXY_BASE_URL_ENV
64
+ from agentic_runner.usage_windows import claude_rate_limit_windows
64
65
  from agentic_runner.workers._runtime_support import (
65
66
  apply_extra_env,
66
67
  bound_text,
@@ -85,6 +86,7 @@ from agentic_runner.workers.command_policy import CommandPolicy, evaluate_comman
85
86
  from agentic_runner.workers.contract_isolation import DirectiveSandbox
86
87
  from agentic_runner.workers.mcp_config import McpServerEntry
87
88
  from agentic_runner.workers.settings import WorkerSettings
89
+ from agentic_runner_contracts.runner_registration import UsageWindow
88
90
 
89
91
  __all__ = ["ACP_BRIDGES", "AcpBridge", "AcpRuntime"]
90
92
 
@@ -218,6 +220,7 @@ class _Turn:
218
220
  output: list[str] = field(default_factory=list)
219
221
  notes: list[str] = field(default_factory=list)
220
222
  held: bool = False
223
+ usage_windows: tuple[UsageWindow, ...] = ()
221
224
 
222
225
 
223
226
  class AcpRuntime:
@@ -594,6 +597,7 @@ class AcpRuntime:
594
597
  request, directive_workspace_id, command_hash, guard_mode, notes
595
598
  ),
596
599
  model_usage=model_usage,
600
+ usage_windows=turn.usage_windows,
597
601
  )
598
602
 
599
603
  def _evidence(
@@ -883,7 +887,17 @@ def _offers_mode(session: Mapping[str, Any], mode_id: str) -> bool:
883
887
 
884
888
  def _collect_update(params: Mapping[str, Any], turn: _Turn) -> None:
885
889
  update = params.get("update")
886
- if not isinstance(update, Mapping) or update.get("sessionUpdate") != "agent_message_chunk":
890
+ if not isinstance(update, Mapping):
891
+ return
892
+ if update.get("sessionUpdate") == "usage_update":
893
+ # claude-agent-acp's relay of the CLI's rate-limit event; the latest one wins.
894
+ meta = update.get("_meta")
895
+ if isinstance(meta, Mapping) and (
896
+ windows := claude_rate_limit_windows(meta.get("_claude/rateLimit"))
897
+ ):
898
+ turn.usage_windows = tuple(windows)
899
+ return
900
+ if update.get("sessionUpdate") != "agent_message_chunk":
887
901
  return
888
902
  content = update.get("content")
889
903
  if isinstance(content, Mapping) and content.get("type") == "text":
@@ -20,6 +20,7 @@ from typing import Protocol, runtime_checkable
20
20
 
21
21
  from agentic_runner.workers.contract_isolation import DirectiveSandbox
22
22
  from agentic_runner.workers.mcp_config import McpServerEntry
23
+ from agentic_runner_contracts.runner_registration import UsageWindow
23
24
 
24
25
 
25
26
  class AuthMode(StrEnum):
@@ -131,6 +132,9 @@ class DirectiveResult:
131
132
  # The harness's own per-model usage, where its protocol carries one (ACP). Empty means
132
133
  # the caller reads usage off ``stdout`` as before.
133
134
  model_usage: tuple[ModelUsage, ...] = ()
135
+ # The subscription usage windows the harness's own stream last reported, where it
136
+ # carries them (claude-agent-acp's rate-limit events, local-agents 10).
137
+ usage_windows: tuple[UsageWindow, ...] = ()
134
138
 
135
139
 
136
140
  # The permission mode of a runtime whose guard configuration is incomplete: it refuses
@@ -23,7 +23,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
23
23
  from agentic_runner.workers.harness_usage import extract_claude_result
24
24
  from agentic_runner_contracts.activity_io import HarnessHold, HarnessHoldKind
25
25
 
26
- __all__ = ["classify_harness_outcome"]
26
+ __all__ = ["classify_harness_outcome", "next_wall_clock", "wall_clock_zone"]
27
27
 
28
28
  _CLAUDE_CODE: Final[str] = "claude_code"
29
29
  _CODEX_CLI: Final[str] = "codex_cli"
@@ -121,7 +121,7 @@ def classify_harness_outcome(
121
121
  clock = reset.search(text, limit.end())
122
122
  return HarnessHold(
123
123
  kind=HarnessHoldKind.USAGE_LIMIT,
124
- retry_not_before=_next_wall_clock(clock.group("clock"), now) if clock else None,
124
+ retry_not_before=next_wall_clock(clock.group("clock"), now) if clock else None,
125
125
  )
126
126
 
127
127
 
@@ -185,7 +185,7 @@ def _joined(*parts: str) -> str:
185
185
  return "\n".join(part.strip() for part in parts if part and part.strip())
186
186
 
187
187
 
188
- def _next_wall_clock(text: str, now: datetime) -> str | None:
188
+ def next_wall_clock(text: str, now: datetime) -> str | None:
189
189
  match = _CLOCK_RE.match(text.strip())
190
190
  if match is None:
191
191
  return None
@@ -193,14 +193,14 @@ def _next_wall_clock(text: str, now: datetime) -> str | None:
193
193
  if not 1 <= hour <= 12 or not 0 <= minute <= 59:
194
194
  return None
195
195
  hour = hour % 12 + (12 if match.group("half").lower() == "p" else 0)
196
- local_now = now.astimezone(_zone(match.group("zone")))
196
+ local_now = now.astimezone(wall_clock_zone(match.group("zone")))
197
197
  candidate = local_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
198
198
  if candidate <= local_now:
199
199
  candidate += timedelta(days=1)
200
200
  return candidate.astimezone(UTC).isoformat()
201
201
 
202
202
 
203
- def _zone(name: str | None) -> tzinfo | None:
203
+ def wall_clock_zone(name: str | None) -> tzinfo | None:
204
204
  """The zone the harness named, or None -- ``astimezone``'s "this host's own"."""
205
205
 
206
206
  if not name:
File without changes
File without changes