claude-dev-env 2.1.0 → 2.2.1

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 (44) hide show
  1. package/CLAUDE.md +1 -1
  2. package/_shared/pr-loop/scripts/pyproject.toml +22 -0
  3. package/hooks/blocking/code_rules_shared.py +4 -2
  4. package/hooks/blocking/pii_payload_scan.py +78 -20
  5. package/hooks/blocking/pii_prevention_blocker.py +3 -1
  6. package/hooks/blocking/test_pii_write_surface_ephemeral.py +221 -0
  7. package/hooks/validators/ruff_integration.py +2 -1
  8. package/hooks/validators/run_all_validators.py +460 -24
  9. package/hooks/validators/test_ruff_integration.py +21 -0
  10. package/hooks/validators/test_run_all_validators_pretooluse.py +223 -3
  11. package/package.json +1 -1
  12. package/rules/CLAUDE.md +1 -0
  13. package/rules/cleanup-command-forms.md +23 -0
  14. package/scripts/check.ps1 +32 -6
  15. package/skills/_shared/pr-loop/CLAUDE.md +18 -13
  16. package/skills/_shared/pr-loop/portable-driver.md +150 -0
  17. package/skills/_shared/pr-loop/scripts/CLAUDE.md +3 -0
  18. package/skills/_shared/pr-loop/scripts/build_converge_task_list.py +310 -0
  19. package/skills/_shared/pr-loop/scripts/portable_converge_driver.py +1637 -0
  20. package/skills/_shared/pr-loop/scripts/select_converge_pacer.py +215 -0
  21. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/CLAUDE.md +3 -0
  22. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/converge_task_list_constants.py +56 -0
  23. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/pacer_constants.py +47 -0
  24. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/portable_driver_constants.py +181 -0
  25. package/skills/_shared/pr-loop/scripts/test_build_converge_task_list.py +140 -0
  26. package/skills/_shared/pr-loop/scripts/test_portable_converge_driver.py +1119 -0
  27. package/skills/_shared/pr-loop/scripts/test_select_converge_pacer.py +207 -0
  28. package/skills/autoconverge/CLAUDE.md +5 -3
  29. package/skills/autoconverge/SKILL.md +185 -74
  30. package/skills/autoconverge/reference/convergence.md +5 -5
  31. package/skills/autoconverge/reference/multi-pr.md +33 -2
  32. package/skills/autoconverge/reference/stop-conditions.md +9 -6
  33. package/skills/autoconverge/test_portable_pacer_gate.py +54 -0
  34. package/skills/copilot-finding-triage/SKILL.md +16 -4
  35. package/skills/pr-converge/CLAUDE.md +4 -3
  36. package/skills/pr-converge/SKILL.md +236 -44
  37. package/skills/pr-converge/reference/multi-pr-orchestration.md +5 -1
  38. package/skills/pr-converge/reference/per-tick.md +51 -24
  39. package/skills/pr-converge/test_portable_pacer_gate.py +67 -0
  40. package/skills/pr-converge/workflows/schedule-wakeup-loop.md +5 -3
  41. package/skills/usage-pause/SKILL.md +5 -4
  42. package/skills/usage-pause/scripts/resolve_usage_window.py +68 -13
  43. package/skills/usage-pause/scripts/test_resolve_usage_window.py +121 -9
  44. package/skills/usage-pause/scripts/usage_pause_constants/resolve_usage_window_constants.py +7 -3
@@ -15,29 +15,38 @@ Use on **draft PR**. Cursor Bugbot and `/bugteam` re-run after each push. Fix
15
15
  findings between rounds until back-to-back clean on same `HEAD`, then mark
16
16
  PR ready for review.
17
17
 
