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
package/ccx/models.py ADDED
@@ -0,0 +1,225 @@
1
+ """Read-only, engine-neutral model catalogs with private per-engine caching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from datetime import datetime, timezone
7
+ import hashlib
8
+ from pathlib import Path
9
+ import time
10
+
11
+ from ccx import constants
12
+ from ccx.claude.oauth import oauth_organization_uuid
13
+ from ccx.claude.usage_control import request_models
14
+ from ccx.codex.runtime import list_model_entries
15
+ from ccx.errors import InvalidRequest
16
+ from ccx.system.private_json import _atomic_private_json, _read_private_json
17
+ from ccx.types import ModelCatalog, ModelInfo
18
+
19
+ _CACHE_TTL_S = 24 * 60 * 60
20
+ _CACHE_VERSION = 1
21
+ _ENGINES = ("claude", "codex")
22
+
23
+
24
+ def _text(value: object) -> str | None:
25
+ return value if isinstance(value, str) and value else None
26
+
27
+
28
+ def _efforts(value: object) -> list[str]:
29
+ if not isinstance(value, list):
30
+ return []
31
+ return list(dict.fromkeys(entry for entry in value
32
+ if isinstance(entry, str) and entry))
33
+
34
+
35
+ def normalize_claude_models(entries: object) -> list[ModelInfo]:
36
+ """Project Claude initialize's models only, with per-model capabilities."""
37
+ if not isinstance(entries, list):
38
+ return []
39
+ models = []
40
+ for item in entries:
41
+ if not isinstance(item, dict):
42
+ continue
43
+ ident = _text(item.get("value"))
44
+ resolved = _text(item.get("resolvedModel"))
45
+ name = _text(item.get("displayName"))
46
+ if not (ident and resolved and name):
47
+ continue
48
+ models.append(ModelInfo(
49
+ id=ident,
50
+ resolved=resolved,
51
+ display_name=name,
52
+ description=(item.get("description")
53
+ if isinstance(item.get("description"), str) else None),
54
+ alias=ident != resolved,
55
+ default=ident == "default",
56
+ efforts=(_efforts(item.get("supportedEffortLevels"))
57
+ if item.get("supportsEffort") is True else []),
58
+ default_effort=None,
59
+ features={
60
+ "fast": item.get("supportsFastMode") is True,
61
+ "auto": item.get("supportsAutoMode") is True,
62
+ "thinkingOff": item.get("supportsThinkingOff") is True,
63
+ },
64
+ ))
65
+ return models
66
+
67
+
68
+ def normalize_codex_models(entries: object) -> list[ModelInfo]:
69
+ """Project model/list items; Codex's model ID is the --model spelling."""
70
+ if not isinstance(entries, list):
71
+ return []
72
+ models = []
73
+ seen = set()
74
+ for item in entries:
75
+ if not isinstance(item, dict) or item.get("hidden") is True:
76
+ continue
77
+ ident = _text(item.get("model")) or _text(item.get("id"))
78
+ if not ident or ident in seen:
79
+ continue
80
+ seen.add(ident)
81
+ efforts = item.get("supportedReasoningEfforts")
82
+ levels = (_efforts([entry.get("reasoningEffort") for entry in efforts
83
+ if isinstance(entry, dict)])
84
+ if isinstance(efforts, list) else [])
85
+ models.append(ModelInfo(
86
+ id=ident,
87
+ resolved=ident,
88
+ display_name=_text(item.get("displayName")) or ident,
89
+ description=(item.get("description")
90
+ if isinstance(item.get("description"), str) else None),
91
+ alias=False,
92
+ default=item.get("isDefault") is True,
93
+ efforts=levels,
94
+ default_effort=_text(item.get("defaultReasoningEffort")),
95
+ features={},
96
+ ))
97
+ return models
98
+
99
+
100
+ def _cache_path(engine: str) -> Path:
101
+ return constants.CCX_ROOT / f"models-{engine}.json"
102
+
103
+
104
+ def _local_claude_version() -> str | None:
105
+ # Bypass oauth.py's process-wide lru_cache: a live CcxClient must notice
106
+ # when the installed Claude Code binary changes beneath it.
107
+ from ccx.claude.oauth import claude_code_version
108
+ return claude_code_version.__wrapped__()
109
+
110
+
111
+ def _cache_key(engine: str) -> dict:
112
+ if engine == "codex":
113
+ # No cheap native Codex version helper; this engine uses the TTL only.
114
+ return {}
115
+ org = oauth_organization_uuid()
116
+ return {
117
+ "version": _local_claude_version(),
118
+ "orgDigest": hashlib.sha256(org.encode()).hexdigest() if org else None,
119
+ }
120
+
121
+
122
+ def _read_cache(engine: str) -> tuple[ModelCatalog | None, dict]:
123
+ try:
124
+ data = _read_private_json(_cache_path(engine))
125
+ if not isinstance(data, dict) or data.get("version") != _CACHE_VERSION:
126
+ return None, {}
127
+ catalog = ModelCatalog.from_dict(data["catalog"])
128
+ if catalog.engine != engine:
129
+ return None, {}
130
+ key = data.get("key")
131
+ return catalog, key if isinstance(key, dict) else {}
132
+ except (OSError, ValueError, KeyError, TypeError, UnicodeError):
133
+ return None, {}
134
+
135
+
136
+ def _fresh(catalog: ModelCatalog, saved_key: dict, current_key: dict) -> bool:
137
+ if catalog.engine == "claude":
138
+ if saved_key.get("orgDigest") != current_key.get("orgDigest"):
139
+ return False
140
+ # If the installed version cannot be read, only TTL/org can be checked.
141
+ local_version = current_key.get("version")
142
+ if local_version is not None and catalog.engine_version != local_version:
143
+ return False
144
+ try:
145
+ stamped = datetime.fromisoformat(catalog.fetched_at.replace("Z", "+00:00"))
146
+ if stamped.tzinfo is None:
147
+ return False
148
+ age = time.time() - stamped.timestamp()
149
+ except (ValueError, OverflowError):
150
+ return False
151
+ return 0 <= age < _CACHE_TTL_S
152
+
153
+
154
+ def _probe(engine: str) -> tuple[str | None, list[ModelInfo]]:
155
+ if engine == "claude":
156
+ reply = request_models()
157
+ if reply.get("kind") != "success":
158
+ raise RuntimeError(reply.get("error") or "initialize unavailable")
159
+ body = reply["body"]
160
+ return _text(body.get("claude_code_version")), normalize_claude_models(body.get("models"))
161
+ # Account-level catalog, independent of the calling project's cwd.
162
+ return None, normalize_codex_models(list_model_entries(cwd=str(Path.home())))
163
+
164
+
165
+ def _builtin(engine: str, note: str) -> ModelCatalog:
166
+ # Only Claude has known aliases; inventing Codex model names would be wrong.
167
+ models = ([ModelInfo(
168
+ id=alias, resolved=None, display_name=alias.title(),
169
+ description=None, alias=True, default=False, efforts=[],
170
+ default_effort=None, features={},
171
+ ) for alias in constants.MODEL_ALIASES] if engine == "claude" else [])
172
+ return ModelCatalog(
173
+ engine=engine, engine_version=None, source="builtin",
174
+ fetched_at=_now(), note=note, models=models,
175
+ )
176
+
177
+
178
+ def _now() -> str:
179
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
180
+
181
+
182
+ def _catalog(engine: str, *, refresh: bool) -> ModelCatalog:
183
+ cached, saved_key = _read_cache(engine)
184
+ try:
185
+ current_key = _cache_key(engine)
186
+ except Exception:
187
+ # Broken or unavailable local identity metadata cannot block the other
188
+ # engine's catalog (or the provider read for this engine).
189
+ current_key = {}
190
+ if not refresh and cached is not None and _fresh(cached, saved_key, current_key):
191
+ return replace(cached, source="cache")
192
+
193
+ try:
194
+ version, models = _probe(engine)
195
+ if not models:
196
+ raise RuntimeError("no valid model entries")
197
+ except Exception as error:
198
+ note = f"{engine.capitalize()} model list unavailable: {error}"
199
+ if cached is not None:
200
+ return replace(cached, source="stale", note=note)
201
+ return _builtin(engine, note)
202
+
203
+ result = ModelCatalog(
204
+ engine=engine, engine_version=version, source="live",
205
+ fetched_at=_now(), note=None, models=models,
206
+ )
207
+ try:
208
+ _atomic_private_json(_cache_path(engine), {
209
+ "version": _CACHE_VERSION,
210
+ "key": ({"version": version, "orgDigest": current_key.get("orgDigest")}
211
+ if engine == "claude" else {}),
212
+ "catalog": result.as_dict(),
213
+ })
214
+ except OSError:
215
+ # A live listing remains useful if the private cache cannot be saved.
216
+ pass
217
+ return result
218
+
219
+
220
+ def read_models(*, engine: str | None = None, refresh: bool = False) -> list[ModelCatalog]:
221
+ """Return one or both model catalogs; probe failures degrade per engine."""
222
+ if engine is not None and engine not in _ENGINES:
223
+ raise InvalidRequest("engine must be claude or codex")
224
+ return [_catalog(name, refresh=refresh)
225
+ for name in ((engine,) if engine else _ENGINES)]
File without changes
@@ -0,0 +1,5 @@
1
+ """Run the notification daemon: ``python -m ccx.notify``."""
2
+ from ccx.notify.daemon import run
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(run())
@@ -0,0 +1,116 @@
1
+ """Public observation and cancellation of durable parent continuations.
2
+
3
+ The notification supervisor owns persistence and delivery. This module exposes a
4
+ small supported surface so dashboards and orchestrators can inspect armed
5
+ registrations and cancel one explicitly without importing parent_notify internals
6
+ or reading its state files.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+ from uuid import UUID
13
+
14
+ from ccx.errors import InvalidRequest
15
+ from ccx.notify import registry as parent_notify
16
+
17
+
18
+ def _event_id(value: str) -> str:
19
+ if not isinstance(value, str) or not value:
20
+ raise InvalidRequest("notification event_id must be a canonical UUID")
21
+ try:
22
+ if str(UUID(value)) != value:
23
+ raise ValueError("noncanonical event id")
24
+ except (ValueError, AttributeError) as error:
25
+ raise InvalidRequest("notification event_id must be a canonical UUID") from error
26
+ return value
27
+
28
+
29
+ class ParentNotificationClient:
30
+ """Inspect durable parent notifications and explicitly cancel one."""
31
+
32
+ @staticmethod
33
+ def _public(value: dict[str, Any]) -> dict[str, Any]:
34
+ return parent_notify.public_registration(value)
35
+
36
+ def get(self, event_id: str) -> dict[str, Any] | None:
37
+ event_id = _event_id(event_id)
38
+ value = parent_notify.read_registration(event_id)
39
+ if not isinstance(value, dict):
40
+ return None
41
+ return self._public(value)
42
+
43
+ def cancel(self, event_id: str, *, reason: str = "cancelled by operator") -> dict[str, Any] | None:
44
+ event_id = _event_id(event_id)
45
+ if not isinstance(reason, str) or not reason.strip():
46
+ raise InvalidRequest("notification cancel reason must be a non-empty string")
47
+ value = parent_notify.read_registration(event_id)
48
+ if not isinstance(value, dict):
49
+ return None
50
+ parent_notify.cancel_registration(event_id, reason.strip())
51
+ updated = parent_notify.read_registration(event_id)
52
+ return self._public(updated) if isinstance(updated, dict) else None
53
+
54
+ def list(
55
+ self,
56
+ *,
57
+ parent: str | None = None,
58
+ worker: str | None = None,
59
+ source: str | None = None,
60
+ state: str | None = None,
61
+ include_terminal: bool = False,
62
+ limit: int | None = None,
63
+ ) -> dict[str, Any]:
64
+ if limit is not None and (
65
+ isinstance(limit, bool) or not isinstance(limit, int) or limit < 1
66
+ ):
67
+ raise InvalidRequest("notification limit must be a positive integer")
68
+
69
+ from ccx.store.identity import canonical_worker_id, IdentityError
70
+ filters = []
71
+ for candidate, kinds in ((parent, {"codex"}),
72
+ (worker, {"codex", "claude", "controller"})):
73
+ if candidate is None:
74
+ filters.append(None)
75
+ continue
76
+ try:
77
+ kind, separator, identity = candidate.partition(":")
78
+ if separator:
79
+ if kind not in kinds:
80
+ raise ValueError("unsupported notification subject kind")
81
+ else:
82
+ identity, kind = candidate, None
83
+ filters.append((kind, canonical_worker_id(identity)))
84
+ except (IdentityError, ValueError, AttributeError) as error:
85
+ raise InvalidRequest("notification filter needs FULL_UUID or KIND:FULL_UUID with a supported kind") from error
86
+ parent_filter, worker_filter = filters
87
+ rows: list[dict[str, Any]] = []
88
+ for value in parent_notify.iter_registrations(
89
+ include_terminal=include_terminal
90
+ ):
91
+ row = self._public(value)
92
+ if any(
93
+ selected is not None and (
94
+ not isinstance(row.get(field), str)
95
+ or row[field].split(":", 1)[-1] != selected[1]
96
+ or (selected[0] is not None and row[field] != f"{selected[0]}:{selected[1]}")
97
+ )
98
+ for field, selected in (("parent", parent_filter), ("worker", worker_filter))
99
+ ):
100
+ continue
101
+ if source is not None and value.get("source") != source:
102
+ continue
103
+ if state is not None and row.get("state") != state:
104
+ continue
105
+ rows.append(row)
106
+
107
+ rows.sort(
108
+ key=lambda row: (
109
+ int(row.get("created_at_ms") or 0),
110
+ str(row.get("event_id") or ""),
111
+ ),
112
+ reverse=True,
113
+ )
114
+ if limit is not None:
115
+ rows = rows[:limit]
116
+ return {"count": len(rows), "notifications": rows}