ccx-native 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +807 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +269 -0
  16. package/ccx/claude/host/session.py +5244 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,204 @@
1
+ """Promptless Claude Code usage and model control protocol; no credential reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import select
8
+ import subprocess
9
+ import time
10
+ import uuid
11
+
12
+ from ccx.constants import CLAUDE_BIN
13
+
14
+ # None of these flags is --bare: --bare disables native OAuth/Keychain auth.
15
+ _PROBE_ARGS = (
16
+ "-p", "--input-format", "stream-json", "--output-format", "stream-json",
17
+ "--verbose", "--safe-mode", "--strict-mcp-config",
18
+ "--no-session-persistence", "--disable-slash-commands", "--no-chrome",
19
+ "--tools", "",
20
+ )
21
+ _MAX_OUTPUT_BYTES = 2 * 1024 * 1024
22
+ _MAX_LINE_BYTES = 1 * 1024 * 1024
23
+
24
+
25
+ def _control_exchange(requests: list[dict], timeout: float, cwd: str | None = None) -> dict:
26
+ """Send a list of requests through one bounded promptless child.
27
+
28
+ Initialize replies are projected immediately: the full response can also
29
+ contain personal account data, which must never leave this transport.
30
+ """
31
+ timeout = max(0.1, float(timeout))
32
+ deadline = time.monotonic() + timeout
33
+ ids = [(uuid.uuid4().hex, request) for request in requests]
34
+ target_id = ids[-1][0]
35
+ subtypes = {rid: request["subtype"] for rid, request in ids}
36
+ child = None
37
+ replies: dict[str, dict] = {}
38
+ seen_target = False
39
+ invalid_line = None
40
+ total_bytes = 0
41
+ buffer = b""
42
+ problem = None
43
+ try:
44
+ child = subprocess.Popen(
45
+ [CLAUDE_BIN, *_PROBE_ARGS], stdin=subprocess.PIPE,
46
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, cwd=cwd,
47
+ )
48
+ for rid, request in ids:
49
+ message = {"type": "control_request", "request_id": rid, "request": request}
50
+ child.stdin.write((json.dumps(message, separators=(",", ":")) + "\n").encode())
51
+ child.stdin.flush()
52
+
53
+ while time.monotonic() < deadline and not seen_target and problem is None:
54
+ readable, _, _ = select.select(
55
+ [child.stdout], [], [], min(0.2, max(0, deadline - time.monotonic()))
56
+ )
57
+ if not readable:
58
+ if child.poll() is not None:
59
+ break
60
+ continue
61
+ chunk = os.read(child.stdout.fileno(), 32768)
62
+ if not chunk:
63
+ break
64
+ total_bytes += len(chunk)
65
+ if total_bytes > _MAX_OUTPUT_BYTES:
66
+ problem = "Claude usage control output too large"
67
+ break
68
+ buffer += chunk
69
+ if len(buffer) > _MAX_LINE_BYTES and b"\n" not in buffer:
70
+ problem = "Claude usage control line too large"
71
+ break
72
+ while b"\n" in buffer:
73
+ line, buffer = buffer.split(b"\n", 1)
74
+ if len(line) > _MAX_LINE_BYTES:
75
+ problem = "Claude usage control line too large"
76
+ break
77
+ try:
78
+ message = json.loads(line)
79
+ except (ValueError, UnicodeDecodeError):
80
+ invalid_line = line[:1000].decode("utf-8", "replace")
81
+ continue
82
+ if not isinstance(message, dict) or message.get("type") != "control_response":
83
+ continue
84
+ response = message.get("response")
85
+ if not isinstance(response, dict):
86
+ continue
87
+ rid = response.get("request_id")
88
+ if rid not in subtypes:
89
+ continue
90
+ subtype = subtypes[rid]
91
+ if subtype == "initialize" and response.get("subtype") == "error":
92
+ problem = "Claude usage initialize control request failed"
93
+ break
94
+ if subtype == "initialize":
95
+ body = response.get("response")
96
+ # Do not retain the rest of initialize (notably 'account').
97
+ safe_body = ({
98
+ "models": body.get("models"),
99
+ "claude_code_version": body.get("claude_code_version"),
100
+ } if isinstance(body, dict) else None)
101
+ message = {
102
+ "type": "control_response",
103
+ "response": {
104
+ "request_id": rid,
105
+ "subtype": response.get("subtype"),
106
+ "response": safe_body,
107
+ },
108
+ }
109
+ replies[subtype] = message
110
+ if rid == target_id:
111
+ seen_target = True
112
+ break
113
+ # Always close stdin after the matching response, or on a bounded failure.
114
+ child.stdin.close()
115
+ child.stdin = None
116
+ remaining = max(0.0, deadline - time.monotonic())
117
+ try:
118
+ exit_code = child.wait(timeout=remaining)
119
+ except subprocess.TimeoutExpired:
120
+ problem = problem or "Claude usage control timed out"
121
+ exit_code = None
122
+ if not seen_target:
123
+ if problem is None:
124
+ problem = "Claude usage control returned no matching response"
125
+ if invalid_line is not None:
126
+ return {"kind": "unparsed", "raw": {"stdout_text": invalid_line},
127
+ "error": problem}
128
+ return {"kind": "error", "error": problem}
129
+ if problem is not None or exit_code not in (0, None):
130
+ return {"kind": "error", "error": problem or "Claude usage control exited unsuccessfully"}
131
+ return {"kind": "success", "responses": replies}
132
+ except (OSError, ValueError, BrokenPipeError) as exc:
133
+ return {"kind": "error", "error": f"Claude usage control unavailable: {type(exc).__name__}"}
134
+ finally:
135
+ if child is not None:
136
+ if child.stdin is not None and not child.stdin.closed:
137
+ child.stdin.close()
138
+ if child.poll() is None:
139
+ # No early SIGKILL: wait for the bounded deadline to expire first.
140
+ try:
141
+ child.wait(timeout=max(0.0, deadline - time.monotonic()))
142
+ except subprocess.TimeoutExpired:
143
+ child.terminate()
144
+ try:
145
+ child.wait(timeout=1.0)
146
+ except subprocess.TimeoutExpired:
147
+ child.kill()
148
+ child.wait(timeout=1.0)
149
+ if child.stdout is not None:
150
+ child.stdout.close()
151
+
152
+
153
+ def request_usage(timeout: float = 8.0) -> dict:
154
+ """Return {kind, body?, raw?, error?} from one bounded get_usage exchange.
155
+
156
+ The native CLI owns auth. The only input consists of initialize/get_usage
157
+ control requests; no model prompt, hooks, MCP server or transcript scan.
158
+ """
159
+ result = _control_exchange([
160
+ {"subtype": "initialize"},
161
+ {"subtype": "get_usage", "skip_behaviors": True},
162
+ ], timeout)
163
+ if result["kind"] != "success":
164
+ return result
165
+ reply = result["responses"]["get_usage"]
166
+ response = reply["response"]
167
+ if response.get("subtype") == "error":
168
+ return {"kind": "error", "error": "Claude usage control response was an error"}
169
+ if response.get("subtype") != "success":
170
+ return {"kind": "unparsed", "raw": reply,
171
+ "error": "Claude usage control returned an unknown response subtype"}
172
+ body = response.get("response")
173
+ if not isinstance(body, dict):
174
+ return {"kind": "unparsed", "raw": reply,
175
+ "error": "Claude usage control returned an unknown JSON body"}
176
+ if body.get("rate_limits_available") is False and body.get("rate_limits") is None:
177
+ return {"kind": "unavailable", "error": "Claude plan rate limits unavailable"}
178
+ if body.get("rate_limits_available") is True and body.get("rate_limits") is None:
179
+ return {"kind": "held", "error": "Claude plan usage is held by the provider"}
180
+ rate_limits = body.get("rate_limits")
181
+ if isinstance(rate_limits, dict) and any(k in rate_limits for k in (
182
+ "five_hour", "seven_day", "limits", "model_scoped", "extra_usage",
183
+ "seven_day_opus", "seven_day_sonnet",
184
+ )):
185
+ return {"kind": "success", "body": rate_limits}
186
+ return {"kind": "unparsed", "raw": reply,
187
+ "error": "Claude usage control returned an unrecognized rate_limits field"}
188
+
189
+
190
+ def request_models(timeout: float = 8.0) -> dict:
191
+ """Return only Claude's model entries and version from initialize."""
192
+ # From home, so one project's settings can't shape the shared catalog.
193
+ result = _control_exchange([{"subtype": "initialize"}], timeout,
194
+ cwd=os.path.expanduser("~"))
195
+ if result["kind"] != "success":
196
+ # Do not return raw stdout; initialize may include private account data.
197
+ return {"kind": "error", "error": result.get("error") or "Claude model probe failed"}
198
+ response = result["responses"]["initialize"]["response"]
199
+ if response.get("subtype") != "success":
200
+ return {"kind": "error", "error": "Claude model initialize failed"}
201
+ body = response.get("response")
202
+ if not isinstance(body, dict) or not isinstance(body.get("models"), list):
203
+ return {"kind": "error", "error": "Claude model initialize returned no models"}
204
+ return {"kind": "success", "body": body}
File without changes