ccx-native 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +807 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +269 -0
- package/ccx/claude/host/session.py +5244 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1558 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2424 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1454 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/models.py +225 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +296 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
package/ccx/client.py
ADDED
|
@@ -0,0 +1,1904 @@
|
|
|
1
|
+
"""Direct Python Claude client bound to a sealed active generation.
|
|
2
|
+
|
|
3
|
+
Managed operations use full CCX worker UUIDs and durable delivery identities."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import ccx.cli.commands.read as commands_read
|
|
7
|
+
import ccx.cli.commands.turns as commands_turns
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import time
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
from typing import Iterator
|
|
14
|
+
|
|
15
|
+
from ccx.rpc import rpc as host_rpc
|
|
16
|
+
from ccx.ops import event_feed
|
|
17
|
+
from ccx.claude.delivery import (
|
|
18
|
+
public_receipt as public_semantic_receipt,
|
|
19
|
+
read_receipt as read_semantic_receipt,
|
|
20
|
+
valid_delivery_id as valid_semantic_delivery_id,
|
|
21
|
+
)
|
|
22
|
+
from ccx.ops.compact_ops import (
|
|
23
|
+
get_operation as get_compact_operation,
|
|
24
|
+
new_operation_id as new_compact_operation_id,
|
|
25
|
+
observe_operation as observe_compact_operation,
|
|
26
|
+
)
|
|
27
|
+
from .constants import (
|
|
28
|
+
COMPACT_POLL_S,
|
|
29
|
+
COMPACT_WAIT_DEFAULT_S,
|
|
30
|
+
DEFAULT_IDLE_TIMEOUT_S,
|
|
31
|
+
S,
|
|
32
|
+
STEER_OBSERVE_DEFAULT_S,
|
|
33
|
+
STEER_OBSERVE_POLL_S,
|
|
34
|
+
)
|
|
35
|
+
from .errors import (
|
|
36
|
+
BudgetStopped,
|
|
37
|
+
CompactFailed,
|
|
38
|
+
CompactIndeterminate,
|
|
39
|
+
DeliveryRejected,
|
|
40
|
+
InvalidRequest,
|
|
41
|
+
PayloadTooLarge,
|
|
42
|
+
SessionNotFound,
|
|
43
|
+
SessionUnavailable,
|
|
44
|
+
SessionWaiting,
|
|
45
|
+
SendIndeterminate,
|
|
46
|
+
SteerIndeterminate,
|
|
47
|
+
TurnIncomplete,
|
|
48
|
+
WorkerApiError,
|
|
49
|
+
WorkerRateLimited,
|
|
50
|
+
api_error_from_lifecycle,
|
|
51
|
+
build_worker_api_error,
|
|
52
|
+
worker_not_found,
|
|
53
|
+
)
|
|
54
|
+
from ccx.store.paths import ensure_dirs, events_ndjson_path, sock_path
|
|
55
|
+
from ccx.ops.steer_ops import (
|
|
56
|
+
PUBLIC_ACCEPTED_PENDING as STEER_PENDING,
|
|
57
|
+
PUBLIC_CONSUMED as STEER_CONSUMED,
|
|
58
|
+
PUBLIC_STARTED as STEER_STARTED,
|
|
59
|
+
PUBLIC_UNKNOWN as STEER_UNKNOWN,
|
|
60
|
+
get_delivery as get_steer_delivery,
|
|
61
|
+
new_delivery_id as new_steer_delivery_id,
|
|
62
|
+
observe_delivery as observe_steer_delivery,
|
|
63
|
+
public_delivery as public_steer_delivery,
|
|
64
|
+
valid_delivery_id as valid_steer_delivery_id,
|
|
65
|
+
)
|
|
66
|
+
from ccx.store.records import read_record
|
|
67
|
+
from ccx.ops.terminate import KILL_TIMEOUT_DEFAULT_S
|
|
68
|
+
from ccx.claude.managed_turns import latest_settled_turn, settlement_for_turn
|
|
69
|
+
from ccx.rpc.rpc import (
|
|
70
|
+
_rpc,
|
|
71
|
+
)
|
|
72
|
+
from ccx.ops.semantics import (
|
|
73
|
+
payload_guidance,
|
|
74
|
+
KIND_PERMISSION,
|
|
75
|
+
answer_illegality,
|
|
76
|
+
answer_request_fence,
|
|
77
|
+
elicitation_illegality,
|
|
78
|
+
elicitation_response_problem,
|
|
79
|
+
filter_events_after,
|
|
80
|
+
pending_input_kind,
|
|
81
|
+
wrong_verb_hint,
|
|
82
|
+
question_answer_map,
|
|
83
|
+
question_answer_message,
|
|
84
|
+
)
|
|
85
|
+
from ccx.claude.transcript import render_transcript
|
|
86
|
+
from .types import (
|
|
87
|
+
ClaudeContext,
|
|
88
|
+
ClaudeTask,
|
|
89
|
+
CompactOperationResult,
|
|
90
|
+
Event,
|
|
91
|
+
FiveHourReset,
|
|
92
|
+
ModelCatalog,
|
|
93
|
+
Report,
|
|
94
|
+
PendingInput,
|
|
95
|
+
SemanticDeliveryResult,
|
|
96
|
+
SendResult,
|
|
97
|
+
SessionInfo,
|
|
98
|
+
SessionStatus,
|
|
99
|
+
SteerResult,
|
|
100
|
+
Usage,
|
|
101
|
+
WaitResult,
|
|
102
|
+
)
|
|
103
|
+
from ccx.claude.usage import fetch_usage, reset_five_hour_limit, session_reset_status, usage_summary
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _compact_failed(
|
|
107
|
+
operation: dict | None, *, short: str, state: str | None,
|
|
108
|
+
operation_id: str | None,
|
|
109
|
+
) -> CompactFailed:
|
|
110
|
+
"""Build the typed error for a proven compaction failure.
|
|
111
|
+
|
|
112
|
+
The journal already carries the classification, so this only re-types it.
|
|
113
|
+
``api_error`` stays ``None`` when the conversation itself was the problem —
|
|
114
|
+
a caller retrying on an API blip must not also retry a compaction that can
|
|
115
|
+
never succeed.
|
|
116
|
+
"""
|
|
117
|
+
result = (operation or {}).get("result") or {}
|
|
118
|
+
payload = result.get("api_error")
|
|
119
|
+
api_error = None
|
|
120
|
+
if isinstance(payload, dict):
|
|
121
|
+
api_error = build_worker_api_error(
|
|
122
|
+
(operation or {}).get("reason") or "",
|
|
123
|
+
short=short,
|
|
124
|
+
kind=payload.get("error"),
|
|
125
|
+
status=payload.get("status"),
|
|
126
|
+
transient=bool(payload.get("transient")),
|
|
127
|
+
rate_limited=bool(payload.get("rate_limited")),
|
|
128
|
+
request_id=payload.get("request_id"),
|
|
129
|
+
detail=payload.get("text"),
|
|
130
|
+
reset_text=payload.get("reset_text"),
|
|
131
|
+
)
|
|
132
|
+
return CompactFailed(
|
|
133
|
+
"compact failed after acceptance: "
|
|
134
|
+
f"{(operation or {}).get('reason') or 'unknown reason'}",
|
|
135
|
+
short=short, state=state, operation_id=operation_id,
|
|
136
|
+
api_error=api_error,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _managed_completed_turns(short: str) -> int:
|
|
141
|
+
# Historical private name retained; this is now the managed lifecycle ordinal.
|
|
142
|
+
record = read_record(short) or {}
|
|
143
|
+
return latest_settled_turn(
|
|
144
|
+
short,
|
|
145
|
+
fallback=int((record.get("stats") or {}).get("turns") or 0),
|
|
146
|
+
record_last_settled_at_ms=record.get("lastSettledAt") or record.get("lastTurnAt"),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _original_input_ack(reply: dict, *, op: str, worker_id: str,
|
|
151
|
+
request_id: str, response_id: str | None) -> None:
|
|
152
|
+
"""Classify the existing verified host RPC; a lost reply is not a refusal."""
|
|
153
|
+
if reply.get("ok") is True:
|
|
154
|
+
if reply.get("request_id") == request_id and (
|
|
155
|
+
response_id is None or reply.get("response_id") == response_id):
|
|
156
|
+
return
|
|
157
|
+
raise SendIndeterminate(
|
|
158
|
+
f"{op}: original input ACK has wrong request identity "
|
|
159
|
+
f"(request_id={request_id!r}, response_id={response_id!r})",
|
|
160
|
+
short=worker_id,
|
|
161
|
+
)
|
|
162
|
+
error = str(reply.get("error") or "unknown host refusal")
|
|
163
|
+
if error.startswith(("unreachable:", "unverified host:")):
|
|
164
|
+
raise SessionUnavailable(f"{op}: original host unavailable: {error}",
|
|
165
|
+
short=worker_id)
|
|
166
|
+
if error in ("timeout", "no reply", "bad reply", "reply too large",
|
|
167
|
+
"non-object reply"):
|
|
168
|
+
raise SendIndeterminate(
|
|
169
|
+
f"{op}: native decision reply indeterminate ({error}); "
|
|
170
|
+
f"request_id={request_id!r}, response_id={response_id!r}",
|
|
171
|
+
short=worker_id,
|
|
172
|
+
)
|
|
173
|
+
if error.startswith(("no pending ", "pending input is not ",
|
|
174
|
+
"AskUserQuestion requires ", "MCP elicitation requires ")):
|
|
175
|
+
raise SessionUnavailable(f"{op}: {error}", short=worker_id)
|
|
176
|
+
raise DeliveryRejected(f"{op} rejected: {error}", short=worker_id,
|
|
177
|
+
reason=error, state=reply.get("state"))
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class CcxClient:
|
|
181
|
+
"""In-process client for ccx-managed Claude Code sessions.
|
|
182
|
+
|
|
183
|
+
Stateless and cheap to construct; all session state lives on disk under
|
|
184
|
+
``CCX_ROOT`` exactly as the CLI leaves it, so a ``CcxClient`` and the ``ccx``
|
|
185
|
+
command interoperate freely on the same sessions.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def _uuid_session_info(self, owner, rec: dict, *,
|
|
189
|
+
inventory_row: dict | None = None) -> SessionInfo:
|
|
190
|
+
"""Typed managed-only public fields, never an external/native alias."""
|
|
191
|
+
native = rec.get("native")
|
|
192
|
+
native = native if isinstance(native, dict) else {}
|
|
193
|
+
args = rec.get("createArgs")
|
|
194
|
+
args = args if isinstance(args, dict) else {}
|
|
195
|
+
stats = rec.get("stats")
|
|
196
|
+
stats = stats if isinstance(stats, dict) else {}
|
|
197
|
+
lifecycle = rec.get("lifecycle")
|
|
198
|
+
lifecycle = lifecycle if isinstance(lifecycle, dict) else {}
|
|
199
|
+
return SessionInfo(
|
|
200
|
+
short=owner.short, worker_id=owner.worker_id,
|
|
201
|
+
session_id=native.get("sessionId"), name=rec.get("name"),
|
|
202
|
+
cwd=rec.get("cwd"), state=lifecycle.get("state"),
|
|
203
|
+
model=args.get("model"), effort=args.get("effort"),
|
|
204
|
+
last_turn_at=(inventory_row["last_turn_at"] if inventory_row is not None
|
|
205
|
+
else rec.get("lastTurnAt")),
|
|
206
|
+
created_at=(inventory_row["created_at"] if inventory_row is not None
|
|
207
|
+
else rec.get("createdAt")),
|
|
208
|
+
turns=stats.get("turns"),
|
|
209
|
+
warning=inventory_row.get("warning") if inventory_row is not None else None,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def __init__(self) -> None:
|
|
213
|
+
"""One fail-closed full-UUID bootstrap; never legacy ensure_dirs."""
|
|
214
|
+
from .constants import CCX_ROOT
|
|
215
|
+
from ccx.store.root import GenerationError, open_or_initialize
|
|
216
|
+
try:
|
|
217
|
+
self._uuid_store, _created = open_or_initialize(CCX_ROOT)
|
|
218
|
+
except (GenerationError, OSError) as exc:
|
|
219
|
+
raise SessionUnavailable(
|
|
220
|
+
f"CCX worker records are unavailable; check CCX_ROOT: {exc}"
|
|
221
|
+
) from exc
|
|
222
|
+
|
|
223
|
+
# -- internal helpers ---------------------------------------------------
|
|
224
|
+
|
|
225
|
+
def _resolve_record(self, identifier: str, *, live: bool = False) -> dict:
|
|
226
|
+
"""Exact full-UUID CCX-owned record; unmanaged Claude inaccessible."""
|
|
227
|
+
if live:
|
|
228
|
+
raise SessionUnavailable(
|
|
229
|
+
"Only CCX-managed Claude workers are supported; use a worker handle"
|
|
230
|
+
)
|
|
231
|
+
return self._resolve_managed(identifier,"inspect")
|
|
232
|
+
|
|
233
|
+
def _resolve_managed(self, identifier: str, op: str) -> dict:
|
|
234
|
+
"""Current global owned full UUID only, no native/name/short fallback."""
|
|
235
|
+
from ccx.store.root import GenerationError
|
|
236
|
+
from ccx.store.identity import IdentityError,UnknownWorker
|
|
237
|
+
try:
|
|
238
|
+
self._uuid_store.revalidate()
|
|
239
|
+
owner=self._uuid_store.resolve(identifier)
|
|
240
|
+
if owner.engine!="claude":
|
|
241
|
+
raise InvalidRequest(f"{op}: Claude full-UUID owner required")
|
|
242
|
+
return self._uuid_store.record(owner.worker)
|
|
243
|
+
except UnknownWorker as exc:
|
|
244
|
+
raise worker_not_found(identifier) from exc
|
|
245
|
+
except IdentityError as exc:
|
|
246
|
+
raise InvalidRequest(f"{op}: invalid Claude full UUID: {exc}") from exc
|
|
247
|
+
except GenerationError as exc:
|
|
248
|
+
raise SessionUnavailable(f"{op}: ACTIVE generation changed: {exc}") from exc
|
|
249
|
+
|
|
250
|
+
# -- write paths --------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
def new(
|
|
253
|
+
self, prompt: str | None = None, *, cwd: str | None = None,
|
|
254
|
+
name: str | None = None, worker_id: str | None = None,
|
|
255
|
+
model: str | None = None, effort: str | None = None,
|
|
256
|
+
agent: str | None = None, append_system_prompt: str | None = None,
|
|
257
|
+
idle_timeout: int = DEFAULT_IDLE_TIMEOUT_S,
|
|
258
|
+
allowed_tools: str | None = None, disallowed_tools: str | None = None,
|
|
259
|
+
add_dir: list[str] | None = None, permission_mode: str | None = None,
|
|
260
|
+
skip_permissions: bool = True, caps: dict | None = None,
|
|
261
|
+
wait: bool = False, timeout: float | None = None,
|
|
262
|
+
start_timeout: float | None = None,
|
|
263
|
+
) -> SessionInfo | SendResult:
|
|
264
|
+
"""Create one verified native Claude session under its original UUID."""
|
|
265
|
+
from ccx.ops.creation import create
|
|
266
|
+
result = create(
|
|
267
|
+
engine="claude",worker_id=worker_id,cwd=cwd or os.getcwd(),
|
|
268
|
+
prompt=prompt,wait=wait,caps=caps,
|
|
269
|
+
name=name,model=model,effort=effort,agent=agent,
|
|
270
|
+
append_system_prompt=append_system_prompt,
|
|
271
|
+
idle_timeout=idle_timeout,allowed_tools=allowed_tools,
|
|
272
|
+
disallowed_tools=disallowed_tools,add_dir=add_dir,
|
|
273
|
+
permission_mode=permission_mode,
|
|
274
|
+
permissions="full" if skip_permissions else "restricted",
|
|
275
|
+
created_via="python-api",timeout=timeout,
|
|
276
|
+
start_timeout=start_timeout,
|
|
277
|
+
expected_generation=self._uuid_store.generation,
|
|
278
|
+
)
|
|
279
|
+
if wait:
|
|
280
|
+
report = result["report"]
|
|
281
|
+
return SendResult(
|
|
282
|
+
short=result["short"], worker_id=result["worker_id"],
|
|
283
|
+
engine="claude", reply=report["text"], state="idle",
|
|
284
|
+
queued=False, delivery_id=result["delivery_id"],
|
|
285
|
+
delivery_status="settled", settled=True,
|
|
286
|
+
turn_ref=result["turn_ref"],
|
|
287
|
+
)
|
|
288
|
+
if prompt is not None:
|
|
289
|
+
# After a first send, reconstruct the typed public identity from
|
|
290
|
+
# the already-verified create result; a second mutable store read
|
|
291
|
+
# could otherwise lose a freshly minted original worker UUID.
|
|
292
|
+
from ccx.store.identity import WorkerOwner
|
|
293
|
+
return self._uuid_session_info(
|
|
294
|
+
WorkerOwner(result["worker_id"], "claude"), result["record"],
|
|
295
|
+
)
|
|
296
|
+
self._uuid_store.revalidate()
|
|
297
|
+
return self._uuid_session_info(
|
|
298
|
+
self._uuid_store.resolve(result["worker"]), result["record"],
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def fork(
|
|
302
|
+
self, target: str, prompt: str | None = None, *,
|
|
303
|
+
name: str | None = None, worker_id: str | None = None,
|
|
304
|
+
model: str | None = None, effort: str | None = None,
|
|
305
|
+
caps: dict | None = None, wait: bool = False,
|
|
306
|
+
timeout: float | None = None,
|
|
307
|
+
) -> SessionInfo | SendResult:
|
|
308
|
+
"""Fork one owned Claude history with an optional original first turn."""
|
|
309
|
+
from math import isfinite
|
|
310
|
+
from ccx.ops.forking import fork
|
|
311
|
+
from ccx.store.root import GenerationError
|
|
312
|
+
from ccx.store.identity import IdentityError, UnknownWorker, parse_managed_target
|
|
313
|
+
|
|
314
|
+
if timeout is not None and (
|
|
315
|
+
type(timeout) not in (int, float) or not isfinite(timeout) or timeout <= 0
|
|
316
|
+
):
|
|
317
|
+
raise InvalidRequest("fork timeout must be positive finite seconds or None")
|
|
318
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
319
|
+
try:
|
|
320
|
+
source = parse_managed_target(target).qualified("claude")
|
|
321
|
+
result = fork(
|
|
322
|
+
source, worker_id=worker_id, name=name, model=model, effort=effort,
|
|
323
|
+
created_via="python-api", expected_generation=self._uuid_store.generation,
|
|
324
|
+
deadline=deadline, prompt=prompt, caps=caps, wait=wait,
|
|
325
|
+
)
|
|
326
|
+
if wait:
|
|
327
|
+
report = result["report"]
|
|
328
|
+
return SendResult(
|
|
329
|
+
short=result["short"], worker_id=result["worker_id"],
|
|
330
|
+
engine="claude", reply=report["text"], state="idle",
|
|
331
|
+
queued=False, delivery_id=result["delivery_id"],
|
|
332
|
+
delivery_status="settled", settled=True,
|
|
333
|
+
turn_ref=result["turn_ref"],
|
|
334
|
+
)
|
|
335
|
+
if prompt is not None:
|
|
336
|
+
from ccx.store.identity import WorkerOwner
|
|
337
|
+
return self._uuid_session_info(
|
|
338
|
+
WorkerOwner(result["worker_id"], "claude"), result["record"],
|
|
339
|
+
)
|
|
340
|
+
self._uuid_store.revalidate()
|
|
341
|
+
return self._uuid_session_info(
|
|
342
|
+
self._uuid_store.resolve(result["worker"]), result["record"],
|
|
343
|
+
)
|
|
344
|
+
except UnknownWorker as exc:
|
|
345
|
+
raise worker_not_found(target) from exc
|
|
346
|
+
except IdentityError as exc:
|
|
347
|
+
raise InvalidRequest(f"fork: invalid Claude full UUID: {exc}") from exc
|
|
348
|
+
except GenerationError as exc:
|
|
349
|
+
raise SessionUnavailable(f"fork: Claude ACTIVE generation unavailable: {exc}") from exc
|
|
350
|
+
|
|
351
|
+
def send(
|
|
352
|
+
self, short: str, text: str, *, wait: bool | None = None,
|
|
353
|
+
timeout: float | None = None, caps: dict | None = None,
|
|
354
|
+
delivery_id: str | None = None,
|
|
355
|
+
) -> SendResult:
|
|
356
|
+
"""One original full-UUID Claude native delivery, never short-routed.
|
|
357
|
+
|
|
358
|
+
Caller-known delivery_id is immutable. Unknown native effect and
|
|
359
|
+
waited completion timeouts retain this original token; they never
|
|
360
|
+
authorize a second provider effect.
|
|
361
|
+
"""
|
|
362
|
+
from uuid import uuid4
|
|
363
|
+
from ccx.ops.send import send_turn
|
|
364
|
+
from ccx.ops.wait import wait_delivery
|
|
365
|
+
from ccx.ops.report import report_worker
|
|
366
|
+
from ccx.store.root import NativeEffectIndeterminate, GenerationError
|
|
367
|
+
from ccx.claude.delivery import ClaudeDeliveryConflict
|
|
368
|
+
from .errors import SendIndeterminate,OperationConflict
|
|
369
|
+
if not isinstance(text,str) or not text.strip():
|
|
370
|
+
raise InvalidRequest("send requires nonempty prompt")
|
|
371
|
+
if timeout is not None and (not isinstance(timeout,(int,float))
|
|
372
|
+
or timeout <= 0):
|
|
373
|
+
raise InvalidRequest("send timeout must be positive or None")
|
|
374
|
+
rec=self._resolve_managed(short,"send")
|
|
375
|
+
worker_id=rec["workerId"]
|
|
376
|
+
handle=f"claude:{worker_id}"
|
|
377
|
+
token=delivery_id if delivery_id is not None else str(uuid4())
|
|
378
|
+
deadline=time.monotonic()+timeout if timeout is not None else None
|
|
379
|
+
try:
|
|
380
|
+
first=send_turn(handle,text,delivery_id=token,mode="new_turn_only",
|
|
381
|
+
cap=caps,expected_generation=self._uuid_store.generation)
|
|
382
|
+
except ClaudeDeliveryConflict as exc:
|
|
383
|
+
raise OperationConflict(
|
|
384
|
+
f"original delivery {token!r} content conflict: {exc}",
|
|
385
|
+
short=worker_id[:8],worker_id=worker_id,
|
|
386
|
+
) from exc
|
|
387
|
+
except NativeEffectIndeterminate as exc:
|
|
388
|
+
raise SendIndeterminate(
|
|
389
|
+
"native Claude effect unknown; reconcile original token, never "
|
|
390
|
+
f"resend: {exc}",short=worker_id[:8],delivery_id=token,
|
|
391
|
+
delivery_status="submitted-unknown",
|
|
392
|
+
) from exc
|
|
393
|
+
except GenerationError as exc:
|
|
394
|
+
raise SessionUnavailable(f"Claude native send unavailable: {exc}") from exc
|
|
395
|
+
observed=None
|
|
396
|
+
if wait is not False:
|
|
397
|
+
completed = wait_delivery(
|
|
398
|
+
handle, delivery_id=token,
|
|
399
|
+
timeout=None if deadline is None else max(0., deadline-time.monotonic()),
|
|
400
|
+
expected_generation=self._uuid_store.generation,
|
|
401
|
+
)
|
|
402
|
+
observed = {"status": "settled", "delivery": completed["delivery"]}
|
|
403
|
+
result=observed or first
|
|
404
|
+
delivery=result.get("delivery") or {}
|
|
405
|
+
status=result.get("status") or delivery.get("status")
|
|
406
|
+
if status=="rejected":
|
|
407
|
+
raise DeliveryRejected(
|
|
408
|
+
delivery.get("rejection_message") or "native Claude refused",
|
|
409
|
+
short=worker_id[:8],refusal=delivery.get("rejection_code"),
|
|
410
|
+
delivery_id=token,delivery_status="rejected",
|
|
411
|
+
)
|
|
412
|
+
settled=(bool(first.get("settled")) if observed is None
|
|
413
|
+
else status=="settled")
|
|
414
|
+
reply=None
|
|
415
|
+
if settled:
|
|
416
|
+
reply=(completed["report"]["text"] if observed is not None else
|
|
417
|
+
report_worker(handle,turn_ref=delivery.get("turn_ref"))["text"])
|
|
418
|
+
return SendResult(
|
|
419
|
+
short=worker_id[:8],worker_id=worker_id,engine="claude",
|
|
420
|
+
reply=reply,state=("idle" if settled else "busy"),queued=False,
|
|
421
|
+
delivery_id=token,
|
|
422
|
+
delivery_status=("settled" if settled else status),
|
|
423
|
+
settled=settled,
|
|
424
|
+
turn_ref=delivery.get("turn_ref"),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def _semantic_result(
|
|
428
|
+
self, short: str, delivery_id: str, delivery: dict | None, *,
|
|
429
|
+
state: str | None = None,
|
|
430
|
+
) -> SemanticDeliveryResult:
|
|
431
|
+
"""Typed original semantic Claude receipt, full UUID globally owned."""
|
|
432
|
+
owner=self._uuid_store.resolve(short)
|
|
433
|
+
value=delivery or {}
|
|
434
|
+
status=value.get("status") or "pending"
|
|
435
|
+
refusal=value.get("last_refusal") or {}
|
|
436
|
+
return SemanticDeliveryResult(
|
|
437
|
+
short=owner.short,worker_id=owner.worker_id,engine="claude",
|
|
438
|
+
delivery_id=value.get("delivery_id") or delivery_id,
|
|
439
|
+
status=status,accepted=status in ("accepted","settled"),
|
|
440
|
+
owned=bool(value.get("owned")),
|
|
441
|
+
settled=bool(value.get("settlement")),
|
|
442
|
+
recommended_action=value.get("recommended_action"),
|
|
443
|
+
route=value.get("route"),placement=value.get("placement"),
|
|
444
|
+
target_turn_ref=value.get("target_turn_ref"),
|
|
445
|
+
turn_ref=value.get("turn_ref"),
|
|
446
|
+
managed_turn_ordinal=value.get("managed_turn_ordinal"),
|
|
447
|
+
state=state,attempts=int(value.get("attempts") or 0),
|
|
448
|
+
maybe_written=bool(value.get("maybe_written")),
|
|
449
|
+
evidence=value.get("evidence"),
|
|
450
|
+
evidence_uuid=value.get("evidence_uuid"),
|
|
451
|
+
reason=(value.get("rejection_message") or refusal.get("message")
|
|
452
|
+
or value.get("evidence_status")),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
def deliver(
|
|
456
|
+
self,
|
|
457
|
+
short: str,
|
|
458
|
+
text: str,
|
|
459
|
+
*,
|
|
460
|
+
delivery_id: str,
|
|
461
|
+
mode: str = "auto",
|
|
462
|
+
timeout: float | None = None,
|
|
463
|
+
caps: dict | None = None,
|
|
464
|
+
) -> SemanticDeliveryResult:
|
|
465
|
+
"""Atomically deliver one caller-identified semantic message."""
|
|
466
|
+
from ccx.ops.send import send_turn
|
|
467
|
+
from ccx.claude.delivery import ClaudeDeliveryConflict
|
|
468
|
+
from ccx.store.root import NativeEffectIndeterminate
|
|
469
|
+
from .errors import OperationConflict
|
|
470
|
+
if mode not in {"auto", "new_turn_only"}:
|
|
471
|
+
raise InvalidRequest(f"deliver: unsupported mode {mode!r}")
|
|
472
|
+
if not isinstance(text, str) or not text.strip():
|
|
473
|
+
raise InvalidRequest("deliver requires nonempty prompt")
|
|
474
|
+
violation = commands_turns._send_payload_violation(text)
|
|
475
|
+
if violation is not None:
|
|
476
|
+
size, limit = violation
|
|
477
|
+
raise PayloadTooLarge(
|
|
478
|
+
payload_guidance(short, size, limit),
|
|
479
|
+
short=short, size=size, limit=limit,
|
|
480
|
+
)
|
|
481
|
+
rec = self._resolve_managed(short, "deliver")
|
|
482
|
+
worker_id = rec["workerId"]
|
|
483
|
+
try:
|
|
484
|
+
result = send_turn(
|
|
485
|
+
f"claude:{worker_id}", text, delivery_id=delivery_id,
|
|
486
|
+
mode=mode, cap=caps, timeout=timeout,
|
|
487
|
+
expected_generation=self._uuid_store.generation,
|
|
488
|
+
)
|
|
489
|
+
except ClaudeDeliveryConflict as exc:
|
|
490
|
+
raise OperationConflict(str(exc), short=worker_id[:8]) from exc
|
|
491
|
+
except NativeEffectIndeterminate as exc:
|
|
492
|
+
raise SendIndeterminate(
|
|
493
|
+
f"Claude delivery uncertain: {exc}",
|
|
494
|
+
short=worker_id[:8], delivery_id=delivery_id,
|
|
495
|
+
delivery_status="submitted-unknown",
|
|
496
|
+
) from exc
|
|
497
|
+
current = self._uuid_store.record(worker_id)
|
|
498
|
+
return self._semantic_result(
|
|
499
|
+
worker_id, delivery_id, result.get("delivery"),
|
|
500
|
+
state=(current.get("lifecycle") or {}).get("state"),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def delivery_status(
|
|
504
|
+
self, short: str, delivery_id: str,
|
|
505
|
+
) -> SemanticDeliveryResult:
|
|
506
|
+
"""Read original Claude receipt without re-entering the provider."""
|
|
507
|
+
from ccx.ops.wait import reconcile_turn
|
|
508
|
+
from ccx.store.root import GenerationError
|
|
509
|
+
rec=self._resolve_managed(short,"delivery_status")
|
|
510
|
+
wid=rec["workerId"]
|
|
511
|
+
self._uuid_store.revalidate()
|
|
512
|
+
try:
|
|
513
|
+
verdict=reconcile_turn(f"claude:{wid}",delivery_id=delivery_id)
|
|
514
|
+
except GenerationError as exc:
|
|
515
|
+
raise SessionUnavailable(
|
|
516
|
+
f"Claude delivery unavailable: {exc}"
|
|
517
|
+
) from exc
|
|
518
|
+
original=verdict.get("delivery")
|
|
519
|
+
if original is None:
|
|
520
|
+
raise SessionNotFound(
|
|
521
|
+
f"no original delivery {delivery_id!r} for Claude {wid}"
|
|
522
|
+
)
|
|
523
|
+
latest=self._uuid_store.record(wid)
|
|
524
|
+
return self._semantic_result(
|
|
525
|
+
wid,delivery_id,original,
|
|
526
|
+
state=(latest.get("lifecycle") or {}).get("state"),
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
@staticmethod
|
|
530
|
+
def _input_deadline(timeout: float | None) -> float | None:
|
|
531
|
+
from math import isfinite
|
|
532
|
+
if timeout is not None and (
|
|
533
|
+
type(timeout) not in (int, float) or not isfinite(timeout) or timeout <= 0
|
|
534
|
+
):
|
|
535
|
+
raise InvalidRequest("input timeout must be positive and finite or None")
|
|
536
|
+
return None if timeout is None else time.monotonic() + timeout
|
|
537
|
+
|
|
538
|
+
@staticmethod
|
|
539
|
+
def _input_remaining(deadline: float | None, cap: float | None = None) -> float | None:
|
|
540
|
+
if deadline is None:
|
|
541
|
+
return cap
|
|
542
|
+
remaining = deadline - time.monotonic()
|
|
543
|
+
if remaining <= 0:
|
|
544
|
+
raise TurnIncomplete("input deadline exhausted")
|
|
545
|
+
return remaining if cap is None else min(cap, remaining)
|
|
546
|
+
|
|
547
|
+
def _input_turn_fence(
|
|
548
|
+
self, worker_id: str, record: dict, request_id: str,
|
|
549
|
+
kind: str, deadline: float | None,
|
|
550
|
+
) -> tuple[str, str | None]:
|
|
551
|
+
"""Bind the already-running original turn while its native gate is open."""
|
|
552
|
+
from ccx.store.root import GenerationError
|
|
553
|
+
|
|
554
|
+
sid = (record.get("native") or {}).get("sessionId")
|
|
555
|
+
try:
|
|
556
|
+
activation = self._uuid_store.native_activation(worker_id)
|
|
557
|
+
except GenerationError as exc:
|
|
558
|
+
raise SessionUnavailable("input original native claim unavailable") from exc
|
|
559
|
+
if (not isinstance(sid, str) or not sid
|
|
560
|
+
or activation.get("state") != "confirmed"
|
|
561
|
+
or activation.get("source") != f"claude-session:{sid}"
|
|
562
|
+
or (activation.get("nativeBinding") or {}).get("sessionId") != sid):
|
|
563
|
+
raise SessionUnavailable("Claude input source could not be verified; check the worker",
|
|
564
|
+
short=worker_id[:8])
|
|
565
|
+
status = _rpc(worker_id, {"op": "status"},
|
|
566
|
+
timeout=self._input_remaining(deadline, 5.0))
|
|
567
|
+
waiting = status.get("waitingFor")
|
|
568
|
+
ref = status.get("activeTurnRef")
|
|
569
|
+
if (status.get("ok") is not True or status.get("state") != "waiting"
|
|
570
|
+
or not isinstance(waiting, dict)
|
|
571
|
+
or waiting.get("id") != request_id
|
|
572
|
+
or pending_input_kind(waiting) != kind):
|
|
573
|
+
raise SessionUnavailable("Claude is not accepting input; check its pending request",
|
|
574
|
+
short=worker_id[:8])
|
|
575
|
+
if (not isinstance(ref, str)
|
|
576
|
+
or not ref.startswith("ccx-turn:claude:v1:")
|
|
577
|
+
or len(ref) <= len("ccx-turn:claude:v1:")):
|
|
578
|
+
raise SessionUnavailable("Claude turn cannot be identified; inspect the worker",
|
|
579
|
+
short=worker_id[:8])
|
|
580
|
+
prior = self._uuid_store.record(worker_id).get("lastSettledTurn") or {}
|
|
581
|
+
prior_ref = prior.get("turnRef")
|
|
582
|
+
if not (isinstance(prior_ref, str)
|
|
583
|
+
and prior_ref.startswith("ccx-turn:claude:v1:")):
|
|
584
|
+
prior_ref = None
|
|
585
|
+
self._uuid_store.revalidate()
|
|
586
|
+
return ref, prior_ref
|
|
587
|
+
|
|
588
|
+
def _input_wait_reply(
|
|
589
|
+
self, worker_id: str, request_id: str, response_id: str | None,
|
|
590
|
+
turn_ref: str, prior_ref: str | None, deadline: float | None,
|
|
591
|
+
) -> SendResult:
|
|
592
|
+
"""Observe only the original native R; never send another input."""
|
|
593
|
+
from ccx.ops.wait import wait_worker
|
|
594
|
+
from ccx.ops.report import report_worker
|
|
595
|
+
from .errors import CcxError
|
|
596
|
+
from ccx.store.root import GenerationError
|
|
597
|
+
|
|
598
|
+
def incomplete(why: str, *, state: str | None = None) -> TurnIncomplete:
|
|
599
|
+
err = TurnIncomplete(
|
|
600
|
+
f"{why}; input accepted, never retry: request_id={request_id!r}, "
|
|
601
|
+
f"response_id={response_id!r}, turn_ref={turn_ref!r}",
|
|
602
|
+
short=worker_id[:8], state=state, turn_id=turn_ref,
|
|
603
|
+
)
|
|
604
|
+
err.input_accepted = True
|
|
605
|
+
err.request_id = request_id
|
|
606
|
+
err.response_id = response_id
|
|
607
|
+
err.turn_ref = turn_ref
|
|
608
|
+
return err
|
|
609
|
+
|
|
610
|
+
current = self._uuid_store.record(worker_id)
|
|
611
|
+
settled = current.get("lastSettledTurn") or {}
|
|
612
|
+
if settled.get("turnRef") != turn_ref:
|
|
613
|
+
budget = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
614
|
+
try:
|
|
615
|
+
verdict = wait_worker(
|
|
616
|
+
f"claude:{worker_id}", next_turn=True, after_turn=prior_ref,
|
|
617
|
+
timeout=budget, expected_generation=self._uuid_store.generation,
|
|
618
|
+
)
|
|
619
|
+
except (CcxError, GenerationError) as exc:
|
|
620
|
+
raise incomplete("original resumed turn observation unavailable") from exc
|
|
621
|
+
latest = verdict.get("last_settled_turn") or {}
|
|
622
|
+
if latest.get("turn_ref") != turn_ref:
|
|
623
|
+
if verdict.get("status") == "waiting":
|
|
624
|
+
raise SessionWaiting(
|
|
625
|
+
"new input gate opened before the original turn settled",
|
|
626
|
+
short=worker_id[:8], waiting_for=verdict.get("waiting_for"),
|
|
627
|
+
)
|
|
628
|
+
if verdict.get("status") == "budget-stopped":
|
|
629
|
+
raise BudgetStopped("original resumed turn stopped on budget",
|
|
630
|
+
short=worker_id[:8], phase="turn")
|
|
631
|
+
if verdict.get("status") in ("dead", "crashed", "host-dead"):
|
|
632
|
+
error = api_error_from_lifecycle(
|
|
633
|
+
(self._uuid_store.record(worker_id).get("lifecycle") or {}),
|
|
634
|
+
short=worker_id[:8],
|
|
635
|
+
)
|
|
636
|
+
if error is not None:
|
|
637
|
+
raise error
|
|
638
|
+
raise incomplete(
|
|
639
|
+
"original resumed turn not proven settled",
|
|
640
|
+
state=verdict.get("state"),
|
|
641
|
+
)
|
|
642
|
+
settled = {"turnRef": latest["turn_ref"], "outcome": latest.get("outcome")}
|
|
643
|
+
if settled.get("outcome") != "completed":
|
|
644
|
+
raise incomplete(
|
|
645
|
+
"The resumed turn did not finish "
|
|
646
|
+
f"({settled.get('outcome') or 'unknown'})"
|
|
647
|
+
)
|
|
648
|
+
try:
|
|
649
|
+
report = report_worker(f"claude:{worker_id}", turn_ref=turn_ref)
|
|
650
|
+
except (CcxError, GenerationError) as exc:
|
|
651
|
+
raise incomplete(
|
|
652
|
+
"original input turn completed but its exact reply report is unavailable"
|
|
653
|
+
) from exc
|
|
654
|
+
if (report.get("turn_ref") != turn_ref
|
|
655
|
+
or report.get("status") != "completed"
|
|
656
|
+
or not isinstance(report.get("text"), str)):
|
|
657
|
+
raise incomplete("The reply is not confirmed complete; retry or inspect the turn")
|
|
658
|
+
return SendResult(
|
|
659
|
+
short=worker_id[:8], worker_id=worker_id, engine="claude",
|
|
660
|
+
reply=report["text"], transcript=None, state=S.IDLE,
|
|
661
|
+
queued=False, settled=True, turn_ref=turn_ref,
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
def _require_dead_original_question_host(
|
|
665
|
+
self, real: str, rec: dict, *, response_id: str | None,
|
|
666
|
+
) -> None:
|
|
667
|
+
"""Prove original QUESTION host died before any native answer effect."""
|
|
668
|
+
from ccx.store.liveness import owned_process
|
|
669
|
+
from ccx.store.root import GenerationError
|
|
670
|
+
lc = rec.get("lifecycle") or {}
|
|
671
|
+
if (not lc.get("hostPid") or owned_process(
|
|
672
|
+
lc.get("hostPid"), lc.get("hostStart")) != "dead"):
|
|
673
|
+
raise SessionUnavailable(
|
|
674
|
+
"Cannot confirm Claude stopped; check its status before sending another answer",
|
|
675
|
+
short=real,
|
|
676
|
+
)
|
|
677
|
+
native = rec.get("native") or {}
|
|
678
|
+
try:
|
|
679
|
+
activation = self._uuid_store.native_activation(real)
|
|
680
|
+
except GenerationError as exc:
|
|
681
|
+
raise SessionUnavailable("Cannot verify this question belongs to the worker; check its status") from exc
|
|
682
|
+
sid = native.get("sessionId")
|
|
683
|
+
if (not isinstance(sid, str) or not sid
|
|
684
|
+
or activation.get("state") != "confirmed"
|
|
685
|
+
or activation.get("source") != f"claude-session:{sid}"
|
|
686
|
+
or (activation.get("nativeBinding") or {}).get("sessionId") != sid):
|
|
687
|
+
raise SessionUnavailable("Cannot verify this question belongs to the worker; check its status", short=real)
|
|
688
|
+
if response_id is not None and self._uuid_store.read_receipt(
|
|
689
|
+
real, "answer", response_id,
|
|
690
|
+
) is not None:
|
|
691
|
+
raise SessionUnavailable(
|
|
692
|
+
"This response ID was already used; "
|
|
693
|
+
"reconcile it, never convert to another message", short=real,
|
|
694
|
+
)
|
|
695
|
+
self._uuid_store.revalidate()
|
|
696
|
+
|
|
697
|
+
def answer(
|
|
698
|
+
self,
|
|
699
|
+
short: str,
|
|
700
|
+
text: str | None = None,
|
|
701
|
+
*,
|
|
702
|
+
request_id: str | None = None,
|
|
703
|
+
options: list[tuple[int, str]] | None = None,
|
|
704
|
+
text_answers: list[tuple[int, str]] | None = None,
|
|
705
|
+
wait: bool = False,
|
|
706
|
+
timeout: float | None = None,
|
|
707
|
+
fallback_to_send: bool = False,
|
|
708
|
+
response_id: str | None = None,
|
|
709
|
+
) -> SendResult:
|
|
710
|
+
"""Answer the exact current original Claude question, optionally wait.
|
|
711
|
+
|
|
712
|
+
A missing request_id binds the currently brokered question once.
|
|
713
|
+
A dead original host may become a NEW strict message only with
|
|
714
|
+
fallback_to_send=True and positive pre-response no-effect evidence.
|
|
715
|
+
"""
|
|
716
|
+
deadline = self._input_deadline(timeout)
|
|
717
|
+
if request_id is not None and (
|
|
718
|
+
not isinstance(request_id, str) or not request_id or len(request_id) > 256
|
|
719
|
+
):
|
|
720
|
+
raise InvalidRequest("answer: request_id must be a nonempty bounded string")
|
|
721
|
+
rec = self._resolve_managed(short, "answer")
|
|
722
|
+
real = rec["workerId"]
|
|
723
|
+
question = rec.get("waitingFor")
|
|
724
|
+
illegality = answer_illegality(question, real)
|
|
725
|
+
if illegality:
|
|
726
|
+
error_type = DeliveryRejected if pending_input_kind(question) is None else SessionUnavailable
|
|
727
|
+
raise error_type(illegality, short=real)
|
|
728
|
+
if not isinstance(question, dict) or not question.get("id"):
|
|
729
|
+
raise SessionUnavailable("answer: no brokered question request ID", short=real)
|
|
730
|
+
if request_id is None:
|
|
731
|
+
request_id = question["id"]
|
|
732
|
+
if not isinstance(request_id, str) or len(request_id) > 256:
|
|
733
|
+
raise InvalidRequest("answer: original request_id must be bounded")
|
|
734
|
+
fence = answer_request_fence(question, request_id)
|
|
735
|
+
if fence:
|
|
736
|
+
raise DeliveryRejected(
|
|
737
|
+
f"answer rejected: {fence} (expected {request_id!r}, "
|
|
738
|
+
f"pending {question.get('id')!r})",
|
|
739
|
+
short=real, reason=fence,
|
|
740
|
+
state=(rec.get("lifecycle") or {}).get("state"),
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
answers, err = question_answer_map(
|
|
744
|
+
(question.get("input") or {}).get("questions") or [],
|
|
745
|
+
text=text, options=options, text_answers=text_answers,
|
|
746
|
+
)
|
|
747
|
+
if err:
|
|
748
|
+
raise InvalidRequest(f"answer: {err}", short=real)
|
|
749
|
+
if not answers:
|
|
750
|
+
raise InvalidRequest("answer: answer text is empty", short=real)
|
|
751
|
+
|
|
752
|
+
if not host_rpc.host_socket_live(real, rec):
|
|
753
|
+
if not fallback_to_send:
|
|
754
|
+
raise SessionUnavailable(
|
|
755
|
+
"answer: original host unavailable; fallback_to_send=True "
|
|
756
|
+
"only when its owned process is confirmed dead", short=real,
|
|
757
|
+
)
|
|
758
|
+
self._require_dead_original_question_host(
|
|
759
|
+
real, rec, response_id=response_id,
|
|
760
|
+
)
|
|
761
|
+
return self.send(
|
|
762
|
+
real, question_answer_message(answers), wait=wait,
|
|
763
|
+
timeout=self._input_remaining(deadline), caps=None,
|
|
764
|
+
)
|
|
765
|
+
turn_fence = self._input_turn_fence(
|
|
766
|
+
real, rec, request_id, "question", deadline,
|
|
767
|
+
) if wait else None
|
|
768
|
+
msg = {"op": "answer-permission", "answers": answers, "request_id": request_id}
|
|
769
|
+
if response_id is not None:
|
|
770
|
+
msg["response_id"] = response_id
|
|
771
|
+
reply = _rpc(real, msg, timeout=self._input_remaining(deadline, 5.0))
|
|
772
|
+
_original_input_ack(reply, op="answer", worker_id=real,
|
|
773
|
+
request_id=request_id, response_id=response_id)
|
|
774
|
+
if turn_fence is not None:
|
|
775
|
+
return self._input_wait_reply(real, request_id, response_id,
|
|
776
|
+
*turn_fence, deadline)
|
|
777
|
+
return SendResult(short=real[:8], worker_id=real, engine="claude",
|
|
778
|
+
queued=True, state=S.BUSY)
|
|
779
|
+
|
|
780
|
+
def respond_elicitation(
|
|
781
|
+
self,
|
|
782
|
+
short: str,
|
|
783
|
+
action: str,
|
|
784
|
+
content: dict | None = None,
|
|
785
|
+
*,
|
|
786
|
+
request_id: str | None = None,
|
|
787
|
+
response_id: str | None = None,
|
|
788
|
+
wait: bool = False,
|
|
789
|
+
timeout: float | None = None,
|
|
790
|
+
) -> SendResult:
|
|
791
|
+
"""Resolve the exact original MCP elicitation, optionally wait for R.
|
|
792
|
+
|
|
793
|
+
Omitted request_id binds the current brokered elicitation once.
|
|
794
|
+
The response is schema-fenced; there is no new-message fallback.
|
|
795
|
+
"""
|
|
796
|
+
deadline = self._input_deadline(timeout)
|
|
797
|
+
if request_id is not None and (
|
|
798
|
+
not isinstance(request_id, str) or not request_id or len(request_id) > 256
|
|
799
|
+
):
|
|
800
|
+
raise InvalidRequest("respond: request_id must be a nonempty bounded string")
|
|
801
|
+
rec = self._resolve_managed(short, "respond")
|
|
802
|
+
real = rec["workerId"]
|
|
803
|
+
pending = rec.get("waitingFor")
|
|
804
|
+
illegality = elicitation_illegality(pending, real)
|
|
805
|
+
if illegality:
|
|
806
|
+
error_type = DeliveryRejected if pending_input_kind(pending) is None else SessionUnavailable
|
|
807
|
+
raise error_type(illegality, short=real)
|
|
808
|
+
if not isinstance(pending, dict) or not pending.get("id"):
|
|
809
|
+
raise SessionUnavailable("respond: no brokered elicitation request ID", short=real)
|
|
810
|
+
if request_id is None:
|
|
811
|
+
request_id = pending["id"]
|
|
812
|
+
if not isinstance(request_id, str) or len(request_id) > 256:
|
|
813
|
+
raise InvalidRequest("respond: original request_id must be bounded")
|
|
814
|
+
fence = answer_request_fence(pending, request_id)
|
|
815
|
+
if fence:
|
|
816
|
+
raise DeliveryRejected(
|
|
817
|
+
f"respond rejected: {fence} (expected {request_id!r}, "
|
|
818
|
+
f"pending {pending.get('id')!r})",
|
|
819
|
+
short=real, reason=fence,
|
|
820
|
+
state=(rec.get("lifecycle") or {}).get("state"),
|
|
821
|
+
)
|
|
822
|
+
problem = elicitation_response_problem(pending, action, content)
|
|
823
|
+
if problem:
|
|
824
|
+
raise InvalidRequest(f"respond: {problem}", short=real)
|
|
825
|
+
if not host_rpc.host_socket_live(real, rec):
|
|
826
|
+
raise SessionUnavailable(
|
|
827
|
+
f"respond: session host not alive "
|
|
828
|
+
f"(state={(rec.get('lifecycle') or {}).get('state')}); the MCP "
|
|
829
|
+
"request died with it and cannot be answered",
|
|
830
|
+
short=real,
|
|
831
|
+
)
|
|
832
|
+
|
|
833
|
+
turn_fence = self._input_turn_fence(
|
|
834
|
+
real, rec, request_id, "elicitation", deadline,
|
|
835
|
+
) if wait else None
|
|
836
|
+
msg: dict = {"op": "elicitation-response", "action": action,
|
|
837
|
+
"request_id": request_id}
|
|
838
|
+
if content is not None:
|
|
839
|
+
msg["content"] = content
|
|
840
|
+
if response_id is not None:
|
|
841
|
+
msg["response_id"] = response_id
|
|
842
|
+
reply = _rpc(real, msg, timeout=self._input_remaining(deadline, 5.0))
|
|
843
|
+
_original_input_ack(reply, op="respond", worker_id=real,
|
|
844
|
+
request_id=request_id, response_id=response_id)
|
|
845
|
+
if turn_fence is not None:
|
|
846
|
+
return self._input_wait_reply(real, request_id, response_id,
|
|
847
|
+
*turn_fence, deadline)
|
|
848
|
+
return SendResult(short=real[:8], worker_id=real, engine="claude",
|
|
849
|
+
queued=True, state=S.BUSY)
|
|
850
|
+
|
|
851
|
+
def approve(
|
|
852
|
+
self,
|
|
853
|
+
short: str,
|
|
854
|
+
*,
|
|
855
|
+
request_id: str | None = None,
|
|
856
|
+
reason: str | None = None,
|
|
857
|
+
wait: bool = False,
|
|
858
|
+
timeout: float | None = None,
|
|
859
|
+
response_id: str | None = None,
|
|
860
|
+
) -> SendResult:
|
|
861
|
+
"""Allow one original Claude permission, optionally await its turn.
|
|
862
|
+
|
|
863
|
+
The current brokered gate binds an omitted request_id once.
|
|
864
|
+
"""
|
|
865
|
+
return self._permission_decision(
|
|
866
|
+
short, "allow", reason=reason, request_id=request_id,
|
|
867
|
+
wait=wait, timeout=timeout, response_id=response_id,
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
def reject(
|
|
871
|
+
self,
|
|
872
|
+
short: str,
|
|
873
|
+
*,
|
|
874
|
+
request_id: str | None = None,
|
|
875
|
+
reason: str | None = None,
|
|
876
|
+
wait: bool = False,
|
|
877
|
+
timeout: float | None = None,
|
|
878
|
+
response_id: str | None = None,
|
|
879
|
+
) -> SendResult:
|
|
880
|
+
"""Deny one original Claude permission, optionally await its turn.
|
|
881
|
+
|
|
882
|
+
A reason is passed to the original host gate unchanged.
|
|
883
|
+
"""
|
|
884
|
+
return self._permission_decision(
|
|
885
|
+
short, "deny", reason=reason, request_id=request_id,
|
|
886
|
+
wait=wait, timeout=timeout, response_id=response_id,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
def _permission_decision(
|
|
890
|
+
self,
|
|
891
|
+
short: str,
|
|
892
|
+
decision: str,
|
|
893
|
+
*,
|
|
894
|
+
reason: str | None,
|
|
895
|
+
request_id: str | None,
|
|
896
|
+
wait: bool,
|
|
897
|
+
timeout: float | None,
|
|
898
|
+
response_id: str | None = None,
|
|
899
|
+
) -> SendResult:
|
|
900
|
+
"""Resolve exactly one native permission and optionally observe its turn."""
|
|
901
|
+
op = "approve" if decision == "allow" else "reject"
|
|
902
|
+
deadline = self._input_deadline(timeout)
|
|
903
|
+
if request_id is not None and (
|
|
904
|
+
not isinstance(request_id, str) or not request_id or len(request_id) > 256
|
|
905
|
+
):
|
|
906
|
+
raise InvalidRequest(f"{op}: request_id must be a nonempty bounded string")
|
|
907
|
+
rec = self._resolve_managed(short, op)
|
|
908
|
+
real = rec["workerId"]
|
|
909
|
+
# A decision against an AskUserQuestion or an MCP elicitation is the
|
|
910
|
+
# wrong verb; the host would reject it. Fail early with the same
|
|
911
|
+
# guidance the CLI prints.
|
|
912
|
+
wf = rec.get("waitingFor")
|
|
913
|
+
kind = pending_input_kind(wf)
|
|
914
|
+
if kind not in (None, KIND_PERMISSION):
|
|
915
|
+
what = ("an AskUserQuestion answer" if kind == "question"
|
|
916
|
+
else "an MCP elicitation response")
|
|
917
|
+
verb = "respond_elicitation" if kind == "elicitation" else wrong_verb_hint(kind)
|
|
918
|
+
raise SessionUnavailable(
|
|
919
|
+
f"session {real} is waiting for {what}; use {verb} instead",
|
|
920
|
+
short=real,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
if not isinstance(wf, dict) or not wf.get("id"):
|
|
924
|
+
raise DeliveryRejected(f"{op}: no brokered permission request", short=real)
|
|
925
|
+
if not host_rpc.host_socket_live(real, rec):
|
|
926
|
+
raise SessionUnavailable(
|
|
927
|
+
f"{op}: session host not alive (state={(rec.get('lifecycle') or {}).get('state')})",
|
|
928
|
+
short=real,
|
|
929
|
+
)
|
|
930
|
+
if request_id is None:
|
|
931
|
+
request_id = wf["id"]
|
|
932
|
+
if not isinstance(request_id, str) or len(request_id) > 256:
|
|
933
|
+
raise InvalidRequest(f"{op}: original request_id must be bounded")
|
|
934
|
+
fence = answer_request_fence(wf, request_id)
|
|
935
|
+
if fence:
|
|
936
|
+
raise DeliveryRejected(f"{op}: {fence}", short=real, reason=fence)
|
|
937
|
+
turn_fence = self._input_turn_fence(
|
|
938
|
+
real, rec, request_id, "permission", deadline,
|
|
939
|
+
) if wait else None
|
|
940
|
+
msg: dict = {"op": "permission-decision", "decision": decision,
|
|
941
|
+
"request_id": request_id}
|
|
942
|
+
if reason:
|
|
943
|
+
msg["reason"] = reason
|
|
944
|
+
if response_id is not None:
|
|
945
|
+
msg["response_id"] = response_id
|
|
946
|
+
reply = _rpc(real, msg, timeout=self._input_remaining(deadline, 5.0))
|
|
947
|
+
_original_input_ack(reply, op=op, worker_id=real,
|
|
948
|
+
request_id=request_id, response_id=response_id)
|
|
949
|
+
if turn_fence is not None:
|
|
950
|
+
return self._input_wait_reply(real, request_id, response_id,
|
|
951
|
+
*turn_fence, deadline)
|
|
952
|
+
return SendResult(short=real[:8], worker_id=real, engine="claude",
|
|
953
|
+
queued=True, state=S.BUSY)
|
|
954
|
+
|
|
955
|
+
def _steer_result(self, short: str, delivery_id: str, delivery: dict | None,
|
|
956
|
+
*, state: str | None, timed_out: bool) -> SteerResult:
|
|
957
|
+
owner = self._uuid_store.resolve(short)
|
|
958
|
+
value = delivery or {}
|
|
959
|
+
state_key = value.get("status")
|
|
960
|
+
placement = value.get("placement")
|
|
961
|
+
if state_key == "accepted":
|
|
962
|
+
status = ("consumed-current-turn" if placement == "current_turn"
|
|
963
|
+
else "started-next-turn" if placement == "new_turn"
|
|
964
|
+
else "unknown")
|
|
965
|
+
elif state_key == "pending" and value.get("owned"):
|
|
966
|
+
status = ("unknown" if value.get("evidence_status") == "unknown"
|
|
967
|
+
else "accepted-pending")
|
|
968
|
+
else:
|
|
969
|
+
status = state_key or "unknown"
|
|
970
|
+
owned = bool(value.get("owned"))
|
|
971
|
+
refusal = value.get("last_refusal") or {}
|
|
972
|
+
return SteerResult(
|
|
973
|
+
short=owner.short, worker_id=owner.worker_id, engine="claude",
|
|
974
|
+
delivery_id=value.get("delivery_id") or delivery_id,
|
|
975
|
+
accepted=owned, status=status,
|
|
976
|
+
timed_out=timed_out, state=state,
|
|
977
|
+
pending=owned and status == "accepted-pending",
|
|
978
|
+
evidence=value.get("evidence"),
|
|
979
|
+
evidence_uuid=value.get("evidence_uuid"),
|
|
980
|
+
attempts=value.get("attempts"),
|
|
981
|
+
reason=(value.get("rejection_message") or refusal.get("message")
|
|
982
|
+
or value.get("evidence_status")),
|
|
983
|
+
recommended_action=("wait" if timed_out else
|
|
984
|
+
value.get("recommended_action")),
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
def steer(
|
|
988
|
+
self,
|
|
989
|
+
short: str,
|
|
990
|
+
text: str,
|
|
991
|
+
*,
|
|
992
|
+
delivery_id: str | None = None,
|
|
993
|
+
wait: bool = True,
|
|
994
|
+
timeout: float | None = STEER_OBSERVE_DEFAULT_S,
|
|
995
|
+
caps: dict | None = None,
|
|
996
|
+
) -> SteerResult:
|
|
997
|
+
"""Steer a BUSY managed worker with a durably-delivered prompt.
|
|
998
|
+
|
|
999
|
+
This is deliberately distinct from :meth:`send` (which starts a strict
|
|
1000
|
+
new turn on an idle worker) and from :meth:`interrupt`: it aims a
|
|
1001
|
+
prompt at a worker that is already working, with exactly-once delivery.
|
|
1002
|
+
|
|
1003
|
+
The prompt and its transcript fence are journaled atomically before the
|
|
1004
|
+
host acknowledges ownership, so ``accepted=True`` means CCX owns the
|
|
1005
|
+
delivery even if this process dies immediately afterwards. Retrying
|
|
1006
|
+
with the same ``delivery_id`` reconciles the original delivery and can
|
|
1007
|
+
never paste the prompt a second time — that is the correct response to
|
|
1008
|
+
a lost RPC reply.
|
|
1009
|
+
|
|
1010
|
+
With ``wait=True`` the call observes for up to ``timeout`` seconds for
|
|
1011
|
+
stronger placement evidence. ``status`` speaks one vocabulary:
|
|
1012
|
+
``accepted-pending`` (durably owned, not visibly placed yet),
|
|
1013
|
+
``consumed-current-turn`` (the running turn saw it),
|
|
1014
|
+
``started-next-turn`` (it runs next), or ``unknown``. Observation
|
|
1015
|
+
elapsing is NOT a failure: the result has ``accepted=True``,
|
|
1016
|
+
``status="accepted-pending"``, ``timed_out=True``, ``pending=True`` and
|
|
1017
|
+
``recommended_action="wait"``. Never resend on that outcome; use
|
|
1018
|
+
:meth:`steer_status` (or ``ccx steer-status``) for placement.
|
|
1019
|
+
|
|
1020
|
+
``caps`` is a per-message Claude quota cap, exactly as in :meth:`send`.
|
|
1021
|
+
It travels with this delivery — it is journaled with the prompt and
|
|
1022
|
+
armed only for the turn the steered text joins — so it survives a host
|
|
1023
|
+
restart without authorizing a second paste.
|
|
1024
|
+
|
|
1025
|
+
Raises :class:`~ccx.errors.DeliveryRejected` if the host refuses the
|
|
1026
|
+
steer (for example the worker is idle, waiting, or compacting), and
|
|
1027
|
+
:class:`~ccx.errors.SteerIndeterminate` when durable acceptance cannot
|
|
1028
|
+
be established or an accepted delivery resolves to ``unknown``.
|
|
1029
|
+
"""
|
|
1030
|
+
from math import isfinite
|
|
1031
|
+
from uuid import uuid4
|
|
1032
|
+
from ccx.ops.send import send_turn
|
|
1033
|
+
from ccx.ops.wait import reconcile_turn
|
|
1034
|
+
from ccx.claude.delivery import ClaudeDeliveryConflict, valid_delivery_id
|
|
1035
|
+
from ccx.store.root import NativeEffectIndeterminate
|
|
1036
|
+
from .errors import OperationConflict
|
|
1037
|
+
|
|
1038
|
+
if timeout is not None and (
|
|
1039
|
+
type(timeout) not in (int, float) or not isfinite(timeout) or timeout < 0
|
|
1040
|
+
):
|
|
1041
|
+
raise InvalidRequest("steer observation timeout must be finite and nonnegative")
|
|
1042
|
+
if not isinstance(text, str) or not text.strip():
|
|
1043
|
+
raise InvalidRequest("steer: prompt text is required")
|
|
1044
|
+
violation = commands_turns._send_payload_violation(text)
|
|
1045
|
+
if violation is not None:
|
|
1046
|
+
size, limit = violation
|
|
1047
|
+
raise PayloadTooLarge(
|
|
1048
|
+
payload_guidance(short, size, limit), short=short,
|
|
1049
|
+
size=size, limit=limit,
|
|
1050
|
+
)
|
|
1051
|
+
token = delivery_id if delivery_id is not None else str(uuid4())
|
|
1052
|
+
if not valid_delivery_id(token):
|
|
1053
|
+
raise InvalidRequest("steer requires a nonempty delivery_id of at most 128 characters")
|
|
1054
|
+
rec = self._resolve_managed(short, "steer")
|
|
1055
|
+
worker_id = rec["workerId"]
|
|
1056
|
+
handle = f"claude:{worker_id}"
|
|
1057
|
+
try:
|
|
1058
|
+
first = send_turn(
|
|
1059
|
+
handle, text, delivery_id=token, mode="steer_only", cap=caps,
|
|
1060
|
+
expected_generation=self._uuid_store.generation,
|
|
1061
|
+
)
|
|
1062
|
+
except ClaudeDeliveryConflict as exc:
|
|
1063
|
+
raise OperationConflict(str(exc), short=worker_id[:8]) from exc
|
|
1064
|
+
except NativeEffectIndeterminate as exc:
|
|
1065
|
+
raise SteerIndeterminate(
|
|
1066
|
+
f"steer native effect uncertain; reconcile original delivery {token}: {exc}",
|
|
1067
|
+
short=worker_id[:8], delivery_id=token,
|
|
1068
|
+
delivery_status="unknown",
|
|
1069
|
+
) from exc
|
|
1070
|
+
|
|
1071
|
+
def project(original: dict, *, timed_out: bool) -> SteerResult:
|
|
1072
|
+
delivery = original.get("delivery") or {}
|
|
1073
|
+
lifecycle = self._uuid_store.record(worker_id).get("lifecycle") or {}
|
|
1074
|
+
value = self._steer_result(
|
|
1075
|
+
handle, token, delivery,
|
|
1076
|
+
state=lifecycle.get("state"), timed_out=timed_out,
|
|
1077
|
+
)
|
|
1078
|
+
if value.status == "rejected":
|
|
1079
|
+
raise DeliveryRejected(
|
|
1080
|
+
delivery.get("rejection_message") or "Claude refused steer",
|
|
1081
|
+
short=worker_id[:8], delivery_id=token,
|
|
1082
|
+
delivery_status="rejected",
|
|
1083
|
+
refusal=delivery.get("rejection_code"),
|
|
1084
|
+
state=value.state,
|
|
1085
|
+
)
|
|
1086
|
+
if not value.accepted:
|
|
1087
|
+
raise SteerIndeterminate(
|
|
1088
|
+
f"steer {token} lacks durable native ownership",
|
|
1089
|
+
short=worker_id[:8], delivery_id=token,
|
|
1090
|
+
delivery_status=value.status,
|
|
1091
|
+
)
|
|
1092
|
+
if value.status == "unknown":
|
|
1093
|
+
raise SteerIndeterminate(
|
|
1094
|
+
f"steer {token} placement remains unknown; do not re-send",
|
|
1095
|
+
short=worker_id[:8], delivery_id=token,
|
|
1096
|
+
delivery_status="unknown",
|
|
1097
|
+
)
|
|
1098
|
+
return value
|
|
1099
|
+
|
|
1100
|
+
if wait is False:
|
|
1101
|
+
return project(first, timed_out=False)
|
|
1102
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
1103
|
+
while True:
|
|
1104
|
+
self._uuid_store.revalidate()
|
|
1105
|
+
observed = reconcile_turn(
|
|
1106
|
+
handle, delivery_id=token, include_worker=False,
|
|
1107
|
+
expected_generation=self._uuid_store.generation,
|
|
1108
|
+
)
|
|
1109
|
+
item = project(observed, timed_out=False)
|
|
1110
|
+
if not item.pending:
|
|
1111
|
+
return item
|
|
1112
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1113
|
+
return project(observed, timed_out=True)
|
|
1114
|
+
time.sleep(STEER_OBSERVE_POLL_S if deadline is None else
|
|
1115
|
+
min(STEER_OBSERVE_POLL_S, max(0, deadline - time.monotonic())))
|
|
1116
|
+
|
|
1117
|
+
def steer_status(self, short: str, delivery_id: str) -> SteerResult:
|
|
1118
|
+
"""Reconcile one durable steering delivery without sending anything."""
|
|
1119
|
+
from ccx.ops.wait import reconcile_turn
|
|
1120
|
+
from ccx.claude.delivery import read_receipt, valid_delivery_id
|
|
1121
|
+
if not valid_delivery_id(delivery_id):
|
|
1122
|
+
raise InvalidRequest("steer status requires a valid delivery_id")
|
|
1123
|
+
rec = self._resolve_managed(short, "steer status")
|
|
1124
|
+
worker_id = rec["workerId"]
|
|
1125
|
+
handle = f"claude:{worker_id}"
|
|
1126
|
+
original = read_receipt(handle, delivery_id)
|
|
1127
|
+
if original is None or original.get("requestedMode") != "steer_only":
|
|
1128
|
+
raise SessionNotFound(
|
|
1129
|
+
f"no exact Claude steer delivery {delivery_id!r} for this worker",
|
|
1130
|
+
short=worker_id[:8],
|
|
1131
|
+
)
|
|
1132
|
+
probe = reconcile_turn(
|
|
1133
|
+
handle, delivery_id=delivery_id, include_worker=False,
|
|
1134
|
+
expected_generation=self._uuid_store.generation,
|
|
1135
|
+
)
|
|
1136
|
+
current = self._uuid_store.record(worker_id).get("lifecycle") or {}
|
|
1137
|
+
return self._steer_result(
|
|
1138
|
+
handle, delivery_id, probe.get("delivery"),
|
|
1139
|
+
state=current.get("state"), timed_out=False,
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
def compact(
|
|
1143
|
+
self,
|
|
1144
|
+
short: str,
|
|
1145
|
+
*,
|
|
1146
|
+
wait: bool = True,
|
|
1147
|
+
timeout: float | None = COMPACT_WAIT_DEFAULT_S,
|
|
1148
|
+
operation_id: str | None = None,
|
|
1149
|
+
execution_timeout: float | None = None,
|
|
1150
|
+
) -> CompactOperationResult:
|
|
1151
|
+
"""Compact one exact original Claude worker, with native terminal proof.
|
|
1152
|
+
|
|
1153
|
+
The caller's observation budget never changes the immutable native
|
|
1154
|
+
effect identity; retry the same operation_id to reconcile, not resend.
|
|
1155
|
+
"""
|
|
1156
|
+
from ccx.ops.compact import compact_worker
|
|
1157
|
+
|
|
1158
|
+
rec = self._resolve_managed(short, "compact")
|
|
1159
|
+
return CompactOperationResult(**compact_worker(
|
|
1160
|
+
f"claude:{rec['workerId']}",
|
|
1161
|
+
operation_id=operation_id if operation_id is not None
|
|
1162
|
+
else new_compact_operation_id(),
|
|
1163
|
+
wait=wait, timeout=timeout,
|
|
1164
|
+
execution_timeout=execution_timeout,
|
|
1165
|
+
))
|
|
1166
|
+
|
|
1167
|
+
def compact_status(self, short: str, operation_id: str) -> CompactOperationResult:
|
|
1168
|
+
"""Inspect one original compact journal without native submission/revival."""
|
|
1169
|
+
from ccx.ops.compact import compact_status
|
|
1170
|
+
|
|
1171
|
+
rec = self._resolve_managed(short, "compact status")
|
|
1172
|
+
return CompactOperationResult(**compact_status(
|
|
1173
|
+
f"claude:{rec['workerId']}", operation_id=operation_id,
|
|
1174
|
+
))
|
|
1175
|
+
|
|
1176
|
+
def kill(self, short: str, *,
|
|
1177
|
+
timeout: float | None = KILL_TIMEOUT_DEFAULT_S) -> SessionInfo:
|
|
1178
|
+
"""Confirm exact owned Claude host and native child are gone.
|
|
1179
|
+
|
|
1180
|
+
Durable session history is retained; automatic full-UUID resume is
|
|
1181
|
+
currently unavailable. An unproven teardown raises, never succeeds.
|
|
1182
|
+
"""
|
|
1183
|
+
from ccx.ops.wait import park_worker
|
|
1184
|
+
from ccx.store.identity import IdentityError, UnknownWorker
|
|
1185
|
+
try:
|
|
1186
|
+
owner = self._uuid_store.resolve(short)
|
|
1187
|
+
except UnknownWorker as exc:
|
|
1188
|
+
raise worker_not_found(short) from exc
|
|
1189
|
+
except IdentityError as exc:
|
|
1190
|
+
raise InvalidRequest(f"kill: invalid full-UUID worker target: {exc}") from exc
|
|
1191
|
+
if owner.engine != "claude":
|
|
1192
|
+
raise InvalidRequest("kill requires a Claude full-UUID owner")
|
|
1193
|
+
park_worker(owner.worker, timeout=timeout,
|
|
1194
|
+
expected_generation=self._uuid_store.generation)
|
|
1195
|
+
return self._uuid_session_info(owner, self._uuid_store.record(owner.worker))
|
|
1196
|
+
|
|
1197
|
+
def interrupt(
|
|
1198
|
+
self,
|
|
1199
|
+
short: str,
|
|
1200
|
+
*,
|
|
1201
|
+
expected_turn_ref: str | None = None,
|
|
1202
|
+
) -> dict:
|
|
1203
|
+
"""Request cancellation of one exact active full-UUID Claude turn.
|
|
1204
|
+
|
|
1205
|
+
A successful return proves native acceptance, NOT interruption.
|
|
1206
|
+
``confirmed`` and ``outcome`` expose separately observed terminality.
|
|
1207
|
+
"""
|
|
1208
|
+
from ccx.ops.cancel import stop_worker
|
|
1209
|
+
rec = self._resolve_managed(short, "interrupt")
|
|
1210
|
+
result = stop_worker(
|
|
1211
|
+
f"claude:{rec['workerId']}", expected_turn_ref=expected_turn_ref,
|
|
1212
|
+
expected_generation=self._uuid_store.generation,
|
|
1213
|
+
)
|
|
1214
|
+
return {
|
|
1215
|
+
**result, "ok": True, "short": rec["workerId"][:8],
|
|
1216
|
+
"sent": True,
|
|
1217
|
+
"cancelled": (result["confirmed"] and result["outcome"] == "interrupted"),
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
def set_model(self, short: str, model: str | None, *, restart: bool = False) -> SessionInfo:
|
|
1221
|
+
"""Edit the next Claude spawn model, never an already running turn.
|
|
1222
|
+
|
|
1223
|
+
Plain edits require no warm owned host or native child. An explicit
|
|
1224
|
+
restart requires a proven settled-idle original and confirms teardown.
|
|
1225
|
+
A later send/resume reuses the original session with the saved model.
|
|
1226
|
+
"""
|
|
1227
|
+
info, _restarted = self._set_spawn_config(
|
|
1228
|
+
short, {"model": model}, restart=restart,
|
|
1229
|
+
)
|
|
1230
|
+
return info
|
|
1231
|
+
|
|
1232
|
+
def set_effort(self, short: str, effort: str | None, *, restart: bool = False) -> SessionInfo:
|
|
1233
|
+
"""Edit future Claude spawn effort (low/medium/high/xhigh/max).
|
|
1234
|
+
|
|
1235
|
+
Changing effort invalidates the model prompt cache on the next turn.
|
|
1236
|
+
None removes the override; no current native turn is reconfigured.
|
|
1237
|
+
"""
|
|
1238
|
+
info, _restarted = self._set_spawn_config(
|
|
1239
|
+
short, {"effort": effort}, restart=restart,
|
|
1240
|
+
)
|
|
1241
|
+
return info
|
|
1242
|
+
|
|
1243
|
+
def _set_spawn_config(
|
|
1244
|
+
self, target: str, updates: dict[str, str | None], *,
|
|
1245
|
+
restart: bool | None, timeout: float = KILL_TIMEOUT_DEFAULT_S,
|
|
1246
|
+
) -> tuple[SessionInfo, bool]:
|
|
1247
|
+
"""The ONE original full-UUID Claude spawn-config admission and CAS.
|
|
1248
|
+
|
|
1249
|
+
restart=None means WorkerClient's settled-IDLE convenience selection;
|
|
1250
|
+
explicit public setters keep their existing restart=False signature.
|
|
1251
|
+
Only authenticated read-only host probes are used for admission, never
|
|
1252
|
+
ensure_ready or an unproven short-record status projection.
|
|
1253
|
+
"""
|
|
1254
|
+
from math import isfinite
|
|
1255
|
+
from ccx.store.liveness import owned_process
|
|
1256
|
+
from ccx.store.root import GenerationError, RecordConflict
|
|
1257
|
+
from ccx.ops.terminate import confirmed_kill_uuid
|
|
1258
|
+
|
|
1259
|
+
if not updates or set(updates) - {"model", "effort", "permissions"}:
|
|
1260
|
+
raise InvalidRequest("Claude configuration requires model, effort and/or permissions")
|
|
1261
|
+
validated: dict[str, str | bool | None] = {}
|
|
1262
|
+
for field, value in updates.items():
|
|
1263
|
+
if field == "permissions":
|
|
1264
|
+
if type(value) is not str or value not in ("full", "restricted"):
|
|
1265
|
+
raise InvalidRequest("Claude permissions must be full or restricted")
|
|
1266
|
+
validated["skip_permissions"] = value == "full"
|
|
1267
|
+
validated["permission_mode"] = None
|
|
1268
|
+
if value == "full":
|
|
1269
|
+
# Full cannot retain a hidden explicit tool denial.
|
|
1270
|
+
validated["disallowed_tools"] = None
|
|
1271
|
+
continue
|
|
1272
|
+
if value is not None and (not isinstance(value, str) or not value.strip()):
|
|
1273
|
+
raise InvalidRequest(f"{field} must be nonblank text or None")
|
|
1274
|
+
normalized = value.strip() if isinstance(value, str) else None
|
|
1275
|
+
if field == "effort" and normalized not in (
|
|
1276
|
+
None, "low", "medium", "high", "xhigh", "max"
|
|
1277
|
+
):
|
|
1278
|
+
raise InvalidRequest("Claude effort must be low/medium/high/xhigh/max or None")
|
|
1279
|
+
validated[field] = normalized
|
|
1280
|
+
if (type(timeout) not in (int, float)
|
|
1281
|
+
or not isfinite(timeout) or timeout <= 0):
|
|
1282
|
+
raise InvalidRequest("Claude config timeout must be positive and finite")
|
|
1283
|
+
if restart not in (True, False, None):
|
|
1284
|
+
raise InvalidRequest("Claude config restart must be true, false, or automatic")
|
|
1285
|
+
deadline = time.monotonic() + timeout
|
|
1286
|
+
rec = self._resolve_managed(target, "set config")
|
|
1287
|
+
owner = self._uuid_store.resolve(f"claude:{rec['workerId']}")
|
|
1288
|
+
handle = owner.worker
|
|
1289
|
+
native = rec.get("native") or {}
|
|
1290
|
+
session_id = native.get("sessionId")
|
|
1291
|
+
if not isinstance(session_id, str) or not session_id:
|
|
1292
|
+
raise SessionUnavailable("Claude session is missing; inspect the worker", short=owner.short)
|
|
1293
|
+
|
|
1294
|
+
def remaining() -> float:
|
|
1295
|
+
left = deadline - time.monotonic()
|
|
1296
|
+
if left <= 0:
|
|
1297
|
+
raise SessionUnavailable("Claude config observation deadline expired", short=owner.short)
|
|
1298
|
+
return left
|
|
1299
|
+
|
|
1300
|
+
# _rpc verifies the original generation, peer credentials and host
|
|
1301
|
+
# process START token on its actual socket, before any status request.
|
|
1302
|
+
attested = _rpc(handle, {"op": "startup-status"}, timeout=min(.5, remaining()))
|
|
1303
|
+
self._uuid_store.revalidate()
|
|
1304
|
+
current = self._uuid_store.record(handle)
|
|
1305
|
+
lc = current.get("lifecycle") or {}
|
|
1306
|
+
|
|
1307
|
+
host_presence = owned_process(lc.get("hostPid"), lc.get("hostStart"))
|
|
1308
|
+
child_presence = owned_process(lc.get("workerPid"), lc.get("workerStart"))
|
|
1309
|
+
warm = False
|
|
1310
|
+
if attested.get("ok") is True:
|
|
1311
|
+
proof = lc.get("nativeStartProof")
|
|
1312
|
+
if (not isinstance(proof, dict)
|
|
1313
|
+
or attested.get("workerId") != owner.worker_id
|
|
1314
|
+
or attested.get("engine") != "claude"
|
|
1315
|
+
or attested.get("generation") != self._uuid_store.generation
|
|
1316
|
+
or attested.get("nativeStartProof") != proof
|
|
1317
|
+
or proof.get("sessionId") != session_id
|
|
1318
|
+
or any(proof.get(field) != lc.get(field) for field in (
|
|
1319
|
+
"hostPid", "hostStart", "workerPid", "workerStart"
|
|
1320
|
+
))
|
|
1321
|
+
or host_presence != "alive" or child_presence != "alive"
|
|
1322
|
+
or not lc.get("hostStart") or not lc.get("workerStart")):
|
|
1323
|
+
raise SessionUnavailable(
|
|
1324
|
+
"Claude process could not be verified; check its status before changing settings",
|
|
1325
|
+
short=owner.short,
|
|
1326
|
+
)
|
|
1327
|
+
status = _rpc(handle, {"op": "status"}, timeout=min(.5, remaining()))
|
|
1328
|
+
if status.get("ok") is not True:
|
|
1329
|
+
raise SessionUnavailable(
|
|
1330
|
+
"Claude status is unavailable; check it before changing settings",
|
|
1331
|
+
short=owner.short,
|
|
1332
|
+
)
|
|
1333
|
+
# Refuse every potentially active, queued or undecidable control:
|
|
1334
|
+
# no native model swap may land while any of these remain.
|
|
1335
|
+
if (status.get("state") != "idle" or lc.get("state") != S.IDLE
|
|
1336
|
+
or status.get("activeTurnRef") is not None
|
|
1337
|
+
or status.get("pendingDeliveries") != []
|
|
1338
|
+
or status.get("queuedInputs") != 0
|
|
1339
|
+
or status.get("waitingFor") is not None
|
|
1340
|
+
or status.get("compacting") is not False
|
|
1341
|
+
or (status.get("compactOperation") is not None
|
|
1342
|
+
and (not isinstance(status["compactOperation"], dict)
|
|
1343
|
+
or status["compactOperation"].get("status")
|
|
1344
|
+
not in {"succeeded", "noop", "failed"}))
|
|
1345
|
+
or status.get("steerJournalError")
|
|
1346
|
+
or status.get("compactJournalError")):
|
|
1347
|
+
raise DeliveryRejected(
|
|
1348
|
+
"Claude is busy or waiting for input; finish the turn before changing settings.",
|
|
1349
|
+
short=owner.short, state=status.get("state"), refusal="busy",
|
|
1350
|
+
)
|
|
1351
|
+
warm = True
|
|
1352
|
+
else:
|
|
1353
|
+
# A timeout, an unverified socket peer, or a live owned PID is
|
|
1354
|
+
# NOT proof of absence. Even a physically missing socket must
|
|
1355
|
+
# agree with both original host AND child process being gone.
|
|
1356
|
+
error = attested.get("error", "")
|
|
1357
|
+
if (not isinstance(error, str) or not error.startswith("unreachable:")
|
|
1358
|
+
or host_presence != "dead" or child_presence != "dead"
|
|
1359
|
+
or lc.get("state") in (S.BOOTING, S.RESUMING, S.BUSY, S.WAITING)):
|
|
1360
|
+
if lc.get("state") in (S.BOOTING, S.RESUMING, S.BUSY, S.WAITING):
|
|
1361
|
+
raise DeliveryRejected(
|
|
1362
|
+
"Claude is starting or busy; finish the turn before changing settings.",
|
|
1363
|
+
short=owner.short, state=lc.get("state"), refusal="busy",
|
|
1364
|
+
)
|
|
1365
|
+
raise SessionUnavailable(
|
|
1366
|
+
"Cannot verify whether Claude is running; check its status and retry.",
|
|
1367
|
+
short=owner.short,
|
|
1368
|
+
)
|
|
1369
|
+
if restart is False and warm:
|
|
1370
|
+
raise DeliveryRejected(
|
|
1371
|
+
"Claude is running; use --restart while idle or stop it first.",
|
|
1372
|
+
short=owner.short, state=lc.get("state"), refusal="busy",
|
|
1373
|
+
)
|
|
1374
|
+
restart_now = warm and restart is not False
|
|
1375
|
+
observed = {
|
|
1376
|
+
"lifecycle.state": lc.get("state"),
|
|
1377
|
+
"lifecycle.hostPid": lc.get("hostPid"),
|
|
1378
|
+
"lifecycle.hostStart": lc.get("hostStart"),
|
|
1379
|
+
"lifecycle.workerPid": lc.get("workerPid"),
|
|
1380
|
+
"lifecycle.workerStart": lc.get("workerStart"),
|
|
1381
|
+
"lifecycle.nativeStartProof": lc.get("nativeStartProof"),
|
|
1382
|
+
}
|
|
1383
|
+
try:
|
|
1384
|
+
patched = self._uuid_store.mutate_record(
|
|
1385
|
+
handle, patches={f"createArgs.{field}": value
|
|
1386
|
+
for field, value in validated.items()},
|
|
1387
|
+
expect=observed,
|
|
1388
|
+
)
|
|
1389
|
+
except (RecordConflict, GenerationError) as exc:
|
|
1390
|
+
raise SessionUnavailable(
|
|
1391
|
+
"Claude changed while saving; retry settings when idle",
|
|
1392
|
+
short=owner.short,
|
|
1393
|
+
) from exc
|
|
1394
|
+
if not restart_now:
|
|
1395
|
+
return self._uuid_session_info(owner, patched), False
|
|
1396
|
+
|
|
1397
|
+
# The config is already durably saved. A failing/unconfirmed teardown
|
|
1398
|
+
# is NOT success or a reason to undo the CAS; the old host may still
|
|
1399
|
+
# run old settings. The caller may inspect/stop it separately.
|
|
1400
|
+
try:
|
|
1401
|
+
stopped = confirmed_kill_uuid(
|
|
1402
|
+
self._uuid_store, handle, timeout=remaining(),
|
|
1403
|
+
)
|
|
1404
|
+
if not stopped.get("killed"):
|
|
1405
|
+
raise SessionUnavailable("Claude did not confirm stopping; check its status")
|
|
1406
|
+
except Exception as exc:
|
|
1407
|
+
raise SessionUnavailable(
|
|
1408
|
+
"Settings were saved, but Claude may still be running; "
|
|
1409
|
+
"the updated model/effort/permissions are for a later spawn only",
|
|
1410
|
+
short=owner.short,
|
|
1411
|
+
) from exc
|
|
1412
|
+
self._uuid_store.revalidate()
|
|
1413
|
+
return self._uuid_session_info(owner, self._uuid_store.record(handle)), True
|
|
1414
|
+
|
|
1415
|
+
def set_name(self, short: str, name: str | None) -> SessionInfo:
|
|
1416
|
+
"""Rename exact managed full UUID using fresh-record semantic CAS."""
|
|
1417
|
+
from ccx.store.identity import IdentityError
|
|
1418
|
+
from ccx.store.root import GenerationError
|
|
1419
|
+
from ccx.store.identity import UnknownWorker
|
|
1420
|
+
try:
|
|
1421
|
+
owner = self._uuid_store.resolve(short)
|
|
1422
|
+
if owner.engine != "claude":
|
|
1423
|
+
raise InvalidRequest("CcxClient.set_name requires a Claude full-UUID worker")
|
|
1424
|
+
updated = self._uuid_store.rename_worker(
|
|
1425
|
+
owner.worker, engine="claude", name=name,
|
|
1426
|
+
)
|
|
1427
|
+
return self._uuid_session_info(owner, updated)
|
|
1428
|
+
except UnknownWorker as exc:
|
|
1429
|
+
raise worker_not_found(short) from exc
|
|
1430
|
+
except IdentityError as exc:
|
|
1431
|
+
raise InvalidRequest(f"Invalid Claude worker handle: {exc}") from exc
|
|
1432
|
+
except GenerationError as exc:
|
|
1433
|
+
raise SessionUnavailable(f"Worker records changed; reopen CCX: {exc}") from exc
|
|
1434
|
+
|
|
1435
|
+
# -- read paths ---------------------------------------------------------
|
|
1436
|
+
|
|
1437
|
+
def peek(self, short: str, *, live: bool = False) -> SessionStatus:
|
|
1438
|
+
"""Recorded-only forensic status; native/liveness queries are fenced."""
|
|
1439
|
+
from ccx.store.identity import UnknownWorker, IdentityError
|
|
1440
|
+
from ccx.store.root import GenerationError
|
|
1441
|
+
if live:
|
|
1442
|
+
raise SessionUnavailable(
|
|
1443
|
+
"CcxClient.peek(live=True) requires full-UUID host adoption"
|
|
1444
|
+
)
|
|
1445
|
+
try:
|
|
1446
|
+
owner = self._uuid_store.resolve(short)
|
|
1447
|
+
if owner.engine != "claude":
|
|
1448
|
+
raise InvalidRequest("CcxClient.peek requires a Claude full-UUID target")
|
|
1449
|
+
rec = self._uuid_store.record(owner.worker)
|
|
1450
|
+
lc = rec.get("lifecycle")
|
|
1451
|
+
lc = lc if isinstance(lc, dict) else {}
|
|
1452
|
+
stats = rec.get("stats")
|
|
1453
|
+
stats = stats if isinstance(stats, dict) else {}
|
|
1454
|
+
import time as _time
|
|
1455
|
+
last = rec.get("lastTurnAt")
|
|
1456
|
+
age = max(0,int(_time.time()-last/1000)) if type(last) is int and last>0 else None
|
|
1457
|
+
return SessionStatus(
|
|
1458
|
+
short=owner.short, worker_id=owner.worker_id,
|
|
1459
|
+
engine="claude", state=lc.get("state"),
|
|
1460
|
+
turn=stats.get("turns"), last_turn_age_s=age,
|
|
1461
|
+
host_status="unobserved",
|
|
1462
|
+
mode="managed",
|
|
1463
|
+
waiting_for=PendingInput.from_waiting_for(rec.get("waitingFor")),
|
|
1464
|
+
context=commands_read._status_context_for_record(owner.worker, rec),
|
|
1465
|
+
background_task_summary=rec.get("backgroundTaskSummary"),
|
|
1466
|
+
)
|
|
1467
|
+
except UnknownWorker as exc:
|
|
1468
|
+
raise worker_not_found(short) from exc
|
|
1469
|
+
except IdentityError as exc:
|
|
1470
|
+
raise InvalidRequest(f"invalid Claude full-UUID target: {exc}") from exc
|
|
1471
|
+
except GenerationError as exc:
|
|
1472
|
+
raise SessionUnavailable(f"Worker record unavailable; inspect worker status: {exc}") from exc
|
|
1473
|
+
|
|
1474
|
+
def status(self, shorts: list[str], *, live: bool = False) -> list[SessionStatus]:
|
|
1475
|
+
"""Batch exact-owned forensic status; never native host polling."""
|
|
1476
|
+
if live:
|
|
1477
|
+
raise SessionUnavailable("Live batch status is not supported; use recorded status")
|
|
1478
|
+
return [self.peek(value, live=False) for value in shorts]
|
|
1479
|
+
|
|
1480
|
+
def report(
|
|
1481
|
+
self, short, *, max_chars: int | None = None,
|
|
1482
|
+
live: bool = False,
|
|
1483
|
+
) -> Report | dict:
|
|
1484
|
+
"""Read Claude's latest turn reply; note an earlier confirmed reply if busy.
|
|
1485
|
+
|
|
1486
|
+
A list of targets returns ``{"reports": [...], "errors": {...}}``.
|
|
1487
|
+
|
|
1488
|
+
Turns without a final answer have an empty text and has_final_message=False.
|
|
1489
|
+
The report's note describes a newer active turn or a failed worker.
|
|
1490
|
+
"""
|
|
1491
|
+
from ccx.ops.report import report_worker
|
|
1492
|
+
from ccx.store.root import GenerationError
|
|
1493
|
+
if live:
|
|
1494
|
+
raise SessionUnavailable(
|
|
1495
|
+
"Only CCX-managed Claude workers are supported; use a worker handle"
|
|
1496
|
+
)
|
|
1497
|
+
if max_chars is not None and (type(max_chars) is not int or max_chars<0):
|
|
1498
|
+
raise InvalidRequest("max_chars must be nonnegative integer")
|
|
1499
|
+
if isinstance(short,(list,tuple)):
|
|
1500
|
+
from .errors import CcxError
|
|
1501
|
+
out=[]
|
|
1502
|
+
errors={}
|
|
1503
|
+
for target in short:
|
|
1504
|
+
try:
|
|
1505
|
+
out.append(self.report(target,max_chars=max_chars))
|
|
1506
|
+
except CcxError as exc:
|
|
1507
|
+
errors[str(target)] = {"error": type(exc).__name__,
|
|
1508
|
+
"message": str(exc), "exit_code": exc.exit_code}
|
|
1509
|
+
return {"reports": out, "errors": errors}
|
|
1510
|
+
rec=self._resolve_managed(short,"report")
|
|
1511
|
+
wid=rec["workerId"]
|
|
1512
|
+
try:
|
|
1513
|
+
native=report_worker(f"claude:{wid}",max_chars=max_chars)
|
|
1514
|
+
except GenerationError as exc:
|
|
1515
|
+
raise SessionUnavailable(
|
|
1516
|
+
f"Claude report unavailable; inspect the worker: {exc}"
|
|
1517
|
+
) from exc
|
|
1518
|
+
return Report(
|
|
1519
|
+
short=wid[:8],worker_id=wid,engine="claude",turn=None,
|
|
1520
|
+
text=native["text"],turn_ref=native.get("turn_ref"),
|
|
1521
|
+
delivery_id=native.get("delivery_id"),transcript=None,
|
|
1522
|
+
note=native.get("note"),
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
def context(self, short: str, *, live: bool = False) -> ClaudeContext:
|
|
1526
|
+
"""Recorded carried context for one owned full UUID.
|
|
1527
|
+
|
|
1528
|
+
`short` is a legacy parameter name, not a short-key target.
|
|
1529
|
+
`live=True` and external discovery are refused. No host or
|
|
1530
|
+
transcript rendering is performed; absent evidence is no-signal.
|
|
1531
|
+
"""
|
|
1532
|
+
from ccx.claude.context import read_claude_observation
|
|
1533
|
+
|
|
1534
|
+
rec = self._resolve_record(short, live=live)
|
|
1535
|
+
owner = self._uuid_store.resolve(rec["workerId"])
|
|
1536
|
+
observed = read_claude_observation(self._uuid_store, owner, rec)
|
|
1537
|
+
return ClaudeContext.from_record(
|
|
1538
|
+
rec, observed.get("context") if observed else None,
|
|
1539
|
+
note=observed.get("note") if observed else None,
|
|
1540
|
+
)
|
|
1541
|
+
|
|
1542
|
+
def tasks(self, short: str, *, live: bool = False) -> list[ClaudeTask]:
|
|
1543
|
+
"""Read this owned Claude worker's native TaskCreate/TaskUpdate list.
|
|
1544
|
+
|
|
1545
|
+
Task files belong to the distinct original native session UUID, never
|
|
1546
|
+
to the public full worker UUID or its eight-character display prefix.
|
|
1547
|
+
"""
|
|
1548
|
+
from ccx.claude.tasks import read_task_list
|
|
1549
|
+
from ccx.store.root import GenerationError
|
|
1550
|
+
from ccx.store.identity import InvalidTarget, canonical_worker_id
|
|
1551
|
+
|
|
1552
|
+
if live:
|
|
1553
|
+
raise SessionUnavailable("tasks(live=True): external/native Claude discovery unavailable")
|
|
1554
|
+
rec = self._resolve_managed(short, "tasks")
|
|
1555
|
+
try:
|
|
1556
|
+
owner = self._uuid_store.resolve("claude:" + rec["workerId"])
|
|
1557
|
+
native = rec.get("native")
|
|
1558
|
+
sid = native.get("sessionId") if isinstance(native, dict) else None
|
|
1559
|
+
try:
|
|
1560
|
+
canonical_worker_id(sid)
|
|
1561
|
+
except InvalidTarget as exc:
|
|
1562
|
+
raise SessionUnavailable(
|
|
1563
|
+
"tasks: original Claude native session ID is unproven",
|
|
1564
|
+
short=owner.short,
|
|
1565
|
+
) from exc
|
|
1566
|
+
fingerprint = rec.get("createSha256")
|
|
1567
|
+
if not isinstance(fingerprint, str) or not fingerprint:
|
|
1568
|
+
raise SessionUnavailable(
|
|
1569
|
+
"tasks: original Claude create identity is unavailable",
|
|
1570
|
+
short=owner.short,
|
|
1571
|
+
)
|
|
1572
|
+
bound = self._uuid_store.creation_binding(
|
|
1573
|
+
owner.worker, engine="claude", fingerprint=fingerprint,
|
|
1574
|
+
)
|
|
1575
|
+
if not isinstance(bound, dict) or bound.get("sessionId") != sid:
|
|
1576
|
+
raise SessionUnavailable(
|
|
1577
|
+
"tasks: native session differs from original Claude creation",
|
|
1578
|
+
short=owner.short,
|
|
1579
|
+
)
|
|
1580
|
+
tasks = read_task_list(sid)
|
|
1581
|
+
self._uuid_store.revalidate()
|
|
1582
|
+
return tasks
|
|
1583
|
+
except GenerationError as exc:
|
|
1584
|
+
raise SessionUnavailable(f"tasks: original Claude source unavailable: {exc}") from exc
|
|
1585
|
+
|
|
1586
|
+
def _uuid_background_task_facts(self, short: str) -> dict:
|
|
1587
|
+
"""Resolve an exact original Claude owner for the shared read."""
|
|
1588
|
+
from ccx.store.root import GenerationError
|
|
1589
|
+
|
|
1590
|
+
rec = self._resolve_managed(short, "background_tasks")
|
|
1591
|
+
try:
|
|
1592
|
+
owner = self._uuid_store.resolve("claude:" + rec["workerId"])
|
|
1593
|
+
except GenerationError as exc:
|
|
1594
|
+
raise SessionUnavailable(
|
|
1595
|
+
f"background_tasks: original Claude owner unavailable: {exc}",
|
|
1596
|
+
) from exc
|
|
1597
|
+
return self._uuid_background_task_facts_owned(self._uuid_store, owner, rec)
|
|
1598
|
+
|
|
1599
|
+
@staticmethod
|
|
1600
|
+
def _uuid_background_task_facts_owned(store, owner, rec: dict) -> dict:
|
|
1601
|
+
"""Read the verified original JSONL using the caller's pinned owner."""
|
|
1602
|
+
from ccx.claude.transcript import open_original_claude_source
|
|
1603
|
+
from ccx.store.root import GenerationError
|
|
1604
|
+
|
|
1605
|
+
try:
|
|
1606
|
+
with open_original_claude_source(store, owner, rec) as source:
|
|
1607
|
+
pinned = source[0] if source is not None else None
|
|
1608
|
+
view = {**rec, "transcript": pinned}
|
|
1609
|
+
facts = commands_read._background_task_facts(view)
|
|
1610
|
+
store.revalidate()
|
|
1611
|
+
return facts
|
|
1612
|
+
except (GenerationError, OSError) as exc:
|
|
1613
|
+
raise SessionUnavailable(
|
|
1614
|
+
f"background_tasks: original Claude native source unavailable: {exc}",
|
|
1615
|
+
) from exc
|
|
1616
|
+
|
|
1617
|
+
def background_tasks(self, short: str, *, live: bool = False) -> list[dict]:
|
|
1618
|
+
"""Transcript-derived Bash, subagent and Monitor facts for one owner.
|
|
1619
|
+
|
|
1620
|
+
An open task is advisory; its liveness never proves turn completion or
|
|
1621
|
+
changes the worker lifecycle. A missing original JSONL yields [].
|
|
1622
|
+
"""
|
|
1623
|
+
if live:
|
|
1624
|
+
raise SessionUnavailable(
|
|
1625
|
+
"background_tasks(live=True): external/native Claude discovery unavailable",
|
|
1626
|
+
)
|
|
1627
|
+
return self._uuid_background_task_facts(short)["background_tasks"]
|
|
1628
|
+
|
|
1629
|
+
def logs(
|
|
1630
|
+
self,
|
|
1631
|
+
short: str,
|
|
1632
|
+
*,
|
|
1633
|
+
tail: int | None = None,
|
|
1634
|
+
jsonl: bool = False,
|
|
1635
|
+
live: bool = False,
|
|
1636
|
+
) -> dict | str:
|
|
1637
|
+
"""Read the original owned Claude transcript without native activity.
|
|
1638
|
+
|
|
1639
|
+
Cleaned mode returns the existing rendered document (optionally its
|
|
1640
|
+
last ``tail`` turns). ``jsonl=True`` returns the entire original raw
|
|
1641
|
+
transcript, including an unfinished final line. Missing transcripts
|
|
1642
|
+
are not replaced by a stale derived render.
|
|
1643
|
+
"""
|
|
1644
|
+
from ccx.claude.transcript import open_original_claude_source
|
|
1645
|
+
from ccx.store.root import GenerationError
|
|
1646
|
+
|
|
1647
|
+
if live:
|
|
1648
|
+
raise InvalidRequest("logs(live=True): unmanaged Claude discovery unavailable")
|
|
1649
|
+
rec = self._resolve_managed(short, "logs")
|
|
1650
|
+
try:
|
|
1651
|
+
owner = self._uuid_store.resolve("claude:" + rec["workerId"])
|
|
1652
|
+
if jsonl:
|
|
1653
|
+
with open_original_claude_source(self._uuid_store, owner, rec) as source:
|
|
1654
|
+
if source is None:
|
|
1655
|
+
raise SessionNotFound(
|
|
1656
|
+
f"transcript not found for {owner.worker}", short=owner.short,
|
|
1657
|
+
)
|
|
1658
|
+
with source[0].open_reader() as stream:
|
|
1659
|
+
result = stream.read().decode("utf-8", "replace")
|
|
1660
|
+
else:
|
|
1661
|
+
result = render_transcript(owner.worker, store=self._uuid_store)
|
|
1662
|
+
if result is None:
|
|
1663
|
+
raise SessionNotFound(
|
|
1664
|
+
f"transcript not found for {owner.worker}", short=owner.short,
|
|
1665
|
+
)
|
|
1666
|
+
if tail:
|
|
1667
|
+
result["turns"] = result.get("turns", [])[-tail:]
|
|
1668
|
+
self._uuid_store.revalidate()
|
|
1669
|
+
return result
|
|
1670
|
+
except (GenerationError, OSError) as exc:
|
|
1671
|
+
raise SessionUnavailable(
|
|
1672
|
+
f"logs: original Claude transcript unavailable: {exc}",
|
|
1673
|
+
short=rec["workerId"][:8],
|
|
1674
|
+
) from exc
|
|
1675
|
+
|
|
1676
|
+
def list(
|
|
1677
|
+
self,
|
|
1678
|
+
*,
|
|
1679
|
+
cwd: str | None = None,
|
|
1680
|
+
cwd_prefix: str | None = None,
|
|
1681
|
+
state: str | None = None,
|
|
1682
|
+
recent_hours: float | None = None,
|
|
1683
|
+
) -> list[SessionInfo]:
|
|
1684
|
+
"""List only managed Claude full UUIDs; no external/native discovery."""
|
|
1685
|
+
from ccx.system.cwd_paths import canonical_cwd
|
|
1686
|
+
from ccx.store.root import GenerationError
|
|
1687
|
+
if cwd is not None and cwd_prefix is not None:
|
|
1688
|
+
raise InvalidRequest("cwd and cwd_prefix are mutually exclusive")
|
|
1689
|
+
try:
|
|
1690
|
+
owned: dict = {}
|
|
1691
|
+
rows = self._uuid_store.list_workers(
|
|
1692
|
+
engine="claude", cwd=canonical_cwd(cwd) if cwd else None,
|
|
1693
|
+
state=state, records=owned,
|
|
1694
|
+
)
|
|
1695
|
+
except GenerationError as exc:
|
|
1696
|
+
raise SessionUnavailable(f"full-UUID worker list unavailable: {exc}") from exc
|
|
1697
|
+
if cwd_prefix:
|
|
1698
|
+
prefix=canonical_cwd(cwd_prefix)
|
|
1699
|
+
rows=[r for r in rows if isinstance(r.get("cwd"),str) and
|
|
1700
|
+
(r["cwd"]==prefix or r["cwd"].startswith(prefix.rstrip(os.sep)+os.sep))]
|
|
1701
|
+
if recent_hours is not None:
|
|
1702
|
+
import time as _time
|
|
1703
|
+
if (not isinstance(recent_hours,(int,float))
|
|
1704
|
+
or not 0<recent_hours<=1000000):
|
|
1705
|
+
raise InvalidRequest("recent_hours must be positive")
|
|
1706
|
+
floor=int((_time.time()-recent_hours*3600)*1000)
|
|
1707
|
+
rows=[r for r in rows if max(r.get("last_turn_at") or 0,
|
|
1708
|
+
r.get("created_at") or 0)>=floor]
|
|
1709
|
+
return [self._uuid_session_info(*owned[row["worker"]], inventory_row=row)
|
|
1710
|
+
for row in rows]
|
|
1711
|
+
|
|
1712
|
+
def wait(
|
|
1713
|
+
self,
|
|
1714
|
+
short: str,
|
|
1715
|
+
*,
|
|
1716
|
+
live: bool = False,
|
|
1717
|
+
next_turn: bool = False,
|
|
1718
|
+
timeout: float | None = None,
|
|
1719
|
+
after_turn: str | None = None,
|
|
1720
|
+
known_input: str | None = None,
|
|
1721
|
+
) -> WaitResult:
|
|
1722
|
+
"""Observe a full-UUID managed Claude worker without native input.
|
|
1723
|
+
|
|
1724
|
+
A numeric timeout returns a timeout verdict, not an exception. Next-turn
|
|
1725
|
+
checkpoints describe exact turn boundaries and input-gate changes; they
|
|
1726
|
+
never by themselves prove a completed assistant reply.
|
|
1727
|
+
"""
|
|
1728
|
+
from ccx.ops.wait import wait_worker
|
|
1729
|
+
if live:
|
|
1730
|
+
raise InvalidRequest("external/live Claude wait is not supported for full UUID")
|
|
1731
|
+
rec = self._resolve_managed(short, "wait")
|
|
1732
|
+
result = wait_worker(
|
|
1733
|
+
"claude:" + rec["workerId"], timeout=timeout, next_turn=next_turn,
|
|
1734
|
+
after_turn=after_turn, known_input=known_input,
|
|
1735
|
+
expected_generation=self._uuid_store.generation,
|
|
1736
|
+
)
|
|
1737
|
+
return WaitResult.from_verdict(result)
|
|
1738
|
+
|
|
1739
|
+
def events(
|
|
1740
|
+
self,
|
|
1741
|
+
short: str,
|
|
1742
|
+
*,
|
|
1743
|
+
since_epoch: str | None = None,
|
|
1744
|
+
since_seq: int | None = None,
|
|
1745
|
+
limit: int | None = None,
|
|
1746
|
+
) -> list[Event]:
|
|
1747
|
+
"""Read original stamped Claude events from the owned full-UUID journal.
|
|
1748
|
+
|
|
1749
|
+
The typed (epoch, seq) cursor spans host restarts; a bare since_seq is
|
|
1750
|
+
only a legacy within-epoch filter. Newest limit applies after filtering.
|
|
1751
|
+
"""
|
|
1752
|
+
from ccx.ops.worker_events import MAX_PAGE_EVENTS, read_full_uuid_event_page
|
|
1753
|
+
from ccx.store.root import GenerationError
|
|
1754
|
+
from ccx.store.identity import UnknownWorker
|
|
1755
|
+
|
|
1756
|
+
record = self._resolve_managed(short, "events")
|
|
1757
|
+
worker = f"claude:{record['workerId']}"
|
|
1758
|
+
parsed: list[Event] = []
|
|
1759
|
+
cursor = None
|
|
1760
|
+
try:
|
|
1761
|
+
while True:
|
|
1762
|
+
page = read_full_uuid_event_page(
|
|
1763
|
+
self._uuid_store, worker, after=cursor,
|
|
1764
|
+
limit=MAX_PAGE_EVENTS, native=True,
|
|
1765
|
+
)
|
|
1766
|
+
parsed.extend(Event.from_dict(obj) for obj in page["events"])
|
|
1767
|
+
if not page["has_more"]:
|
|
1768
|
+
break
|
|
1769
|
+
cursor = page["next_cursor"]
|
|
1770
|
+
except UnknownWorker as exc:
|
|
1771
|
+
raise worker_not_found(worker) from exc
|
|
1772
|
+
except GenerationError as exc:
|
|
1773
|
+
raise SessionUnavailable(f"events: original source unavailable: {exc}") from exc
|
|
1774
|
+
|
|
1775
|
+
out = filter_events_after(parsed, since_epoch, since_seq)
|
|
1776
|
+
if limit is not None and limit >= 0:
|
|
1777
|
+
out = out[-limit:]
|
|
1778
|
+
return out
|
|
1779
|
+
|
|
1780
|
+
def subscribe(
|
|
1781
|
+
self,
|
|
1782
|
+
short: str,
|
|
1783
|
+
*,
|
|
1784
|
+
since_epoch: str | None = None,
|
|
1785
|
+
since_seq: int | None = None,
|
|
1786
|
+
timeout: float | None = None,
|
|
1787
|
+
) -> Iterator[Event]:
|
|
1788
|
+
"""Replay typed original Claude events, then follow its owned journal.
|
|
1789
|
+
|
|
1790
|
+
Capture the ev2 source watermark before replay and dedupe overlapping
|
|
1791
|
+
historical/live events per epoch, not by a global sequence counter.
|
|
1792
|
+
"""
|
|
1793
|
+
from math import isfinite
|
|
1794
|
+
from ccx.ops.worker_events import read_full_uuid_event_page, follow_full_uuid_events
|
|
1795
|
+
from ccx.store.root import GenerationError
|
|
1796
|
+
from ccx.store.identity import UnknownWorker
|
|
1797
|
+
|
|
1798
|
+
if timeout is not None and (
|
|
1799
|
+
type(timeout) not in (int, float) or not isfinite(timeout) or timeout < 0
|
|
1800
|
+
):
|
|
1801
|
+
raise InvalidRequest("subscribe timeout must be nonnegative finite or None")
|
|
1802
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
1803
|
+
record = self._resolve_managed(short, "subscribe")
|
|
1804
|
+
worker = f"claude:{record['workerId']}"
|
|
1805
|
+
try:
|
|
1806
|
+
watermark = read_full_uuid_event_page(
|
|
1807
|
+
self._uuid_store, worker, tail=0, native=True,
|
|
1808
|
+
)["next_cursor"]
|
|
1809
|
+
except UnknownWorker as exc:
|
|
1810
|
+
raise worker_not_found(worker) from exc
|
|
1811
|
+
except GenerationError as exc:
|
|
1812
|
+
raise SessionUnavailable(
|
|
1813
|
+
f"subscribe: original source unavailable: {exc}"
|
|
1814
|
+
) from exc
|
|
1815
|
+
|
|
1816
|
+
yielded_max: dict[str, int] = {}
|
|
1817
|
+
|
|
1818
|
+
def fresh(epoch, seq) -> bool:
|
|
1819
|
+
if epoch is None or seq is None:
|
|
1820
|
+
return True
|
|
1821
|
+
if epoch in yielded_max and seq <= yielded_max[epoch]:
|
|
1822
|
+
return False
|
|
1823
|
+
yielded_max[epoch] = seq
|
|
1824
|
+
return True
|
|
1825
|
+
|
|
1826
|
+
for event in self.events(
|
|
1827
|
+
worker, since_epoch=since_epoch, since_seq=since_seq,
|
|
1828
|
+
):
|
|
1829
|
+
if fresh(event.epoch, event.seq):
|
|
1830
|
+
yield event
|
|
1831
|
+
remaining = (None if deadline is None
|
|
1832
|
+
else max(0.0, deadline - time.monotonic()))
|
|
1833
|
+
try:
|
|
1834
|
+
for raw in follow_full_uuid_events(
|
|
1835
|
+
self._uuid_store, worker, after=watermark, native=True,
|
|
1836
|
+
timeout=remaining, heartbeat=None,
|
|
1837
|
+
):
|
|
1838
|
+
# The synthetic heartbeat is not a stamped source event.
|
|
1839
|
+
if raw.get("kind") == "heartbeat" and "seq" not in raw:
|
|
1840
|
+
continue
|
|
1841
|
+
event = Event.from_dict(raw)
|
|
1842
|
+
if fresh(event.epoch, event.seq):
|
|
1843
|
+
yield event
|
|
1844
|
+
except UnknownWorker as exc:
|
|
1845
|
+
raise worker_not_found(worker) from exc
|
|
1846
|
+
except GenerationError as exc:
|
|
1847
|
+
raise SessionUnavailable(
|
|
1848
|
+
f"subscribe: original source unavailable: {exc}"
|
|
1849
|
+
) from exc
|
|
1850
|
+
|
|
1851
|
+
def models(self, *, refresh: bool = False) -> ModelCatalog:
|
|
1852
|
+
"""The models Claude Code offers, as ``ccx models --engine claude``."""
|
|
1853
|
+
from ccx.models import read_models
|
|
1854
|
+
return read_models(engine="claude", refresh=refresh)[0]
|
|
1855
|
+
|
|
1856
|
+
def usage(self, *, cached: bool = False) -> Usage:
|
|
1857
|
+
"""Live quota utilization (the signal usage caps enforce). With
|
|
1858
|
+
``cached=True`` serve a recent cached value instead of forcing a fresh
|
|
1859
|
+
fetch. Raises :class:`InvalidRequest` if no signal is available; plan data
|
|
1860
|
+
comes from native Claude Code unless the machine selected OAuth."""
|
|
1861
|
+
data = fetch_usage(force=not cached)
|
|
1862
|
+
if data is None:
|
|
1863
|
+
raise InvalidRequest("usage: no signal (Claude usage unavailable or held; see ccx usage)")
|
|
1864
|
+
return Usage(
|
|
1865
|
+
summary=usage_summary(data),
|
|
1866
|
+
raw=data,
|
|
1867
|
+
five_hour_reset=session_reset_status(data),
|
|
1868
|
+
)
|
|
1869
|
+
|
|
1870
|
+
def reset_five_hour_limit(self) -> FiveHourReset:
|
|
1871
|
+
"""Claim Anthropic's weekly session-limit reset when provider-claimable.
|
|
1872
|
+
|
|
1873
|
+
The provider helper performs a mandatory live eligibility preflight, at
|
|
1874
|
+
most one mutation, and a read-only reconciliation. No local utilization
|
|
1875
|
+
threshold or entitlement calculation participates in authorization.
|
|
1876
|
+
|
|
1877
|
+
This is Claude Code's own reset offer (a limited rollout), claimed through
|
|
1878
|
+
its account endpoint, which isn't a public API and can change. Off by
|
|
1879
|
+
default: raises :class:`InvalidRequest` unless
|
|
1880
|
+
``CCX_ENABLE_UNOFFICIAL_RESET=1`` is set.
|
|
1881
|
+
"""
|
|
1882
|
+
outcome = reset_five_hour_limit()
|
|
1883
|
+
return FiveHourReset(
|
|
1884
|
+
result=str(outcome.get("result") or "error"),
|
|
1885
|
+
status=(
|
|
1886
|
+
outcome.get("status")
|
|
1887
|
+
if isinstance(outcome.get("status"), dict)
|
|
1888
|
+
else None
|
|
1889
|
+
),
|
|
1890
|
+
next_available_at=(
|
|
1891
|
+
outcome.get("next_available_at")
|
|
1892
|
+
if isinstance(outcome.get("next_available_at"), str)
|
|
1893
|
+
else None
|
|
1894
|
+
),
|
|
1895
|
+
weekly_resets_at=(
|
|
1896
|
+
outcome.get("weekly_resets_at")
|
|
1897
|
+
if isinstance(outcome.get("weekly_resets_at"), str)
|
|
1898
|
+
else None
|
|
1899
|
+
),
|
|
1900
|
+
refreshed=bool(outcome.get("refreshed")),
|
|
1901
|
+
)
|
|
1902
|
+
|
|
1903
|
+
|
|
1904
|
+
__all__ = ["CcxClient"]
|