LastNoob commited on
Commit
4a8663f
·
unverified ·
1 Parent(s): 02356b1

Refactor CLI surfaces around launchers and managed Claude (#861)

Browse files

## Problem

The CLI package preserved a generic adapter layer and managed Codex
parser path that did not match the supported customer workflows.
Messaging runs Claude Code sessions, while Codex is supported through
`fcc-codex` and extensions.

## Changes

| Before | After |
| --- | --- |
| `fcc-claude` and `fcc-codex` shared generic adapter plumbing. |
`fcc-claude` and `fcc-codex` use explicit launcher modules. |
| Messaging depended on a generic CLI session abstraction. | Messaging
depends on managed Claude Code sessions. |
| Codex catalog generation lived as a top-level CLI helper. | Codex
catalog generation lives under the Codex launcher owner. |
| Tests asserted deleted internal adapter shapes. | Tests assert
launcher, managed-Claude, and customer-surface behavior. |

ARCHITECTURE.md CHANGED
@@ -128,8 +128,8 @@ Console scripts are registered in [pyproject.toml](pyproject.toml):
128
 
129
  - `fcc-server` and `free-claude-code` call `cli.entrypoints:serve`.
130
  - `fcc-init` calls `cli.entrypoints:init`.
131
- - `fcc-claude` calls `cli.entrypoints:launch_claude`.
132
- - `fcc-codex` calls `cli.entrypoints:launch_codex`.
133
 
134
  [scripts/install.sh](scripts/install.sh) and [scripts/install.ps1](scripts/install.ps1)
135
  install or update the uv tool plus optional voice extras. [scripts/uninstall.sh](scripts/uninstall.sh)
@@ -442,25 +442,19 @@ conversion. Forced `web_search` or `web_fetch` requests are handled locally when
442
  `ENABLE_WEB_SERVER_TOOLS` is true; otherwise OpenAI-chat upstreams reject them
443
  and native Anthropic Messages transports may receive them.
444
 
445
- ## CLI Launchers And Client Adapter Boundary
446
 
447
- [cli/adapters/base.py](cli/adapters/base.py) defines `ClientCliAdapter`, the
448
- boundary for building subprocess commands, building launcher environments,
449
- parsing stdout lines, and extracting persistent session IDs.
450
-
451
- [cli/adapters/claude.py](cli/adapters/claude.py) implements the Claude Code
452
- adapter:
453
 
454
  - `fcc-claude` strips inherited `ANTHROPIC_*` variables, sets
455
  `ANTHROPIC_BASE_URL`, enables gateway model discovery, configures the
456
  auto-compact window, and always sets `ANTHROPIC_AUTH_TOKEN`. Blank proxy auth
457
  becomes the local-only `fcc-no-auth` sentinel so Claude Code reaches the proxy
458
  instead of stopping at its login gate.
459
- - Managed task invocations set `ANTHROPIC_API_URL`, `ANTHROPIC_BASE_URL`,
460
- gateway model discovery, non-interactive terminal settings, optional
461
- `--resume`, optional `--fork-session`, and `--output-format stream-json`.
462
 
463
- [cli/adapters/codex.py](cli/adapters/codex.py) implements the Codex adapter:
 
464
 
465
  - `fcc-codex` strips official OpenAI and Codex credential variables.
466
  - It creates an ephemeral `fcc` model provider with `wire_api = "responses"` and
@@ -470,15 +464,18 @@ adapter:
470
  native `/model` picker lists FCC provider slugs. Catalog generation is
471
  fail-open: launch continues with a warning if the catalog cannot be prepared.
472
  - It stores the proxy auth token in `FCC_CODEX_API_KEY` for Codex to read.
473
- - Managed task invocations use Codex JSON output and map Responses events into
474
- the messaging parser event shape.
475
- - Codex `response.reasoning_text.delta` events are converted into the shared
476
- Anthropic-style `thinking_delta` parser shape; summary reasoning events remain
477
- raw unless a future feature selects them as the proxy wire shape.
478
 
479
- [cli/manager.py](cli/manager.py) coordinates multiple `CLISession` instances so
480
- separate conversations can run in separate client CLI processes while replies
481
- reuse or fork existing sessions.
 
 
 
 
 
 
 
 
482
 
483
  ## Messaging Architecture
484
 
@@ -536,8 +533,8 @@ sequenceDiagram
536
  participant Intake as MessagingTurnIntake
537
  participant Queue as TreeQueueManager
538
  participant Runner as MessagingNodeRunner
539
- participant Manager as CLISessionManager
540
- participant CLI as ClientCLI
541
  participant Proxy as LocalProxy
542
 
543
  Platform->>Workflow: IncomingMessage
@@ -621,16 +618,18 @@ when maintainers want branch-level assurance.
621
  updated in place.
622
  5. Add tests under [tests/api/](tests/api/) or [tests/config/](tests/config/).
623
 
624
- ### Add A Client Adapter
625
 
626
- 1. Implement the `ClientCliAdapter` protocol from
627
- [cli/adapters/base.py](cli/adapters/base.py).
628
- 2. Register selection behavior in [cli/adapters/registry.py](cli/adapters/registry.py).
629
- 3. Ensure launcher env construction strips conflicting upstream credentials.
630
- 4. Ensure managed task parsing emits the event shapes expected by
 
631
  [messaging/event_parser.py](messaging/event_parser.py) and
632
  [messaging/node_event_pipeline.py](messaging/node_event_pipeline.py).
633
- 5. Add CLI adapter and session-manager tests under [tests/cli/](tests/cli/).
 
634
 
635
  ### Add A Messaging Platform
636
 
 
128
 
129
  - `fcc-server` and `free-claude-code` call `cli.entrypoints:serve`.
130
  - `fcc-init` calls `cli.entrypoints:init`.
131
+ - `fcc-claude` calls `cli.launchers.claude:launch`.
132
+ - `fcc-codex` calls `cli.launchers.codex:launch`.
133
 
134
  [scripts/install.sh](scripts/install.sh) and [scripts/install.ps1](scripts/install.ps1)
135
  install or update the uv tool plus optional voice extras. [scripts/uninstall.sh](scripts/uninstall.sh)
 
442
  `ENABLE_WEB_SERVER_TOOLS` is true; otherwise OpenAI-chat upstreams reject them
443
  and native Anthropic Messages transports may receive them.
444
 
445
+ ## CLI Launchers And Managed Claude
446
 
447
+ [cli/launchers/claude.py](cli/launchers/claude.py) owns the installed
448
+ `fcc-claude` launcher:
 
 
 
 
449
 
450
  - `fcc-claude` strips inherited `ANTHROPIC_*` variables, sets
451
  `ANTHROPIC_BASE_URL`, enables gateway model discovery, configures the
452
  auto-compact window, and always sets `ANTHROPIC_AUTH_TOKEN`. Blank proxy auth
453
  becomes the local-only `fcc-no-auth` sentinel so Claude Code reaches the proxy
454
  instead of stopping at its login gate.
 
 
 
455
 
456
+ [cli/launchers/codex.py](cli/launchers/codex.py) owns the installed
457
+ `fcc-codex` launcher:
458
 
459
  - `fcc-codex` strips official OpenAI and Codex credential variables.
460
  - It creates an ephemeral `fcc` model provider with `wire_api = "responses"` and
 
464
  native `/model` picker lists FCC provider slugs. Catalog generation is
465
  fail-open: launch continues with a warning if the catalog cannot be prepared.
466
  - It stores the proxy auth token in `FCC_CODEX_API_KEY` for Codex to read.
 
 
 
 
 
467
 
468
+ [cli/managed/](cli/managed/) owns managed Claude Code subprocesses used by
469
+ Discord and Telegram messaging. Managed task invocations set
470
+ `ANTHROPIC_API_URL`, `ANTHROPIC_BASE_URL`, gateway model discovery,
471
+ non-interactive terminal settings, optional `--resume`, optional
472
+ `--fork-session`, and `--output-format stream-json`. The managed session parser
473
+ extracts persistent Claude session IDs and yields Claude stream-json events to
474
+ the messaging event parser.
475
+
476
+ Codex is supported through `fcc-codex` and Codex extensions. FCC does not keep an
477
+ internal managed-Codex session runner because no user-facing messaging setting
478
+ selects Codex for Discord or Telegram.
479
 
480
  ## Messaging Architecture
481
 
 
533
  participant Intake as MessagingTurnIntake
534
  participant Queue as TreeQueueManager
535
  participant Runner as MessagingNodeRunner
536
+ participant Manager as ManagedClaudeSessionManager
537
+ participant CLI as ClaudeCode
538
  participant Proxy as LocalProxy
539
 
540
  Platform->>Workflow: IncomingMessage
 
618
  updated in place.
619
  5. Add tests under [tests/api/](tests/api/) or [tests/config/](tests/config/).
620
 
621
+ ### Add Or Change A Client Surface
622
 
623
+ 1. For an installed wrapper, add or update a launcher under
624
+ [cli/launchers/](cli/launchers/) and keep credential stripping local to that
625
+ client.
626
+ 2. For messaging-managed execution, update [cli/managed/](cli/managed/) only
627
+ when Discord or Telegram should actually run a different managed client.
628
+ 3. Ensure managed task parsing emits the event shapes expected by
629
  [messaging/event_parser.py](messaging/event_parser.py) and
630
  [messaging/node_event_pipeline.py](messaging/node_event_pipeline.py).
631
+ 4. Add launcher, managed-session, and customer-flow tests under
632
+ [tests/cli/](tests/cli/) and [tests/messaging/](tests/messaging/).
633
 
634
  ### Add A Messaging Platform
635
 
api/runtime.py CHANGED
@@ -17,7 +17,7 @@ from providers.exceptions import ServiceUnavailableError
17
  from providers.registry import ProviderRegistry
18
 
19
  if TYPE_CHECKING:
20
- from cli.manager import CLISessionManager
21
  from messaging.platforms.base import MessagingPlatform
22
  from messaging.session import SessionStore
23
  from messaging.workflow import MessagingWorkflow
@@ -90,7 +90,7 @@ class AppRuntime:
90
  _provider_registry: ProviderRegistry | None = field(default=None, init=False)
91
  messaging_platform: MessagingPlatform | None = None
92
  messaging_workflow: MessagingWorkflow | None = None
93
- cli_manager: CLISessionManager | None = None
94
 
95
  @classmethod
96
  def for_app(
@@ -224,7 +224,7 @@ class AppRuntime:
224
  )
225
 
226
  async def _start_messaging_workflow(self) -> None:
227
- from cli.manager import CLISessionManager
228
  from messaging.session import SessionStore
229
  from messaging.workflow import MessagingWorkflow
230
 
@@ -244,7 +244,7 @@ class AppRuntime:
244
  os.path.join(self.settings.claude_workspace, "plans")
245
  )
246
  plans_directory = os.path.relpath(plans_dir_abs, workspace)
247
- self.cli_manager = CLISessionManager(
248
  workspace_path=workspace,
249
  api_url=api_url,
250
  allowed_dirs=allowed_dirs,
 
17
  from providers.registry import ProviderRegistry
18
 
19
  if TYPE_CHECKING:
20
+ from cli.managed import ManagedClaudeSessionManager
21
  from messaging.platforms.base import MessagingPlatform
22
  from messaging.session import SessionStore
23
  from messaging.workflow import MessagingWorkflow
 
90
  _provider_registry: ProviderRegistry | None = field(default=None, init=False)
91
  messaging_platform: MessagingPlatform | None = None
92
  messaging_workflow: MessagingWorkflow | None = None
93
+ cli_manager: ManagedClaudeSessionManager | None = None
94
 
95
  @classmethod
96
  def for_app(
 
224
  )
225
 
226
  async def _start_messaging_workflow(self) -> None:
227
+ from cli.managed import ManagedClaudeSessionManager
228
  from messaging.session import SessionStore
229
  from messaging.workflow import MessagingWorkflow
230
 
 
244
  os.path.join(self.settings.claude_workspace, "plans")
245
  )
246
  plans_directory = os.path.relpath(plans_dir_abs, workspace)
