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,216 @@
1
+ """Execution helpers for CCX worker variables.
2
+
3
+ This module intentionally depends only on the Python standard library. The
4
+ standalone durable path executes this file with the worker-selected Python,
5
+ which may not have the ccx package importable.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import linecache
13
+ import os
14
+ from pathlib import Path
15
+ import runpy
16
+ import sys
17
+ import traceback
18
+ from typing import Any
19
+
20
+
21
+ SCHEMA_VERSION = 1
22
+ STATE_FILE_LIMIT = 4 * 1024 * 1024
23
+
24
+
25
+ def strict_json(value: Any) -> str:
26
+ return json.dumps(
27
+ value,
28
+ separators=(",", ":"),
29
+ ensure_ascii=False,
30
+ allow_nan=False,
31
+ )
32
+
33
+
34
+ def _system_exit_code(error: SystemExit) -> int:
35
+ if error.code is None:
36
+ return 0
37
+ if isinstance(error.code, int):
38
+ return error.code
39
+ if error.code:
40
+ print(error.code, file=sys.stderr)
41
+ return 1
42
+
43
+
44
+ def execute_target(args: list[str], globals_seed: dict[str, Any]) -> tuple[int, dict[str, Any]]:
45
+ """Execute one supported Python invocation against a fresh globals mapping."""
46
+ ns = dict(globals_seed)
47
+ ns.setdefault("__name__", "__main__")
48
+ try:
49
+ if not args or args[0] == "-":
50
+ if sys.path:
51
+ sys.path[0] = ""
52
+ sys.argv = ["-", *args[1:]]
53
+ ns["__file__"] = "<stdin>"
54
+ source = sys.stdin.read()
55
+ exec(compile(source, "<stdin>", "exec"), ns, ns)
56
+ return 0, ns
57
+
58
+ if args[0] == "-c":
59
+ if sys.path:
60
+ sys.path[0] = ""
61
+ if len(args) < 2:
62
+ print("argument expected for -c", file=sys.stderr)
63
+ return 2, ns
64
+ sys.argv = ["-c", *args[2:]]
65
+ ns["__file__"] = "<string>"
66
+ # The bootstrap itself ran as -c; give traceback the user's source.
67
+ linecache.cache["<string>"] = (
68
+ len(args[1]), None, args[1].splitlines(keepends=True), "<string>"
69
+ )
70
+ exec(compile(args[1], "<string>", "exec"), ns, ns)
71
+ return 0, ns
72
+
73
+ if args[0] == "-m":
74
+ if sys.path:
75
+ sys.path[0] = ""
76
+ if len(args) < 2:
77
+ print("argument expected for -m", file=sys.stderr)
78
+ return 2, ns
79
+ sys.argv = [args[1], *args[2:]]
80
+ # Resolve packages through runpy, but retain the executing globals
81
+ # even when the module exits or raises before runpy can return them.
82
+ _, spec, code = runpy._get_module_details(args[1])
83
+ with runpy._TempModule("__main__") as temporary, runpy._ModifiedArgv0(spec.origin):
84
+ ns = temporary.module.__dict__
85
+ ns.update(globals_seed)
86
+ runpy._run_code(code, ns, mod_name="__main__", mod_spec=spec)
87
+ return 0, ns
88
+
89
+ if args[0].startswith("-"):
90
+ print(
91
+ f"ccx shared python: unsupported interpreter option {args[0]!r}; "
92
+ "use an explicit interpreter path for native Python",
93
+ file=sys.stderr,
94
+ )
95
+ return 2, ns
96
+
97
+ script = Path(args[0]).resolve()
98
+ if sys.path:
99
+ sys.path[0] = str(script.parent)
100
+ sys.argv = [str(script), *args[1:]]
101
+ ns["__file__"] = str(script)
102
+ source = script.read_bytes()
103
+ exec(compile(source, str(script), "exec"), ns, ns)
104
+ return 0, ns
105
+ except SystemExit as error:
106
+ return _system_exit_code(error), ns
107
+ except KeyboardInterrupt as error:
108
+ tb = error.__traceback__
109
+ traceback.print_exception(type(error), error, tb.tb_next if tb else None)
110
+ return 130, ns
111
+ except BaseException as error:
112
+ tb = error.__traceback__
113
+ traceback.print_exception(type(error), error, tb.tb_next if tb else None)
114
+ return 1, ns
115
+
116
+
117
+ def _atomic_write(path: Path, text: str) -> None:
118
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
119
+ tmp.write_text(text, encoding="utf-8")
120
+ try:
121
+ os.chmod(tmp, 0o600)
122
+ except OSError:
123
+ pass
124
+ os.replace(tmp, path)
125
+
126
+
127
+ def _parse_runner_args(argv: list[str] | None = None):
128
+ parser = argparse.ArgumentParser(add_help=False)
129
+ parser.add_argument("--state", required=True)
130
+ parser.add_argument("--lock", required=True)
131
+ parser.add_argument("--cwd", required=True)
132
+ parser.add_argument("--real-python", required=True)
133
+ parser.add_argument("target_args", nargs=argparse.REMAINDER)
134
+ value = parser.parse_args(argv)
135
+ if value.target_args and value.target_args[0] == "--":
136
+ value.target_args = value.target_args[1:]
137
+ return value
138
+
139
+
140
+ def durable_main(argv: list[str] | None = None) -> int:
141
+ """Run one durable invocation under a whole-invocation file lock."""
142
+ import fcntl
143
+
144
+ args = _parse_runner_args(argv)
145
+ state_path = Path(args.state)
146
+ lock_path = Path(args.lock)
147
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
148
+ with lock_path.open("a+") as lock_file:
149
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
150
+ try:
151
+ document = json.loads(state_path.read_text(encoding="utf-8"))
152
+ except FileNotFoundError:
153
+ print("ccx: worker variable state is no longer available", file=sys.stderr)
154
+ return 69
155
+
156
+ if not isinstance(document.get("stateId"), str):
157
+ print("ccx: durable variable state is malformed", file=sys.stderr)
158
+ return 69
159
+
160
+ names = {
161
+ str(name)
162
+ for name in document.get("names", [])
163
+ if isinstance(name, str)
164
+ }
165
+ variables = document.get("variables")
166
+ if not isinstance(variables, dict):
167
+ variables = {}
168
+
169
+ seed = {name: variables[name] for name in names if name in variables}
170
+ old_cwd = os.getcwd()
171
+ old_argv = sys.argv[:]
172
+ try:
173
+ os.chdir(args.cwd)
174
+ exit_code, final_ns = execute_target(args.target_args, seed)
175
+ finally:
176
+ sys.argv = old_argv
177
+ os.chdir(old_cwd)
178
+
179
+ if exit_code != 0:
180
+ return exit_code
181
+
182
+ final_names = sorted(name for name in names if name in final_ns)
183
+ final_variables = {name: final_ns[name] for name in final_names}
184
+ try:
185
+ encoded_variables = strict_json(final_variables)
186
+ except (TypeError, ValueError) as error:
187
+ bad_name = "<unknown>"
188
+ for name, value in final_variables.items():
189
+ try:
190
+ strict_json(value)
191
+ except (TypeError, ValueError):
192
+ bad_name = name
193
+ break
194
+ print(
195
+ f"CcxStateValueUnsupported: variable {bad_name!r} can't be saved: "
196
+ f"{error}; use JSON dicts, lists, strings, numbers, booleans or null",
197
+ file=sys.stderr,
198
+ )
199
+ return 1
200
+
201
+ updated = dict(document)
202
+ updated["schemaVersion"] = SCHEMA_VERSION
203
+ updated["names"] = final_names
204
+ updated["variables"] = json.loads(encoded_variables)
205
+ text = strict_json(updated)
206
+ if len(text.encode("utf-8")) > STATE_FILE_LIMIT:
207
+ print("CcxStateTooLarge: worker variables exceed the 4 MiB state limit; "
208
+ "shrink a bound variable and retry", file=sys.stderr)
209
+ return 1
210
+ _atomic_write(state_path, text)
211
+ return 0
212
+
213
+
214
+
215
+ if __name__ == "__main__":
216
+ raise SystemExit(durable_main())
@@ -0,0 +1,213 @@
1
+ """Controller-attempt observations for the shared parent notification daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ccx.controllers import store as store
9
+ from ccx.errors import ControllerError
10
+
11
+
12
+ def _attempt_for_registration(
13
+ record: dict[str, Any], reg: dict[str, Any]
14
+ ) -> dict[str, Any] | None:
15
+ operation = reg.get("operation") if isinstance(reg.get("operation"), dict) else {}
16
+ number = operation.get("attempt")
17
+ token = operation.get("attemptToken")
18
+ if not isinstance(token, str) or not token:
19
+ return None
20
+ for attempt in record.get("attempts") or []:
21
+ if not isinstance(attempt, dict) or attempt.get("number") != number:
22
+ continue
23
+ if attempt.get("attemptToken") != token:
24
+ continue
25
+ return attempt
26
+ return None
27
+
28
+
29
+ def _startup_deadline(
30
+ reg: dict[str, Any],
31
+ attempt: dict[str, Any] | None = None,
32
+ ) -> int:
33
+ operation = (
34
+ reg.get("operation")
35
+ if isinstance(reg.get("operation"), dict)
36
+ else {}
37
+ )
38
+ deadline = int(operation.get("startupDeadlineMs") or 0)
39
+ if isinstance(attempt, dict):
40
+ deadline = max(
41
+ deadline,
42
+ int(attempt.get("startedAt") or 0) + 45_000,
43
+ )
44
+ return deadline
45
+
46
+
47
+ def _record(reg: dict[str, Any]) -> dict[str, Any] | None:
48
+ run_id = reg["worker"]["workerId"]
49
+ try:
50
+ bootstrap = store.bootstrap_root() / run_id
51
+ run_dir = (bootstrap if bootstrap.is_dir() else next(
52
+ (path for path in store._all_run_dirs() if path.name == run_id), None
53
+ ))
54
+ if run_dir is None:
55
+ raise ControllerError("original controller run is unavailable")
56
+ record = store.read_json(store.run_record_path(run_dir))
57
+ if record.get("runId") != run_id:
58
+ raise ControllerError("controller run identity differs from notification")
59
+ attempt = _attempt_for_registration(record, reg)
60
+ if (
61
+ isinstance(attempt, dict)
62
+ and attempt.get("lifecycle") in store.TERMINAL_LIFECYCLES
63
+ ):
64
+ return record
65
+
66
+ # A registration can deliberately precede attempt publication. While
67
+ # that bounded startup window is open, missing attempt state is pending.
68
+ # After the window, settlement/progress will turn a missing or token-
69
+ # mismatched attempt into a structural unavailable result. Never inspect
70
+ # a later attempt's liveness on behalf of this registration.
71
+ if attempt is None:
72
+ return record
73
+
74
+ # An earlier unfinished attempt must never sample a newer host's PID.
75
+ if (record.get("attempts") or [])[-1] is not attempt:
76
+ return record
77
+
78
+ lifecycle = record.get("lifecycle") or {}
79
+ deadline = _startup_deadline(reg, attempt)
80
+ if (
81
+ lifecycle.get("state") == "starting"
82
+ and not lifecycle.get("pid")
83
+ and store.now_ms() < deadline
84
+ ):
85
+ return record
86
+ return store.refresh_liveness(run_dir)
87
+ except (ControllerError, OSError):
88
+ # Bootstrap publication can temporarily hide the directory. After the
89
+ # already recorded deadline, keep this event visible as unavailable.
90
+ if store.now_ms() < _startup_deadline(reg):
91
+ return None
92
+ return {"runId": run_id, "controller": reg["worker"].get("name"),
93
+ "attempts": [], "reports": []}
94
+
95
+
96
+ def settlement(reg: dict[str, Any]) -> dict[str, Any] | None:
97
+ record = _record(reg)
98
+ if record is None:
99
+ return None
100
+ number = reg["operation"]["attempt"]
101
+ store.snapshot_attempt_outcome(record)
102
+ attempt = _attempt_for_registration(record, reg)
103
+ if attempt is None:
104
+ if store.now_ms() < _startup_deadline(reg):
105
+ return None
106
+ token = reg.get("operation", {}).get("attemptToken")
107
+ return {
108
+ "status": "unavailable",
109
+ "workerState": "unavailable",
110
+ "operationType": "controller-attempt",
111
+ "operationId": f"{record['runId']}:{number}",
112
+ "controller": record.get("controller"),
113
+ "runId": record["runId"],
114
+ "attempt": number,
115
+ "controllerStatus": None,
116
+ "checkpoint": None,
117
+ "summary": None,
118
+ "report": None,
119
+ "reportTruncated": False,
120
+ "reportPath": None,
121
+ "error": {
122
+ "type": "ControllerAttemptUnavailable",
123
+ "message": (
124
+ "controller attempt was not durably published"
125
+ if token
126
+ else "controller attempt is unavailable"
127
+ ),
128
+ },
129
+ }
130
+ if attempt.get("lifecycle") not in store.TERMINAL_LIFECYCLES:
131
+ return None
132
+ outcome = attempt.get("outcome") or {}
133
+ metadata = outcome.get("report") or {}
134
+ report = None
135
+ path = metadata.get("path")
136
+ error = outcome.get("error")
137
+ if path:
138
+ try:
139
+ report = Path(path).read_text(encoding="utf-8")
140
+ except OSError as exc:
141
+ error = error or {"type": "ControllerReportUnavailable", "message": str(exc)}
142
+ from ccx.notify.settle import _truncate_report
143
+ report, truncated = _truncate_report(report)
144
+ return {
145
+ "status": attempt["lifecycle"],
146
+ "workerState": attempt["lifecycle"],
147
+ "operationType": "controller-attempt",
148
+ "operationId": f"{record['runId']}:{number}",
149
+ "controller": record.get("controller"),
150
+ "runId": record["runId"],
151
+ "attempt": number,
152
+ "controllerStatus": outcome.get("status"),
153
+ "checkpoint": outcome.get("checkpoint"),
154
+ "summary": outcome.get("summary"),
155
+ "report": report,
156
+ "reportTruncated": truncated,
157
+ "reportPath": path,
158
+ "error": error,
159
+ }
160
+
161
+
162
+ def progress(reg: dict[str, Any]) -> dict[str, Any]:
163
+ record = _record(reg)
164
+ result: dict[str, Any] = {
165
+ "elapsedSeconds": max(0, int((store.now_ms() - reg["createdAtMs"]) / 1000)),
166
+ "attempt": reg["operation"]["attempt"],
167
+ }
168
+ if record is None:
169
+ return {**result, "workerState": "unknown", "health": "record-unavailable"}
170
+ store.snapshot_attempt_outcome(record)
171
+ attempt = _attempt_for_registration(record, reg)
172
+ if attempt is None:
173
+ if store.now_ms() >= _startup_deadline(reg):
174
+ return {
175
+ **result,
176
+ "workerState": "unavailable",
177
+ "health": "attempt-unavailable",
178
+ "controller": record.get("controller"),
179
+ "runId": record["runId"],
180
+ }
181
+ return {
182
+ **result,
183
+ "workerState": "starting",
184
+ "health": "attempt-pending-publication",
185
+ "controller": record.get("controller"),
186
+ "runId": record["runId"],
187
+ }
188
+ if attempt.get("outcome") is not None:
189
+ outcome = attempt["outcome"]
190
+ return {
191
+ **result, "workerState": attempt["lifecycle"],
192
+ "controller": record.get("controller"), "runId": record["runId"],
193
+ "controllerStatus": outcome.get("status"),
194
+ "checkpoint": outcome.get("checkpoint"),
195
+ }
196
+ if (record.get("attempts") or [])[-1] is not attempt:
197
+ return {
198
+ **result, "workerState": attempt.get("lifecycle") or "unavailable",
199
+ "controller": record.get("controller"), "runId": record["runId"],
200
+ }
201
+ reports = (record.get("reports") or [])[int(attempt.get("reportStartIndex", 0)):]
202
+ latest = reports[-1] if reports else {}
203
+ tasks = list((record.get("tasks") or {}).values())
204
+ return {
205
+ **result,
206
+ "workerState": (record.get("lifecycle") or {}).get("state"),
207
+ "controller": record.get("controller"),
208
+ "runId": record["runId"],
209
+ "controllerStatus": latest.get("status"),
210
+ "checkpoint": latest.get("checkpoint"),
211
+ "taskCount": len(tasks),
212
+ "activeTasks": [t.get("name") or t.get("id") for t in tasks if t.get("state") == "running"][:4],
213
+ }
@@ -0,0 +1,193 @@
1
+ """Identify the harness currently driving the CCX CLI.
2
+
3
+ This is deliberately about the *caller*, not the worker engine. A Codex turn can
4
+ launch either a Claude or Codex worker, and the continuation policy follows the
5
+ parent harness that invoked CCX.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ import os
12
+ from typing import Any
13
+
14
+ from ccx.errors import InvalidRequest
15
+
16
+
17
+ def _external_codex_unavailable(thread_id: str) -> str | None:
18
+ """Check the *owning* app-server, not merely a thread saved on disk.
19
+
20
+ A plain Codex TUI has its own in-process app-server. A separately running
21
+ daemon can read that TUI's rollout, but cannot deliver into the TUI turn.
22
+ Only server-local loaded-thread membership permits an external return arm.
23
+ """
24
+ from ccx.codex import control as codex_control
25
+
26
+ path = codex_control.control_socket_path()
27
+ try:
28
+ is_socket = path.is_socket()
29
+ except OSError as error: # e.g. a sandbox denying access to ~/.codex
30
+ return f"Codex app-server control socket at {path} is not accessible: {error}"
31
+ if not is_socket:
32
+ return f"no Codex app-server control socket at {path} (plain Codex TUI runs in-process)"
33
+ client = None
34
+ try:
35
+ client = codex_control.CodexControlClient(handshake_timeout=2)
36
+ client.initialize(timeout=2)
37
+ result = client.request("thread/loaded/list", {}, timeout=2)
38
+ except Exception as error:
39
+ return f"Codex app-server control socket at {path} is unreachable: {error}"
40
+ finally:
41
+ if client is not None:
42
+ client.close()
43
+ loaded = result.get("data")
44
+ if not isinstance(loaded, list) or not all(isinstance(item, str) for item in loaded):
45
+ return f"Codex app-server at {path} did not confirm its loaded threads"
46
+ if thread_id not in loaded:
47
+ return (f"Codex parent thread {thread_id} is not loaded in the app-server at {path}; "
48
+ "a separate TUI process cannot receive its notifications there")
49
+ return None
50
+
51
+
52
+ def _owned_codex_thread(store, thread_id: str):
53
+ """Complete read-only native return-address census, never a worker alias."""
54
+ from ccx.errors import SessionUnavailable
55
+
56
+ if not isinstance(thread_id, str) or not thread_id:
57
+ raise InvalidRequest("Notification needs a Codex recipient; call it from Codex")
58
+ matched = None
59
+ for row in store.list_workers(engine="codex", limit=None):
60
+ native = store.record(row["worker"]).get("native")
61
+ if not isinstance(native, dict):
62
+ raise SessionUnavailable("Codex worker list is unavailable; check CCX_ROOT")
63
+ if native.get("threadId") == thread_id:
64
+ if matched is not None:
65
+ raise SessionUnavailable("Cannot identify the Codex caller; use a worker handle")
66
+ matched = row["worker"]
67
+ store.revalidate()
68
+ return store.resolve(matched) if matched is not None else None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class CallerContext:
73
+ """Full-UUID owned caller or explicitly unverified native origin."""
74
+ kind: str # codex | claude | unknown
75
+ native_id: str | None = None
76
+ worker_id: str | None = None
77
+ managed: bool = False
78
+
79
+ def parent_notify_unavailable(self) -> str | None:
80
+ """The single capability rule for wait policy and notification arms."""
81
+ if (self.kind != "codex" or not self.native_id
82
+ or (not self.worker_id if self.managed else self.worker_id is not None)):
83
+ return ("parent notification needs a managed Codex caller or its own "
84
+ "CODEX_THREAD_ID; Claude and mixed origins are not supported")
85
+ if self.managed:
86
+ return None # CCX-owned parents keep their original codexd path.
87
+ return _external_codex_unavailable(self.native_id)
88
+
89
+ @property
90
+ def supports_parent_notify(self) -> bool:
91
+ return self.parent_notify_unavailable() is None
92
+
93
+ def parent_ref(self) -> dict[str, Any]:
94
+ unavailable = self.parent_notify_unavailable()
95
+ if unavailable is not None:
96
+ raise InvalidRequest(f"{unavailable}; use --wait / ccx wait TARGET instead")
97
+ from ccx.constants import CCX_ROOT
98
+ from ccx.errors import SessionUnavailable
99
+ from ccx.store.root import GenerationError, require_ccx_active
100
+ from ccx.store.identity import IdentityError
101
+
102
+ if not self.managed:
103
+ # The caller's own CODEX_THREAD_ID is a return address, not a
104
+ # worker target. A missing managed stamp must never cause the
105
+ # interactive app-server to dual-subscribe a CCX-owned rollout.
106
+ try:
107
+ store = require_ccx_active(CCX_ROOT)
108
+ if _owned_codex_thread(store, self.native_id) is not None:
109
+ raise InvalidRequest(
110
+ "Notification recipient is already a CCX worker; use that handle"
111
+ )
112
+ except (GenerationError, IdentityError, OSError) as exc:
113
+ raise SessionUnavailable(
114
+ f"Cannot list Codex workers; check CCX_ROOT: {exc}"
115
+ ) from exc
116
+ return {"kind": "codex-native-thread", "threadId": self.native_id}
117
+
118
+ try:
119
+ store = require_ccx_active(CCX_ROOT)
120
+ owner = store.resolve(f"codex:{self.worker_id}")
121
+ store.authorize_native_action(owner.worker, "send")
122
+ native = store.record(owner.worker).get("native")
123
+ if not isinstance(native, dict):
124
+ raise SessionUnavailable("Codex worker record is missing; inspect its handle")
125
+ if (native.get("threadId") != self.native_id
126
+ or native.get("threadSource")
127
+ != store.codex_source(owner.worker)):
128
+ raise SessionUnavailable(
129
+ "Codex caller does not match the worker; verify the handle"
130
+ )
131
+ store.revalidate()
132
+ except (GenerationError, IdentityError, OSError) as exc:
133
+ raise SessionUnavailable(
134
+ f"Cannot verify the Codex parent worker; inspect status: {exc}"
135
+ ) from exc
136
+ return {"kind": "ccx-codex", "workerId": owner.worker_id,
137
+ "threadId": native["threadId"], "generation": store.generation}
138
+
139
+
140
+ def current_caller() -> CallerContext:
141
+ """Classify owner with full-ID engine stamp; never infer from old short.
142
+
143
+ The managed full-ID stamp is independently checked against current
144
+ ACTIVE global owner record and provider-native thread/session identity.
145
+ A native-only stamp is NOT a CCX worker association.
146
+ """
147
+ from ccx.store.records import spawned_by_from_env
148
+ from ccx.constants import CCX_ROOT
149
+ from ccx.store.root import GenerationError, require_ccx_active
150
+ from ccx.errors import SessionUnavailable
151
+
152
+ origin = spawned_by_from_env()
153
+ if origin is None:
154
+ return CallerContext("unknown")
155
+ if origin.get("kind") == "ccx-worker":
156
+ wid, engine = origin["workerId"], origin["engine"]
157
+ try:
158
+ rec = require_ccx_active(CCX_ROOT).record(f"{engine}:{wid}")
159
+ except (GenerationError, OSError) as exc:
160
+ raise SessionUnavailable(
161
+ f"Caller worker changed; retry the notification: {exc}"
162
+ ) from exc
163
+ native = rec.get("native") if isinstance(rec.get("native"), dict) else {}
164
+ key = "sessionId" if engine == "claude" else "threadId"
165
+ env_key = ("CLAUDE_CODE_SESSION_ID" if engine == "claude"
166
+ else "CODEX_THREAD_ID")
167
+ supplied = os.environ.get(env_key, "").strip()
168
+ if supplied and supplied != native.get(key):
169
+ raise SessionUnavailable(
170
+ "Codex caller does not match this worker; verify the handle"
171
+ )
172
+ return CallerContext(
173
+ engine, native_id=supplied or native.get(key),
174
+ worker_id=wid, managed=True,
175
+ )
176
+ if origin.get("kind") == "provider-native-unknown":
177
+ engine = origin.get("engine")
178
+ if engine in ("codex", "claude"):
179
+ return CallerContext(engine, native_id=origin.get("nativeId"))
180
+ return CallerContext("unknown")
181
+ raise SessionUnavailable("Notification caller could not be identified; use a CCX Codex worker")
182
+
183
+
184
+ def require_notify_parent() -> dict[str, Any]:
185
+ """Return the machine-continuation parent reference or raise before effects."""
186
+ return current_caller().parent_ref()
187
+
188
+
189
+ def codex_blocking_override_enabled() -> bool:
190
+ """Hidden developer escape hatch; intentionally absent from the public CLI."""
191
+ return os.environ.get("CCX_ALLOW_CODEX_BLOCKING_WAIT", "").strip().lower() in {
192
+ "1", "true", "yes", "on"
193
+ }
@@ -0,0 +1,57 @@
1
+ """Harness-specific CLI policy for blocking versus continuation modes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ccx.constants import EXIT_ERROR
8
+ from ccx.controllers.orchestrator import codex_blocking_override_enabled, current_caller
9
+
10
+
11
+ def blocking_turn_family(argv: list[str]) -> str | None:
12
+ """Classify calls whose blocking completion has a durable continuation sibling.
13
+
14
+ This includes model turns and native compaction. Operational waits such as
15
+ kill confirmation and report reads stay synchronous because notification is
16
+ not a semantic replacement for them.
17
+ """
18
+ if not argv or "--help" in argv or "-h" in argv:
19
+ return None
20
+ command, flags = argv[0], argv[1:]
21
+ if command in {"new", "fork"} and "--wait" in flags:
22
+ return command
23
+ # Every managed root send is a strict new turn and waits by default,
24
+ # regardless of the child engine. No target/native probe is needed here.
25
+ if command == "send" and "--no-wait" not in flags and "--notify-parent" not in flags:
26
+ return "send"
27
+ if command == "wait":
28
+ return "wait.next-turn" if "--next-turn" in flags else "wait"
29
+ if command in {"answer", "approve", "reject", "respond"} and "--wait" in flags:
30
+ return command
31
+ if command == "compact" and "--no-wait" not in flags and "--notify-parent" not in flags:
32
+ return "compact"
33
+ return None
34
+
35
+
36
+ def _guidance(family: str) -> str:
37
+ if family == "wait.next-turn":
38
+ return "use `ccx notify TARGET --next-turn` to arm a durable future-turn continuation"
39
+ if family in {"wait"}:
40
+ return "use `ccx notify TARGET` to arm a durable parent continuation"
41
+ if family == "compact":
42
+ return "use `--notify-parent` so Codex is resumed when the exact compact operation settles"
43
+ return "use `--notify-parent` instead of blocking for the worker turn"
44
+
45
+
46
+ def reject_codex_blocking_call(argv: list[str]) -> None:
47
+ family = blocking_turn_family(argv)
48
+ if family is None or codex_blocking_override_enabled():
49
+ return
50
+ if not current_caller().supports_parent_notify:
51
+ return
52
+ sys.stderr.write(
53
+ "[ccx] blocking CCX waits are disabled for Codex orchestrators when a durable continuation exists; "
54
+ + _guidance(family)
55
+ + ". The notification returns immediately and resumes Codex when the awaited operation settles.\n"
56
+ )
57
+ raise SystemExit(EXIT_ERROR)