ccx-native 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +807 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +269 -0
  16. package/ccx/claude/host/session.py +5244 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,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
+ ]