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,269 @@
1
+ from __future__ import annotations
2
+ import fcntl
3
+ import os
4
+ import re
5
+ import select
6
+ import struct
7
+ import termios
8
+ import time
9
+ from ccx.constants import (
10
+ CLEAR_KILL_REPEAT,
11
+ POST_CLEAR_SETTLE_S,
12
+ PASTE_INLINE_MAX_NEWLINES,
13
+ PASTE_INLINE_MAX_UNITS,
14
+ PASTE_INLINE_ROWS_RESERVED,
15
+ PASTE_SUBMIT_DELAY_S,
16
+ PASTE_STAGED_BYTES_PER_S,
17
+ PASTE_STAGED_MAX_DELAY_S,
18
+ PASTE_STAGED_THRESHOLD_BYTES,
19
+ PTY_QUIESCENCE_S,
20
+ PTY_WRITE_TIMEOUT_S,
21
+ )
22
+ from ccx.claude.transcript import BARE_IMAGE_PATH_RE
23
+
24
+ # Claude (2.1.319) checks every pasted line that, trimmed, unquoted and with
25
+ # backslash escapes removed, ends in .png, .jpg, .jpeg, .gif or .webp: it
26
+ # looks for the image file asynchronously and inserts the paste only then,
27
+ # after any pastes that arrived meanwhile, and drops an Enter pressed during
28
+ # the check. So a paste boundary goes right after the dot of every image
29
+ # extension. More formats than Claude checks today: extra boundaries are free.
30
+ _IMAGE_EXTENSIONS = ("png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg",
31
+ "avif", "heic", "heif", "tif", "tiff")
32
+ _IMAGE_EXTENSION_DOT = re.compile(
33
+ r"\.(?=\\*(?:%s))" % "|".join(r"\\*".join(ext) for ext in _IMAGE_EXTENSIONS),
34
+ re.IGNORECASE)
35
+
36
+
37
+ def set_winsize(fd, rows, cols):
38
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
39
+
40
+
41
+ def paste_bytes(text: str) -> bytes:
42
+ """Frame text as one bracketed paste, without the submitting Enter.
43
+
44
+ Claude's decoder emits the pasted text only after the closing marker. The
45
+ caller sends Enter separately after this complete payload has drained.
46
+ """
47
+ return b"\x1b[200~" + text.encode() + b"\x1b[201~"
48
+
49
+
50
+ def paste_submit_delay(text: str) -> float:
51
+ """How long Ink gets to materialize a paste before CCX presses Enter.
52
+
53
+ Small prompts remain effectively immediate. Large prompts are staged: Ink
54
+ asynchronously turns them into a collapsed ``[Pasted text …]`` attachment,
55
+ and an Enter delivered before that work finishes is silently discarded.
56
+ The caller must wait without blocking the host loop so PTY output continues
57
+ to drain during this interval.
58
+ """
59
+ size = len(text.encode())
60
+ if size <= PASTE_STAGED_THRESHOLD_BYTES:
61
+ return PASTE_SUBMIT_DELAY_S
62
+ return min(PASTE_STAGED_MAX_DELAY_S, size / PASTE_STAGED_BYTES_PER_S)
63
+
64
+
65
+ def get_winsize_rows(fd) -> int:
66
+ """The PTY's current row count (viewers may resize it at any time)."""
67
+ rows, _cols, _x, _y = struct.unpack(
68
+ "HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\0" * 8))
69
+ return rows
70
+
71
+
72
+ def inline_paste_newline_cap(rows: int) -> int:
73
+ """Line breaks one paste may carry and still be inserted as typed text."""
74
+ return max(0, min(rows - PASTE_INLINE_ROWS_RESERVED, PASTE_INLINE_MAX_NEWLINES))
75
+
76
+
77
+ def paste_chunks(text: str, max_newlines: int,
78
+ max_units: int = PASTE_INLINE_MAX_UNITS) -> list[str] | None:
79
+ """Split text into pastes small enough for Claude to insert as typed text.
80
+
81
+ Sizes are measured the way Claude's paste handler measures them: UTF-16
82
+ units after it normalizes CRLF/CR to LF and expands tabs to four spaces.
83
+ A CRLF pair is never split. Returns None when the text has a line break but
84
+ no paste may carry one (a PTY under ``PASTE_INLINE_ROWS_RESERVED + 1``
85
+ rows), so the caller falls back to a single paste.
86
+
87
+ No paste holds an image extension whole (see ``_IMAGE_EXTENSION_DOT``),
88
+ except in a prompt that is only an image path, which Claude attaches as
89
+ the image.
90
+ """
91
+ breaks = (set() if BARE_IMAGE_PATH_RE.match(text.strip())
92
+ else {m.end() for m in _IMAGE_EXTENSION_DOT.finditer(text)})
93
+ chunks: list[str] = []
94
+ cur: list[str] = []
95
+ units = newlines = 0
96
+ i = 0
97
+ while i < len(text):
98
+ ch = text[i]
99
+ if ch == "\r" and text[i + 1:i + 2] == "\n":
100
+ ch = "\r\n"
101
+ is_newline = ch in ("\n", "\r", "\r\n")
102
+ if is_newline and max_newlines < 1:
103
+ return None
104
+ cost = 4 if ch == "\t" else 2 if ord(ch[0]) > 0xFFFF else 1
105
+ if cur and (units + cost > max_units or i in breaks):
106
+ chunks.append("".join(cur))
107
+ cur, units, newlines = [], 0, 0
108
+ cur.append(ch)
109
+ units += cost
110
+ i += len(ch)
111
+ if is_newline:
112
+ newlines += 1
113
+ if newlines >= max_newlines:
114
+ chunks.append("".join(cur))
115
+ cur, units, newlines = [], 0, 0
116
+ if cur:
117
+ chunks.append("".join(cur))
118
+ return chunks
119
+
120
+
121
+ def prompt_paste(text: str, rows: int) -> tuple[bytes, float, int]:
122
+ """Frame a prompt for Claude's editor: ``(payload, submit_delay, pastes)``.
123
+
124
+ Normally a run of small pastes (see ``paste_chunks``) that Claude inserts
125
+ verbatim, so the submitted prompt reaches the model as the user's own
126
+ words rather than wrapped in <pasted_content> tags, and Enter can follow
127
+ immediately. A PTY too short for inline line breaks falls back to one
128
+ paste with the size-aware staged submit.
129
+ """
130
+ chunks = paste_chunks(text, inline_paste_newline_cap(rows))
131
+ if chunks is None:
132
+ return paste_bytes(text), paste_submit_delay(text), 1
133
+ chunks = chunks or [text]
134
+ return b"".join(paste_bytes(c) for c in chunks), PASTE_SUBMIT_DELAY_S, len(chunks)
135
+
136
+
137
+ def clear_input_bytes() -> bytes:
138
+ """Ctrl-C (Claude's app:interrupt). At an idle prompt it empties the whole
139
+ editor however many lines it holds, where a Ctrl-U burst only removes one
140
+ wrapped visual line per press. On an empty editor it only arms Claude's
141
+ exit confirmation, so a second press within 800ms would exit the worker:
142
+ the host spaces these presses at least ``CLEAR_INPUT_MIN_GAP_S`` apart."""
143
+ return b"\x03"
144
+
145
+
146
+ def stalled_paste_clear_action(slot: dict, now: float, last_pty: float) -> str | None:
147
+ """Only reject a partial PTY write after an explicit quiet editor clear."""
148
+ if not last_pty or now - last_pty < PTY_QUIESCENCE_S:
149
+ return None
150
+ unstuck = slot.get("stalled_unstuck_at")
151
+ if unstuck is None:
152
+ return "unstick"
153
+ if now - unstuck < POST_CLEAR_SETTLE_S:
154
+ return None
155
+ cleared = slot.get("stalled_cleared_at")
156
+ if cleared is None:
157
+ return "clear"
158
+ if now - cleared < POST_CLEAR_SETTLE_S:
159
+ return None
160
+ return "reject"
161
+
162
+
163
+ def write_all(fd: int, payload: bytes, *, timeout_s: float = PTY_WRITE_TIMEOUT_S,
164
+ absolute_timeout_s: float | None = None,
165
+ on_partial=None, on_read=None) -> bool:
166
+ """Drain short PTY writes, reading peer output to avoid bidirectional stalls.
167
+
168
+ The timeout bounds *lack of write progress*, not total payload time. A busy
169
+ Ink renderer can fill the opposite PTY buffer, stop reading input, and
170
+ deadlock a write-only host. ``on_read`` drains that output through the same
171
+ path as the host loop; it returns False on EOF. PTY write boundaries do not
172
+ change the logical bracketed pastes. ``absolute_timeout_s`` is an
173
+ optional non-renewable elapsed-time budget used only by attached-terminal
174
+ input; normal semantic writes preserve their progress-based timeout.
175
+ """
176
+ start = time.monotonic()
177
+ deadline = start + timeout_s
178
+ absolute_deadline = (start + absolute_timeout_s
179
+ if absolute_timeout_s is not None else None)
180
+ sent = 0
181
+ while sent < len(payload):
182
+ if absolute_deadline is not None and time.monotonic() >= absolute_deadline:
183
+ return False
184
+ try:
185
+ written = os.write(fd, payload[sent:])
186
+ except InterruptedError:
187
+ continue
188
+ except BlockingIOError:
189
+ written = 0
190
+ except OSError:
191
+ return False
192
+
193
+ if written > 0:
194
+ sent += written
195
+ deadline = time.monotonic() + timeout_s
196
+ if sent < len(payload) and on_partial is not None:
197
+ on_partial(written, sent, len(payload))
198
+ continue
199
+
200
+ remaining = deadline - time.monotonic()
201
+ if absolute_deadline is not None:
202
+ remaining = min(remaining, absolute_deadline - time.monotonic())
203
+ if remaining <= 0:
204
+ return False
205
+ try:
206
+ readable, writable, _ = select.select(
207
+ [fd] if on_read is not None else [], [fd], [], remaining
208
+ )
209
+ except InterruptedError:
210
+ continue
211
+ except OSError:
212
+ return False
213
+ if readable and on_read() is False:
214
+ return False
215
+ if not readable and not writable:
216
+ return False
217
+ return True
218
+
219
+
220
+ _ANSI_RE = re.compile(
221
+ rb"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)"
222
+ rb"|\x1b\[[0-9;?]*[ -/]*[@-~]"
223
+ rb"|\x1b[@-Z\\-_]"
224
+ )
225
+
226
+
227
+ def pty_tail_reason(buf: bytes | bytearray, *, max_chars: int = 300) -> str | None:
228
+ """Distill a dead worker's last PTY output into a one-line failure reason.
229
+
230
+ A worker that dies at boot (an unknown `--agent`, a rejected `--model`, an
231
+ onboarding/trust gate) prints why on the PTY and exits. Nothing else records
232
+ that: the host log holds only the launcher's stdio, so without this the
233
+ record kept an opaque state=crashed with lastError=None and the operator had
234
+ no way to tell a typo'd flag from a real crash.
235
+
236
+ Returns None when there is nothing quotable. Box-drawing and spinner residue
237
+ from a half-rendered TUI is dropped — a line must carry alphanumeric text to
238
+ count as a message — and consecutive duplicate lines (Ink re-renders the same
239
+ frame) collapse to one.
240
+ """
241
+ text = _ANSI_RE.sub(b"", bytes(buf)).decode("utf-8", "replace")
242
+ lines: list[str] = []
243
+ for raw in text.replace("\r", "\n").split("\n"):
244
+ line = "".join(c for c in raw if c.isprintable()).strip()
245
+ if not line or not any(c.isalnum() for c in line):
246
+ continue
247
+ if not lines or lines[-1] != line:
248
+ lines.append(line)
249
+ if not lines:
250
+ return None
251
+ return " ".join(lines[-3:])[:max_chars]
252
+
253
+
254
+ def unstick_bytes() -> bytes:
255
+ """A lone closing bracketed-paste marker (\\x1b[201~). If a lost first paste
256
+ left Claude's decoder stuck IN_PASTE (its closing marker never arrived),
257
+ control keys are swallowed into the paste buffer and ignored; this marker
258
+ forces the decoder back to normal and materializes whatever partial it held
259
+ into the visible editor, where a kill key can then remove it."""
260
+ return b"\x1b[201~"
261
+
262
+
263
+ def drain_bytes(kill_repeat: int = CLEAR_KILL_REPEAT) -> bytes:
264
+ """A burst of Ctrl-U (\\x15), used for the single-line ``/compact``
265
+ residue. Ctrl-U is bound to delete-to-line-start, not whole-buffer clear:
266
+ each press removes one wrapped visual line, so it cannot empty a long
267
+ multi-line prompt (see clear_input_bytes()). No-op once the buffer is
268
+ empty."""
269
+ return b"\x15" * kill_repeat