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.
- package/LICENSE +21 -0
- package/README.md +241 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Exact, proof-backed settlement of Codex's original accepted turn receipts.
|
|
2
|
+
|
|
3
|
+
The live notification supplies the completed turn without a history read. A
|
|
4
|
+
fast completion can precede the caller's accepted-receipt write; its durable
|
|
5
|
+
evidence journal is then the same notification source for a later catch-up.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ccx.codex import delivery as codex_delivery
|
|
13
|
+
from ccx.codex import evidence as codex_evidence
|
|
14
|
+
from ccx.codex.reports import report_from_turn, select_report_for_turn
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def settle_completed(
|
|
18
|
+
store: Any, handle: str, *, thread_id: str, turn: dict[str, Any],
|
|
19
|
+
live_report: dict[str, Any] | None = None,
|
|
20
|
+
) -> dict[str, Any] | None:
|
|
21
|
+
"""Settle only the exact admitted new-turn delivery and original source."""
|
|
22
|
+
from ccx.store.store import _digest
|
|
23
|
+
|
|
24
|
+
turn_id = turn.get("id") if isinstance(turn, dict) else None
|
|
25
|
+
if (not isinstance(turn_id, str) or not turn_id
|
|
26
|
+
or turn.get("status") != "completed"):
|
|
27
|
+
return None
|
|
28
|
+
owner = store.resolve(handle)
|
|
29
|
+
if owner.engine != "codex":
|
|
30
|
+
return None
|
|
31
|
+
receipt = codex_delivery.receipt_for_turn(owner.worker, turn_id)
|
|
32
|
+
if (not isinstance(receipt, dict)
|
|
33
|
+
or receipt.get("state") != "accepted"
|
|
34
|
+
or codex_delivery.route(receipt) != "new_turn"):
|
|
35
|
+
return None
|
|
36
|
+
if isinstance(receipt.get("settlement"), dict):
|
|
37
|
+
return receipt
|
|
38
|
+
source = store.codex_source(owner.worker)
|
|
39
|
+
native = (store.record(owner.worker).get("native") or {})
|
|
40
|
+
activation = store.native_activation(owner.worker)
|
|
41
|
+
binding = activation.get("nativeBinding") or {}
|
|
42
|
+
effect = store.read_receipt(owner.worker, "codex-send", receipt["deliveryId"])
|
|
43
|
+
if (not isinstance(native, dict)
|
|
44
|
+
or native.get("threadId") != thread_id
|
|
45
|
+
or native.get("threadSource") != source
|
|
46
|
+
or activation.get("state") != "confirmed"
|
|
47
|
+
or activation.get("source") != source
|
|
48
|
+
or not isinstance(binding, dict)
|
|
49
|
+
or binding.get("threadId") != thread_id
|
|
50
|
+
or not isinstance(effect, dict)
|
|
51
|
+
or effect.get("state") != "accepted"
|
|
52
|
+
or effect.get("workerId") != owner.worker_id
|
|
53
|
+
or effect.get("engine") != "codex"
|
|
54
|
+
or effect.get("generation") != store.generation
|
|
55
|
+
or effect.get("action") != "send"
|
|
56
|
+
or effect.get("intentSha256") != _digest(
|
|
57
|
+
codex_delivery.intent_spec(receipt))):
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
selected = select_report_for_turn(turn, live_report=live_report)
|
|
61
|
+
text = selected["text"] if selected is not None else ""
|
|
62
|
+
proof = {
|
|
63
|
+
"ok": True, "status": "completed", "workerId": owner.worker_id,
|
|
64
|
+
"engine": "codex", "nativeSource": source, "threadId": thread_id,
|
|
65
|
+
"turnId": turn_id, "deliveryId": receipt["deliveryId"],
|
|
66
|
+
"text": text, "nativeEvidenceSha256": _digest({
|
|
67
|
+
"source": source, "threadId": thread_id, "turnId": turn_id,
|
|
68
|
+
"status": "completed", "text": text,
|
|
69
|
+
}),
|
|
70
|
+
}
|
|
71
|
+
store.revalidate()
|
|
72
|
+
return codex_delivery.mark_settled(
|
|
73
|
+
owner.worker, receipt["deliveryId"], turn_id=turn_id,
|
|
74
|
+
original_positive_completion=proof,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def settle_from_journal(store: Any, handle: str, turn_id: str) -> dict[str, Any] | None:
|
|
79
|
+
"""Catch a pre-acceptance completion from original sparse native events.
|
|
80
|
+
|
|
81
|
+
This is not a substitute for native observation when events were missed.
|
|
82
|
+
In that case reconciliation falls through to a source-fenced thread read.
|
|
83
|
+
"""
|
|
84
|
+
owner = store.resolve(handle)
|
|
85
|
+
native = store.record(owner.worker).get("native") or {}
|
|
86
|
+
thread_id = native.get("threadId")
|
|
87
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
88
|
+
return None
|
|
89
|
+
journal = codex_evidence.read_journal(owner.worker_id)
|
|
90
|
+
if not journal.complete:
|
|
91
|
+
return None
|
|
92
|
+
items: list[dict[str, Any]] = []
|
|
93
|
+
started = False
|
|
94
|
+
for event in journal.events:
|
|
95
|
+
if event.get("turnId") != turn_id:
|
|
96
|
+
continue
|
|
97
|
+
if event.get("threadId") != thread_id:
|
|
98
|
+
return None
|
|
99
|
+
params = event.get("params") or {}
|
|
100
|
+
if event.get("method") == "turn/started":
|
|
101
|
+
started_turn = params.get("turn")
|
|
102
|
+
started = (isinstance(started_turn, dict)
|
|
103
|
+
and started_turn.get("id") == turn_id)
|
|
104
|
+
elif event.get("method") == "item/completed":
|
|
105
|
+
item = params.get("item")
|
|
106
|
+
if isinstance(item, dict):
|
|
107
|
+
items.append(item)
|
|
108
|
+
elif event.get("method") == "turn/completed":
|
|
109
|
+
turn = params.get("turn")
|
|
110
|
+
if (not started or not isinstance(turn, dict)
|
|
111
|
+
or turn.get("id") != turn_id):
|
|
112
|
+
return None
|
|
113
|
+
# Keep native turn.items intact, but also honor a stronger
|
|
114
|
+
# final_answer published in the live item/completed stream. The
|
|
115
|
+
# same report_from_turn rule applies to both observation surfaces.
|
|
116
|
+
stream_report = report_from_turn({"id": turn_id, "items": items})
|
|
117
|
+
return settle_completed(
|
|
118
|
+
store, owner.worker, thread_id=thread_id, turn=turn,
|
|
119
|
+
live_report=stream_report,
|
|
120
|
+
)
|
|
121
|
+
return None
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"Small dependency-free client for Codex's local app-server control socket.\n\nNormal CCX-owned Codex workers use :mod:`ccx.codex.daemon`; this transport is only for\nwaking a *parent* Codex thread owned by the user's primary Codex runtime/UI.\n"
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ccx.version import ccx_version
|
|
6
|
+
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import queue
|
|
13
|
+
import select
|
|
14
|
+
import socket
|
|
15
|
+
import struct
|
|
16
|
+
import threading
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CodexControlError(RuntimeError):
|
|
23
|
+
def __init__(self, method: str, error: Any):
|
|
24
|
+
self.method = method
|
|
25
|
+
self.error = error
|
|
26
|
+
self.code = error.get("code") if isinstance(error, dict) else None
|
|
27
|
+
self.data = error.get("data") if isinstance(error, dict) else None
|
|
28
|
+
message = error.get("message") if isinstance(error, dict) else str(error)
|
|
29
|
+
self.message = str(message or error)
|
|
30
|
+
super().__init__(f"{method} failed: {self.message}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _masked_frame(opcode: int, payload: bytes = b"") -> bytes:
|
|
34
|
+
"""Wire bytes for a client-to-server WebSocket frame (always masked)."""
|
|
35
|
+
mask = os.urandom(4)
|
|
36
|
+
size = len(payload)
|
|
37
|
+
if size < 126:
|
|
38
|
+
header = bytes([0x80 | opcode, 0x80 | size])
|
|
39
|
+
elif size < 65536:
|
|
40
|
+
header = bytes([0x80 | opcode, 0x80 | 126]) + struct.pack("!H", size)
|
|
41
|
+
else:
|
|
42
|
+
header = bytes([0x80 | opcode, 0x80 | 127]) + struct.pack("!Q", size)
|
|
43
|
+
return header + mask + bytes(value ^ mask[i % 4] for i, value in enumerate(payload))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _UnixWebSocket:
|
|
47
|
+
def __init__(self, path: str, *, handshake_timeout: float | None = None):
|
|
48
|
+
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
49
|
+
if handshake_timeout is not None:
|
|
50
|
+
self.sock.settimeout(handshake_timeout)
|
|
51
|
+
self.sock.connect(path)
|
|
52
|
+
self._write_lock = threading.Lock()
|
|
53
|
+
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
54
|
+
request = (
|
|
55
|
+
"GET /rpc HTTP/1.1\r\n"
|
|
56
|
+
"Host: localhost\r\n"
|
|
57
|
+
"Upgrade: websocket\r\n"
|
|
58
|
+
"Connection: Upgrade\r\n"
|
|
59
|
+
f"Sec-WebSocket-Key: {key}\r\n"
|
|
60
|
+
"Sec-WebSocket-Version: 13\r\n\r\n"
|
|
61
|
+
).encode("ascii")
|
|
62
|
+
self.sock.sendall(request)
|
|
63
|
+
head = b""
|
|
64
|
+
while b"\r\n\r\n" not in head:
|
|
65
|
+
chunk = self.sock.recv(4096)
|
|
66
|
+
if not chunk:
|
|
67
|
+
raise ConnectionError("Codex control socket closed during WebSocket handshake")
|
|
68
|
+
head += chunk
|
|
69
|
+
if len(head) > 65536:
|
|
70
|
+
raise ConnectionError("oversized Codex control WebSocket handshake")
|
|
71
|
+
header, self._buffer = head.split(b"\r\n\r\n", 1)
|
|
72
|
+
lines = header.decode("latin1").split("\r\n")
|
|
73
|
+
if not lines or " 101 " not in (" " + lines[0] + " "):
|
|
74
|
+
raise ConnectionError(
|
|
75
|
+
f"Codex control WebSocket upgrade failed: {lines[0] if lines else header!r}"
|
|
76
|
+
)
|
|
77
|
+
headers: dict[str, str] = {}
|
|
78
|
+
for line in lines[1:]:
|
|
79
|
+
if ":" in line:
|
|
80
|
+
k, v = line.split(":", 1)
|
|
81
|
+
headers[k.strip().lower()] = v.strip()
|
|
82
|
+
expected = base64.b64encode(
|
|
83
|
+
hashlib.sha1((key + _GUID).encode("ascii")).digest()
|
|
84
|
+
).decode("ascii")
|
|
85
|
+
if headers.get("sec-websocket-accept") != expected:
|
|
86
|
+
raise ConnectionError("Codex control WebSocket accept key mismatch")
|
|
87
|
+
if handshake_timeout is not None:
|
|
88
|
+
self.sock.settimeout(None)
|
|
89
|
+
|
|
90
|
+
def _read_exact(self, size: int) -> bytes:
|
|
91
|
+
out = b""
|
|
92
|
+
if self._buffer:
|
|
93
|
+
take = min(size, len(self._buffer))
|
|
94
|
+
out, self._buffer = self._buffer[:take], self._buffer[take:]
|
|
95
|
+
while len(out) < size:
|
|
96
|
+
try:
|
|
97
|
+
chunk = self.sock.recv(size - len(out))
|
|
98
|
+
except BlockingIOError:
|
|
99
|
+
# Socket-mode codexd writes are bounded/nonblocking; the
|
|
100
|
+
# single WS reader tolerates that flag without busy-spinning.
|
|
101
|
+
select.select([self.sock], [], [], 0.1)
|
|
102
|
+
continue
|
|
103
|
+
if not chunk:
|
|
104
|
+
raise EOFError("Codex control WebSocket closed")
|
|
105
|
+
out += chunk
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
def _send_frame(self, opcode: int, payload: bytes = b"") -> None:
|
|
109
|
+
with self._write_lock:
|
|
110
|
+
self.sock.sendall(_masked_frame(opcode, payload))
|
|
111
|
+
|
|
112
|
+
def send_text(self, text: str) -> None:
|
|
113
|
+
self._send_frame(0x1, text.encode("utf-8"))
|
|
114
|
+
|
|
115
|
+
def recv_text(self, *, on_ping: Callable[[bytes], None] | None = None) -> str:
|
|
116
|
+
pieces: list[bytes] = []
|
|
117
|
+
started = False
|
|
118
|
+
while True:
|
|
119
|
+
b1, b2 = self._read_exact(2)
|
|
120
|
+
fin = bool(b1 & 0x80)
|
|
121
|
+
opcode = b1 & 0x0F
|
|
122
|
+
masked = bool(b2 & 0x80)
|
|
123
|
+
size = b2 & 0x7F
|
|
124
|
+
if size == 126:
|
|
125
|
+
size = struct.unpack("!H", self._read_exact(2))[0]
|
|
126
|
+
elif size == 127:
|
|
127
|
+
size = struct.unpack("!Q", self._read_exact(8))[0]
|
|
128
|
+
mask = self._read_exact(4) if masked else None
|
|
129
|
+
payload = self._read_exact(size)
|
|
130
|
+
if mask:
|
|
131
|
+
payload = bytes(value ^ mask[i % 4] for i, value in enumerate(payload))
|
|
132
|
+
if opcode == 0x8:
|
|
133
|
+
raise EOFError("Codex control WebSocket close frame")
|
|
134
|
+
if opcode == 0x9:
|
|
135
|
+
if on_ping is not None:
|
|
136
|
+
on_ping(payload)
|
|
137
|
+
else:
|
|
138
|
+
self._send_frame(0xA, payload)
|
|
139
|
+
continue
|
|
140
|
+
if opcode == 0xA:
|
|
141
|
+
continue
|
|
142
|
+
if opcode == 0x1:
|
|
143
|
+
pieces = [payload]
|
|
144
|
+
started = True
|
|
145
|
+
elif opcode == 0x0 and started:
|
|
146
|
+
pieces.append(payload)
|
|
147
|
+
else:
|
|
148
|
+
continue
|
|
149
|
+
if fin:
|
|
150
|
+
return b"".join(pieces).decode("utf-8")
|
|
151
|
+
|
|
152
|
+
def close(self) -> None:
|
|
153
|
+
try:
|
|
154
|
+
self._send_frame(0x8)
|
|
155
|
+
except Exception:
|
|
156
|
+
pass
|
|
157
|
+
try:
|
|
158
|
+
self.sock.shutdown(socket.SHUT_RDWR)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
try:
|
|
162
|
+
self.sock.close()
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def control_socket_path() -> Path:
|
|
168
|
+
# Like Codex itself, an empty CODEX_HOME means unset (not the current directory).
|
|
169
|
+
codex_home = Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser()
|
|
170
|
+
return codex_home / "app-server-control" / "app-server-control.sock"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class CodexControlClient:
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
*,
|
|
177
|
+
on_notification: Callable[[str, dict[str, Any]], None] | None = None,
|
|
178
|
+
socket_path: str | None = None,
|
|
179
|
+
handshake_timeout: float | None = None,
|
|
180
|
+
):
|
|
181
|
+
self.ws = _UnixWebSocket(socket_path or str(control_socket_path()),
|
|
182
|
+
handshake_timeout=handshake_timeout)
|
|
183
|
+
self.on_notification = on_notification or (lambda method, params: None)
|
|
184
|
+
self._next_id = 1
|
|
185
|
+
self._pending: dict[int, queue.Queue] = {}
|
|
186
|
+
self._lock = threading.Lock()
|
|
187
|
+
self._closed = threading.Event()
|
|
188
|
+
self._failure: BaseException | None = None
|
|
189
|
+
self._reader = threading.Thread(
|
|
190
|
+
target=self._read_loop, name="ccx-codex-parent-control", daemon=True
|
|
191
|
+
)
|
|
192
|
+
self._reader.start()
|
|
193
|
+
|
|
194
|
+
def _send(self, value: dict[str, Any]) -> None:
|
|
195
|
+
if self._failure:
|
|
196
|
+
raise RuntimeError(f"Codex control connection failed: {self._failure!r}")
|
|
197
|
+
self.ws.send_text(json.dumps(value, separators=(",", ":")))
|
|
198
|
+
|
|
199
|
+
def request(
|
|
200
|
+
self, method: str, params: dict[str, Any] | None = None, *, timeout: float = 30
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
with self._lock:
|
|
203
|
+
if self._failure:
|
|
204
|
+
raise RuntimeError(f"Codex control connection failed: {self._failure!r}")
|
|
205
|
+
rid = self._next_id
|
|
206
|
+
self._next_id += 1
|
|
207
|
+
result_queue: queue.Queue = queue.Queue(maxsize=1)
|
|
208
|
+
self._pending[rid] = result_queue
|
|
209
|
+
message: dict[str, Any] = {"id": rid, "method": method}
|
|
210
|
+
if params is not None:
|
|
211
|
+
message["params"] = params
|
|
212
|
+
self._send(message)
|
|
213
|
+
try:
|
|
214
|
+
response = result_queue.get(timeout=timeout)
|
|
215
|
+
except queue.Empty as error:
|
|
216
|
+
with self._lock:
|
|
217
|
+
self._pending.pop(rid, None)
|
|
218
|
+
raise TimeoutError(f"{method} timed out") from error
|
|
219
|
+
if isinstance(response, BaseException):
|
|
220
|
+
raise response
|
|
221
|
+
if "error" in response:
|
|
222
|
+
raise CodexControlError(method, response["error"])
|
|
223
|
+
result = response.get("result")
|
|
224
|
+
return result if isinstance(result, dict) else {"value": result}
|
|
225
|
+
|
|
226
|
+
def notify(self, method: str, params: dict[str, Any] | None = None) -> None:
|
|
227
|
+
message: dict[str, Any] = {"method": method}
|
|
228
|
+
if params is not None:
|
|
229
|
+
message["params"] = params
|
|
230
|
+
self._send(message)
|
|
231
|
+
|
|
232
|
+
def initialize(self, *, timeout: float = 20) -> None:
|
|
233
|
+
self.request(
|
|
234
|
+
"initialize",
|
|
235
|
+
{
|
|
236
|
+
"clientInfo": {
|
|
237
|
+
"name": "ccx-parent-notify",
|
|
238
|
+
"title": "CCX parent notification",
|
|
239
|
+
"version": ccx_version(),
|
|
240
|
+
},
|
|
241
|
+
"capabilities": {"experimentalApi": False},
|
|
242
|
+
},
|
|
243
|
+
timeout=timeout,
|
|
244
|
+
)
|
|
245
|
+
self.notify("initialized")
|
|
246
|
+
|
|
247
|
+
def _read_loop(self) -> None:
|
|
248
|
+
try:
|
|
249
|
+
while not self._closed.is_set():
|
|
250
|
+
message = json.loads(self.ws.recv_text())
|
|
251
|
+
rid = message.get("id")
|
|
252
|
+
if rid is not None and ("result" in message or "error" in message):
|
|
253
|
+
with self._lock:
|
|
254
|
+
result_queue = self._pending.pop(rid, None)
|
|
255
|
+
if result_queue:
|
|
256
|
+
result_queue.put(message)
|
|
257
|
+
continue
|
|
258
|
+
method = message.get("method")
|
|
259
|
+
params = message.get("params")
|
|
260
|
+
if not isinstance(params, dict):
|
|
261
|
+
params = {}
|
|
262
|
+
if rid is not None:
|
|
263
|
+
self._send(
|
|
264
|
+
{
|
|
265
|
+
"id": rid,
|
|
266
|
+
"error": {
|
|
267
|
+
"code": -32601,
|
|
268
|
+
"message": f"CCX parent notification does not handle {method}",
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
elif method:
|
|
273
|
+
self.on_notification(method, params)
|
|
274
|
+
except BaseException as error:
|
|
275
|
+
if not self._closed.is_set():
|
|
276
|
+
self._failure = error
|
|
277
|
+
with self._lock:
|
|
278
|
+
pending = list(self._pending.values())
|
|
279
|
+
self._pending.clear()
|
|
280
|
+
for result_queue in pending:
|
|
281
|
+
try:
|
|
282
|
+
result_queue.put_nowait(error)
|
|
283
|
+
except queue.Full:
|
|
284
|
+
pass
|
|
285
|
+
|
|
286
|
+
def close(self) -> None:
|
|
287
|
+
self._closed.set()
|
|
288
|
+
self.ws.close()
|
|
File without changes
|