247
+ self.cli_manager = ManagedClaudeSessionManager(
248
  workspace_path=workspace,
249
  api_url=api_url,
250
  allowed_dirs=allowed_dirs,
cli/__init__.py CHANGED
@@ -1,6 +1,5 @@
1
- """CLI integration for Claude Code."""
2
 
3
- from .manager import CLISessionManager
4
- from .session import CLISession
5
 
6
- __all__ = ["CLISession", "CLISessionManager"]
 
1
+ """CLI integration for installed launchers and managed Claude Code."""
2
 
3
+ from .managed import ManagedClaudeSession, ManagedClaudeSessionManager
 
4
 
5
+ __all__ = ["ManagedClaudeSession", "ManagedClaudeSessionManager"]
cli/adapters/__init__.py DELETED
@@ -1,14 +0,0 @@
1
- """Client CLI adapter implementations."""
2
-
3
- from .claude import CLAUDE_CLI_ADAPTER, ClaudeCliAdapter
4
- from .codex import CODEX_CLI_ADAPTER, CodexCliAdapter
5
- from .registry import DEFAULT_CLIENT_CLI_ID, get_client_cli_adapter
6
-
7
- __all__ = [
8
- "CLAUDE_CLI_ADAPTER",
9
- "CODEX_CLI_ADAPTER",
10
- "DEFAULT_CLIENT_CLI_ID",
11
- "ClaudeCliAdapter",
12
- "CodexCliAdapter",
13
- "get_client_cli_adapter",
14
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cli/adapters/base.py DELETED
@@ -1,92 +0,0 @@
1
- """Shared contracts for client CLI subprocess adapters."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Iterable, Mapping
6
- from dataclasses import dataclass, field
7
- from typing import Any, Protocol
8
-
9
-
10
- @dataclass(frozen=True, slots=True)
11
- class CliTaskRequest:
12
- """A single prompt execution request for a managed client CLI process."""
13
-
14
- prompt: str
15
- session_id: str | None = None
16
- fork_session: bool = False
17
-
18
-
19
- @dataclass(frozen=True, slots=True)
20
- class CliInvocation:
21
- """Concrete subprocess invocation assembled by a client CLI adapter."""
22
-
23
- argv: tuple[str, ...]
24
- env: dict[str, str]
25
- cwd: str
26
- trace_metadata: dict[str, Any] = field(default_factory=dict)
27
-
28
-
29
- @dataclass(slots=True)
30
- class CliParseState:
31
- """Mutable line-parser state for a single client CLI process run."""
32
-
33
- log_raw_cli_diagnostics: bool = False
34
- session_id_extracted: bool = False
35
- responses_seen_output_keys: set[str] = field(default_factory=set)
36
- responses_current_output_scope: str | None = None
37
- responses_next_implicit_output_scope: int = 0
38
-
39
-
40
- class ClientCliAdapter(Protocol):
41
- """Adapter boundary for client CLI command/env construction and output parsing."""
42
-
43
- id: str
44
- display_name: str
45
- default_binary: str
46
- install_hint: str
47
- trace_stage: str
48
- process_launch_event: str
49
- trace_source: str
50
-
51
- def build_task_invocation(
52
- self,
53
- *,
54
- config: Any,
55
- request: CliTaskRequest,
56
- base_env: Mapping[str, str],
57
- ) -> CliInvocation:
58
- """Build the subprocess invocation for a managed task run."""
59
- ...
60
-
61
- def parse_stdout_line(self, line: str, state: CliParseState) -> Iterable[Any]:
62
- """Parse one stdout line into parser-ready internal CLI events."""
63
- ...
64
-
65
- def extract_session_id(self, event: Any) -> str | None:
66
- """Extract a persistent client CLI session id from a parsed event."""
67
- ...
68
-
69
- def get_launcher_binary_name(self, settings: Any) -> str:
70
- """Return the configured executable name for a wrapper entrypoint."""
71
- ...
72
-
73
- def build_launcher_command(
74
- self,
75
- *,
76
- binary_path: str,
77
- argv: Iterable[str],
78
- settings: Any,
79
- proxy_root_url: str,
80
- ) -> list[str]:
81
- """Build the wrapper subprocess command for a client CLI launch."""
82
- ...
83
-
84
- def build_launcher_env(
85
- self,
86
- *,
87
- proxy_root_url: str,
88
- auth_token: str,
89
- base_env: Mapping[str, str],
90
- ) -> dict[str, str]:
91
- """Build environment variables for a wrapper-launched client CLI."""
92
- ...
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cli/adapters/claude.py DELETED
@@ -1,234 +0,0 @@
1
- """Claude Code client CLI adapter."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from collections.abc import Iterable, Mapping
7
- from typing import Any
8
-
9
- from loguru import logger
10
-
11
- from .base import CliInvocation, CliParseState, CliTaskRequest
12
-
13
- _AUTO_COMPACT_WINDOW = "190000"
14
- _NO_AUTH_SENTINEL = "fcc-no-auth"
15
-
16
-
17
- class ClaudeCliAdapter:
18
- """Client CLI adapter for Claude Code."""
19
-
20
- id = "claude"
21
- display_name = "Claude Code"
22
- default_binary = "claude"
23
- install_hint = "Install Claude Code with: npm install -g @anthropic-ai/claude-code"
24
- trace_stage = "claude_cli"
25
- process_launch_event = "claude_cli.process.launch"
26
- trace_source = "claude_cli"
27
-
28
- def build_task_invocation(
29
- self,
30
- *,
31
- config: Any,
32
- request: CliTaskRequest,
33
- base_env: Mapping[str, str],
34
- ) -> CliInvocation:
35
- """Build a Claude Code stream-json subprocess invocation."""
36
-
37
- env = self._task_env(
38
- api_url=config.api_url,
39
- auth_token=config.auth_token,
40
- base_env=base_env,
41
- )
42
- cmd = self._task_command(
43
- claude_bin=config.claude_bin,
44
- prompt=request.prompt,
45
- session_id=request.session_id,
46
- fork_session=request.fork_session,
47
- allowed_dirs=config.allowed_dirs,
48
- plans_directory=config.plans_directory,
49
- )
50
-
51
- resume_session_id = (
52
- request.session_id
53
- if request.session_id and not request.session_id.startswith("pending_")
54
- else None
55
- )
56
- return CliInvocation(
57
- argv=tuple(cmd),
58
- env=env,
59
- cwd=config.workspace_path,
60
- trace_metadata={
61
- "client_cli_id": self.id,
62
- "resume_session_id": resume_session_id,
63
- "fork_session": request.fork_session,
64
- "prompt": request.prompt,
65
- "cwd": config.workspace_path,
66
- "claude_binary": config.claude_bin,
67
- "cli_argv": cmd,
68
- },
69
- )
70
-
71
- def parse_stdout_line(self, line: str, state: CliParseState) -> Iterable[Any]:
72
- """Parse one Claude Code JSONL line into existing parser-ready events."""
73
-
74
- try:
75
- event = json.loads(line)
76
- except json.JSONDecodeError:
77
- if state.log_raw_cli_diagnostics:
78
- logger.debug("Non-JSON output: {}", line)
79
- else:
80
- logger.debug("Non-JSON CLI line: char_len={}", len(line))
81
- yield {"type": "raw", "content": line}
82
- return
83
-
84
- if not state.session_id_extracted:
85
- extracted_id = self.extract_session_id(event)
86
- if extracted_id:
87
- state.session_id_extracted = True
88
- logger.info(f"Extracted session ID: {extracted_id}")
89
- yield {"type": "session_info", "session_id": extracted_id}
90
-
91
- yield event
92
-
93
- def extract_session_id(self, event: Any) -> str | None:
94
- """Extract a Claude Code session ID from supported event shapes."""
95
-
96
- if not isinstance(event, dict):
97
- return None
98
-
99
- if session_id := _string_value(event.get("session_id")):
100
- return session_id
101
- if session_id := _string_value(event.get("sessionId")):
102
- return session_id
103
-
104
- for key in ["init", "system", "result", "metadata"]:
105
- nested = event.get(key)
106
- if not isinstance(nested, dict):
107
- continue
108
- if session_id := _string_value(nested.get("session_id")):
109
- return session_id
110
- if session_id := _string_value(nested.get("sessionId")):
111
- return session_id
112
-
113
- conv = event.get("conversation")
114
- if isinstance(conv, dict):
115
- return _string_value(conv.get("id"))
116
-
117
- return None
118
-
119
- def get_launcher_binary_name(self, settings: Any) -> str:
120
- """Return the configured Claude Code binary name."""
121
-
122
- configured = getattr(settings, "claude_cli_bin", "")
123
- return configured or self.default_binary
124
-
125
- def build_launcher_command(
126
- self,
127
- *,
128
- binary_path: str,
129
- argv: Iterable[str],
130
- settings: Any,
131
- proxy_root_url: str,
132
- ) -> list[str]:
133
- """Return the Claude wrapper command without changing user arguments."""
134
-
135
- return [binary_path, *argv]
136
-
137
- def build_launcher_env(
138
- self,
139
- *,
140
- proxy_root_url: str,
141
- auth_token: str,
142
- base_env: Mapping[str, str],
143
- ) -> dict[str, str]:
144
- """Return a Claude Code environment that targets the local proxy."""
145
-
146
- env = {
147
- key: value
148
- for key, value in base_env.items()
149
- if not key.startswith("ANTHROPIC_")
150
- }
151
- env["ANTHROPIC_BASE_URL"] = proxy_root_url
152
- env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
153
- env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = _AUTO_COMPACT_WINDOW
154
- env["ANTHROPIC_AUTH_TOKEN"] = _claude_auth_token(auth_token)
155
- return env
156
-
157
- def _task_env(
158
- self,
159
- *,
160
- api_url: str,
161
- auth_token: str,
162
- base_env: Mapping[str, str],
163
- ) -> dict[str, str]:
164
- env = dict(base_env)
165
- env["ANTHROPIC_API_URL"] = api_url
166
- if api_url.endswith("/v1"):
167
- env["ANTHROPIC_BASE_URL"] = api_url[:-3]
168
- else:
169
- env["ANTHROPIC_BASE_URL"] = api_url
170
- env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
171
- env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = _AUTO_COMPACT_WINDOW
172
- env.pop("ANTHROPIC_API_KEY", None)
173
- env["ANTHROPIC_AUTH_TOKEN"] = _claude_auth_token(auth_token)
174
-
175
- env["TERM"] = "dumb"
176
- env["PYTHONIOENCODING"] = "utf-8"
177
- return env
178
-
179
- def _task_command(
180
- self,
181
- *,
182
- claude_bin: str,
183
- prompt: str,
184
- session_id: str | None,
185
- fork_session: bool,
186
- allowed_dirs: list[str],
187
- plans_directory: str | None,
188
- ) -> list[str]:
189
- if session_id and not session_id.startswith("pending_"):
190
- cmd = [
191
- claude_bin,
192
- "--resume",
193
- session_id,
194
- ]
195
- if fork_session:
196
- cmd.append("--fork-session")
197
- cmd += [
198
- "-p",
199
- prompt,
200
- "--output-format",
201
- "stream-json",
202
- "--dangerously-skip-permissions",
203
- "--verbose",
204
- ]
205
- else:
206
- cmd = [
207
- claude_bin,
208
- "-p",
209
- prompt,
210
- "--output-format",
211
- "stream-json",
212
- "--dangerously-skip-permissions",
213
- "--verbose",
214
- ]
215
-
216
- for directory in allowed_dirs:
217
- cmd.extend(["--add-dir", directory])
218
-
219
- if plans_directory is not None:
220
- settings_json = json.dumps({"plansDirectory": plans_directory})
221
- cmd.extend(["--settings", settings_json])
222
-
223
- return cmd
224
-
225
-
226
- def _string_value(value: Any) -> str | None:
227
- return value if isinstance(value, str) else None
228
-
229
-
230
- def _claude_auth_token(auth_token: str) -> str:
231
- return auth_token.strip() or _NO_AUTH_SENTINEL
232
-
233
-
234
- CLAUDE_CLI_ADAPTER = ClaudeCliAdapter()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cli/adapters/codex.py DELETED
@@ -1,525 +0,0 @@
1
- """Codex CLI adapter."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- from collections.abc import Iterable, Mapping
7
- from typing import Any, cast
8
-
9
- from loguru import logger
10
-
11
- from .base import CliInvocation, CliParseState, CliTaskRequest
12
-
13
- _CODEX_AUTH_ENV_KEY = "FCC_CODEX_API_KEY"
14
- _STRIPPED_CODEX_ENV_KEYS = frozenset(
15
- {
16
- "OPENAI_API_KEY",
17
- "OPENAI_BASE_URL",
18
- "OPENAI_API_BASE",
19
- "OPENAI_ORG_ID",
20
- "OPENAI_ORGANIZATION",
21
- "CODEX_API_KEY",
22
- _CODEX_AUTH_ENV_KEY,
23
- }
24
- )
25
-
26
-
27
- class CodexCliAdapter:
28
- """Client CLI adapter for OpenAI Codex CLI."""
29
-
30
- id = "codex"
31
- display_name = "Codex CLI"
32
- default_binary = "codex"
33
- install_hint = "Install Codex with: npm install -g @openai/codex"
34
- trace_stage = "codex_cli"
35
- process_launch_event = "codex_cli.process.launch"
36
- trace_source = "codex_cli"
37
-
38
- def build_task_invocation(
39
- self,
40
- *,
41
- config: Any,
42
- request: CliTaskRequest,
43
- base_env: Mapping[str, str],
44
- ) -> CliInvocation:
45
- """Build a Codex JSONL subprocess invocation for managed messaging."""
46
-
47
- env = self._task_env(
48
- api_url=config.api_url,
49
- auth_token=config.auth_token,
50
- base_env=base_env,
51
- )
52
- codex_bin = getattr(config, "codex_bin", None) or self.default_binary
53
- cmd = self._task_command(
54
- codex_bin=codex_bin,
55
- prompt=request.prompt,
56
- session_id=request.session_id,
57
- fork_session=request.fork_session,
58
- api_url=config.api_url,
59
- allowed_dirs=config.allowed_dirs,
60
- workspace_path=config.workspace_path,
61
- )
62
- resume_session_id = (
63
- request.session_id
64
- if request.session_id
65
- and not request.session_id.startswith("pending_")
66
- and not request.fork_session
67
- else None
68
- )
69
- return CliInvocation(
70
- argv=tuple(cmd),
71
- env=env,
72
- cwd=config.workspace_path,
73
- trace_metadata={
74
- "client_cli_id": self.id,
75
- "resume_session_id": resume_session_id,
76
- "fork_session": request.fork_session,
77
- "prompt": request.prompt,
78
- "cwd": config.workspace_path,
79
- "codex_binary": codex_bin,
80
- "cli_argv": cmd,
81
- },
82
- )
83
-
84
- def parse_stdout_line(self, line: str, state: CliParseState) -> Iterable[Any]:
85
- """Parse one Codex JSONL line into existing parser-ready events."""
86
-
87
- try:
88
- event = json.loads(line)
89
- except json.JSONDecodeError:
90
- if state.log_raw_cli_diagnostics:
91
- logger.debug("Non-JSON Codex output: {}", line)
92
- else:
93
- logger.debug("Non-JSON Codex CLI line: char_len={}", len(line))
94
- yield {"type": "raw", "content": line}
95
- return
96
-
97
- if not state.session_id_extracted:
98
- extracted_id = self.extract_session_id(event)
99
- if extracted_id:
100
- state.session_id_extracted = True
101
- logger.info("Extracted Codex session ID: {}", extracted_id)
102
- yield {"type": "session_info", "session_id": extracted_id}
103
-
104
- mapped = list(_codex_event_to_parser_events(event, state))
105
- if mapped:
106
- yield from mapped
107
- return
108
- if event.get("type") in {"response.output_item.done", "response.completed"}:
109
- return
110
-
111
- yield {"type": "raw", "content": line}
112
-
113
- def extract_session_id(self, event: Any) -> str | None:
114
- """Extract a Codex session or thread id from supported event shapes."""
115
-
116
- if not isinstance(event, dict):
117
- return None
118
- for key in ("session_id", "sessionId", "thread_id", "threadId"):
119
- if session_id := _string_value(event.get(key)):
120
- return session_id
121
- for key in ("thread", "session", "conversation", "metadata"):
122
- nested = event.get(key)
123
- if not isinstance(nested, dict):
124
- continue
125
- for nested_key in ("id", "session_id", "thread_id", "conversation_id"):
126
- if session_id := _string_value(nested.get(nested_key)):
127
- return session_id
128
- return None
129
-
130
- def get_launcher_binary_name(self, settings: Any) -> str:
131
- """Return the configured Codex binary name."""
132
-
133
- configured = getattr(settings, "codex_cli_bin", "")
134
- return configured or self.default_binary
135
-
136
- def build_launcher_command(
137
- self,
138
- *,
139
- binary_path: str,
140
- argv: Iterable[str],
141
- settings: Any,
142
- proxy_root_url: str,
143
- ) -> list[str]:
144
- """Return a Codex command with ephemeral FCC provider config."""
145
-
146
- return [
147
- binary_path,
148
- *self._codex_config_args(
149
- api_url=_ensure_v1_url(proxy_root_url),
150
- model=getattr(settings, "model", None),
151
- ),
152
- *argv,
153
- ]
154
-
155
- def build_launcher_env(
156
- self,
157
- *,
158
- proxy_root_url: str,
159
- auth_token: str,
160
- base_env: Mapping[str, str],
161
- ) -> dict[str, str]:
162
- """Return a Codex environment that targets the local proxy provider."""
163
-
164
- env = _base_codex_env(base_env)
165
- env[_CODEX_AUTH_ENV_KEY] = auth_token.strip() or "fcc-no-auth"
166
- return env
167
-
168
- def build_model_catalog_config_args(self, catalog_path: str) -> list[str]:
169
- """Return Codex config args for a generated model catalog."""
170
-
171
- return ["-c", _toml_assignment("model_catalog_json", catalog_path)]
172
-
173
- def _task_env(
174
- self,
175
- *,
176
- api_url: str,
177
- auth_token: str,
178
- base_env: Mapping[str, str],
179
- ) -> dict[str, str]:
180
- env = _base_codex_env(base_env)
181
- env[_CODEX_AUTH_ENV_KEY] = auth_token.strip() or "fcc-no-auth"
182
- env["TERM"] = "dumb"
183
- env["PYTHONIOENCODING"] = "utf-8"
184
- return env
185
-
186
- def _task_command(
187
- self,
188
- *,
189
- codex_bin: str,
190
- prompt: str,
191
- session_id: str | None,
192
- fork_session: bool,
193
- api_url: str,
194
- allowed_dirs: list[str],
195
- workspace_path: str,
196
- ) -> list[str]:
197
- common_args = [
198
- "--json",
199
- "--skip-git-repo-check",
200
- "--dangerously-bypass-approvals-and-sandbox",
201
- *self._codex_config_args(api_url=api_url),
202
- ]
203
- if session_id and not session_id.startswith("pending_") and not fork_session:
204
- return [
205
- codex_bin,
206
- "exec",
207
- "resume",
208
- *common_args,
209
- session_id,
210
- prompt,
211
- ]
212
-
213
- cmd = [
214
- codex_bin,
215
- "exec",
216
- *common_args,
217
- "-C",
218
- workspace_path,
219
- ]
220
- for directory in allowed_dirs:
221
- cmd.extend(["--add-dir", directory])
222
- cmd.append(prompt)
223
- return cmd
224
-
225
- def _codex_config_args(
226
- self, *, api_url: str, model: str | None = None
227
- ) -> list[str]:
228
- args = [
229
- "-c",
230
- _toml_assignment("model_provider", "fcc"),
231
- "-c",
232
- _toml_assignment("model_providers.fcc.name", "Free Claude Code"),
233
- "-c",
234
- _toml_assignment("model_providers.fcc.base_url", _ensure_v1_url(api_url)),
235
- "-c",
236
- _toml_assignment("model_providers.fcc.env_key", _CODEX_AUTH_ENV_KEY),
237
- "-c",
238
- _toml_assignment("model_providers.fcc.wire_api", "responses"),
239
- ]
240
- if model:
241
- args.extend(["-c", _toml_assignment("model", model)])
242
- return args
243
-
244
-
245
- def _codex_event_to_parser_events(
246
- event: dict[str, Any], state: CliParseState
247
- ) -> Iterable[dict[str, Any]]:
248
- event_type = event.get("type")
249
- if event_type in {"error", "turn.failed"}:
250
- yield {"type": "error", "error": {"message": _event_message(event)}}
251
- return
252
- if event_type == "response.failed":
253
- _finish_response_scope(state)
254
- yield {"type": "error", "error": {"message": _event_message(event)}}
255
- return
256
- if event_type == "response.output_text.delta":
257
- _mark_streamed_message_item_seen(event, state)
258
- yield {
259
- "type": "content_block_delta",
260
- "index": 0,
261
- "delta": {"type": "text_delta", "text": str(event.get("delta", ""))},
262
- }
263
- return
264
- if event_type == "response.reasoning_text.delta":
265
- yield {
266
- "type": "content_block_delta",
267
- "index": _event_output_index(event),
268
- "delta": {
269
- "type": "thinking_delta",
270
- "thinking": str(event.get("delta", "")),
271
- },
272
- }
273
- return
274
- if event_type in {"agent_message", "assistant_message"}:
275
- text = _event_message(event)
276
- if text:
277
- yield {
278
- "type": "assistant",
279
- "message": {"content": [{"type": "text", "text": text}]},
280
- }
281
- return
282
- if event_type == "response.output_item.done":
283
- item = event.get("item")
284
- if isinstance(item, dict) and _mark_response_item_unseen(
285
- item,
286
- state,
287
- response_scope=_response_scope(event, state),
288
- output_index=_optional_event_output_index(event),
289
- ):
290
- yield from _responses_item_to_parser_events(item)
291
- return
292
- if event_type == "response.completed":
293
- response = event.get("response")
294
- output = response.get("output") if isinstance(response, dict) else None
295
- response_scope = _response_scope(event, state)
296
- if isinstance(output, list):
297
- for output_index, item in enumerate(output):
298
- if not isinstance(item, dict):
299
- continue
300
- item_mapping = cast(Mapping[str, Any], item)
301
- if _mark_response_item_unseen(
302
- item_mapping,
303
- state,
304
- response_scope=response_scope,
305
- output_index=output_index,
306
- ):
307
- yield from _responses_item_to_parser_events(item_mapping)
308
- _finish_response_scope(state)
309
- return
310
-
311
-
312
- def _responses_item_to_parser_events(
313
- item: Mapping[str, Any],
314
- ) -> Iterable[dict[str, Any]]:
315
- item_type = item.get("type")
316
- if item_type == "message":
317
- text_parts: list[str] = []
318
- content = item.get("content")
319
- if isinstance(content, list):
320
- text_parts.extend(
321
- str(part.get("text", ""))
322
- for part in content
323
- if isinstance(part, dict) and part.get("type") == "output_text"
324
- )
325
- text = "".join(text_parts)
326
- if text:
327
- yield {
328
- "type": "assistant",
329
- "message": {"content": [{"type": "text", "text": text}]},
330
- }
331
- return
332
- if item_type == "function_call":
333
- yield {
334
- "type": "assistant",
335
- "message": {
336
- "content": [
337
- {
338
- "type": "tool_use",
339
- "id": str(item.get("call_id", item.get("id", ""))),
340
- "name": str(item.get("name", "")),
341
- "input": _safe_json_object(item.get("arguments")),
342
- }
343
- ]
344
- },
345
- }
346
- return
347
- if item_type == "custom_tool_call":
348
- yield {
349
- "type": "assistant",
350
- "message": {
351
- "content": [
352
- {
353
- "type": "tool_use",
354
- "id": str(item.get("call_id", item.get("id", ""))),
355
- "name": str(item.get("name", "")),
356
- "input": {"input": _custom_tool_input_text(item.get("input"))},
357
- }
358
- ]
359
- },
360
- }
361
-
362
-
363
- def _event_message(event: Mapping[str, Any]) -> str:
364
- for key in ("message", "text", "content", "error"):
365
- value = event.get(key)
366
- if isinstance(value, str):
367
- return value
368
- if isinstance(value, dict) and isinstance(value.get("message"), str):
369
- return str(value["message"])
370
- response = event.get("response")
371
- if isinstance(response, dict):
372
- error = response.get("error")
373
- if isinstance(error, dict) and isinstance(error.get("message"), str):
374
- return str(error["message"])
375
- return ""
376
-
377
-
378
- def _event_output_index(event: Mapping[str, Any]) -> int:
379
- value = event.get("output_index")
380
- return value if isinstance(value, int) else 0
381
-
382
-
383
- def _optional_event_output_index(event: Mapping[str, Any]) -> int | None:
384
- value = event.get("output_index")
385
- return value if isinstance(value, int) else None
386
-
387
-
388
- def _mark_response_item_unseen(
389
- item: Mapping[str, Any],
390
- state: CliParseState,
391
- *,
392
- response_scope: str | None = None,
393
- output_index: int | None = None,
394
- ) -> bool:
395
- item_key = _response_item_key(
396
- item,
397
- response_scope=response_scope,
398
- output_index=output_index,
399
- )
400
- if item_key is None:
401
- return True
402
- if item_key in state.responses_seen_output_keys:
403
- return False
404
- state.responses_seen_output_keys.add(item_key)
405
- return True
406
-
407
-
408
- def _mark_streamed_message_item_seen(
409
- event: Mapping[str, Any], state: CliParseState
410
- ) -> None:
411
- item_id = event.get("item_id")
412
- if isinstance(item_id, str) and item_id:
413
- state.responses_seen_output_keys.add(f"message:{item_id}")
414
- output_index = _optional_event_output_index(event)
415
- if output_index is not None:
416
- state.responses_seen_output_keys.add(
417
- _response_output_index_key(
418
- "message",
419
- _response_scope(event, state),
420
- output_index,
421
- )
422
- )
423
-
424
-
425
- def _response_item_key(
426
- item: Mapping[str, Any],
427
- *,
428
- response_scope: str | None = None,
429
- output_index: int | None = None,
430
- ) -> str | None:
431
- item_type = item.get("type")
432
- if (
433
- item_type == "message"
434
- and response_scope is not None
435
- and output_index is not None
436
- ):
437
- return _response_output_index_key(item_type, response_scope, output_index)
438
- for key in ("id", "call_id"):
439
- value = item.get(key)
440
- if isinstance(value, str) and value:
441
- return f"{item_type}:{value}"
442
- return None
443
-
444
-
445
- def _response_scope(event: Mapping[str, Any], state: CliParseState) -> str:
446
- if state.responses_current_output_scope is not None:
447
- return state.responses_current_output_scope
448
- response_id = _event_response_id(event)
449
- if response_id is not None:
450
- scope = f"response:{response_id}"
451
- else:
452
- scope = f"implicit:{state.responses_next_implicit_output_scope}"
453
- state.responses_next_implicit_output_scope += 1
454
- state.responses_current_output_scope = scope
455
- return scope
456
-
457
-
458
- def _finish_response_scope(state: CliParseState) -> None:
459
- state.responses_current_output_scope = None
460
-
461
-
462
- def _event_response_id(event: Mapping[str, Any]) -> str | None:
463
- for key in ("response_id", "responseId"):
464
- value = event.get(key)
465
- if isinstance(value, str) and value:
466
- return value
467
- response = event.get("response")
468
- if isinstance(response, dict):
469
- value = response.get("id")
470
- if isinstance(value, str) and value:
471
- return value
472
- return None
473
-
474
-
475
- def _response_output_index_key(
476
- item_type: object, response_scope: str, output_index: int
477
- ) -> str:
478
- return f"{item_type}_output_index:{response_scope}:{output_index}"
479
-
480
-
481
- def _safe_json_object(value: Any) -> dict[str, Any]:
482
- if isinstance(value, dict):
483
- return value
484
- if not isinstance(value, str) or not value:
485
- return {}
486
- try:
487
- parsed = json.loads(value)
488
- except json.JSONDecodeError:
489
- return {}
490
- return parsed if isinstance(parsed, dict) else {}
491
-
492
-
493
- def _custom_tool_input_text(value: Any) -> str:
494
- if value is None:
495
- return ""
496
- if isinstance(value, str):
497
- return value
498
- try:
499
- return json.dumps(value)
500
- except TypeError:
501
- return str(value)
502
-
503
-
504
- def _base_codex_env(base_env: Mapping[str, str]) -> dict[str, str]:
505
- return {
506
- key: value
507
- for key, value in base_env.items()
508
- if key not in _STRIPPED_CODEX_ENV_KEYS and not key.startswith("OPENAI_")
509
- }
510
-
511
-
512
- def _ensure_v1_url(url: str) -> str:
513
- stripped = url.rstrip("/")
514
- return stripped if stripped.endswith("/v1") else f"{stripped}/v1"
515
-
516
-
517
- def _toml_assignment(key: str, value: str) -> str:
518
- return f"{key}={json.dumps(value)}"
519
-
520
-
521
- def _string_value(value: Any) -> str | None:
522
- return value if isinstance(value, str) else None
523
-
524
-
525
- CODEX_CLI_ADAPTER = CodexCliAdapter()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cli/adapters/registry.py DELETED
@@ -1,25 +0,0 @@
1
- """Internal client CLI adapter registry."""
2
-
3
- from __future__ import annotations
4
-
5
- from .base import ClientCliAdapter
6
- from .claude import CLAUDE_CLI_ADAPTER
7
- from .codex import CODEX_CLI_ADAPTER
8
-
9
- DEFAULT_CLIENT_CLI_ID = "claude"
10
-
11
- _ADAPTERS: dict[str, ClientCliAdapter] = {
12
- CLAUDE_CLI_ADAPTER.id: CLAUDE_CLI_ADAPTER,
13
- CODEX_CLI_ADAPTER.id: CODEX_CLI_ADAPTER,
14
- }
15
-
16
-
17
- def get_client_cli_adapter(
18
- client_cli_id: str = DEFAULT_CLIENT_CLI_ID,
19
- ) -> ClientCliAdapter:
20
- """Return a registered client CLI adapter by id."""
21
-
22
- try:
23
- return _ADAPTERS[client_cli_id]
24
- except KeyError as exc:
25
- raise ValueError(f"Unknown client CLI adapter: {client_cli_id}") from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cli/claude_env.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared Claude Code environment policy for FCC client surfaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000"
6
+ CLAUDE_NO_AUTH_SENTINEL = "fcc-no-auth"
7
+
8
+
9
+ def claude_auth_token(auth_token: str) -> str:
10
+ """Return the Claude Code auth marker for proxy-auth or no-auth sessions."""
11
+
12
+ return auth_token.strip() or CLAUDE_NO_AUTH_SENTINEL
cli/entrypoints.py CHANGED
@@ -2,46 +2,28 @@
2
 
3
  from __future__ import annotations
4
 
5
- import json
6
  import os
7
  import shutil
8
- import subprocess
9
- import sys
10
  import threading
11
  import time
12
  import webbrowser
13
- from collections.abc import Mapping, Sequence
14
  from pathlib import Path
15
- from urllib.error import HTTPError, URLError
16
- from urllib.request import Request, urlopen
17
 
18
  import uvicorn
19
 
20
  from api.admin_urls import local_admin_url, local_proxy_root_url
21
  from api.app import GracefulLifespanApp, create_app
22
- from cli.adapters.base import ClientCliAdapter
23
- from cli.adapters.claude import CLAUDE_CLI_ADAPTER
24
- from cli.adapters.codex import CODEX_CLI_ADAPTER
25
- from cli.codex_model_catalog import (
26
- build_codex_model_catalog,
27
- write_codex_model_catalog,
28
- )
29
  from cli.process_registry import (
30
  kill_all_best_effort,
31
- kill_pid_tree_best_effort,
32
- register_pid,
33
- unregister_pid,
34
  )
35
  from config.paths import (
36
- codex_model_catalog_path,
37
  config_dir_path,
38
  legacy_env_paths,
39
  managed_env_path,
40
  )
41
  from config.settings import Settings, get_settings
42
 
43
- PROXY_PREFLIGHT_PATH = "/health"
44
- PROXY_PREFLIGHT_TIMEOUT_SECONDS = 1.5
45
  SERVER_GRACEFUL_SHUTDOWN_SECONDS = 5
46
 
47
 
@@ -99,7 +81,7 @@ def _schedule_open_admin_browser(settings: Settings) -> None:
99
  def open_when_ready() -> None:
100
  deadline = time.monotonic() + 30.0
101
  while time.monotonic() < deadline:
102
- if _preflight_proxy(proxy_root_url) is None:
103
  webbrowser.open(admin_url)
104
  return
105
  time.sleep(0.15)
@@ -180,161 +162,3 @@ def _migrate_legacy_env_if_missing() -> Path | None:
180
  return legacy_env
181
 
182
  return None
183
-
184
-
185
- def _claude_child_env(
186
- settings: Settings, base_env: Mapping[str, str]
187
- ) -> dict[str, str]:
188
- """Return a Claude Code environment that targets this proxy."""
189
-
190
- return CLAUDE_CLI_ADAPTER.build_launcher_env(
191
- proxy_root_url=local_proxy_root_url(settings),
192
- auth_token=settings.anthropic_auth_token,
193
- base_env=base_env,
194
- )
195
-
196
-
197
- def _preflight_proxy(proxy_root_url: str) -> str | None:
198
- """Return an error message when the local proxy health check is unreachable."""
199
-
200
- url = f"{proxy_root_url.rstrip('/')}{PROXY_PREFLIGHT_PATH}"
201
- request = Request(url, method="GET")
202
- try:
203
- with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response:
204
- status_code = response.getcode()
205
- except HTTPError as exc:
206
- return f"returned HTTP {exc.code}"
207
- except URLError as exc:
208
- return str(exc.reason)
209
- except OSError as exc:
210
- return str(exc)
211
-
212
- if not 200 <= status_code < 300:
213
- return f"returned HTTP {status_code}"
214
- return None
215
-
216
-
217
- def launch_claude(argv: Sequence[str] | None = None) -> None:
218
- """Launch Claude Code with Free Claude Code proxy environment variables."""
219
-
220
- _launch_client_cli(CLAUDE_CLI_ADAPTER, argv)
221
-
222
-
223
- def launch_codex(argv: Sequence[str] | None = None) -> None:
224
- """Launch Codex CLI with Free Claude Code proxy configuration."""
225
-
226
- _launch_client_cli(CODEX_CLI_ADAPTER, argv)
227
-
228
-
229
- def _launch_client_cli(
230
- adapter: ClientCliAdapter, argv: Sequence[str] | None = None
231
- ) -> None:
232
- """Launch a client CLI with Free Claude Code proxy environment variables."""
233
-
234
- settings = get_settings()
235
- proxy_root_url = local_proxy_root_url(settings)
236
- if error := _preflight_proxy(proxy_root_url):
237
- print(
238
- f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}",
239
- file=sys.stderr,
240
- )
241
- print("Start it in another terminal with: fcc-server", file=sys.stderr)
242
- raise SystemExit(1)
243
-
244
- args = list(sys.argv[1:] if argv is None else argv)
245
- binary_name = adapter.get_launcher_binary_name(settings)
246
- client_command = shutil.which(binary_name)
247
- if client_command is None:
248
- print(
249
- f"Could not find {adapter.display_name} command: {binary_name}",
250
- file=sys.stderr,
251
- )
252
- print(adapter.install_hint, file=sys.stderr)
253
- raise SystemExit(127)
254
-
255
- command = adapter.build_launcher_command(
256
- binary_path=client_command,
257
- argv=args,
258
- settings=settings,
259
- proxy_root_url=proxy_root_url,
260
- )
261
- catalog_args = _codex_model_catalog_config_args(adapter, proxy_root_url, settings)
262
- if catalog_args:
263
- command = [command[0], *catalog_args, *command[1:]]
264
- env = adapter.build_launcher_env(
265
- proxy_root_url=proxy_root_url,
266
- auth_token=settings.anthropic_auth_token,
267
- base_env=os.environ,
268
- )
269
- process: subprocess.Popen[bytes] | None = None
270
- try:
271
- process = subprocess.Popen(command, env=env)
272
- if process.pid:
273
- register_pid(process.pid)
274
- return_code = process.wait()
275
- except FileNotFoundError:
276
- print(
277
- f"Could not find {adapter.display_name} command: {binary_name}",
278
- file=sys.stderr,
279
- )
280
- print(adapter.install_hint, file=sys.stderr)
281
- raise SystemExit(127) from None
282
- except KeyboardInterrupt:
283
- if process is not None and process.pid:
284
- kill_pid_tree_best_effort(process.pid)
285
- process.wait()
286
- raise
287
- finally:
288
- if process is not None and process.pid:
289
- unregister_pid(process.pid)
290
-
291
- raise SystemExit(return_code)
292
-
293
-
294
- def _codex_model_catalog_config_args(
295
- adapter: ClientCliAdapter, proxy_root_url: str, settings: Settings
296
- ) -> list[str]:
297
- if adapter.id != CODEX_CLI_ADAPTER.id:
298
- return []
299
-
300
- try:
301
- models_response = _fetch_proxy_models_response(
302
- proxy_root_url, settings.anthropic_auth_token
303
- )
304
- catalog = build_codex_model_catalog(models_response)
305
- models = catalog.get("models")
306
- if not isinstance(models, list) or not models:
307
- print(
308
- "Free Claude Code warning: Codex model catalog is empty; "
309
- "launching without model picker catalog.",
310
- file=sys.stderr,
311
- )
312
- return []
313
- catalog_path = codex_model_catalog_path()
314
- write_codex_model_catalog(catalog_path, catalog)
315
- except Exception as exc:
316
- print(
317
- "Free Claude Code warning: could not prepare Codex model catalog "
318
- f"({exc}); launching without model picker catalog.",
319
- file=sys.stderr,
320
- )
321
- return []
322
-
323
- return CODEX_CLI_ADAPTER.build_model_catalog_config_args(str(catalog_path))
324
-
325
-
326
- def _fetch_proxy_models_response(
327
- proxy_root_url: str, auth_token: str
328
- ) -> dict[str, object]:
329
- url = f"{proxy_root_url.rstrip('/')}/v1/models"
330
- headers: dict[str, str] = {}
331
- if token := auth_token.strip():
332
- headers["X-API-Key"] = token
333
-
334
- request = Request(url, headers=headers, method="GET")
335
- with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response:
336
- payload = json.loads(response.read().decode("utf-8"))
337
-
338
- if not isinstance(payload, dict):
339
- raise ValueError("model list response was not a JSON object")
340
- return payload
 
2
 
3
  from __future__ import annotations
4
 
 
5
  import os
6
  import shutil
 
 
7
  import threading
8
  import time
9
  import webbrowser
 
10
  from pathlib import Path
 
 
11
 
12
  import uvicorn
13
 
14
  from api.admin_urls import local_admin_url, local_proxy_root_url
15
  from api.app import GracefulLifespanApp, create_app
16
+ from cli.launchers.common import preflight_proxy
 
 
 
 
 
 
17
  from cli.process_registry import (
18
  kill_all_best_effort,
 
 
 
19
  )
20
  from config.paths import (
 
21
  config_dir_path,
22
  legacy_env_paths,
23
  managed_env_path,
24
  )
25
  from config.settings import Settings, get_settings
26
 
 
 
27
  SERVER_GRACEFUL_SHUTDOWN_SECONDS = 5
28
 
29
 
 
81
  def open_when_ready() -> None:
82
  deadline = time.monotonic() + 30.0
83
  while time.monotonic() < deadline:
84
+ if preflight_proxy(proxy_root_url) is None:
85
  webbrowser.open(admin_url)
86
  return
87
  time.sleep(0.15)
 
162
  return legacy_env
163
 
164
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cli/launchers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Installed FCC client CLI launchers."""
cli/launchers/claude.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Installed `fcc-claude` launcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from collections.abc import Mapping, Sequence
8
+
9
+ from api.admin_urls import local_proxy_root_url
10
+ from cli.claude_env import CLAUDE_CODE_AUTO_COMPACT_WINDOW, claude_auth_token
11
+ from config.settings import Settings, get_settings
12
+
13
+ from .common import preflight_proxy, resolve_client_binary, run_client_process
14
+
15
+ _DISPLAY_NAME = "Claude Code"
16
+ _DEFAULT_BINARY = "claude"
17
+ _INSTALL_HINT = "Install Claude Code with: npm install -g @anthropic-ai/claude-code"
18
+
19
+
20
+ def launch(argv: Sequence[str] | None = None) -> None:
21
+ """Launch Claude Code with Free Claude Code proxy environment variables."""
22
+
23
+ settings = get_settings()
24
+ proxy_root_url = local_proxy_root_url(settings)
25
+ if error := preflight_proxy(proxy_root_url):
26
+ print(
27
+ f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}",
28
+ file=sys.stderr,
29
+ )
30
+ print("Start it in another terminal with: fcc-server", file=sys.stderr)
31
+ raise SystemExit(1)
32
+
33
+ binary_name = claude_binary_name(settings)
34
+ binary_path = resolve_client_binary(
35
+ binary_name=binary_name,
36
+ display_name=_DISPLAY_NAME,
37
+ install_hint=_INSTALL_HINT,
38
+ )
39
+ args = list(sys.argv[1:] if argv is None else argv)
40
+ run_client_process(
41
+ command=build_claude_launcher_command(binary_path=binary_path, argv=args),
42
+ env=build_claude_launcher_env(
43
+ proxy_root_url=proxy_root_url,
44
+ auth_token=settings.anthropic_auth_token,
45
+ base_env=os.environ,
46
+ ),
47
+ binary_name=binary_name,
48
+ display_name=_DISPLAY_NAME,
49
+ install_hint=_INSTALL_HINT,
50
+ )
51
+
52
+
53
+ def claude_binary_name(settings: Settings) -> str:
54
+ """Return the configured Claude Code binary name."""
55
+
56
+ return settings.claude_cli_bin or _DEFAULT_BINARY
57
+
58
+
59
+ def build_claude_launcher_command(
60
+ *, binary_path: str, argv: Sequence[str]
61
+ ) -> list[str]:
62
+ """Return the Claude wrapper command without changing user arguments."""
63
+
64
+ return [binary_path, *argv]
65
+
66
+
67
+ def build_claude_launcher_env(
68
+ *,
69
+ proxy_root_url: str,
70
+ auth_token: str,
71
+ base_env: Mapping[str, str],
72
+ ) -> dict[str, str]:
73
+ """Return a Claude Code environment that targets the local proxy."""
74
+
75
+ env = {
76
+ key: value
77
+ for key, value in base_env.items()
78
+ if not key.startswith("ANTHROPIC_")
79
+ }
80
+ env["ANTHROPIC_BASE_URL"] = proxy_root_url
81
+ env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
82
+ env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = CLAUDE_CODE_AUTO_COMPACT_WINDOW
83
+ env["ANTHROPIC_AUTH_TOKEN"] = claude_auth_token(auth_token)
84
+ return env
cli/launchers/codex.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Installed `fcc-codex` launcher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import sys
8
+ from collections.abc import Mapping, Sequence
9
+ from urllib.request import Request, urlopen
10
+
11
+ from api.admin_urls import local_proxy_root_url
12
+ from config.paths import codex_model_catalog_path
13
+ from config.settings import Settings, get_settings
14
+
15
+ from .codex_model_catalog import build_codex_model_catalog, write_codex_model_catalog
16
+ from .common import (
17
+ PROXY_PREFLIGHT_TIMEOUT_SECONDS,
18
+ preflight_proxy,
19
+ resolve_client_binary,
20
+ run_client_process,
21
+ )
22
+
23
+ _CODEX_AUTH_ENV_KEY = "FCC_CODEX_API_KEY"
24
+ _DISPLAY_NAME = "Codex CLI"
25
+ _DEFAULT_BINARY = "codex"
26
+ _INSTALL_HINT = "Install Codex with: npm install -g @openai/codex"
27
+ _STRIPPED_CODEX_ENV_KEYS = frozenset(
28
+ {
29
+ "OPENAI_API_KEY",
30
+ "OPENAI_BASE_URL",
31
+ "OPENAI_API_BASE",
32
+ "OPENAI_ORG_ID",
33
+ "OPENAI_ORGANIZATION",
34
+ "CODEX_API_KEY",
35
+ _CODEX_AUTH_ENV_KEY,
36
+ }
37
+ )
38
+
39
+
40
+ def launch(argv: Sequence[str] | None = None) -> None:
41
+ """Launch Codex CLI with Free Claude Code proxy configuration."""
42
+
43
+ settings = get_settings()
44
+ proxy_root_url = local_proxy_root_url(settings)
45
+ if error := preflight_proxy(proxy_root_url):
46
+ print(
47
+ f"Free Claude Code proxy is not reachable at {proxy_root_url}: {error}",
48
+ file=sys.stderr,
49
+ )
50
+ print("Start it in another terminal with: fcc-server", file=sys.stderr)
51
+ raise SystemExit(1)
52
+
53
+ binary_name = codex_binary_name(settings)
54
+ binary_path = resolve_client_binary(
55
+ binary_name=binary_name,
56
+ display_name=_DISPLAY_NAME,
57
+ install_hint=_INSTALL_HINT,
58
+ )
59
+ catalog_args = codex_model_catalog_config_args(proxy_root_url, settings)
60
+ args = list(sys.argv[1:] if argv is None else argv)
61
+ run_client_process(
62
+ command=build_codex_launcher_command(
63
+ binary_path=binary_path,
64
+ argv=args,
65
+ settings=settings,
66
+ proxy_root_url=proxy_root_url,
67
+ catalog_config_args=catalog_args,
68
+ ),
69
+ env=build_codex_launcher_env(
70
+ auth_token=settings.anthropic_auth_token,
71
+ base_env=os.environ,
72
+ ),
73
+ binary_name=binary_name,
74
+ display_name=_DISPLAY_NAME,
75
+ install_hint=_INSTALL_HINT,
76
+ )
77
+
78
+
79
+ def codex_binary_name(settings: Settings) -> str:
80
+ """Return the configured Codex binary name."""
81
+
82
+ return settings.codex_cli_bin or _DEFAULT_BINARY
83
+
84
+
85
+ def build_codex_launcher_command(
86
+ *,
87
+ binary_path: str,
88
+ argv: Sequence[str],
89
+ settings: Settings,
90
+ proxy_root_url: str,
91
+ catalog_config_args: Sequence[str] = (),
92
+ ) -> list[str]:
93
+ """Return a Codex command with ephemeral FCC provider config."""
94
+
95
+ return [
96
+ binary_path,
97
+ *catalog_config_args,
98
+ *codex_config_args(
99
+ api_url=_ensure_v1_url(proxy_root_url),
100
+ model=getattr(settings, "model", None),
101
+ ),
102
+ *argv,
103
+ ]
104
+
105
+
106
+ def build_codex_launcher_env(
107
+ *,
108
+ auth_token: str,
109
+ base_env: Mapping[str, str],
110
+ ) -> dict[str, str]:
111
+ """Return a Codex environment that targets the local proxy provider."""
112
+
113
+ env = {
114
+ key: value
115
+ for key, value in base_env.items()
116
+ if key not in _STRIPPED_CODEX_ENV_KEYS and not key.startswith("OPENAI_")
117
+ }
118
+ env[_CODEX_AUTH_ENV_KEY] = auth_token.strip() or "fcc-no-auth"
119
+ return env
120
+
121
+
122
+ def codex_model_catalog_config_args(
123
+ proxy_root_url: str, settings: Settings
124
+ ) -> list[str]:
125
+ """Prepare the generated Codex model catalog and return its config args."""
126
+
127
+ try:
128
+ models_response = fetch_proxy_models_response(
129
+ proxy_root_url, settings.anthropic_auth_token
130
+ )
131
+ catalog = build_codex_model_catalog(models_response)
132
+ models = catalog.get("models")
133
+ if not isinstance(models, list) or not models:
134
+ print(
135
+ "Free Claude Code warning: Codex model catalog is empty; "
136
+ "launching without model picker catalog.",
137
+ file=sys.stderr,
138
+ )
139
+ return []
140
+ catalog_path = codex_model_catalog_path()
141
+ write_codex_model_catalog(catalog_path, catalog)
142
+ except Exception as exc:
143
+ print(
144
+ "Free Claude Code warning: could not prepare Codex model catalog "
145
+ f"({exc}); launching without model picker catalog.",
146
+ file=sys.stderr,
147
+ )
148
+ return []
149
+
150
+ return build_model_catalog_config_args(str(catalog_path))
151
+
152
+
153
+ def fetch_proxy_models_response(
154
+ proxy_root_url: str, auth_token: str
155
+ ) -> dict[str, object]:
156
+ """Fetch the local proxy `/v1/models` response for Codex catalog generation."""
157
+
158
+ url = f"{proxy_root_url.rstrip('/')}/v1/models"
159
+ headers: dict[str, str] = {}
160
+ if token := auth_token.strip():
161
+ headers["X-API-Key"] = token
162
+
163
+ request = Request(url, headers=headers, method="GET")
164
+ with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response:
165
+ payload = json.loads(response.read().decode("utf-8"))
166
+
167
+ if not isinstance(payload, dict):
168
+ raise ValueError("model list response was not a JSON object")
169
+ return payload
170
+
171
+
172
+ def build_model_catalog_config_args(catalog_path: str) -> list[str]:
173
+ """Return Codex config args for a generated model catalog."""
174
+
175
+ return ["-c", _toml_assignment("model_catalog_json", catalog_path)]
176
+
177
+
178
+ def codex_config_args(*, api_url: str, model: str | None = None) -> list[str]:
179
+ """Return Codex `-c` assignments for the ephemeral FCC provider."""
180
+
181
+ args = [
182
+ "-c",
183
+ _toml_assignment("model_provider", "fcc"),
184
+ "-c",
185
+ _toml_assignment("model_providers.fcc.name", "Free Claude Code"),
186
+ "-c",
187
+ _toml_assignment("model_providers.fcc.base_url", _ensure_v1_url(api_url)),
188
+ "-c",
189
+ _toml_assignment("model_providers.fcc.env_key", _CODEX_AUTH_ENV_KEY),
190
+ "-c",
191
+ _toml_assignment("model_providers.fcc.wire_api", "responses"),
192
+ ]
193
+ if model:
194
+ args.extend(["-c", _toml_assignment("model", model)])
195
+ return args
196
+
197
+
198
+ def _ensure_v1_url(url: str) -> str:
199
+ stripped = url.rstrip("/")
200
+ return stripped if stripped.endswith("/v1") else f"{stripped}/v1"
201
+
202
+
203
+ def _toml_assignment(key: str, value: str) -> str:
204
+ return f"{key}={json.dumps(value)}"
cli/{codex_model_catalog.py → launchers/codex_model_catalog.py} RENAMED
File without changes
cli/launchers/common.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared process helpers for installed client CLI launchers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from collections.abc import Mapping
9
+ from urllib.error import HTTPError, URLError
10
+ from urllib.request import Request, urlopen
11
+
12
+ from cli.process_registry import (
13
+ kill_pid_tree_best_effort,
14
+ register_pid,
15
+ unregister_pid,
16
+ )
17
+
18
+ PROXY_PREFLIGHT_PATH = "/health"
19
+ PROXY_PREFLIGHT_TIMEOUT_SECONDS = 1.5
20
+
21
+
22
+ def preflight_proxy(proxy_root_url: str) -> str | None:
23
+ """Return an error message when the local proxy health check is unreachable."""
24
+
25
+ url = f"{proxy_root_url.rstrip('/')}{PROXY_PREFLIGHT_PATH}"
26
+ request = Request(url, method="GET")
27
+ try:
28
+ with urlopen(request, timeout=PROXY_PREFLIGHT_TIMEOUT_SECONDS) as response:
29
+ status_code = response.getcode()
30
+ except HTTPError as exc:
31
+ return f"returned HTTP {exc.code}"
32
+ except URLError as exc:
33
+ return str(exc.reason)
34
+ except OSError as exc:
35
+ return str(exc)
36
+
37
+ if not 200 <= status_code < 300:
38
+ return f"returned HTTP {status_code}"
39
+ return None
40
+
41
+
42
+ def resolve_client_binary(
43
+ *,
44
+ binary_name: str,
45
+ display_name: str,
46
+ install_hint: str,
47
+ ) -> str:
48
+ """Resolve an installed client binary or exit with a user-facing hint."""
49
+
50
+ client_command = shutil.which(binary_name)
51
+ if client_command is None:
52
+ print(
53
+ f"Could not find {display_name} command: {binary_name}",
54
+ file=sys.stderr,
55
+ )
56
+ print(install_hint, file=sys.stderr)
57
+ raise SystemExit(127)
58
+ return client_command
59
+
60
+
61
+ def run_client_process(
62
+ *,
63
+ command: list[str],
64
+ env: Mapping[str, str],
65
+ binary_name: str,
66
+ display_name: str,
67
+ install_hint: str,
68
+ ) -> None:
69
+ """Run a client CLI command and mirror its exit code."""
70
+
71
+ process: subprocess.Popen[bytes] | None = None
72
+ try:
73
+ process = subprocess.Popen(command, env=dict(env))
74
+ if process.pid:
75
+ register_pid(process.pid)
76
+ return_code = process.wait()
77
+ except FileNotFoundError:
78
+ print(
79
+ f"Could not find {display_name} command: {binary_name}",
80
+ file=sys.stderr,
81
+ )
82
+ print(install_hint, file=sys.stderr)
83
+ raise SystemExit(127) from None
84
+ except KeyboardInterrupt:
85
+ if process is not None and process.pid:
86
+ kill_pid_tree_best_effort(process.pid)
87
+ process.wait()
88
+ raise
89
+ finally:
90
+ if process is not None and process.pid:
91
+ unregister_pid(process.pid)
92
+
93
+ raise SystemExit(return_code)
cli/managed/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """Managed Claude Code sessions used by messaging."""
2
+
3
+ from .manager import ManagedClaudeSessionManager
4
+ from .session import ManagedClaudeSession
5
+
6
+ __all__ = ["ManagedClaudeSession", "ManagedClaudeSessionManager"]
cli/managed/claude.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Managed Claude Code task command, environment, and stdout parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable, Mapping
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from loguru import logger
11
+
12
+ from cli.claude_env import CLAUDE_CODE_AUTO_COMPACT_WINDOW, claude_auth_token
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class ManagedClaudeTaskRequest:
17
+ """One prompt execution request for a managed Claude Code subprocess."""
18
+
19
+ prompt: str
20
+ session_id: str | None = None
21
+ fork_session: bool = False
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ManagedClaudeInvocation:
26
+ """Concrete subprocess invocation assembled for a managed Claude task."""
27
+
28
+ argv: tuple[str, ...]
29
+ env: dict[str, str]
30
+ cwd: str
31
+ trace_metadata: dict[str, Any] = field(default_factory=dict)
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ManagedClaudeConfig:
36
+ """Configuration for a managed Claude Code subprocess."""
37
+
38
+ workspace_path: str
39
+ api_url: str
40
+ allowed_dirs: list[str] = field(default_factory=list)
41
+ plans_directory: str | None = None
42
+ claude_bin: str = "claude"
43
+ auth_token: str = ""
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class ManagedClaudeParseState:
48
+ """Mutable stdout parser state for one managed Claude Code task run."""
49
+
50
+ log_raw_cli_diagnostics: bool = False
51
+ session_id_extracted: bool = False
52
+
53
+
54
+ def build_managed_claude_invocation(
55
+ *,
56
+ config: ManagedClaudeConfig,
57
+ request: ManagedClaudeTaskRequest,
58
+ base_env: Mapping[str, str],
59
+ ) -> ManagedClaudeInvocation:
60
+ """Build a Claude Code stream-json subprocess invocation."""
61
+
62
+ cmd = build_managed_claude_command(
63
+ claude_bin=config.claude_bin,
64
+ prompt=request.prompt,
65
+ session_id=request.session_id,
66
+ fork_session=request.fork_session,
67
+ allowed_dirs=config.allowed_dirs,
68
+ plans_directory=config.plans_directory,
69
+ )
70
+ resume_session_id = (
71
+ request.session_id
72
+ if request.session_id and not request.session_id.startswith("pending_")
73
+ else None
74
+ )
75
+ return ManagedClaudeInvocation(
76
+ argv=tuple(cmd),
77
+ env=build_managed_claude_env(
78
+ api_url=config.api_url,
79
+ auth_token=config.auth_token,
80
+ base_env=base_env,
81
+ ),
82
+ cwd=config.workspace_path,
83
+ trace_metadata={
84
+ "client_cli_id": "claude",
85
+ "resume_session_id": resume_session_id,
86
+ "fork_session": request.fork_session,
87
+ "prompt": request.prompt,
88
+ "cwd": config.workspace_path,
89
+ "claude_binary": config.claude_bin,
90
+ "cli_argv": cmd,
91
+ },
92
+ )
93
+
94
+
95
+ def build_managed_claude_env(
96
+ *,
97
+ api_url: str,
98
+ auth_token: str,
99
+ base_env: Mapping[str, str],
100
+ ) -> dict[str, str]:
101
+ """Return a Claude Code task environment that targets the local proxy."""
102
+
103
+ env = dict(base_env)
104
+ env["ANTHROPIC_API_URL"] = api_url
105
+ env["ANTHROPIC_BASE_URL"] = api_url[:-3] if api_url.endswith("/v1") else api_url
106
+ env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] = "1"
107
+ env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] = CLAUDE_CODE_AUTO_COMPACT_WINDOW
108
+ env.pop("ANTHROPIC_API_KEY", None)
109
+ env["ANTHROPIC_AUTH_TOKEN"] = claude_auth_token(auth_token)
110
+ env["TERM"] = "dumb"
111
+ env["PYTHONIOENCODING"] = "utf-8"
112
+ return env
113
+
114
+
115
+ def build_managed_claude_command(
116
+ *,
117
+ claude_bin: str,
118
+ prompt: str,
119
+ session_id: str | None,
120
+ fork_session: bool,
121
+ allowed_dirs: list[str],
122
+ plans_directory: str | None,
123
+ ) -> list[str]:
124
+ """Return the Claude Code stream-json command for a managed task."""
125
+
126
+ if session_id and not session_id.startswith("pending_"):
127
+ cmd = [
128
+ claude_bin,
129
+ "--resume",
130
+ session_id,
131
+ ]
132
+ if fork_session:
133
+ cmd.append("--fork-session")
134
+ cmd += [
135
+ "-p",
136
+ prompt,
137
+ "--output-format",
138
+ "stream-json",
139
+ "--dangerously-skip-permissions",
140
+ "--verbose",
141
+ ]
142
+ else:
143
+ cmd = [
144
+ claude_bin,
145
+ "-p",
146
+ prompt,
147
+ "--output-format",
148
+ "stream-json",
149
+ "--dangerously-skip-permissions",
150
+ "--verbose",
151
+ ]
152
+
153
+ for directory in allowed_dirs:
154
+ cmd.extend(["--add-dir", directory])
155
+
156
+ if plans_directory is not None:
157
+ cmd.extend(["--settings", json.dumps({"plansDirectory": plans_directory})])
158
+
159
+ return cmd
160
+
161
+
162
+ def parse_managed_claude_stdout_line(
163
+ line: str, state: ManagedClaudeParseState
164
+ ) -> Iterable[Any]:
165
+ """Parse one Claude Code stream-json stdout line."""
166
+
167
+ try:
168
+ event = json.loads(line)
169
+ except json.JSONDecodeError:
170
+ if state.log_raw_cli_diagnostics:
171
+ logger.debug("Non-JSON output: {}", line)
172
+ else:
173
+ logger.debug("Non-JSON CLI line: char_len={}", len(line))
174
+ yield {"type": "raw", "content": line}
175
+ return
176
+
177
+ if not state.session_id_extracted:
178
+ extracted_id = extract_managed_claude_session_id(event)
179
+ if extracted_id:
180
+ state.session_id_extracted = True
181
+ logger.info("Extracted session ID: {}", extracted_id)
182
+ yield {"type": "session_info", "session_id": extracted_id}
183
+
184
+ yield event
185
+
186
+
187
+ def extract_managed_claude_session_id(event: Any) -> str | None:
188
+ """Extract a Claude Code session ID from supported stream-json event shapes."""
189
+
190
+ if not isinstance(event, dict):
191
+ return None
192
+
193
+ if session_id := _string_value(event.get("session_id")):
194
+ return session_id
195
+ if session_id := _string_value(event.get("sessionId")):
196
+ return session_id
197
+
198
+ for key in ("init", "system", "result", "metadata"):
199
+ nested = event.get(key)
200
+ if not isinstance(nested, dict):
201
+ continue
202
+ if session_id := _string_value(nested.get("session_id")):
203
+ return session_id
204
+ if session_id := _string_value(nested.get("sessionId")):
205
+ return session_id
206
+
207
+ conversation = event.get("conversation")
208
+ if isinstance(conversation, dict):
209
+ return _string_value(conversation.get("id"))
210
+
211
+ return None
212
+
213
+
214
+ def _string_value(value: Any) -> str | None:
215
+ return value if isinstance(value, str) else None
cli/{manager.py → managed/manager.py} RENAMED
@@ -1,27 +1,19 @@
1
- """
2
- CLI Session Manager for Multi-Instance Claude CLI Support
3
-
4
- Manages a pool of CLISession instances, each handling one conversation.
5
- This enables true parallel processing where multiple conversations run
6
- simultaneously in separate CLI processes.
7
- """
8
 
