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,302 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import sys
5
+
6
+ from ccx.controllers.policy import reject_codex_blocking_call
7
+ from ccx.cli.parser import build_parser, ccx_version
8
+ from ccx.system.pipes import quiet_broken_stdout
9
+
10
+
11
+ # Only ALREADY-ENABLED user-facing worker-reference arguments. Never infer
12
+ # from a generic "target" name: controller run IDs, delivery/turn/request IDs,
13
+ # creation worker_id, native hook IDs and protocol rpc are different domains.
14
+ _USER_WORKER_FIELDS = {
15
+ "new": ("vars_from",),
16
+ "fork": ("target", "vars_from"),
17
+ "resume": ("target",),
18
+ "send": ("target", "vars_from"),
19
+ "report": ("targets",),
20
+ "context": ("target",),
21
+ "peek": ("target", "extra_shorts"),
22
+ "set": ("target",),
23
+ "vars": ("target",),
24
+ "events": ("target",),
25
+ "logs": ("target",),
26
+ "history": ("targets",),
27
+ "wait": ("target",),
28
+ "answer": ("target",),
29
+ "approve": ("target",),
30
+ "reject": ("target",),
31
+ "respond": ("target",),
32
+ "stop": ("target",),
33
+ "kill": ("target",),
34
+ "attach": ("target",),
35
+ "steer": ("target",),
36
+ "reconcile": ("target",),
37
+ "compact": ("target",),
38
+ "notify": ("target",),
39
+ }
40
+ def _preflight_worker_references(args, store) -> None:
41
+ """Canonicalize all user prefix fields before any handler/effect starts."""
42
+ if (args.cmd in {"new", "fork", "send"}
43
+ and getattr(args, "var", None) is not None
44
+ and getattr(args, "vars_from", None) is not None):
45
+ # Existing mutual-exclusion refusal must win before discovery of an
46
+ # unrelated --vars-from owner; no handler may claim a child or send.
47
+ return
48
+ if args.cmd == "controller":
49
+ # Controller target/name/run IDs are a separate namespace. Only an
50
+ # explicit worker input is CLI-discovered, before any handler effect.
51
+ if (getattr(args, "controller_cmd", None) in {"input", "resume"}
52
+ and getattr(args, "worker", None) is not None):
53
+ from ccx.store.discovery import resolve_worker
54
+ args.worker = resolve_worker(args.worker)
55
+ return
56
+
57
+ if (args.cmd == "peek" and args.status
58
+ and getattr(args, "extra_shorts", None)):
59
+ # Read-only multi-target status isolates failures in its row reader.
60
+ # Single-target and effectful verbs still preflight atomically.
61
+ return
62
+
63
+ from ccx.store.discovery import resolve_cli_fields
64
+
65
+ fields = _USER_WORKER_FIELDS.get(args.cmd, ())
66
+ if fields:
67
+ # Preserve the report command's multi-target per-row error results.
68
+ multi_report = (args.cmd == "report" and len(args.targets) > 1)
69
+ resolve_cli_fields(args, store, fields, check_exact=not multi_report)
70
+
71
+
72
+ def _native_handler(args, store):
73
+ """Choose only existing native CLI handlers, after complete prefix preflight."""
74
+ from ccx.cli import codex as codex_commands
75
+ from ccx.constants import EXIT_ERROR
76
+ from ccx.claude.usage import _caps_from_args
77
+
78
+ def refuse(message):
79
+ sys.stderr.write(f"ccx {args.cmd}: {message}; no native effect submitted\n")
80
+ raise SystemExit(EXIT_ERROR)
81
+
82
+ if args.cmd == "usage":
83
+ if args.engine == "claude":
84
+ if args.tokens:
85
+ refuse("--tokens is Codex-only")
86
+ if args.set_source and args.cached:
87
+ refuse("--set-source cannot combine with --cached")
88
+ elif args.engine == "codex" and (args.cached or args.summary or args.set_source):
89
+ refuse("--cached/--summary/--set-source are Claude-only")
90
+ if args.engine is None and args.set_source and (args.cached or args.tokens):
91
+ refuse("--set-source cannot combine with --cached or --tokens")
92
+ return codex_commands.cmd_usage if args.engine == "codex" else args.func
93
+
94
+ if args.cmd in {"answer", "respond"}:
95
+ for target in [args.target]:
96
+ if store.resolve(target).engine != "claude":
97
+ refuse("this operation is Claude-only")
98
+ return args.func
99
+
100
+ target_verbs = {
101
+ "fork", "send", "steer", "reconcile", "wait", "notify",
102
+ "approve", "reject", "set", "compact", "logs",
103
+ }
104
+ if args.cmd not in target_verbs:
105
+ return args.func
106
+ # Ordinary normalized history has its own typed owner/error handling.
107
+ if args.cmd == "logs" and args.format == "conversation" and not args.jsonl:
108
+ return args.func
109
+ from ccx.store.identity import IdentityError, UnknownWorker
110
+ try:
111
+ owner = store.resolve(args.target)
112
+ except IdentityError as error:
113
+ if args.cmd == "logs":
114
+ return args.func
115
+ sys.stderr.write(f"ccx {args.cmd}: original CCX owner unavailable: {error}\n")
116
+ raise SystemExit(2 if isinstance(error, UnknownWorker) else EXIT_ERROR) from error
117
+ codex = owner.engine == "codex"
118
+ settle = bool(getattr(args, "settle", False))
119
+ notify = bool(getattr(args, "notify_parent", False))
120
+ if args.cmd in {"fork", "send", "steer"}:
121
+ if codex and "opus" in (_caps_from_args(args) or {}):
122
+ refuse("--cap-opus is Claude-only")
123
+ if args.cmd in {"fork", "send", "compact", "approve", "reject"}:
124
+ if settle and (not codex or not notify):
125
+ refuse("--settle requires a Codex worker and --notify-parent")
126
+ if args.cmd == "fork":
127
+ return args.func # Fork derives native engine from the original source.
128
+ if args.cmd == "steer" and codex and args.timeout is not None:
129
+ refuse("--timeout on steer is Claude-only")
130
+ if args.cmd == "reconcile" and not codex and args.turn_id is not None:
131
+ refuse("--turn-id is Codex-only")
132
+ if args.cmd == "wait":
133
+ if codex and (args.next_turn or args.after_turn is not None or args.known_input is not None):
134
+ refuse("--next-turn/--after-turn/--known-input are Claude-only")
135
+ if not codex and (settle or args.wake_grace is not None):
136
+ refuse("--settle/--wake-grace are Codex-only")
137
+ if codex and not settle and args.wake_grace is not None:
138
+ refuse("--wake-grace requires --settle")
139
+ if codex:
140
+ from ccx.codex.settle import DEFAULT_WAKE_GRACE_S
141
+ args.wake_grace = (DEFAULT_WAKE_GRACE_S if args.wake_grace is None
142
+ else args.wake_grace)
143
+ if args.cmd == "notify":
144
+ if codex and args.next_turn:
145
+ refuse("--next-turn is Claude-only")
146
+ if not codex and settle:
147
+ refuse("--settle is Codex-only")
148
+ if args.cmd in {"approve", "reject"}:
149
+ if codex and (args.wait or args.timeout is not None):
150
+ refuse("--wait/--timeout on a native Codex gate are unsupported; use ccx wait")
151
+ if not codex and settle:
152
+ refuse("--settle is Codex-only")
153
+ if codex and args.cmd == "reject" and args.reason is not None:
154
+ refuse("--reason is Claude-only")
155
+ if args.cmd == "set" and codex and (args.permissions is not None or args.restart):
156
+ refuse("--permissions/--restart are Claude-only")
157
+ if args.cmd == "compact" and codex and args.operation_id is not None:
158
+ refuse("--operation-id is Claude-only")
159
+ if args.cmd == "logs" and codex and (args.format != "conversation" or args.jsonl):
160
+ refuse("--format rendered/--jsonl are Claude-only")
161
+ codex_handlers = {
162
+ "send": codex_commands.cmd_send,
163
+ "steer": codex_commands.cmd_steer,
164
+ "reconcile": codex_commands.cmd_reconcile,
165
+ "wait": codex_commands.cmd_wait,
166
+ "notify": codex_commands.cmd_notify,
167
+ "approve": codex_commands.cmd_approve,
168
+ "reject": codex_commands.cmd_reject,
169
+ "set": codex_commands.cmd_set,
170
+ "compact": codex_commands.cmd_compact,
171
+ }
172
+ return codex_handlers[args.cmd] if codex and args.cmd in codex_handlers else args.func
173
+
174
+
175
+ def _deepest_parser(parser, argv):
176
+ """The (sub)command parser the argv selects, so errors show its usage."""
177
+ import argparse
178
+ for token in argv:
179
+ actions = [a for a in parser._actions if isinstance(a, argparse._SubParsersAction)]
180
+ if actions and token in actions[0].choices:
181
+ parser = actions[0].choices[token]
182
+ return parser
183
+
184
+
185
+ def main(argv=None):
186
+ with quiet_broken_stdout():
187
+ _main(argv)
188
+
189
+
190
+ # Internal relays run inside workers CCX launched; they must keep working there.
191
+ _SANDBOX_EXEMPT = {"skills", "host", "statusline-relay", "hooks-relay", "_live-vars-exec"}
192
+
193
+ # Explicit top-level user commands. Internal relays and the controller's
194
+ # private detached-host verbs keep a strict existing-store requirement.
195
+ _AUTO_INIT_COMMANDS = frozenset({
196
+ "new", "import", "fork", "resume", "send", "steer", "reconcile",
197
+ "wait", "notify", "answer", "approve", "reject", "respond",
198
+ "set", "compact", "stop", "kill", "attach", "shutdown", "untrust",
199
+ "vars", "list", "logs", "history", "report", "context", "peek", "events",
200
+ "usage", "models", "controller",
201
+ })
202
+
203
+
204
+ def _refuse_codex_sandbox(cmd: str) -> None:
205
+ """Fail once, clearly, when a Codex parent runs ccx inside its own sandbox.
206
+
207
+ CCX starts native Claude/Codex processes and uses ~/.claude, ~/.codex and
208
+ its root, so inside the sandbox it would fail later in scattered ways.
209
+ Codex sets CODEX_SANDBOX only on sandboxed commands (seatbelt on macOS).
210
+ CODEX_SANDBOX_NETWORK_DISABLED is no signal: Codex also sets it on commands
211
+ the user approved to run outside the sandbox.
212
+ """
213
+ if not os.environ.get("CODEX_SANDBOX") or cmd in _SANDBOX_EXEMPT:
214
+ return
215
+ sys.stderr.write(
216
+ "ccx unavailable: running inside the Codex sandbox (CODEX_SANDBOX is set). "
217
+ "CCX starts Claude/Codex processes and writes outside the workspace, which the "
218
+ "sandbox blocks. Rerun this ccx command with escalated permissions (outside the "
219
+ "sandbox), or start Codex with full access.\n"
220
+ )
221
+ raise SystemExit(3)
222
+
223
+
224
+ def _main(argv=None):
225
+ raw_argv = list(sys.argv[1:] if argv is None else argv)
226
+ # Answer before touching state so `ccx --version` never creates ~/.ccx.
227
+ if raw_argv in (["--version"], ["-V"]):
228
+ print(f"ccx {ccx_version()}")
229
+ return
230
+ from ccx.errors import SessionUnavailable
231
+ try:
232
+ reject_codex_blocking_call(raw_argv)
233
+ except SessionUnavailable as exc:
234
+ sys.stderr.write(
235
+ "ccx unavailable: caller worker identity is not proven in this "
236
+ f"CCX_ROOT; use the worker's original root: {exc}\n"
237
+ )
238
+ raise SystemExit(3) from None
239
+ if raw_argv[:1] == ["help"]:
240
+ raw_argv = raw_argv[1:] + ["--help"] # `ccx help [command]`
241
+ p = build_parser()
242
+ # Parse first: --help and usage errors exit here without creating ~/.ccx.
243
+ args, extra = p.parse_known_args(raw_argv)
244
+ if extra:
245
+ _deepest_parser(p, raw_argv).error(f"unrecognized arguments: {' '.join(extra)}")
246
+ _refuse_codex_sandbox(args.cmd)
247
+ if args.cmd not in _SANDBOX_EXEMPT:
248
+ # Keep `ccx skills install` copies current after an upgrade; cheap
249
+ # when nothing changed, and never fatal.
250
+ from ccx.cli.skills import refresh_installed
251
+ refresh_installed()
252
+ if args.cmd == "init":
253
+ # Fully wired explicit empty-root bootstrap, not a legacy migration.
254
+ args.func(args)
255
+ return
256
+ if args.cmd == "skills":
257
+ # Setup discovery must work before any root exists or is initialized.
258
+ args.func(args)
259
+ return
260
+ if args.cmd == "rpc":
261
+ # RPC validates capabilities before opening an active store.
262
+ args.func(args)
263
+ return
264
+ # A worker-owned Python shim is pinned to its creator root, even when
265
+ # the command has an unrelated CCX_ROOT in its environment. The native
266
+ # Python child still inherits that caller environment unchanged.
267
+ if args.cmd == "_live-vars-exec" and args.root:
268
+ # Shell functions generated before --root existed still call without
269
+ # it; they keep resolving through the caller's CCX_ROOT.
270
+ from ccx import constants
271
+ # Native Python remains the caller's program: save its exact environment
272
+ # before pinning CCX internals to this worker's (different) root.
273
+ args._bridge_native_env = os.environ.copy()
274
+ constants.select_root(args.root)
275
+ # Validate the sealed store before handlers perform side effects.
276
+ from ccx.constants import CCX_ROOT
277
+ from ccx.store.root import GenerationError, require_ccx_active, open_or_initialize
278
+ try:
279
+ user_facing = (args.cmd in _AUTO_INIT_COMMANDS
280
+ and not (args.cmd == "controller"
281
+ and args.controller_cmd in {"_launch", "_host"}))
282
+ if user_facing:
283
+ active_store, created = open_or_initialize(CCX_ROOT)
284
+ if created:
285
+ sys.stderr.write(f"ccx: created store at {active_store.root}\n")
286
+ else:
287
+ active_store = require_ccx_active(CCX_ROOT)
288
+ except (GenerationError, OSError) as exc:
289
+ sys.stderr.write(f"ccx unavailable: {exc}\n")
290
+ raise SystemExit(3) from None
291
+ from ccx.errors import CcxError, SessionNotFound
292
+ try:
293
+ _preflight_worker_references(args, active_store)
294
+ except CcxError as exc:
295
+ sys.stderr.write(
296
+ f"{exc}\n" if isinstance(exc, SessionNotFound)
297
+ else f"ccx worker discovery: {exc}\n"
298
+ )
299
+ raise SystemExit(exc.exit_code) from exc
300
+ handler = _native_handler(args, active_store)
301
+ handler(args)
302
+ return