18
- Run every tick in parent harness session. Pacing lives in
19
- [`../workflows/schedule-wakeup-loop.md`](../workflows/schedule-wakeup-loop.md) (read before Step 4); see [Pacing
20
- workflow](#pacing-workflow).
18
+ Run every tick in the parent harness session. Pacing depends on the selected
19
+ pacer from pre-flight (`select_converge_pacer.py`):
20
+
21
+ - `schedule_wakeup` — [`../workflows/schedule-wakeup-loop.md`](../workflows/schedule-wakeup-loop.md)
22
+ - `portable` — [`../../_shared/pr-loop/portable-driver.md`](../../_shared/pr-loop/portable-driver.md)
23
+
24
+ See [Pacing workflow](#pacing-workflow).
21
25
 
22
26
  Every BUGTEAM tick runs **bugteam** — never hand-rolled substitute. Fix
23
- protocol per [fix-protocol.md](fix-protocol.md). Pacing stays in main session via
24
- `ScheduleWakeup` (pre-flight aborts when absent).
27
+ protocol per [fix-protocol.md](fix-protocol.md). Pacing stays in the main
28
+ session (native `ScheduleWakeup` or the portable continuous driver).
25
29
 
26
30
  ## Invocation modes
27
31
 
28
- - **`/pr-converge`** runs one tick, then Step 4 schedules the next via
29
- `ScheduleWakeup`. Omit the next wakeup only on convergence or **Stop
30
- conditions**.
32
+ - **`/pr-converge` with `pacer=schedule_wakeup`** runs one tick, then Step 4
33
+ schedules the next via `ScheduleWakeup`. Omit the next wakeup only on
34
+ convergence or **Stop conditions**.
35
+ - **`/pr-converge` with `pacer=portable`** runs ticks continuously in-session
36
+ (poll waits at the same delays); write handoff at budget boundaries. See
37
+ the portable driver.
31
38
 
32
39
  ## Pacing workflow
33
40
 
34
- Read [`../workflows/schedule-wakeup-loop.md`](../workflows/schedule-wakeup-loop.md)
41
+ When `pacer=schedule_wakeup`, read
42
+ [`../workflows/schedule-wakeup-loop.md`](../workflows/schedule-wakeup-loop.md)
35
43
  (installed copy under `$HOME/.claude/skills/pr-converge/workflows/`) before
36
- Step 4. The pre-flight gate guarantees `ScheduleWakeup` is invokable; the
37
- workflow file specifies delays, prompts, and convergence cleanup.
44
+ Step 4. When `pacer=portable`, read
45
+ [`../../_shared/pr-loop/portable-driver.md`](../../_shared/pr-loop/portable-driver.md)
46
+ and skip `ScheduleWakeup` calls.
38
47
 
39
48
  - **`/pr-converge`** (default): loops until convergence. After each tick
40
- (unless converged or stopped), run **Step 4**.
49
+ (unless converged or stopped), run **Step 4** using the selected pacer.
41
50
 
42
51
  ## Step 1: Resolve current HEAD and PR context
43
52
 
@@ -244,8 +253,8 @@ c. Decide (three branches; match first whose predicate holds):
244
253
 
245
254
  - **Failed review (`returncode != 0`, or chain mode with null
246
255
  `served_command`):** The review did not complete a successful serve. Do
247
- not set `code_review_clean_at`. Stay `phase = CODE_REVIEW`, schedule next
248
- wakeup, return. A failed chain often leaves `dirty_tree` false — that is
256
+ not set `code_review_clean_at`. Stay `phase = CODE_REVIEW`, apply Step 4 pacer
257
+ (ScheduleWakeup or portable continue/poll), return. A failed chain often leaves `dirty_tree` false — that is
249
258
  not a clean stamp.
250
259
  - **Fixes applied (working tree dirty / `dirty_tree` true):** Commit the
251
260
  applied fixes in one commit → push, following the shared fix protocol
@@ -253,7 +262,7 @@ c. Decide (three branches; match first whose predicate holds):
253
262
  push-invalidated markers per [ground-rules.md](ground-rules.md) /
254
263
  [state-schema.md](state-schema.md) (all `*_clean_at`, `merge_state_status`,
255
264
  `bugbot_down`, `bugbot_acknowledged_at`, `codex_down`). Stay
256
- `phase = CODE_REVIEW`, schedule next wakeup, return. Every fix push
265
+ `phase = CODE_REVIEW`, apply Step 4 pacer (ScheduleWakeup or portable continue/poll), return. Every fix push
257
266
  re-enters the internal passes on the new HEAD.
258
267
  - **Clean (successful serve: `returncode == 0`, chain `served_command`
259
268
  non-null when `mode == chain`, and `dirty_tree` false):** Set
@@ -318,7 +327,7 @@ d. Decide based on post-bugteam state — order matters. Check
318
327
  pushed-during-bugteam FIRST so a convergence report against a stale HEAD
319
328
  never falsely terminates:
320
329
  - **Audit pushed this tick (clean-at fields reset in step b):**
321
- `phase = CODE_REVIEW`, schedule next wakeup, return. Every fix push
330
+ `phase = CODE_REVIEW`, apply Step 4 pacer (ScheduleWakeup or portable continue/poll), return. Every fix push
322
331
  re-enters the internal passes on the new HEAD.
323
332
  - **Convergence AND no push:** the internal passes are clean on
324
333
  `current_head`. Stamp `bugteam_clean_at = current_head`, then
@@ -328,8 +337,8 @@ never falsely terminates:
328
337
  ([`../../../_shared/pr-loop/fix-protocol.md`](../../../_shared/pr-loop/fix-protocol.md); skill deltas in [fix-protocol.md](fix-protocol.md)). Reset push-invalidated markers
329
338
  per [ground-rules.md](ground-rules.md) / [state-schema.md](state-schema.md)
330
339
  (all `*_clean_at`, `merge_state_status`, `bugbot_down`,
331
- `bugbot_acknowledged_at`, `codex_down`), `phase = CODE_REVIEW`, schedule
332
- next wakeup, return.
340
+ `bugbot_acknowledged_at`, `codex_down`), `phase = CODE_REVIEW`, apply Step 4 pacer
341
+ (ScheduleWakeup or portable continue/poll), return.
333
342
 
334
343
  ### `phase == BUGBOT` (terminal gate)
335
344
 
@@ -386,7 +395,7 @@ pull_request_read(owner=OWNER, repo=REPO, pullNumber=NUMBER, method="get_review_
386
395
  c. Decide (four branches; match first whose predicate holds):
387
396
  - **No bugbot review yet, OR latest review's `commit_id` ≠
388
397
  `current_head`:** Re-trigger bugbot (Step 3), set `bugbot_clean_at =
389
- null`, reset `inline_lag_streak = 0`, schedule next wakeup, return to the
398
+ null`, reset `inline_lag_streak = 0`, apply Step 4 pacer (ScheduleWakeup or portable continue/poll), return to the
390
399
  Bugbot gate next tick.
391
400
  - **`commit_id == current_head` AND zero unaddressed inline AND review
392
401
  body clean:** Set `bugbot_clean_at = current_head`, reset
@@ -402,7 +411,7 @@ c. Decide (four branches; match first whose predicate holds):
402
411
  `state.json`: the clean-coder teammate executes the fix, writes
403
412
  `state.json`, goes idle; the next tick re-enters CODE_REVIEW on the new
404
413
  HEAD. No `state.json` (single-PR): the lead executes it, stays
405
- `phase = CODE_REVIEW`. Schedule next wakeup, return.
414
+ `phase = CODE_REVIEW`. Apply Step 4 pacer (ScheduleWakeup or portable continue/poll), return.
406
415
 
407
416
  ### `phase == COPILOT_WAIT`
408
417
 
@@ -435,12 +444,12 @@ b. Decide (three branches; match first whose predicate holds):
435
444
  (all `*_clean_at`, `merge_state_status`, `bugbot_down`,
436
445
  `bugbot_acknowledged_at`, `codex_down`). **Set `phase = CODE_REVIEW`**
437
446
  (NOT COPILOT_WAIT) — every fix push re-enters the internal passes on the
438
- new HEAD. Schedule next wakeup, return.
447
+ new HEAD. Apply Step 4 pacer (ScheduleWakeup or portable continue/poll), return.
439
448
  - **No Copilot review at `current_head` yet:** Increment
440
449
  `copilot_wait_count` (init 0 on COPILOT_WAIT entry; reset to 0 on
441
450
  every push and on every successful Copilot review). `>= 3` → hard
442
451
  blocker per [stop-conditions.md](stop-conditions.md). Otherwise
443
- schedule next wakeup (360s), return.
452
+ apply Step 4 pacer (ScheduleWakeup or portable continue/poll; 360s wait), return.
444
453
 
445
454
  **Non-negotiable:** After any Copilot fix push, `phase` MUST route to
446
455
  `CODE_REVIEW`. Never cycle COPILOT_WAIT → fix → COPILOT_WAIT. The
@@ -508,8 +517,11 @@ base), never an invented commit range.
508
517
 
509
518
  ## Step 4: Loop pacing
510
519
 
511
- **`ScheduleWakeup` field hints** (prefer [Pacing
512
- workflow](#pacing-workflow)):
520
+ Apply the pacer selected at pre-flight ([Pacing workflow](#pacing-workflow)).
521
+
522
+ ### `pacer=schedule_wakeup`
523
+
524
+ **`ScheduleWakeup` field hints:**
513
525
 
514
526
  - `delaySeconds: 360` after bugbot re-trigger. Exception:
515
527
  BUGBOT inline-lag branch uses `delaySeconds: 90` (no re-trigger;
@@ -521,6 +533,21 @@ workflow](#pacing-workflow)):
521
533
  **On convergence:** apply **Convergence** section of
522
534
  `../workflows/schedule-wakeup-loop.md` (omit wakeups).
523
535
 
536
+ ### `pacer=portable`
537
+
538
+ Do **not** call `ScheduleWakeup`. After writing state and handoff:
539
+
540
+ - **Immediate work next** → continue the next tick in the same turn.
541
+ - **Wait next** (Bugbot queued, COPILOT_WAIT with no review yet) → poll
542
+ in-session for the same delay (`360` default, `90` on Bugbot inline-lag),
543
+ then resume the same step. Honor the same hard caps as the ScheduleWakeup
544
+ path.
545
+ - **Budget too low for a full tick** → stop at the tick boundary; print
546
+ `/pr-converge <PR URL>` and the persisted phase.
547
+
548
+ Full portable rules:
549
+ [`../../_shared/pr-loop/portable-driver.md`](../../_shared/pr-loop/portable-driver.md).
550
+
524
551
  ## Bugteam execution
525
552
 
526
553
  **Second audit** (BUGTEAM phase) is **always** **bugteam** skill: preflight,
@@ -0,0 +1,67 @@
1
+ """Contract checks: pr-converge selects a portable pacer when ScheduleWakeup is absent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ SKILL_ROOT = Path(__file__).resolve().parent
9
+ SKILL_MARKDOWN_PATH = SKILL_ROOT / "SKILL.md"
10
+ PER_TICK_MARKDOWN_PATH = SKILL_ROOT / "reference" / "per-tick.md"
11
+ PORTABLE_DRIVER_PATH = (
12
+ SKILL_ROOT.parent / "_shared" / "pr-loop" / "portable-driver.md"
13
+ )
14
+
15
+ SELECT_CONVERGE_PACER_SCRIPT = "select_converge_pacer.py"
16
+ PACER_PORTABLE_TOKEN = "pacer=portable"
17
+ PACER_SCHEDULE_WAKEUP_TOKEN = "pacer=schedule_wakeup"
18
+ ABORT_ONLY_PHRASE = "pr-converge requires ScheduleWakeup; aborting"
19
+ PORTABLE_DRIVER_DOC = "portable-driver.md"
20
+ PORTABLE_DRIVER_LINK_PATTERN = re.compile(
21
+ r"\((?P<relative>(?:\.\./)+_shared/pr-loop/portable-driver\.md)\)"
22
+ )
23
+
24
+
25
+ def test_skill_selects_pacer_and_forbids_abort_only_schedule_wakeup() -> None:
26
+ skill_text = SKILL_MARKDOWN_PATH.read_text(encoding="utf-8")
27
+ assert SELECT_CONVERGE_PACER_SCRIPT in skill_text
28
+ assert PACER_PORTABLE_TOKEN in skill_text
29
+ assert PACER_SCHEDULE_WAKEUP_TOKEN in skill_text
30
+ assert PORTABLE_DRIVER_DOC in skill_text
31
+ assert ABORT_ONLY_PHRASE not in skill_text
32
+
33
+
34
+ def test_skill_portable_driver_links_resolve_on_disk() -> None:
35
+ skill_text = SKILL_MARKDOWN_PATH.read_text(encoding="utf-8")
36
+ all_relative_links = PORTABLE_DRIVER_LINK_PATTERN.findall(skill_text)
37
+ assert all_relative_links, "SKILL.md must link portable-driver.md"
38
+ for each_relative_link in all_relative_links:
39
+ resolved_path = (SKILL_MARKDOWN_PATH.parent / each_relative_link).resolve()
40
+ assert resolved_path.is_file(), (
41
+ f"portable-driver link must resolve on disk: "
42
+ f"{each_relative_link} -> {resolved_path}"
43
+ )
44
+ assert resolved_path == PORTABLE_DRIVER_PATH.resolve()
45
+
46
+
47
+ def test_per_tick_documents_both_pacers() -> None:
48
+ per_tick_text = PER_TICK_MARKDOWN_PATH.read_text(encoding="utf-8")
49
+ assert "portable" in per_tick_text
50
+ assert "schedule_wakeup" in per_tick_text or "ScheduleWakeup" in per_tick_text
51
+ assert PORTABLE_DRIVER_DOC in per_tick_text
52
+ assert "pacer=portable" in per_tick_text
53
+ assert "Do **not** call `ScheduleWakeup`" in per_tick_text or (
54
+ "Do not call `ScheduleWakeup`" in per_tick_text
55
+ )
56
+
57
+
58
+ def test_portable_driver_protocol_exists() -> None:
59
+ assert PORTABLE_DRIVER_PATH.is_file()
60
+ portable_text = PORTABLE_DRIVER_PATH.read_text(encoding="utf-8")
61
+ assert "check_convergence.py" in portable_text
62
+ assert "invoke_code_review.py" in portable_text
63
+ assert "resolve_worker_spawn.py" in portable_text
64
+ assert "build_converge_task_list.py" in portable_text
65
+ assert "all_runnable_reviews_clean_same_head" in portable_text
66
+ assert "portable_converge_driver.py" in portable_text
67
+ assert "Never abort solely because" in portable_text
@@ -1,8 +1,10 @@
1
1
  # ScheduleWakeup loop pacing (pr-converge)
2
2
 
3
- Load this document for converge **loop pacing**. The pre-flight in `SKILL.md`
4
- guarantees `ScheduleWakeup` is available before any tick runs. Shared bugbot
5
- / bugteam / Fix protocol steps stay in the main `SKILL.md`.
3
+ Load this document when pre-flight selects `pacer=schedule_wakeup`. When
4
+ `pacer=portable`, use
5
+ [`../../_shared/pr-loop/portable-driver.md`](../../_shared/pr-loop/portable-driver.md)
6
+ instead. Shared bugbot / bugteam / Fix protocol steps stay in the main
7
+ `SKILL.md`.
6
8
 
7
9
  ## Calling ScheduleWakeup
8
10
 
@@ -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"