9
  import asyncio
10
  import uuid
11
 
12
  from loguru import logger
13
 
14
- from .adapters.base import ClientCliAdapter
15
- from .adapters.registry import get_client_cli_adapter
16
- from .session import CLISession
17
 
18
 
19
- class CLISessionManager:
20
  """
21
- Manages multiple CLISession instances for parallel conversation processing.
22
 
23
- Each new conversation gets its own CLISession with its own subprocess.
24
- Replies to existing conversations reuse the same CLISession instance.
25
  """
26
 
27
  def __init__(
@@ -33,7 +25,6 @@ class CLISessionManager:
33
  claude_bin: str = "claude",
34
  auth_token: str = "",
35
  *,
36
- client_cli_adapter: ClientCliAdapter | None = None,
37
  log_raw_cli_diagnostics: bool = False,
38
  log_messaging_error_details: bool = False,
39
  ):
@@ -52,24 +43,23 @@ class CLISessionManager:
52
  self.plans_directory = plans_directory
53
  self.claude_bin = claude_bin
54
  self.auth_token = auth_token
55
- self._client_cli_adapter = client_cli_adapter or get_client_cli_adapter()
56
  self._log_raw_cli_diagnostics = log_raw_cli_diagnostics
57
  self._log_messaging_error_details = log_messaging_error_details
