diff --git a/src/cli_agent_orchestrator/providers/claude_code.py b/src/cli_agent_orchestrator/providers/claude_code.py index 33684ff69..949477d6a 100644 --- a/src/cli_agent_orchestrator/providers/claude_code.py +++ b/src/cli_agent_orchestrator/providers/claude_code.py @@ -28,7 +28,7 @@ logger = logging.getLogger(__name__) -# Serializes concurrent _ensure_skip_bypass_prompt_setting() read-modify-writes to +# Serializes concurrent _ensure_startup_settings() read-modify-writes to # ~/.claude/settings.json -- after the async conversion, N concurrent inits can run this # in N threads (via asyncio.to_thread), and an unlocked read-modify-write can race: one # thread reads while another is mid-write, decodes a truncated file, falls back to {}, and @@ -459,21 +459,33 @@ def _build_claude_command(self, profile: Optional["AgentProfile"] = _UNSET) -> s return f"{unset_cmd}; {claude_cmd}" @staticmethod - def _ensure_skip_bypass_prompt_setting() -> None: - """Ensure ``skipDangerousModePermissionPrompt`` is set in settings. - - Claude Code (v2.1.41+) shows a bypass permissions confirmation dialog - on every launch with ``--dangerously-skip-permissions`` unless - ``skipDangerousModePermissionPrompt: true`` is persisted in - ``~/.claude/settings.json``. CAO already uses the flag intentionally, - so the confirmation is redundant and blocks initialization. - - After the async conversion, N concurrent inits may run this - read-modify-write in N threads. ``_SETTINGS_WRITE_LOCK`` serializes - our own threads (in-process only: a second cao-server process, or - Claude Code itself, writing between our read and ``os.replace`` is - still a last-writer-wins lost update); ``os.replace`` only guarantees - no torn reads for anything outside CAO. + def _ensure_startup_settings() -> None: + """Seed ``~/.claude/settings.json`` with the settings that prevent CLI startup + prompts CAO never wants to see, so PREVENTION is what suppresses them rather than + runtime detect-and-dismiss. + + Two keys are seeded in a single atomic read-modify-write: + + - ``skipDangerousModePermissionPrompt: true``: Claude Code (v2.1.41+) shows a bypass + permissions confirmation dialog on every launch with ``--dangerously-skip-permissions`` + unless this is persisted. CAO already uses the flag intentionally, so the confirmation + is redundant and blocks initialization. + - ``tui: "default"`` (workain/harness-control#225): Claude Code shows a first-run + "Try the new fullscreen renderer?" onboarding upsell on a HOME dir whose stored + onboarding-version state lags the installed CLI, unless the CLI's own ``/tui`` setting + is already explicitly set. ``"default"`` keeps the classic renderer that this file's + own screen-scraping status detection (``get_status``/``wait_until_status`` parse raw + pane content) already expects -- the real fullscreen mode uses the terminal's alternate + screen, untested against that scraping, so it is not something to switch on as a side + effect of dialog suppression. Prevention beats reacting to a prompt shape that only + exists at all because this setting was left unset (this replaces an earlier runtime + detect-and-dismiss approach). + + After the async conversion, N concurrent inits may run this read-modify-write in N + threads (via ``asyncio.to_thread``). ``_SETTINGS_WRITE_LOCK`` serializes our own threads + (in-process only: a second cao-server process, or Claude Code itself, writing between our + read and ``os.replace`` is still a last-writer-wins lost update); ``os.replace`` only + guarantees no torn reads for anything outside CAO. """ settings_path = Path.home() / ".claude" / "settings.json" with _SETTINGS_WRITE_LOCK: @@ -487,10 +499,19 @@ def _ensure_skip_bypass_prompt_setting() -> None: except (json.JSONDecodeError, OSError): pass - if settings.get("skipDangerousModePermissionPrompt") is True: + # Seed both keys in one write. Only overwrite ``tui`` when it is ABSENT -- + # an operator who deliberately chose ``"fullscreen"`` should not be reset on + # every launch (unlike skipDangerousModePermissionPrompt, which CAO always owns). + changed = False + if settings.get("skipDangerousModePermissionPrompt") is not True: + settings["skipDangerousModePermissionPrompt"] = True + changed = True + if "tui" not in settings: + settings["tui"] = "default" + changed = True + if not changed: return - settings["skipDangerousModePermissionPrompt"] = True settings_path.parent.mkdir(parents=True, exist_ok=True) # PID-suffixed so a stale tmp file from a prior crashed process # can never collide with -- or be clobbered by -- this write. @@ -501,7 +522,7 @@ def _ensure_skip_bypass_prompt_setting() -> None: # `apiKeyHelper` secrets) -- the tmp file would otherwise pick # up the process umask (typically 0644) and os.replace would # make the target adopt that on every launch that toggles - # this flag. + # these settings. with open(tmp_path, "w") as f: json.dump(settings, f, indent=2) os.chmod(tmp_path, existing_mode if existing_mode is not None else 0o600) @@ -512,7 +533,7 @@ def _ensure_skip_bypass_prompt_setting() -> None: # the tmp file indefinitely. tmp_path.unlink(missing_ok=True) raise - logger.info("Set skipDangerousModePermissionPrompt in ~/.claude/settings.json") + logger.info("Seeded startup-prompt-suppressing settings in ~/.claude/settings.json") async def _handle_startup_prompts( self, idle_gap: Optional[float] = None, outer_timeout: Optional[float] = None @@ -523,7 +544,7 @@ async def _handle_startup_prompts( 1. **Bypass permissions confirmation** (``--dangerously-skip-permissions``) – shows "Yes, I accept" as option 2; requires ``Down`` + ``Enter``. - The settings-based fix (``_ensure_skip_bypass_prompt_setting``) prevents + The settings-based fix (``_ensure_startup_settings``) prevents this in most cases; this handler is a defensive fallback. 2. **Workspace trust dialog** – shows "Yes, I trust this folder"; requires ``Enter``. @@ -663,7 +684,7 @@ async def initialize(self) -> bool: # Not exhaustive: wait_for_shell's own backend polling, _load_profile(), # and _build_claude_command's temp-file I/O above/below are still # loop-side -- tens of ms each, not the multi-second pileup #451 fixes. - await asyncio.to_thread(self._ensure_skip_bypass_prompt_setting) + await asyncio.to_thread(self._ensure_startup_settings) # Build properly escaped command string command = self._build_claude_command(profile) diff --git a/test/providers/test_claude_code_unit.py b/test/providers/test_claude_code_unit.py index de1c4c02c..566d77c96 100644 --- a/test/providers/test_claude_code_unit.py +++ b/test/providers/test_claude_code_unit.py @@ -37,9 +37,9 @@ def cleanup_tmp_files(): f.unlink(missing_ok=True) -# All initialization tests need to patch _ensure_skip_bypass_prompt_setting +# All initialization tests need to patch _ensure_startup_settings # to avoid writing to the real ~/.claude/settings.json. -_PATCH_SETTINGS = patch.object(ClaudeCodeProvider, "_ensure_skip_bypass_prompt_setting") +_PATCH_SETTINGS = patch.object(ClaudeCodeProvider, "_ensure_startup_settings") def _extract_mcp_config(command: str) -> dict: @@ -1955,8 +1955,8 @@ class TestClaudeCodeProviderSettings: """Tests for Claude Code settings management.""" @patch("cli_agent_orchestrator.providers.claude_code.Path") - def test_ensure_skip_bypass_prompt_already_set(self, mock_path_cls): - """Test no-op when setting is already present.""" + def test_ensure_startup_settings_noop_when_both_keys_present(self, mock_path_cls): + """Test no-op when BOTH seeded keys are already present.""" mock_settings_path = MagicMock() mock_settings_path.exists.return_value = True mock_path_cls.home.return_value.__truediv__ = MagicMock( @@ -1969,13 +1969,63 @@ def test_ensure_skip_bypass_prompt_already_set(self, mock_path_cls): mock_home.__truediv__ = MagicMock(return_value=mock_claude_dir) mock_claude_dir.__truediv__ = MagicMock(return_value=mock_settings_path) - existing = json.dumps({"skipDangerousModePermissionPrompt": True}) + # Both keys already set (and tui at some explicit value) -> genuine no-op. + existing = json.dumps( + {"skipDangerousModePermissionPrompt": True, "tui": "default"} + ) with patch("builtins.open", mock_open(read_data=existing)): - ClaudeCodeProvider._ensure_skip_bypass_prompt_setting() + ClaudeCodeProvider._ensure_startup_settings() - # Should not write (file handle's write not called) + # Should not write (no mkdir, no tmp-file replace) mock_settings_path.parent.mkdir.assert_not_called() + def test_ensure_startup_settings_seeds_tui_default_with_bypass(self, tmp_path): + """harness-control#225: both keys are seeded together in ONE atomic write -- + tui:"default" (upsell prevention) alongside skipDangerousModePermissionPrompt.""" + settings_file = tmp_path / ".claude" / "settings.json" + + with ( + patch("cli_agent_orchestrator.providers.claude_code.Path") as mock_path_cls, + patch( + "cli_agent_orchestrator.providers.claude_code.os.replace", wraps=os.replace + ) as mock_replace, + ): + mock_home = MagicMock() + mock_path_cls.home.return_value = mock_home + mock_home.__truediv__ = MagicMock( + return_value=MagicMock(__truediv__=MagicMock(return_value=settings_file)) + ) + + ClaudeCodeProvider._ensure_startup_settings() + + result = json.loads(settings_file.read_text()) + assert result["skipDangerousModePermissionPrompt"] is True + assert result["tui"] == "default" + # Both keys landed in a SINGLE atomic os.replace (not two separate writes). + mock_replace.assert_called_once() + # A freshly-created settings.json may carry secrets -> 0600. + assert stat.S_IMODE(settings_file.stat().st_mode) == 0o600 + + def test_ensure_startup_settings_preserves_explicit_tui(self, tmp_path): + """An operator who deliberately chose tui:"fullscreen" must NOT be reset on + every launch -- tui is only seeded when ABSENT (unlike the bypass flag CAO owns).""" + settings_file = tmp_path / ".claude" / "settings.json" + settings_file.parent.mkdir(parents=True) + settings_file.write_text(json.dumps({"tui": "fullscreen"})) + + with patch("cli_agent_orchestrator.providers.claude_code.Path") as mock_path_cls: + mock_home = MagicMock() + mock_path_cls.home.return_value = mock_home + mock_home.__truediv__ = MagicMock( + return_value=MagicMock(__truediv__=MagicMock(return_value=settings_file)) + ) + + ClaudeCodeProvider._ensure_startup_settings() + + result = json.loads(settings_file.read_text()) + assert result["tui"] == "fullscreen" # preserved, not clobbered + assert result["skipDangerousModePermissionPrompt"] is True # still seeded + def test_ensure_skip_bypass_prompt_writes_setting(self, tmp_path): """Test that setting is written when missing.""" settings_file = tmp_path / ".claude" / "settings.json" @@ -1989,7 +2039,7 @@ def test_ensure_skip_bypass_prompt_writes_setting(self, tmp_path): return_value=MagicMock(__truediv__=MagicMock(return_value=settings_file)) ) - ClaudeCodeProvider._ensure_skip_bypass_prompt_setting() + ClaudeCodeProvider._ensure_startup_settings() result = json.loads(settings_file.read_text()) assert result["skipDangerousModePermissionPrompt"] is True @@ -2007,7 +2057,7 @@ def test_ensure_skip_bypass_prompt_creates_file(self, tmp_path): return_value=MagicMock(__truediv__=MagicMock(return_value=settings_file)) ) - ClaudeCodeProvider._ensure_skip_bypass_prompt_setting() + ClaudeCodeProvider._ensure_startup_settings() result = json.loads(settings_file.read_text()) assert result["skipDangerousModePermissionPrompt"] is True @@ -2028,7 +2078,7 @@ def test_ensure_skip_bypass_prompt_preserves_file_mode(self, tmp_path): return_value=MagicMock(__truediv__=MagicMock(return_value=settings_file)) ) - ClaudeCodeProvider._ensure_skip_bypass_prompt_setting() + ClaudeCodeProvider._ensure_startup_settings() assert stat.S_IMODE(settings_file.stat().st_mode) == 0o600 @@ -2045,7 +2095,7 @@ def test_ensure_skip_bypass_prompt_new_file_defaults_to_0600(self, tmp_path): return_value=MagicMock(__truediv__=MagicMock(return_value=settings_file)) ) - ClaudeCodeProvider._ensure_skip_bypass_prompt_setting() + ClaudeCodeProvider._ensure_startup_settings() assert stat.S_IMODE(settings_file.stat().st_mode) == 0o600 @@ -2067,7 +2117,7 @@ def test_ensure_skip_bypass_prompt_concurrent_writes_preserve_keys(self, tmp_pat ) threads = [ - threading.Thread(target=ClaudeCodeProvider._ensure_skip_bypass_prompt_setting) + threading.Thread(target=ClaudeCodeProvider._ensure_startup_settings) for _ in range(32) ] for t in threads: @@ -2100,7 +2150,7 @@ def test_ensure_skip_bypass_prompt_uses_atomic_replace(self, tmp_path): return_value=MagicMock(__truediv__=MagicMock(return_value=settings_file)) ) - ClaudeCodeProvider._ensure_skip_bypass_prompt_setting() + ClaudeCodeProvider._ensure_startup_settings() mock_replace.assert_called_once() tmp_arg = mock_replace.call_args[0][0] diff --git a/test/providers/test_container_wrapped.py b/test/providers/test_container_wrapped.py index 3f06ec945..94e246ddb 100644 --- a/test/providers/test_container_wrapped.py +++ b/test/providers/test_container_wrapped.py @@ -222,7 +222,7 @@ async def test_idle_timeout_prompt_handler(mock_backend, mock_time, mock_sleep): @pytest.mark.asyncio -@patch.object(ClaudeCodeProvider, "_ensure_skip_bypass_prompt_setting") +@patch.object(ClaudeCodeProvider, "_ensure_startup_settings") @patch.object(ClaudeCodeProvider, "_build_claude_command", return_value="claude") @patch("cli_agent_orchestrator.providers.claude_code.load_agent_profile") @patch("cli_agent_orchestrator.providers.claude_code.wait_for_shell") diff --git a/test/providers/test_provider_init_timeout.py b/test/providers/test_provider_init_timeout.py index 56bb24f62..bbd474d02 100644 --- a/test/providers/test_provider_init_timeout.py +++ b/test/providers/test_provider_init_timeout.py @@ -48,7 +48,7 @@ class TestInitializePassesResolvedInitTimeout: load_agent_profile (profile source), wait_for_shell / wait_until_status / wait_until_input_ready (the async waits), _build_claude_command (avoids temp-file I/O), _handle_startup_prompts (asserted separately), - _ensure_skip_bypass_prompt_setting (avoids writing + _ensure_startup_settings (avoids writing ~/.claude/settings.json), and the terminal backend. """ @@ -58,7 +58,7 @@ def _mock_input_ready(self): yield @pytest.mark.asyncio - @patch.object(ClaudeCodeProvider, "_ensure_skip_bypass_prompt_setting") + @patch.object(ClaudeCodeProvider, "_ensure_startup_settings") @patch.object(ClaudeCodeProvider, "_build_claude_command", return_value="claude") @patch.object(ClaudeCodeProvider, "_handle_startup_prompts") @patch(f"{_CC}.load_agent_profile") @@ -91,7 +91,7 @@ async def test_profile_override_flows_to_every_wait( @pytest.mark.asyncio @patch(_SETTINGS, return_value={"provider_init_timeout": 60}) - @patch.object(ClaudeCodeProvider, "_ensure_skip_bypass_prompt_setting") + @patch.object(ClaudeCodeProvider, "_ensure_startup_settings") @patch.object(ClaudeCodeProvider, "_build_claude_command", return_value="claude") @patch.object(ClaudeCodeProvider, "_handle_startup_prompts") @patch(f"{_CC}.load_agent_profile") @@ -125,7 +125,7 @@ async def test_profile_without_override_uses_server_default( @pytest.mark.asyncio @patch(_SETTINGS, return_value={"provider_init_timeout": 60}) - @patch.object(ClaudeCodeProvider, "_ensure_skip_bypass_prompt_setting") + @patch.object(ClaudeCodeProvider, "_ensure_startup_settings") @patch.object(ClaudeCodeProvider, "_build_claude_command", return_value="claude") @patch.object(ClaudeCodeProvider, "_handle_startup_prompts") @patch(f"{_CC}.wait_for_shell") @@ -155,7 +155,7 @@ async def test_no_profile_uses_server_default( assert mock_handle.call_args.kwargs["outer_timeout"] == 60 @pytest.mark.asyncio - @patch.object(ClaudeCodeProvider, "_ensure_skip_bypass_prompt_setting") + @patch.object(ClaudeCodeProvider, "_ensure_startup_settings") @patch.object(ClaudeCodeProvider, "_build_claude_command", return_value="claude") @patch.object(ClaudeCodeProvider, "_handle_startup_prompts") @patch(f"{_CC}.load_agent_profile") diff --git a/uv.lock b/uv.lock index 4ee354dd1..816c05436 100644 --- a/uv.lock +++ b/uv.lock @@ -369,7 +369,7 @@ wheels = [ [[package]] name = "cli-agent-orchestrator" -version = "2.4.0" +version = "2.4.1" source = { editable = "." } dependencies = [ { name = "apscheduler" }, @@ -802,7 +802,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/ed/6bfa4109fcb23a58819600392564fea69cdc6551ffd5e69ccf1d52a40cbc/greenlet-3.2.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8c68325b0d0acf8d91dde4e6f930967dd52a5302cd4062932a6b2e7c2969f47c", size = 271061, upload-time = "2025-08-07T13:17:15.373Z" }, { url = "https://files.pythonhosted.org/packages/2a/fc/102ec1a2fc015b3a7652abab7acf3541d58c04d3d17a8d3d6a44adae1eb1/greenlet-3.2.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:94385f101946790ae13da500603491f04a76b6e4c059dab271b3ce2e283b2590", size = 629475, upload-time = "2025-08-07T13:42:54.009Z" }, { url = "https://files.pythonhosted.org/packages/c5/26/80383131d55a4ac0fb08d71660fd77e7660b9db6bdb4e8884f46d9f2cc04/greenlet-3.2.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f10fd42b5ee276335863712fa3da6608e93f70629c631bf77145021600abc23c", size = 640802, upload-time = "2025-08-07T13:45:25.52Z" }, - { url = "https://files.pythonhosted.org/packages/9f/7c/e7833dbcd8f376f3326bd728c845d31dcde4c84268d3921afcae77d90d08/greenlet-3.2.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c8c9e331e58180d0d83c5b7999255721b725913ff6bc6cf39fa2a45841a4fd4b", size = 636703, upload-time = "2025-08-07T13:53:12.622Z" }, { url = "https://files.pythonhosted.org/packages/e9/49/547b93b7c0428ede7b3f309bc965986874759f7d89e4e04aeddbc9699acb/greenlet-3.2.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58b97143c9cc7b86fc458f215bd0932f1757ce649e05b640fea2e79b54cedb31", size = 635417, upload-time = "2025-08-07T13:18:25.189Z" }, { url = "https://files.pythonhosted.org/packages/7f/91/ae2eb6b7979e2f9b035a9f612cf70f1bf54aad4e1d125129bef1eae96f19/greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d", size = 584358, upload-time = "2025-08-07T13:18:23.708Z" }, { url = "https://files.pythonhosted.org/packages/f7/85/433de0c9c0252b22b16d413c9407e6cb3b41df7389afc366ca204dbc1393/greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5", size = 1113550, upload-time = "2025-08-07T13:42:37.467Z" }, @@ -813,7 +812,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/de/f28ced0a67749cac23fecb02b694f6473f47686dff6afaa211d186e2ef9c/greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2", size = 272305, upload-time = "2025-08-07T13:15:41.288Z" }, { url = "https://files.pythonhosted.org/packages/09/16/2c3792cba130000bf2a31c5272999113f4764fd9d874fb257ff588ac779a/greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246", size = 632472, upload-time = "2025-08-07T13:42:55.044Z" }, { url = "https://files.pythonhosted.org/packages/ae/8f/95d48d7e3d433e6dae5b1682e4292242a53f22df82e6d3dda81b1701a960/greenlet-3.2.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:94abf90142c2a18151632371140b3dba4dee031633fe614cb592dbb6c9e17bc3", size = 644646, upload-time = "2025-08-07T13:45:26.523Z" }, - { url = "https://files.pythonhosted.org/packages/d5/5e/405965351aef8c76b8ef7ad370e5da58d57ef6068df197548b015464001a/greenlet-3.2.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:4d1378601b85e2e5171b99be8d2dc85f594c79967599328f95c1dc1a40f1c633", size = 640519, upload-time = "2025-08-07T13:53:13.928Z" }, { url = "https://files.pythonhosted.org/packages/25/5d/382753b52006ce0218297ec1b628e048c4e64b155379331f25a7316eb749/greenlet-3.2.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0db5594dce18db94f7d1650d7489909b57afde4c580806b8d9203b6e79cdc079", size = 639707, upload-time = "2025-08-07T13:18:27.146Z" }, { url = "https://files.pythonhosted.org/packages/1f/8e/abdd3f14d735b2929290a018ecf133c901be4874b858dd1c604b9319f064/greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8", size = 587684, upload-time = "2025-08-07T13:18:25.164Z" }, { url = "https://files.pythonhosted.org/packages/5d/65/deb2a69c3e5996439b0176f6651e0052542bb6c8f8ec2e3fba97c9768805/greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52", size = 1116647, upload-time = "2025-08-07T13:42:38.655Z" }, @@ -824,7 +822,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, - { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, @@ -835,7 +832,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, - { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, @@ -846,7 +842,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, - { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, { url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },