ccx-native 0.0.0-stage → 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 -2
- package/bin/ccx +56 -0
- 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 +40 -4
- 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
|
File without changes
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import pathlib
|
|
4
|
+
import re
|
|
5
|
+
import socket
|
|
6
|
+
import sys
|
|
7
|
+
from ccx.constants import (
|
|
8
|
+
BUDGET_EXIT,
|
|
9
|
+
EXIT_ERROR,
|
|
10
|
+
EXIT_API_TRANSIENT,
|
|
11
|
+
EXIT_RATE_LIMITED,
|
|
12
|
+
EXIT_TURN_INCOMPLETE,
|
|
13
|
+
EXIT_UNAVAILABLE,
|
|
14
|
+
EXIT_WAITING,
|
|
15
|
+
)
|
|
16
|
+
from ccx.errors import (
|
|
17
|
+
CcxError,
|
|
18
|
+
DeliveryRejected,
|
|
19
|
+
InvalidRequest,
|
|
20
|
+
SessionUnavailable,
|
|
21
|
+
)
|
|
22
|
+
from ccx.store.paths import sock_path
|
|
23
|
+
from ccx.store.records import read_record
|
|
24
|
+
from ccx.controllers.orchestrator import require_notify_parent
|
|
25
|
+
from ccx.notify.daemon import prepare_notify_parent
|
|
26
|
+
from ccx.notify.registry import public_registration as public_parent_notification
|
|
27
|
+
from ccx.rpc.rpc import (
|
|
28
|
+
_rpc,
|
|
29
|
+
)
|
|
30
|
+
from ccx.ops.semantics import (
|
|
31
|
+
KIND_ELICITATION,
|
|
32
|
+
payload_guidance as _payload_guidance, # noqa: F401
|
|
33
|
+
pending_input_kind,
|
|
34
|
+
question_answer_map as _pure_question_answer_map,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_stdin_prompt(op: str) -> str:
|
|
39
|
+
try:
|
|
40
|
+
return sys.stdin.read()
|
|
41
|
+
except OSError as e:
|
|
42
|
+
sys.stderr.write(f"[ccx] {op}: failed to read prompt from stdin: {e}\n")
|
|
43
|
+
sys.exit(EXIT_ERROR)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read_file_prompt(op: str, path: str) -> str:
|
|
47
|
+
if path == "-":
|
|
48
|
+
return _read_stdin_prompt(op)
|
|
49
|
+
try:
|
|
50
|
+
return pathlib.Path(path).read_text()
|
|
51
|
+
except OSError as e:
|
|
52
|
+
sys.stderr.write(f"[ccx] {op}: failed to read prompt file {path}: {e}\n")
|
|
53
|
+
sys.exit(EXIT_ERROR)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_prompt_source(args, attr: str, op: str, *, required: bool) -> str | None:
|
|
57
|
+
"""Resolve positional / --stdin / --file prompt input.
|
|
58
|
+
|
|
59
|
+
`new` allows no prompt source; `send` requires exactly one. Validation lives
|
|
60
|
+
here, not only in argparse, so programmatic command calls obey the same rules.
|
|
61
|
+
"""
|
|
62
|
+
positional = getattr(args, attr, None)
|
|
63
|
+
use_stdin = bool(getattr(args, "stdin", False))
|
|
64
|
+
file_path = getattr(args, "file", None)
|
|
65
|
+
sources = int(positional is not None) + int(use_stdin) + int(file_path is not None)
|
|
66
|
+
if sources > 1:
|
|
67
|
+
msg = f"[ccx] {op}: choose only one prompt source: positional text, --stdin, or --file"
|
|
68
|
+
# `ccx new mywork --stdin` is almost always a name/prompt mix-up: the
|
|
69
|
+
# positional is a *prompt*, names go through -n. Only hint when the
|
|
70
|
+
# positional is a single name-like token, so real prompt text doesn't
|
|
71
|
+
# get echoed back as a bogus suggestion.
|
|
72
|
+
if (op == "new" and positional is not None
|
|
73
|
+
and len(positional) <= 64
|
|
74
|
+
and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", positional)):
|
|
75
|
+
msg += (f" (the positional argument is the prompt, not a name — "
|
|
76
|
+
f"did you mean `-n {positional}`?)")
|
|
77
|
+
sys.stderr.write(msg + "\n")
|
|
78
|
+
sys.exit(EXIT_ERROR)
|
|
79
|
+
if sources == 0:
|
|
80
|
+
if required:
|
|
81
|
+
sys.stderr.write(f"[ccx] {op}: prompt required (pass text, --stdin, or --file)\n")
|
|
82
|
+
# A usage error, like argparse's own: bad input, never "not found".
|
|
83
|
+
sys.exit(EXIT_ERROR)
|
|
84
|
+
return None
|
|
85
|
+
if use_stdin:
|
|
86
|
+
return _read_stdin_prompt(op)
|
|
87
|
+
if file_path is not None:
|
|
88
|
+
return _read_file_prompt(op, file_path)
|
|
89
|
+
return positional
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _require_parent_notification():
|
|
93
|
+
try:
|
|
94
|
+
parent = require_notify_parent()
|
|
95
|
+
prepare_notify_parent(parent)
|
|
96
|
+
return parent
|
|
97
|
+
except CcxError as error:
|
|
98
|
+
sys.stderr.write(f"[ccx] notify: {error}\n")
|
|
99
|
+
raise SystemExit(error.exit_code) from None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _prepare_parent_notification(args):
|
|
103
|
+
if not getattr(args, "notify_parent", False):
|
|
104
|
+
return None
|
|
105
|
+
return _require_parent_notification()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _emit_parent_notification_armed(
|
|
109
|
+
short: str, registration: dict, *, json_mode: bool, base: dict | None = None
|
|
110
|
+
) -> None:
|
|
111
|
+
public = public_parent_notification(registration)
|
|
112
|
+
if json_mode:
|
|
113
|
+
value = dict(base or {})
|
|
114
|
+
value.setdefault("short", short)
|
|
115
|
+
value["notify_parent"] = public
|
|
116
|
+
print(json.dumps(value, indent=2))
|
|
117
|
+
return
|
|
118
|
+
if base is None:
|
|
119
|
+
print(short)
|
|
120
|
+
print(f"notify: armed {public['event_id']} -> Codex parent {public['parent']}")
|
|
121
|
+
print(f"notify: {public['instruction']}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _nbwait_exit_code(status: str | None, api_error: dict | None = None) -> int:
|
|
125
|
+
"""Canonical status-to-exit-code mapping for wait outcomes.
|
|
126
|
+
|
|
127
|
+
Original managed wait outcomes map to meaningful process exit codes.
|
|
128
|
+
A wait verdict describes observation, not whether native input was sent.
|
|
129
|
+
|
|
130
|
+
Documented contract:
|
|
131
|
+
blocked / waiting -> EXIT_WAITING (input gate still up; exit 4)
|
|
132
|
+
gone / dead / crashed -> EXIT_UNAVAILABLE (worker gone; managed adds
|
|
133
|
+
host-dead: live-looking record, host not alive)
|
|
134
|
+
budget-stopped -> BUDGET_EXIT (cap trip)
|
|
135
|
+
timeout / unknown -> EXIT_TURN_INCOMPLETE (clean-completion miss; exit 6)
|
|
136
|
+
|
|
137
|
+
`api_error` refines the terminal case into the three outcomes with different
|
|
138
|
+
remedies: rate-limited (wait for the reset), overloaded (retry in minutes,
|
|
139
|
+
costs no quota), and everything else (permanent — fix it or stop). Without
|
|
140
|
+
the split, a script waiting on a run cannot tell them apart.
|
|
141
|
+
"""
|
|
142
|
+
if status in ("blocked", "waiting"):
|
|
143
|
+
return EXIT_WAITING
|
|
144
|
+
if status in ("gone", "dead", "crashed", "idle-killed",
|
|
145
|
+
"host-dead", "unsupported"):
|
|
146
|
+
if isinstance(api_error, dict):
|
|
147
|
+
# Rate limiting wins over transience: a throttled 429 is marked
|
|
148
|
+
# both, and the rate limit is the one that must not be missed.
|
|
149
|
+
if api_error.get("rate_limited"):
|
|
150
|
+
return EXIT_RATE_LIMITED
|
|
151
|
+
if api_error.get("transient"):
|
|
152
|
+
return EXIT_API_TRANSIENT
|
|
153
|
+
return EXIT_UNAVAILABLE
|
|
154
|
+
if status == "budget-stopped":
|
|
155
|
+
return BUDGET_EXIT
|
|
156
|
+
return EXIT_TURN_INCOMPLETE
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _has_structured_answer_args(args) -> bool:
|
|
160
|
+
return bool(getattr(args, "option", None) or getattr(args, "text_answer", None))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _question_answer_map(question: dict | None, args) -> tuple[dict[str, str], str | None]:
|
|
164
|
+
'CLI-args adapter over the pure :func:`ccx.ops.semantics.question_answer_map`.'
|
|
165
|
+
return _pure_question_answer_map(
|
|
166
|
+
(question or {}).get("questions") or [],
|
|
167
|
+
text=getattr(args, "text", None),
|
|
168
|
+
options=getattr(args, "option", None),
|
|
169
|
+
text_answers=getattr(args, "text_answer", None),
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _render_question_wait_lines(short: str, waiting_for: dict, *, indent: bool = False) -> list[str]:
|
|
174
|
+
prefix = (lambda s: _indent(s)) if indent else (lambda s: s)
|
|
175
|
+
questions = ((waiting_for.get("input") or {}).get("questions") or [])
|
|
176
|
+
lines = [f"waiting for answer: AskUserQuestion"]
|
|
177
|
+
if waiting_for.get("id"):
|
|
178
|
+
lines.append(prefix(f"id: {waiting_for.get('id')}"))
|
|
179
|
+
for i, q in enumerate(questions, 1):
|
|
180
|
+
header = q.get("header") or f"Q{i}"
|
|
181
|
+
multi = " [multiSelect]" if q.get("multiSelect") else ""
|
|
182
|
+
lines.append(prefix(f"{i}. [{header}] {q.get('question')}{multi}"))
|
|
183
|
+
for opt in (q.get("options") or []):
|
|
184
|
+
label = opt.get("label")
|
|
185
|
+
desc = opt.get("description")
|
|
186
|
+
suffix = f" — {desc}" if desc else ""
|
|
187
|
+
lines.append(prefix(f" - {label}{suffix}"))
|
|
188
|
+
if len(questions) <= 1:
|
|
189
|
+
lines.append(prefix(f"answer: ccx answer {short} <option-or-text> --wait"))
|
|
190
|
+
else:
|
|
191
|
+
lines.append(prefix(f"answer: ccx answer {short} --option 1 <label> --text-answer 2 <text> --wait"))
|
|
192
|
+
lines.append(prefix(f"answer-json: ccx answer {short} '{{\"<question>\": \"<answer>\"}}' --wait"))
|
|
193
|
+
return lines
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _elicitation_field_line(name: str, spec: dict, required: bool) -> str:
|
|
197
|
+
kind = spec.get("type") or "string"
|
|
198
|
+
if kind == "array":
|
|
199
|
+
items = spec.get("items") if isinstance(spec.get("items"), dict) else {}
|
|
200
|
+
options = items.get("enum") or [o.get("const") for o in (items.get("anyOf") or [])
|
|
201
|
+
if isinstance(o, dict)]
|
|
202
|
+
kind = f"list of {options}" if options else "list"
|
|
203
|
+
else:
|
|
204
|
+
options = spec.get("enum") or [o.get("const") for o in (spec.get("oneOf") or [])
|
|
205
|
+
if isinstance(o, dict)]
|
|
206
|
+
if options:
|
|
207
|
+
kind = f"one of {options}"
|
|
208
|
+
title = spec.get("title") or spec.get("description")
|
|
209
|
+
extras = []
|
|
210
|
+
if "default" in spec:
|
|
211
|
+
extras.append(f"default {json.dumps(spec['default'], ensure_ascii=False)}")
|
|
212
|
+
if required:
|
|
213
|
+
extras.append("required")
|
|
214
|
+
tail = f" ({', '.join(extras)})" if extras else ""
|
|
215
|
+
label = f" — {title}" if title else ""
|
|
216
|
+
return f" - {name}: {kind}{tail}{label}"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _render_elicitation_wait_lines(short: str, waiting_for: dict, *, indent: bool = False) -> list[str]:
|
|
220
|
+
"""Human rendering of a pending MCP elicitation plus the exact verbs."""
|
|
221
|
+
prefix = (lambda s: _indent(s)) if indent else (lambda s: s)
|
|
222
|
+
server = waiting_for.get("server") or "?"
|
|
223
|
+
lines = [f"waiting for MCP input: {server} ({waiting_for.get('mode') or 'form'})"]
|
|
224
|
+
if waiting_for.get("id"):
|
|
225
|
+
lines.append(prefix(f"id: {waiting_for.get('id')}"))
|
|
226
|
+
if waiting_for.get("message"):
|
|
227
|
+
lines.append(prefix(_truncate(str(waiting_for.get("message")), 2000)))
|
|
228
|
+
if waiting_for.get("url"):
|
|
229
|
+
lines.append(prefix(f"url: {waiting_for.get('url')}"))
|
|
230
|
+
schema = waiting_for.get("schema") if isinstance(waiting_for.get("schema"), dict) else {}
|
|
231
|
+
props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
|
|
232
|
+
required = set(schema.get("required") or [])
|
|
233
|
+
if props:
|
|
234
|
+
lines.append(prefix("fields:"))
|
|
235
|
+
for name, spec in props.items():
|
|
236
|
+
lines.append(prefix(_elicitation_field_line(
|
|
237
|
+
name, spec if isinstance(spec, dict) else {}, name in required)))
|
|
238
|
+
if not waiting_for.get("id"):
|
|
239
|
+
lines.append(prefix(f"This input is only available in the worker terminal; open it with ccx attach {short}"))
|
|
240
|
+
return lines
|
|
241
|
+
if waiting_for.get("mode") == "url":
|
|
242
|
+
lines.append(prefix(f"accept: ccx respond {short} --accept --wait (after finishing the URL flow)"))
|
|
243
|
+
elif props:
|
|
244
|
+
example = " ".join(f"--field {name}=<value>" for name in list(props)[:2])
|
|
245
|
+
lines.append(prefix(f"accept: ccx respond {short} --accept {example} --wait"))
|
|
246
|
+
else:
|
|
247
|
+
lines.append(prefix(f"accept: ccx respond {short} --accept --wait"))
|
|
248
|
+
lines.append(prefix(f"decline: ccx respond {short} --decline --wait"))
|
|
249
|
+
lines.append(prefix(f"cancel: ccx respond {short} --cancel --wait"))
|
|
250
|
+
return lines
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _managed_question_json(short: str, waiting_for: dict) -> dict:
|
|
254
|
+
questions = ((waiting_for.get("input") or {}).get("questions") or [])
|
|
255
|
+
if len(questions) <= 1:
|
|
256
|
+
answer_command = f"ccx answer {short} <option-or-text> --wait"
|
|
257
|
+
else:
|
|
258
|
+
answer_command = f"ccx answer {short} --option 1 <label> --text-answer 2 <text> --wait"
|
|
259
|
+
return {
|
|
260
|
+
"source": "managed-hook",
|
|
261
|
+
"tool": "AskUserQuestion",
|
|
262
|
+
"request_id": waiting_for.get("id"),
|
|
263
|
+
"questions": questions,
|
|
264
|
+
"can_answer": True,
|
|
265
|
+
"answer_command": answer_command,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _uuid_input_guard(args, verb: str) -> None:
|
|
270
|
+
"""Reject unavailable notification handling before side effects."""
|
|
271
|
+
if getattr(args, "notify_parent", False):
|
|
272
|
+
raise InvalidRequest(
|
|
273
|
+
f"{verb}: --notify-parent requires the separate N4 native input "
|
|
274
|
+
"notification restoration"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _uuid_input_print(args, verb: str, request_id: str, result) -> None:
|
|
279
|
+
"""Render the actual original client result; fallback is a NEW message."""
|
|
280
|
+
fallback = result.delivery_id is not None
|
|
281
|
+
mode = "new-message-fallback" if fallback else "native-input"
|
|
282
|
+
if args.json:
|
|
283
|
+
print(json.dumps({
|
|
284
|
+
"ok": True, **result.as_dict(),
|
|
285
|
+
"request_id": request_id, "decision": verb,
|
|
286
|
+
"input_mode": mode, "original_gate_answered": not fallback,
|
|
287
|
+
}, ensure_ascii=False, indent=2))
|
|
288
|
+
return
|
|
289
|
+
if fallback:
|
|
290
|
+
print(
|
|
291
|
+
f"{verb}: original input host was dead; NEW user message delivered "
|
|
292
|
+
"(former gate NOT answered)"
|
|
293
|
+
)
|
|
294
|
+
print(f"Delivery: {result.delivery_id}")
|
|
295
|
+
elif result.settled:
|
|
296
|
+
print(f"{verb}: input accepted; turn {result.turn_ref} completed")
|
|
297
|
+
else:
|
|
298
|
+
print(f"{verb} accepted for Claude request {request_id}")
|
|
299
|
+
if result.reply is not None:
|
|
300
|
+
print(result.reply)
|
|
301
|
+
print(f"Worker: claude:{result.worker_id}")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _uuid_input_error(verb: str, error: CcxError) -> None:
|
|
305
|
+
message = str(error)
|
|
306
|
+
if message.startswith(f"{verb}: "):
|
|
307
|
+
message = message[len(verb) + 2:]
|
|
308
|
+
print(f"[ccx] {verb}: {message}", file=sys.stderr)
|
|
309
|
+
raise SystemExit(error.exit_code) from None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _uuid_input_id(args, waiting: dict | None, kind: str) -> str:
|
|
313
|
+
"""Bind one current original right-kind gate when a CLI ID was omitted."""
|
|
314
|
+
supplied = getattr(args, "request_id", None)
|
|
315
|
+
if supplied is not None:
|
|
316
|
+
return supplied
|
|
317
|
+
if pending_input_kind(waiting) is None:
|
|
318
|
+
raise DeliveryRejected(
|
|
319
|
+
f"No pending {kind} request; check ccx peek for open input."
|
|
320
|
+
)
|
|
321
|
+
if (not isinstance(waiting, dict)
|
|
322
|
+
or pending_input_kind(waiting) != kind
|
|
323
|
+
or not isinstance(waiting.get("id"), str)
|
|
324
|
+
or not waiting["id"]):
|
|
325
|
+
raise SessionUnavailable(
|
|
326
|
+
f"No pending {kind} request; check ccx peek for open input."
|
|
327
|
+
)
|
|
328
|
+
return waiting["id"]
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _read_waiting_for(short: str) -> dict | None:
|
|
332
|
+
"""Best-effort fetch of the live pending-permission preview: prefer the host
|
|
333
|
+
(authoritative, in-memory) and fall back to the on-disk record."""
|
|
334
|
+
if sock_path(short).exists():
|
|
335
|
+
try:
|
|
336
|
+
r = _rpc(short, {"op": "status"}, timeout=0.5)
|
|
337
|
+
if r.get("ok") and r.get("waitingFor"):
|
|
338
|
+
return r.get("waitingFor")
|
|
339
|
+
except (OSError, ValueError, socket.timeout):
|
|
340
|
+
pass
|
|
341
|
+
return (read_record(short) or {}).get("waitingFor")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def emit_waiting(short: str, header: str | None = None) -> None:
|
|
345
|
+
"""Print an actionable description of the pending permission request to
|
|
346
|
+
stdout, so a caller whose `send`/`new --wait` exited on WAITING knows what is
|
|
347
|
+
blocked and exactly how to clear it. Human-readable in all modes — these are
|
|
348
|
+
blocking-exit paths an operator or orchestrating agent reads directly, and the
|
|
349
|
+
`Short:` line keeps it greppable like the other non-zero exits."""
|
|
350
|
+
from ccx.client import CcxClient
|
|
351
|
+
waiting = CcxClient().peek(short).waiting_for
|
|
352
|
+
wf = waiting.as_dict() if waiting is not None else None
|
|
353
|
+
request_id = (wf or {}).get("id")
|
|
354
|
+
tool = (wf or {}).get("tool") or "?"
|
|
355
|
+
if not request_id:
|
|
356
|
+
print(header or f"Worker: claude:{short}")
|
|
357
|
+
if (wf or {}).get("message"):
|
|
358
|
+
print(_indent(_truncate(str(wf["message"]), 2000)))
|
|
359
|
+
print("This input cannot be answered here; use ccx attach in the worker terminal")
|
|
360
|
+
return
|
|
361
|
+
if tool == "AskUserQuestion":
|
|
362
|
+
print(header or f"Short: {short}")
|
|
363
|
+
for line in _render_question_wait_lines(short, wf, indent=True):
|
|
364
|
+
print(line.replace(" --wait", f" --request-id {request_id}"))
|
|
365
|
+
return
|
|
366
|
+
if pending_input_kind(wf) == KIND_ELICITATION:
|
|
367
|
+
print(header or f"Short: {short}")
|
|
368
|
+
for line in _render_elicitation_wait_lines(short, wf, indent=True):
|
|
369
|
+
print(line.replace(" --wait", f" --request-id {request_id}"))
|
|
370
|
+
return
|
|
371
|
+
print(header or f"Short: {short}")
|
|
372
|
+
print(f"waiting for approval: {tool} request_id={request_id}")
|
|
373
|
+
inp = (wf or {}).get("input")
|
|
374
|
+
if inp is not None:
|
|
375
|
+
try:
|
|
376
|
+
rendered = json.dumps(inp, ensure_ascii=False, indent=2)
|
|
377
|
+
except TypeError:
|
|
378
|
+
rendered = str(inp)
|
|
379
|
+
print(_indent(_truncate(rendered, 2000)))
|
|
380
|
+
print(f" approve: ccx approve {short} --request-id {request_id}")
|
|
381
|
+
print(f" reject: ccx reject {short} --request-id {request_id} --reason \"...\"")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _truncate(s, n):
|
|
385
|
+
s = "" if s is None else str(s)
|
|
386
|
+
return s if len(s) <= n else s[:n] + "…"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _indent(s, prefix=" "):
|
|
390
|
+
return "\n".join(prefix + line for line in s.splitlines())
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def local_reset_time(value) -> str:
|
|
394
|
+
"""A provider reset time (epoch seconds or ISO text) in local time with its zone."""
|
|
395
|
+
from datetime import datetime, timezone
|
|
396
|
+
try:
|
|
397
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
398
|
+
moment = datetime.fromtimestamp(value, timezone.utc)
|
|
399
|
+
elif isinstance(value, str) and value:
|
|
400
|
+
moment = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
401
|
+
if moment.tzinfo is None:
|
|
402
|
+
moment = moment.replace(tzinfo=timezone.utc)
|
|
403
|
+
else:
|
|
404
|
+
return ""
|
|
405
|
+
except (ValueError, OverflowError, OSError):
|
|
406
|
+
return str(value)[:16].replace("T", " ")
|
|
407
|
+
return moment.astimezone().strftime("%Y-%m-%d %H:%M %Z")
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
from ccx.constants import (
|
|
5
|
+
EXIT_UNAVAILABLE,
|
|
6
|
+
)
|
|
7
|
+
from ccx.errors import (
|
|
8
|
+
CcxError,
|
|
9
|
+
InvalidRequest,
|
|
10
|
+
SessionUnavailable,
|
|
11
|
+
)
|
|
12
|
+
from ccx.ops.semantics import (
|
|
13
|
+
payload_guidance as _payload_guidance, # noqa: F401
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def cmd_kill(args):
|
|
18
|
+
"""Confirm only the exact full-UUID Claude runtime exits."""
|
|
19
|
+
from ccx.ops.wait import park_worker
|
|
20
|
+
try:
|
|
21
|
+
result = park_worker(args.target, timeout=getattr(args, "timeout", 90))
|
|
22
|
+
except CcxError as error:
|
|
23
|
+
sys.stderr.write(f"[ccx] kill: {error}\n")
|
|
24
|
+
raise SystemExit(error.exit_code) from error
|
|
25
|
+
if getattr(args, "json", False):
|
|
26
|
+
print(json.dumps(result, indent=2))
|
|
27
|
+
else:
|
|
28
|
+
print(result["worker"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_stop(args):
|
|
32
|
+
"""Request one exact full-UUID Claude turn interrupt, not a runtime kill."""
|
|
33
|
+
from ccx.ops.cancel import stop_worker
|
|
34
|
+
from ccx.errors import CcxError, DeliveryRejected
|
|
35
|
+
try:
|
|
36
|
+
result = stop_worker(args.target)
|
|
37
|
+
except CcxError as error:
|
|
38
|
+
if getattr(args, "json", False):
|
|
39
|
+
print(json.dumps({
|
|
40
|
+
"ok": False, "error": str(error),
|
|
41
|
+
"status": getattr(error, "turn_status", "rejected"
|
|
42
|
+
if isinstance(error, DeliveryRejected) else "unavailable"),
|
|
43
|
+
"cancellation_id": getattr(error, "cancellation_id", None),
|
|
44
|
+
"turn_ref": getattr(error, "turn_ref", None),
|
|
45
|
+
}))
|
|
46
|
+
print(f"ccx stop: {error}", file=sys.stderr)
|
|
47
|
+
raise SystemExit(error.exit_code) from error
|
|
48
|
+
value = {
|
|
49
|
+
**result, "ok": True, "short": result["worker"]["short"],
|
|
50
|
+
"requested": True,
|
|
51
|
+
"cancelled": result["confirmed"] and result["outcome"] == "interrupted",
|
|
52
|
+
}
|
|
53
|
+
print(json.dumps(value) if getattr(args, "json", False)
|
|
54
|
+
else (f"{result['worker']['worker']}: stop confirmed; turn outcome unknown until Claude settles"
|
|
55
|
+
if result["confirmed"] and result["outcome"] == "unknown"
|
|
56
|
+
else f"{result['worker']['worker']}: stop {result['status']}; turn outcome {result['outcome']}"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cmd_attach(args):
|
|
60
|
+
"""Attach the user's terminal to the original native worker."""
|
|
61
|
+
from ccx.store.root import require_ccx_active
|
|
62
|
+
from ccx.constants import CCX_ROOT
|
|
63
|
+
from ccx.client import CcxClient
|
|
64
|
+
from ccx.claude.attach import attach as run_attach
|
|
65
|
+
from ccx.ops.send import ensure_ready
|
|
66
|
+
from ccx.store.root import GenerationError
|
|
67
|
+
from ccx.store.liveness import owned_pid_alive
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
owner = require_ccx_active(CCX_ROOT).resolve(args.target)
|
|
71
|
+
if owner.engine == "codex":
|
|
72
|
+
from ccx.cli.codex import cmd_attach as codex_attach
|
|
73
|
+
return codex_attach(args)
|
|
74
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
75
|
+
raise InvalidRequest("attach requires terminal stdin and stdout")
|
|
76
|
+
client = CcxClient()
|
|
77
|
+
record = client._resolve_managed(args.target, "attach")
|
|
78
|
+
if record.get("engine") != "claude":
|
|
79
|
+
raise InvalidRequest("shared attach supports managed Claude workers only")
|
|
80
|
+
worker_id = record["workerId"]
|
|
81
|
+
handle = f"claude:{worker_id}"
|
|
82
|
+
native = record.get("native") or {}
|
|
83
|
+
if not native.get("sessionId") or not isinstance(native.get("sessionId"), str):
|
|
84
|
+
raise SessionUnavailable("Claude session is unavailable; check worker status")
|
|
85
|
+
client._uuid_store.authorize_native_action(handle, "resume")
|
|
86
|
+
|
|
87
|
+
# The normal ready helper intentionally refuses BUSY/WAITING deliveries.
|
|
88
|
+
# A live, exact, attested host is already ready for attach regardless of
|
|
89
|
+
# its turn state. Only stopped/unavailable hosts require normal revival.
|
|
90
|
+
from ccx.rpc.rpc import _rpc
|
|
91
|
+
status = _rpc(handle, {"op": "status"}, timeout=1.0)
|
|
92
|
+
startup = _rpc(handle, {"op": "startup-status"}, timeout=1.0)
|
|
93
|
+
current = client._uuid_store.record(handle)
|
|
94
|
+
life = current.get("lifecycle") or {}
|
|
95
|
+
proof = life.get("nativeStartProof") or {}
|
|
96
|
+
live = (
|
|
97
|
+
status.get("ok") is True
|
|
98
|
+
and status.get("state") in ("idle", "busy", "waiting")
|
|
99
|
+
and startup.get("ok") is True
|
|
100
|
+
and startup.get("workerId") == worker_id
|
|
101
|
+
and startup.get("generation") == client._uuid_store.generation
|
|
102
|
+
and startup.get("nativeStartProof") == proof
|
|
103
|
+
and proof.get("sessionId") == native["sessionId"]
|
|
104
|
+
and proof.get("hostPid") == life.get("hostPid")
|
|
105
|
+
and str(proof.get("hostStart")) == str(life.get("hostStart"))
|
|
106
|
+
and proof.get("workerPid") == life.get("workerPid")
|
|
107
|
+
and str(proof.get("workerStart")) == str(life.get("workerStart"))
|
|
108
|
+
and owned_pid_alive(life.get("hostPid"), life.get("hostStart"))
|
|
109
|
+
and owned_pid_alive(life.get("workerPid"), life.get("workerStart"))
|
|
110
|
+
)
|
|
111
|
+
if not live:
|
|
112
|
+
ensure_ready(handle, timeout=90,
|
|
113
|
+
expected_generation=client._uuid_store.generation)
|
|
114
|
+
reason = run_attach(handle, status=not getattr(args, "no_status", False))
|
|
115
|
+
if reason == "connection-ended":
|
|
116
|
+
raise SessionUnavailable("managed host connection lost; worker may have exited")
|
|
117
|
+
if reason != "detached":
|
|
118
|
+
print(f"[ccx] attach ended: {reason}", file=sys.stderr)
|
|
119
|
+
except CcxError as error:
|
|
120
|
+
print(f"[ccx] attach: {error}", file=sys.stderr)
|
|
121
|
+
raise SystemExit(error.exit_code) from error
|
|
122
|
+
except (GenerationError, OSError, ValueError, RuntimeError) as error:
|
|
123
|
+
print(f"[ccx] attach: {error}", file=sys.stderr)
|
|
124
|
+
raise SystemExit(EXIT_UNAVAILABLE) from error
|
|
125
|
+
|
|
126
|
+
from ccx.ops.maintenance import shutdown
|
|
127
|
+
from ccx.constants import EXIT_UNAVAILABLE
|
|
128
|
+
|
|
129
|
+
def cmd_shutdown(args) -> None:
|
|
130
|
+
result = shutdown(dry_run=args.dry_run, timeout=args.timeout,
|
|
131
|
+
engine=getattr(args, "engine", None))
|
|
132
|
+
if args.json:
|
|
133
|
+
print(json.dumps(result, indent=2))
|
|
134
|
+
else:
|
|
135
|
+
for engine in ("claude", "codex"):
|
|
136
|
+
for row in result[engine]:
|
|
137
|
+
label = f" ({row['name']})" if row.get("name") else ""
|
|
138
|
+
print(f"{row['worker']}{label}: {row['action']}")
|
|
139
|
+
print(f"codexd: {result['codexd']}")
|
|
140
|
+
print(f"notifyd: {result['notifyd']}")
|
|
141
|
+
for error in result["errors"]:
|
|
142
|
+
sys.stderr.write(f"[ccx] shutdown: {error.get('worker', error['short'])}: {error['error']}\n")
|
|
143
|
+
if not result["ok"]:
|
|
144
|
+
sys.exit(EXIT_UNAVAILABLE)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_untrust(args) -> None:
|
|
148
|
+
from ccx.constants import CLAUDE_DOTFILE
|
|
149
|
+
from ccx.claude.settings import untrust_paths
|
|
150
|
+
|
|
151
|
+
rows = untrust_paths(args.paths or None, dry_run=args.dry_run)
|
|
152
|
+
if args.json:
|
|
153
|
+
print(json.dumps({"config": str(CLAUDE_DOTFILE), "dry_run": args.dry_run,
|
|
154
|
+
"paths": rows}, indent=2))
|
|
155
|
+
return
|
|
156
|
+
if not rows:
|
|
157
|
+
print(f"no trust entries recorded for {CLAUDE_DOTFILE}")
|
|
158
|
+
return
|
|
159
|
+
suffix = " (dry run)" if args.dry_run else ""
|
|
160
|
+
for row in rows:
|
|
161
|
+
print(f"{row['path']}: {row['action']}{suffix}")
|