claude-dev-env 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/advisor/advisor-protocol.md +2 -2
  3. package/_shared/pr-loop/scripts/CLAUDE.md +1 -0
  4. package/_shared/pr-loop/scripts/grant_project_claude_permissions.py +306 -284
  5. package/_shared/pr-loop/scripts/pr_loop_shared_constants/CLAUDE.md +1 -0
  6. package/_shared/pr-loop/scripts/pr_loop_shared_constants/claude_permissions_constants.py +3 -3
  7. package/_shared/pr-loop/scripts/pr_loop_shared_constants/stale_worktree_rule_sweep_constants.py +107 -0
  8. package/_shared/pr-loop/scripts/pyproject.toml +22 -0
  9. package/_shared/pr-loop/scripts/revoke_project_claude_permissions.py +5 -0
  10. package/_shared/pr-loop/scripts/stale_worktree_rule_sweep.py +107 -0
  11. package/_shared/pr-loop/scripts/tests/CLAUDE.md +2 -0
  12. package/_shared/pr-loop/scripts/tests/test_agent_config_carveout.py +55 -73
  13. package/_shared/pr-loop/scripts/tests/test_claude_permissions_constants.py +9 -15
  14. package/_shared/pr-loop/scripts/tests/test_grant_project_claude_permissions.py +91 -1
  15. package/_shared/pr-loop/scripts/tests/test_revoke_project_claude_permissions.py +88 -1
  16. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep.py +301 -0
  17. package/_shared/pr-loop/scripts/tests/test_stale_worktree_rule_sweep_constants.py +85 -0
  18. package/_shared/pr-loop/worker-spawn.md +1 -1
  19. package/agents/code-verifier.md +1 -1
  20. package/hooks/blocking/code_rules_shared.py +4 -2
  21. package/hooks/blocking/config/verified_commit_constants.py +11 -0
  22. package/hooks/blocking/pii_payload_scan.py +78 -20
  23. package/hooks/blocking/pii_prevention_blocker.py +3 -1
  24. package/hooks/blocking/test_code_verifier_tools_contract.py +28 -0
  25. package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
  26. package/hooks/blocking/test_verification_verdict_store.py +93 -0
  27. package/hooks/blocking/verification_verdict_store.py +91 -0
  28. package/hooks/validators/ruff_integration.py +2 -1
  29. package/hooks/validators/run_all_validators.py +460 -24
  30. package/hooks/validators/test_ruff_integration.py +21 -0
  31. package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
  32. package/package.json +1 -1
  33. package/rules/CLAUDE.md +1 -0
  34. package/rules/cleanup-command-forms.md +23 -0
  35. package/scripts/CLAUDE.md +2 -1
  36. package/scripts/check.ps1 +32 -6
  37. package/scripts/claude-chain.example.json +15 -3
  38. package/scripts/claude_chain_runner.py +130 -27
  39. package/scripts/claude_chain_usage.py +346 -0
  40. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -3
  41. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +13 -1
  42. package/scripts/dev_env_scripts_constants/claude_chain_usage_constants.py +58 -0
  43. package/scripts/dev_env_scripts_constants/code_review_constants.py +1 -1
  44. package/scripts/dev_env_scripts_constants/timing.py +1 -1
  45. package/scripts/test_claude_chain_runner.py +412 -6
  46. package/scripts/test_claude_chain_usage.py +534 -0
  47. package/skills/orchestrator/SKILL.md +1 -20
  48. package/skills/orchestrator-refresh/SKILL.md +1 -1
  49. package/skills/pr-converge/SKILL.md +3 -3
  50. package/skills/pr-converge/reference/examples.md +3 -3
  51. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  52. package/skills/pr-converge/reference/ground-rules.md +3 -3
  53. package/skills/pr-converge/reference/per-tick.md +5 -5
  54. package/skills/pr-converge/reference/progress-checklist.md +1 -1
  55. package/skills/pr-converge/test_step5_host_branch.py +1 -1
  56. package/skills/team-advisor/SKILL.md +3 -2
  57. package/skills/usage-pause/SKILL.md +5 -4
  58. package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
  59. package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
  60. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
@@ -71,7 +71,7 @@ Capture `number`, `head.sha` (= `current_head`), owner/repo, branch.
71
71
 
72
72
  The **PR worktree** is the local working tree of the PR's repo on its head
73
73
  branch. Every local operation this tick runs there: the CODE_REVIEW static sweep
