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
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
"""Pure, importable CCX semantic helpers — single-sourced client semantics.
|
|
2
|
+
|
|
3
|
+
This module holds four pure semantic domains shared by the real Python client,
|
|
4
|
+
CLI, worker facade and host:
|
|
5
|
+
|
|
6
|
+
1. **Pending-input kind** — classifying a raw ``waitingFor`` payload as an
|
|
7
|
+
AskUserQuestion, an MCP elicitation, or a permission prompt
|
|
8
|
+
(:func:`pending_input_kind`).
|
|
9
|
+
2. **Answer legality / mapping** — whether ``answer()`` is legal against the
|
|
10
|
+
current pending input (:func:`answer_illegality`), the request-identity
|
|
11
|
+
fence (:func:`answer_request_fence`), and the mapping of caller-supplied
|
|
12
|
+
answers onto pending questions (:func:`question_answer_map`,
|
|
13
|
+
:func:`question_answer_message`, :func:`parse_question_index`), and the
|
|
14
|
+
MCP elicitation equivalents (:func:`elicitation_illegality`,
|
|
15
|
+
:func:`elicitation_response_problem`, :func:`elicitation_field_content`).
|
|
16
|
+
3. **Event filtering** — the epoch-aware ``(epoch, seq)`` cursor semantics for
|
|
17
|
+
the persisted event stream (:func:`filter_events_after`), including the
|
|
18
|
+
documented unknown-epoch full replay.
|
|
19
|
+
4. **Optional payload bounds** — the managed paste-channel byte bound and its guidance
|
|
20
|
+
message (:func:`send_payload_violation`, :func:`payload_guidance`).
|
|
21
|
+
|
|
22
|
+
Everything here is a pure function of its inputs (plus the process-level
|
|
23
|
+
``SEND_TEXT_MAX_BYTES`` setting). No I/O, no sockets, no records, no clock.
|
|
24
|
+
The real client, CLI and host import from here. Tests assert their common
|
|
25
|
+
semantic behavior without duplicating the rules.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
from typing import TYPE_CHECKING
|
|
32
|
+
|
|
33
|
+
from ccx import constants as _constants
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
36
|
+
from ccx.types import Event
|
|
37
|
+
|
|
38
|
+
# The tool name whose pending input is a structured question (answered with
|
|
39
|
+
# `answer`); every other pending tool is a permission prompt (approve/reject).
|
|
40
|
+
QUESTION_TOOL = "AskUserQuestion"
|
|
41
|
+
|
|
42
|
+
KIND_QUESTION = "question"
|
|
43
|
+
KIND_PERMISSION = "permission"
|
|
44
|
+
# An MCP server's `elicitation/create` form or URL request, brokered through
|
|
45
|
+
# Claude's `Elicitation` hook. Resolved with accept/decline/cancel (`respond`),
|
|
46
|
+
# never with `answer` or `approve`/`reject`.
|
|
47
|
+
KIND_ELICITATION = "elicitation"
|
|
48
|
+
ELICITATION_ACTIONS = ("accept", "decline", "cancel")
|
|
49
|
+
|
|
50
|
+
# Host wording for a request-identity fence violation; the host-side
|
|
51
|
+
# `answer-permission` RPC rejects with exactly this reason, so a client-side
|
|
52
|
+
# fence must use the same vocabulary (one condition for consumers).
|
|
53
|
+
REQUEST_ID_MISMATCH = "request id mismatch"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- 1. pending-input kind ---------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def pending_input_kind(waiting_for: dict | None) -> str | None:
|
|
59
|
+
"""Classify a raw host ``waitingFor`` preview.
|
|
60
|
+
|
|
61
|
+
Returns ``"question"`` for an AskUserQuestion, ``"elicitation"`` for an
|
|
62
|
+
MCP elicitation, ``"permission"`` for any other pending tool approval, and
|
|
63
|
+
``None`` when there is no pending input. This is the single source for the
|
|
64
|
+
kind distinction used by :class:`ccx.types.PendingInput`,
|
|
65
|
+
``CcxClient.answer``/``approve``/``reject``/``respond_elicitation`` guards,
|
|
66
|
+
and the scripted client.
|
|
67
|
+
"""
|
|
68
|
+
if not waiting_for or not isinstance(waiting_for, dict):
|
|
69
|
+
return None
|
|
70
|
+
if waiting_for.get("type") == KIND_ELICITATION:
|
|
71
|
+
return KIND_ELICITATION
|
|
72
|
+
return KIND_QUESTION if waiting_for.get("tool") == QUESTION_TOOL else KIND_PERMISSION
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def wrong_verb_hint(kind: str | None) -> str:
|
|
76
|
+
"""The verb that resolves a pending input of ``kind`` (for error text)."""
|
|
77
|
+
if kind == KIND_QUESTION:
|
|
78
|
+
return "answer"
|
|
79
|
+
if kind == KIND_ELICITATION:
|
|
80
|
+
return "respond"
|
|
81
|
+
return "approve/reject"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- 2. answer legality / mapping ---------------------------------------------
|
|
85
|
+
|
|
86
|
+
def answer_illegality(waiting_for: dict | None, short: str) -> str | None:
|
|
87
|
+
"""Why answering a *question* is illegal right now, or ``None`` if legal.
|
|
88
|
+
|
|
89
|
+
Mirrors the real answer-verb legality matrix: no pending input at all, or
|
|
90
|
+
pending input of the permission kind (which must be resolved with
|
|
91
|
+
approve/reject, never `answer`). Host liveness (live answer vs
|
|
92
|
+
``fallback_to_send`` revive/degrade) is deliberately not part of this
|
|
93
|
+
check — it is transport state, not answer legality, and both clients
|
|
94
|
+
handle it after this gate.
|
|
95
|
+
"""
|
|
96
|
+
kind = pending_input_kind(waiting_for)
|
|
97
|
+
if kind is None:
|
|
98
|
+
return f"no pending question found for {short}"
|
|
99
|
+
if kind == KIND_ELICITATION:
|
|
100
|
+
return (
|
|
101
|
+
f"session {short} is waiting on an MCP elicitation from "
|
|
102
|
+
f"{(waiting_for or {}).get('server') or '?'}; use respond instead"
|
|
103
|
+
)
|
|
104
|
+
if kind != KIND_QUESTION:
|
|
105
|
+
return (
|
|
106
|
+
f"session {short} is waiting on a permission prompt for "
|
|
107
|
+
f"{(waiting_for or {}).get('tool') or '?'}; use approve/reject instead"
|
|
108
|
+
)
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def answer_request_fence(
|
|
113
|
+
waiting_for: dict | None, expected_request_id: str | None
|
|
114
|
+
) -> str | None:
|
|
115
|
+
"""Request-identity fence for ``answer(expected_request_id=...)``.
|
|
116
|
+
|
|
117
|
+
Returns ``None`` when no fence was requested or the pending request's id
|
|
118
|
+
matches; otherwise the canonical :data:`REQUEST_ID_MISMATCH` reason. This
|
|
119
|
+
closes the peek→answer TOCTOU window: a question that changed between
|
|
120
|
+
inspection and answer is rejected instead of silently answering the new
|
|
121
|
+
question with a stale decision.
|
|
122
|
+
"""
|
|
123
|
+
if expected_request_id is None:
|
|
124
|
+
return None
|
|
125
|
+
if (waiting_for or {}).get("id") != expected_request_id:
|
|
126
|
+
return REQUEST_ID_MISMATCH
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_question_index(raw, count: int) -> tuple[int, str | None]:
|
|
131
|
+
"""Parse a 1-based question index into a 0-based one, or an error."""
|
|
132
|
+
try:
|
|
133
|
+
idx = int(raw)
|
|
134
|
+
except (TypeError, ValueError):
|
|
135
|
+
return -1, f"question index must be an integer, got {raw!r}"
|
|
136
|
+
if idx < 1 or idx > count:
|
|
137
|
+
return -1, f"question index {idx} is out of range; expected 1..{count}"
|
|
138
|
+
return idx - 1, None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def question_answer_map(
|
|
142
|
+
questions: list[dict],
|
|
143
|
+
*,
|
|
144
|
+
text: str | None = None,
|
|
145
|
+
options: list[tuple] | None = None,
|
|
146
|
+
text_answers: list[tuple] | None = None,
|
|
147
|
+
) -> tuple[dict[str, str], str | None]:
|
|
148
|
+
"""Map caller-supplied answers onto pending questions.
|
|
149
|
+
|
|
150
|
+
Pure core of the ``answer`` verb shared by the CLI, the Python client, and
|
|
151
|
+
the scripted client. ``questions`` is the pending AskUserQuestion
|
|
152
|
+
``input.questions`` list. Exactly one of two shapes is legal:
|
|
153
|
+
|
|
154
|
+
* structured: ``options=[(1-based index, label), ...]`` and/or
|
|
155
|
+
``text_answers=[(index, free text), ...]`` covering every question;
|
|
156
|
+
* plain text: a single answer string for one question, or a JSON object
|
|
157
|
+
mapping exact question text to answer text for several.
|
|
158
|
+
|
|
159
|
+
Returns ``(answers, None)`` on success — mapping exact question text to
|
|
160
|
+
answer text — or ``({}, error)`` describing the violation. An empty plain
|
|
161
|
+
``text`` yields ``({}, None)``: "no answer given" is the caller's error to
|
|
162
|
+
raise, matching the historical split between mapping and emptiness.
|
|
163
|
+
"""
|
|
164
|
+
questions = list(questions or [])
|
|
165
|
+
if options or text_answers:
|
|
166
|
+
if text:
|
|
167
|
+
return {}, "do not combine positional/stdin/file answer text with --option/--text-answer"
|
|
168
|
+
answers_by_index: dict[int, list[str]] = {}
|
|
169
|
+
for raw_idx, value in (options or []):
|
|
170
|
+
idx, err = parse_question_index(raw_idx, len(questions))
|
|
171
|
+
if err:
|
|
172
|
+
return {}, err
|
|
173
|
+
answers_by_index.setdefault(idx, []).append(value)
|
|
174
|
+
for raw_idx, value in (text_answers or []):
|
|
175
|
+
idx, err = parse_question_index(raw_idx, len(questions))
|
|
176
|
+
if err:
|
|
177
|
+
return {}, err
|
|
178
|
+
if idx in answers_by_index:
|
|
179
|
+
return {}, f"question {idx + 1} has both --option and --text-answer"
|
|
180
|
+
answers_by_index[idx] = [value]
|
|
181
|
+
missing = [
|
|
182
|
+
str(i + 1) for i, q in enumerate(questions)
|
|
183
|
+
if q.get("question") and i not in answers_by_index
|
|
184
|
+
]
|
|
185
|
+
if missing:
|
|
186
|
+
return {}, "missing answer for question(s): " + ", ".join(missing)
|
|
187
|
+
out: dict[str, str] = {}
|
|
188
|
+
for i, vals in answers_by_index.items():
|
|
189
|
+
qtext = questions[i].get("question")
|
|
190
|
+
if qtext:
|
|
191
|
+
out[str(qtext)] = ", ".join(v.strip() for v in vals if v.strip())
|
|
192
|
+
return out, None
|
|
193
|
+
|
|
194
|
+
answer = (text or "").strip()
|
|
195
|
+
if not answer:
|
|
196
|
+
return {}, None
|
|
197
|
+
if len(questions) == 1:
|
|
198
|
+
qtext = questions[0].get("question")
|
|
199
|
+
return ({str(qtext): answer} if qtext else {}), None
|
|
200
|
+
try:
|
|
201
|
+
parsed = json.loads(answer)
|
|
202
|
+
except ValueError:
|
|
203
|
+
return {}, (
|
|
204
|
+
"multiple pending questions require --option/--text-answer flags or a "
|
|
205
|
+
"JSON object mapping exact question text to answer text"
|
|
206
|
+
)
|
|
207
|
+
if not isinstance(parsed, dict) or not all(
|
|
208
|
+
isinstance(k, str) and isinstance(v, str) for k, v in parsed.items()
|
|
209
|
+
):
|
|
210
|
+
return {}, "answer JSON must be an object with string question keys and string answers"
|
|
211
|
+
expected = {str(q.get("question")) for q in questions if q.get("question")}
|
|
212
|
+
missing = sorted(expected - set(parsed))
|
|
213
|
+
if missing:
|
|
214
|
+
return {}, "answer JSON is missing question(s): " + "; ".join(missing)
|
|
215
|
+
return parsed, None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def question_answer_message(answers: dict[str, str]) -> str:
|
|
219
|
+
"""Plain user-turn fallback for a dead AskUserQuestion host.
|
|
220
|
+
|
|
221
|
+
Live AskUserQuestion answers go through the host's in-memory permission
|
|
222
|
+
broker. If that host died, the original tool call is gone, but the
|
|
223
|
+
persisted question preview still lets us carry the user's answer forward
|
|
224
|
+
as a normal resumed message.
|
|
225
|
+
"""
|
|
226
|
+
lines = ["User provided answer to the previous question:"]
|
|
227
|
+
for question, answer in answers.items():
|
|
228
|
+
lines.append(f"Question: {question}")
|
|
229
|
+
lines.append(f"Answer: {answer}")
|
|
230
|
+
return "\n".join(lines)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def elicitation_illegality(waiting_for: dict | None, short: str) -> str | None:
|
|
234
|
+
"""Why responding to an *elicitation* is illegal right now, or ``None``.
|
|
235
|
+
|
|
236
|
+
The elicitation counterpart of :func:`answer_illegality`: no pending input,
|
|
237
|
+
a pending input of another kind, or an elicitation CCX observed only from
|
|
238
|
+
Claude's notification (no brokered hook request to release, so it can only
|
|
239
|
+
be resolved in the attached terminal).
|
|
240
|
+
"""
|
|
241
|
+
kind = pending_input_kind(waiting_for)
|
|
242
|
+
if kind is None:
|
|
243
|
+
return f"no pending elicitation found for {short}"
|
|
244
|
+
if kind != KIND_ELICITATION:
|
|
245
|
+
return (
|
|
246
|
+
f"session {short} is waiting on a {kind}; use "
|
|
247
|
+
f"{wrong_verb_hint(kind)} instead"
|
|
248
|
+
)
|
|
249
|
+
if not (waiting_for or {}).get("id"):
|
|
250
|
+
return (
|
|
251
|
+
f"session {short} shows an MCP elicitation dialog CCX did not broker; "
|
|
252
|
+
f"resolve it in the terminal (ccx attach {short})"
|
|
253
|
+
)
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _schema_properties(schema: dict | None) -> dict:
|
|
258
|
+
props = (schema or {}).get("properties") if isinstance(schema, dict) else None
|
|
259
|
+
return props if isinstance(props, dict) else {}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _enum_values(spec: dict) -> list | None:
|
|
263
|
+
"""Allowed values of an MCP enum schema (legacy ``enum`` or titled
|
|
264
|
+
``oneOf``/``anyOf`` ``const`` options), or ``None`` when unconstrained."""
|
|
265
|
+
if isinstance(spec.get("enum"), list):
|
|
266
|
+
return list(spec["enum"])
|
|
267
|
+
for key in ("oneOf", "anyOf"):
|
|
268
|
+
options = spec.get(key)
|
|
269
|
+
if isinstance(options, list):
|
|
270
|
+
return [o.get("const") for o in options if isinstance(o, dict) and "const" in o]
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _field_problem(name: str, spec: dict, value) -> str | None:
|
|
275
|
+
kind = spec.get("type")
|
|
276
|
+
if kind == "boolean":
|
|
277
|
+
return None if isinstance(value, bool) else f"field {name!r} must be a boolean"
|
|
278
|
+
if kind in ("number", "integer"):
|
|
279
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
280
|
+
return f"field {name!r} must be a {kind}"
|
|
281
|
+
if kind == "integer" and not float(value).is_integer():
|
|
282
|
+
return f"field {name!r} must be an integer"
|
|
283
|
+
if isinstance(spec.get("minimum"), (int, float)) and value < spec["minimum"]:
|
|
284
|
+
return f"field {name!r} must be >= {spec['minimum']}"
|
|
285
|
+
if isinstance(spec.get("maximum"), (int, float)) and value > spec["maximum"]:
|
|
286
|
+
return f"field {name!r} must be <= {spec['maximum']}"
|
|
287
|
+
return None
|
|
288
|
+
if kind == "array":
|
|
289
|
+
if not isinstance(value, list):
|
|
290
|
+
return f"field {name!r} must be a list"
|
|
291
|
+
items = spec.get("items") if isinstance(spec.get("items"), dict) else {}
|
|
292
|
+
allowed = _enum_values(items)
|
|
293
|
+
bad = [v for v in value if allowed is not None and v not in allowed]
|
|
294
|
+
if bad:
|
|
295
|
+
return f"field {name!r} has value(s) outside {allowed}: {bad}"
|
|
296
|
+
if isinstance(spec.get("minItems"), int) and len(value) < spec["minItems"]:
|
|
297
|
+
return f"field {name!r} needs at least {spec['minItems']} item(s)"
|
|
298
|
+
if isinstance(spec.get("maxItems"), int) and len(value) > spec["maxItems"]:
|
|
299
|
+
return f"field {name!r} allows at most {spec['maxItems']} item(s)"
|
|
300
|
+
return None
|
|
301
|
+
# string (plain or enum). An unknown/absent type is treated as a string so
|
|
302
|
+
# a newer schema keyword never makes a well-formed answer unsendable.
|
|
303
|
+
if not isinstance(value, str):
|
|
304
|
+
return f"field {name!r} must be a string"
|
|
305
|
+
allowed = _enum_values(spec)
|
|
306
|
+
if allowed is not None and value not in allowed:
|
|
307
|
+
return f"field {name!r} must be one of {allowed}"
|
|
308
|
+
if isinstance(spec.get("minLength"), int) and len(value) < spec["minLength"]:
|
|
309
|
+
return f"field {name!r} must be at least {spec['minLength']} character(s)"
|
|
310
|
+
if isinstance(spec.get("maxLength"), int) and len(value) > spec["maxLength"]:
|
|
311
|
+
return f"field {name!r} must be at most {spec['maxLength']} character(s)"
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def elicitation_response_problem(
|
|
316
|
+
waiting_for: dict | None, action: str, content: dict | None,
|
|
317
|
+
) -> str | None:
|
|
318
|
+
"""Validate a response against the pending elicitation, or ``None`` if ok.
|
|
319
|
+
|
|
320
|
+
``decline``/``cancel`` carry no content. A form-mode ``accept`` must supply
|
|
321
|
+
an object that satisfies the server's flat ``requestedSchema`` (every
|
|
322
|
+
``required`` field present, no unknown fields, primitive types, enum
|
|
323
|
+
membership, and length/range bounds). A URL-mode ``accept`` only confirms
|
|
324
|
+
the user finished the out-of-band flow, so it carries no content either.
|
|
325
|
+
"""
|
|
326
|
+
if action not in ELICITATION_ACTIONS:
|
|
327
|
+
return f"action must be one of {', '.join(ELICITATION_ACTIONS)}"
|
|
328
|
+
if action != "accept":
|
|
329
|
+
return None if not content else f"{action} does not take content"
|
|
330
|
+
wf = waiting_for or {}
|
|
331
|
+
if wf.get("mode") == "url":
|
|
332
|
+
return None if not content else "a URL elicitation accept does not take content"
|
|
333
|
+
if content is None:
|
|
334
|
+
content = {}
|
|
335
|
+
if not isinstance(content, dict):
|
|
336
|
+
return "content must be an object"
|
|
337
|
+
schema = wf.get("schema") if isinstance(wf.get("schema"), dict) else {}
|
|
338
|
+
props = _schema_properties(schema)
|
|
339
|
+
unknown = sorted(k for k in content if k not in props)
|
|
340
|
+
if unknown:
|
|
341
|
+
return "unknown field(s): " + ", ".join(unknown) + (
|
|
342
|
+
f"; expected {sorted(props)}" if props else "")
|
|
343
|
+
required = schema.get("required") if isinstance(schema.get("required"), list) else []
|
|
344
|
+
missing = [k for k in required if k not in content]
|
|
345
|
+
if missing:
|
|
346
|
+
return "missing required field(s): " + ", ".join(str(k) for k in missing)
|
|
347
|
+
for name, value in content.items():
|
|
348
|
+
spec = props.get(name) if isinstance(props.get(name), dict) else {}
|
|
349
|
+
problem = _field_problem(name, spec, value)
|
|
350
|
+
if problem:
|
|
351
|
+
return problem
|
|
352
|
+
return None
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
_TRUE = {"true", "yes", "y", "1", "on"}
|
|
356
|
+
_FALSE = {"false", "no", "n", "0", "off"}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def elicitation_field_content(
|
|
360
|
+
waiting_for: dict | None, pairs: list[tuple[str, str]],
|
|
361
|
+
) -> tuple[dict, str | None]:
|
|
362
|
+
"""Coerce CLI ``name=value`` strings into typed elicitation content.
|
|
363
|
+
|
|
364
|
+
Each value is converted using the pending schema's declared field type
|
|
365
|
+
(boolean words, numbers, integers; strings unchanged). Repeating a name
|
|
366
|
+
builds a list for an array (multi-select) field. Returns ``(content,
|
|
367
|
+
None)`` or ``({}, error)``; schema validation is left to
|
|
368
|
+
:func:`elicitation_response_problem`.
|
|
369
|
+
"""
|
|
370
|
+
props = _schema_properties((waiting_for or {}).get("schema"))
|
|
371
|
+
out: dict = {}
|
|
372
|
+
for name, raw in pairs:
|
|
373
|
+
spec = props.get(name) if isinstance(props.get(name), dict) else {}
|
|
374
|
+
kind = spec.get("type")
|
|
375
|
+
if kind == "boolean":
|
|
376
|
+
low = raw.strip().lower()
|
|
377
|
+
if low in _TRUE:
|
|
378
|
+
value = True
|
|
379
|
+
elif low in _FALSE:
|
|
380
|
+
value = False
|
|
381
|
+
else:
|
|
382
|
+
return {}, f"field {name!r} expects true/false, got {raw!r}"
|
|
383
|
+
elif kind in ("number", "integer"):
|
|
384
|
+
try:
|
|
385
|
+
value = int(raw) if kind == "integer" else float(raw)
|
|
386
|
+
except ValueError:
|
|
387
|
+
return {}, f"field {name!r} expects a {kind}, got {raw!r}"
|
|
388
|
+
else:
|
|
389
|
+
value = raw
|
|
390
|
+
if kind == "array":
|
|
391
|
+
out.setdefault(name, []).append(value)
|
|
392
|
+
elif name in out:
|
|
393
|
+
return {}, f"field {name!r} given more than once"
|
|
394
|
+
else:
|
|
395
|
+
out[name] = value
|
|
396
|
+
return out, None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# --- 3. event filtering --------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
def filter_events_after(
|
|
402
|
+
events: "list[Event]",
|
|
403
|
+
since_epoch: str | None,
|
|
404
|
+
since_seq: int | None,
|
|
405
|
+
) -> "list[Event]":
|
|
406
|
+
"""Return the events that follow the ``(since_epoch, since_seq)`` cursor.
|
|
407
|
+
|
|
408
|
+
``events`` is in chronological (file) order. The cursor is epoch-aware
|
|
409
|
+
because ``seq`` resets per host epoch:
|
|
410
|
+
|
|
411
|
+
* No cursor → return everything.
|
|
412
|
+
* ``since_seq`` only (legacy) → plain ``seq > since_seq`` filter; correct only
|
|
413
|
+
within a single epoch.
|
|
414
|
+
* ``(epoch, seq)`` cursor → walk in order: skip events before the cursor's
|
|
415
|
+
epoch block, keep ``> since_seq`` events within that epoch, and keep every
|
|
416
|
+
event in a later epoch block. If the cursor epoch is absent from the file
|
|
417
|
+
(rotated out, or unknown), replay everything rather than risk dropping new
|
|
418
|
+
events — duplicates are idempotent for consumers, dropped events are not.
|
|
419
|
+
"""
|
|
420
|
+
if since_epoch is None:
|
|
421
|
+
if since_seq is None:
|
|
422
|
+
return list(events)
|
|
423
|
+
return [e for e in events if (e.seq or 0) > since_seq]
|
|
424
|
+
|
|
425
|
+
if not any(e.epoch == since_epoch for e in events):
|
|
426
|
+
# Cursor epoch is gone or unknown → safest is a full replay.
|
|
427
|
+
return list(events)
|
|
428
|
+
|
|
429
|
+
out: "list[Event]" = []
|
|
430
|
+
seen_cursor_epoch = False
|
|
431
|
+
for e in events:
|
|
432
|
+
if e.epoch == since_epoch:
|
|
433
|
+
seen_cursor_epoch = True
|
|
434
|
+
if since_seq is None or e.seq is None or e.seq > since_seq:
|
|
435
|
+
out.append(e)
|
|
436
|
+
elif seen_cursor_epoch:
|
|
437
|
+
# A different epoch appearing after the cursor's block is newer.
|
|
438
|
+
out.append(e)
|
|
439
|
+
# else: an earlier epoch (before the cursor) — already consumed, skip.
|
|
440
|
+
return out
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# --- 4. payload bounds ----------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
def send_payload_violation(text: str, limit: int | None = None) -> tuple[int, int] | None:
|
|
446
|
+
"""Return ``(size, limit)`` when ``text`` exceeds the managed paste-channel
|
|
447
|
+
payload bound, else ``None``. ``limit=None`` uses the live process setting
|
|
448
|
+
(``ccx.constants.SEND_TEXT_MAX_BYTES``), which is unlimited by default; a
|
|
449
|
+
non-positive limit disables the check. Shared by the CLI send, the client,
|
|
450
|
+
and the scripted client so an explicitly configured bound behaves the same
|
|
451
|
+
on every surface."""
|
|
452
|
+
if limit is None:
|
|
453
|
+
limit = _constants.SEND_TEXT_MAX_BYTES
|
|
454
|
+
if limit <= 0:
|
|
455
|
+
return None
|
|
456
|
+
size = len(text.encode("utf-8", errors="ignore"))
|
|
457
|
+
return (size, limit) if size > limit else None
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def payload_guidance(short: str, size: int, limit: int) -> str:
|
|
461
|
+
"""The canonical oversize-prompt guidance string carried by
|
|
462
|
+
:class:`ccx.errors.PayloadTooLarge` and the CLI error path."""
|
|
463
|
+
return (f"send: prompt is {size} bytes; the configured managed paste channel "
|
|
464
|
+
f"limit is {limit} bytes. Write the content to a file in the worker's "
|
|
465
|
+
f"cwd and send a short pointer message instead, e.g. "
|
|
466
|
+
f"`ccx send {short} \"Read task.md and do what it says\"`. "
|
|
467
|
+
f"Adjust or disable the bound with CCX_SEND_MAX_BYTES.")
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
__all__ = [
|
|
471
|
+
"QUESTION_TOOL",
|
|
472
|
+
"KIND_QUESTION",
|
|
473
|
+
"KIND_PERMISSION",
|
|
474
|
+
"KIND_ELICITATION",
|
|
475
|
+
"ELICITATION_ACTIONS",
|
|
476
|
+
"REQUEST_ID_MISMATCH",
|
|
477
|
+
"pending_input_kind",
|
|
478
|
+
"wrong_verb_hint",
|
|
479
|
+
"answer_illegality",
|
|
480
|
+
"elicitation_illegality",
|
|
481
|
+
"elicitation_response_problem",
|
|
482
|
+
"elicitation_field_content",
|
|
483
|
+
"answer_request_fence",
|
|
484
|
+
"parse_question_index",
|
|
485
|
+
"question_answer_map",
|
|
486
|
+
"question_answer_message",
|
|
487
|
+
"filter_events_after",
|
|
488
|
+
"send_payload_violation",
|
|
489
|
+
"payload_guidance",
|
|
490
|
+
]
|