ccx-native 0.0.1 → 0.2.0

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