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,367 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ import time
5
+ from typing import Any
6
+ from ccx.codex.records import now_ms
7
+ from ccx.codex.daemon.models import ReleasePending, _ThreadRelease, _retire_runtime_markers
8
+ import ccx.codex.daemon.models as daemon_models
9
+ from typing import TYPE_CHECKING
10
+ if TYPE_CHECKING:
11
+ from ccx.codex.daemon.worker import Worker
12
+
13
+
14
+ _RELEASE_FENCE_WAIT_S = 5.0
15
+ # A stopped worker adopted moments ago (never loaded) is about to be resumed,
16
+ # killed or sent to; its caller takes load_lock right after adoption. Leave it
17
+ # alone briefly so the janitor's cleanup doesn't unload it first.
18
+ _STOPPED_UNLOAD_GRACE_S = 5.0
19
+
20
+
21
+ def _abandon_killed_load(daemon, worker: Worker) -> None:
22
+ """Finish a kill the loader was still racing, durably.
23
+
24
+ This is the completion path for a kill that could not confirm — an
25
+ unresolved `turn/start` — as well as the tidy-up for one that could.
26
+ Deregistering alone is not enough: leaving a nonterminal record behind
27
+ would show a killed worker as `busy` forever. So the record is written
28
+ terminal and its transient runtime fields cleared here, exactly
29
+ as a confirmed kill does, before the worker leaves the registries.
30
+
31
+ Idempotent, and it never touches another worker's bindings.
32
+ """
33
+ worker.loaded = False
34
+ # `_state` then `_unload` is the same durable order `kill` uses:
35
+ # terminal state first, then turn/approval fields cleared and saved.
36
+ worker._state("dead")
37
+ daemon._unload(worker, owns_lifecycle=True, terminal_state="dead")
38
+
39
+
40
+ def _unload(
41
+ daemon,
42
+ worker: Worker,
43
+ *,
44
+ terminal_state: str,
45
+ owns_lifecycle: bool = False,
46
+ ) -> None:
47
+ """Take one Worker object out of the daemon and release its threads.
48
+
49
+ Exactly one teardown runs per object. Only the registered object is
50
+ torn down: the janitor's snapshot can outlive a kill and a reload, and
51
+ a stale object must not save its record over the fresh worker's, drop
52
+ the fresh worker's bindings or release the thread the fresh worker
53
+ resumed. A second unload of an object already being torn down returns
54
+ at once. The exception is the loader (`owns_lifecycle`): it holds this
55
+ short's lifecycle lock, so no fresh object can exist, and a kill that
56
+ raced its resume released the thread before that resume subscribed
57
+ it, so the loader must release it again.
58
+
59
+ The release never waits for Codex. Each thread gets a fence that a
60
+ load of that thread waits on, and that only Codex's answer (or the
61
+ loss of the connection) clears; see `_release_native_threads`.
62
+ """
63
+ # Sequential, never nested inside daemon.lock: lock order is
64
+ # daemon.lock -> worker.lock everywhere else.
65
+ with daemon.lock:
66
+ current = daemon.workers.get(worker.short)
67
+ if owns_lifecycle:
68
+ if current is not None and current is not worker:
69
+ return
70
+ elif current is not worker or worker.teardown_claimed:
71
+ return
72
+ # Lock order is daemon.lock -> worker.lock. A native callback must
73
+ # not change the active-command set between the idle-only check
74
+ # and the final teardown claim.
75
+ with worker.lock:
76
+ if terminal_state == "idle-killed" and not owns_lifecycle:
77
+ if worker.attach_count or worker._bg_open:
78
+ return
79
+ worker.teardown_claimed = True
80
+ releases = [
81
+ _fence_release_locked(daemon, thread_id)
82
+ for thread_id in sorted(worker.native_threads)
83
+ if daemon.by_thread.get(thread_id, worker.short) == worker.short
84
+ ]
85
+ # Unrouted before the final record write, so no notification or
86
+ # server request looked up from now on can reach this worker; one
87
+ # routed earlier sees `teardown_claimed` under `worker.lock` and
88
+ # changes nothing (see `on_notification`).
89
+ daemon._drop_thread_bindings_locked(worker.short)
90
+ # Once claimed, the teardown always finishes: even if the record write
91
+ # fails, the worker leaves the registry and every fence is sent or
92
+ # settled, since no later unload may claim it again.
93
+ try:
94
+ # The journal is closed next: an unloaded worker observes nothing
95
+ # more, and the close is where its last events reach the disk — so
96
+ # its outcome, including a failed final flush, belongs in the
97
+ # record write below rather than being discovered by nobody.
98
+ with worker.lock:
99
+ # Its last lifecycle facts go in before the journal closes;
100
+ # the save below then finds nothing new to journal.
101
+ worker._journal_lifecycle_locked(
102
+ state=terminal_state, approvals=[]
103
+ )
104
+ worker.evidence.close()
105
+ with worker.lock:
106
+ lifecycle = worker.record["lifecycle"]
107
+ # A notification handled after kill wrote its terminal state
108
+ # but before the claim may have moved the record; the teardown
109
+ # owns it.
110
+ if lifecycle["state"] != terminal_state:
111
+ lifecycle["since"] = now_ms()
112
+ lifecycle["state"] = terminal_state
113
+ lifecycle["activeTurnId"] = None
114
+ worker.record["activeTurnError"] = None
115
+ # A server request cannot survive its JSON-RPC connection scope
116
+ # once the worker leaves the daemon.
117
+ worker.record["pendingApprovals"] = []
118
+ if worker.evidence.observed:
119
+ worker.record["evidence"] = worker.evidence.state()
120
+ worker._save()
121
+ finally:
122
+ with daemon.lock:
123
+ if daemon.workers.get(worker.short) is worker:
124
+ del daemon.workers[worker.short]
125
+ if not daemon.workers:
126
+ daemon.empty_since = time.monotonic()
127
+ daemon._release_native_threads(worker, releases)
128
+
129
+
130
+ def _retire_workers(daemon) -> None:
131
+ """Write every still-registered worker's record for a daemon exit."""
132
+ with daemon.lock:
133
+ remaining = list(daemon.workers.values())
134
+ daemon.workers.clear()
135
+ daemon.by_thread.clear()
136
+ for worker in remaining:
137
+ with worker.lock:
138
+ lifecycle = worker.record["lifecycle"]
139
+ lifecycle["activeTurnId"] = None
140
+ state = lifecycle["state"]
141
+ if state in {"booting", "resuming", "busy", "waiting"}:
142
+ # Work was cut, not finished: the same verdict the next
143
+ # daemon's orphan repair gives a record left this way.
144
+ lifecycle["state"] = "crashed"
145
+ lifecycle["lastError"] = (
146
+ "codexd stopped while the worker was active")
147
+ lifecycle["since"] = now_ms()
148
+ elif state == "idle":
149
+ # A daemon exit merely unloads an idle worker; `dead` is
150
+ # reserved for an explicit kill.
151
+ lifecycle["state"] = "idle-killed"
152
+ lifecycle["since"] = now_ms()
153
+ elif state not in {"idle-killed", "crashed", "dead", "budget-stopped"}:
154
+ lifecycle["state"] = "dead"
155
+ lifecycle["since"] = now_ms()
156
+ _retire_runtime_markers(
157
+ worker.record,
158
+ "codexd stopped before the compaction was observed")
159
+ # This native app-server is gone. Neither its open command IDs
160
+ # nor foreground candidates can pin the next runtime's janitor.
161
+ worker._lose_native_background_locked()
162
+ # A server request cannot survive the process that owns its
163
+ # JSON-RPC connection.
164
+ worker.record["pendingApprovals"] = []
165
+ worker._save()
166
+
167
+
168
+ def _fence_release_locked(daemon, thread_id: str) -> "_ThreadRelease":
169
+ release = _ThreadRelease(thread_id)
170
+ daemon.releases.setdefault(thread_id, []).append(release)
171
+ return release
172
+
173
+
174
+ def _settle_release(daemon, release: "_ThreadRelease") -> None:
175
+ with daemon.lock:
176
+ fences = daemon.releases.get(release.thread_id) or []
177
+ if release in fences:
178
+ fences.remove(release)
179
+ if not fences:
180
+ daemon.releases.pop(release.thread_id, None)
181
+ release.settled.set()
182
+
183
+
184
+ def _release_native_threads(
185
+ daemon, worker: Worker, releases: list["_ThreadRelease"]
186
+ ) -> None:
187
+ """Send `thread/unsubscribe` for each fenced thread; never wait for it.
188
+
189
+ Forgetting a worker locally is not enough: Codex keeps a thread, and
190
+ any stdio MCP servers it started, loaded for as long as this connection
191
+ stays subscribed, which on a long-lived daemon means forever. Once the
192
+ last subscriber leaves, Codex unloads an idle thread after its own
193
+ `thread_unload_delay_secs`; a later resume reloads it from its rollout.
194
+
195
+ Each fence stays until Codex answers or the connection is lost, never
196
+ on a local timeout: a load of the thread waits on it, so a late
197
+ unsubscribe can never land after that load's resume and leave a live
198
+ worker deaf. A refusal only costs memory until the app-server exits,
199
+ so it is logged.
200
+
201
+ Sending still writes to the app-server's stdin under the client's
202
+ write lock, so it can wait briefly behind another request's write. It
203
+ blocks for long only if Codex stops reading its input, and then every
204
+ other request, including kill's own `turn/interrupt`, hangs the same
205
+ way, so the send is not moved to another thread.
206
+ """
207
+ client = daemon.client
208
+ for release in releases:
209
+ if client is None:
210
+ _settle_release(daemon, release)
211
+ continue
212
+ try:
213
+ pending = client.begin_request(
214
+ "thread/unsubscribe", {"threadId": release.thread_id}
215
+ )
216
+ except Exception as error: # noqa: BLE001 - must not block unload
217
+ _log_release_failure(daemon, worker, release.thread_id, error)
218
+ _settle_release(daemon, release)
219
+ continue
220
+ pending.add_done_callback(
221
+ lambda answered, release=release: _on_release_answered(daemon, worker, release, answered
222
+ )
223
+ )
224
+
225
+
226
+ def _on_release_answered(
227
+ daemon, worker: Worker, release: "_ThreadRelease", answered: Any
228
+ ) -> None:
229
+ # Runs on the reader thread: no app-server request may be made here.
230
+ try:
231
+ answered.result()
232
+ except Exception as error: # noqa: BLE001
233
+ _log_release_failure(daemon, worker, release.thread_id, error)
234
+ finally:
235
+ _settle_release(daemon, release)
236
+
237
+
238
+ def _log_release_failure(daemon, worker: Worker, thread_id: str, error: Any) -> None:
239
+ print(
240
+ f"ccx codexd: could not release Codex thread {thread_id} "
241
+ f"of worker {worker.short}: {error}",
242
+ file=sys.stderr, flush=True,
243
+ )
244
+
245
+
246
+ def _await_thread_release(daemon, thread_id: str | None) -> None:
247
+ """Wait briefly for Codex to answer an earlier release of `thread_id`.
248
+
249
+ Raises `ReleasePending` rather than let a resume of the thread race an
250
+ unsubscribe Codex has not answered yet.
251
+ """
252
+ if not thread_id:
253
+ return
254
+ deadline = time.monotonic() + _RELEASE_FENCE_WAIT_S
255
+ while True:
256
+ with daemon.lock:
257
+ fences = list(daemon.releases.get(thread_id) or [])
258
+ if not fences:
259
+ return
260
+ for fence in fences:
261
+ if not fence.settled.wait(max(0.0, deadline - time.monotonic())):
262
+ raise ReleasePending(
263
+ f"Codex has not yet confirmed releasing thread "
264
+ f"{thread_id} from an earlier unload; retry shortly"
265
+ )
266
+
267
+
268
+ def _janitor(daemon) -> None:
269
+ while not daemon.stop.wait(1):
270
+ # The janitor is the only thing that idle-kills workers and ends
271
+ # the linger. An exception escaping it would end both silently and
272
+ # keep codexd and its app-server alive forever, so every step is
273
+ # isolated and logged.
274
+ try:
275
+ with daemon.lock:
276
+ snapshot = list(daemon.workers.values())
277
+ for worker in snapshot:
278
+ try:
279
+ daemon._janitor_worker(worker)
280
+ except Exception as error: # noqa: BLE001
281
+ _log_janitor_failure(daemon, f"worker {worker.short}", error)
282
+ daemon._janitor_native_start_pass()
283
+ if daemon._janitor_daemon_pass():
284
+ return
285
+ except Exception as error: # noqa: BLE001
286
+ _log_janitor_failure(daemon, "pass", error)
287
+
288
+
289
+ def _log_janitor_failure(daemon, what: str, error: BaseException) -> None:
290
+ print(f"ccx codexd: janitor {what} failed: {error!r}",
291
+ file=sys.stderr, flush=True)
292
+
293
+
294
+ def _janitor_worker(daemon, worker: Worker) -> None:
295
+ if worker.load_lock.locked():
296
+ return
297
+ worker.flush_context()
298
+ with worker.lock:
299
+ state = worker.record["lifecycle"]["state"]
300
+ timeout = int(
301
+ worker.record["createArgs"].get("idleTimeout", 1800)
302
+ )
303
+ expired = (
304
+ state == "idle"
305
+ and timeout > 0
306
+ and worker.attach_count == 0
307
+ and not worker._bg_open
308
+ and time.monotonic() - worker.last_activity >= timeout
309
+ )
310
+ if expired:
311
+ if worker.kill_intent.is_set() or worker.killed.is_set():
312
+ # An explicit kill owns this worker's terminal record;
313
+ # idle-kill must not write `idle-killed` over it.
314
+ return
315
+ if not worker.operation_lock.acquire(blocking=False):
316
+ return
317
+ try:
318
+ if (worker.kill_intent.is_set() or worker.killed.is_set()
319
+ or not daemon._still_registered(worker)):
320
+ return
321
+ with worker.lock:
322
+ state = worker.record["lifecycle"]["state"]
323
+ timeout = int(
324
+ worker.record["createArgs"].get(
325
+ "idleTimeout", 1800
326
+ )
327
+ )
328
+ expired = (
329
+ state == "idle"
330
+ and timeout > 0
331
+ and worker.attach_count == 0
332
+ and not worker._bg_open
333
+ and time.monotonic() - worker.last_activity >= timeout
334
+ )
335
+ if expired:
336
+ daemon._idle_kill(worker)
337
+ return
338
+ finally:
339
+ worker.operation_lock.release()
340
+ if (state in {"idle-killed", "crashed", "dead"}
341
+ and (worker.loaded
342
+ or time.monotonic() - worker.last_activity >= _STOPPED_UNLOAD_GRACE_S)):
343
+ daemon._unload(worker, terminal_state=state)
344
+
345
+
346
+ def _janitor_daemon_pass(daemon) -> bool:
347
+ """Linger; True once the daemon should stop."""
348
+ with daemon.lock:
349
+ with daemon._native_start_hold_lock:
350
+ # In-flight originals and safely held positive replies keep
351
+ # the daemon alive until a definitive provider result or fsync.
352
+ # Unsafe diagnostic history does not pin the singleton.
353
+ native_pending = bool(daemon._native_start_held)
354
+ if daemon.workers or native_pending:
355
+ daemon.empty_since = None
356
+ expired = False
357
+ else:
358
+ if daemon.empty_since is None:
359
+ daemon.empty_since = time.monotonic()
360
+ expired = (
361
+ time.monotonic() - daemon.empty_since >= daemon_models.linger_seconds()
362
+ )
363
+ if expired:
364
+ daemon.stopping = True
365
+ if expired:
366
+ daemon.stop.set()
367
+ return expired