ccx-native 0.0.0-stage → 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 -2
  3. package/bin/ccx +56 -0
  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 +40 -4
  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
File without changes
@@ -0,0 +1,267 @@
1
+ """Local Codex TUI PTY with the existing CCX terminal status painter.
2
+
3
+ Codex owns its native input, including /quit, Ctrl-C and Ctrl-D. CCX
4
+ filters only the shared Claude detach keys before forwarding to the local
5
+ Codex TUI client. Codexd retains the native thread independently.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import fcntl
10
+ import os
11
+ import pty
12
+ import select
13
+ import signal
14
+ import struct
15
+ import termios
16
+ import time
17
+ import tty
18
+
19
+ from ccx.claude.attach import DetachFilter, MAX_PENDING_OUTPUT, StatusBar, _tty_size, _write_tty
20
+ from ccx.codex import runtime as codex_runtime
21
+
22
+ _MAX_INPUT = 64 * 1024
23
+
24
+
25
+ def _codex_status(worker_id: str) -> dict | None:
26
+ """One cheap read of the *existing* worker; never start a daemon or turn."""
27
+ observation = codex_runtime.observe_worker(worker_id, timeout=0.8)
28
+ reply = observation.get("reply") or {}
29
+ if observation.get("presence") != "served" or reply.get("ok") is not True:
30
+ return None
31
+ state = reply.get("state")
32
+ if state == "waiting" and reply.get("pendingApprovals"):
33
+ state = "waiting for approval"
34
+ elif state == "busy":
35
+ # Compact owns the busy state too. Only inspect its existing marker,
36
+ # never call the native provider or acquire an operation lock.
37
+ compact = codex_runtime.daemon_rpc(
38
+ {"op": "compact-observe", "short": worker_id}, timeout=0.8,
39
+ )
40
+ if compact.get("ok") is True and isinstance(compact.get("activeCompact"), dict):
41
+ state = "compacting"
42
+ return {"ok": True, "state": state}
43
+
44
+
45
+ def _spawn_tui(argv: list[str], cwd: str, rows: int, cols: int) -> tuple[int, int]:
46
+ """Start only our own controlling PTY child, with its size set before exec."""
47
+ gate_r, gate_w = os.pipe()
48
+ try:
49
+ pid, master = pty.fork()
50
+ except BaseException:
51
+ os.close(gate_r)
52
+ os.close(gate_w)
53
+ raise
54
+ if pid == 0:
55
+ os.close(gate_w)
56
+ try:
57
+ if os.read(gate_r, 1) != b"1":
58
+ os._exit(125)
59
+ os.close(gate_r)
60
+ os.chdir(cwd)
61
+ os.execvp(argv[0], argv)
62
+ except BaseException as error:
63
+ os.write(2, f"ccx attach: cannot start Codex: {error}\n".encode()[:512])
64
+ os._exit(127)
65
+ os.close(gate_r)
66
+ try:
67
+ fcntl.ioctl(master, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
68
+ os.write(gate_w, b"1")
69
+ return pid, master
70
+ except BaseException:
71
+ try:
72
+ os.killpg(pid, signal.SIGKILL)
73
+ except ProcessLookupError:
74
+ pass
75
+ os.waitpid(pid, 0)
76
+ os.close(master)
77
+ raise
78
+ finally:
79
+ os.close(gate_w)
80
+
81
+
82
+ def _reap_owned_tui(pid: int) -> None:
83
+ """Detach only the local TUI group, never the codexd-owned worker.
84
+
85
+ SIGHUP lets the Codex remote client exit cleanly; escalation is bounded
86
+ even when the client or its launcher ignores hangup/termination.
87
+ """
88
+ try:
89
+ if os.getpgid(pid) != pid:
90
+ return # do not signal a group that cannot be proven ours
91
+ except ProcessLookupError:
92
+ return
93
+ for sig in (signal.SIGHUP, signal.SIGTERM):
94
+ try:
95
+ os.killpg(pid, sig)
96
+ except ProcessLookupError:
97
+ pass
98
+ deadline = time.monotonic() + .6
99
+ while time.monotonic() < deadline:
100
+ try:
101
+ exited, _ = os.waitpid(pid, os.WNOHANG)
102
+ if exited:
103
+ return
104
+ except ChildProcessError:
105
+ return
106
+ time.sleep(.03)
107
+ try:
108
+ os.killpg(pid, signal.SIGKILL)
109
+ except ProcessLookupError:
110
+ pass
111
+ try:
112
+ os.waitpid(pid, 0)
113
+ except ChildProcessError:
114
+ pass
115
+
116
+
117
+ def run_status_tui(
118
+ argv: list[str], *, cwd: str, worker_id: str, input_fd: int = 0,
119
+ output_fd: int = 1, status_fetch=None, status=True,
120
+ ) -> int:
121
+ """Proxy Codex through a PTY, with optional CCX status bar and detach keys."""
122
+ bar = (StatusBar(
123
+ worker_id[:8],
124
+ fetch=status_fetch if status_fetch is not None
125
+ else lambda: _codex_status(worker_id),
126
+ ) if status else None)
127
+ old_attrs = termios.tcgetattr(input_fd)
128
+ old_flags = fcntl.fcntl(output_fd, fcntl.F_GETFL)
129
+ rows, cols = _tty_size(output_fd)
130
+ pid, master = _spawn_tui(argv, cwd, bar.resize(rows, cols) if bar else rows, cols)
131
+ os.set_blocking(master, False)
132
+ handlers = {}
133
+ resized = [False]
134
+ stopped = [None]
135
+ child_exit = None
136
+ child_exited_at = None
137
+
138
+ def on_resize(_sig, _frame):
139
+ resized[0] = True
140
+
141
+ def on_stop(sig, _frame):
142
+ stopped[0] = sig
143
+
144
+ try:
145
+ for sig, fn in ((signal.SIGWINCH, on_resize),
146
+ (signal.SIGTERM, on_stop), (signal.SIGHUP, on_stop)):
147
+ handlers[sig] = signal.getsignal(sig)
148
+ signal.signal(sig, fn)
149
+ fcntl.fcntl(output_fd, fcntl.F_SETFL, old_flags | os.O_NONBLOCK)
150
+ tty.setraw(input_fd)
151
+ if bar is not None:
152
+ bar.start()
153
+ output = bytearray()
154
+ pending_input = bytearray()
155
+ filter_ = DetachFilter()
156
+ escape_at = None
157
+ master_ended = False
158
+
159
+ while stopped[0] is None:
160
+ if resized[0]:
161
+ resized[0] = False
162
+ rows, cols = _tty_size(output_fd)
163
+ child_rows = bar.resize(rows, cols) if bar else rows
164
+ fcntl.ioctl(master, termios.TIOCSWINSZ,
165
+ struct.pack("HHHH", child_rows, cols, 0, 0))
166
+ if bar is not None:
167
+ bar.update()
168
+ output.extend(bar.poll())
169
+ if child_exit is None:
170
+ exited, status = os.waitpid(pid, os.WNOHANG)
171
+ if exited:
172
+ child_exit = os.waitstatus_to_exitcode(status)
173
+ child_exited_at = time.monotonic()
174
+ if child_exit is not None and master_ended and not output:
175
+ return child_exit
176
+ # A stopped terminal reader must never pin termios restoration
177
+ # after the native child has exited.
178
+ if child_exited_at is not None and time.monotonic() - child_exited_at > 2:
179
+ return child_exit
180
+
181
+ readers = []
182
+ if not master_ended and len(output) < MAX_PENDING_OUTPUT - 32768:
183
+ readers.append(master)
184
+ if child_exit is None and len(pending_input) < _MAX_INPUT:
185
+ readers.append(input_fd)
186
+ writers = []
187
+ if output:
188
+ writers.append(output_fd)
189
+ if pending_input and not master_ended:
190
+ writers.append(master)
191
+ try:
192
+ ready, writable, _ = select.select(readers, writers, [], .05)
193
+ except InterruptedError:
194
+ continue
195
+ if output_fd in writable:
196
+ try:
197
+ n = os.write(output_fd, output[:16384])
198
+ if not n:
199
+ raise OSError("terminal output unavailable")
200
+ del output[:n]
201
+ except (BlockingIOError, InterruptedError):
202
+ pass
203
+ if master in writable:
204
+ try:
205
+ n = os.write(master, pending_input[:16384])
206
+ if not n:
207
+ raise OSError("Codex PTY no longer receives input")
208
+ del pending_input[:n]
209
+ except (BlockingIOError, InterruptedError):
210
+ pass
211
+ except OSError:
212
+ pending_input.clear()
213
+ master_ended = True
214
+ if input_fd in ready:
215
+ data = os.read(input_fd, 16384)
216
+ if not data:
217
+ break
218
+ filtered = filter_.feed(data)
219
+ if filter_.detached:
220
+ # CCX detaches only its local remote TUI; the codexd
221
+ # subscription and any running turn keep their lifecycle.
222
+ return 0
223
+ pending_input.extend(filtered)
224
+ escape_at = time.monotonic() if filter_.escape else None
225
+ if (filter_.escape and escape_at is not None
226
+ and time.monotonic() - escape_at >= .2):
227
+ # Same lone-ESC flush rule as shared Claude attach.
228
+ pending_input.extend(filter_.flush_escape())
229
+ escape_at = None
230
+ if master in ready:
231
+ try:
232
+ data = os.read(master, 16384)
233
+ except (BlockingIOError, InterruptedError):
234
+ data = None
235
+ except OSError: # EIO is normal PTY EOF on Linux.
236
+ data = b""
237
+ if data == b"":
238
+ master_ended = True
239
+ elif data:
240
+ output.extend(bar.feed(data) if bar is not None else data)
241
+
242
+ return 128 + stopped[0] if stopped[0] is not None else 1
243
+ finally:
244
+ if bar is not None:
245
+ bar.stop()
246
+ if child_exit is None:
247
+ _reap_owned_tui(pid)
248
+ os.close(master)
249
+ try:
250
+ try:
251
+ _write_tty(
252
+ output_fd, (bar.restore() if bar is not None else b"") +
253
+ b"\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1006l"
254
+ b"\x1b[?1004l\x1b[?25h\x1b[?1049l",
255
+ timeout_s=0.1,
256
+ )
257
+ except (OSError, TimeoutError, InterruptedError):
258
+ pass
259
+ finally:
260
+ try:
261
+ termios.tcsetattr(input_fd, termios.TCSANOW, old_attrs)
262
+ finally:
263
+ try:
264
+ fcntl.fcntl(output_fd, fcntl.F_SETFL, old_flags)
265
+ finally:
266
+ for sig, fn in handlers.items():
267
+ signal.signal(sig, fn)