ccx-native 0.0.1 → 0.1.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 (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -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 +762 -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 +250 -0
  16. package/ccx/claude/host/session.py +5184 -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 +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -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/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,215 @@
1
+ """Install the bundled agent skills into agent skill folders.
2
+
3
+ A wheel carries the skills as package data in ``ccx/_skills``; a source
4
+ checkout or editable install uses the repository's ``skills/`` directory.
5
+ Installs are copies with a marker file, so CCX only ever replaces or removes
6
+ folders it created, and refreshes them after an upgrade.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import shutil
16
+ import sys
17
+ import tempfile
18
+ import uuid
19
+
20
+ MARKER = ".ccx-skill.json"
21
+ _IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", MARKER)
22
+
23
+
24
+ def skills_dir() -> Path:
25
+ package = Path(__file__).resolve().parents[1]
26
+ bundled = package / "_skills"
27
+ if bundled.is_dir():
28
+ return bundled
29
+ return package.parent / "skills"
30
+
31
+
32
+ def bundled_skills() -> dict[str, Path]:
33
+ root = skills_dir()
34
+ if not root.is_dir():
35
+ return {}
36
+ return {p.name: p for p in sorted(root.iterdir()) if (p / "SKILL.md").is_file()}
37
+
38
+
39
+ def _digest(folder: Path) -> str:
40
+ h = hashlib.sha256()
41
+ for path in sorted(folder.rglob("*")):
42
+ rel = path.relative_to(folder)
43
+ if not path.is_file() or rel.name == MARKER or "__pycache__" in rel.parts \
44
+ or rel.suffix == ".pyc":
45
+ continue
46
+ h.update(str(rel).encode() + b"\0" + path.read_bytes() + b"\0")
47
+ return h.hexdigest()
48
+
49
+
50
+ def _claude_skills_dir() -> Path:
51
+ config = os.environ.get("CLAUDE_CONFIG_DIR")
52
+ return (Path(config).expanduser() if config else Path.home() / ".claude") / "skills"
53
+
54
+
55
+ def _agents_skills_dir() -> Path:
56
+ return Path.home() / ".agents" / "skills"
57
+
58
+
59
+ def _installed(engine: str) -> bool:
60
+ from ccx.errors import CcxError
61
+ from ccx.system.engine_binary import resolve_engine_binary
62
+ try:
63
+ resolve_engine_binary(engine)
64
+ except CcxError:
65
+ return False
66
+ return True
67
+
68
+
69
+ def default_targets() -> list[Path]:
70
+ """Claude Code's folder, and the shared ~/.agents/skills (Codex and others)."""
71
+ targets = []
72
+ if _installed("claude"):
73
+ targets.append(_claude_skills_dir())
74
+ agents = _agents_skills_dir()
75
+ if _installed("codex") or agents.is_dir():
76
+ targets.append(agents)
77
+ return targets
78
+
79
+
80
+ def _marker(folder: Path) -> dict | None:
81
+ if folder.is_symlink() or not folder.is_dir():
82
+ return None
83
+ try:
84
+ data = json.loads((folder / MARKER).read_text(encoding="utf-8"))
85
+ except (OSError, ValueError):
86
+ return None
87
+ return data if isinstance(data, dict) and data.get("installedBy") == "ccx" else None
88
+
89
+
90
+ def _remove(folder: Path) -> None:
91
+ if folder.is_symlink() or folder.is_file():
92
+ folder.unlink()
93
+ elif folder.exists():
94
+ shutil.rmtree(folder)
95
+
96
+
97
+ def _copy(source: Path, dest: Path, digest: str) -> None:
98
+ """Copy next to dest, then swap it in, so readers never see half a skill."""
99
+ from ccx.version import ccx_version
100
+ dest.parent.mkdir(parents=True, exist_ok=True)
101
+ staging = dest.parent / f".{dest.name}.ccx-{uuid.uuid4().hex[:8]}"
102
+ shutil.copytree(source, staging, ignore=_IGNORE)
103
+ (staging / MARKER).write_text(json.dumps(
104
+ {"installedBy": "ccx", "version": ccx_version(), "digest": digest},
105
+ indent=2) + "\n", encoding="utf-8")
106
+ old = None
107
+ if dest.exists() or dest.is_symlink():
108
+ old = dest.parent / f".{dest.name}.ccx-old-{uuid.uuid4().hex[:8]}"
109
+ os.rename(dest, old)
110
+ os.rename(staging, dest)
111
+ if old is not None:
112
+ _remove(old)
113
+
114
+
115
+ def install(targets: list[Path], *, force: bool = False) -> list[tuple[str, Path, str]]:
116
+ rows = []
117
+ for target in targets:
118
+ for name, source in bundled_skills().items():
119
+ dest = target / name
120
+ digest = _digest(source)
121
+ marker = _marker(dest)
122
+ if marker is None and (dest.exists() or dest.is_symlink()) and not force:
123
+ rows.append(("skipped", dest, "not created by ccx; use --force to replace"))
124
+ continue
125
+ if marker is not None and marker.get("digest") == digest:
126
+ rows.append(("unchanged", dest, ""))
127
+ continue
128
+ action = ("updated" if marker is not None
129
+ else "replaced" if dest.exists() or dest.is_symlink() else "installed")
130
+ _copy(source, dest, digest)
131
+ rows.append((action, dest, ""))
132
+ return rows
133
+
134
+
135
+ def uninstall(targets: list[Path]) -> list[tuple[str, Path, str]]:
136
+ rows = []
137
+ for target in targets:
138
+ for name in bundled_skills():
139
+ dest = target / name
140
+ if _marker(dest) is not None:
141
+ _remove(dest)
142
+ rows.append(("removed", dest, ""))
143
+ elif dest.exists() or dest.is_symlink():
144
+ rows.append(("skipped", dest, "not created by ccx"))
145
+ return rows
146
+
147
+
148
+ def refresh_installed() -> None:
149
+ """Bring CCX-installed copies in the default folders up to date.
150
+
151
+ Runs on ordinary commands after an upgrade. Only marked folders are
152
+ touched, nothing is created, and any failure leaves things as they were.
153
+ """
154
+ try:
155
+ skills = bundled_skills()
156
+ stale = []
157
+ for target in (_claude_skills_dir(), _agents_skills_dir()):
158
+ for name, source in skills.items():
159
+ marker = _marker(target / name)
160
+ if marker is not None and marker.get("digest") != _digest(source):
161
+ stale.append((source, target / name))
162
+ if not stale:
163
+ return
164
+ import fcntl
165
+ lock_path = Path(tempfile.gettempdir()) / f"ccx-skills-{os.getuid()}.lock"
166
+ with open(lock_path, "a") as lock:
167
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
168
+ for source, dest in stale:
169
+ digest = _digest(source)
170
+ marker = _marker(dest)
171
+ if marker is not None and marker.get("digest") != digest:
172
+ _copy(source, dest, digest)
173
+ sys.stderr.write(f"[ccx] refreshed the {dest.name} skill in {dest.parent}\n")
174
+ except Exception:
175
+ return
176
+
177
+
178
+ def _print(rows) -> None:
179
+ home = str(Path.home())
180
+ for action, path, note in rows:
181
+ shown = str(path)
182
+ if shown == home or shown.startswith(home + os.sep):
183
+ shown = "~" + shown[len(home):]
184
+ print(f"{action:<10} {shown}" + (f" ({note})" if note else ""))
185
+
186
+
187
+ def _targets(args) -> list[Path]:
188
+ if getattr(args, "dir", None):
189
+ return [Path(args.dir).expanduser().resolve()]
190
+ targets = default_targets()
191
+ if not targets:
192
+ sys.stderr.write(
193
+ "[ccx] skills: neither Claude Code nor Codex was found and ~/.agents/skills "
194
+ "doesn't exist; pass --dir with your agent's skills folder\n")
195
+ raise SystemExit(1)
196
+ return targets
197
+
198
+
199
+ def cmd_skills(args) -> None:
200
+ if not bundled_skills():
201
+ sys.stderr.write(f"[ccx] skills: no bundled skills found in {skills_dir()}\n")
202
+ raise SystemExit(1)
203
+ command = getattr(args, "skills_cmd", None)
204
+ if command == "install":
205
+ _print(install(_targets(args), force=args.force))
206
+ elif command == "uninstall":
207
+ targets = ([Path(args.dir).expanduser().resolve()] if args.dir
208
+ else [_claude_skills_dir(), _agents_skills_dir()])
209
+ rows = uninstall(targets)
210
+ if rows:
211
+ _print(rows)
212
+ else:
213
+ print("no ccx-installed skills found")
214
+ else:
215
+ args.skills_parser.print_help()
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import json
5
+ import os
6
+ import socket
7
+ from pathlib import Path
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ import time
12
+
13
+ from ccx.constants import SELF_ENGINE_ENV, SELF_WORKER_ID_ENV
14
+
15
+ SCHEMA_VERSION = 1
16
+ _MAX_STDIN = 32768
17
+ _USER_COMMAND_TIMEOUT_S = 2.0
18
+
19
+
20
+ def _normalize_payload(short: str, payload: dict) -> dict:
21
+ model = payload.get("model")
22
+ if not isinstance(model, dict):
23
+ model = {}
24
+ cw = payload.get("context_window")
25
+ if not isinstance(cw, dict):
26
+ cw = None
27
+ return {
28
+ "schemaVersion": SCHEMA_VERSION,
29
+ "writtenAtMs": int(time.time() * 1000),
30
+ "short": short,
31
+ "sessionId": payload.get("session_id"),
32
+ "model": {
33
+ "id": model.get("id"),
34
+ "display_name": model.get("display_name"),
35
+ },
36
+ "statuslineVersion": payload.get("version"),
37
+ "contextWindow": cw,
38
+ "exceeds200k": payload.get("exceeds_200k_tokens"),
39
+ }
40
+
41
+
42
+ def write_statusline_sidecar(short: str, payload: dict) -> str | None:
43
+ """Capture only the original owned Claude session's advisory statusline."""
44
+ from ccx import constants
45
+ from ccx.store.root import StorageIntegrityError, _write_json, require_ccx_active
46
+
47
+ store = require_ccx_active(constants.CCX_ROOT)
48
+ owner = store.resolve(short) # Exact global full UUID; never display/short/native.
49
+ if (owner.engine != "claude"
50
+ or os.environ.get(SELF_WORKER_ID_ENV) != owner.worker_id
51
+ or os.environ.get(SELF_ENGINE_ENV) != "claude"):
52
+ raise StorageIntegrityError("statusline worker binding mismatch")
53
+ rec = store.record(owner.worker)
54
+ native = rec.get("native")
55
+ native_sid = native.get("sessionId") if isinstance(native, dict) else None
56
+ fingerprint = rec.get("createSha256")
57
+ if (not isinstance(native_sid, str) or not isinstance(fingerprint, str)
58
+ or payload.get("session_id") != native_sid):
59
+ raise StorageIntegrityError("statusline original native session mismatch")
60
+ original = store.creation_binding(
61
+ owner.worker, engine="claude", fingerprint=fingerprint,
62
+ )
63
+ if not isinstance(original, dict) or original.get("sessionId") != native_sid:
64
+ raise StorageIntegrityError("statusline original native binding unproven")
65
+ doc = _normalize_payload(owner.worker_id, payload)
66
+ with store.lease():
67
+ current_owner, current = store._owned(owner.worker)
68
+ if (current_owner != owner or not isinstance(current.get("native"), dict)
69
+ or current["native"].get("sessionId") != native_sid
70
+ or current.get("createSha256") != fingerprint):
71
+ raise StorageIntegrityError("statusline owner changed during capture")
72
+ with store._gen("workers", "claude", owner.worker_id) as fd:
73
+ _write_json(fd, "statusline.json", doc)
74
+ return rec.get("cwd") if isinstance(rec.get("cwd"), str) else None
75
+
76
+
77
+ def nudge_statusline_host(short: str, session_id: str) -> None:
78
+ """One best-effort local wake. The host, not this relay, reads and journals."""
79
+ from ccx.store.paths import sock_path
80
+ try:
81
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
82
+ sock.setblocking(False)
83
+ # Never select/wait for a busy host and never read an ACK.
84
+ if sock.connect_ex(str(sock_path(short))) != 0:
85
+ return
86
+ sock.send((json.dumps({
87
+ "op": "statusline-updated", "worker_id": short,
88
+ "session_id": session_id,
89
+ }) + "\n").encode())
90
+ except (OSError, ValueError):
91
+ pass
92
+
93
+
94
+ def _configured_statusline(cwd: Path) -> str | None:
95
+ """Resolve Claude's user -> project -> local command, omitting our sidecar.
96
+
97
+ This is a separate projection of the caller's settings. Do not read CCX's
98
+ generated per-worker ``--settings`` file, which points back at this relay.
99
+ Later layers override earlier ones, including explicit null/disabled values.
100
+ """
101
+ from ccx import constants
102
+
103
+ selected = None
104
+ for path in (constants.CLAUDE_HOME / "settings.json",
105
+ cwd / ".claude" / "settings.json",
106
+ cwd / ".claude" / "settings.local.json"):
107
+ try:
108
+ cfg = json.loads(path.read_text(encoding="utf-8"))
109
+ except (OSError, ValueError, UnicodeError):
110
+ continue
111
+ if isinstance(cfg, dict) and "statusLine" in cfg:
112
+ selected = cfg["statusLine"]
113
+ if not isinstance(selected, dict) or selected.get("type") != "command":
114
+ return None
115
+ command = selected.get("command")
116
+ return command if isinstance(command, str) and command.strip() else None
117
+
118
+
119
+ def cmd_statusline_relay(args) -> None:
120
+ """Capture exact original statusline, then pass stdin to user configuration.
121
+
122
+ The user-configured command is unmodified and runs from the owned worker's
123
+ cwd with the original statusline JSON bytes. Its stderr is not displayed or
124
+ logged. Any missing configuration, failed command or timeout is fail-open.
125
+ """
126
+ try:
127
+ raw = sys.stdin.read(_MAX_STDIN + 1)
128
+ if len(raw) > _MAX_STDIN:
129
+ return
130
+ payload = json.loads(raw or "{}")
131
+ if not isinstance(payload, dict):
132
+ return
133
+ cwd_string = write_statusline_sidecar(args.short, payload)
134
+ if not cwd_string:
135
+ return
136
+ nudge_statusline_host(args.short, payload.get("session_id"))
137
+ cwd = Path(cwd_string)
138
+ command = _configured_statusline(cwd)
139
+ if command is None:
140
+ return
141
+ # Shell commands can launch children/pipelines. Own a fresh process
142
+ # group and kill the whole group on timeout, not just the shell, so
143
+ # statusline refreshes never strand a slow background child.
144
+ child = subprocess.Popen(
145
+ command, shell=True, cwd=cwd, text=True,
146
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE,
147
+ stderr=subprocess.PIPE, start_new_session=True,
148
+ )
149
+ try:
150
+ stdout, _stderr = child.communicate(input=raw, timeout=_USER_COMMAND_TIMEOUT_S)
151
+ except subprocess.TimeoutExpired:
152
+ with contextlib.suppress(OSError):
153
+ os.killpg(child.pid, signal.SIGKILL)
154
+ with contextlib.suppress(OSError, subprocess.TimeoutExpired):
155
+ child.communicate(timeout=1.0)
156
+ return
157
+ if child.returncode == 0:
158
+ sys.stdout.write(stdout)
159
+ except Exception:
160
+ pass