58
 
59
- self._sessions: dict[str, CLISession] = {}
60
- self._pending_sessions: dict[str, CLISession] = {}
61
  self._temp_to_real: dict[str, str] = {}
62
  self._real_to_temp: dict[str, str] = {}
63
  self._lock = asyncio.Lock()
64
 
65
  async def get_or_create_session(
66
  self, session_id: str | None = None
67
- ) -> tuple[CLISession, str, bool]:
68
  """
69
  Get an existing session or create a new one.
70
 
71
  Returns:
72
- Tuple of (CLISession instance, session_id, is_new_session)
73
  """
74
  async with self._lock:
75
  if session_id:
@@ -82,14 +72,13 @@ class CLISessionManager:
82
 
83
  temp_id = session_id if session_id else f"pending_{uuid.uuid4().hex[:8]}"
84
 
85
- new_session = CLISession(
86
  workspace_path=self.workspace,
87
  api_url=self.api_url,
88
  allowed_dirs=self.allowed_dirs,
89
  plans_directory=self.plans_directory,
90
  claude_bin=self.claude_bin,
91
  auth_token=self.auth_token,
92
- client_cli_adapter=self._client_cli_adapter,
93
  log_raw_cli_diagnostics=self._log_raw_cli_diagnostics,
94
  )
95
  self._pending_sessions[temp_id] = new_session
 
1
+ """Managed Claude Code session pool for messaging."""
 
 
 
 
 
 
2
 
3
  import asyncio
4
  import uuid
5
 
6
  from loguru import logger
7
 
8
+ from .session import ManagedClaudeSession
 
 
9
 
10
 
11
+ class ManagedClaudeSessionManager:
12
  """
13
+ Manages multiple Claude Code sessions for parallel conversation processing.
14
 
15
+ Each new conversation gets its own subprocess. Replies to existing
16
+ conversations reuse the same session instance.
17
  """
18
 
19
  def __init__(
 
25
  claude_bin: str = "claude",
26
  auth_token: str = "",
27
  *,
 
28
  log_raw_cli_diagnostics: bool = False,
29
  log_messaging_error_details: bool = False,
30
  ):
 
43
  self.plans_directory = plans_directory
44
  self.claude_bin = claude_bin
45
  self.auth_token = auth_token
 
46
  self._log_raw_cli_diagnostics = log_raw_cli_diagnostics
47
  self._log_messaging_error_details = log_messaging_error_details
48
 
49
+ self._sessions: dict[str, ManagedClaudeSession] = {}
50
+ self._pending_sessions: dict[str, ManagedClaudeSession] = {}
51
  self._temp_to_real: dict[str, str] = {}
52
  self._real_to_temp: dict[str, str] = {}
53
  self._lock = asyncio.Lock()
54
 
55
  async def get_or_create_session(
56
  self, session_id: str | None = None
57
+ ) -> tuple[ManagedClaudeSession, str, bool]:
58
  """
59
  Get an existing session or create a new one.
60
 
61
  Returns:
62
+ Tuple of (session instance, session_id, is_new_session)
63
  """
64
  async with self._lock:
65
  if session_id:
 
72
 
73
  temp_id = session_id if session_id else f"pending_{uuid.uuid4().hex[:8]}"
74
 
75
+ new_session = ManagedClaudeSession(
76
  workspace_path=self.workspace,
77
  api_url=self.api_url,
78
  allowed_dirs=self.allowed_dirs,
79
  plans_directory=self.plans_directory,
80
  claude_bin=self.claude_bin,
81
  auth_token=self.auth_token,
 
82
  log_raw_cli_diagnostics=self._log_raw_cli_diagnostics,
83
  )
84
  self._pending_sessions[temp_id] = new_session
cli/{session.py → managed/session.py} RENAMED
@@ -1,37 +1,28 @@
1
- """Claude Code CLI session management."""
2
 
3
  import asyncio
4
  import os
5
  from collections.abc import AsyncGenerator
6
- from dataclasses import dataclass, field
7
- from typing import Any
8
 
9
  from loguru import logger
10
 
 
11
  from core.trace import trace_event
12
 
13
- from .adapters.base import ClientCliAdapter, CliParseState, CliTaskRequest
14
- from .adapters.registry import get_client_cli_adapter
15
- from .process_registry import kill_pid_tree_best_effort, register_pid, unregister_pid
 
 
 
 
16
 
17
  # Cap stderr capture so a runaway child cannot exhaust memory; pipe is still drained.
18
  _MAX_STDERR_CAPTURE_BYTES = 256 * 1024
19
 
20
 
21
- @dataclass(frozen=True, slots=True)
22
- class ClaudeCliConfig:
23
- """Configuration for a managed Claude CLI subprocess."""
24
-
25
- workspace_path: str
26
- api_url: str
27
- allowed_dirs: list[str] = field(default_factory=list)
28
- plans_directory: str | None = None
29
- claude_bin: str = "claude"
30
- auth_token: str = ""
31
-
32
-
33
- class CLISession:
34
- """Manages a single persistent Claude Code CLI subprocess."""
35
 
36
  def __init__(
37
  self,
@@ -42,10 +33,9 @@ class CLISession:
42
  claude_bin: str = "claude",
43
  auth_token: str = "",
44
  *,
45
- client_cli_adapter: ClientCliAdapter | None = None,
46
  log_raw_cli_diagnostics: bool = False,
47
  ):
