ccx-native 0.0.1 → 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 -4
  3. package/bin/ccx +55 -2
  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 +39 -7
  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,295 @@
1
+ """Machine-level maintenance: stop CCX runtimes, undo CCX's trust writes.
2
+
3
+ Both commands act on what CCX itself owns and nothing else: global ``shutdown``
4
+ stops managed Claude hosts, Codex workers loaded in the shared codexd (then
5
+ codexd) and the parent-notification supervisor. An engine-scoped shutdown
6
+ leaves the other engine and the notification supervisor untouched. ``untrust``
7
+ reverts only the ``~/.claude.json`` trust entries recorded in CCX's ledger. External
8
+ Claude sessions (native background agents, interactive terminals) and running
9
+ controllers are never touched.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import signal
17
+ import sys
18
+ import time
19
+
20
+ from ccx.constants import EXIT_UNAVAILABLE
21
+ from ccx.errors import CcxError
22
+ from ccx.ops.terminate import KILL_TIMEOUT_DEFAULT_S
23
+
24
+ _NOTIFYD_STOP_S = 5.0
25
+ _CODEXD_EXIT_S = 10.0
26
+
27
+
28
+ # --- shutdown ----------------------------------------------------------------
29
+
30
+ def _live_claude_workers(store) -> tuple[list[dict], list[dict]]:
31
+ from ccx.store.liveness import owned_process
32
+ from ccx.store.root import GenerationError, StorageIntegrityError
33
+ from ccx.store.identity import IdentityError
34
+
35
+ rows, errors = [], []
36
+ # The ACTIVE generation's full-ID owners, never legacy sessions/shorts.
37
+ for owner_row in store.list_workers(engine="claude"):
38
+ row = {key: owner_row[key] for key in (
39
+ "engine", "worker", "worker_id", "short", "name", "state")}
40
+ try:
41
+ record = store.record(row["worker"])
42
+ lifecycle = record.get("lifecycle") or {}
43
+ if not isinstance(lifecycle, dict):
44
+ raise StorageIntegrityError("original Claude lifecycle is malformed")
45
+ # Tokenless/unprovable PIDs remain visible so kill must refuse
46
+ # rather than letting shutdown mistake uncertainty for absence.
47
+ def present(pid, start):
48
+ return owned_process(pid, start) != "dead"
49
+
50
+ if present(lifecycle.get("hostPid"), lifecycle.get("hostStart")) or present(
51
+ lifecycle.get("workerPid"), lifecycle.get("workerStart")
52
+ ):
53
+ rows.append(row)
54
+ except (GenerationError, IdentityError, OSError) as error:
55
+ row["action"] = "failed"
56
+ rows.append(row)
57
+ errors.append({"engine": "claude", "worker": row["worker"],
58
+ "short": row["short"], "error": str(error)})
59
+ return rows, errors
60
+
61
+
62
+ def _loaded_codex_workers(store) -> tuple[str, list[dict], list[dict], int | None]:
63
+ from ccx.codex.runtime import observe_registry
64
+ from ccx.constants import CODEXD_RPC_GENERATION
65
+ from ccx.store.root import GenerationError
66
+ from ccx.store.identity import IdentityError
67
+
68
+ try:
69
+ registry = observe_registry(timeout=2.0)
70
+ except (CcxError, OSError, ValueError) as error:
71
+ registry = {"presence": "inconclusive", "reply": {"error": str(error)}}
72
+ presence = registry.get("presence")
73
+ if presence == "absent":
74
+ return presence, [], [], None
75
+
76
+ rows, errors = [], []
77
+ raw = registry.get("reply")
78
+ # A positively served response from an authenticated daemon of another
79
+ # RPC generation is not a readable worker inventory. Only its generation
80
+ # and the generation-agnostic daemon-stop operation can be used.
81
+ version = raw.get("rpcGeneration") if isinstance(raw, dict) else None
82
+ if (isinstance(raw, dict) and raw.get("transport") == "served"
83
+ and raw.get("ok") is True and type(version) is int
84
+ and version != CODEXD_RPC_GENERATION):
85
+ return "older-generation", rows, errors, version
86
+ workers = raw.get("workers") if isinstance(raw, dict) else None
87
+ if presence != "served" or not isinstance(workers, dict):
88
+ reason = raw.get("error") if isinstance(raw, dict) else presence
89
+ errors.append({"engine": "codex", "worker": "codexd", "short": "codexd",
90
+ "error": f"codexd original registry unavailable or malformed: {reason}"})
91
+ return "inconclusive", rows, errors, None
92
+ for worker_id, state in sorted(workers.items()):
93
+ handle = f"codex:{worker_id}"
94
+ try:
95
+ owner = store.resolve(handle)
96
+ record = store.record(owner.worker)
97
+ if not isinstance(state, str) or not state:
98
+ raise ValueError("original Codex worker registry state malformed")
99
+ rows.append({"engine": "codex", "worker": owner.worker,
100
+ "worker_id": owner.worker_id, "short": owner.short,
101
+ "name": record.get("name"), "state": state})
102
+ except (GenerationError, IdentityError, OSError, ValueError) as error:
103
+ errors.append({"engine": "codex", "worker": handle,
104
+ "short": str(worker_id), "error": str(error)})
105
+ return presence, rows, errors, None
106
+
107
+
108
+ def _codexd_generation_note(version: int) -> str:
109
+ from ccx.constants import CODEXD_RPC_GENERATION
110
+ direction = "older" if version < CODEXD_RPC_GENERATION else "newer"
111
+ return f"{direction} generation {version}"
112
+
113
+
114
+ def _stop_codexd(deadline: float | None, *, older_generation: int | None = None) -> str:
115
+ from ccx.codex.runtime import daemon_generation, daemon_stop, observe_registry
116
+ from ccx.constants import CODEXD_RPC_GENERATION
117
+
118
+ # Older registry layouts cannot be enumerated/unloaded individually:
119
+ # daemon teardown is the one cross-generation recovery operation. It
120
+ # unloads its own workers as it exits and retains their native histories.
121
+ reply = daemon_stop(force=older_generation is not None)
122
+ if not reply.get("ok"):
123
+ if reply.get("transport") == "absent":
124
+ return "not-running"
125
+ # Workers this shutdown did not kill are still active: ones a codexd
126
+ # from another ccx version holds (its registry is unreadable here, so
127
+ # they were never listed) or ones loaded after the kills.
128
+ why = ("it is a codexd from another ccx version, whose workers this "
129
+ "ccx cannot list or kill"
130
+ if daemon_generation(reply) != CODEXD_RPC_GENERATION
131
+ else "workers were loaded after the kills")
132
+ raise CcxError(
133
+ f"codexd stop rejected: {reply.get('error') or reply} ({why}); "
134
+ "codexd remains running to protect those workers; resolve the "
135
+ "loaded-worker ownership before retrying `ccx shutdown --engine codex`")
136
+ wait_until = time.monotonic() + _CODEXD_EXIT_S
137
+ if deadline is not None:
138
+ wait_until = min(wait_until, deadline)
139
+ while time.monotonic() < wait_until:
140
+ if observe_registry(timeout=0.5)["presence"] == "absent":
141
+ return (f"stopped ({_codexd_generation_note(older_generation)})"
142
+ if older_generation is not None else "stopped")
143
+ time.sleep(0.1)
144
+ raise CcxError("codexd accepted stop but is still serving")
145
+
146
+
147
+ def _bound_notifyd_pid(store) -> tuple[int, str] | None:
148
+ """Only a positively owned, current-generation notifier can be stopped.
149
+
150
+ A missing/malformed stamp denotes a daemon not yet serving. A proven
151
+ dead original is stale metadata, not a shutdown blocker; uncertain
152
+ PID/start observations must never authorize signalling.
153
+ """
154
+ from ccx.constants import NOTIFYD_GENERATION
155
+ from ccx.store.liveness import owned_process
156
+ from ccx.notify.daemon import _read_notifyd_stamp
157
+
158
+ stamp = _read_notifyd_stamp()
159
+ if stamp is None:
160
+ return None # no published serving identity
161
+ pid, version, generation, start = stamp
162
+ presence = owned_process(pid, start)
163
+ if presence == "dead":
164
+ return None
165
+ if presence != "alive":
166
+ raise CcxError("notification supervisor PID/start unprovable")
167
+ if version != NOTIFYD_GENERATION or generation != store.generation:
168
+ raise CcxError("notification supervisor version/ACTIVE generation incompatible")
169
+ return pid, start
170
+
171
+
172
+ def _stop_notifyd(bound: tuple[int, str] | None) -> str:
173
+ from ccx.constants import CCX_ROOT
174
+ from ccx.store.root import require_ccx_active
175
+ from ccx.store.liveness import owned_process
176
+
177
+ if bound is None:
178
+ return "not-running"
179
+ pid, start = bound
180
+ # Reprove the exact four-field stamp and PID/start once, at the signal.
181
+ current = _bound_notifyd_pid(require_ccx_active(CCX_ROOT))
182
+ if current is None:
183
+ return "not-running"
184
+ if current != bound:
185
+ raise CcxError("notification supervisor identity changed before SIGTERM")
186
+ try:
187
+ os.kill(pid, signal.SIGTERM)
188
+ except ProcessLookupError:
189
+ return "not-running"
190
+ except OSError as error:
191
+ raise CcxError(f"cannot stop notification supervisor {pid}: {error}") from error
192
+ wait_until = time.monotonic() + _NOTIFYD_STOP_S
193
+ while time.monotonic() < wait_until:
194
+ presence = owned_process(pid, start)
195
+ if presence == "dead":
196
+ return "stopped"
197
+ if presence == "unprovable":
198
+ raise CcxError(f"notification supervisor {pid} exit unprovable")
199
+ time.sleep(0.05)
200
+ raise CcxError(f"notification supervisor {pid} did not stop")
201
+
202
+
203
+ def shutdown(*, dry_run: bool = False, timeout: float | None = KILL_TIMEOUT_DEFAULT_S,
204
+ engine: str | None = None) -> dict:
205
+ """Confirmed CCX-owned shutdown, optionally engine-scoped; timeout per kill."""
206
+ from ccx.constants import CCX_ROOT
207
+ from ccx.store.root import GenerationError, require_ccx_active
208
+ from ccx.store.identity import IdentityError
209
+
210
+ if engine not in (None, "claude", "codex"):
211
+ raise ValueError(f"unsupported shutdown engine: {engine}")
212
+
213
+ errors: list[dict] = []
214
+ claude_rows, codex_rows = [], []
215
+ try:
216
+ store = require_ccx_active(CCX_ROOT)
217
+ if engine != "codex":
218
+ claude_rows, claude_errors = _live_claude_workers(store)
219
+ errors.extend(claude_errors)
220
+ except (GenerationError, IdentityError, OSError) as error:
221
+ # An unreadable/non-ACTIVE global owner index never means an empty
222
+ # machine. No runtime control is safe before it is readable.
223
+ return {"dry_run": dry_run, "claude": [], "codex": [],
224
+ "codexd": "untouched" if engine == "claude" else "failed",
225
+ "notifyd": "failed" if engine is None else "untouched",
226
+ "errors": [{"engine": None, "worker": "inventory",
227
+ "short": "inventory", "error": str(error)}], "ok": False}
228
+ codexd = "untouched"
229
+ older_generation = None
230
+ if engine != "claude":
231
+ presence, codex_rows, codex_errors, older_generation = _loaded_codex_workers(store)
232
+ errors.extend(codex_errors)
233
+ codexd = ("not-running" if presence == "absent" else
234
+ "failed" if codex_errors else
235
+ f"would-stop ({_codexd_generation_note(older_generation)})"
236
+ if older_generation is not None else "would-stop")
237
+
238
+ notifyd_pid = None
239
+ notifyd = "untouched"
240
+ if engine is None:
241
+ try:
242
+ notifyd_pid = _bound_notifyd_pid(store)
243
+ notifyd = "would-stop" if notifyd_pid is not None else "not-running"
244
+ except (CcxError, GenerationError, IdentityError, OSError) as error:
245
+ notifyd = "failed"
246
+ errors.append({"engine": None, "worker": "notifyd",
247
+ "short": "notifyd", "error": str(error)})
248
+
249
+ if dry_run:
250
+ for row in claude_rows + codex_rows:
251
+ row.setdefault("action", "would-kill")
252
+ return {"dry_run": True, "claude": claude_rows, "codex": codex_rows,
253
+ "codexd": codexd, "notifyd": notifyd,
254
+ "errors": errors, "ok": not errors}
255
+
256
+ from ccx.ops.terminate import confirmed_kill_uuid
257
+
258
+ for engine, rows in (("claude", claude_rows), ("codex", codex_rows)):
259
+ for row in rows:
260
+ if row.get("action") == "failed":
261
+ continue
262
+ try:
263
+ result = confirmed_kill_uuid(store, row["worker"], timeout=timeout)
264
+ except (CcxError, GenerationError, IdentityError, OSError) as error:
265
+ row["action"] = "failed"
266
+ errors.append({"engine": engine, "worker": row["worker"],
267
+ "short": row["short"], "error": str(error)})
268
+ continue
269
+ row["action"] = "killed" if result["changed"] else "already-down"
270
+ row["state"] = result["state"]
271
+
272
+ if codexd.startswith("would-stop"):
273
+ try:
274
+ deadline = None if timeout is None else time.monotonic() + max(0.0, timeout)
275
+ codexd = (_stop_codexd(deadline, older_generation=older_generation)
276
+ if older_generation is not None else _stop_codexd(deadline))
277
+ except CcxError as error:
278
+ codexd = "failed"
279
+ errors.append({"engine": "codex", "worker": "codexd",
280
+ "short": "codexd", "error": str(error)})
281
+ if notifyd == "would-stop":
282
+ try:
283
+ notifyd = _stop_notifyd(notifyd_pid)
284
+ except CcxError as error:
285
+ notifyd = "failed"
286
+ errors.append({"engine": None, "worker": "notifyd",
287
+ "short": "notifyd", "error": str(error)})
288
+ return {"dry_run": False, "claude": claude_rows, "codex": codex_rows,
289
+ "codexd": codexd, "notifyd": notifyd,
290
+ "errors": errors, "ok": not errors}
291
+
292
+
293
+
294
+
295
+ # --- untrust -----------------------------------------------------------------