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,105 @@
1
+ """CCX-owned client ids for the user messages CCX sends into Codex.
2
+
3
+ Native Codex accepts an optional ``clientUserMessageId`` on ``turn/start`` and
4
+ ``turn/steer`` and persists it on the resulting ``userMessage`` item as
5
+ ``clientId``. That is the only durable, first-class way to tell — reading a
6
+ thread back cold, possibly on another machine — which user messages CCX itself
7
+ put there, and whether one started a turn or steered a running one.
8
+
9
+ The id names the semantic user input, not a JSON-RPC attempt: an explicit
10
+ overload retry re-sends the identical request and therefore reuses the same id,
11
+ while a genuinely new input always gets a fresh one.
12
+
13
+ Parsing is deliberately conservative. Only the exact format this module emits
14
+ is recognized; an id written by another client, a malformed one, a legacy
15
+ ``null``, and anything CCX cannot prove it authored all stay unknown rather
16
+ than being guessed at from position, text, or turn shape.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import uuid
23
+
24
+ # Historical v1 native client message IDs remain immutable forensic facts.
25
+ # Mint v2 only for new full-UUID owners; no rewrite of original operation IDs.
26
+ FORMAT_VERSION = "v2"
27
+
28
+ START = "start"
29
+ STEER = "steer"
30
+ KINDS = (START, STEER)
31
+
32
+ _ORIGINS = {START: "ccx-start", STEER: "ccx-steer"}
33
+
34
+ # Original short-keyed v1 native client IDs remain parseable but not
35
+ # managed-worker addressing authority; v2 uses the complete owner UUID.
36
+ _PATTERN = re.compile(
37
+ r"^ccx:"
38
+ r"v1:"
39
+ r"(?P<short>[0-9a-f]{8}):"
40
+ rf"(?P<kind>{START}|{STEER}):"
41
+ r"(?P<uuid>[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$"
42
+ )
43
+
44
+ # Historical v1 IDs in existing native Codex threads remain forensic
45
+ # provenance and are never rewritten. New full-UUID identities use v2
46
+ # with one independent canonical full owner (no truncated alias).
47
+ _PATTERN_V2 = re.compile(
48
+ r"^ccx:v2:"
49
+ r"(?P<workerId>[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}):"
50
+ rf"(?P<kind>{START}|{STEER}):"
51
+ r"(?P<uuid>[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$"
52
+ )
53
+
54
+
55
+ def new_client_message_id(short: str, kind: str) -> str:
56
+ """Mint one id for a single semantic user input.
57
+
58
+ Carries no prompt text — an id ends up in a native thread CCX does not own
59
+ the retention of, so it must be pure provenance.
60
+ """
61
+ if kind not in KINDS:
62
+ raise ValueError(f"unknown client message kind: {kind!r}")
63
+ from ccx.store.identity import InvalidTarget, canonical_worker_id
64
+ try:
65
+ owner = canonical_worker_id(short)
66
+ except InvalidTarget as exc:
67
+ raise ValueError(
68
+ "new Codex clientUserMessageId requires canonical full CCX UUIDv4"
69
+ ) from exc
70
+ return f"ccx:{FORMAT_VERSION}:{owner}:{kind}:{uuid.uuid4()}"
71
+
72
+
73
+ def parse_client_message_id(value: object) -> dict[str, str] | None:
74
+ """Decompose an id CCX can prove it authored, else ``None``."""
75
+ if not isinstance(value, str):
76
+ return None
77
+ match = _PATTERN_V2.match(value) or _PATTERN.match(value)
78
+ if match is None:
79
+ return None
80
+ parsed = match.groupdict()
81
+ # `uuid4()` produces the canonical lowercase form the pattern requires, so
82
+ # a value that parses but does not round-trip was not written by us.
83
+ if str(uuid.UUID(parsed["uuid"])) != parsed["uuid"]:
84
+ return None
85
+ return parsed
86
+
87
+
88
+ def origin_of(value: object) -> str | None:
89
+ """``"ccx-start"``/``"ccx-steer"`` for a CCX id, ``None`` for anything else.
90
+
91
+ ``None`` is a real answer: the message exists and its origin is unknown.
92
+ """
93
+ parsed = parse_client_message_id(value)
94
+ return _ORIGINS[parsed["kind"]] if parsed else None
95
+
96
+
97
+ __all__ = [
98
+ "FORMAT_VERSION",
99
+ "KINDS",
100
+ "START",
101
+ "STEER",
102
+ "new_client_message_id",
103
+ "origin_of",
104
+ "parse_client_message_id",
105
+ ]
@@ -0,0 +1,29 @@
1
+ """Shared native Codex pending approval projection for CLI, WorkerClient and the public protocol."""
2
+ from __future__ import annotations
3
+ from typing import Any
4
+
5
+
6
+ def pending_input(approvals: Any) -> dict[str, Any] | None:
7
+ """The cross-engine normalized pending-input object for Codex approvals.
8
+
9
+ The one projection ``peek``, ``WorkerClient`` and the public protocol share. A
10
+ Codex approval is never dressed up as a user question: Claude's
11
+ AskUserQuestion is a separate capability, so ``questions`` stays empty.
12
+ """
13
+ approvals = [value for value in approvals or [] if isinstance(value, dict)]
14
+ if not approvals:
15
+ return None
16
+ first = approvals[0]
17
+ message = first.get("reason") or first.get("command")
18
+ return {
19
+ "kind": "permission",
20
+ # Native request IDs may repeat after app-server restart. Only a real
21
+ # CCX gateId is actionable; historical previews cannot be decided.
22
+ "request_id": first.get("gateId") if isinstance(first.get("gateId"), str) else None,
23
+ "tool": first.get("kind"),
24
+ "message": message if isinstance(message, str) else (
25
+ None if message is None else str(message)
26
+ ),
27
+ "questions": [],
28
+ "native": {"pendingApprovals": approvals},
29
+ }
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+
7
+ def now_ms() -> int:
8
+ return int(time.time() * 1000)
9
+
10
+
11
+ def _uuid_codex_store():
12
+ """ACTIVE full-ID bootstrap before any Codex record filesystem operation."""
13
+ from ccx.constants import CCX_ROOT
14
+ from ccx.store.root import require_ccx_active
15
+ return require_ccx_active(CCX_ROOT)
16
+
17
+
18
+ def read_record(target: str) -> dict[str, Any]:
19
+ """Read exact managed Codex full UUID, never short/name/thread alias."""
20
+ from ccx.store.identity import EngineMismatch
21
+ store = _uuid_codex_store()
22
+ owner = store.resolve(target)
23
+ if owner.engine != "codex":
24
+ raise EngineMismatch("Codex record lookup requires Codex full worker UUID")
25
+ return store.record(owner.worker)
26
+
27
+
28
+ def write_record(record: dict[str, Any]) -> None:
29
+ """Require original exact whole-record revision, or refuse stale overwrite."""
30
+ from ccx.store.root import RecordConflict, StorageIntegrityError
31
+ if not isinstance(record, dict) or record.get("engine") != "codex":
32
+ raise StorageIntegrityError("Codex full-UUID record must preserve engine")
33
+ original_revision = record.get("recordRevision")
34
+ if type(original_revision) is not int or original_revision < 0:
35
+ raise RecordConflict(
36
+ "Codex whole-record snapshot needs current original recordRevision"
37
+ )
38
+ store = _uuid_codex_store()
39
+ updated = store.replace_record_if_revision(
40
+ f"codex:{record.get('workerId')}", record,
41
+ expected_revision=original_revision,
42
+ )
43
+ record.clear()
44
+ record.update(updated)
45
+
46
+
47
+ def iter_records():
48
+ """Only full-UUID Codex global owners, not old codex/sessions short path."""
49
+ store = _uuid_codex_store()
50
+ for row in store.list_workers(engine="codex"):
51
+ yield store.record(row["worker"])
@@ -0,0 +1,177 @@
1
+ """Phase-aware selection of native Codex final assistant reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ COMMENTARY_PHASE = "commentary"
9
+ FINAL_ANSWER_PHASE = "final_answer"
10
+
11
+
12
+ def native_timestamp_ms(value: Any) -> int | None:
13
+ """Convert a finite native Unix-seconds timestamp to integer milliseconds."""
14
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
15
+ try:
16
+ if math.isfinite(value):
17
+ return int(value * 1000)
18
+ except (OverflowError, ValueError):
19
+ pass
20
+ return None
21
+
22
+
23
+ def report_from_item(
24
+ item: dict[str, Any] | None,
25
+ *,
26
+ turn_id: str | None,
27
+ completed_at: int | None = None,
28
+ ) -> dict[str, Any] | None:
29
+ """Return a report candidate for one completed native item.
30
+
31
+ Codex providers do not consistently populate ``phase``. A missing or null
32
+ phase is therefore deliberately accepted as the legacy compatibility
33
+ fallback, while explicit commentary is never a report.
34
+ """
35
+ if not isinstance(item, dict) or item.get("type") != "agentMessage":
36
+ return None
37
+ text = item.get("text")
38
+ if not text:
39
+ return None
40
+ phase = item.get("phase")
41
+ if phase == COMMENTARY_PHASE:
42
+ return None
43
+ if phase not in (None, FINAL_ANSWER_PHASE):
44
+ return None
45
+ return {
46
+ "text": str(text),
47
+ "turnId": turn_id,
48
+ "itemId": item.get("id"),
49
+ "phase": phase,
50
+ "completedAt": completed_at,
51
+ }
52
+
53
+
54
+ def report_from_turn(turn: dict[str, Any] | None) -> dict[str, Any] | None:
55
+ """Select one completed turn's final answer, if it has one.
56
+
57
+ A strong ``final_answer`` candidate wins over every phase-less fallback in
58
+ the turn. Within either class, native item order determines the winner.
59
+ """
60
+ if not isinstance(turn, dict):
61
+ return None
62
+ turn_id = turn.get("id")
63
+ completed_at = native_timestamp_ms(turn.get("completedAt"))
64
+ strong = None
65
+ fallback = None
66
+ for item in turn.get("items") or []:
67
+ candidate = report_from_item(
68
+ item, turn_id=turn_id, completed_at=completed_at
69
+ )
70
+ if not candidate:
71
+ continue
72
+ if candidate["phase"] == FINAL_ANSWER_PHASE:
73
+ strong = candidate
74
+ else:
75
+ fallback = candidate
76
+ return strong or fallback
77
+
78
+
79
+ def select_report_for_turn(
80
+ turn: dict[str, Any] | None, *,
81
+ live_report: dict[str, Any] | None = None,
82
+ ) -> dict[str, Any] | None:
83
+ """Prefer an exact-turn streamed final answer to a weaker history fallback.
84
+
85
+ Both codexd's live completion writer and its history observer use this
86
+ phase-aware rule; an unrelated turn's cached report is never borrowed.
87
+ """
88
+ selected = report_from_turn(turn)
89
+ turn_id = turn.get("id") if isinstance(turn, dict) else None
90
+ if (isinstance(live_report, dict)
91
+ and isinstance(turn_id, str) and turn_id
92
+ and live_report.get("turnId") == turn_id
93
+ and isinstance(live_report.get("text"), str)
94
+ and (selected is None or (
95
+ live_report.get("phase") == FINAL_ANSWER_PHASE
96
+ and selected.get("phase") != FINAL_ANSWER_PHASE))):
97
+ return live_report
98
+ return selected
99
+
100
+
101
+ def report_from_thread(thread: dict[str, Any] | None) -> dict[str, Any] | None:
102
+ """Select the latest report candidate from completed native turns."""
103
+ if not isinstance(thread, dict):
104
+ return None
105
+ for turn in reversed(thread.get("turns") or []):
106
+ if not isinstance(turn, dict) or turn.get("status") != "completed":
107
+ continue
108
+ candidate = report_from_turn(turn)
109
+ if candidate:
110
+ return candidate
111
+ return None
112
+
113
+
114
+ def should_replace_live_report(
115
+ current: dict[str, Any] | None,
116
+ candidate: dict[str, Any],
117
+ ) -> bool:
118
+ """Keep a strong final candidate ahead of a later legacy fallback."""
119
+ if not isinstance(current, dict):
120
+ return True
121
+ if candidate.get("phase") == FINAL_ANSWER_PHASE:
122
+ return True
123
+ return not (
124
+ current.get("turnId") == candidate.get("turnId")
125
+ and current.get("phase") == FINAL_ANSWER_PHASE
126
+ )
127
+
128
+
129
+ def merge_report_cache(
130
+ existing: dict[str, Any] | None,
131
+ candidate: dict[str, Any],
132
+ ) -> dict[str, Any]:
133
+ """Merge historical evidence without degrading a precise live timestamp.
134
+
135
+ Item IDs remain native to their observation surface. Explicit final-answer
136
+ text and turn identity are the cross-surface evidence when those IDs differ.
137
+ """
138
+ if not isinstance(existing, dict):
139
+ return candidate
140
+ same_turn_and_text = (
141
+ existing.get("turnId") is not None
142
+ and candidate.get("turnId") is not None
143
+ and existing.get("turnId") == candidate.get("turnId")
144
+ and existing.get("text") == candidate.get("text")
145
+ )
146
+ same_item = (
147
+ existing.get("itemId") is not None
148
+ and candidate.get("itemId") is not None
149
+ and existing.get("itemId") == candidate.get("itemId")
150
+ )
151
+ same_explicit_final = (
152
+ existing.get("itemId") is not None
153
+ and candidate.get("itemId") is not None
154
+ and existing.get("phase") == FINAL_ANSWER_PHASE
155
+ and candidate.get("phase") == FINAL_ANSWER_PHASE
156
+ )
157
+ if (
158
+ same_turn_and_text
159
+ and (same_item or same_explicit_final)
160
+ and existing.get("phase") == candidate.get("phase")
161
+ and existing.get("completedAt") is not None
162
+ ):
163
+ return {**candidate, "completedAt": existing["completedAt"]}
164
+ return candidate
165
+
166
+
167
+ __all__ = [
168
+ "COMMENTARY_PHASE",
169
+ "FINAL_ANSWER_PHASE",
170
+ "report_from_item",
171
+ "native_timestamp_ms",
172
+ "report_from_thread",
173
+ "report_from_turn",
174
+ "select_report_for_turn",
175
+ "merge_report_cache",
176
+ "should_replace_live_report",
177
+ ]