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
@@ -0,0 +1,374 @@
1
+ """Persistent stdio transport for Protocol V1 (`ccx rpc --serve`).
2
+
3
+ The one-shot transport pays interpreter start-up and the full CCX import on
4
+ every request. Here a warm parent reads newline-delimited request envelopes
5
+ and forks one child per request. The child runs exactly the one-shot
6
+ `respond` path, so each request keeps today's process-per-request semantics:
7
+ its own locks, caches and failure domain. Only the import is shared.
8
+
9
+ The parent never dispatches. It owns the streams, correlates each child's
10
+ single response line with its request, and enforces a hard deadline so every
11
+ request is answered within its budget even if a child wedges.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import selectors
19
+ import signal
20
+ import stat
21
+ import sys
22
+ import time
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+ from ccx.system import oslayer
27
+ from ccx.rpc.local_rpc import (
28
+ CANCEL_OP,
29
+ DEFAULT_TIMEOUT_S,
30
+ MAX_MESSAGE_BYTES,
31
+ PROTOCOL_VERSION,
32
+ SUPPORTED_PROTOCOL_VERSIONS,
33
+ _error,
34
+ _error_from_exception,
35
+ _params,
36
+ _request_id,
37
+ _required_text,
38
+ _serialized,
39
+ _success,
40
+ _timeout_seconds,
41
+ _validate_operation_params,
42
+ parse_request,
43
+ respond,
44
+ )
45
+
46
+ # Requests a single serve process runs at once; beyond it, refused as data.
47
+ MAX_IN_FLIGHT = 256
48
+ # How long past its own budget a child may run before the parent kills it.
49
+ DEADLINE_GRACE_S = 5.0
50
+ # Requests that only observe. A child running one dies with the parent: its
51
+ # answer has nowhere to go, and a held wait could otherwise outlive the server
52
+ # by hours. A mutating request is left to finish, as a one-shot `ccx rpc`
53
+ # would be when its caller dies; it has its own bounded timeout.
54
+ READ_ONLY_OPS = frozenset({
55
+ "system.info", "worker.inspect", "worker.wait", "turn.reconcile",
56
+ "worker.report", "worker.telemetry", "worker.events",
57
+ "worker.compact.status",
58
+ "transcript.read", "conversation.page", "account.usage.read",
59
+ "notification.inspect",
60
+ })
61
+ _REAP_INTERVAL_S = 1.0
62
+
63
+
64
+ @dataclass
65
+ class _Child:
66
+ pid: int
67
+ fd: int
68
+ request_id: str | None
69
+ op: str | None
70
+ deadline: float
71
+ buf: bytearray = field(default_factory=bytearray)
72
+ # Reaped already: its pid may belong to another process now.
73
+ exited: bool = False
74
+
75
+
76
+ def _envelope_field(raw: bytes, name: str) -> str | None:
77
+ """Best-effort text field of a request envelope; never raises."""
78
+ try:
79
+ value = json.loads(raw)
80
+ except (ValueError, UnicodeError):
81
+ return None
82
+ field_value = value.get(name) if isinstance(value, dict) else None
83
+ return field_value if isinstance(field_value, str) else None
84
+
85
+
86
+ def _deadline_of(raw: bytes) -> float:
87
+ try:
88
+ value = json.loads(raw)
89
+ budget = _timeout_seconds(value, DEFAULT_TIMEOUT_S) if isinstance(value, dict) else DEFAULT_TIMEOUT_S
90
+ except Exception:
91
+ budget = DEFAULT_TIMEOUT_S
92
+ return time.monotonic() + budget + DEADLINE_GRACE_S
93
+
94
+
95
+ def _max_fd() -> int:
96
+ try:
97
+ return int(os.sysconf("SC_OPEN_MAX"))
98
+ except (OSError, ValueError):
99
+ return 65536
100
+
101
+
102
+ def _run_child(raw: bytes, write_fd: int, parent: int, generation: str) -> None:
103
+ """Child side: answer one request on ``write_fd``, then exit."""
104
+ try:
105
+ if _envelope_field(raw, "op") in READ_ONLY_OPS:
106
+ oslayer.arm_parent_death(parent)
107
+ # Keep the parent's streams out of reach: stray prints go to stderr and
108
+ # nothing a request spawns can read the request stream.
109
+ devnull = os.open(os.devnull, os.O_RDONLY)
110
+ os.dup2(devnull, 0)
111
+ os.close(devnull)
112
+ os.dup2(2, 1)
113
+ # Nothing else the parent holds (its streams, the selector, sibling
114
+ # pipes) may outlive this request in a process it spawns.
115
+ os.closerange(3, write_fd)
116
+ os.closerange(write_fd + 1, _max_fd())
117
+ payload = respond(lambda: parse_request(raw),
118
+ expected_generation=generation)
119
+ view = memoryview(payload)
120
+ while view:
121
+ view = view[os.write(write_fd, view):]
122
+ finally:
123
+ os._exit(0)
124
+
125
+
126
+ class _Server:
127
+ def __init__(self, in_fd: int, out_fd: int, generation: str) -> None:
128
+ if not isinstance(generation, str) or not generation:
129
+ raise ValueError("serve requires the original ACTIVE generation")
130
+ self.generation = generation
131
+ self.in_fd = in_fd
132
+ self.out_fd = out_fd
133
+ # epoll refuses a regular file and kqueue never reports its EOF;
134
+ # select() treats it as always readable, which is what it is.
135
+ self.selector = (selectors.SelectSelector()
136
+ if stat.S_ISREG(os.fstat(in_fd).st_mode)
137
+ else selectors.DefaultSelector())
138
+ self.children: dict[int, _Child] = {}
139
+ self.pending = bytearray()
140
+ self.discarding = False
141
+ self.eof = False
142
+
143
+ def emit(self, payload: bytes) -> None:
144
+ view = memoryview(payload)
145
+ while view:
146
+ view = view[os.write(self.out_fd, view):]
147
+
148
+ def refuse(self, request_id: str | None, code: str, message: str, **details: Any) -> None:
149
+ self.emit(_serialized(_error(request_id, code, message, details=details or None)))
150
+
151
+ def start(self, raw: bytes) -> None:
152
+ request_id = _envelope_field(raw, "request_id")
153
+ op = _envelope_field(raw, "op")
154
+ if op == CANCEL_OP:
155
+ # Answered here: the request it names lives in this process.
156
+ self.cancel(raw, request_id)
157
+ return
158
+ if len(self.children) >= MAX_IN_FLIGHT:
159
+ self.refuse(request_id, "runtime_unavailable",
160
+ "serve concurrency limit reached", limit=MAX_IN_FLIGHT)
161
+ return
162
+ if request_id is not None and any(
163
+ child.request_id == request_id for child in self.children.values()
164
+ ):
165
+ # Responses are matched by request_id, so two in flight would be
166
+ # indistinguishable to the caller.
167
+ self.refuse(request_id, "conflict",
168
+ "a request with this request_id is already in flight")
169
+ return
170
+ read_fd, write_fd = os.pipe()
171
+ parent = os.getpid()
172
+ pid = os.fork()
173
+ if pid == 0:
174
+ os.close(read_fd)
175
+ _run_child(raw, write_fd, parent, self.generation)
176
+ os.close(write_fd)
177
+ os.set_blocking(read_fd, False)
178
+ child = _Child(pid, read_fd, request_id, op, _deadline_of(raw))
179
+ self.children[read_fd] = child
180
+ self.selector.register(read_fd, selectors.EVENT_READ, child)
181
+
182
+ def cancel(self, raw: bytes, request_id: str | None) -> None:
183
+ """Answer ``request.cancel``: stop one in-flight observation.
184
+
185
+ Only a request in ``READ_ONLY_OPS`` is stopped; it is answered with
186
+ ``cancelled`` before the cancel's own result. A mutation is left to
187
+ finish, since killing it would only turn its answer into a lost reply.
188
+ """
189
+ try:
190
+ request = parse_request(raw)
191
+ request_id = _request_id(request.get("request_id"))
192
+ if request.get("protocol_version") != PROTOCOL_VERSION:
193
+ self.refuse(
194
+ request_id, "unsupported_protocol_version",
195
+ f"unsupported protocol_version {request.get('protocol_version')!r}",
196
+ supported_protocol_versions=list(SUPPORTED_PROTOCOL_VERSIONS),
197
+ )
198
+ return
199
+ params = _params(request)
200
+ _validate_operation_params(CANCEL_OP, params)
201
+ target = _required_text(params, "cancel_request_id")
202
+ except Exception as error:
203
+ self.emit(_serialized(_error_from_exception(request_id, error)))
204
+ return
205
+ if any(child.request_id == request_id for child in self.children.values()):
206
+ self.refuse(request_id, "conflict",
207
+ "a request with this request_id is already in flight")
208
+ return
209
+ child = next(
210
+ (c for c in self.children.values() if c.request_id == target), None
211
+ )
212
+ if child is not None:
213
+ # An answer already in the pipe wins over the cancellation.
214
+ self.read_child(child)
215
+ if child.fd not in self.children:
216
+ child = None
217
+ if child is None:
218
+ reason = "not_in_flight"
219
+ elif child.op not in READ_ONLY_OPS:
220
+ reason = "not_cancellable"
221
+ else:
222
+ self.drop(child, "cancelled", "request cancelled by request.cancel")
223
+ reason = None
224
+ self.emit(_serialized(_success(request_id, {
225
+ "cancel_request_id": target,
226
+ "cancelled": reason is None,
227
+ "reason": reason,
228
+ })))
229
+
230
+ def drop(self, child: _Child, code: str, message: str) -> None:
231
+ """Kill an unanswered child and answer its request with ``code``."""
232
+ if not child.exited:
233
+ try:
234
+ os.kill(child.pid, signal.SIGKILL)
235
+ except ProcessLookupError:
236
+ pass
237
+ self.selector.unregister(child.fd)
238
+ os.close(child.fd)
239
+ del self.children[child.fd]
240
+ self.refuse(child.request_id, code, message)
241
+ if not child.exited:
242
+ try:
243
+ os.waitpid(child.pid, 0)
244
+ except ChildProcessError:
245
+ pass
246
+
247
+ def finish(self, child: _Child, payload: bytes | None) -> None:
248
+ self.selector.unregister(child.fd)
249
+ os.close(child.fd)
250
+ del self.children[child.fd]
251
+ if payload is None:
252
+ self.refuse(child.request_id, "internal_error",
253
+ "request process exited without a response")
254
+ else:
255
+ self.emit(payload)
256
+ # The response is complete; reap without waiting on anything the
257
+ # request may have spawned.
258
+ if not child.exited:
259
+ try:
260
+ os.waitpid(child.pid, os.WNOHANG)
261
+ except ChildProcessError:
262
+ pass
263
+
264
+ def read_child(self, child: _Child) -> None:
265
+ try:
266
+ chunk = os.read(child.fd, 65536)
267
+ except BlockingIOError:
268
+ if child.exited:
269
+ # Dead, its output drained, and a process it spawned keeps the
270
+ # pipe open: no line is coming.
271
+ self.finish(child, None)
272
+ return
273
+ if chunk:
274
+ child.buf.extend(chunk)
275
+ # A response is exactly one line; stop at it rather than waiting
276
+ # for EOF, which a process the request spawned could hold open.
277
+ end = child.buf.find(b"\n")
278
+ if end >= 0:
279
+ self.finish(child, bytes(child.buf[: end + 1]))
280
+ elif child.exited:
281
+ self.read_child(child)
282
+ return
283
+ self.finish(child, None)
284
+
285
+ def read_input(self) -> None:
286
+ chunk = os.read(self.in_fd, 65536)
287
+ if not chunk:
288
+ self.eof = True
289
+ self.selector.unregister(self.in_fd)
290
+ if self.pending.strip() and not self.discarding:
291
+ self.start(bytes(self.pending))
292
+ self.pending.clear()
293
+ return
294
+ self.pending.extend(chunk)
295
+ while True:
296
+ end = self.pending.find(b"\n")
297
+ if end < 0:
298
+ if len(self.pending) > MAX_MESSAGE_BYTES and not self.discarding:
299
+ self.discarding = True
300
+ self.refuse(None, "invalid_request",
301
+ "protocol request exceeds the 4 MiB V1 limit",
302
+ limit_bytes=MAX_MESSAGE_BYTES)
303
+ if self.discarding:
304
+ self.pending.clear()
305
+ return
306
+ line = bytes(self.pending[:end])
307
+ del self.pending[: end + 1]
308
+ if self.discarding:
309
+ self.discarding = False
310
+ continue
311
+ if len(line) > MAX_MESSAGE_BYTES:
312
+ self.refuse(None, "invalid_request",
313
+ "protocol request exceeds the 4 MiB V1 limit",
314
+ limit_bytes=MAX_MESSAGE_BYTES)
315
+ continue
316
+ if line.strip():
317
+ self.start(line)
318
+
319
+ def expire(self) -> None:
320
+ now = time.monotonic()
321
+ for child in [c for c in self.children.values() if c.deadline <= now]:
322
+ self.drop(child, "timeout",
323
+ "request exceeded its timeout_ms budget in serve mode")
324
+
325
+ def reap(self) -> None:
326
+ # Children whose response was already forwarded may exit later. A
327
+ # child still tracked is answered from what it wrote: its pipe is
328
+ # drained now, since a process it spawned may hold it open forever.
329
+ while True:
330
+ try:
331
+ pid, _status = os.waitpid(-1, os.WNOHANG)
332
+ except ChildProcessError:
333
+ return
334
+ if pid == 0:
335
+ return
336
+ for child in list(self.children.values()):
337
+ if child.pid == pid:
338
+ child.exited = True
339
+ self.read_child(child)
340
+
341
+ def run(self) -> int:
342
+ self.selector.register(self.in_fd, selectors.EVENT_READ, None)
343
+ while not (self.eof and not self.children):
344
+ soonest = min((c.deadline for c in self.children.values()), default=None)
345
+ # While requests run, wake at least every second to reap a child
346
+ # that died without a response (its pipe may never close).
347
+ wait = None if soonest is None else min(
348
+ _REAP_INTERVAL_S, max(0.0, soonest - time.monotonic()))
349
+ for key, _events in self.selector.select(wait):
350
+ if key.data is None:
351
+ self.read_input()
352
+ elif key.fd in self.children:
353
+ self.read_child(key.data)
354
+ self.expire()
355
+ self.reap()
356
+ return 0
357
+
358
+
359
+ def serve(in_fd: int | None = None, out_fd: int | None = None) -> int:
360
+ """Run the original per-request process server on one pinned ACTIVE generation."""
361
+ from ccx.constants import CCX_ROOT
362
+ from ccx.store.root import GenerationError, open_or_initialize
363
+ try:
364
+ generation = open_or_initialize(CCX_ROOT)[0].generation
365
+ except (GenerationError, OSError) as exc:
366
+ print(f"ccx rpc --serve unavailable: {exc}", file=sys.stderr)
367
+ return 3
368
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
369
+ in_fd = sys.stdin.fileno() if in_fd is None else in_fd
370
+ out_fd = sys.stdout.fileno() if out_fd is None else out_fd
371
+ return _Server(in_fd, out_fd, generation).run()
372
+
373
+
374
+ __all__ = ["DEADLINE_GRACE_S", "DEFAULT_TIMEOUT_S", "MAX_IN_FLIGHT", "serve"]