48
- self.config = ClaudeCliConfig(
49
  workspace_path=os.path.normpath(os.path.abspath(workspace_path)),
50
  api_url=api_url,
51
  allowed_dirs=[os.path.normpath(d) for d in (allowed_dirs or [])],
@@ -59,7 +49,6 @@ class CLISession:
59
  self.plans_directory = self.config.plans_directory
60
  self.claude_bin = self.config.claude_bin
61
  self.auth_token = self.config.auth_token
62
- self._client_cli_adapter = client_cli_adapter or get_client_cli_adapter()
63
  self._log_raw_cli_diagnostics = log_raw_cli_diagnostics
64
  self.process: asyncio.subprocess.Process | None = None
65
  self.current_session_id: str | None = None
@@ -114,9 +103,9 @@ class CLISession:
114
  """
115
  async with self._cli_lock:
116
  self._is_busy = True
117
- invocation = self._client_cli_adapter.build_task_invocation(
118
  config=self.config,
119
- request=CliTaskRequest(
120
  prompt=prompt,
121
  session_id=session_id,
122
  fork_session=fork_session,
@@ -125,9 +114,9 @@ class CLISession:
125
  )
126
 
127
  trace_event(
128
- stage=self._client_cli_adapter.trace_stage,
129
- event=self._client_cli_adapter.process_launch_event,
130
- source=self._client_cli_adapter.trace_source,
131
  **invocation.trace_metadata,
132
  )
133
 
@@ -146,7 +135,7 @@ class CLISession:
146
  yield {"type": "exit", "code": 1}
147
  return
148
 
149
- parse_state = CliParseState(
150
  log_raw_cli_diagnostics=self._log_raw_cli_diagnostics
151
  )
152
  buffer = bytearray()
@@ -231,20 +220,16 @@ class CLISession:
231
  unregister_pid(self.process.pid)
232
 
233
  async def _handle_line_gen(
234
- self, line_str: str, parse_state: CliParseState
235
  ) -> AsyncGenerator[dict]:
236
  """Process a single line and yield events."""
237
- for event in self._client_cli_adapter.parse_stdout_line(line_str, parse_state):
238
  if isinstance(event, dict) and event.get("type") == "session_info":
239
  session_id = event.get("session_id")
240
  if isinstance(session_id, str):
241
  self.current_session_id = session_id
242
  yield event
243
 
244
- def _extract_session_id(self, event: Any) -> str | None:
245
- """Extract session ID from CLI event."""
246
- return self._client_cli_adapter.extract_session_id(event)
247
-
248
  async def stop(self):
249
  """Stop the CLI process."""
250
  if self.process and self.process.returncode is None:
 
1
+ """Managed Claude Code subprocess session."""
2
 
3
  import asyncio
4
  import os
5
  from collections.abc import AsyncGenerator
 
 
6
 
7
  from loguru import logger
8
 
9
+ from cli.process_registry import kill_pid_tree_best_effort, register_pid, unregister_pid
10
  from core.trace import trace_event
11
 
12
+ from .claude import (
13
+ ManagedClaudeConfig,
14
+ ManagedClaudeParseState,
15
+ ManagedClaudeTaskRequest,
16
+ build_managed_claude_invocation,
17
+ parse_managed_claude_stdout_line,
18
+ )
19
 
20
  # Cap stderr capture so a runaway child cannot exhaust memory; pipe is still drained.
21
  _MAX_STDERR_CAPTURE_BYTES = 256 * 1024
22
 
23
 
24
+ class ManagedClaudeSession:
25
+ """Manages a single persistent Claude Code subprocess."""
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  def __init__(
28
  self,
 
33
  claude_bin: str = "claude",
34
  auth_token: str = "",
35
  *,
 
36
  log_raw_cli_diagnostics: bool = False,
37
  ):
38
+ self.config = ManagedClaudeConfig(
39
  workspace_path=os.path.normpath(os.path.abspath(workspace_path)),
40
  api_url=api_url,
41
  allowed_dirs=[os.path.normpath(d) for d in (allowed_dirs or [])],
 
49
  self.plans_directory = self.config.plans_directory
50
  self.claude_bin = self.config.claude_bin
51
  self.auth_token = self.config.auth_token
 
52
  self._log_raw_cli_diagnostics = log_raw_cli_diagnostics
53
  self.process: asyncio.subprocess.Process | None = None
54
  self.current_session_id: str | None = None
 
103
  """
104
  async with self._cli_lock:
105
  self._is_busy = True
106
+ invocation = build_managed_claude_invocation(
107
  config=self.config,
108
+ request=ManagedClaudeTaskRequest(
109
  prompt=prompt,
110
  session_id=session_id,
111
  fork_session=fork_session,
 
114
  )
115
 
116
  trace_event(
117
+ stage="claude_cli",
118
+ event="claude_cli.process.launch",
119
+ source="claude_cli",
120
  **invocation.trace_metadata,
121
  )
122
 
 
135
  yield {"type": "exit", "code": 1}
136
  return
137
 
138
+ parse_state = ManagedClaudeParseState(
139
  log_raw_cli_diagnostics=self._log_raw_cli_diagnostics
140
  )
141
  buffer = bytearray()
 
220
  unregister_pid(self.process.pid)
221
 
222
  async def _handle_line_gen(
223
+ self, line_str: str, parse_state: ManagedClaudeParseState
224
  ) -> AsyncGenerator[dict]:
225
  """Process a single line and yield events."""
226
+ for event in parse_managed_claude_stdout_line(line_str, parse_state):
227
  if isinstance(event, dict) and event.get("type") == "session_info":
228
  session_id = event.get("session_id")
229
  if isinstance(session_id, str):
230
  self.current_session_id = session_id
231
  yield event
232
 
 
 
 
 
233
  async def stop(self):
234
  """Stop the CLI process."""
235
  if self.process and self.process.returncode is None:
messaging/__init__.py CHANGED
@@ -2,20 +2,24 @@
2
 
3
  from .event_parser import parse_cli_event
4
  from .models import IncomingMessage
5
- from .platforms.base import CLISession, MessagingPlatform, SessionManagerInterface
 
 
 
 
6
  from .session import SessionStore
7
  from .trees import MessageNode, MessageState, MessageTree, TreeQueueManager
8
  from .workflow import MessagingWorkflow
9
 
10
  __all__ = [
11
- "CLISession",
12
  "IncomingMessage",
 
 
13
  "MessageNode",
14
  "MessageState",
15
  "MessageTree",
16
  "MessagingPlatform",
17
  "MessagingWorkflow",
18
- "SessionManagerInterface",
19
  "SessionStore",
20
  "TreeQueueManager",
21
  "parse_cli_event",
 
2
 
3
  from .event_parser import parse_cli_event
4
  from .models import IncomingMessage
5
+ from .platforms.base import (
6
+ ManagedClaudeSessionManagerProtocol,
7
+ ManagedClaudeSessionProtocol,
8
+ MessagingPlatform,
9
+ )
10
  from .session import SessionStore
11
  from .trees import MessageNode, MessageState, MessageTree, TreeQueueManager
12
  from .workflow import MessagingWorkflow
13
 
14
  __all__ = [
 
15
  "IncomingMessage",
16
+ "ManagedClaudeSessionManagerProtocol",
17
+ "ManagedClaudeSessionProtocol",
18
  "MessageNode",
19
  "MessageState",
20
  "MessageTree",
21
  "MessagingPlatform",
22
  "MessagingWorkflow",
 
23
  "SessionStore",
24
  "TreeQueueManager",
25
  "parse_cli_event",
messaging/command_context.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
 
5
  from typing import Protocol
6
 
7
- from .platforms.base import MessagingPlatform, SessionManagerInterface
8
  from .session import SessionStore
9
  from .transcript import RenderCtx
10
  from .trees import MessageNode, MessageTree, TreeQueueManager
@@ -14,7 +14,7 @@ class MessagingCommandContext(Protocol):
14
  """Operations commands need from the messaging workflow."""
15
 
16
  platform: MessagingPlatform
17
- cli_manager: SessionManagerInterface
18
  session_store: SessionStore
19
 
20
  @property
 
4
 
5
  from typing import Protocol
6
 
7
+ from .platforms.base import ManagedClaudeSessionManagerProtocol, MessagingPlatform
8
  from .session import SessionStore
9
  from .transcript import RenderCtx
10
  from .trees import MessageNode, MessageTree, TreeQueueManager
 
14
  """Operations commands need from the messaging workflow."""
15
 
16
  platform: MessagingPlatform
17
+ cli_manager: ManagedClaudeSessionManagerProtocol
18
  session_store: SessionStore
19
 
20
  @property
messaging/node_event_pipeline.py CHANGED
@@ -10,7 +10,7 @@ from loguru import logger
10
  from core.trace import trace_event
11
 
12
  from .cli_event_constants import TRANSCRIPT_EVENT_TYPES, get_status_for_event
13
- from .platforms.base import SessionManagerInterface
14
  from .safe_diagnostics import text_len_hint
15
  from .session import SessionStore
16
  from .transcript import TranscriptBuffer
@@ -24,7 +24,7 @@ async def handle_session_info_event(
24
  captured_session_id: str | None,
25
  temp_session_id: str | None,
26
  *,
27
- cli_manager: SessionManagerInterface,
28
  session_store: SessionStore,
29
  ) -> tuple[str | None, str | None]:
30
  """Handle session_info event; return updated (captured_session_id, temp_session_id)."""
 
10
  from core.trace import trace_event
11
 
12
  from .cli_event_constants import TRANSCRIPT_EVENT_TYPES, get_status_for_event
13
+ from .platforms.base import ManagedClaudeSessionManagerProtocol
14
  from .safe_diagnostics import text_len_hint
15
  from .session import SessionStore
16
  from .transcript import TranscriptBuffer
 
24
  captured_session_id: str | None,
25
  temp_session_id: str | None,
26
  *,
27
+ cli_manager: ManagedClaudeSessionManagerProtocol,
28
  session_store: SessionStore,
29
  ) -> tuple[str | None, str | None]:
30
  """Handle session_info event; return updated (captured_session_id, temp_session_id)."""
messaging/node_runner.py CHANGED
@@ -12,7 +12,7 @@ from core.trace import trace_event
12
 
13
  from .event_parser import parse_cli_event
14
  from .node_event_pipeline import handle_session_info_event, process_parsed_cli_event
15
- from .platforms.base import MessagingPlatform, SessionManagerInterface
16
  from .safe_diagnostics import format_exception_for_log
17
  from .session import SessionStore
18
  from .transcript import RenderCtx, TranscriptBuffer
@@ -27,7 +27,7 @@ class MessagingNodeRunner:
27
  self,
28
  *,
29
  platform: MessagingPlatform,
30
- cli_manager: SessionManagerInterface,
31
  session_store: SessionStore,
32
  get_tree_queue: Callable[[], TreeQueueManager],
33
  format_status: Callable[[str, str, str | None], str],
 
12
 
13
  from .event_parser import parse_cli_event
14
  from .node_event_pipeline import handle_session_info_event, process_parsed_cli_event
15
+ from .platforms.base import ManagedClaudeSessionManagerProtocol, MessagingPlatform
16
  from .safe_diagnostics import format_exception_for_log
17
  from .session import SessionStore
18
  from .transcript import RenderCtx, TranscriptBuffer
 
27
  self,
28
  *,
29
  platform: MessagingPlatform,
30
+ cli_manager: ManagedClaudeSessionManagerProtocol,
31
  session_store: SessionStore,
32
  get_tree_queue: Callable[[], TreeQueueManager],
33
  format_status: Callable[[str, str, str | None], str],
messaging/platforms/__init__.py CHANGED
@@ -1,11 +1,15 @@
1
  """Messaging platform adapters (Telegram, Discord, etc.)."""
2
 
3
- from .base import CLISession, MessagingPlatform, SessionManagerInterface
 
 
 
 
4
  from .factory import create_messaging_platform
5
 
6
  __all__ = [
7
- "CLISession",
 
8
  "MessagingPlatform",
9
- "SessionManagerInterface",
10
  "create_messaging_platform",
11
  ]
 
1
  """Messaging platform adapters (Telegram, Discord, etc.)."""
2
 
3
+ from .base import (
4
+ ManagedClaudeSessionManagerProtocol,
5
+ ManagedClaudeSessionProtocol,
6
+ MessagingPlatform,
7
+ )
8
  from .factory import create_messaging_platform
9
 
10
  __all__ = [
11
+ "ManagedClaudeSessionManagerProtocol",
12
+ "ManagedClaudeSessionProtocol",
13
  "MessagingPlatform",
 
14
  "create_messaging_platform",
15
  ]
messaging/platforms/base.py CHANGED
@@ -12,8 +12,8 @@ from ..models import IncomingMessage
12
 
13
 
14
  @runtime_checkable
15
- class CLISession(Protocol):
16
- """Protocol for CLI session - avoid circular import from cli package."""
17
 
18
  def start_task(
19
  self, prompt: str, session_id: str | None = None, fork_session: bool = False
@@ -24,16 +24,14 @@ class CLISession(Protocol):
24
 
25
 
26
  @runtime_checkable
27
- class SessionManagerInterface(Protocol):
28
  """
29
- Protocol for session managers to avoid tight coupling with cli package.
30
-
31
- Implementations: CLISessionManager
32
  """
33
 
34
  async def get_or_create_session(
35
  self, session_id: str | None = None
36
- ) -> tuple[CLISession, str, bool]:
37
  """
38
  Get an existing session or create a new one.
39
 
 
12
 
13
 
14
  @runtime_checkable
15
+ class ManagedClaudeSessionProtocol(Protocol):
16
+ """Protocol for managed Claude sessions - avoid circular imports."""
17
 
18
  def start_task(
19
  self, prompt: str, session_id: str | None = None, fork_session: bool = False
 
24
 
25
 
26
  @runtime_checkable
27
+ class ManagedClaudeSessionManagerProtocol(Protocol):
28
  """
29
+ Protocol for managed Claude session managers to avoid tight coupling.
 
 
30
  """
31
 
32
  async def get_or_create_session(
33
  self, session_id: str | None = None
34
+ ) -> tuple[ManagedClaudeSessionProtocol, str, bool]:
35
  """
36
  Get an existing session or create a new one.
37
 
messaging/workflow.py CHANGED
@@ -8,7 +8,7 @@ from core.trace import trace_event
8
 
9
  from .models import IncomingMessage
10
  from .node_runner import MessagingNodeRunner
11
- from .platforms.base import MessagingPlatform, SessionManagerInterface
12
  from .rendering.profiles import build_rendering_profile
13
  from .safe_diagnostics import format_exception_for_log
14
  from .session import SessionStore
@@ -28,7 +28,7 @@ class MessagingWorkflow:
28
  def __init__(
29
  self,
30
  platform: MessagingPlatform,
31
- cli_manager: SessionManagerInterface,
32
  session_store: SessionStore,
33
  *,
34
  debug_platform_edits: bool = False,
 
8
 
9
  from .models import IncomingMessage
10
  from .node_runner import MessagingNodeRunner
11
+ from .platforms.base import ManagedClaudeSessionManagerProtocol, MessagingPlatform
12
  from .rendering.profiles import build_rendering_profile
13
  from .safe_diagnostics import format_exception_for_log
14
  from .session import SessionStore
 
28
  def __init__(
29
  self,
30
  platform: MessagingPlatform,
31
+ cli_manager: ManagedClaudeSessionManagerProtocol,
32
  session_store: SessionStore,
33
  *,
34
  debug_platform_edits: bool = False,
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
 
5
  [project]
6
  name = "free-claude-code"
7
- version = "2.3.10"
8
  description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
9
  readme = "README.md"
10
  requires-python = ">=3.14.0"
@@ -29,8 +29,8 @@ dependencies = [
29
  fcc-server = "cli.entrypoints:serve"
30
  free-claude-code = "cli.entrypoints:serve"
31
  fcc-init = "cli.entrypoints:init"
32
- fcc-claude = "cli.entrypoints:launch_claude"
33
- fcc-codex = "cli.entrypoints:launch_codex"
34
 
35
  [project.optional-dependencies]
36
  voice = [
 
4
 
5
  [project]
6
  name = "free-claude-code"
7
+ version = "2.3.11"
8
  description = "Middleware between Claude Code CLI (Anthropic API) and NVIDIA NIM"
9
  readme = "README.md"
10
  requires-python = ">=3.14.0"
 
29
  fcc-server = "cli.entrypoints:serve"
30
  free-claude-code = "cli.entrypoints:serve"
31
  fcc-init = "cli.entrypoints:init"
32
+ fcc-claude = "cli.launchers.claude:launch"
33
+ fcc-codex = "cli.launchers.codex:launch"
34
 
35
  [project.optional-dependencies]
36
  voice = [
smoke/capabilities.py CHANGED
@@ -69,7 +69,8 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = (
69
  (
70
  "tests/api/test_openai_responses.py",
71
  "tests/core/openai_responses/test_sse.py",
72
- "tests/cli/test_adapters.py",
 
73
  ),
74
  (
75
  "test_probe_and_models_routes",
@@ -424,7 +425,7 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = (
424
  "cli",
425
  "claude_cli_drop_in",
426
  "claude_cli_drop_in",
427
- "cli.session.CLISession",
428
  "Claude CLI binary and proxy env",
429
  "stream-json events and session id mapping",
430
  "stderr/error event and process cleanup",
@@ -439,12 +440,12 @@ CAPABILITY_CONTRACTS: tuple[CapabilityContract, ...] = (
439
  "cli",
440
  "codex_cli_drop_in",
441
  "drop_in_codex_replacement",
442
- "cli.adapters.codex.CodexCliAdapter",
443
  "Codex CLI binary and fcc provider env",
444
- "Responses config and JSONL event mapping",
445
- "stderr/error event and process cleanup",
446
  (
447
- "tests/cli/test_adapters.py",
448
  "tests/cli/test_entrypoints.py",
449
  ),
450
  (),
 
69
  (
70
  "tests/api/test_openai_responses.py",
71
  "tests/core/openai_responses/test_sse.py",
72
+ "tests/cli/test_entrypoints.py",
73
+ "tests/cli/test_codex_model_catalog.py",
74
  ),
75
  (
76
  "test_probe_and_models_routes",
 
425
  "cli",
426
  "claude_cli_drop_in",
427
  "claude_cli_drop_in",
428
+ "cli.managed.session.ManagedClaudeSession",
429
  "Claude CLI binary and proxy env",
430
  "stream-json events and session id mapping",
431
  "stderr/error event and process cleanup",
 
440
  "cli",
441
  "codex_cli_drop_in",
442
  "drop_in_codex_replacement",
443
+ "cli.launchers.codex",
444
  "Codex CLI binary and fcc provider env",
445
+ "Responses config, auth env, and native /model catalog injection",
446
+ "proxy preflight and catalog fail-open warning",
447
  (
448
+ "tests/cli/test_codex_model_catalog.py",
449
  "tests/cli/test_entrypoints.py",
450
  ),
451
  (),
smoke/features.py CHANGED
@@ -93,7 +93,8 @@ FEATURE_INVENTORY: tuple[FeatureCoverage, ...] = (
93
  "readme",
94
  (
95
  "tests/api/test_openai_responses.py",
96
- "tests/cli/test_adapters.py",
 
97
  "tests/core/openai_responses/test_sse.py",
98
  ),
99
  ("test_probe_and_models_routes",),
 
93
  "readme",
94
  (
95
  "tests/api/test_openai_responses.py",
96
+ "tests/cli/test_entrypoints.py",
97
+ "tests/cli/test_codex_model_catalog.py",
98
  "tests/core/openai_responses/test_sse.py",
99
  ),
100
  ("test_probe_and_models_routes",),
smoke/product/test_cli_package_product_live.py CHANGED
@@ -8,8 +8,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
8
 
9
  import pytest
10
 
11
- from cli.manager import CLISessionManager
12
- from cli.session import CLISession
13
  from smoke.lib.child_process import cmd_fcc_init
14
  from smoke.lib.config import SmokeConfig
15
 
@@ -37,7 +37,7 @@ def test_entrypoint_init_e2e(smoke_config: SmokeConfig, tmp_path: Path) -> None:
37
 
38
  @pytest.mark.asyncio
39
  async def test_cli_session_resume_fork_e2e(tmp_path: Path) -> None:
40
- session = CLISession(str(tmp_path), "http://127.0.0.1:8082/v1")
41
  process = AsyncMock()
42
  process.stdout.read.side_effect = [b""]
43
  process.stderr.read.return_value = b""
@@ -62,7 +62,7 @@ async def test_cli_session_resume_fork_e2e(tmp_path: Path) -> None:
62
 
63
  @pytest.mark.asyncio
64
  async def test_cli_process_cleanup_e2e(tmp_path: Path) -> None:
65
- manager = CLISessionManager(
66
  workspace_path=str(tmp_path),
67
  api_url="http://127.0.0.1:8082/v1",
68
  )
@@ -84,14 +84,14 @@ async def test_cli_process_cleanup_e2e(tmp_path: Path) -> None:
84
 
85
  @pytest.mark.asyncio
86
  async def test_cli_session_stop_kills_child_e2e(tmp_path: Path) -> None:
87
- session = CLISession(str(tmp_path), "http://127.0.0.1:8082/v1")
88
  process = MagicMock()
89
  process.pid = 123456
90
  process.returncode = None
91
  process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0])
92
  session.process = process
93
 
94
- with patch("cli.session.kill_pid_tree_best_effort") as kill_tree:
95
  stopped = await session.stop()
96
 
97
  assert stopped is True
 
8
 
9
  import pytest
10
 
11
+ from cli.managed.manager import ManagedClaudeSessionManager
12
+ from cli.managed.session import ManagedClaudeSession
13
  from smoke.lib.child_process import cmd_fcc_init
14
  from smoke.lib.config import SmokeConfig
15
 
 
37
 
38
  @pytest.mark.asyncio
39
  async def test_cli_session_resume_fork_e2e(tmp_path: Path) -> None:
40
+ session = ManagedClaudeSession(str(tmp_path), "http://127.0.0.1:8082/v1")
41
  process = AsyncMock()
42
  process.stdout.read.side_effect = [b""]
43
  process.stderr.read.return_value = b""
 
62
 
63
  @pytest.mark.asyncio
64
  async def test_cli_process_cleanup_e2e(tmp_path: Path) -> None:
65
+ manager = ManagedClaudeSessionManager(
66
  workspace_path=str(tmp_path),
67
  api_url="http://127.0.0.1:8082/v1",
68
  )
 
84
 
85
  @pytest.mark.asyncio
86
  async def test_cli_session_stop_kills_child_e2e(tmp_path: Path) -> None:
87
+ session = ManagedClaudeSession(str(tmp_path), "http://127.0.0.1:8082/v1")
88
  process = MagicMock()
89
  process.pid = 123456
90
  process.returncode = None
91
  process.wait = AsyncMock(side_effect=[asyncio.TimeoutError, 0])
92
  session.process = process
93
 
94
+ with patch("cli.managed.session.kill_pid_tree_best_effort") as kill_tree:
95
  stopped = await session.stop()
96
 
97
  assert stopped is True
tests/api/test_app_lifespan_and_errors.py CHANGED
@@ -313,7 +313,7 @@ def test_app_lifespan_sets_state_and_cleans_up(tmp_path, messaging_enabled):
313
  return_value=fake_platform if messaging_enabled else None,
314
  ) as create_platform,
315
  patch("messaging.session.SessionStore", return_value=session_store),
316
- patch("cli.manager.CLISessionManager", return_value=cli_manager),
317
  patch(
318
  "messaging.trees.TreeQueueManager.from_dict",
319
  return_value=fake_queue,
@@ -383,7 +383,7 @@ def test_app_lifespan_cleanup_continues_if_platform_stop_raises(tmp_path):
383
  return_value=fake_platform,
384
  ),
385
  patch("messaging.session.SessionStore", return_value=session_store),
386
- patch("cli.manager.CLISessionManager", return_value=cli_manager),
387
  TestClient(app),
388
  ):
389
  pass
@@ -560,7 +560,7 @@ def test_app_lifespan_platform_start_exception_cleanup_still_runs(tmp_path):
560
  return_value=fake_platform,
561
  ),
562
  patch("messaging.session.SessionStore", return_value=session_store),
563
- patch("cli.manager.CLISessionManager", return_value=cli_manager),
564
  TestClient(app),
565
  ):
566
  pass
@@ -611,7 +611,7 @@ def test_app_lifespan_flush_pending_save_exception_warning_only(tmp_path):
611
  return_value=fake_platform,
612
  ),
613
  patch("messaging.session.SessionStore", return_value=session_store),
614
- patch("cli.manager.CLISessionManager", return_value=cli_manager),
615
  TestClient(app),
616
  ):
617
  pass
 
313
  return_value=fake_platform if messaging_enabled else None,
314
  ) as create_platform,
315
  patch("messaging.session.SessionStore", return_value=session_store),
316
+ patch("cli.managed.ManagedClaudeSessionManager", return_value=cli_manager),
317
  patch(
318
  "messaging.trees.TreeQueueManager.from_dict",
319
  return_value=fake_queue,
 
383
  return_value=fake_platform,
384
  ),
385
  patch("messaging.session.SessionStore", return_value=session_store),
386
+ patch("cli.managed.ManagedClaudeSessionManager", return_value=cli_manager),
387
  TestClient(app),
388
  ):
389
  pass
 
560
  return_value=fake_platform,
561
  ),
562
  patch("messaging.session.SessionStore", return_value=session_store),
563
+ patch("cli.managed.ManagedClaudeSessionManager", return_value=cli_manager),
564
  TestClient(app),
565
  ):
566
  pass
 
611
  return_value=fake_platform,
612
  ),
613
  patch("messaging.session.SessionStore", return_value=session_store),
614
+ patch("cli.managed.ManagedClaudeSessionManager", return_value=cli_manager),
615
  TestClient(app),
616
  ):
617
  pass
tests/cli/test_adapters.py DELETED
@@ -1,879 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from types import SimpleNamespace
5
-
6
- from cli.adapters.base import CliParseState, CliTaskRequest
7
- from cli.adapters.claude import CLAUDE_CLI_ADAPTER
8
- from cli.adapters.codex import CODEX_CLI_ADAPTER
9
- from cli.adapters.registry import DEFAULT_CLIENT_CLI_ID, get_client_cli_adapter
10
-
11
-
12
- def _config(**overrides: object) -> SimpleNamespace:
13
- values: dict[str, object] = {
14
- "workspace_path": "/workspace",
15
- "api_url": "http://127.0.0.1:8082/v1",
16
- "allowed_dirs": [],
17
- "plans_directory": None,
18
- "claude_bin": "claude-test",
19
- "auth_token": "",
20
- }
21
- values.update(overrides)
22
- return SimpleNamespace(**values)
23
-
24
-
25
- def test_registry_returns_default_claude_adapter() -> None:
26
- assert DEFAULT_CLIENT_CLI_ID == "claude"
27
- assert get_client_cli_adapter() is CLAUDE_CLI_ADAPTER
28
- assert get_client_cli_adapter("claude") is CLAUDE_CLI_ADAPTER
29
- assert get_client_cli_adapter("codex") is CODEX_CLI_ADAPTER
30
-
31
-
32
- def test_claude_adapter_builds_new_task_command_and_env() -> None:
33
- invocation = CLAUDE_CLI_ADAPTER.build_task_invocation(
34
- config=_config(auth_token="proxy-token"),
35
- request=CliTaskRequest(prompt="hello"),
36
- base_env={
37
- "KEEP_ME": "yes",
38
- "ANTHROPIC_API_KEY": "official-key",
39
- "ANTHROPIC_AUTH_TOKEN": "stale-token",
40
- },
41
- )
42
-
43
- assert invocation.argv == (
44
- "claude-test",
45
- "-p",
46
- "hello",
47
- "--output-format",
48
- "stream-json",
49
- "--dangerously-skip-permissions",
50
- "--verbose",
51
- )
52
- assert invocation.env["KEEP_ME"] == "yes"
53
- assert invocation.env["ANTHROPIC_API_URL"] == "http://127.0.0.1:8082/v1"
54
- assert invocation.env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8082"
55
- assert invocation.env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token"
56
- assert invocation.env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
57
- assert invocation.env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000"
58
- assert "ANTHROPIC_API_KEY" not in invocation.env
59
- assert invocation.trace_metadata["client_cli_id"] == "claude"
60
-
61
-
62
- def test_claude_adapter_builds_resume_fork_command() -> None:
63
- invocation = CLAUDE_CLI_ADAPTER.build_task_invocation(
64
- config=_config(),
65
- request=CliTaskRequest(
66
- prompt="continue",
67
- session_id="sess_123",
68
- fork_session=True,
69
- ),
70
- base_env={},
71
- )
72
-
73
- assert invocation.argv[:4] == (
74
- "claude-test",
75
- "--resume",
76
- "sess_123",
77
- "--fork-session",
78
- )
79
- assert "-p" in invocation.argv
80
- assert "continue" in invocation.argv
81
- assert invocation.trace_metadata["resume_session_id"] == "sess_123"
82
- assert invocation.trace_metadata["fork_session"] is True
83
-
84
-
85
- def test_claude_adapter_does_not_resume_pending_session() -> None:
86
- invocation = CLAUDE_CLI_ADAPTER.build_task_invocation(
87
- config=_config(),
88
- request=CliTaskRequest(prompt="new", session_id="pending_123"),
89
- base_env={},
90
- )
91
-
92
- assert "--resume" not in invocation.argv
93
- assert invocation.trace_metadata["resume_session_id"] is None
94
-
95
-
96
- def test_claude_adapter_adds_allowed_dirs_and_plans_directory() -> None:
97
- invocation = CLAUDE_CLI_ADAPTER.build_task_invocation(
98
- config=_config(
99
- allowed_dirs=["/dir1", "/dir2"],
100
- plans_directory="./agent_workspace/plans",
101
- ),
102
- request=CliTaskRequest(prompt="hello"),
103
- base_env={},
104
- )
105
-
106
- assert invocation.argv.count("--add-dir") == 2
107
- assert "/dir1" in invocation.argv
108
- assert "/dir2" in invocation.argv
109
- settings_idx = invocation.argv.index("--settings")
110
- settings = json.loads(invocation.argv[settings_idx + 1])
111
- assert settings["plansDirectory"] == "./agent_workspace/plans"
112
-
113
-
114
- def test_claude_adapter_launcher_env_targets_proxy() -> None:
115
- env = CLAUDE_CLI_ADAPTER.build_launcher_env(
116
- proxy_root_url="http://127.0.0.1:9191",
117
- auth_token=" proxy-token ",
118
- base_env={
119
- "PATH": "keep",
120
- "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
121
- "ANTHROPIC_API_KEY": "official-key",
122
- "ANTHROPIC_AUTH_TOKEN": "stale-token",
123
- },
124
- )
125
-
126
- assert env["PATH"] == "keep"
127
- assert env["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9191"
128
- assert env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token"
129
- assert env["CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY"] == "1"
130
- assert env["CLAUDE_CODE_AUTO_COMPACT_WINDOW"] == "190000"
131
- assert "ANTHROPIC_API_KEY" not in env
132
-
133
-
134
- def test_claude_adapter_uses_sentinel_auth_when_proxy_auth_blank() -> None:
135
- invocation = CLAUDE_CLI_ADAPTER.build_task_invocation(
136
- config=_config(auth_token=""),
137
- request=CliTaskRequest(prompt="hello"),
138
- base_env={
139
- "ANTHROPIC_API_KEY": "official-key",
140
- "ANTHROPIC_AUTH_TOKEN": "stale-token",
141
- },
142
- )
143
-
144
- assert invocation.env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth"
145
- assert "ANTHROPIC_API_KEY" not in invocation.env
146
-
147
- env = CLAUDE_CLI_ADAPTER.build_launcher_env(
148
- proxy_root_url="http://127.0.0.1:9191",
149
- auth_token="",
150
- base_env={
151
- "ANTHROPIC_API_KEY": "official-key",
152
- "ANTHROPIC_AUTH_TOKEN": "stale-token",
153
- },
154
- )
155
-
156
- assert env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth"
157
- assert "ANTHROPIC_API_KEY" not in env
158
-
159
-
160
- def test_claude_adapter_launcher_command_preserves_args() -> None:
161
- command = CLAUDE_CLI_ADAPTER.build_launcher_command(
162
- binary_path="claude.cmd",
163
- argv=["--model", "sonnet"],
164
- settings=_config(),
165
- proxy_root_url="http://127.0.0.1:8082",
166
- )
167
-
168
- assert command == ["claude.cmd", "--model", "sonnet"]
169
-
170
-
171
- def test_claude_adapter_extracts_supported_session_id_shapes() -> None:
172
- assert CLAUDE_CLI_ADAPTER.extract_session_id({"session_id": "direct"}) == "direct"
173
- assert CLAUDE_CLI_ADAPTER.extract_session_id({"sessionId": "camel"}) == "camel"
174
- assert (
175
- CLAUDE_CLI_ADAPTER.extract_session_id({"init": {"session_id": "nested"}})
176
- == "nested"
177
- )
178
- assert (
179
- CLAUDE_CLI_ADAPTER.extract_session_id({"result": {"sessionId": "result"}})
180
- == "result"
181
- )
182
- assert (
183
- CLAUDE_CLI_ADAPTER.extract_session_id({"conversation": {"id": "conv"}})
184
- == "conv"
185
- )
186
- assert CLAUDE_CLI_ADAPTER.extract_session_id({"type": "message"}) is None
187
- assert CLAUDE_CLI_ADAPTER.extract_session_id("not a dict") is None
188
-
189
-
190
- def test_claude_adapter_invalid_stdout_json_becomes_raw_event() -> None:
191
- events = list(
192
- CLAUDE_CLI_ADAPTER.parse_stdout_line(
193
- "Not valid json",
194
- CliParseState(log_raw_cli_diagnostics=False),
195
- )
196
- )
197
-
198
- assert events == [{"type": "raw", "content": "Not valid json"}]
199
-
200
-
201
- def test_claude_adapter_synthesizes_session_info_once() -> None:
202
- state = CliParseState()
203
-
204
- first_events = list(
205
- CLAUDE_CLI_ADAPTER.parse_stdout_line('{"session_id": "sess_1"}', state)
206
- )
207
- second_events = list(
208
- CLAUDE_CLI_ADAPTER.parse_stdout_line('{"session_id": "sess_2"}', state)
209
- )
210
-
211
- assert first_events == [
212
- {"type": "session_info", "session_id": "sess_1"},
213
- {"session_id": "sess_1"},
214
- ]
215
- assert second_events == [{"session_id": "sess_2"}]
216
-
217
-
218
- def test_codex_adapter_builds_new_task_command_and_env() -> None:
219
- invocation = CODEX_CLI_ADAPTER.build_task_invocation(
220
- config=_config(auth_token="proxy-token"),
221
- request=CliTaskRequest(prompt="hello"),
222
- base_env={
223
- "KEEP_ME": "yes",
224
- "OPENAI_API_KEY": "official-key",
225
- "OPENAI_BASE_URL": "https://api.openai.com/v1",
226
- "CODEX_API_KEY": "stale-token",
227
- "CODEX_HOME": "/tmp/codex",
228
- },
229
- )
230
-
231
- assert invocation.argv[:3] == ("codex", "exec", "--json")
232
- assert "--dangerously-bypass-approvals-and-sandbox" in invocation.argv
233
- assert "-C" in invocation.argv
234
- assert "/workspace" in invocation.argv
235
- assert invocation.argv[-1] == "hello"
236
- assert 'model_provider="fcc"' in invocation.argv
237
- assert 'model_providers.fcc.wire_api="responses"' in invocation.argv
238
- assert invocation.env["KEEP_ME"] == "yes"
239
- assert invocation.env["CODEX_HOME"] == "/tmp/codex"
240
- assert invocation.env["FCC_CODEX_API_KEY"] == "proxy-token"
241
- assert "OPENAI_API_KEY" not in invocation.env
242
- assert "OPENAI_BASE_URL" not in invocation.env
243
- assert "CODEX_API_KEY" not in invocation.env
244
- assert invocation.trace_metadata["client_cli_id"] == "codex"
245
- assert invocation.trace_metadata["codex_binary"] == "codex"
246
- assert "claude_binary" not in invocation.trace_metadata
247
- assert CODEX_CLI_ADAPTER.trace_stage == "codex_cli"
248
- assert CODEX_CLI_ADAPTER.process_launch_event == "codex_cli.process.launch"
249
- assert CODEX_CLI_ADAPTER.trace_source == "codex_cli"
250
-
251
-
252
- def test_codex_adapter_uses_explicit_codex_binary_when_provided() -> None:
253
- invocation = CODEX_CLI_ADAPTER.build_task_invocation(
254
- config=_config(codex_bin="codex-test"),
255
- request=CliTaskRequest(prompt="hello"),
256
- base_env={},
257
- )
258
-
259
- assert invocation.argv[:3] == ("codex-test", "exec", "--json")
260
- assert invocation.trace_metadata["codex_binary"] == "codex-test"
261
-
262
-
263
- def test_codex_adapter_builds_resume_command() -> None:
264
- invocation = CODEX_CLI_ADAPTER.build_task_invocation(
265
- config=_config(),
266
- request=CliTaskRequest(prompt="continue", session_id="sess_123"),
267
- base_env={},
268
- )
269
-
270
- assert invocation.argv[:4] == ("codex", "exec", "resume", "--json")
271
- assert "sess_123" in invocation.argv
272
- assert invocation.argv[-1] == "continue"
273
- assert invocation.trace_metadata["resume_session_id"] == "sess_123"
274
-
275
-
276
- def test_codex_adapter_fork_starts_new_session() -> None:
277
- invocation = CODEX_CLI_ADAPTER.build_task_invocation(
278
- config=_config(),
279
- request=CliTaskRequest(
280
- prompt="fork",
281
- session_id="sess_123",
282
- fork_session=True,
283
- ),
284
- base_env={},
285
- )
286
-
287
- assert "resume" not in invocation.argv
288
- assert invocation.trace_metadata["resume_session_id"] is None
289
- assert invocation.trace_metadata["fork_session"] is True
290
-
291
-
292
- def test_codex_adapter_launcher_command_targets_responses_provider() -> None:
293
- command = CODEX_CLI_ADAPTER.build_launcher_command(
294
- binary_path="codex.cmd",
295
- argv=["exec", "hello"],
296
- settings=_config(model="nvidia_nim/test-model"),
297
- proxy_root_url="http://127.0.0.1:8082",
298
- )
299
-
300
- assert command[:2] == ["codex.cmd", "-c"]
301
- assert 'model_provider="fcc"' in command
302
- assert 'model_providers.fcc.base_url="http://127.0.0.1:8082/v1"' in command
303
- assert 'model_providers.fcc.env_key="FCC_CODEX_API_KEY"' in command
304
- assert 'model_providers.fcc.wire_api="responses"' in command
305
- assert 'model="nvidia_nim/test-model"' in command
306
- assert command[-2:] == ["exec", "hello"]
307
-
308
-
309
- def test_codex_adapter_launcher_env_strips_openai_credentials() -> None:
310
- env = CODEX_CLI_ADAPTER.build_launcher_env(
311
- proxy_root_url="http://127.0.0.1:9191",
312
- auth_token=" proxy-token ",
313
- base_env={
314
- "PATH": "keep",
315
- "CODEX_HOME": "/tmp/codex",
316
- "OPENAI_API_KEY": "official-key",
317
- "OPENAI_BASE_URL": "https://api.openai.com/v1",
318
- "CODEX_API_KEY": "stale",
319
- "FCC_CODEX_API_KEY": "old",
320
- },
321
- )
322
-
323
- assert env["PATH"] == "keep"
324
- assert env["CODEX_HOME"] == "/tmp/codex"
325
- assert env["FCC_CODEX_API_KEY"] == "proxy-token"
326
- assert "OPENAI_API_KEY" not in env
327
- assert "OPENAI_BASE_URL" not in env
328
- assert "CODEX_API_KEY" not in env
329
-
330
-
331
- def test_codex_adapter_parses_response_text_delta() -> None:
332
- events = list(
333
- CODEX_CLI_ADAPTER.parse_stdout_line(
334
- '{"type":"response.output_text.delta","delta":"hi","thread_id":"t1"}',
335
- CliParseState(),
336
- )
337
- )
338
-
339
- assert events == [
340
- {"type": "session_info", "session_id": "t1"},
341
- {
342
- "type": "content_block_delta",
343
- "index": 0,
344
- "delta": {"type": "text_delta", "text": "hi"},
345
- },
346
- ]
347
-
348
-
349
- def test_codex_adapter_marks_streamed_message_items_seen() -> None:
350
- state = CliParseState()
351
- item = {
352
- "type": "message",
353
- "id": "msg1",
354
- "content": [{"type": "output_text", "text": "hi"}],
355
- }
356
-
357
- delta_events = list(
358
- CODEX_CLI_ADAPTER.parse_stdout_line(
359
- json.dumps(
360
- {
361
- "type": "response.output_text.delta",
362
- "item_id": "msg1",
363
- "output_index": 0,
364
- "delta": "hi",
365
- }
366
- ),
367
- state,
368
- )
369
- )
370
- item_done_events = list(
371
- CODEX_CLI_ADAPTER.parse_stdout_line(
372
- json.dumps({"type": "response.output_item.done", "item": item}),
373
- state,
374
- )
375
- )
376
- completed_events = list(
377
- CODEX_CLI_ADAPTER.parse_stdout_line(
378
- json.dumps(
379
- {
380
- "type": "response.completed",
381
- "response": {"output": [item]},
382
- }
383
- ),
384
- state,
385
- )
386
- )
387
-
388
- assert delta_events == [
389
- {
390
- "type": "content_block_delta",
391
- "index": 0,
392
- "delta": {"type": "text_delta", "text": "hi"},
393
- }
394
- ]
395
- assert item_done_events == []
396
- assert completed_events == []
397
-
398
-
399
- def test_codex_adapter_dedupes_output_index_only_message_done() -> None:
400
- state = CliParseState()
401
- item = {
402
- "type": "message",
403
- "content": [{"type": "output_text", "text": "hi"}],
404
- }
405
-
406
- delta_events = list(
407
- CODEX_CLI_ADAPTER.parse_stdout_line(
408
- json.dumps(
409
- {
410
- "type": "response.output_text.delta",
411
- "output_index": 0,
412
- "delta": "hi",
413
- }
414
- ),
415
- state,
416
- )
417
- )
418
- item_done_events = list(
419
- CODEX_CLI_ADAPTER.parse_stdout_line(
420
- json.dumps(
421
- {
422
- "type": "response.output_item.done",
423
- "output_index": 0,
424
- "item": item,
425
- }
426
- ),
427
- state,
428
- )
429
- )
430
-
431
- assert delta_events == [
432
- {
433
- "type": "content_block_delta",
434
- "index": 0,
435
- "delta": {"type": "text_delta", "text": "hi"},
436
- }
437
- ]
438
- assert item_done_events == []
439
-
440
-
441
- def test_codex_adapter_prefers_streamed_output_index_for_final_message_id() -> None:
442
- state = CliParseState()
443
- item = {
444
- "type": "message",
445
- "id": "msg_1",
446
- "content": [{"type": "output_text", "text": "hi"}],
447
- }
448
-
449
- delta_events = list(
450
- CODEX_CLI_ADAPTER.parse_stdout_line(
451
- json.dumps(
452
- {
453
- "type": "response.output_text.delta",
454
- "output_index": 0,
455
- "delta": "hi",
456
- }
457
- ),
458
- state,
459
- )
460
- )
461
- item_done_events = list(
462
- CODEX_CLI_ADAPTER.parse_stdout_line(
463
- json.dumps(
464
- {
465
- "type": "response.output_item.done",
466
- "output_index": 0,
467
- "item": item,
468
- }
469
- ),
470
- state,
471
- )
472
- )
473
- completed_events = list(
474
- CODEX_CLI_ADAPTER.parse_stdout_line(
475
- json.dumps(
476
- {
477
- "type": "response.completed",
478
- "response": {"output": [item]},
479
- }
480
- ),
481
- state,
482
- )
483
- )
484
-
485
- assert delta_events == [
486
- {
487
- "type": "content_block_delta",
488
- "index": 0,
489
- "delta": {"type": "text_delta", "text": "hi"},
490
- }
491
- ]
492
- assert item_done_events == []
493
- assert completed_events == []
494
-
495
-
496
- def test_codex_adapter_dedupes_output_index_only_completed_message() -> None:
497
- state = CliParseState()
498
- item = {
499
- "type": "message",
500
- "content": [{"type": "output_text", "text": "hi"}],
501
- }
502
-
503
- delta_events = list(
504
- CODEX_CLI_ADAPTER.parse_stdout_line(
505
- json.dumps(
506
- {
507
- "type": "response.output_text.delta",
508
- "output_index": 0,
509
- "delta": "hi",
510
- }
511
- ),
512
- state,
513
- )
514
- )
515
- completed_events = list(
516
- CODEX_CLI_ADAPTER.parse_stdout_line(
517
- json.dumps(
518
- {
519
- "type": "response.completed",
520
- "response": {"output": [item]},
521
- }
522
- ),
523
- state,
524
- )
525
- )
526
-
527
- assert delta_events == [
528
- {
529
- "type": "content_block_delta",
530
- "index": 0,
531
- "delta": {"type": "text_delta", "text": "hi"},
532
- }
533
- ]
534
- assert completed_events == []
535
-
536
-
537
- def test_codex_adapter_output_index_dedupe_scope_advances_after_terminal() -> None:
538
- state = CliParseState()
539
- streamed_item = {
540
- "type": "message",
541
- "content": [{"type": "output_text", "text": "first"}],
542
- }
543
- fallback_item = {
544
- "type": "message",
545
- "content": [{"type": "output_text", "text": "second"}],
546
- }
547
-
548
- list(
549
- CODEX_CLI_ADAPTER.parse_stdout_line(
550
- json.dumps(
551
- {
552
- "type": "response.output_text.delta",
553
- "output_index": 0,
554
- "delta": "first",
555
- }
556
- ),
557
- state,
558
- )
559
- )
560
- first_completed_events = list(
561
- CODEX_CLI_ADAPTER.parse_stdout_line(
562
- json.dumps(
563
- {
564
- "type": "response.completed",
565
- "response": {"output": [streamed_item]},
566
- }
567
- ),
568
- state,
569
- )
570
- )
571
- second_completed_events = list(
572
- CODEX_CLI_ADAPTER.parse_stdout_line(
573
- json.dumps(
574
- {
575
- "type": "response.completed",
576
- "response": {"output": [fallback_item]},
577
- }
578
- ),
579
- state,
580
- )
581
- )
582
-
583
- assert first_completed_events == []
584
- assert second_completed_events == [
585
- {
586
- "type": "assistant",
587
- "message": {"content": [{"type": "text", "text": "second"}]},
588
- }
589
- ]
590
-
591
-
592
- def test_codex_adapter_output_index_dedupe_scope_advances_after_failed_response() -> (
593
- None
594
- ):
595
- state = CliParseState()
596
- fallback_item = {
597
- "type": "message",
598
- "content": [{"type": "output_text", "text": "second"}],
599
- }
600
-
601
- list(
602
- CODEX_CLI_ADAPTER.parse_stdout_line(
603
- json.dumps(
604
- {
605
- "type": "response.output_text.delta",
606
- "output_index": 0,
607
- "delta": "first",
608
- }
609
- ),
610
- state,
611
- )
612
- )
613
- failed_events = list(
614
- CODEX_CLI_ADAPTER.parse_stdout_line(
615
- json.dumps(
616
- {
617
- "type": "response.failed",
618
- "response": {
619
- "error": {
620
- "message": "upstream failed",
621
- },
622
- },
623
- }
624
- ),
625
- state,
626
- )
627
- )
628
- completed_events = list(
629
- CODEX_CLI_ADAPTER.parse_stdout_line(
630
- json.dumps(
631
- {
632
- "type": "response.completed",
633
- "response": {"output": [fallback_item]},
634
- }
635
- ),
636
- state,
637
- )
638
- )
639
-
640
- assert failed_events == [{"type": "error", "error": {"message": "upstream failed"}}]
641
- assert completed_events == [
642
- {
643
- "type": "assistant",
644
- "message": {"content": [{"type": "text", "text": "second"}]},
645
- }
646
- ]
647
-
648
-
649
- def test_codex_adapter_parses_response_reasoning_text_delta() -> None:
650
- events = list(
651
- CODEX_CLI_ADAPTER.parse_stdout_line(
652
- (
653
- '{"type":"response.reasoning_text.delta","delta":"think",'
654
- '"output_index":2,"thread_id":"t1"}'
655
- ),
656
- CliParseState(),
657
- )
658
- )
659
-
660
- assert events == [
661
- {"type": "session_info", "session_id": "t1"},
662
- {
663
- "type": "content_block_delta",
664
- "index": 2,
665
- "delta": {"type": "thinking_delta", "thinking": "think"},
666
- },
667
- ]
668
-
669
-
670
- def test_codex_adapter_parses_completed_function_call_item() -> None:
671
- line = json.dumps(
672
- {
673
- "type": "response.output_item.done",
674
- "item": {
675
- "type": "function_call",
676
- "call_id": "call_1",
677
- "name": "echo",
678
- "arguments": '{"value":"FCC"}',
679
- },
680
- }
681
- )
682
-
683
- events = list(CODEX_CLI_ADAPTER.parse_stdout_line(line, CliParseState()))
684
-
685
- assert events == [
686
- {
687
- "type": "assistant",
688
- "message": {
689
- "content": [
690
- {
691
- "type": "tool_use",
692
- "id": "call_1",
693
- "name": "echo",
694
- "input": {"value": "FCC"},
695
- }
696
- ]
697
- },
698
- }
699
- ]
700
-
701
-
702
- def test_codex_adapter_parses_completed_custom_tool_call_item() -> None:
703
- line = json.dumps(
704
- {
705
- "type": "response.output_item.done",
706
- "item": {
707
- "type": "custom_tool_call",
708
- "call_id": "call_1",
709
- "name": "apply_patch",
710
- "input": "*** Begin Patch",
711
- },
712
- }
713
- )
714
-
715
- events = list(CODEX_CLI_ADAPTER.parse_stdout_line(line, CliParseState()))
716
-
717
- assert events == [
718
- {
719
- "type": "assistant",
720
- "message": {
721
- "content": [
722
- {
723
- "type": "tool_use",
724
- "id": "call_1",
725
- "name": "apply_patch",
726
- "input": {"input": "*** Begin Patch"},
727
- }
728
- ]
729
- },
730
- }
731
- ]
732
-
733
-
734
- def test_codex_adapter_dedupes_output_item_done_against_completed_output() -> None:
735
- state = CliParseState()
736
- item = {
737
- "type": "custom_tool_call",
738
- "id": "ctc_1",
739
- "call_id": "call_1",
740
- "name": "apply_patch",
741
- "input": "*** Begin Patch",
742
- }
743
-
744
- item_done_events = list(
745
- CODEX_CLI_ADAPTER.parse_stdout_line(
746
- json.dumps({"type": "response.output_item.done", "item": item}),
747
- state,
748
- )
749
- )
750
- completed_events = list(
751
- CODEX_CLI_ADAPTER.parse_stdout_line(
752
- json.dumps(
753
- {
754
- "type": "response.completed",
755
- "response": {"output": [item]},
756
- }
757
- ),
758
- state,
759
- )
760
- )
761
-
762
- assert item_done_events == [
763
- {
764
- "type": "assistant",
765
- "message": {
766
- "content": [
767
- {
768
- "type": "tool_use",
769
- "id": "call_1",
770
- "name": "apply_patch",
771
- "input": {"input": "*** Begin Patch"},
772
- }
773
- ]
774
- },
775
- }
776
- ]
777
- assert completed_events == []
778
-
779
-
780
- def test_codex_adapter_completed_output_is_fallback_for_unseen_message_item() -> None:
781
- item = {
782
- "type": "message",
783
- "id": "msg1",
784
- "content": [{"type": "output_text", "text": "hi"}],
785
- }
786
-
787
- events = list(
788
- CODEX_CLI_ADAPTER.parse_stdout_line(
789
- json.dumps(
790
- {
791
- "type": "response.completed",
792
- "response": {"output": [item]},
793
- }
794
- ),
795
- CliParseState(),
796
- )
797
- )
798
-
799
- assert events == [
800
- {
801
- "type": "assistant",
802
- "message": {"content": [{"type": "text", "text": "hi"}]},
803
- }
804
- ]
805
-
806
-
807
- def test_codex_adapter_completed_output_is_fallback_for_unseen_tool_item() -> None:
808
- item = {
809
- "type": "function_call",
810
- "id": "fc_1",
811
- "call_id": "call_1",
812
- "name": "echo",
813
- "arguments": '{"value":"FCC"}',
814
- }
815
-
816
- events = list(
817
- CODEX_CLI_ADAPTER.parse_stdout_line(
818
- json.dumps(
819
- {
820
- "type": "response.completed",
821
- "response": {"output": [item]},
822
- }
823
- ),
824
- CliParseState(),
825
- )
826
- )
827
-
828
- assert events == [
829
- {
830
- "type": "assistant",
831
- "message": {
832
- "content": [
833
- {
834
- "type": "tool_use",
835
- "id": "call_1",
836
- "name": "echo",
837
- "input": {"value": "FCC"},
838
- }
839
- ]
840
- },
841
- }
842
- ]
843
-
844
-
845
- def test_codex_adapter_reasoning_summary_delta_remains_raw() -> None:
846
- events = list(
847
- CODEX_CLI_ADAPTER.parse_stdout_line(
848
- (
849
- '{"type":"response.reasoning_summary_text.delta",'
850
- '"delta":"summary","thread_id":"t1"}'
851
- ),
852
- CliParseState(),
853
- )
854
- )
855
-
856
- assert events == [
857
- {"type": "session_info", "session_id": "t1"},
858
- {
859
- "type": "raw",
860
- "content": (
861
- '{"type":"response.reasoning_summary_text.delta",'
862
- '"delta":"summary","thread_id":"t1"}'
863
- ),
864
- },
865
- ]
866
-
867
-
868
- def test_codex_adapter_unknown_json_becomes_raw_event() -> None:
869
- events = list(
870
- CODEX_CLI_ADAPTER.parse_stdout_line(
871
- '{"type":"thread.started","thread_id":"t1"}',
872
- CliParseState(),
873
- )
874
- )
875
-
876
- assert events == [
877
- {"type": "session_info", "session_id": "t1"},
878
- {"type": "raw", "content": '{"type":"thread.started","thread_id":"t1"}'},
879
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/cli/test_cli.py CHANGED
@@ -3,8 +3,7 @@
3
  import asyncio
4
  import json
5
  import os
6
- from collections.abc import Iterable, Mapping
7
- from typing import Any, cast
8
  from unittest.mock import AsyncMock, MagicMock, patch
9
 
10
  import pytest
@@ -147,14 +146,14 @@ class TestCLIParser:
147
  # --- CLI Session Tests ---
148
 
149
 
150
- class TestCLISession:
151
- """Test CLISession."""
152
 
153
  def test_session_init(self):
154
- """Test CLISession initialization."""
155
- from cli.session import CLISession
156
 
157
- session = CLISession(
158
  workspace_path="/tmp/test",
159
  api_url="http://localhost:8082/v1",
160
  allowed_dirs=["/home/user/projects"],
@@ -165,42 +164,40 @@ class TestCLISession:
165
 
166
  def test_session_extract_session_id(self):
167
  """Test session ID extraction from various event formats."""
168
- from cli.session import CLISession
169
-
170
- session = CLISession("/tmp", "http://localhost:8082/v1")
171
 
172
  # Direct session_id field
173
- assert session._extract_session_id({"session_id": "abc123"}) == "abc123"
174
- assert session._extract_session_id({"sessionId": "abc123"}) == "abc123"
175
 
176
  # Nested in init
177
  assert (
178
- session._extract_session_id({"init": {"session_id": "nested123"}})
179
  == "nested123"
180
  )
181
 
182
  # Nested in result
183
  assert (
184
- session._extract_session_id({"result": {"session_id": "res123"}})
185
  == "res123"
186
  )
187
 
188
  # Conversation id
189
  assert (
190
- session._extract_session_id({"conversation": {"id": "conv123"}})
191
  == "conv123"
192
  )
193
 
194
  # No session ID
195
- assert session._extract_session_id({"type": "message"}) is None
196
- assert session._extract_session_id("not a dict") is None
197
 
198
  @pytest.mark.asyncio
199
  async def test_start_task_basic_flow(self):
200
  """Test start_task running a basic command flow."""
201
- from cli.session import CLISession
202
 
203
- session = CLISession("/tmp", "http://localhost:8082/v1")
204
 
205
  # Mock subprocess
206
  mock_process = AsyncMock()
@@ -242,9 +239,9 @@ class TestCLISession:
242
  @pytest.mark.asyncio
243
  async def test_start_task_with_session_resume(self):
244
  """Test resuming an existing session."""
245
- from cli.session import CLISession
246
 
247
- session = CLISession("/tmp", "http://localhost:8082/v1")
248
 
249
  mock_process = AsyncMock()
250
  mock_process.stdout.read.side_effect = [
@@ -269,9 +266,9 @@ class TestCLISession:
269
  @pytest.mark.asyncio
270
  async def test_start_task_with_session_resume_and_fork(self):
271
  """Test resuming an existing session and forking."""
272
- from cli.session import CLISession
273
 
274
- session = CLISession("/tmp", "http://localhost:8082/v1")
275
 
276
  mock_process = AsyncMock()
277
  mock_process.stdout.read.side_effect = [b""] # Immediate EOF
@@ -296,9 +293,9 @@ class TestCLISession:
296
  @pytest.mark.asyncio
297
  async def test_start_task_process_failure_with_stderr(self):
298
  """Test process exit with error code and stderr output."""
299
- from cli.session import CLISession
300
 
301
- session = CLISession("/tmp", "http://localhost:8082/v1")
302
 
303
  mock_process = AsyncMock()
304
  mock_process.stdout.read.side_effect = [b""] # No stdout
@@ -324,9 +321,9 @@ class TestCLISession:
324
  @pytest.mark.asyncio
325
  async def test_start_task_stderr_while_stdout_streams(self):
326
  """Stderr is drained concurrently so stdout streaming is not blocked."""
327
- from cli.session import CLISession
328
 
329
- session = CLISession("/tmp", "http://localhost:8082/v1")
330
 
331
  mock_process = AsyncMock()
332
  mock_process.stdout.read.side_effect = [
@@ -353,7 +350,7 @@ class TestCLISession:
353
  @pytest.mark.asyncio
354
  async def test_drain_stderr_bounded_retains_cap_but_drains_to_eof(self):
355
  """Oversized stderr is fully drained so the pipe cannot deadlock; capture is bounded."""
356
- from cli.session import _MAX_STDERR_CAPTURE_BYTES, CLISession
357
 
358
  total_len = _MAX_STDERR_CAPTURE_BYTES + 100_000
359
  remaining: dict[str, int] = {"n": total_len}
@@ -370,7 +367,7 @@ class TestCLISession:
370
  class _FakeProcess:
371
  stderr = _FakeStderr()
372
 
373
- out = await CLISession._drain_stderr_bounded(
374
  cast(asyncio.subprocess.Process, _FakeProcess())
375
  )
376
  assert len(out) == _MAX_STDERR_CAPTURE_BYTES
@@ -380,9 +377,9 @@ class TestCLISession:
380
  @pytest.mark.asyncio
381
  async def test_stop_session(self):
382
  """Test stopping the session process."""
383
- from cli.session import CLISession
384
 
385
- session = CLISession("/tmp", "http://localhost:8082/v1")
386
 
387
  mock_process = MagicMock()
388
  mock_process.returncode = None # Running
@@ -391,7 +388,7 @@ class TestCLISession:
391
 
392
  session.process = mock_process
393
 
394
- with patch("cli.session.kill_pid_tree_best_effort") as kill_tree:
395
  stopped = await session.stop()
396
 
397
  assert stopped is True
@@ -401,9 +398,9 @@ class TestCLISession:
401
  @pytest.mark.asyncio
402
  async def test_stop_session_timeout_force_kill(self):
403
  """Test force kill if terminate times out."""
404
- from cli.session import CLISession
405
 
406
- session = CLISession("/tmp", "http://localhost:8082/v1")
407
 
408
  mock_process = MagicMock()
409
  mock_process.returncode = None
@@ -419,7 +416,7 @@ class TestCLISession:
419
 
420
  session.process = mock_process
421
 
422
- with patch("cli.session.kill_pid_tree_best_effort") as kill_tree:
423
  stopped = await session.stop()
424
 
425
  assert stopped is True
@@ -429,9 +426,9 @@ class TestCLISession:
429
  @pytest.mark.asyncio
430
  async def test_start_task_split_buffer(self):
431
  """Test handling of JSON split across chunks."""
432
- from cli.session import CLISession
433
 
434
- session = CLISession("/tmp", "http://localhost:8082/v1")
435
 
436
  mock_process = AsyncMock()
437
  # Split json: {"type": "mess... age"}
@@ -458,9 +455,9 @@ class TestCLISession:
458
  @pytest.mark.asyncio
459
  async def test_start_task_remnant_buffer(self):
460
  """Test handling of buffer remnant at EOF (no newline at end)."""
461
- from cli.session import CLISession
462
 
463
- session = CLISession("/tmp", "http://localhost:8082/v1")
464
 
465
  mock_process = AsyncMock()
466
  mock_process.stdout.read.side_effect = [
@@ -485,10 +482,10 @@ class TestCLISession:
485
  @pytest.mark.asyncio
486
  async def test_start_task_non_v1_url(self):
487
  """Test start_task with a non-v1 URL."""
488
- from cli.session import CLISession
489
 
490
  # URL not ending in /v1
491
- session = CLISession("/tmp", "http://localhost:8082")
492
 
493
  mock_process = AsyncMock()
494
  mock_process.stdout.read.side_effect = [b""]
@@ -510,9 +507,9 @@ class TestCLISession:
510
  @pytest.mark.asyncio
511
  async def test_start_task_sets_proxy_auth_token(self):
512
  """Test start_task forwards configured proxy auth to Claude Code."""
513
- from cli.session import CLISession
514
 
515
- session = CLISession(
516
  "/tmp", "http://localhost:8082/v1", auth_token="proxy-token"
517
  )
518
 
@@ -540,9 +537,11 @@ class TestCLISession:
540
  @pytest.mark.asyncio
541
  async def test_start_task_uses_sentinel_when_proxy_auth_blank(self):
542
  """Test start_task does not leak inherited Claude auth into proxy calls."""
543
- from cli.session import CLISession
544
 
545
- session = CLISession("/tmp", "http://localhost:8082/v1", auth_token="")
 
 
546
 
547
  mock_process = AsyncMock()
548
  mock_process.stdout.read.side_effect = [b""]
@@ -565,9 +564,9 @@ class TestCLISession:
565
  @pytest.mark.asyncio
566
  async def test_start_task_allowed_dirs(self):
567
  """Test start_task includes allowed dirs in command."""
568
- from cli.session import CLISession
569
 
570
- session = CLISession(
571
  "/tmp", "http://localhost:8082/v1", allowed_dirs=["/dir1", "/dir2"]
572
  )
573
 
@@ -591,9 +590,9 @@ class TestCLISession:
591
  @pytest.mark.asyncio
592
  async def test_start_task_plans_directory(self):
593
  """Test start_task includes --settings plansDirectory when plans_directory set."""
594
- from cli.session import CLISession
595
 
596
- session = CLISession(
597
  "/tmp",
598
  "http://localhost:8082/v1",
599
  plans_directory="./agent_workspace/plans",
@@ -621,9 +620,9 @@ class TestCLISession:
621
  @pytest.mark.asyncio
622
  async def test_start_task_json_error(self):
623
  """Test handling of non-JSON output from CLI."""
624
- from cli.session import CLISession
625
 
626
- session = CLISession("/tmp", "http://localhost:8082/v1")
627
 
628
  mock_process = AsyncMock()
629
  mock_process.stdout.read.side_effect = [b"Not valid json\n", b""]
@@ -640,123 +639,12 @@ class TestCLISession:
640
  assert len(events) == 1
641
  assert events[0]["content"] == "Not valid json"
642
 
643
- @pytest.mark.asyncio
644
- async def test_start_task_uses_injected_client_cli_adapter(self, tmp_path):
645
- """CLISession delegates argv/env/line parsing to the injected adapter."""
646
- from cli.adapters.base import CliInvocation, CliParseState, CliTaskRequest
647
- from cli.session import CLISession
648
-
649
- class FakeClientCliAdapter:
650
- id = "fake"
651
- display_name = "Fake CLI"
652
- default_binary = "fake"
653
- install_hint = "Install Fake CLI"
654
- trace_stage = "claude_cli"
655
- process_launch_event = "claude_cli.process.launch"
656
- trace_source = "claude_cli"
657
-
658
- def __init__(self) -> None:
659
- self.request: CliTaskRequest | None = None
660
-
661
- def build_task_invocation(
662
- self,
663
- *,
664
- config: Any,
665
- request: CliTaskRequest,
666
- base_env: Mapping[str, str],
667
- ) -> CliInvocation:
668
- self.request = request
669
- return CliInvocation(
670
- argv=("fake-cli", "--prompt", request.prompt),
671
- env={"FAKE_ENV": base_env.get("KEEP_ME", "")},
672
- cwd=config.workspace_path,
673
- trace_metadata={"client_cli_id": self.id},
674
- )
675
-
676
- def parse_stdout_line(
677
- self, line: str, state: CliParseState
678
- ) -> Iterable[dict[str, Any]]:
679
- if not state.session_id_extracted:
680
- state.session_id_extracted = True
681
- yield {"type": "session_info", "session_id": "fake_session"}
682
- yield {"type": "message", "content": line}
683
-
684
- def extract_session_id(self, event: Any) -> str | None:
685
- if isinstance(event, dict):
686
- value = event.get("session_id")
687
- return value if isinstance(value, str) else None
688
- return None
689
-
690
- def get_launcher_binary_name(self, settings: Any) -> str:
691
- return self.default_binary
692
-
693
- def build_launcher_command(
694
- self,
695
- *,
696
- binary_path: str,
697
- argv: Iterable[str],
698
- settings: Any,
699
- proxy_root_url: str,
700
- ) -> list[str]:
701
- return [binary_path, *argv]
702
-
703
- def build_launcher_env(
704
- self,
705
- *,
706
- proxy_root_url: str,
707
- auth_token: str,
708
- base_env: Mapping[str, str],
709
- ) -> dict[str, str]:
710
- return dict(base_env)
711
-
712
- adapter = FakeClientCliAdapter()
713
- session = CLISession(
714
- str(tmp_path),
715
- "http://localhost:8082/v1",
716
- client_cli_adapter=adapter,
717
- )
718
-
719
- mock_process = AsyncMock()
720
- mock_process.stdout.read.side_effect = [b"hello\n", b""]
721
- mock_process.stderr.read.return_value = b""
722
- mock_process.wait.return_value = 0
723
- mock_process.returncode = 0
724
-
725
- with (
726
- patch.dict(os.environ, {"KEEP_ME": "yes"}, clear=False),
727
- patch(
728
- "asyncio.create_subprocess_exec", new_callable=AsyncMock
729
- ) as mock_exec,
730
- ):
731
- mock_exec.return_value = mock_process
732
- events = [
733
- e
734
- async for e in session.start_task(
735
- "adapter prompt",
736
- session_id="sess_fake",
737
- fork_session=True,
738
- )
739
- ]
740
-
741
- assert adapter.request == CliTaskRequest(
742
- prompt="adapter prompt",
743
- session_id="sess_fake",
744
- fork_session=True,
745
- )
746
- assert mock_exec.call_args.args == ("fake-cli", "--prompt", "adapter prompt")
747
- assert mock_exec.call_args.kwargs["env"] == {"FAKE_ENV": "yes"}
748
- assert session.current_session_id == "fake_session"
749
- assert events[:2] == [
750
- {"type": "session_info", "session_id": "fake_session"},
751
- {"type": "message", "content": "hello"},
752
- ]
753
-
754
  @pytest.mark.asyncio
755
  async def test_stop_exception(self):
756
  """Test exception handling during stop."""
757
- from cli.session import CLISession
758
 
759
- session = CLISession("/tmp", "http://localhost:8082/v1")
760
 
761
  mock_process = MagicMock()
762
  mock_process.returncode = None
@@ -764,22 +652,22 @@ class TestCLISession:
764
  session.process = mock_process
765
 
766
  with patch(
767
- "cli.session.kill_pid_tree_best_effort",
768
  side_effect=RuntimeError("Permission denied"),
769
  ):
770
  stopped = await session.stop()
771
  assert stopped is False
772
 
773
 
774
- class TestCLISessionManager:
775
- """Test CLISessionManager."""
776
 
777
  @pytest.mark.asyncio
778
  async def test_manager_create_session(self):
779
  """Test creating a new session."""
780
- from cli.manager import CLISessionManager
781
 
782
- manager = CLISessionManager(
783
  workspace_path="/tmp/test",
784
  api_url="http://localhost:8082/v1",
785
  )
@@ -792,9 +680,9 @@ class TestCLISessionManager:
792
  @pytest.mark.asyncio
793
  async def test_manager_reuse_session(self):
794
  """Test reusing an existing session."""
795
- from cli.manager import CLISessionManager
796
 
797
- manager = CLISessionManager(
798
  workspace_path="/tmp/test",
799
  api_url="http://localhost:8082/v1",
800
  )
@@ -811,9 +699,9 @@ class TestCLISessionManager:
811
  @pytest.mark.asyncio
812
  async def test_manager_stats(self):
813
  """Test manager stats."""
814
- from cli.manager import CLISessionManager
815
 
816
- manager = CLISessionManager(
817
  workspace_path="/tmp/test",
818
  api_url="http://localhost:8082/v1",
819
  )
 
3
  import asyncio
4
  import json
5
  import os
6
+ from typing import cast
 
7
  from unittest.mock import AsyncMock, MagicMock, patch
8
 
9
  import pytest
 
146
  # --- CLI Session Tests ---
147
 
148
 
149
+ class TestManagedClaudeSession:
150
+ """Test ManagedClaudeSession."""
151
 
152
  def test_session_init(self):
153
+ """Test ManagedClaudeSession initialization."""
154
+ from cli.managed.session import ManagedClaudeSession
155
 
156
+ session = ManagedClaudeSession(
157
  workspace_path="/tmp/test",
158
  api_url="http://localhost:8082/v1",
159
  allowed_dirs=["/home/user/projects"],
 
164
 
165
  def test_session_extract_session_id(self):
166
  """Test session ID extraction from various event formats."""
167
+ from cli.managed.claude import extract_managed_claude_session_id
 
 
168
 
169
  # Direct session_id field
170
+ assert extract_managed_claude_session_id({"session_id": "abc123"}) == "abc123"
171
+ assert extract_managed_claude_session_id({"sessionId": "abc123"}) == "abc123"
172
 
173
  # Nested in init
174
  assert (
175
+ extract_managed_claude_session_id({"init": {"session_id": "nested123"}})
176
  == "nested123"
177
  )
178
 
179
  # Nested in result
180
  assert (
181
+ extract_managed_claude_session_id({"result": {"session_id": "res123"}})
182
  == "res123"
183
  )
184
 
185
  # Conversation id
186
  assert (
187
+ extract_managed_claude_session_id({"conversation": {"id": "conv123"}})
188
  == "conv123"
189
  )
190
 
191
  # No session ID
192
+ assert extract_managed_claude_session_id({"type": "message"}) is None
193
+ assert extract_managed_claude_session_id("not a dict") is None
194
 
195
  @pytest.mark.asyncio
196
  async def test_start_task_basic_flow(self):
197
  """Test start_task running a basic command flow."""
198
+ from cli.managed.session import ManagedClaudeSession
199
 
200
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
201
 
202
  # Mock subprocess
203
  mock_process = AsyncMock()
 
239
  @pytest.mark.asyncio
240
  async def test_start_task_with_session_resume(self):
241
  """Test resuming an existing session."""
242
+ from cli.managed.session import ManagedClaudeSession
243
 
244
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
245
 
246
  mock_process = AsyncMock()
247
  mock_process.stdout.read.side_effect = [
 
266
  @pytest.mark.asyncio
267
  async def test_start_task_with_session_resume_and_fork(self):
268
  """Test resuming an existing session and forking."""
269
+ from cli.managed.session import ManagedClaudeSession
270
 
271
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
272
 
273
  mock_process = AsyncMock()
274
  mock_process.stdout.read.side_effect = [b""] # Immediate EOF
 
293
  @pytest.mark.asyncio
294
  async def test_start_task_process_failure_with_stderr(self):
295
  """Test process exit with error code and stderr output."""
296
+ from cli.managed.session import ManagedClaudeSession
297
 
298
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
299
 
300
  mock_process = AsyncMock()
301
  mock_process.stdout.read.side_effect = [b""] # No stdout
 
321
  @pytest.mark.asyncio
322
  async def test_start_task_stderr_while_stdout_streams(self):
323
  """Stderr is drained concurrently so stdout streaming is not blocked."""
324
+ from cli.managed.session import ManagedClaudeSession
325
 
326
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
327
 
328
  mock_process = AsyncMock()
329
  mock_process.stdout.read.side_effect = [
 
350
  @pytest.mark.asyncio
351
  async def test_drain_stderr_bounded_retains_cap_but_drains_to_eof(self):
352
  """Oversized stderr is fully drained so the pipe cannot deadlock; capture is bounded."""
353
+ from cli.managed.session import _MAX_STDERR_CAPTURE_BYTES, ManagedClaudeSession
354
 
355
  total_len = _MAX_STDERR_CAPTURE_BYTES + 100_000
356
  remaining: dict[str, int] = {"n": total_len}
 
367
  class _FakeProcess:
368
  stderr = _FakeStderr()
369
 
370
+ out = await ManagedClaudeSession._drain_stderr_bounded(
371
  cast(asyncio.subprocess.Process, _FakeProcess())
372
  )
373
  assert len(out) == _MAX_STDERR_CAPTURE_BYTES
 
377
  @pytest.mark.asyncio
378
  async def test_stop_session(self):
379
  """Test stopping the session process."""
380
+ from cli.managed.session import ManagedClaudeSession
381
 
382
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
383
 
384
  mock_process = MagicMock()
385
  mock_process.returncode = None # Running
 
388
 
389
  session.process = mock_process
390
 
391
+ with patch("cli.managed.session.kill_pid_tree_best_effort") as kill_tree:
392
  stopped = await session.stop()
393
 
394
  assert stopped is True
 
398
  @pytest.mark.asyncio
399
  async def test_stop_session_timeout_force_kill(self):
400
  """Test force kill if terminate times out."""
401
+ from cli.managed.session import ManagedClaudeSession
402
 
403
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
404
 
405
  mock_process = MagicMock()
406
  mock_process.returncode = None
 
416
 
417
  session.process = mock_process
418
 
419
+ with patch("cli.managed.session.kill_pid_tree_best_effort") as kill_tree:
420
  stopped = await session.stop()
421
 
422
  assert stopped is True
 
426
  @pytest.mark.asyncio
427
  async def test_start_task_split_buffer(self):
428
  """Test handling of JSON split across chunks."""
429
+ from cli.managed.session import ManagedClaudeSession
430
 
431
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
432
 
433
  mock_process = AsyncMock()
434
  # Split json: {"type": "mess... age"}
 
455
  @pytest.mark.asyncio
456
  async def test_start_task_remnant_buffer(self):
457
  """Test handling of buffer remnant at EOF (no newline at end)."""
458
+ from cli.managed.session import ManagedClaudeSession
459
 
460
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
461
 
462
  mock_process = AsyncMock()
463
  mock_process.stdout.read.side_effect = [
 
482
  @pytest.mark.asyncio
483
  async def test_start_task_non_v1_url(self):
484
  """Test start_task with a non-v1 URL."""
485
+ from cli.managed.session import ManagedClaudeSession
486
 
487
  # URL not ending in /v1
488
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082")
489
 
490
  mock_process = AsyncMock()
491
  mock_process.stdout.read.side_effect = [b""]
 
507
  @pytest.mark.asyncio
508
  async def test_start_task_sets_proxy_auth_token(self):
509
  """Test start_task forwards configured proxy auth to Claude Code."""
510
+ from cli.managed.session import ManagedClaudeSession
511
 
512
+ session = ManagedClaudeSession(
513
  "/tmp", "http://localhost:8082/v1", auth_token="proxy-token"
514
  )
515
 
 
537
  @pytest.mark.asyncio
538
  async def test_start_task_uses_sentinel_when_proxy_auth_blank(self):
539
  """Test start_task does not leak inherited Claude auth into proxy calls."""
540
+ from cli.managed.session import ManagedClaudeSession
541
 
542
+ session = ManagedClaudeSession(
543
+ "/tmp", "http://localhost:8082/v1", auth_token=""
544
+ )
545
 
546
  mock_process = AsyncMock()
547
  mock_process.stdout.read.side_effect = [b""]
 
564
  @pytest.mark.asyncio
565
  async def test_start_task_allowed_dirs(self):
566
  """Test start_task includes allowed dirs in command."""
567
+ from cli.managed.session import ManagedClaudeSession
568
 
569
+ session = ManagedClaudeSession(
570
  "/tmp", "http://localhost:8082/v1", allowed_dirs=["/dir1", "/dir2"]
571
  )
572
 
 
590
  @pytest.mark.asyncio
591
  async def test_start_task_plans_directory(self):
592
  """Test start_task includes --settings plansDirectory when plans_directory set."""
593
+ from cli.managed.session import ManagedClaudeSession
594
 
595
+ session = ManagedClaudeSession(
596
  "/tmp",
597
  "http://localhost:8082/v1",
598
  plans_directory="./agent_workspace/plans",
 
620
  @pytest.mark.asyncio
621
  async def test_start_task_json_error(self):
622
  """Test handling of non-JSON output from CLI."""
623
+ from cli.managed.session import ManagedClaudeSession
624
 
625
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
626
 
627
  mock_process = AsyncMock()
628
  mock_process.stdout.read.side_effect = [b"Not valid json\n", b""]
 
639
  assert len(events) == 1
640
  assert events[0]["content"] == "Not valid json"
641
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  @pytest.mark.asyncio
643
  async def test_stop_exception(self):
644
  """Test exception handling during stop."""
645
+ from cli.managed.session import ManagedClaudeSession
646
 
647
+ session = ManagedClaudeSession("/tmp", "http://localhost:8082/v1")
648
 
649
  mock_process = MagicMock()
650
  mock_process.returncode = None
 
652
  session.process = mock_process
653
 
654
  with patch(
655
+ "cli.managed.session.kill_pid_tree_best_effort",
656
  side_effect=RuntimeError("Permission denied"),
657
  ):
658
  stopped = await session.stop()
659
  assert stopped is False
660
 
661
 
662
+ class TestManagedClaudeSessionManager:
663
+ """Test ManagedClaudeSessionManager."""
664
 
665
  @pytest.mark.asyncio
666
  async def test_manager_create_session(self):
667
  """Test creating a new session."""
668
+ from cli.managed.manager import ManagedClaudeSessionManager
669
 
670
+ manager = ManagedClaudeSessionManager(
671
  workspace_path="/tmp/test",
672
  api_url="http://localhost:8082/v1",
673
  )
 
680
  @pytest.mark.asyncio
681
  async def test_manager_reuse_session(self):
682
  """Test reusing an existing session."""
683
+ from cli.managed.manager import ManagedClaudeSessionManager
684
 
685
+ manager = ManagedClaudeSessionManager(
686
  workspace_path="/tmp/test",
687
  api_url="http://localhost:8082/v1",
688
  )
 
699
  @pytest.mark.asyncio
700
  async def test_manager_stats(self):
701
  """Test manager stats."""
702
+ from cli.managed.manager import ManagedClaudeSessionManager
703
 
704
+ manager = ManagedClaudeSessionManager(
705
  workspace_path="/tmp/test",
706
  api_url="http://localhost:8082/v1",
707
  )
tests/cli/test_cli_manager_edge_cases.py CHANGED
@@ -3,23 +3,17 @@ from unittest.mock import AsyncMock, MagicMock, patch
3
  import pytest
4
 
5
 
6
- def _fake_client_cli_adapter():
7
- adapter = MagicMock()
8
- adapter.id = "fake"
9
- return adapter
10
-
11
-
12
  @pytest.mark.asyncio
13
  async def test_register_real_session_id_moves_pending_to_active_and_maps():
14
- from cli.manager import CLISessionManager
15
 
16
- with patch("cli.manager.CLISession") as mock_session_cls:
17
  mock_session = MagicMock()
18
  mock_session.is_busy = False
19
  mock_session.stop = AsyncMock(return_value=True)
20
  mock_session_cls.return_value = mock_session
21
 
22
- manager = CLISessionManager(
23
  workspace_path="/tmp", api_url="http://x/v1", auth_token="proxy-token"
24
  )
25
  session, temp_id, is_new = await manager.get_or_create_session()
@@ -40,24 +34,26 @@ async def test_register_real_session_id_moves_pending_to_active_and_maps():
40
 
41
  @pytest.mark.asyncio
42
  async def test_register_real_session_id_missing_temp_id_returns_false():
43
- from cli.manager import CLISessionManager
44
 
45
- manager = CLISessionManager(workspace_path="/tmp", api_url="http://x/v1")
46
  ok = await manager.register_real_session_id("missing", "real_1")
47
  assert ok is False
48
 
49
 
50
  @pytest.mark.asyncio
51
  async def test_remove_session_pending_stops_and_returns_true():
52
- from cli.manager import CLISessionManager
53
 
54
- with patch("cli.manager.CLISession") as mock_session_cls:
55
  mock_session = MagicMock()
56
  mock_session.is_busy = False
57
  mock_session.stop = AsyncMock(return_value=True)
58
  mock_session_cls.return_value = mock_session
59
 
60
- manager = CLISessionManager(workspace_path="/tmp", api_url="http://x/v1")
 
 
61
  _, temp_id, _ = await manager.get_or_create_session()
62
 
63
  removed = await manager.remove_session(temp_id)
@@ -67,15 +63,17 @@ async def test_remove_session_pending_stops_and_returns_true():
67
 
68
  @pytest.mark.asyncio
69
  async def test_remove_session_active_removes_temp_mapping():
70
- from cli.manager import CLISessionManager
71
 
72
- with patch("cli.manager.CLISession") as mock_session_cls:
73
  mock_session = MagicMock()
74
  mock_session.is_busy = False
75
  mock_session.stop = AsyncMock(return_value=True)
76
  mock_session_cls.return_value = mock_session
77
 
78
- manager = CLISessionManager(workspace_path="/tmp", api_url="http://x/v1")
 
 
79
  _, temp_id, _ = await manager.get_or_create_session()
80
  await manager.register_real_session_id(temp_id, "real_1")
81
 
@@ -90,9 +88,9 @@ async def test_remove_session_active_removes_temp_mapping():
90
 
91
  @pytest.mark.asyncio
92
  async def test_stop_all_handles_stop_exceptions():
93
- from cli.manager import CLISessionManager
94
 
95
- manager = CLISessionManager(workspace_path="/tmp", api_url="http://x/v1")
96
 
97
  s1 = MagicMock()
98
  s1.stop = AsyncMock(side_effect=RuntimeError("boom"))
@@ -110,23 +108,3 @@ async def test_stop_all_handles_stop_exceptions():
110
  s2.stop.assert_awaited_once()
111
  assert manager.get_stats()["active_sessions"] == 0
112
  assert manager.get_stats()["pending_sessions"] == 0
113
-
114
-
115
- @pytest.mark.asyncio
116
- async def test_get_or_create_session_passes_client_cli_adapter():
117
- from cli.manager import CLISessionManager
118
-
119
- adapter = _fake_client_cli_adapter()
120
- with patch("cli.manager.CLISession") as mock_session_cls:
121
- mock_session = MagicMock()
122
- mock_session.is_busy = False
123
- mock_session_cls.return_value = mock_session
124
-
125
- manager = CLISessionManager(
126
- workspace_path="/tmp",
127
- api_url="http://x/v1",
128
- client_cli_adapter=adapter,
129
- )
130
- await manager.get_or_create_session()
131
-
132
- assert mock_session_cls.call_args.kwargs["client_cli_adapter"] is adapter
 
3
  import pytest
4
 
5
 
 
 
 
 
 
 
6
  @pytest.mark.asyncio
7
  async def test_register_real_session_id_moves_pending_to_active_and_maps():
8
+ from cli.managed.manager import ManagedClaudeSessionManager
9
 
10
+ with patch("cli.managed.manager.ManagedClaudeSession") as mock_session_cls:
11
  mock_session = MagicMock()
12
  mock_session.is_busy = False
13
  mock_session.stop = AsyncMock(return_value=True)
14
  mock_session_cls.return_value = mock_session
15
 
16
+ manager = ManagedClaudeSessionManager(
17
  workspace_path="/tmp", api_url="http://x/v1", auth_token="proxy-token"
18
  )
19
  session, temp_id, is_new = await manager.get_or_create_session()
 
34
 
35
  @pytest.mark.asyncio
36
  async def test_register_real_session_id_missing_temp_id_returns_false():
37
+ from cli.managed.manager import ManagedClaudeSessionManager
38
 
39
+ manager = ManagedClaudeSessionManager(workspace_path="/tmp", api_url="http://x/v1")
40
  ok = await manager.register_real_session_id("missing", "real_1")
41
  assert ok is False
42
 
43
 
44
  @pytest.mark.asyncio
45
  async def test_remove_session_pending_stops_and_returns_true():
46
+ from cli.managed.manager import ManagedClaudeSessionManager
47
 
48
+ with patch("cli.managed.manager.ManagedClaudeSession") as mock_session_cls:
49
  mock_session = MagicMock()
50
  mock_session.is_busy = False
51
  mock_session.stop = AsyncMock(return_value=True)
52
  mock_session_cls.return_value = mock_session
53
 
54
+ manager = ManagedClaudeSessionManager(
55
+ workspace_path="/tmp", api_url="http://x/v1"
56
+ )
57
  _, temp_id, _ = await manager.get_or_create_session()
58
 
59
  removed = await manager.remove_session(temp_id)
 
63
 
64
  @pytest.mark.asyncio
65
  async def test_remove_session_active_removes_temp_mapping():
66
+ from cli.managed.manager import ManagedClaudeSessionManager
67
 
68
+ with patch("cli.managed.manager.ManagedClaudeSession") as mock_session_cls:
69
  mock_session = MagicMock()
70
  mock_session.is_busy = False
71
  mock_session.stop = AsyncMock(return_value=True)
72
  mock_session_cls.return_value = mock_session
73
 
74
+ manager = ManagedClaudeSessionManager(
75
+ workspace_path="/tmp", api_url="http://x/v1"
76
+ )
77
  _, temp_id, _ = await manager.get_or_create_session()
78
  await manager.register_real_session_id(temp_id, "real_1")
79
 
 
88
 
89
  @pytest.mark.asyncio
90
  async def test_stop_all_handles_stop_exceptions():
91
+ from cli.managed.manager import ManagedClaudeSessionManager
92
 
93
+ manager = ManagedClaudeSessionManager(workspace_path="/tmp", api_url="http://x/v1")
94
 
95
  s1 = MagicMock()
96
  s1.stop = AsyncMock(side_effect=RuntimeError("boom"))
 
108
  s2.stop.assert_awaited_once()
109
  assert manager.get_stats()["active_sessions"] == 0
110
  assert manager.get_stats()["pending_sessions"] == 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/cli/test_cli_ownership.py CHANGED
@@ -2,11 +2,11 @@ from __future__ import annotations
2
 
3
  from pathlib import Path
4
 
5
- from cli.session import CLISession
6
 
7
 
8
  def test_cli_session_owns_typed_runner_config(tmp_path: Path) -> None:
9
- session = CLISession(
10
  workspace_path=str(tmp_path),
11
  api_url="http://127.0.0.1:8082/v1",
12
  allowed_dirs=[str(tmp_path)],
 
2
 
3
  from pathlib import Path
4
 
5
+ from cli.managed.session import ManagedClaudeSession
6
 
7
 
8
  def test_cli_session_owns_typed_runner_config(tmp_path: Path) -> None:
9
+ session = ManagedClaudeSession(
10
  workspace_path=str(tmp_path),
11
  api_url="http://127.0.0.1:8082/v1",
12
  allowed_dirs=[str(tmp_path)],
tests/cli/test_codex_model_catalog.py CHANGED
@@ -9,7 +9,7 @@ from typing import Any, cast
9
 
10
  import pytest
11
 
12
- from cli.codex_model_catalog import (
13
  build_codex_model_catalog,
14
  write_codex_model_catalog,
15
  )
 
9
 
10
  import pytest
11
 
12
+ from cli.launchers.codex_model_catalog import (
13
  build_codex_model_catalog,
14
  write_codex_model_catalog,
15
  )
tests/cli/test_entrypoints.py CHANGED
@@ -174,8 +174,8 @@ def test_cli_scripts_are_registered() -> None:
174
  scripts = pyproject["project"]["scripts"]
175
  assert scripts["fcc-server"] == "cli.entrypoints:serve"
176
  assert scripts["free-claude-code"] == "cli.entrypoints:serve"
177
- assert scripts["fcc-claude"] == "cli.entrypoints:launch_claude"
178
- assert scripts["fcc-codex"] == "cli.entrypoints:launch_codex"
179
 
180
 
181
  def test_schedule_open_admin_browser_opens_when_health_ready(
@@ -199,7 +199,7 @@ def test_schedule_open_admin_browser_opens_when_health_ready(
199
 
200
  with (
201
  patch.object(entrypoints.threading, "Thread", ImmediateThread),
202
- patch.object(entrypoints, "_preflight_proxy", return_value=None),
203
  patch.object(
204
  entrypoints.webbrowser,
205
  "open",
@@ -309,11 +309,12 @@ def test_serve_handles_keyboard_interrupt_without_traceback() -> None:
309
 
310
 
311
  def test_claude_child_env_targets_current_proxy_config() -> None:
312
- from cli.entrypoints import _claude_child_env
313
 
314
- env = _claude_child_env(
315
- _launcher_settings(port=9090, token=" proxy-token "),
316
- {
 
317
  "PATH": "keep",
318
  "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
319
  "ANTHROPIC_AUTH_TOKEN": "old-token",
@@ -330,11 +331,12 @@ def test_claude_child_env_targets_current_proxy_config() -> None:
330
 
331
 
332
  def test_claude_child_env_uses_sentinel_for_blank_configured_auth_token() -> None:
333
- from cli.entrypoints import _claude_child_env
334
 
335
- env = _claude_child_env(
336
- _launcher_settings(token=""),
337
- {
 
338
  "ANTHROPIC_AUTH_TOKEN": "inherited-token",
339
  "ANTHROPIC_API_KEY": "official-key",
340
  },
@@ -347,7 +349,7 @@ def test_claude_child_env_uses_sentinel_for_blank_configured_auth_token() -> Non
347
  def test_launch_claude_passes_args_and_child_env(
348
  monkeypatch: pytest.MonkeyPatch,
349
  ) -> None:
350
- from cli.entrypoints import launch_claude
351
 
352
  monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
353
  monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "old-token")
@@ -355,19 +357,18 @@ def test_launch_claude_passes_args_and_child_env(
355
  settings = _launcher_settings(port=9191, token="proxy-token")
356
 
357
  with (
358
- patch("cli.entrypoints.get_settings", return_value=settings),
359
- patch("cli.entrypoints._preflight_proxy", return_value=None),
360
- patch("cli.entrypoints.shutil.which", return_value="resolved-claude.cmd"),
361
- patch("cli.entrypoints.urlopen") as urlopen,
362
- patch("cli.entrypoints.subprocess.Popen") as popen,
363
- patch("cli.entrypoints.register_pid") as register_pid,
364
- patch("cli.entrypoints.unregister_pid") as unregister_pid,
365
  pytest.raises(SystemExit) as exc_info,
366
  ):
367
  process = popen.return_value
368
  process.pid = 12345
369
  process.wait.return_value = 7
370
- launch_claude(["--model", "sonnet"])
371
 
372
  assert exc_info.value.code == 7
373
  popen.assert_called_once()
@@ -380,14 +381,13 @@ def test_launch_claude_passes_args_and_child_env(
380
  assert child_env["KEEP_ME"] == "yes"
381
  register_pid.assert_called_once_with(12345)
382
  unregister_pid.assert_called_once_with(12345)
383
- urlopen.assert_not_called()
384
 
385
 
386
  def test_launch_codex_passes_responses_config_and_child_env(
387
  monkeypatch: pytest.MonkeyPatch,
388
  tmp_path: Path,
389
  ) -> None:
390
- from cli.entrypoints import launch_codex
391
 
392
  monkeypatch.setenv("OPENAI_API_KEY", "official-key")
393
  monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
@@ -419,20 +419,22 @@ def test_launch_codex_passes_responses_config_and_child_env(
419
  )
420
 
421
  with (
422
- patch("cli.entrypoints.get_settings", return_value=settings),
423
- patch("cli.entrypoints._preflight_proxy", return_value=None),
424
- patch("cli.entrypoints.shutil.which", return_value="resolved-codex.cmd"),
425
- patch("cli.entrypoints.codex_model_catalog_path", return_value=catalog_path),
426
- patch("cli.entrypoints.urlopen", side_effect=fake_urlopen),
427
- patch("cli.entrypoints.subprocess.Popen") as popen,
428
- patch("cli.entrypoints.register_pid") as register_pid,
429
- patch("cli.entrypoints.unregister_pid") as unregister_pid,
 
 
430
  pytest.raises(SystemExit) as exc_info,
431
  ):
432
  process = popen.return_value
433
  process.pid = 12345
434
  process.wait.return_value = 0
435
- launch_codex(["exec", "hello"])
436
 
437
  assert exc_info.value.code == 0
438
  command = popen.call_args.args[0]
@@ -464,28 +466,28 @@ def test_launch_codex_catalog_failure_warns_and_continues(
464
  capsys: pytest.CaptureFixture[str],
465
  tmp_path: Path,
466
  ) -> None:
467
- from cli.entrypoints import launch_codex
468
 
469
  settings = _launcher_settings(port=9191, token="proxy-token")
470
 
471
  with (
472
- patch("cli.entrypoints.get_settings", return_value=settings),
473
- patch("cli.entrypoints._preflight_proxy", return_value=None),
474
- patch("cli.entrypoints.shutil.which", return_value="resolved-codex.cmd"),
475
  patch(
476
- "cli.entrypoints.codex_model_catalog_path",
477
  return_value=tmp_path / "codex-model-catalog.json",
478
  ),
479
- patch("cli.entrypoints.urlopen", side_effect=URLError("boom")),
480
- patch("cli.entrypoints.subprocess.Popen") as popen,
481
- patch("cli.entrypoints.register_pid"),
482
- patch("cli.entrypoints.unregister_pid"),
483
  pytest.raises(SystemExit) as exc_info,
484
  ):
485
  process = popen.return_value
486
  process.pid = 12345
487
  process.wait.return_value = 0
488
- launch_codex(["exec", "hello"])
489
 
490
  assert exc_info.value.code == 0
491
  command = popen.call_args.args[0]
@@ -496,25 +498,25 @@ def test_launch_codex_catalog_failure_warns_and_continues(
496
 
497
 
498
  def test_launch_claude_keyboard_interrupt_kills_child_tree() -> None:
499
- from cli.entrypoints import launch_claude
500
 
501
  settings = _launcher_settings(port=9191, token="proxy-token")
502
 
503
  with (
504
- patch("cli.entrypoints.get_settings", return_value=settings),
505
- patch("cli.entrypoints._preflight_proxy", return_value=None),
506
- patch("cli.entrypoints.shutil.which", return_value="resolved-claude.cmd"),
507
- patch("cli.entrypoints.subprocess.Popen") as popen,
508
- patch("cli.entrypoints.register_pid"),
509
- patch("cli.entrypoints.kill_pid_tree_best_effort") as kill_tree,
510
- patch("cli.entrypoints.unregister_pid") as unregister_pid,
511
  pytest.raises(KeyboardInterrupt),
512
  ):
513
  process = popen.return_value
514
  process.pid = 12345
515
  process.wait.side_effect = [KeyboardInterrupt, 0]
516
 
517
- launch_claude([])
518
 
519
  kill_tree.assert_called_once_with(12345)
520
  unregister_pid.assert_called_once_with(12345)
@@ -523,17 +525,17 @@ def test_launch_claude_keyboard_interrupt_kills_child_tree() -> None:
523
  def test_launch_claude_exits_when_command_cannot_be_resolved(
524
  capsys: pytest.CaptureFixture[str],
525
  ) -> None:
526
- from cli.entrypoints import launch_claude
527
 
528
  settings = _launcher_settings()
529
  with (
530
- patch("cli.entrypoints.get_settings", return_value=settings),
531
- patch("cli.entrypoints._preflight_proxy", return_value=None),
532
- patch("cli.entrypoints.shutil.which", return_value=None),
533
- patch("cli.entrypoints.subprocess.Popen") as popen,
534
  pytest.raises(SystemExit) as exc_info,
535
  ):
536
- launch_claude([])
537
 
538
  assert exc_info.value.code == 127
539
  popen.assert_not_called()
@@ -545,19 +547,21 @@ def test_launch_claude_exits_when_command_cannot_be_resolved(
545
  def test_launch_claude_unreachable_proxy_exits_with_hint(
546
  capsys: pytest.CaptureFixture[str],
547
  ) -> None:
548
- from cli.entrypoints import launch_claude
549
 
550
  settings = _launcher_settings(port=9393)
551
  with (
552
- patch("cli.entrypoints.get_settings", return_value=settings),
553
- patch("cli.entrypoints._preflight_proxy", return_value="connection refused"),
554
- patch("cli.entrypoints.subprocess.run") as run,
 
 
555
  pytest.raises(SystemExit) as exc_info,
556
  ):
557
- launch_claude([])
558
 
559
  assert exc_info.value.code == 1
560
- run.assert_not_called()
561
  captured = capsys.readouterr()
562
  assert "http://127.0.0.1:9393" in captured.err
563
  assert "fcc-server" in captured.err
 
174
  scripts = pyproject["project"]["scripts"]
175
  assert scripts["fcc-server"] == "cli.entrypoints:serve"
176
  assert scripts["free-claude-code"] == "cli.entrypoints:serve"
177
+ assert scripts["fcc-claude"] == "cli.launchers.claude:launch"
178
+ assert scripts["fcc-codex"] == "cli.launchers.codex:launch"
179
 
180
 
181
  def test_schedule_open_admin_browser_opens_when_health_ready(
 
199
 
200
  with (
201
  patch.object(entrypoints.threading, "Thread", ImmediateThread),
202
+ patch.object(entrypoints, "preflight_proxy", return_value=None),
203
  patch.object(
204
  entrypoints.webbrowser,
205
  "open",
 
309
 
310
 
311
  def test_claude_child_env_targets_current_proxy_config() -> None:
312
+ from cli.launchers.claude import build_claude_launcher_env
313
 
314
+ env = build_claude_launcher_env(
315
+ proxy_root_url="http://127.0.0.1:9090",
316
+ auth_token=" proxy-token ",
317
+ base_env={
318
  "PATH": "keep",
319
  "ANTHROPIC_BASE_URL": "https://api.anthropic.com",
320
  "ANTHROPIC_AUTH_TOKEN": "old-token",
 
331
 
332
 
333
  def test_claude_child_env_uses_sentinel_for_blank_configured_auth_token() -> None:
334
+ from cli.launchers.claude import build_claude_launcher_env
335
 
336
+ env = build_claude_launcher_env(
337
+ proxy_root_url="http://127.0.0.1:8082",
338
+ auth_token="",
339
+ base_env={
340
  "ANTHROPIC_AUTH_TOKEN": "inherited-token",
341
  "ANTHROPIC_API_KEY": "official-key",
342
  },
 
349
  def test_launch_claude_passes_args_and_child_env(
350
  monkeypatch: pytest.MonkeyPatch,
351
  ) -> None:
352
+ from cli.launchers.claude import launch
353
 
354
  monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
355
  monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "old-token")
 
357
  settings = _launcher_settings(port=9191, token="proxy-token")
358
 
359
  with (
360
+ patch("cli.launchers.claude.get_settings", return_value=settings),
361
+ patch("cli.launchers.claude.preflight_proxy", return_value=None),
362
+ patch("cli.launchers.common.shutil.which", return_value="resolved-claude.cmd"),
363
+ patch("cli.launchers.common.subprocess.Popen") as popen,
364
+ patch("cli.launchers.common.register_pid") as register_pid,
365
+ patch("cli.launchers.common.unregister_pid") as unregister_pid,
 
366
  pytest.raises(SystemExit) as exc_info,
367
  ):
368
  process = popen.return_value
369
  process.pid = 12345
370
  process.wait.return_value = 7
371
+ launch(["--model", "sonnet"])
372
 
373
  assert exc_info.value.code == 7
374
  popen.assert_called_once()
 
381
  assert child_env["KEEP_ME"] == "yes"
382
  register_pid.assert_called_once_with(12345)
383
  unregister_pid.assert_called_once_with(12345)
 
384
 
385
 
386
  def test_launch_codex_passes_responses_config_and_child_env(
387
  monkeypatch: pytest.MonkeyPatch,
388
  tmp_path: Path,
389
  ) -> None:
390
+ from cli.launchers.codex import launch
391
 
392
  monkeypatch.setenv("OPENAI_API_KEY", "official-key")
393
  monkeypatch.setenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
 
419
  )
420
 
421
  with (
422
+ patch("cli.launchers.codex.get_settings", return_value=settings),
423
+ patch("cli.launchers.codex.preflight_proxy", return_value=None),
424
+ patch("cli.launchers.common.shutil.which", return_value="resolved-codex.cmd"),
425
+ patch(
426
+ "cli.launchers.codex.codex_model_catalog_path", return_value=catalog_path
427
+ ),
428
+ patch("cli.launchers.codex.urlopen", side_effect=fake_urlopen),
429
+ patch("cli.launchers.common.subprocess.Popen") as popen,
430
+ patch("cli.launchers.common.register_pid") as register_pid,
431
+ patch("cli.launchers.common.unregister_pid") as unregister_pid,
432
  pytest.raises(SystemExit) as exc_info,
433
  ):
434
  process = popen.return_value
435
  process.pid = 12345
436
  process.wait.return_value = 0
437
+ launch(["exec", "hello"])
438
 
439
  assert exc_info.value.code == 0
440
  command = popen.call_args.args[0]
 
466
  capsys: pytest.CaptureFixture[str],
467
  tmp_path: Path,
468
  ) -> None:
469
+ from cli.launchers.codex import launch
470
 
471
  settings = _launcher_settings(port=9191, token="proxy-token")
472
 
473
  with (
474
+ patch("cli.launchers.codex.get_settings", return_value=settings),
475
+ patch("cli.launchers.codex.preflight_proxy", return_value=None),
476
+ patch("cli.launchers.common.shutil.which", return_value="resolved-codex.cmd"),
477
  patch(
478
+ "cli.launchers.codex.codex_model_catalog_path",
479
  return_value=tmp_path / "codex-model-catalog.json",
480
  ),
481
+ patch("cli.launchers.codex.urlopen", side_effect=URLError("boom")),
482
+ patch("cli.launchers.common.subprocess.Popen") as popen,
483
+ patch("cli.launchers.common.register_pid"),
484
+ patch("cli.launchers.common.unregister_pid"),
485
  pytest.raises(SystemExit) as exc_info,
486
  ):
487
  process = popen.return_value
488
  process.pid = 12345
489
  process.wait.return_value = 0
490
+ launch(["exec", "hello"])
491
 
492
  assert exc_info.value.code == 0
493
  command = popen.call_args.args[0]
 
498
 
499
 
500
  def test_launch_claude_keyboard_interrupt_kills_child_tree() -> None:
501
+ from cli.launchers.claude import launch
502
 
503
  settings = _launcher_settings(port=9191, token="proxy-token")
504
 
505
  with (
506
+ patch("cli.launchers.claude.get_settings", return_value=settings),
507
+ patch("cli.launchers.claude.preflight_proxy", return_value=None),
508
+ patch("cli.launchers.common.shutil.which", return_value="resolved-claude.cmd"),
509
+ patch("cli.launchers.common.subprocess.Popen") as popen,
510
+ patch("cli.launchers.common.register_pid"),
511
+ patch("cli.launchers.common.kill_pid_tree_best_effort") as kill_tree,
512
+ patch("cli.launchers.common.unregister_pid") as unregister_pid,
513
  pytest.raises(KeyboardInterrupt),
514
  ):
515
  process = popen.return_value
516
  process.pid = 12345
517
  process.wait.side_effect = [KeyboardInterrupt, 0]
518
 
519
+ launch([])
520
 
521
  kill_tree.assert_called_once_with(12345)
522
  unregister_pid.assert_called_once_with(12345)
 
525
  def test_launch_claude_exits_when_command_cannot_be_resolved(
526
  capsys: pytest.CaptureFixture[str],
527
  ) -> None:
528
+ from cli.launchers.claude import launch
529
 
530
  settings = _launcher_settings()
531
  with (
532
+ patch("cli.launchers.claude.get_settings", return_value=settings),
533
+ patch("cli.launchers.claude.preflight_proxy", return_value=None),
534
+ patch("cli.launchers.common.shutil.which", return_value=None),
535
+ patch("cli.launchers.common.subprocess.Popen") as popen,
536
  pytest.raises(SystemExit) as exc_info,
537
  ):
538
+ launch([])
539
 
540
  assert exc_info.value.code == 127
541
  popen.assert_not_called()
 
547
  def test_launch_claude_unreachable_proxy_exits_with_hint(
548
  capsys: pytest.CaptureFixture[str],
549
  ) -> None:
550
+ from cli.launchers.claude import launch
551
 
552
  settings = _launcher_settings(port=9393)
553
  with (
554
+ patch("cli.launchers.claude.get_settings", return_value=settings),
555
+ patch(
556
+ "cli.launchers.claude.preflight_proxy", return_value="connection refused"
557
+ ),
558
+ patch("cli.launchers.common.subprocess.Popen") as popen,
559
  pytest.raises(SystemExit) as exc_info,
560
  ):
561
+ launch([])
562
 
563
  assert exc_info.value.code == 1
564
+ popen.assert_not_called()
565
  captured = capsys.readouterr()
566
  assert "http://127.0.0.1:9393" in captured.err
567
  assert "fcc-server" in captured.err
tests/cli/test_managed_claude.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from cli.managed.claude import (
6
+ ManagedClaudeConfig,
7
+ ManagedClaudeParseState,
8
+ ManagedClaudeTaskRequest,
9
+ build_managed_claude_env,
10
+ build_managed_claude_invocation,
11
+ extract_managed_claude_session_id,
12
+ parse_managed_claude_stdout_line,
13
+ )
14
+
15
+
16
+ def _config(**overrides: object) -> ManagedClaudeConfig:
17
+ workspace_path = overrides.get("workspace_path", os.path.normpath("/tmp/workspace"))
18
+ api_url = overrides.get("api_url", "http://localhost:8082/v1")
19
+ raw_allowed_dirs = overrides.get("allowed_dirs")
20
+ allowed_dirs: list[str] = []
21
+ if raw_allowed_dirs is not None:
22
+ assert isinstance(raw_allowed_dirs, list)
23
+ for directory in raw_allowed_dirs:
24
+ assert isinstance(directory, str)
25
+ allowed_dirs.append(directory)
26
+ plans_directory = overrides.get("plans_directory")
27
+ claude_bin = overrides.get("claude_bin", "claude")
28
+ auth_token = overrides.get("auth_token", "proxy-token")
29
+
30
+ assert isinstance(workspace_path, str)
31
+ assert isinstance(api_url, str)
32
+ assert plans_directory is None or isinstance(plans_directory, str)
33
+ assert isinstance(claude_bin, str)
34
+ assert isinstance(auth_token, str)
35
+ return ManagedClaudeConfig(
36
+ workspace_path=workspace_path,
37
+ api_url=api_url,
38
+ allowed_dirs=allowed_dirs,
39
+ plans_directory=plans_directory,
40
+ claude_bin=claude_bin,
41
+ auth_token=auth_token,
42
+ )
43
+
44
+
45
+ def test_managed_claude_builds_new_task_command_and_env() -> None:
46
+ invocation = build_managed_claude_invocation(
47
+ config=_config(
48
+ allowed_dirs=[os.path.normpath("/tmp/extra")],
49
+ plans_directory=".plans",
50
+ ),
51
+ request=ManagedClaudeTaskRequest(prompt="hello"),
52
+ base_env={"PATH": "keep", "ANTHROPIC_API_KEY": "official"},
53
+ )
54
+
55
+ assert invocation.argv[:2] == ("claude", "-p")
56
+ assert "hello" in invocation.argv
57
+ assert "--output-format" in invocation.argv
58
+ assert "stream-json" in invocation.argv
59
+ assert "--add-dir" in invocation.argv
60
+ assert os.path.normpath("/tmp/extra") in invocation.argv
61
+ assert "--settings" in invocation.argv
62
+ assert invocation.env["PATH"] == "keep"
63
+ assert invocation.env["ANTHROPIC_API_URL"] == "http://localhost:8082/v1"
64
+ assert invocation.env["ANTHROPIC_BASE_URL"] == "http://localhost:8082"
65
+ assert invocation.env["ANTHROPIC_AUTH_TOKEN"] == "proxy-token"
66
+ assert "ANTHROPIC_API_KEY" not in invocation.env
67
+ assert invocation.trace_metadata["client_cli_id"] == "claude"
68
+ assert invocation.trace_metadata["claude_binary"] == "claude"
69
+
70
+
71
+ def test_managed_claude_builds_resume_and_fork_commands() -> None:
72
+ resume = build_managed_claude_invocation(
73
+ config=_config(),
74
+ request=ManagedClaudeTaskRequest(prompt="again", session_id="sess_1"),
75
+ base_env={},
76
+ )
77
+ fork = build_managed_claude_invocation(
78
+ config=_config(),
79
+ request=ManagedClaudeTaskRequest(
80
+ prompt="branch", session_id="sess_1", fork_session=True
81
+ ),
82
+ base_env={},
83
+ )
84
+
85
+ assert resume.argv[:3] == ("claude", "--resume", "sess_1")
86
+ assert "--fork-session" not in resume.argv
87
+ assert fork.argv[:3] == ("claude", "--resume", "sess_1")
88
+ assert "--fork-session" in fork.argv
89
+
90
+
91
+ def test_managed_claude_env_uses_sentinel_when_proxy_auth_blank() -> None:
92
+ env = build_managed_claude_env(
93
+ api_url="http://localhost:8082/v1",
94
+ auth_token="",
95
+ base_env={"ANTHROPIC_AUTH_TOKEN": "stale"},
96
+ )
97
+
98
+ assert env["ANTHROPIC_AUTH_TOKEN"] == "fcc-no-auth"
99
+
100
+
101
+ def test_managed_claude_extracts_session_ids() -> None:
102
+ assert extract_managed_claude_session_id({"session_id": "direct"}) == "direct"
103
+ assert extract_managed_claude_session_id({"sessionId": "camel"}) == "camel"
104
+ assert (
105
+ extract_managed_claude_session_id({"init": {"session_id": "nested"}})
106
+ == "nested"
107
+ )
108
+ assert (
109
+ extract_managed_claude_session_id({"result": {"sessionId": "result"}})
110
+ == "result"
111
+ )
112
+ assert extract_managed_claude_session_id({"conversation": {"id": "conv"}}) == "conv"
113
+ assert extract_managed_claude_session_id({"type": "message"}) is None
114
+ assert extract_managed_claude_session_id("not a dict") is None
115
+
116
+
117
+ def test_managed_claude_parser_emits_session_info_once() -> None:
118
+ state = ManagedClaudeParseState()
119
+
120
+ first = list(parse_managed_claude_stdout_line('{"session_id": "sess_1"}', state))
121
+ second = list(parse_managed_claude_stdout_line('{"session_id": "sess_2"}', state))
122
+
123
+ assert first == [
124
+ {"type": "session_info", "session_id": "sess_1"},
125
+ {"session_id": "sess_1"},
126
+ ]
127
+ assert second == [{"session_id": "sess_2"}]
128
+
129
+
130
+ def test_managed_claude_parser_returns_raw_for_non_json() -> None:
131
+ events = list(
132
+ parse_managed_claude_stdout_line(
133
+ "not json", ManagedClaudeParseState(log_raw_cli_diagnostics=False)
134
+ )
135
+ )
136
+
137
+ assert events == [{"type": "raw", "content": "not json"}]
tests/conftest.py CHANGED
@@ -85,9 +85,9 @@ def llamacpp_provider(provider_config):
85
 
86
  @pytest.fixture
87
  def mock_cli_session():
88
- from messaging.platforms.base import CLISession
89
 
90
- session = MagicMock(spec=CLISession)
91
  session.start_task = MagicMock() # This will return an async generator
92
  session.is_busy = False
93
  return session
@@ -95,9 +95,9 @@ def mock_cli_session():
95
 
96
  @pytest.fixture
97
  def mock_cli_manager():
98
- from messaging.platforms.base import SessionManagerInterface
99
 
100
- manager = MagicMock(spec=SessionManagerInterface)
101
  manager.get_or_create_session = AsyncMock()
102
  manager.register_real_session_id = AsyncMock(return_value=True)
103
  manager.stop_all = AsyncMock()
 
85
 
86
  @pytest.fixture
87
  def mock_cli_session():
88
+ from messaging.platforms.base import ManagedClaudeSessionProtocol
89
 
90
+ session = MagicMock(spec=ManagedClaudeSessionProtocol)
91
  session.start_task = MagicMock() # This will return an async generator
92
  session.is_busy = False
93
  return session
 
95
 
96
  @pytest.fixture
97
  def mock_cli_manager():
98
+ from messaging.platforms.base import ManagedClaudeSessionManagerProtocol
99
 
100
+ manager = MagicMock(spec=ManagedClaudeSessionManagerProtocol)
101
  manager.get_or_create_session = AsyncMock()
102
  manager.register_real_session_id = AsyncMock(return_value=True)
103
  manager.stop_all = AsyncMock()
tests/contracts/test_import_boundaries.py CHANGED
@@ -258,6 +258,65 @@ def test_messaging_platforms_use_shared_outbox_and_voice_flow() -> None:
258
  assert "NamedTemporaryFile" not in text
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  def _imports_matching(
262
  roots: list[Path], *, forbidden_prefixes: tuple[str, ...]
263
  ) -> list[str]:
 
258
  assert "NamedTemporaryFile" not in text
259
 
260
 
261
+ def test_cli_surfaces_are_explicit_launchers_and_managed_claude() -> None:
262
+ repo_root = Path(__file__).resolve().parents[2]
263
+ cli_root = repo_root / "cli"
264
+
265
+ assert not (cli_root / "adapters" / "__init__.py").exists()
266
+ assert not any((cli_root / "adapters").glob("*.py"))
267
+ assert not (cli_root / "session.py").exists()
268
+ assert not (cli_root / "manager.py").exists()
269
+ assert not (cli_root / "codex_model_catalog.py").exists()
270
+
271
+ for path in {
272
+ cli_root / "claude_env.py",
273
+ cli_root / "launchers" / "claude.py",
274
+ cli_root / "launchers" / "codex.py",
275
+ cli_root / "launchers" / "codex_model_catalog.py",
276
+ cli_root / "managed" / "claude.py",
277
+ cli_root / "managed" / "session.py",
278
+ cli_root / "managed" / "manager.py",
279
+ }:
280
+ assert path.exists()
281
+
282
+ entrypoints_text = (cli_root / "entrypoints.py").read_text(encoding="utf-8")
283
+ assert "launch_claude" not in entrypoints_text
284
+ assert "launch_codex" not in entrypoints_text
285
+ assert "codex_model_catalog" not in entrypoints_text
286
+ assert "_preflight" + "_proxy" not in entrypoints_text
287
+ assert _text_occurrences(repo_root, "_preflight" + "_proxy") == []
288
+
289
+ claude_env_text = (cli_root / "claude_env.py").read_text(encoding="utf-8")
290
+ assert 'CLAUDE_CODE_AUTO_COMPACT_WINDOW = "190000"' in claude_env_text
291
+ assert 'CLAUDE_NO_AUTH_SENTINEL = "fcc-no-auth"' in claude_env_text
292
+ for path in {
293
+ cli_root / "launchers" / "claude.py",
294
+ cli_root / "managed" / "claude.py",
295
+ }:
296
+ text = path.read_text(encoding="utf-8")
297
+ assert '"190000"' not in text
298
+ assert '"fcc-no-auth"' not in text
299
+
300
+ messaging_base_text = (repo_root / "messaging" / "platforms" / "base.py").read_text(
301
+ encoding="utf-8"
302
+ )
303
+ assert "class ManagedClaudeSessionProtocol(Protocol)" in messaging_base_text
304
+ assert "class ManagedClaudeSession(Protocol)" not in messaging_base_text
305
+ assert "class ManagedClaudeSessionManagerProtocol(Protocol)" in messaging_base_text
306
+ assert "class SessionManagerInterface(Protocol)" not in messaging_base_text
307
+ for path in {
308
+ repo_root / "messaging" / "__init__.py",
309
+ repo_root / "messaging" / "platforms" / "__init__.py",
310
+ }:
311
+ text = path.read_text(encoding="utf-8")
312
+ assert '"ManagedClaudeSession"' not in text
313
+ assert "SessionManagerInterface" not in text
314
+
315
+ pyproject_text = (repo_root / "pyproject.toml").read_text(encoding="utf-8")
316
+ assert 'fcc-claude = "cli.launchers.claude:launch"' in pyproject_text
317
+ assert 'fcc-codex = "cli.launchers.codex:launch"' in pyproject_text
318
+
319
+
320
  def _imports_matching(
321
  roots: list[Path], *, forbidden_prefixes: tuple[str, ...]
322
  ) -> list[str]:
uv.lock CHANGED
@@ -561,7 +561,7 @@ wheels = [
561
 
562
  [[package]]
563
  name = "free-claude-code"
564
- version = "2.3.10"
565
  source = { editable = "." }
566
  dependencies = [
567
  { name = "aiohttp" },
 
561
 
562
  [[package]]
563
  name = "free-claude-code"
564
+ version = "2.3.11"
565
  source = { editable = "." }
566
  dependencies = [
567
  { name = "aiohttp" },