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.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -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 +807 -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 +269 -0
- package/ccx/claude/host/session.py +5244 -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 +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -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 +1558 -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 +2424 -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/history_store.py +131 -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 +1454 -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/models.py +225 -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 +296 -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 +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -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 +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -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 +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -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/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
package/ccx/cli/codex.py
ADDED
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import ccx.ops.input as ops_input
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ccx.codex.ops import caller_status, configure_worker
|
|
13
|
+
from ccx.codex.client import CodexClient
|
|
14
|
+
from ccx.codex.types import rate_limit_windows, window_label
|
|
15
|
+
from ccx.claude.usage import _caps_from_args
|
|
16
|
+
from ccx.codex import runtime as runtime
|
|
17
|
+
from ccx.controllers import live_vars
|
|
18
|
+
from ccx.codex.settle import (
|
|
19
|
+
DEFAULT_WAKE_GRACE_S,
|
|
20
|
+
wait_for_settlement,
|
|
21
|
+
)
|
|
22
|
+
from ccx.constants import (
|
|
23
|
+
EXIT_ERROR,
|
|
24
|
+
EXIT_NOT_FOUND,
|
|
25
|
+
EXIT_REJECTED,
|
|
26
|
+
EXIT_TURN_INCOMPLETE,
|
|
27
|
+
EXIT_UNAVAILABLE,
|
|
28
|
+
EXIT_WAITING,
|
|
29
|
+
)
|
|
30
|
+
from ccx.errors import BudgetStopped, CcxError, DeliveryRejected, EngineCliNotFound, InvalidRequest, SendIndeterminate
|
|
31
|
+
from ccx.controllers.orchestrator import require_notify_parent
|
|
32
|
+
from ccx.notify.daemon import prepare_notify_parent
|
|
33
|
+
from ccx.notify.registry import public_registration as public_parent_notification, set_codex_turn_id
|
|
34
|
+
|
|
35
|
+
STABLE_STATES = {"idle", "waiting", "idle-killed", "crashed", "dead"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_attach(args) -> None:
|
|
39
|
+
"""Run the real Codex remote TUI on codexd's live native thread."""
|
|
40
|
+
from ccx.system.engine_binary import resolve_engine_binary
|
|
41
|
+
from ccx.store.root import GenerationError
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
45
|
+
raise InvalidRequest("attach requires terminal stdin and stdout")
|
|
46
|
+
with runtime.attached_worker(args.target) as session:
|
|
47
|
+
argv = [resolve_engine_binary("codex"), "resume", session["threadId"]]
|
|
48
|
+
# Codex's startup update prompt defaults to running an installer.
|
|
49
|
+
argv += ["-c", "check_for_update_on_startup=false"]
|
|
50
|
+
# Turns typed in the TUI use the worker's settings, as CCX sends do.
|
|
51
|
+
if session.get("model"):
|
|
52
|
+
argv += ["-m", session["model"]]
|
|
53
|
+
if session.get("effort"):
|
|
54
|
+
argv += ["-c", f'model_reasoning_effort="{session["effort"]}"']
|
|
55
|
+
argv += ["--remote", "unix://" + session["socket"]]
|
|
56
|
+
from ccx.codex.attach_tty import run_status_tui
|
|
57
|
+
exit_code = run_status_tui(
|
|
58
|
+
argv, cwd=session["cwd"], worker_id=session["workerId"],
|
|
59
|
+
status=not getattr(args, "no_status", False),
|
|
60
|
+
)
|
|
61
|
+
if exit_code:
|
|
62
|
+
if not session.get("hasTurn"):
|
|
63
|
+
# Codex writes a thread's rollout with its first turn.
|
|
64
|
+
print("[ccx] attach: if Codex could not resume this thread, it may "
|
|
65
|
+
"have no turn yet; send it a first message with `ccx send`, "
|
|
66
|
+
"then attach", file=sys.stderr)
|
|
67
|
+
raise SystemExit(exit_code)
|
|
68
|
+
except (CcxError, GenerationError, OSError, RuntimeError) as error:
|
|
69
|
+
print(f"[ccx] attach: {error}", file=sys.stderr)
|
|
70
|
+
raise SystemExit(getattr(error, "exit_code", EXIT_UNAVAILABLE)) from error
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fail(message: str, code: int = EXIT_ERROR) -> None:
|
|
74
|
+
detail = message.removeprefix("ccx: ")
|
|
75
|
+
if detail.startswith("ccx ") and ": " in detail:
|
|
76
|
+
heading, body = detail.split(": ", 1)
|
|
77
|
+
verb = heading.removeprefix("ccx ")
|
|
78
|
+
while body.startswith((f"{verb}: ", f"{heading}: ")):
|
|
79
|
+
body = body.split(": ", 1)[1]
|
|
80
|
+
detail = f"{heading}: {body}"
|
|
81
|
+
else:
|
|
82
|
+
detail = f"ccx: {detail}"
|
|
83
|
+
print(detail, file=sys.stderr)
|
|
84
|
+
raise SystemExit(code)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _fail_send_indeterminate(error, args) -> None:
|
|
88
|
+
delivery_id = getattr(error, "delivery_id", None)
|
|
89
|
+
status = getattr(error, "delivery_status", None)
|
|
90
|
+
if getattr(args, "json", False):
|
|
91
|
+
print(json.dumps({
|
|
92
|
+
"accepted": False,
|
|
93
|
+
"reconcile_required": True,
|
|
94
|
+
"delivery_id": delivery_id,
|
|
95
|
+
"delivery_status": status,
|
|
96
|
+
"error": str(error),
|
|
97
|
+
}, indent=2))
|
|
98
|
+
raise SystemExit(EXIT_TURN_INCOMPLETE)
|
|
99
|
+
suffix = f"; delivery_id={delivery_id}" if delivery_id else ""
|
|
100
|
+
_fail(
|
|
101
|
+
f"{error}{suffix}; reconcile this delivery and do not resend it",
|
|
102
|
+
EXIT_TURN_INCOMPLETE,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _fail_send_rejected(error, short: str) -> None:
|
|
107
|
+
"""A definite send refusal: nothing started, so exit with its own code
|
|
108
|
+
(EXIT_REJECTED for busy/waiting) and say how to deliver the text later."""
|
|
109
|
+
message = _send_rejection_guidance(short, {
|
|
110
|
+
"error": getattr(error, "reason", None) or str(error),
|
|
111
|
+
"state": getattr(error, "state", None),
|
|
112
|
+
})
|
|
113
|
+
delivery_id = getattr(error, "delivery_id", None)
|
|
114
|
+
if delivery_id and getattr(error, "delivery_status", None) == "prepared":
|
|
115
|
+
# Refused before submission: the same id is safe to retry later.
|
|
116
|
+
message = (
|
|
117
|
+
f"{message}; nothing was submitted; delivery_id={delivery_id}. To "
|
|
118
|
+
f"send this text once the worker is idle (`ccx wait {short}`), "
|
|
119
|
+
f"retry it with `--delivery-id {delivery_id}`"
|
|
120
|
+
)
|
|
121
|
+
_fail(message, error.exit_code)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _prompt(args, *, required: bool) -> str | None:
|
|
125
|
+
sources = sum([
|
|
126
|
+
getattr(args, "text", None) is not None
|
|
127
|
+
or getattr(args, "prompt", None) is not None,
|
|
128
|
+
bool(getattr(args, "stdin", False)),
|
|
129
|
+
getattr(args, "file", None) is not None,
|
|
130
|
+
])
|
|
131
|
+
if sources > 1:
|
|
132
|
+
_fail("use exactly one of positional text, --stdin, or --file")
|
|
133
|
+
positional = getattr(args, "text", None)
|
|
134
|
+
if positional is None:
|
|
135
|
+
positional = getattr(args, "prompt", None)
|
|
136
|
+
if getattr(args, "stdin", False) or getattr(args, "file", None) == "-":
|
|
137
|
+
value = sys.stdin.read()
|
|
138
|
+
elif getattr(args, "file", None):
|
|
139
|
+
try:
|
|
140
|
+
value = Path(args.file).read_text()
|
|
141
|
+
except OSError as error:
|
|
142
|
+
_fail(f"cannot read prompt file: {error}")
|
|
143
|
+
else:
|
|
144
|
+
value = positional
|
|
145
|
+
if required and (value is None or not value.strip()):
|
|
146
|
+
_fail("prompt text is required")
|
|
147
|
+
return value
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _rpc(short: str, message: dict[str, Any], timeout: float = 10) -> dict[str, Any]:
|
|
151
|
+
return runtime.rpc(short, message, timeout)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _record(target: str) -> dict[str, Any]:
|
|
155
|
+
try:
|
|
156
|
+
return runtime.record(target)
|
|
157
|
+
except CcxError as error:
|
|
158
|
+
_fail(str(error), error.exit_code)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _require_workspace(record: dict[str, Any]) -> None:
|
|
162
|
+
"""Refuse to start project work in a workspace that no longer exists."""
|
|
163
|
+
try:
|
|
164
|
+
runtime.require_workspace(record)
|
|
165
|
+
except CcxError as error:
|
|
166
|
+
_fail(str(error), error.exit_code)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _identity(record: dict[str, Any]) -> dict[str, Any]:
|
|
170
|
+
return runtime.identity(record)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _emit(value: dict[str, Any], args, *, plain: str | None = None) -> None:
|
|
174
|
+
if getattr(args, "json", False):
|
|
175
|
+
print(json.dumps(value, indent=2))
|
|
176
|
+
elif plain is not None:
|
|
177
|
+
print(plain)
|
|
178
|
+
else:
|
|
179
|
+
print(value.get("short", ""))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _wait(short: str, timeout: float | None) -> dict[str, Any]:
|
|
183
|
+
return runtime.wait(short, timeout)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _emit_wait(
|
|
187
|
+
result: dict[str, Any],
|
|
188
|
+
args,
|
|
189
|
+
*,
|
|
190
|
+
include_report: bool = True,
|
|
191
|
+
expected_turn_id: str | None = None,
|
|
192
|
+
) -> None:
|
|
193
|
+
state = result.get("state")
|
|
194
|
+
last_turn = result.get("lastTurn") or result.get("last_turn")
|
|
195
|
+
if not isinstance(last_turn, dict):
|
|
196
|
+
last_turn = None
|
|
197
|
+
# A non-success turn is this command's failure only when the command was
|
|
198
|
+
# waiting for that turn. Bare `ccx wait` asks about worker health, so
|
|
199
|
+
# an older failed or interrupted turn stays visible without making a
|
|
200
|
+
# healthy idle worker exit nonzero.
|
|
201
|
+
outcome_matches = expected_turn_id is not None and (
|
|
202
|
+
last_turn is not None and last_turn.get("turnId") == expected_turn_id
|
|
203
|
+
)
|
|
204
|
+
settled_non_success = (
|
|
205
|
+
last_turn is not None
|
|
206
|
+
and last_turn.get("status") in {"failed", "interrupted"}
|
|
207
|
+
)
|
|
208
|
+
# A wait that ran out of budget, or found an active record the daemon no
|
|
209
|
+
# longer serves, is not a stable worker, whatever the recorded state says.
|
|
210
|
+
timed_out = result.get("ok") is False and result.get("error") == "wait timed out"
|
|
211
|
+
orphaned = result.get("ok") is False and result.get("loaded") is False
|
|
212
|
+
output = {
|
|
213
|
+
"engine": "codex",
|
|
214
|
+
"short": result.get("short"),
|
|
215
|
+
"thread_id": result.get("threadId") or result.get("thread_id"),
|
|
216
|
+
"name": result.get("name"),
|
|
217
|
+
"cwd": result.get("cwd"),
|
|
218
|
+
"state": state,
|
|
219
|
+
"active_turn_id": result.get("activeTurnId")
|
|
220
|
+
or result.get("active_turn_id"),
|
|
221
|
+
"pending_approvals": result.get("pendingApprovals") or [],
|
|
222
|
+
"last_turn": last_turn,
|
|
223
|
+
"active_turn_error": (
|
|
224
|
+
result.get("activeTurnError") or result.get("active_turn_error")
|
|
225
|
+
),
|
|
226
|
+
# Evidence health, beside worker health rather than inside it: a
|
|
227
|
+
# degraded journal never changes the state or the exit code.
|
|
228
|
+
"evidence": result.get("evidence"),
|
|
229
|
+
# The status of the turn this command waited for. Null when no
|
|
230
|
+
# specific turn was awaited (bare `wait`) or when the settled outcome
|
|
231
|
+
# belongs to another turn; `last_turn` stays visible either way.
|
|
232
|
+
"turn_status": (
|
|
233
|
+
last_turn.get("status") if outcome_matches else None
|
|
234
|
+
),
|
|
235
|
+
"timed_out": timed_out,
|
|
236
|
+
}
|
|
237
|
+
if timed_out or orphaned:
|
|
238
|
+
output["error"] = result.get("error")
|
|
239
|
+
report = result.get("lastReport")
|
|
240
|
+
if not isinstance(report, dict):
|
|
241
|
+
report = None
|
|
242
|
+
# Scopes 02/04 deliberately preserve an older valid report when a newer
|
|
243
|
+
# turn produces none, so the worker's history keeps it. That history is not
|
|
244
|
+
# this operation's answer: a command that waited for one turn may only
|
|
245
|
+
# present a report that came from that turn and settled with it.
|
|
246
|
+
if expected_turn_id is not None and not (
|
|
247
|
+
outcome_matches and report is not None
|
|
248
|
+
and report.get("turnId") == expected_turn_id
|
|
249
|
+
):
|
|
250
|
+
report = None
|
|
251
|
+
if include_report and report:
|
|
252
|
+
output["report"] = report
|
|
253
|
+
if result.get("settle"):
|
|
254
|
+
output.update({
|
|
255
|
+
"worker": result.get("worker"), "settle": True,
|
|
256
|
+
"settlement_verdict": result.get("settlementVerdict"),
|
|
257
|
+
"settled": bool(result.get("settled")),
|
|
258
|
+
"outstanding_count": int(result.get("outstandingCount") or 0),
|
|
259
|
+
"outstanding": list(result.get("outstanding") or []),
|
|
260
|
+
"outstanding_truncated": bool(result.get("outstandingTruncated")),
|
|
261
|
+
"pending_wake_count": int(result.get("pendingWakeCount") or 0),
|
|
262
|
+
"pending_wake": list(result.get("pendingWake") or []),
|
|
263
|
+
"pending_wake_truncated": bool(result.get("pendingWakeTruncated")),
|
|
264
|
+
"registration_count": int(result.get("registrationCount") or 0),
|
|
265
|
+
"wake_grace_seconds": result.get("wakeGraceSeconds"),
|
|
266
|
+
"last_message": result.get("lastMessage"),
|
|
267
|
+
"error": result.get("error"),
|
|
268
|
+
})
|
|
269
|
+
if getattr(args, "json", False):
|
|
270
|
+
print(json.dumps(output, indent=2))
|
|
271
|
+
elif result.get("settle"):
|
|
272
|
+
verdict = result.get("settlementVerdict") or "unknown"
|
|
273
|
+
print(f"{output['worker']} {verdict}")
|
|
274
|
+
elif timed_out or orphaned:
|
|
275
|
+
print(f"{output['short']} {state} ({result.get('error')})")
|
|
276
|
+
elif state == "waiting":
|
|
277
|
+
print(json.dumps(output["pending_approvals"], indent=2))
|
|
278
|
+
elif settled_non_success:
|
|
279
|
+
# Never print the preserved older report here: it did not come from the
|
|
280
|
+
# turn that failed or was interrupted.
|
|
281
|
+
print(f"{output['short']} {state} (last turn {last_turn['status']})")
|
|
282
|
+
elif report and include_report:
|
|
283
|
+
print(report.get("text", ""))
|
|
284
|
+
else:
|
|
285
|
+
print(f"{output['short']} {state}")
|
|
286
|
+
if result.get("settle"):
|
|
287
|
+
verdict = result.get("settlementVerdict")
|
|
288
|
+
if verdict == "settled":
|
|
289
|
+
return
|
|
290
|
+
if verdict == "approval_waiting":
|
|
291
|
+
raise SystemExit(EXIT_WAITING)
|
|
292
|
+
if verdict in {"crashed", "dead"}:
|
|
293
|
+
raise SystemExit(EXIT_UNAVAILABLE)
|
|
294
|
+
raise SystemExit(EXIT_TURN_INCOMPLETE)
|
|
295
|
+
if timed_out:
|
|
296
|
+
raise SystemExit(EXIT_TURN_INCOMPLETE)
|
|
297
|
+
if orphaned:
|
|
298
|
+
raise SystemExit(EXIT_UNAVAILABLE)
|
|
299
|
+
if state == "waiting":
|
|
300
|
+
raise SystemExit(EXIT_WAITING)
|
|
301
|
+
if state in {"crashed", "dead"}:
|
|
302
|
+
raise SystemExit(EXIT_UNAVAILABLE)
|
|
303
|
+
if expected_turn_id is None:
|
|
304
|
+
return
|
|
305
|
+
# The waited turn owns this exit status: it must be the settled turn and it
|
|
306
|
+
# must have completed. An unknown native status is never success.
|
|
307
|
+
if not outcome_matches or last_turn.get("status") != "completed":
|
|
308
|
+
raise SystemExit(EXIT_TURN_INCOMPLETE)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _require_parent_notification():
|
|
312
|
+
try:
|
|
313
|
+
parent = require_notify_parent()
|
|
314
|
+
prepare_notify_parent(parent)
|
|
315
|
+
return parent
|
|
316
|
+
except CcxError as error:
|
|
317
|
+
_fail(f"notify: {error}", error.exit_code)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _prepare_parent_notification(args):
|
|
321
|
+
settle = bool(getattr(args, "settle", False))
|
|
322
|
+
if settle and not getattr(args, "notify_parent", False):
|
|
323
|
+
_fail("--settle requires --notify-parent")
|
|
324
|
+
if not getattr(args, "notify_parent", False):
|
|
325
|
+
return None
|
|
326
|
+
return _require_parent_notification()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _emit_parent_notification_armed(
|
|
330
|
+
record: dict[str, Any],
|
|
331
|
+
registration: dict[str, Any],
|
|
332
|
+
args,
|
|
333
|
+
*,
|
|
334
|
+
extra: dict[str, Any] | None = None,
|
|
335
|
+
) -> None:
|
|
336
|
+
output = {**_identity(record), **(extra or {})}
|
|
337
|
+
output["notify_parent"] = public_parent_notification(registration)
|
|
338
|
+
_emit(output, args, plain=record["short"] if not getattr(args, "json", False) else None)
|
|
339
|
+
if not getattr(args, "json", False):
|
|
340
|
+
public = output["notify_parent"]
|
|
341
|
+
print(f"notify: armed {public['event_id']} -> Codex parent {public['parent']}")
|
|
342
|
+
print(f"notify: {public['instruction']}")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def cmd_set(args) -> None:
|
|
346
|
+
"""Edit exact original Codex future-turn config; rename remains metadata."""
|
|
347
|
+
name_given = args.name is not None
|
|
348
|
+
model_given = args.model is not None
|
|
349
|
+
effort_given = args.effort is not None
|
|
350
|
+
if not (name_given or model_given or effort_given):
|
|
351
|
+
_fail("set: nothing to change (pass --name, --model and/or --effort)")
|
|
352
|
+
try:
|
|
353
|
+
client = CodexClient()
|
|
354
|
+
if model_given or effort_given:
|
|
355
|
+
updates = {}
|
|
356
|
+
if model_given:
|
|
357
|
+
updates["model"] = None if args.model == "default" else args.model
|
|
358
|
+
if effort_given:
|
|
359
|
+
updates["effort"] = None if args.effort == "default" else args.effort
|
|
360
|
+
# one shared core patch for both settings, before a rename;
|
|
361
|
+
# a native busy refusal must not leave a new display name.
|
|
362
|
+
record = configure_worker(
|
|
363
|
+
args.target, expected_generation=client._uuid_store.generation,
|
|
364
|
+
**updates,
|
|
365
|
+
)
|
|
366
|
+
from ccx.codex.types import CodexSessionInfo
|
|
367
|
+
info = CodexSessionInfo.from_record(record)
|
|
368
|
+
else:
|
|
369
|
+
# No daemon config RPC for pure display metadata.
|
|
370
|
+
info = None
|
|
371
|
+
if name_given:
|
|
372
|
+
info = client.set_name(args.target, args.name)
|
|
373
|
+
assert info is not None
|
|
374
|
+
except CcxError as error:
|
|
375
|
+
_fail(str(error), error.exit_code)
|
|
376
|
+
result = {
|
|
377
|
+
"worker": f"codex:{info.worker_id}", "worker_id": info.worker_id,
|
|
378
|
+
"short": info.short, "engine": "codex",
|
|
379
|
+
"action": ("set+rename" if name_given else "set")
|
|
380
|
+
if (model_given or effort_given) else "rename",
|
|
381
|
+
}
|
|
382
|
+
if name_given:
|
|
383
|
+
result["name"] = info.name
|
|
384
|
+
if model_given:
|
|
385
|
+
result["model"] = info.model
|
|
386
|
+
if effort_given:
|
|
387
|
+
result["effort"] = info.effort
|
|
388
|
+
_emit(result, args, plain=result["worker"])
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def cmd_send(args) -> None:
|
|
392
|
+
"""Original Codex CLI sends one full-ID native turn through codexd.
|
|
393
|
+
|
|
394
|
+
The one globally owned full UUID and immutable delivery ID select the
|
|
395
|
+
original semantic receipt; Codex native threadId remains independently
|
|
396
|
+
bound. No implicit thread/start, no native-busy steering, and no old
|
|
397
|
+
short/name or external source is authorized.
|
|
398
|
+
"""
|
|
399
|
+
from ccx import constants
|
|
400
|
+
import time
|
|
401
|
+
from ccx.ops.send import send_turn as full_send
|
|
402
|
+
from ccx.ops.wait import reconcile_turn
|
|
403
|
+
from ccx.ops.report import report_worker
|
|
404
|
+
from ccx.store.root import GenerationError, require_ccx_active
|
|
405
|
+
from ccx.store.identity import IdentityError, EngineMismatch
|
|
406
|
+
from ccx.codex.delivery import DeliveryReceiptError
|
|
407
|
+
from uuid import uuid4
|
|
408
|
+
if getattr(args, "settle", False) and not args.notify_parent:
|
|
409
|
+
_fail("--settle requires --notify-parent")
|
|
410
|
+
if args.timeout is not None and args.timeout <= 0:
|
|
411
|
+
_fail("positive timeout required")
|
|
412
|
+
prompt = _prompt(args, required=True)
|
|
413
|
+
token = args.delivery_id or str(uuid4())
|
|
414
|
+
try:
|
|
415
|
+
store=require_ccx_active(constants.CCX_ROOT)
|
|
416
|
+
owner=store.resolve(args.target)
|
|
417
|
+
if owner.engine != "codex":
|
|
418
|
+
raise EngineMismatch("Send requires a Codex worker handle")
|
|
419
|
+
if args.var is not None or args.vars_from is not None:
|
|
420
|
+
from ccx.cli.commands.turns import _publish_cli_send_variables
|
|
421
|
+
prompt = _publish_cli_send_variables(store, owner, args, prompt, token)
|
|
422
|
+
first=full_send(owner.worker,prompt,delivery_id=token,
|
|
423
|
+
cap=_caps_from_args(args),
|
|
424
|
+
mode="new_turn_only", notify_parent=bool(args.notify_parent),
|
|
425
|
+
settle=bool(getattr(args, "settle", False)))
|
|
426
|
+
# Parser makes --notify-parent / --wait / --no-wait exclusive;
|
|
427
|
+
# the parent gets its durable event, then yields rather than polling.
|
|
428
|
+
should_wait=not (args.no_wait or args.notify_parent)
|
|
429
|
+
started=time.monotonic()
|
|
430
|
+
observation=None
|
|
431
|
+
failure=None
|
|
432
|
+
if should_wait:
|
|
433
|
+
from ccx.codex.settle import send_settle_failure
|
|
434
|
+
while True:
|
|
435
|
+
# native-observe can hold for its entire budget: poll in short
|
|
436
|
+
# slices so a failed turn cannot consume a 120s send timeout.
|
|
437
|
+
observation=reconcile_turn(
|
|
438
|
+
owner.worker, delivery_id=token,
|
|
439
|
+
timeout=(0.5 if args.timeout is None else
|
|
440
|
+
max(0.001, min(0.5, args.timeout-(time.monotonic()-started)))),
|
|
441
|
+
)
|
|
442
|
+
if observation["status"] in ("settled","rejected"):
|
|
443
|
+
break
|
|
444
|
+
failure=send_settle_failure(owner.worker, observation)
|
|
445
|
+
if failure is not None:
|
|
446
|
+
break
|
|
447
|
+
if args.timeout is not None and time.monotonic()-started>=args.timeout:
|
|
448
|
+
break
|
|
449
|
+
time.sleep(.1)
|
|
450
|
+
complete = (first.get("settled") if observation is None
|
|
451
|
+
else observation["status"]=="settled")
|
|
452
|
+
result={
|
|
453
|
+
"worker":owner.worker, "worker_id":owner.worker_id,
|
|
454
|
+
"engine":"codex","short":owner.short,
|
|
455
|
+
"delivery_id":token,
|
|
456
|
+
"status":(failure["status"] if failure else
|
|
457
|
+
observation["status"] if observation else first["status"]),
|
|
458
|
+
"settled":bool(complete),
|
|
459
|
+
"delivery":(observation.get("delivery") if observation
|
|
460
|
+
else first.get("delivery")),
|
|
461
|
+
}
|
|
462
|
+
if failure is not None:
|
|
463
|
+
result["error"]=failure.get("error")
|
|
464
|
+
if args.notify_parent:
|
|
465
|
+
result["notify_parent"]=first.get("notification")
|
|
466
|
+
if complete and not args.notify_parent:
|
|
467
|
+
# Report this delivery's original R, never the worker's latest.
|
|
468
|
+
turn_ref=(result["delivery"] or {}).get("turn_ref")
|
|
469
|
+
if not turn_ref:
|
|
470
|
+
from ccx.errors import SessionUnavailable
|
|
471
|
+
raise SessionUnavailable(
|
|
472
|
+
"Completed delivery has no turn ID; "
|
|
473
|
+
"reconcile its delivery ID"
|
|
474
|
+
)
|
|
475
|
+
result["report"]=report_worker(owner.worker,turn_ref=turn_ref)
|
|
476
|
+
if args.json:
|
|
477
|
+
_emit(result,args)
|
|
478
|
+
else:
|
|
479
|
+
print(owner.worker)
|
|
480
|
+
print(f"delivery_id: {token}")
|
|
481
|
+
if args.notify_parent:
|
|
482
|
+
notification = result["notify_parent"] or {}
|
|
483
|
+
print(f"notification: {notification.get('event_id')} ({notification.get('state')})")
|
|
484
|
+
elif complete:
|
|
485
|
+
print(result["report"]["text"])
|
|
486
|
+
else:
|
|
487
|
+
print(f"status: {result['status']}" +
|
|
488
|
+
(f"; error: {result['error']}" if result.get("error") else ""))
|
|
489
|
+
if result["status"]=="rejected":
|
|
490
|
+
raise SystemExit(EXIT_REJECTED)
|
|
491
|
+
if should_wait and not complete:
|
|
492
|
+
from ccx.cli.commands._common import _nbwait_exit_code
|
|
493
|
+
raise SystemExit(
|
|
494
|
+
(EXIT_UNAVAILABLE if failure["status"] == "unavailable" else
|
|
495
|
+
_nbwait_exit_code(failure["status"], failure.get("apiError")))
|
|
496
|
+
if failure is not None else EXIT_TURN_INCOMPLETE
|
|
497
|
+
)
|
|
498
|
+
except SendIndeterminate as error:
|
|
499
|
+
_fail_send_indeterminate(error, args)
|
|
500
|
+
except BudgetStopped as error:
|
|
501
|
+
if args.json:
|
|
502
|
+
_emit({
|
|
503
|
+
"worker": owner.worker, "worker_id": owner.worker_id,
|
|
504
|
+
"engine": "codex", "short": owner.short,
|
|
505
|
+
"delivery_id": token, "status": "budget-stopped",
|
|
506
|
+
"code": "budget_stopped", "reason": error.reason or str(error),
|
|
507
|
+
"settled": False,
|
|
508
|
+
}, args)
|
|
509
|
+
raise SystemExit(error.exit_code) from None
|
|
510
|
+
_fail("ccx send: " + str(error), error.exit_code)
|
|
511
|
+
except CcxError as error:
|
|
512
|
+
# Definite busy refusal is exit 5; immutable input/parent conflicts
|
|
513
|
+
# remain typed, and no uncertain native write is reported as unavailable.
|
|
514
|
+
_fail("ccx send: "+str(error), error.exit_code)
|
|
515
|
+
except (GenerationError, IdentityError, DeliveryReceiptError) as error:
|
|
516
|
+
_fail("ccx send: native delivery unavailable: "+str(error),3)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def cmd_reconcile(args) -> None:
|
|
520
|
+
"""Read-only reconciliation of one original Codex delivery or turn receipt.
|
|
521
|
+
|
|
522
|
+
The exact owned full UUID's semantic receipt is the only authority; a
|
|
523
|
+
native turn CCX holds no receipt for is not_found, never accepted.
|
|
524
|
+
"""
|
|
525
|
+
from ccx import constants
|
|
526
|
+
from ccx.codex.delivery import DeliveryReceiptError
|
|
527
|
+
from ccx.codex.ops import _semantic_status
|
|
528
|
+
from ccx.ops.wait import reconcile_turn
|
|
529
|
+
from ccx.store.root import GenerationError, require_ccx_active
|
|
530
|
+
from ccx.store.identity import EngineMismatch, IdentityError
|
|
531
|
+
try:
|
|
532
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
533
|
+
owner = store.resolve(args.target)
|
|
534
|
+
if owner.engine != "codex":
|
|
535
|
+
raise EngineMismatch("Reconcile requires a Codex worker handle")
|
|
536
|
+
result = reconcile_turn(
|
|
537
|
+
owner.worker,
|
|
538
|
+
delivery_id=getattr(args, "delivery_id", None),
|
|
539
|
+
turn_id=getattr(args, "turn_id", None),
|
|
540
|
+
)
|
|
541
|
+
record = store.record(owner.worker)
|
|
542
|
+
except CcxError as error:
|
|
543
|
+
_fail(str(error), error.exit_code)
|
|
544
|
+
except (GenerationError, IdentityError, DeliveryReceiptError) as error:
|
|
545
|
+
_fail("ccx reconcile unavailable: " + str(error), 3)
|
|
546
|
+
|
|
547
|
+
# Project from the semantic receipt, not the shared raw status: a prepared
|
|
548
|
+
# receipt whose send effect advanced recommends "wait" (pending, never
|
|
549
|
+
# retryable), and accepted stays delivered (exit 0) whether settled or not.
|
|
550
|
+
delivery = result.get("delivery")
|
|
551
|
+
semantic = _semantic_status(delivery)
|
|
552
|
+
status, pending = caller_status({
|
|
553
|
+
"status": semantic, "pending": semantic == "pending", "delivery": delivery,
|
|
554
|
+
})
|
|
555
|
+
output = {
|
|
556
|
+
**_identity(record), "worker": owner.worker, "worker_id": owner.worker_id,
|
|
557
|
+
"engine": "codex", "thread_id": (record.get("native") or {}).get("threadId"),
|
|
558
|
+
"reconcile_status": status,
|
|
559
|
+
"pending": pending,
|
|
560
|
+
"retryable": status == "not_delivered",
|
|
561
|
+
"delivery": delivery,
|
|
562
|
+
}
|
|
563
|
+
turn = (delivery or {}).get("turn_id") or getattr(args, "turn_id", None) or "-"
|
|
564
|
+
delivery_id = (delivery or {}).get("delivery_id") or getattr(args, "delivery_id", None) or "-"
|
|
565
|
+
_emit(output, args, plain=(
|
|
566
|
+
f"{owner.short} {status} delivery_id: {delivery_id} turn: {turn}"
|
|
567
|
+
))
|
|
568
|
+
|
|
569
|
+
if status == "not_found":
|
|
570
|
+
raise SystemExit(EXIT_NOT_FOUND)
|
|
571
|
+
# Definitively not delivered shares the refused send's exit code.
|
|
572
|
+
if status in {"rejected", "not_delivered"}:
|
|
573
|
+
raise SystemExit(EXIT_REJECTED)
|
|
574
|
+
if pending:
|
|
575
|
+
raise SystemExit(EXIT_TURN_INCOMPLETE)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _send_rejection_guidance(short: str, reply: dict[str, Any]) -> str:
|
|
579
|
+
"""A refused strict send, named with the verb the caller actually wanted."""
|
|
580
|
+
error = str(reply.get("error") or "send rejected")
|
|
581
|
+
state = reply.get("state")
|
|
582
|
+
if state == "waiting":
|
|
583
|
+
return (f"{error}; the worker is blocked on an approval — resolve it "
|
|
584
|
+
f"with `ccx approve {short}` or `ccx reject {short}`")
|
|
585
|
+
if state == "busy":
|
|
586
|
+
return (f"{error}; `send` starts a new turn on an idle worker. To add "
|
|
587
|
+
f"input to the turn already running, use "
|
|
588
|
+
f"`ccx steer {short} \"...\"`")
|
|
589
|
+
return error
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def cmd_notify(args) -> None:
|
|
593
|
+
"""Arm an independent read-only observation of one original Codex turn."""
|
|
594
|
+
from ccx import constants
|
|
595
|
+
from ccx.errors import CcxError, InvalidRequest, SessionNotFound, SessionUnavailable
|
|
596
|
+
from ccx.controllers.orchestrator import require_notify_parent
|
|
597
|
+
from ccx.notify.registry import create_registration, public_registration
|
|
598
|
+
from ccx.notify.daemon import ensure_notify_daemon
|
|
599
|
+
from ccx.notify.select import select_current_turn
|
|
600
|
+
from ccx.store.root import GenerationError, require_ccx_active
|
|
601
|
+
from ccx.store.identity import IdentityError, UnknownWorker
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
try:
|
|
605
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
606
|
+
owner = store.resolve(args.target)
|
|
607
|
+
if owner.engine != "codex":
|
|
608
|
+
raise InvalidRequest("Notification requires a Codex worker handle")
|
|
609
|
+
parent = require_notify_parent()
|
|
610
|
+
selection = select_current_turn(owner.worker)
|
|
611
|
+
reg = create_registration(
|
|
612
|
+
parent=parent, worker_engine="codex", worker_id=owner.worker_id,
|
|
613
|
+
worker_name=selection["name"], operation=selection["operation"],
|
|
614
|
+
source="notify", settle=bool(getattr(args, "settle", False)),
|
|
615
|
+
)
|
|
616
|
+
except UnknownWorker as exc:
|
|
617
|
+
raise SessionNotFound(f"ccx notify: no such managed worker {args.target}") from exc
|
|
618
|
+
except IdentityError as exc:
|
|
619
|
+
raise InvalidRequest(f"Invalid worker handle for notification: {exc}") from exc
|
|
620
|
+
except (GenerationError, OSError) as exc:
|
|
621
|
+
# Selection or publication failed; never reported as an armed event.
|
|
622
|
+
raise SessionUnavailable(f"Cannot select a Codex turn to notify about; check status: {exc}") from exc
|
|
623
|
+
available = True
|
|
624
|
+
try:
|
|
625
|
+
ensure_notify_daemon()
|
|
626
|
+
except (CcxError, GenerationError, OSError) as exc:
|
|
627
|
+
available = False
|
|
628
|
+
print(f"ccx notify: event durably armed; supervisor unavailable: {exc}",
|
|
629
|
+
file=sys.stderr)
|
|
630
|
+
result = {**public_registration(reg), "armed": True,
|
|
631
|
+
"selected_state": selection["state"],
|
|
632
|
+
"notifier_available": available}
|
|
633
|
+
except CcxError as exc:
|
|
634
|
+
_fail(str(exc), exc.exit_code)
|
|
635
|
+
_emit(result, args, plain=f"{result['worker']} event_id={result['event_id']}")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def cmd_steer(args) -> None:
|
|
640
|
+
"""One original full-UUID Codex native steer, accepted placement only."""
|
|
641
|
+
from uuid import uuid4
|
|
642
|
+
|
|
643
|
+
text = _prompt(args, required=True)
|
|
644
|
+
token = getattr(args, "delivery_id", None)
|
|
645
|
+
if token is None:
|
|
646
|
+
token = str(uuid4())
|
|
647
|
+
try:
|
|
648
|
+
accepted = CodexClient().steer(
|
|
649
|
+
args.target, text, delivery_id=token,
|
|
650
|
+
caps=_caps_from_args(args),
|
|
651
|
+
)
|
|
652
|
+
except SendIndeterminate as error:
|
|
653
|
+
_fail_send_indeterminate(error, args)
|
|
654
|
+
except DeliveryRejected as error:
|
|
655
|
+
if getattr(error, "delivery_status", None) is None:
|
|
656
|
+
_fail(f"steer: {error}; delivery_id={token}; nothing recorded; "
|
|
657
|
+
"this ID may be used when a turn is running", error.exit_code)
|
|
658
|
+
_fail(f"steer: {error}; delivery_id={token}; "
|
|
659
|
+
"this rejected delivery is terminal; choose a new ID for a "
|
|
660
|
+
"new instruction", error.exit_code)
|
|
661
|
+
except CcxError as error:
|
|
662
|
+
_fail(f"steer: {error}; delivery_id={token}", error.exit_code)
|
|
663
|
+
value = accepted.as_dict()
|
|
664
|
+
_emit(value, args, plain=f"{value['worker']} steer accepted-current-turn "
|
|
665
|
+
f"delivery_id={accepted.delivery_id}")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def cmd_stop(args) -> None:
|
|
669
|
+
"""Request only this full-UUID Codex worker's active native turn interrupt."""
|
|
670
|
+
from ccx.ops.cancel import stop_worker
|
|
671
|
+
try:
|
|
672
|
+
result = stop_worker(args.target)
|
|
673
|
+
except CcxError as error:
|
|
674
|
+
if getattr(args, "json", False):
|
|
675
|
+
print(json.dumps({
|
|
676
|
+
"ok": False, "error": str(error),
|
|
677
|
+
"status": getattr(error, "turn_status", "rejected"
|
|
678
|
+
if isinstance(error, DeliveryRejected) else "unavailable"),
|
|
679
|
+
"cancellation_id": getattr(error, "cancellation_id", None),
|
|
680
|
+
"turn_ref": getattr(error, "turn_ref", None),
|
|
681
|
+
}))
|
|
682
|
+
_fail(f"stop: {error}", error.exit_code)
|
|
683
|
+
value = {
|
|
684
|
+
**result, "ok": True, "short": result["worker"]["short"],
|
|
685
|
+
"requested": True,
|
|
686
|
+
"cancelled": result["confirmed"] and result["outcome"] == "interrupted",
|
|
687
|
+
"interrupted": result["confirmed"] and result["outcome"] == "interrupted",
|
|
688
|
+
"turn_id": result["turn_ref"].rsplit(":", 1)[-1],
|
|
689
|
+
}
|
|
690
|
+
_emit(value, args, plain=result["worker"]["worker"])
|
|
691
|
+
|
|
692
|
+
def cmd_kill(args) -> None:
|
|
693
|
+
"""Unload only the exact full-UUID Codex worker, never the shared daemon."""
|
|
694
|
+
from ccx.ops.wait import park_worker
|
|
695
|
+
try:
|
|
696
|
+
result = park_worker(args.target, timeout=getattr(args, "timeout", 90))
|
|
697
|
+
except CcxError as error:
|
|
698
|
+
_fail(f"kill: {error}", error.exit_code)
|
|
699
|
+
_emit(result, args, plain=result["worker"])
|
|
700
|
+
|
|
701
|
+
def cmd_wait(args) -> None:
|
|
702
|
+
from ccx.cli.commands.turns import _emit_full_uuid_wait
|
|
703
|
+
from ccx.ops.wait import wait_worker
|
|
704
|
+
try:
|
|
705
|
+
if getattr(args, "settle", False):
|
|
706
|
+
verdict = wait_for_settlement(
|
|
707
|
+
args.target, timeout=getattr(args, "timeout", None),
|
|
708
|
+
wake_grace_s=getattr(args, "wake_grace", DEFAULT_WAKE_GRACE_S))
|
|
709
|
+
else:
|
|
710
|
+
verdict = wait_worker(args.target, timeout=getattr(args, "timeout", None))
|
|
711
|
+
except CcxError as error:
|
|
712
|
+
_fail(f"wait: {error}", error.exit_code)
|
|
713
|
+
if verdict.get("settle"):
|
|
714
|
+
_emit_wait(verdict, args)
|
|
715
|
+
else:
|
|
716
|
+
_emit_full_uuid_wait(args, verdict)
|
|
717
|
+
|
|
718
|
+
def _decision(args, operation: str) -> None:
|
|
719
|
+
# Freeze one current CCX gateId before parent preparation or native effect.
|
|
720
|
+
# Supplied IDs pass through unchanged; the backend fences the original gate.
|
|
721
|
+
if getattr(args, "settle", False) and not getattr(args, "notify_parent", False):
|
|
722
|
+
_fail("--settle requires --notify-parent")
|
|
723
|
+
try:
|
|
724
|
+
request_id = args.request_id
|
|
725
|
+
record = None
|
|
726
|
+
if request_id is None:
|
|
727
|
+
record = _record(args.target)
|
|
728
|
+
gates = [gate for gate in record.get("pendingApprovals") or []
|
|
729
|
+
if isinstance(gate, dict)]
|
|
730
|
+
if not gates:
|
|
731
|
+
raise DeliveryRejected(f"Codex {operation}: no pending approval; check ccx peek.")
|
|
732
|
+
if len(gates) != 1:
|
|
733
|
+
raise InvalidRequest(
|
|
734
|
+
"Several approvals are pending; specify --request-id from ccx peek."
|
|
735
|
+
)
|
|
736
|
+
request_id = gates[0].get("gateId")
|
|
737
|
+
if not isinstance(request_id, str) or not request_id:
|
|
738
|
+
raise DeliveryRejected("Cannot identify a pending approval; check ccx peek.")
|
|
739
|
+
_prepare_parent_notification(args)
|
|
740
|
+
if getattr(args, "notify_parent", False):
|
|
741
|
+
from ccx import constants
|
|
742
|
+
from ccx.ops import common as protocol_ops
|
|
743
|
+
from ccx.store.root import require_ccx_active
|
|
744
|
+
if record is None:
|
|
745
|
+
record = _record(args.target)
|
|
746
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
747
|
+
owner = store.resolve(f"codex:{record['workerId']}")
|
|
748
|
+
# Deterministic gate response token, never the native requestId.
|
|
749
|
+
token = str(uuid.uuid5(
|
|
750
|
+
uuid.NAMESPACE_URL,
|
|
751
|
+
f"ccx-input-decision:{store.generation}:{owner.worker_id}:{request_id}",
|
|
752
|
+
))
|
|
753
|
+
result = ops_input.decide_input(
|
|
754
|
+
owner.worker, request_id=request_id, response_id=token,
|
|
755
|
+
decision=operation, notify_parent=True,
|
|
756
|
+
settle=bool(getattr(args, "settle", False)),
|
|
757
|
+
)
|
|
758
|
+
public = {
|
|
759
|
+
"worker": owner.worker, "worker_id": owner.worker_id,
|
|
760
|
+
"request_id": request_id, "response_id": token,
|
|
761
|
+
"decision": "accept" if operation == "approve" else "decline",
|
|
762
|
+
"status": result["status"], "owned": result["owned"],
|
|
763
|
+
"recommended_action": result["recommended_action"],
|
|
764
|
+
"decision_sent": result["status"] == "accepted",
|
|
765
|
+
"notify_parent": result["notification"],
|
|
766
|
+
}
|
|
767
|
+
_emit(public, args, plain=f"{operation} {result['status']} for Codex request "
|
|
768
|
+
f"{request_id}")
|
|
769
|
+
if result["status"] in {"submitted", "unknown"}:
|
|
770
|
+
raise SystemExit(EXIT_TURN_INCOMPLETE)
|
|
771
|
+
if result["status"] == "rejected":
|
|
772
|
+
raise SystemExit(EXIT_REJECTED)
|
|
773
|
+
return
|
|
774
|
+
client = CodexClient()
|
|
775
|
+
native = (client.approve if operation == "approve" else client.reject)(
|
|
776
|
+
args.target, request_id=request_id)
|
|
777
|
+
except CcxError as error:
|
|
778
|
+
_fail(str(error), error.exit_code)
|
|
779
|
+
result = native.as_dict()
|
|
780
|
+
result["decision_sent"] = True # Native gate response; no model completion.
|
|
781
|
+
_emit(result, args, plain=f"{operation} accepted for Codex request {request_id}")
|
|
782
|
+
|
|
783
|
+
def cmd_approve(args) -> None:
|
|
784
|
+
_decision(args, "approve")
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def cmd_reject(args) -> None:
|
|
788
|
+
_decision(args, "reject")
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def cmd_compact(args) -> None:
|
|
792
|
+
"""Compact the exact owned Codex thread through the shared operation core."""
|
|
793
|
+
from ccx.codex.ops import compact_worker
|
|
794
|
+
from ccx.constants import CCX_ROOT
|
|
795
|
+
from ccx.errors import (
|
|
796
|
+
CompactFailed, CompactIndeterminate,
|
|
797
|
+
)
|
|
798
|
+
from ccx.store.root import GenerationError, NativeEffectIndeterminate, require_ccx_active
|
|
799
|
+
from ccx.store.identity import EngineMismatch, IdentityError, UnknownWorker
|
|
800
|
+
|
|
801
|
+
operation_id = str(uuid.uuid4()) # Internal; Codex CLI has no ID flag.
|
|
802
|
+
if getattr(args, "notify_parent", False) or getattr(args, "settle", False):
|
|
803
|
+
from ccx.notify.registry import compact_notification_failure, create_registration, public_registration
|
|
804
|
+
notification = None
|
|
805
|
+
try:
|
|
806
|
+
if not getattr(args, "notify_parent", False):
|
|
807
|
+
raise InvalidRequest("--settle requires --notify-parent")
|
|
808
|
+
store = require_ccx_active(CCX_ROOT)
|
|
809
|
+
owner = store.resolve(args.target)
|
|
810
|
+
if owner.engine != "codex":
|
|
811
|
+
raise InvalidRequest("Codex compact notification needs a Codex worker")
|
|
812
|
+
record = store.record(owner.worker)
|
|
813
|
+
parent = require_notify_parent()
|
|
814
|
+
prepare_notify_parent(parent)
|
|
815
|
+
notification = create_registration(
|
|
816
|
+
parent=parent, worker_engine="codex", worker_id=owner.worker_id,
|
|
817
|
+
worker_name=record.get("name"),
|
|
818
|
+
operation={"kind": "codex-compact", "operationId": operation_id,
|
|
819
|
+
"nativeSourceId": (record.get("native") or {}).get("threadId")},
|
|
820
|
+
source="compact", settle=bool(getattr(args, "settle", False)),
|
|
821
|
+
)
|
|
822
|
+
# One native call after the durable parent event.
|
|
823
|
+
result = compact_worker(
|
|
824
|
+
owner.worker, operation_id=operation_id, wait=False,
|
|
825
|
+
timeout=args.timeout)
|
|
826
|
+
if result["status"] in {"prepared", "rejected", "not_found"}:
|
|
827
|
+
raise DeliveryRejected(
|
|
828
|
+
result.get("reason") or "Codex did not start compaction; check worker status",
|
|
829
|
+
refusal="native-rejected", operation_id=operation_id)
|
|
830
|
+
if result["status"] == "failed":
|
|
831
|
+
raise CompactFailed(result.get("reason") or
|
|
832
|
+
"Codex compaction failed; check worker status",
|
|
833
|
+
operation_id=operation_id)
|
|
834
|
+
if (result["status"] == "unknown"
|
|
835
|
+
or (result["status"] == "submitted"
|
|
836
|
+
and result.get("receipt_status") != "accepted")):
|
|
837
|
+
raise CompactIndeterminate(
|
|
838
|
+
"Codex compaction outcome is unknown; "
|
|
839
|
+
"keep the notification and operation ID to check later",
|
|
840
|
+
operation_id=operation_id, operation_status=result["status"])
|
|
841
|
+
except (CcxError, GenerationError, NativeEffectIndeterminate,
|
|
842
|
+
IdentityError, EngineMismatch, UnknownWorker, OSError) as error:
|
|
843
|
+
if notification is not None:
|
|
844
|
+
try:
|
|
845
|
+
uncertain, state = compact_notification_failure(notification, str(error))
|
|
846
|
+
except (ValueError, OSError) as failure:
|
|
847
|
+
uncertain, state = True, f"unreadable ({failure})"
|
|
848
|
+
code = EXIT_TURN_INCOMPLETE if uncertain else getattr(
|
|
849
|
+
error, "exit_code", EXIT_UNAVAILABLE)
|
|
850
|
+
_fail(f"{error}; operation_id={operation_id}; "
|
|
851
|
+
f"event_id={notification['eventId']}; notification_state={state}"
|
|
852
|
+
+ ("; effect uncertain, no resubmission" if uncertain
|
|
853
|
+
else "; no native compact was submitted"), code)
|
|
854
|
+
_fail(f"{error}; operation_id={operation_id}",
|
|
855
|
+
getattr(error, "exit_code", EXIT_UNAVAILABLE))
|
|
856
|
+
record = store.record(owner.worker)
|
|
857
|
+
status = result["status"]
|
|
858
|
+
proven = status == "succeeded" and result.get("compacted") is True
|
|
859
|
+
output = {
|
|
860
|
+
**_identity(record), "worker": owner.worker, "worker_id": owner.worker_id,
|
|
861
|
+
"engine": "codex", "thread_id": (record.get("native") or {}).get("threadId"),
|
|
862
|
+
"operation_id": operation_id, "operation_status": status,
|
|
863
|
+
"accepted": result.get("receipt_status") == "accepted" or proven,
|
|
864
|
+
"compacted": proven, "settled": False,
|
|
865
|
+
"compact_turn_id": result.get("compactTurnId"),
|
|
866
|
+
"context_item_id": result.get("contextItemId"),
|
|
867
|
+
"native_turn_status": result.get("nativeTurnStatus"),
|
|
868
|
+
"context_before": result.get("contextBefore"),
|
|
869
|
+
"context_after": result.get("contextAfter") if proven else None,
|
|
870
|
+
"notify_parent": public_registration(notification),
|
|
871
|
+
}
|
|
872
|
+
_emit(output, args, plain=(f"Codex compact {status} (parent not yet notified): "
|
|
873
|
+
f"{owner.worker}; operation_id={operation_id}; "
|
|
874
|
+
f"event_id={notification['eventId']}"))
|
|
875
|
+
return
|
|
876
|
+
try:
|
|
877
|
+
result = compact_worker(
|
|
878
|
+
args.target, operation_id=operation_id,
|
|
879
|
+
wait=not args.no_wait, timeout=args.timeout,
|
|
880
|
+
)
|
|
881
|
+
status = result["status"]
|
|
882
|
+
if status == "failed":
|
|
883
|
+
raise CompactFailed(
|
|
884
|
+
result.get("reason") or "Codex compaction failed; check worker status",
|
|
885
|
+
operation_id=operation_id,
|
|
886
|
+
)
|
|
887
|
+
if status == "rejected":
|
|
888
|
+
raise DeliveryRejected(
|
|
889
|
+
result.get("reason") or "Codex refused compaction; check worker status",
|
|
890
|
+
refusal="native-rejected", operation_id=operation_id,
|
|
891
|
+
)
|
|
892
|
+
if not args.no_wait and status != "succeeded":
|
|
893
|
+
raise CompactIndeterminate(
|
|
894
|
+
f"Codex compaction outcome is unknown; inspect its operation ({status})",
|
|
895
|
+
operation_id=operation_id, operation_status=status,
|
|
896
|
+
)
|
|
897
|
+
store = require_ccx_active(CCX_ROOT)
|
|
898
|
+
owner = store.resolve(args.target)
|
|
899
|
+
record = store.record(owner.worker)
|
|
900
|
+
identity = _identity(record)
|
|
901
|
+
native = record.get("native") or {}
|
|
902
|
+
proven = status == "succeeded" and result.get("compacted") is True
|
|
903
|
+
output = {
|
|
904
|
+
**identity, "worker": owner.worker, "worker_id": owner.worker_id,
|
|
905
|
+
"engine": "codex", "thread_id": native.get("threadId"),
|
|
906
|
+
"operation_id": operation_id, "operation_status": status,
|
|
907
|
+
"accepted": result.get("receipt_status") == "accepted" or proven,
|
|
908
|
+
"compacted": proven,
|
|
909
|
+
"context_before": result.get("contextBefore"),
|
|
910
|
+
"context_after": result.get("contextAfter") if proven else None,
|
|
911
|
+
"compact_turn_id": result.get("compactTurnId"),
|
|
912
|
+
"context_item_id": result.get("contextItemId"),
|
|
913
|
+
"native_turn_status": result.get("nativeTurnStatus"),
|
|
914
|
+
"reason": result.get("reason"),
|
|
915
|
+
}
|
|
916
|
+
except NativeEffectIndeterminate as error:
|
|
917
|
+
error = CompactIndeterminate(
|
|
918
|
+
f"Codex native compact admission unknown: {error}",
|
|
919
|
+
operation_id=operation_id, operation_status="unknown",
|
|
920
|
+
)
|
|
921
|
+
_fail(f"{error}; operation_id={operation_id}", error.exit_code)
|
|
922
|
+
except UnknownWorker as error:
|
|
923
|
+
_fail(f"{error}; operation_id={operation_id}", EXIT_NOT_FOUND)
|
|
924
|
+
except (IdentityError, EngineMismatch) as error:
|
|
925
|
+
_fail(f"{error}; operation_id={operation_id}", EXIT_ERROR)
|
|
926
|
+
except GenerationError as error:
|
|
927
|
+
_fail(f"Cannot compact Codex; inspect its status: {error}; "
|
|
928
|
+
f"operation_id={operation_id}", EXIT_UNAVAILABLE)
|
|
929
|
+
except CcxError as error:
|
|
930
|
+
_fail(f"{error}; operation_id={operation_id}", error.exit_code)
|
|
931
|
+
|
|
932
|
+
if output["compacted"]:
|
|
933
|
+
plain = f"Codex compact succeeded: {output['worker']}; operation_id={operation_id}"
|
|
934
|
+
else:
|
|
935
|
+
plain = (f"Codex compact {status} (not proven complete): "
|
|
936
|
+
f"{output['worker']}; operation_id={operation_id}")
|
|
937
|
+
_emit(output, args, plain=plain)
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
def _reset_time(value: Any) -> str:
|
|
941
|
+
from ccx.cli.commands._common import local_reset_time
|
|
942
|
+
return local_reset_time(value) if isinstance(value, (int, float)) else ""
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _quota_rows(data: dict[str, Any]) -> list[tuple[str, int, str]]:
|
|
946
|
+
rows: list[tuple[str, int, str]] = []
|
|
947
|
+
for limit_id, window in sorted(
|
|
948
|
+
rate_limit_windows(data),
|
|
949
|
+
key=lambda item: (item[0].casefold() != "codex", item[0].casefold()),
|
|
950
|
+
):
|
|
951
|
+
by_id = data.get("rateLimitsByLimitId") or {}
|
|
952
|
+
bucket = (by_id.get(limit_id) if isinstance(by_id, dict) and by_id
|
|
953
|
+
else data.get("rateLimits"))
|
|
954
|
+
bucket_name = (bucket.get("limitName") if isinstance(bucket, dict) else None) or limit_id
|
|
955
|
+
suffix = (
|
|
956
|
+
""
|
|
957
|
+
if not bucket_name or str(bucket_name).casefold() == "codex"
|
|
958
|
+
else f" {bucket_name}"
|
|
959
|
+
)
|
|
960
|
+
used = window.get("usedPercent")
|
|
961
|
+
if type(used) not in (int, float):
|
|
962
|
+
continue
|
|
963
|
+
rows.append((
|
|
964
|
+
window_label(window) + suffix,
|
|
965
|
+
int(used),
|
|
966
|
+
_reset_time(window.get("resetsAt")),
|
|
967
|
+
))
|
|
968
|
+
return rows
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def cmd_usage(args) -> None:
|
|
972
|
+
"""Print live Codex account quota utilization or token activity."""
|
|
973
|
+
tokens = getattr(args, "tokens", False)
|
|
974
|
+
try:
|
|
975
|
+
data = runtime.read_account_usage(tokens=tokens)
|
|
976
|
+
except CcxError as error:
|
|
977
|
+
if args.json and isinstance(error, EngineCliNotFound):
|
|
978
|
+
print(json.dumps({"ok": False, "code": "runtime_unavailable",
|
|
979
|
+
"message": str(error)}, sort_keys=True))
|
|
980
|
+
raise SystemExit(error.exit_code) from error
|
|
981
|
+
_fail(str(error), error.exit_code)
|
|
982
|
+
if args.json:
|
|
983
|
+
print(json.dumps(data, indent=2))
|
|
984
|
+
return
|
|
985
|
+
if tokens:
|
|
986
|
+
summary = data.get("summary") or {}
|
|
987
|
+
print("usage (tokens):")
|
|
988
|
+
fields = (
|
|
989
|
+
("lifetime", "lifetimeTokens"),
|
|
990
|
+
("peak/day", "peakDailyTokens"),
|
|
991
|
+
("streak", "currentStreakDays"),
|
|
992
|
+
)
|
|
993
|
+
emitted = False
|
|
994
|
+
for label, field in fields:
|
|
995
|
+
value = summary.get(field)
|
|
996
|
+
if isinstance(value, int):
|
|
997
|
+
print(f" {label:<10} {value:>12,}")
|
|
998
|
+
emitted = True
|
|
999
|
+
if not emitted:
|
|
1000
|
+
print(" unavailable")
|
|
1001
|
+
return
|
|
1002
|
+
print("usage (live):")
|
|
1003
|
+
rows = _quota_rows(data)
|
|
1004
|
+
if not rows:
|
|
1005
|
+
print(" unavailable")
|
|
1006
|
+
return
|
|
1007
|
+
for label, used, resets in rows:
|
|
1008
|
+
reset_text = f" resets {resets}" if resets else ""
|
|
1009
|
+
print(f" {label:<10} {used:>5.1f}%{reset_text}")
|