agentic-runner 3.5.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.5.0 → agentic_runner-3.7.0}/PKG-INFO +1 -1
  2. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/__init__.py +1 -1
  3. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/activities.py +12 -0
  4. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/device_login_activities.py +30 -12
  5. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/sealed_box.py +15 -4
  6. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/service.py +123 -3
  7. agentic_runner-3.7.0/src/agentic_runner/usage_windows.py +529 -0
  8. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/acp_runtime.py +15 -1
  9. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/agent_runtime.py +4 -0
  10. agentic_runner-3.7.0/src/agentic_runner/workers/claude_sign_in.py +534 -0
  11. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/contract_device_login.py +32 -14
  12. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/harness_outcome.py +5 -5
  13. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/.gitignore +0 -0
  14. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/LICENSE +0 -0
  15. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/README.md +0 -0
  16. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/pyproject.toml +0 -0
  17. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/attempts.py +0 -0
  18. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/auth_mode.py +0 -0
  19. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/build.py +0 -0
  20. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/callback.py +0 -0
  21. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/child_watcher.py +0 -0
  22. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/cli.py +0 -0
  23. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/cli_floor.py +0 -0
  24. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/config.py +0 -0
  25. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/credentials.py +0 -0
  26. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/egress.py +0 -0
  27. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/harness_self_test.py +0 -0
  28. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/heartbeat_link.py +0 -0
  29. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/hooks.py +0 -0
  30. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/host_store.py +0 -0
  31. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/__init__.py +0 -0
  32. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
  33. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
  34. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
  35. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
  36. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
  37. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
  38. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/auth.py +0 -0
  39. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
  40. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
  41. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/lifecycle.py +0 -0
  42. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/llm_proxy.py +0 -0
  43. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/mcp.py +0 -0
  44. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/message_store.py +0 -0
  45. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/private_state.py +0 -0
  46. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/py.typed +0 -0
  47. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/recipient_key_secret.py +0 -0
  48. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/registration.py +0 -0
  49. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/runtime/__init__.py +0 -0
  50. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
  51. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/__init__.py +0 -0
  52. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/__main__.py +0 -0
  53. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/control_plane.py +0 -0
  54. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/plugin.py +0 -0
  55. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/test_conformance.py +0 -0
  56. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/testing/workflow.py +0 -0
  57. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/tiny_http.py +0 -0
  58. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/triage_activities.py +0 -0
  59. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/user_sources.py +0 -0
  60. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/__init__.py +0 -0
  61. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
  62. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
  63. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
  64. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/command_policy.py +0 -0
  65. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
  66. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
  67. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/harness_usage.py +0 -0
  68. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/mcp_config.py +0 -0
  69. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/settings.py +0 -0
  70. {agentic_runner-3.5.0 → agentic_runner-3.7.0}/src/agentic_runner/workers/skills.py +0 -0
  71. {agentic_runner-3.5.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.5.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.5.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(
@@ -13,7 +13,8 @@ from datetime import UTC, datetime
13
13
  from temporalio import activity
14
14
  from temporalio.exceptions import ApplicationError
15
15
 
16
- from agentic_runner.auth_mode import SHARED_RUNNER_SIGN_IN_RULE
16
+ from agentic_runner.auth_mode import SHARED_RUNNER_SIGN_IN_RULE, is_shared_runner
17
+ from agentic_runner.workers.claude_sign_in import OAUTH_TOKEN
17
18
  from agentic_runner.workers.contract_device_login import ContractDeviceLogin
18
19
  from agentic_runner.workers.contract_isolation import ContractIsolation
19
20
  from agentic_runner_contracts.activity_io import (
@@ -36,10 +37,10 @@ class ContractDeviceLoginActivities:
36
37
  host_party: str | None = None,
37
38
  ) -> None:
38
39
  self._contract_isolation = contract_isolation
39
- # The party this process was registered as hosted by. A shared Runner never runs a
40
- # subscription, so it never starts or reads one either (local-agents 04).
40
+ # The party this process was registered as hosted by. A shared Runner signs in only
41
+ # a Claude Code long-lived token (local-agents 04, amended by 21).
41
42
  self._host_party = host_party
42
- # Injectable for tests; None falls through to the real `codex` subprocess.
43
+ # Injectable for tests; None falls through to the real vendor CLIs.
43
44
  self._device_login = device_login or ContractDeviceLogin(contract_isolation)
44
45
 
45
46
  def activity_callables(self) -> list[Callable[..., object]]:
@@ -52,9 +53,12 @@ class ContractDeviceLoginActivities:
52
53
  async def sign_in_contract_device_login(
53
54
  self, request: ContractDeviceLoginInput
54
55
  ) -> ContractDeviceLoginResult:
55
- self._refuse_on_shared_runner()
56
+ long_lived = request.runtime_kind == "claude_code" and (request.method or OAUTH_TOKEN) == (
57
+ OAUTH_TOKEN
58
+ )
59
+ self._refuse_on_shared_runner(allow=long_lived)
56
60
  prompt = await self._device_login.sign_in(
57
- request.contract_id, runtime_kind=request.runtime_kind
61
+ request.contract_id, runtime_kind=request.runtime_kind, method=request.method
58
62
  )
59
63
  return ContractDeviceLoginResult(
60
64
  contract_id=prompt.contract_id,
@@ -62,12 +66,24 @@ class ContractDeviceLoginActivities:
62
66
  verification_uri=prompt.verification_uri,
63
67
  user_code=prompt.user_code,
64
68
  expires_at=prompt.expires_at.isoformat(),
69
+ sign_in_id=prompt.sign_in_id,
65
70
  )
66
71
 
67
72
  @activity.defn(name="check_contract_device_login_status")
68
73
  async def check_contract_device_login_status(
69
74
  self, request: ContractDeviceLoginStatusInput
70
75
  ) -> ContractDeviceLoginStatusResult:
76
+ if request.runtime_kind == "claude_code":
77
+ self._refuse_on_shared_runner(allow=True)
78
+ status = await self._device_login.claude.status(request.contract_id)
79
+ return ContractDeviceLoginStatusResult(
80
+ contract_id=request.contract_id,
81
+ runtime_kind=request.runtime_kind,
82
+ token_present=status.logged_in,
83
+ delivered_at=None,
84
+ auth_method=status.auth_method,
85
+ subscription_type=status.subscription_type,
86
+ )
71
87
  self._refuse_on_shared_runner()
72
88
  present = self._device_login.token_present(
73
89
  request.contract_id, runtime_kind=request.runtime_kind
@@ -86,18 +102,20 @@ class ContractDeviceLoginActivities:
86
102
  delivered_at=delivered_at,
87
103
  )
88
104
 
89
- def _refuse_on_shared_runner(self) -> None:
105
+ def _refuse_on_shared_runner(self, *, allow: bool = False) -> None:
90
106
  """Non-retryable, and typed by the rule: there is no Work Record to write Evidence
91
107
  on here, so the failure the platform's sign-in workflow receives names it.
92
108
 
93
- Only a Runner known to be the person's own signs in: one whose state names no host
94
- party (registered before issue 42) is refused too, as `choose_auth_mode` never runs
95
- a subscription on it either."""
109
+ Only a Runner known to be the person's own signs in, except that a shared Runner
110
+ may make (``allow``) a Claude Code long-lived token (local-agents 21). One whose
111
+ state names no host party (registered before issue 42) is refused either way, as
112
+ `choose_auth_mode` never runs a subscription on it."""
96
113
 
97
- if self._host_party != "user":
114
+ if self._host_party != "user" and not (allow and is_shared_runner(self._host_party)):
98
115
  raise ApplicationError(
99
116
  f"this Runner is hosted by {self._host_party!r}; only a person's own Runner "
100
- "signs in, a shared Runner runs API keys only",
117
+ "signs in, a shared Runner runs API keys and Claude Code long-lived tokens "
118
+ "only",
101
119
  type=SHARED_RUNNER_SIGN_IN_RULE,
102
120
  non_retryable=True,
103
121
  )
@@ -61,6 +61,7 @@ from agentic_runner_contracts.sealed_credential import (
61
61
  SEAL_DOMAIN,
62
62
  OpenedCredential,
63
63
  SealedCredential,
64
+ SealedSignInCode,
64
65
  delivery_binding,
65
66
  key_fingerprint,
66
67
  )
@@ -516,15 +517,25 @@ class SealedCredentialStream:
516
517
  if not self._awaiting:
517
518
  self._keys.retire_previous()
518
519
 
520
+ def open_sign_in_code(self, sealed: SealedSignInCode) -> str | None:
521
+ """A relayed sign-in code, or None when it does not open (local-agents 05).
522
+
523
+ Opened with the same Recipient Key as a slot, but never held: the caller types it
524
+ into the waiting CLI and drops it.
525
+ """
526
+
527
+ return self._open_with(sealed.recipient_key_id, sealed.binding(), sealed.ciphertext)
528
+
519
529
  def _open(self, sealed: SealedCredential) -> str | None:
530
+ return self._open_with(sealed.recipient_key_id, sealed.binding(), sealed.ciphertext)
531
+
532
+ def _open_with(self, recipient_key_id: str, binding: bytes, ciphertext: str) -> str | None:
520
533
  for pair in (self._keys.current(), self._keys.previous()):
521
- if pair is None or pair.key_id != sealed.recipient_key_id:
534
+ if pair is None or pair.key_id != recipient_key_id:
522
535
  continue
523
536
  try:
524
537
  return open_sealed(
525
- private_key=pair.private_key,
526
- binding=sealed.binding(),
527
- ciphertext=sealed.ciphertext,
538
+ private_key=pair.private_key, binding=binding, ciphertext=ciphertext
528
539
  )
529
540
  except SealedCredentialError:
530
541
  return None
@@ -98,11 +98,19 @@ 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
104
110
  from agentic_runner.workers.claude_runtime import ClaudeRuntime
111
+ from agentic_runner.workers.claude_sign_in import ClaudeSignIns
105
112
  from agentic_runner.workers.codex_runtime import CodexRuntime
113
+ from agentic_runner.workers.contract_device_login import ContractDeviceLogin
106
114
  from agentic_runner.workers.contract_isolation import UID_MAP_FILENAME, ContractIsolation
107
115
  from agentic_runner.workers.fastapi_client import DirectiveTokenSource, RunnerFastApiClient
108
116
  from agentic_runner.workers.settings import WorkerSettings, get_worker_settings
@@ -124,7 +132,11 @@ from agentic_runner_contracts.runner_registration import (
124
132
  ResourcePressure,
125
133
  )
126
134
  from agentic_runner_contracts.runtime_context import WorkerRuntimeContextResolver
127
- from agentic_runner_contracts.sealed_credential import key_fingerprint
135
+ from agentic_runner_contracts.sealed_credential import (
136
+ SignInCodeOutcome,
137
+ SignInCodeRelay,
138
+ key_fingerprint,
139
+ )
128
140
 
129
141
  __all__ = [
130
142
  "AGENT_TOKEN_ENV",
@@ -575,8 +587,15 @@ class ControlPlaneStream:
575
587
  runtimes: Mapping[str, AgentRuntime] = field(default_factory=dict)
576
588
  contracts: ContractIsolation | None = None
577
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
578
592
  # Local-agents 04b: which delivered references fill the LLM proxy's slot, and where.
579
593
  llm_slots: Mapping[str, LlmProvider] = field(default_factory=lambda: dict(LLM_SLOT_REFERENCES))
594
+ # Local-agents 05: the Claude Code sign-ins a relayed code is typed into.
595
+ sign_ins: ClaudeSignIns | None = None
596
+ # Reported once, on the beat after the relay: a lost beat loses the outcome, and the
597
+ # console's own wait on the sign-in is what tells the person.
598
+ _sign_in_outcomes: list[SignInCodeRelay] = field(default_factory=list)
580
599
  _delivered: set[tuple[str, str]] = field(default_factory=set)
581
600
  _offered_to_proxy: dict[tuple[str, str], str] = field(default_factory=dict)
582
601
  _last_seen: tuple[float, datetime] | None = None
@@ -642,6 +661,11 @@ class ControlPlaneStream:
642
661
  if self.self_tests is not None
643
662
  else None
644
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
+ ),
645
669
  )
646
670
 
647
671
  async def directive_token(self, directive_id: str) -> str:
@@ -679,11 +703,14 @@ class ControlPlaneStream:
679
703
  clock_skew_seconds=self._clock_skew(now),
680
704
  source_status=self.sources.statuses() if self.sources is not None else [],
681
705
  tool_servers=self.tool_servers.latest() if self.tool_servers is not None else [],
706
+ sign_in_codes=self._sign_in_outcomes,
682
707
  )
708
+ self._sign_in_outcomes = []
683
709
  ack = await self.client.heartbeat(self.state, envelope)
684
710
  if self.sources is not None:
685
711
  self.sources.assign(ack.user_sources)
686
712
  await self._deliver_transcripts(ack)
713
+ await self._relay_sign_in_codes(ack)
687
714
  if self.lifecycle is not None:
688
715
  self.lifecycle.acknowledge(lifecycle)
689
716
  if self.heartbeat_stamp is not None:
@@ -730,6 +757,28 @@ class ControlPlaneStream:
730
757
  "transcript %s could not be delivered: %s", request.request_id, error
731
758
  )
732
759
 
760
+ async def _relay_sign_in_codes(self, ack: HeartbeatAck) -> None:
761
+ """Type each relayed code into the sign-in it names (local-agents 05).
762
+
763
+ The code is opened, written to that sign-in's PTY and dropped here; nothing logs
764
+ it and only its outcome travels back.
765
+ """
766
+
767
+ for sealed in ack.sign_in_codes:
768
+ code = self.sealed.open_sign_in_code(sealed)
769
+ if code is None:
770
+ outcome = SignInCodeOutcome.UNOPENABLE
771
+ elif self.sign_ins is None:
772
+ outcome = SignInCodeOutcome.UNKNOWN
773
+ else:
774
+ outcome = await self.sign_ins.relay(
775
+ str(sealed.contract_id), sealed.sign_in_id, code
776
+ )
777
+ del code
778
+ self._sign_in_outcomes.append(
779
+ SignInCodeRelay(sign_in_id=sealed.sign_in_id, outcome=outcome)
780
+ )
781
+
733
782
  def _sync_delivered(self) -> None:
734
783
  """The resolver holds exactly what the last ack let this installation open."""
735
784
 
@@ -980,6 +1029,9 @@ async def _serve_registered(
980
1029
  served=runtimes,
981
1030
  monotonic=time.monotonic,
982
1031
  )
1032
+ usage_windows = build_usage_windows(
1033
+ host_party=state.host_party, runtimes=runtimes, isolation=isolation, in_flight=in_flight
1034
+ )
983
1035
  stream = ControlPlaneStream(
984
1036
  client=RunnerRegistrationClient(base_url=control_plane, client=http_client),
985
1037
  state=state,
@@ -1007,6 +1059,7 @@ async def _serve_registered(
1007
1059
  runtimes=runtimes,
1008
1060
  contracts=isolation,
1009
1061
  self_tests=self_tests,
1062
+ usage_windows=usage_windows,
1010
1063
  )
1011
1064
  stream.sources = UserSourcePoller(
1012
1065
  stream=SignedIntakeStream(stream.client, state),
@@ -1019,6 +1072,8 @@ async def _serve_registered(
1019
1072
  )
1020
1073
  messages = MessageStore(state_dir / MESSAGES_DIR)
1021
1074
  stream.messages = messages
1075
+ sign_ins = ClaudeSignIns(build_contract_isolation(settings, can_separate_uids=can_change_uid))
1076
+ stream.sign_ins = sign_ins
1022
1077
  tool_servers = ToolServerHealthLog()
1023
1078
  stream.tool_servers = tool_servers
1024
1079
  link = HeartbeatLink(stream, ceilings=proxy.ceilings)
@@ -1065,6 +1120,8 @@ async def _serve_registered(
1065
1120
  token_source=stream,
1066
1121
  messages=messages,
1067
1122
  tool_servers=tool_servers,
1123
+ sign_ins=sign_ins,
1124
+ usage_windows=usage_windows,
1068
1125
  # The wake signal rides the connection this process polls with: one
1069
1126
  # namespace, the Organisation's own (PRD issue 52, map ticket 15 §2).
1070
1127
  signaller=TemporalWorkflowSignaller(client),
@@ -1082,16 +1139,24 @@ async def _serve_registered(
1082
1139
  self_test = asyncio.create_task(
1083
1140
  _self_test_forever(self_tests, isolation, SELF_TEST_SWEEP_SECONDS)
1084
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
+ )
1085
1149
  readiness.ready = True
1086
1150
  await run_runner_lifecycle_hook(hooks, HookName.RUNNER_STARTUP)
1087
1151
  try:
1088
1152
  await run_worker_until_terminated(worker, stop=stop)
1089
1153
  finally:
1090
1154
  readiness.ready = False
1091
- for task in (beat, poll, self_test):
1155
+ for task in sweeps:
1092
1156
  task.cancel()
1093
1157
  with contextlib.suppress(asyncio.CancelledError):
1094
1158
  await task
1159
+ await sign_ins.close()
1095
1160
  await run_runner_lifecycle_hook(hooks, HookName.RUNNER_SHUTDOWN)
1096
1161
  await _report_stop(link, lifecycle)
1097
1162
  return 1 if revoked.is_set() else 0
@@ -1187,6 +1252,56 @@ async def _self_test_forever(
1187
1252
  await asyncio.sleep(interval)
1188
1253
 
1189
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
+
1190
1305
  def _activities(
1191
1306
  fastapi: RunnerFastApiClient,
1192
1307
  *,
@@ -1204,6 +1319,8 @@ def _activities(
1204
1319
  messages: MessageStore | None = None,
1205
1320
  signaller: TemporalWorkflowSignaller | None = None,
1206
1321
  tool_servers: ToolServerHealthLog | None = None,
1322
+ sign_ins: ClaudeSignIns | None = None,
1323
+ usage_windows: UsageWindows | None = None,
1207
1324
  ) -> list[Callable[..., Any]]:
1208
1325
  github_client = GitHubAppClient(
1209
1326
  app_id=os.getenv("GITHUB_APP_ID"),
@@ -1251,11 +1368,14 @@ def _activities(
1251
1368
  tool_server_health=tool_servers,
1252
1369
  attempt_records=attempt_records,
1253
1370
  directives_in_flight=directives_in_flight,
1371
+ usage_windows=usage_windows,
1254
1372
  )
1255
1373
  return [
1256
1374
  *ralph.activity_callables(),
1257
1375
  *ContractDeviceLoginActivities(
1258
- contract_isolation=isolation, host_party=state.host_party or None
1376
+ contract_isolation=isolation,
1377
+ device_login=ContractDeviceLogin(isolation, claude=sign_ins),
1378
+ host_party=state.host_party or None,
1259
1379
  ).activity_callables(),
1260
1380
  *RunnerTriageActivities(
1261
1381
  proxy=proxy,