74
- and `/code-review high --fix`, every `clean-coder` fix spawn, and every commit and
74
+ and `/code-review xhigh --fix`, every `clean-coder` fix spawn, and every commit and
75
75
  push. `/code-review` and `git` both act on the repo of the current working
76
76
  directory, so the working directory must be the PR worktree before any local
77
77
  work begins. Re-resolve it every tick — a rebase or a fresh HEAD can move the
@@ -180,7 +180,7 @@ CODE_REVIEW.
180
180
  ### `phase == CODE_REVIEW`
181
181
 
182
182
  The entry phase of every convergence tick, re-entered after any fix push. It runs
183
- a deterministic static sweep, then the built-in `/code-review high --fix` on the
183
+ a deterministic static sweep, then the built-in `/code-review xhigh --fix` on the
184
184
  full `origin/main...HEAD` diff at effort high on model opus. `/code-review`
185
185
  produces no GitHub review artifact, so there are no code-review threads to
186
186
  resolve.
@@ -196,7 +196,7 @@ a. **Static sweep — runs first, before `/code-review`.** Run the deterministic
196
196
  `phase = CODE_REVIEW`, and re-run the sweep. When the sweep is clean, run
197
197
  the host-aware review below.
198
198
 
199
- b. Run the built-in `/code-review high --fix` on the FULL `origin/main...HEAD`
199
+ b. Run the built-in `/code-review xhigh --fix` with OPUS on the FULL `origin/main...HEAD`
200
200
  diff — every file the PR touches — via the
201
201
  [local diff review](https://code.claude.com/docs/en/code-review#review-a-diff-locally).
202
202
  The review always runs at effort high on model opus. It reviews the diff and
@@ -231,14 +231,14 @@ b. Run the built-in `/code-review high --fix` on the FULL `origin/main...HEAD`
231
231
  Match the first mode whose predicate holds:
232
232
 
233
233
  - **`mode == "in_session"`** (Claude host and session model is opus): run
234
- `/code-review high --fix` in this session with no path arguments so it
234
+ `/code-review xhigh --fix` with OPUS in this session with no path arguments so it
235
235
  audits the whole branch diff against `origin/main`. After it returns, a
236
236
  non-empty `git status --porcelain` means fixes applied (`dirty_tree`
237
237
  equivalent). Treat a failed in-session slash command the same as a failed
238
238
  review: do not set `code_review_clean_at`.
239
239
  - **`mode == "chain"`** (any other host, or a Claude session on any model
240
240
  other than opus): the helper already ran the headless review
241
- (`claude -p "/code-review high --fix" --model opus` through the chain
241
+ (`claude -p "/code-review xhigh --fix" --model opus` through the chain
242
242
  runner) with cwd set to the PR worktree. Read `returncode`,
243
243
  `served_command`, and `dirty_tree` from the JSON. A successful serve is
244
244
  `returncode == 0` with a non-null `served_command`. `dirty_tree` true
@@ -91,7 +91,7 @@ round as converged. This rule holds every tick, every loop, every PR.
91
91
  - [ ] **Static sweep fails** → apply shared fix protocol → push → reset markers
92
92
  → stay CODE_REVIEW → Step 5
93
93
  - [ ] **`mode == in_session`** (Claude host, session model opus) → run
94
- `/code-review high --fix` in-session (no path args)
94
+ `/code-review xhigh --fix` in-session (no path args)
95
95
  - [ ] **`mode == chain`** (any other host or non-opus session) → helper
96
96
  already ran the headless review; read `returncode`,
97
97
  `served_command`, and `dirty_tree` from JSON
@@ -32,7 +32,7 @@ NEVER_PUSHES_PHRASE = "never pushes"
32
32
  EMPTY_STDIN_PHRASE = "empty"
33
33
  CWD_FLAG = "--cwd"
34
34
  OPUS_MODEL = "opus"
35
- HIGH_EFFORT_SLASH = "/code-review high --fix"
35
+ HIGH_EFFORT_SLASH = "/code-review xhigh --fix"
36
36
 
37
37
 
38
38
  def _read_markdown(markdown_path: Path) -> str:
@@ -30,8 +30,9 @@ the only consumer (skip the "who you are and your assignment" opener in each
30
30
  consult; a single-consumer session doesn't need it).
31
31
 
32
32
  **Third-party host:** bind a max-tier Claude advisor through the shared CLI Claude-chain
33
- in the protocol (Fable max, then Opus max; `claude_chain_runner.py` walks
34
- `~/.claude/claude-chain.json` for account usage failover). Consult via
33
+ in the protocol (Fable max, then Opus max; `claude_chain_runner.py` ranks
34
+ `~/.claude/claude-chain.json` accounts by weekly remaining via `claude_chain_usage`
35
+ and fails over on usage limits). Consult via
35
36
  `--resume <session_id>` on that bind. This session is the sole consumer of that
36
37
  CLI advisor; skip the multi-consumer opener. When the chain cannot bind or
37
38
  reply, fail closed and report to the user — do **not** answer ENDORSE /
@@ -41,10 +41,10 @@ On exit 2 the script prints `{"error": ...}`. Ask the user for a manual reset ti
41
41
 
42
42
  `scripts/resolve_usage_window.py` is the source of truth for live probe behavior. Endpoint URL, header names/values, credential path and token keys, response bucket keys, stage sizing, and the weekly warn threshold all live in `scripts/usage_pause_constants/resolve_usage_window_constants.py` — read those modules for the current values; do not restate them here.
43
43
 
44
- In short: the resolver picks a bearer token, probes the OAuth usage endpoint the interactive `/usage` panel uses, and returns the session and weekly buckets with utilization and reset times. Token sources, in order:
44
+ In short: the resolver picks a bearer token, probes the OAuth usage endpoint the interactive `/usage` panel uses, and returns the session and weekly buckets with utilization and reset times. Token sources depend on the host:
45
45
 
46
- 1. The Claude Code CLI's stored OAuth access token (honored only while unexpired).
47
- 2. The session ingress bearer token file named by `CLAUDE_SESSION_INGRESS_TOKEN_FILE` (cloud sessions).
46
+ - **Desktop host** (the `CLAUDE_CODE_ENTRYPOINT` variable is `claude-desktop`): the resolver does not read the CLI credential file, which belongs to a different authentication session than the one the desktop app counts usage against. It uses the session ingress token when one is set, and otherwise takes the manual-override path.
47
+ - **Every other host**: the resolver reads the Claude Code CLI's stored OAuth access token first (honored only while unexpired), then the session ingress bearer token file named by `CLAUDE_SESSION_INGRESS_TOKEN_FILE` (cloud sessions) when the credential token is unavailable.
48
48
 
49
49
  Fallbacks, in order: both token sources unavailable (expired/unreadable credential and no ingress file), a failed request, or a response with no readable session-window reset time all end in exit 2 — the manual-override ask above. The manual path works with no probe at all, so the skill functions even when both token sources are unavailable.
50
50
 
@@ -106,8 +106,9 @@ Fill each `<slot>` at schedule time: `<remaining_stage_durations>` is the tail o
106
106
  | `SKILL.md` | This flow: resolve, weekly guard, stage chain, templates |
107
107
  | `scripts/resolve_usage_window.py` | The window resolver and stage planner CLI |
108
108
  | `scripts/test_resolve_usage_window.py` | Behavioral tests for parsing, staging, token reading, extraction, CLI |
109
- | `scripts/usage_pause_constants/resolve_usage_window_constants.py` | Endpoint, credential keys, stage sizing, thresholds, result keys |
109
+ | `scripts/usage_pause_constants/resolve_usage_window_constants.py` | Endpoint, credential keys, host detection, stage sizing, thresholds, result keys |
110
110
 
111
111
  ## Gotchas
112
112
 
113
113
  - The stored access token lives about 8 hours and the CLI rewrites it on its own schedule, so a mid-afternoon probe can find it expired even while the CLI itself still works. That is the designed exit-2 path: give a manual time.
114
+ - On the desktop host the resolver never reads the CLI credential file, because that credential is a different authentication session than the one the desktop app counts usage against. With no session ingress token set, the desktop host takes the manual path: read the reset time from the interactive `/usage` panel and pass it, for example `/usage-pause 10:20pm`.
@@ -7,11 +7,13 @@
7
7
  {"source": "override", "reset_at": "2026-07-08T10:14:00-07:00",
8
8
  "seconds_until_reset": 4440, "stages_seconds": [3480, 960, 120], ...}
9
9
 
10
- With no ``--override``, the script resolves a bearer token: the Claude Code
11
- OAuth access token from the CLI credential file, or the session ingress token
12
- file when that credential is unavailable. It asks the OAuth usage endpoint for the
13
- ``five_hour`` and ``seven_day`` windows. Exit code 2 means the probe cannot
14
- resolve; the caller then asks the user for a manual reset time.
10
+ With no ``--override``, the script resolves a bearer token. On the desktop
11
+ host it uses only the session ingress token. On every other host it reads the
12
+ Claude Code OAuth access token from the CLI credential file, then the session
13
+ ingress token file when that credential is unavailable. It asks the OAuth
14
+ usage endpoint for the ``five_hour`` and ``seven_day`` windows. Exit code 2
15
+ means the probe cannot resolve; the caller then asks the user for a manual
16
+ reset time.
15
17
  """
16
18
 
17
19
  from __future__ import annotations
@@ -41,7 +43,9 @@ from usage_pause_constants.resolve_usage_window_constants import (
41
43
  CREDENTIALS_ACCESS_TOKEN_KEY,
42
44
  CREDENTIALS_EXPIRES_AT_KEY,
43
45
  CREDENTIALS_OAUTH_SECTION_KEY,
46
+ DESKTOP_ENTRYPOINT_VALUE,
44
47
  DURATION_PATTERN,
48
+ ENTRYPOINT_ENV_VAR,
45
49
  EPOCH_MILLISECONDS_THRESHOLD,
46
50
  EXIT_CODE_PROBE_UNAVAILABLE,
47
51
  EXIT_CODE_RESOLVED,
@@ -267,14 +271,36 @@ def read_session_ingress_token() -> str | None:
267
271
  return token_text
268
272
 
269
273
 
274
+ def running_on_desktop_host() -> bool:
275
+ """Tell whether this process runs under the Claude desktop app.
276
+
277
+ ::
278
+
279
+ entrypoint is the desktop marker -> True
280
+ entrypoint is the CLI or unset -> False
281
+
282
+ The desktop app meters its session under a different auth session than
283
+ the CLI credential file, so a true answer tells the resolver to leave
284
+ that credential file alone.
285
+
286
+ Returns:
287
+ True when the entrypoint environment variable names the desktop app.
288
+ """
289
+ return os.environ.get(ENTRYPOINT_ENV_VAR) == DESKTOP_ENTRYPOINT_VALUE
290
+
291
+
270
292
  def resolve_access_token(credentials_path: Path, now: datetime) -> str | None:
271
293
  """Choose the usage-endpoint bearer token from its available sources.
272
294
 
273
295
  ::
274
296
 
275
- credential file token valid -> the credential token
276
- credential token unavailable -> the session ingress token
277
- neither source available -> None
297
+ CLI host, credential token valid -> the credential token
298
+ CLI host, credential token unavailable -> the session ingress token
299
+ desktop host -> the session ingress token
300
+ no source available -> None
301
+
302
+ On the desktop host the CLI credential file belongs to a different auth
303
+ session, so it is skipped and only the session ingress token is honored.
278
304
 
279
305
  Args:
280
306
  credentials_path: The CLI credential file holding the OAuth section.
@@ -283,6 +309,8 @@ def resolve_access_token(credentials_path: Path, now: datetime) -> str | None:
283
309
  Returns:
284
310
  The bearer token for the usage endpoint, or None when no source has one.
285
311
  """
312
+ if running_on_desktop_host():
313
+ return read_session_ingress_token()
286
314
  credential_token = read_oauth_access_token(credentials_path, now)
287
315
  return credential_token or read_session_ingress_token()
288
316
 
@@ -447,6 +475,37 @@ def _describe_ingress_token_source() -> str:
447
475
  return f"the session ingress token file ({SESSION_INGRESS_TOKEN_FILE_ENV_VAR} set but empty)"
448
476
 
449
477
 
478
+ def _no_token_error_message(credentials_path: Path) -> str:
479
+ """Build the no-usable-token exit-2 error, host-aware for the desktop app.
480
+
481
+ ::
482
+
483
+ desktop host -> names the different-session skip and the /usage remedy
484
+ other host -> names the credential path and the ingress source tried
485
+
486
+ On the desktop host the CLI credential file is never read, so the message
487
+ points the user at the interactive /usage panel for a manual reset time.
488
+
489
+ Args:
490
+ credentials_path: The CLI credential file the non-desktop message names.
491
+
492
+ Returns:
493
+ The error text for the no-usable-token exit-2 path.
494
+ """
495
+ if running_on_desktop_host():
496
+ return (
497
+ "on the desktop host the CLI credential file belongs to a different "
498
+ f"auth session and is not read, and {_describe_ingress_token_source()} "
499
+ "has no token; read the reset time from the interactive /usage panel "
500
+ "and give it manually, for example /usage-pause 10:20pm or /usage-pause 74m"
501
+ )
502
+ return (
503
+ "no usable bearer token from the OAuth credential file at "
504
+ f"{credentials_path} or {_describe_ingress_token_source()}; give a "
505
+ "manual reset time, for example /usage-pause 10:20pm or /usage-pause 74m"
506
+ )
507
+
508
+
450
509
  def _parse_arguments() -> argparse.Namespace:
451
510
  parser = argparse.ArgumentParser(
452
511
  description="Resolve the 5-hour usage window reset and plan the pause stage chain.",
@@ -503,11 +562,7 @@ def main() -> int:
503
562
  )
504
563
  access_token = resolve_access_token(credentials_path, now)
505
564
  if access_token is None:
506
- return _emit_error(
507
- "no usable bearer token from the OAuth credential file at "
508
- f"{credentials_path} or {_describe_ingress_token_source()}; give a "
509
- "manual reset time, for example /usage-pause 10:20pm or /usage-pause 74m"
510
- )
565
+ return _emit_error(_no_token_error_message(credentials_path))
511
566
  try:
512
567
  usage_payload = _fetch_usage_payload(access_token)
513
568
  except (
@@ -18,7 +18,9 @@ SCRIPTS_DIRECTORY = Path(__file__).resolve().parent
18
18
  if str(SCRIPTS_DIRECTORY) not in sys.path:
19
19
  sys.path.insert(0, str(SCRIPTS_DIRECTORY))
20
20
 
21
- from usage_pause_constants.resolve_usage_window_constants import (
21
+ from usage_pause_constants.resolve_usage_window_constants import ( # noqa: E402
22
+ DESKTOP_ENTRYPOINT_VALUE,
23
+ ENTRYPOINT_ENV_VAR,
22
24
  SESSION_INGRESS_TOKEN_FILE_ENV_VAR as INGRESS_TOKEN_FILE_ENV_VAR,
23
25
  )
24
26
 
@@ -188,8 +190,7 @@ class TestReadOauthAccessToken:
188
190
  credentials_path.write_text("{not valid json", encoding="utf-8")
189
191
  with caplog.at_level(logging.WARNING):
190
192
  assert (
191
- resolver.read_oauth_access_token(credentials_path, local_now())
192
- is None
193
+ resolver.read_oauth_access_token(credentials_path, local_now()) is None
193
194
  )
194
195
  assert any("unreadable" in each_message for each_message in caplog.messages)
195
196
 
@@ -240,7 +241,43 @@ class TestReadSessionIngressToken:
240
241
  assert INGRESS_TOKEN_FILE_ENV_VAR == "CLAUDE_SESSION_INGRESS_TOKEN_FILE"
241
242
 
242
243
 
244
+ class TestRunningOnDesktopHost:
245
+ def should_be_true_when_entrypoint_names_the_desktop_app(
246
+ self, monkeypatch: pytest.MonkeyPatch
247
+ ) -> None:
248
+ resolver = load_resolver_module()
249
+ monkeypatch.setenv(ENTRYPOINT_ENV_VAR, DESKTOP_ENTRYPOINT_VALUE)
250
+ assert resolver.running_on_desktop_host() is True
251
+
252
+ def should_be_false_when_entrypoint_names_a_non_desktop_host(
253
+ self, monkeypatch: pytest.MonkeyPatch
254
+ ) -> None:
255
+ resolver = load_resolver_module()
256
+ monkeypatch.setenv(ENTRYPOINT_ENV_VAR, "cli")
257
+ assert resolver.running_on_desktop_host() is False
258
+
259
+ def should_be_false_when_entrypoint_is_unset(
260
+ self, monkeypatch: pytest.MonkeyPatch
261
+ ) -> None:
262
+ resolver = load_resolver_module()
263
+ monkeypatch.delenv(ENTRYPOINT_ENV_VAR, raising=False)
264
+ assert resolver.running_on_desktop_host() is False
265
+
266
+ def should_pin_the_desktop_entrypoint_contract_to_the_literal_marker(
267
+ self, monkeypatch: pytest.MonkeyPatch
268
+ ) -> None:
269
+ resolver = load_resolver_module()
270
+ monkeypatch.setenv("CLAUDE_CODE_ENTRYPOINT", "claude-desktop")
271
+ assert resolver.running_on_desktop_host() is True
272
+ assert ENTRYPOINT_ENV_VAR == "CLAUDE_CODE_ENTRYPOINT"
273
+ assert DESKTOP_ENTRYPOINT_VALUE == "claude-desktop"
274
+
275
+
243
276
  class TestResolveAccessToken:
277
+ @pytest.fixture(autouse=True)
278
+ def force_non_desktop_host(self, monkeypatch: pytest.MonkeyPatch) -> None:
279
+ monkeypatch.delenv(ENTRYPOINT_ENV_VAR, raising=False)
280
+
244
281
  def should_use_ingress_token_when_credential_file_missing(
245
282
  self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
246
283
  ) -> None:
@@ -295,6 +332,57 @@ class TestResolveAccessToken:
295
332
  assert resolver.resolve_access_token(missing_credentials, local_now()) is None
296
333
 
297
334
 
335
+ class TestResolveAccessTokenHostAware:
336
+ def should_ignore_a_valid_cli_credential_token_on_the_desktop_host(
337
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
338
+ ) -> None:
339
+ resolver = load_resolver_module()
340
+ now = local_now()
341
+ credentials_path = tmp_path / ".credentials.json"
342
+ future_milliseconds = int((now + timedelta(hours=1)).timestamp() * 1000)
343
+ write_credentials(
344
+ credentials_path, future_milliseconds, access_token="cli-credential-token"
345
+ )
346
+ monkeypatch.setenv(ENTRYPOINT_ENV_VAR, DESKTOP_ENTRYPOINT_VALUE)
347
+ monkeypatch.delenv(INGRESS_TOKEN_FILE_ENV_VAR, raising=False)
348
+ chosen_token = resolver.resolve_access_token(credentials_path, now)
349
+ assert chosen_token != "cli-credential-token"
350
+ assert chosen_token is None
351
+
352
+ def should_use_the_ingress_token_over_the_cli_credential_on_the_desktop_host(
353
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
354
+ ) -> None:
355
+ resolver = load_resolver_module()
356
+ now = local_now()
357
+ credentials_path = tmp_path / ".credentials.json"
358
+ future_milliseconds = int((now + timedelta(hours=1)).timestamp() * 1000)
359
+ write_credentials(
360
+ credentials_path, future_milliseconds, access_token="cli-credential-token"
361
+ )
362
+ token_file = tmp_path / "ingress-token"
363
+ token_file.write_text("ingress-token-value", encoding="utf-8")
364
+ monkeypatch.setenv(ENTRYPOINT_ENV_VAR, DESKTOP_ENTRYPOINT_VALUE)
365
+ monkeypatch.setenv(INGRESS_TOKEN_FILE_ENV_VAR, str(token_file))
366
+ chosen_token = resolver.resolve_access_token(credentials_path, now)
367
+ assert chosen_token == "ingress-token-value"
368
+ assert chosen_token != "cli-credential-token"
369
+
370
+ def should_return_the_cli_credential_token_on_a_non_desktop_host(
371
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
372
+ ) -> None:
373
+ resolver = load_resolver_module()
374
+ now = local_now()
375
+ credentials_path = tmp_path / ".credentials.json"
376
+ future_milliseconds = int((now + timedelta(hours=1)).timestamp() * 1000)
377
+ write_credentials(
378
+ credentials_path, future_milliseconds, access_token="cli-credential-token"
379
+ )
380
+ monkeypatch.setenv(ENTRYPOINT_ENV_VAR, "cli")
381
+ monkeypatch.delenv(INGRESS_TOKEN_FILE_ENV_VAR, raising=False)
382
+ chosen_token = resolver.resolve_access_token(credentials_path, now)
383
+ assert chosen_token == "cli-credential-token"
384
+
385
+
298
386
  class TestExtractUsageWindows:
299
387
  def should_extract_session_and_weekly_buckets(self) -> None:
300
388
  resolver = load_resolver_module()
@@ -361,13 +449,20 @@ class TestBuildPausePlan:
361
449
 
362
450
  class TestCommandLine:
363
451
  def run_resolver(
364
- self, *arguments: str, ingress_token_file: str | None = None
452
+ self,
453
+ *arguments: str,
454
+ ingress_token_file: str | None = None,
455
+ entrypoint: str | None = None,
365
456
  ) -> subprocess.CompletedProcess[str]:
366
457
  child_environment = dict(os.environ)
367
458
  if ingress_token_file is None:
368
459
  child_environment.pop(INGRESS_TOKEN_FILE_ENV_VAR, None)
369
460
  else:
370
461
  child_environment[INGRESS_TOKEN_FILE_ENV_VAR] = ingress_token_file
462
+ if entrypoint is None:
463
+ child_environment.pop(ENTRYPOINT_ENV_VAR, None)
464
+ else:
465
+ child_environment[ENTRYPOINT_ENV_VAR] = entrypoint
371
466
  return subprocess.run(
372
467
  [sys.executable, str(RESOLVER_PATH), *arguments],
373
468
  capture_output=True,
@@ -403,9 +498,7 @@ class TestCommandLine:
403
498
  assert str(missing_path) in resolved_payload["error"]
404
499
  assert f"{INGRESS_TOKEN_FILE_ENV_VAR} unset" in resolved_payload["error"]
405
500
 
406
- def should_name_the_ingress_token_path_in_the_error(
407
- self, tmp_path: Path
408
- ) -> None:
501
+ def should_name_the_ingress_token_path_in_the_error(self, tmp_path: Path) -> None:
409
502
  missing_credentials = tmp_path / "absent.json"
410
503
  stale_ingress = tmp_path / "stale-ingress-token"
411
504
  completed = self.run_resolver(
@@ -429,10 +522,29 @@ class TestCommandLine:
429
522
  assert completed.returncode == 2
430
523
  resolved_payload = json.loads(completed.stdout)
431
524
  assert (
432
- f"{INGRESS_TOKEN_FILE_ENV_VAR} set but empty"
433
- in resolved_payload["error"]
525
+ f"{INGRESS_TOKEN_FILE_ENV_VAR} set but empty" in resolved_payload["error"]
434
526
  )
435
527
 
528
+ def should_exit_two_with_a_host_aware_error_on_the_desktop_host(
529
+ self, tmp_path: Path
530
+ ) -> None:
531
+ credentials_path = tmp_path / ".credentials.json"
532
+ future_milliseconds = int((local_now() + timedelta(hours=1)).timestamp() * 1000)
533
+ write_credentials(
534
+ credentials_path, future_milliseconds, access_token="cli-credential-token"
535
+ )
536
+ completed = self.run_resolver(
537
+ "--credentials-path",
538
+ str(credentials_path),
539
+ entrypoint="claude-desktop",
540
+ )
541
+ assert completed.returncode == 2
542
+ assert "cli-credential-token" not in completed.stdout
543
+ error_text = json.loads(completed.stdout)["error"]
544
+ assert "different auth session" in error_text
545
+ assert "/usage" in error_text
546
+ assert "/usage-pause" in error_text
547
+
436
548
  def should_reject_invalid_override_with_error_payload(self) -> None:
437
549
  completed = self.run_resolver("--override", "soon")
438
550
  assert completed.returncode == 2
@@ -1,9 +1,10 @@
1
1
  """Constants for the usage-window resolver.
2
2
 
3
3
  Groups: the OAuth usage-endpoint probe, the CLI credential file keys, the
4
- session ingress token file environment variable, the usage-response field
5
- keys, the override parse patterns, the wakeup stage sizing, the weekly warn
6
- threshold, the result JSON keys, the source labels, and the exit codes.
4
+ session ingress token file environment variable, the host entrypoint
5
+ detection, the usage-response field keys, the override parse patterns, the
6
+ wakeup stage sizing, the weekly warn threshold, the result JSON keys, the
7
+ source labels, and the exit codes.
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -24,6 +25,9 @@ CREDENTIALS_EXPIRES_AT_KEY = "expiresAt"
24
25
  SESSION_INGRESS_TOKEN_FILE_ENV_VAR = "CLAUDE_SESSION_INGRESS_TOKEN_FILE"
25
26
  MILLISECONDS_PER_SECOND = 1000
26
27
 
28
+ ENTRYPOINT_ENV_VAR = "CLAUDE_CODE_ENTRYPOINT"
29
+ DESKTOP_ENTRYPOINT_VALUE = "claude-desktop"
30
+
27
31
  FIVE_HOUR_BUCKET_KEY = "five_hour"
28
32
  SEVEN_DAY_BUCKET_KEY = "seven_day"
29
33
  UTILIZATION_KEY = "utilization"