ccx-native 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,1324 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import textwrap
|
|
7
|
+
|
|
8
|
+
from ccx.cli.commands.control import cmd_attach, cmd_kill, cmd_shutdown, cmd_stop, cmd_untrust
|
|
9
|
+
from ccx.cli.commands.create import cmd_fork, cmd_import, cmd_new, cmd_resume
|
|
10
|
+
from ccx.cli.commands.read import (
|
|
11
|
+
cmd_context, cmd_events, cmd_list, cmd_logs, cmd_peek,
|
|
12
|
+
cmd_report, cmd_usage,
|
|
13
|
+
)
|
|
14
|
+
from ccx.cli.commands.turns import (
|
|
15
|
+
cmd_answer, cmd_approve, cmd_compact, cmd_notify, cmd_reconcile, cmd_reject,
|
|
16
|
+
cmd_respond, cmd_send, cmd_set, cmd_steer, cmd_vars, cmd_wait,
|
|
17
|
+
)
|
|
18
|
+
from ccx.constants import (
|
|
19
|
+
COMPACT_WAIT_DEFAULT_S,
|
|
20
|
+
DEFAULT_IDLE_TIMEOUT_S,
|
|
21
|
+
EXIT_ERROR,
|
|
22
|
+
HOST_SOCK_WAIT_NEW_S,
|
|
23
|
+
)
|
|
24
|
+
from ccx.ops.terminate import KILL_TIMEOUT_DEFAULT_S
|
|
25
|
+
from ccx.version import ccx_version # noqa: F401 (re-exported for cli)
|
|
26
|
+
from ccx.cli import controller as controller_commands
|
|
27
|
+
from ccx.cli import skills
|
|
28
|
+
from ccx.controllers import live_vars
|
|
29
|
+
from ccx.rpc import local_rpc
|
|
30
|
+
from ccx.claude.host.hooks import cmd_hooks_relay
|
|
31
|
+
from ccx.claude.host.session import cmd_host
|
|
32
|
+
from ccx.cli.statusline import cmd_statusline_relay
|
|
33
|
+
from ccx.claude.usage import _add_cap_flags
|
|
34
|
+
|
|
35
|
+
class _HelpFormatter(argparse.HelpFormatter):
|
|
36
|
+
"""Wrap prose paragraphs; keep indented blocks (examples, tables) as written."""
|
|
37
|
+
|
|
38
|
+
def _fill_text(self, text, width, indent):
|
|
39
|
+
blocks = []
|
|
40
|
+
for block in textwrap.dedent(text).strip("\n").split("\n\n"):
|
|
41
|
+
if any(line.startswith(" ") for line in block.splitlines()):
|
|
42
|
+
blocks.append(textwrap.indent(block, indent))
|
|
43
|
+
else:
|
|
44
|
+
blocks.append(textwrap.fill(" ".join(block.split()), width,
|
|
45
|
+
initial_indent=indent,
|
|
46
|
+
subsequent_indent=indent))
|
|
47
|
+
return "\n\n".join(blocks)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _PeekTargets(argparse.Action):
|
|
51
|
+
"""Show one positional in help while retaining peek's existing target fields."""
|
|
52
|
+
|
|
53
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
54
|
+
namespace.target = values[0]
|
|
55
|
+
namespace.extra_shorts = values[1:]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _CcxArgumentParser(argparse.ArgumentParser):
|
|
59
|
+
"""Usage errors exit EXIT_ERROR (bad input) instead of argparse's 2.
|
|
60
|
+
|
|
61
|
+
2 is EXIT_NOT_FOUND, so a mistyped flag must not read as "no such worker".
|
|
62
|
+
Subparsers inherit the class, so every subcommand gets the same code and
|
|
63
|
+
the same help formatter.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, *args, **kwargs):
|
|
67
|
+
kwargs.setdefault("formatter_class", _HelpFormatter)
|
|
68
|
+
super().__init__(*args, **kwargs)
|
|
69
|
+
|
|
70
|
+
def error(self, message):
|
|
71
|
+
if (self.prog == "ccx peek"
|
|
72
|
+
and message == "the following arguments are required: target, more-targets"):
|
|
73
|
+
message = "the following arguments are required: target"
|
|
74
|
+
self.print_usage(sys.stderr)
|
|
75
|
+
self.exit(EXIT_ERROR, f"{self.prog}: error: {message}\n")
|
|
76
|
+
|
|
77
|
+
_intermixing = False
|
|
78
|
+
|
|
79
|
+
def parse_known_args(self, args=None, namespace=None):
|
|
80
|
+
"""Accept a trailing optional positional after options.
|
|
81
|
+
|
|
82
|
+
Plain argparse binds `send TARGET [text]` as soon as it sees TARGET, so
|
|
83
|
+
`ccx send W --wait 'text'` failed with "unrecognized arguments".
|
|
84
|
+
Intermixed parsing fixes that, but argparse supports it only on leaf
|
|
85
|
+
parsers without REMAINDER/subparsers; those keep plain parsing.
|
|
86
|
+
"""
|
|
87
|
+
if self._intermixing or not self._can_intermix():
|
|
88
|
+
return super().parse_known_args(args, namespace)
|
|
89
|
+
self._intermixing = True
|
|
90
|
+
try:
|
|
91
|
+
return self.parse_known_intermixed_args(args, namespace)
|
|
92
|
+
finally:
|
|
93
|
+
self._intermixing = False
|
|
94
|
+
|
|
95
|
+
def _can_intermix(self):
|
|
96
|
+
if any(a.nargs in (argparse.REMAINDER, argparse.PARSER) for a in self._actions):
|
|
97
|
+
return False
|
|
98
|
+
return not any(not action.option_strings
|
|
99
|
+
for group in self._mutually_exclusive_groups
|
|
100
|
+
for action in group._group_actions)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _add_json_flag(parser, help: str = "Print JSON instead of text.") -> None:
|
|
104
|
+
parser.add_argument("--json", action="store_true", help=help)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
_WORKER_QUERY_HELP = (
|
|
108
|
+
"Worker: claude:/codex: handle, full UUID, or a unique UUID prefix "
|
|
109
|
+
"(at least 8 lowercase hex characters)."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _examples(*lines: str) -> str:
|
|
114
|
+
return "Examples:\n" + "\n".join(f" {line}" for line in lines)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
OVERVIEW = """\
|
|
118
|
+
Run persistent Claude Code and Codex workers from the command line.
|
|
119
|
+
|
|
120
|
+
Quick start:
|
|
121
|
+
W=$(ccx new --engine claude --cwd ~/repo -n tests "Fix the failing tests")
|
|
122
|
+
ccx wait "$W" # until it's done or needs input
|
|
123
|
+
ccx report "$W" # its last reply
|
|
124
|
+
ccx send "$W" "Now add a regression test"
|
|
125
|
+
|
|
126
|
+
Workers keep their own history. Target them by the handle `ccx new` prints
|
|
127
|
+
(claude:<uuid> or codex:<uuid>), the full UUID, or a unique UUID prefix of at
|
|
128
|
+
least 8 hex characters. Names are labels for `ccx list`, not targets. Idle workers
|
|
129
|
+
stop after a while and revive on the next send. `ccx import` brings in a
|
|
130
|
+
Claude Code session or a Codex thread you started yourself.
|
|
131
|
+
|
|
132
|
+
Waiting for results: a person or a Claude parent uses --wait or `ccx wait`.
|
|
133
|
+
A Codex parent uses --notify-parent (or `ccx notify`) and ends its turn; CCX
|
|
134
|
+
wakes it when the work settles. That works from a CCX Codex worker, the Codex
|
|
135
|
+
app or a default `codex` terminal. A `codex` running its own app-server (for
|
|
136
|
+
example one started with -c, --profile or --no-daemon, or `codex exec`) can't
|
|
137
|
+
be woken; CCX refuses and says to wait instead. --no-wait returns at once.
|
|
138
|
+
|
|
139
|
+
From Codex, run ccx outside the sandbox (or with full access): it starts
|
|
140
|
+
processes and writes outside the workspace. Codex must be on PATH or
|
|
141
|
+
CCX_CODEX_BIN must point to its executable."""
|
|
142
|
+
|
|
143
|
+
COMMAND_GROUPS = (
|
|
144
|
+
("Create", ("new", "fork", "resume", "import")),
|
|
145
|
+
("Turns", ("send", "steer", "stop", "wait", "notify", "reconcile")),
|
|
146
|
+
("Input requests", ("answer", "approve", "reject", "respond")),
|
|
147
|
+
("Inspect", ("list", "peek", "report", "logs", "context", "events", "vars")),
|
|
148
|
+
("Manage", ("attach", "compact", "set", "kill", "shutdown")),
|
|
149
|
+
("Account", ("usage",)),
|
|
150
|
+
("Setup and automation", ("init", "skills", "untrust", "controller", "rpc")),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
ENVIRONMENT = """\
|
|
154
|
+
Environment:
|
|
155
|
+
CCX_ROOT store directory (default ~/.ccx)
|
|
156
|
+
CCX_CLAUDE_BIN claude executable to run
|
|
157
|
+
CCX_CODEX_BIN codex executable to run (otherwise codex must be on PATH)
|
|
158
|
+
CCX_SEND_MAX_BYTES optional size limit for prompts sent to Claude workers
|
|
159
|
+
Inside a worker, CCX sets CCX_SELF_WORKER_ID and CCX_SELF_ENGINE."""
|
|
160
|
+
|
|
161
|
+
EXIT_CODES = """\
|
|
162
|
+
Exit codes (same for Claude and Codex):
|
|
163
|
+
0 success 5 refused in the current state
|
|
164
|
+
1 bad input or usage 6 incomplete: timed out, turn failed,
|
|
165
|
+
2 not found or delivery uncertain
|
|
166
|
+
3 unavailable (no store, 7 a usage cap stopped the turn
|
|
167
|
+
not running, sandboxed) 8 Anthropic rate limit (429)
|
|
168
|
+
4 waiting for input 9 transient API error (529 / 5xx)"""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _command_overview(subparsers) -> str:
|
|
172
|
+
"""The grouped command list, built from each subcommand's own summary."""
|
|
173
|
+
summaries = {action.dest: action.help for action in subparsers._choices_actions
|
|
174
|
+
if action.help is not argparse.SUPPRESS}
|
|
175
|
+
grouped = [name for _, names in COMMAND_GROUPS for name in names]
|
|
176
|
+
assert sorted(grouped) == sorted(summaries), "COMMAND_GROUPS out of date"
|
|
177
|
+
lines = ["Commands (`ccx <command> --help` for details):"]
|
|
178
|
+
for title, names in COMMAND_GROUPS:
|
|
179
|
+
lines.append(f" {title}:")
|
|
180
|
+
lines.extend(f" {name:<11} {summaries[name]}" for name in names)
|
|
181
|
+
return "\n".join(lines)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _add_controller_parsers(sub) -> None:
|
|
185
|
+
"""`ccx controller ...`: durable Python orchestration controllers."""
|
|
186
|
+
pcontroller = sub.add_parser(
|
|
187
|
+
"controller",
|
|
188
|
+
help="Run Python scripts that orchestrate CCX workers.",
|
|
189
|
+
description=(
|
|
190
|
+
"A controller is a Python script with an @controller entry point "
|
|
191
|
+
"that drives ordinary CCX workers. Its runs, state and logs are kept "
|
|
192
|
+
"under $CCX_ROOT/controllers. Controllers are addressed by name or "
|
|
193
|
+
"unique run-id prefix, not by worker id."
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
controller_sub = pcontroller.add_subparsers(
|
|
197
|
+
dest="controller_cmd", required=True,
|
|
198
|
+
metavar="{run,list,runs,status,report,wait,notify,logs,input,resume,stop}",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
crun = controller_sub.add_parser(
|
|
202
|
+
"run", help="Start a run and wait for it (Codex parent: --notify-parent).",
|
|
203
|
+
description="Put CCX options before SCRIPT; everything after it goes to the script.",
|
|
204
|
+
)
|
|
205
|
+
crun_completion = crun.add_mutually_exclusive_group()
|
|
206
|
+
crun_completion.add_argument("--detach", action="store_true", help="Return after startup; check it later with status, report or logs.")
|
|
207
|
+
crun_completion.add_argument("--notify-parent", action="store_true", help="Return after startup; wake the calling Codex parent when the run finishes.")
|
|
208
|
+
_add_json_flag(crun)
|
|
209
|
+
crun.add_argument(
|
|
210
|
+
"--name",
|
|
211
|
+
help=(
|
|
212
|
+
"Run under this controller name instead of the decorator default."
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
crun.add_argument("script", help="Python script with an @controller entry point.")
|
|
216
|
+
crun.add_argument("script_args", nargs=argparse.REMAINDER, help="All arguments after SCRIPT are passed to it; an initial -- is optional.")
|
|
217
|
+
crun.set_defaults(func=controller_commands.cmd_run)
|
|
218
|
+
|
|
219
|
+
clist = controller_sub.add_parser(
|
|
220
|
+
"list", help="List controllers and their newest run.",
|
|
221
|
+
description="Lifecycle tracks execution; report status comes from the script.",
|
|
222
|
+
)
|
|
223
|
+
_add_json_flag(clist)
|
|
224
|
+
clist.set_defaults(func=controller_commands.cmd_list)
|
|
225
|
+
|
|
226
|
+
cruns = controller_sub.add_parser(
|
|
227
|
+
"runs", help="List stored runs for one controller.",
|
|
228
|
+
description="List stored runs of a named controller.",
|
|
229
|
+
)
|
|
230
|
+
cruns.add_argument("name", help="Controller name.")
|
|
231
|
+
_add_json_flag(cruns)
|
|
232
|
+
cruns.set_defaults(func=controller_commands.cmd_runs)
|
|
233
|
+
|
|
234
|
+
cstatus = controller_sub.add_parser(
|
|
235
|
+
"status", help="Show a run's tasks, worktrees and workers.",
|
|
236
|
+
description="Lifecycle tracks execution; report status comes from the script.",
|
|
237
|
+
)
|
|
238
|
+
cstatus.add_argument("target", help="Controller name or unique run-id prefix.")
|
|
239
|
+
_add_json_flag(cstatus)
|
|
240
|
+
cstatus.set_defaults(func=controller_commands.cmd_status)
|
|
241
|
+
|
|
242
|
+
creport = controller_sub.add_parser(
|
|
243
|
+
"report", help="Print the run's latest report.",
|
|
244
|
+
description="Print the run's latest published report.",
|
|
245
|
+
)
|
|
246
|
+
creport.add_argument("target", help="Controller name or unique run-id prefix.")
|
|
247
|
+
_add_json_flag(creport)
|
|
248
|
+
creport.set_defaults(func=controller_commands.cmd_report)
|
|
249
|
+
|
|
250
|
+
cwait = controller_sub.add_parser(
|
|
251
|
+
"wait", help="Wait for a run to finish (not from a Codex parent).",
|
|
252
|
+
description="Wait for a run to finish; a timeout leaves the run active.",
|
|
253
|
+
)
|
|
254
|
+
cwait.add_argument("target", help="Controller name or unique run-id prefix.")
|
|
255
|
+
cwait.add_argument("--timeout", type=float, default=None,
|
|
256
|
+
help="Bound the wait to N seconds (default: unbounded); running out exits 6.")
|
|
257
|
+
_add_json_flag(cwait)
|
|
258
|
+
cwait.set_defaults(func=controller_commands.cmd_wait)
|
|
259
|
+
|
|
260
|
+
cnotify_controller = controller_sub.add_parser(
|
|
261
|
+
"notify", help="Wake the calling Codex parent when a run finishes.",
|
|
262
|
+
description="Wake the calling Codex parent when the latest attempt of a controller run finishes. Starts nothing.",
|
|
263
|
+
)
|
|
264
|
+
cnotify_controller.add_argument("target", help="Controller name or unique run-id prefix.")
|
|
265
|
+
_add_json_flag(cnotify_controller)
|
|
266
|
+
cnotify_controller.set_defaults(func=controller_commands.cmd_notify)
|
|
267
|
+
|
|
268
|
+
clogs = controller_sub.add_parser(
|
|
269
|
+
"logs", help="Print a run's stdout/stderr log.",
|
|
270
|
+
description="Print a run's saved stdout and stderr log.",
|
|
271
|
+
)
|
|
272
|
+
clogs.add_argument("target", help="Controller name or unique run-id prefix.")
|
|
273
|
+
clogs.add_argument("--follow", action="store_true",
|
|
274
|
+
help="Keep printing until the run finishes (not from a Codex parent). Cannot be combined with --json.")
|
|
275
|
+
_add_json_flag(clogs)
|
|
276
|
+
clogs.set_defaults(func=controller_commands.cmd_logs)
|
|
277
|
+
|
|
278
|
+
cinput = controller_sub.add_parser(
|
|
279
|
+
"input", help="Leave a note for a controller without waking it.",
|
|
280
|
+
description="Give either --note, or --worker with --comment.",
|
|
281
|
+
)
|
|
282
|
+
cinput.add_argument("target", help="Controller name or unique run-id prefix.")
|
|
283
|
+
cinput.add_argument("--worker", help="Worker the comment is about (a worker id, not a name).")
|
|
284
|
+
cinput.add_argument("--comment", help="Comment about --worker (both together).")
|
|
285
|
+
cinput.add_argument("--note", help="Note about the whole run; not with --worker/--comment.")
|
|
286
|
+
_add_json_flag(cinput)
|
|
287
|
+
cinput.set_defaults(func=controller_commands.cmd_input)
|
|
288
|
+
|
|
289
|
+
cresume_controller = controller_sub.add_parser("resume", help="Continue a finished run, reusing done steps (Codex parent: --notify-parent).",
|
|
290
|
+
description="The script must be unchanged since the run started; after editing it, start a new run.")
|
|
291
|
+
cresume_controller.add_argument("--worker", default=None, help="Worker the comment is about (a worker id, not a name).")
|
|
292
|
+
cresume_controller.add_argument("--comment", default=None, help="Comment about --worker (both together).")
|
|
293
|
+
cresume_controller.add_argument("--note", default=None, help="Note about the whole run; not with --worker/--comment.")
|
|
294
|
+
cresume_completion = cresume_controller.add_mutually_exclusive_group()
|
|
295
|
+
cresume_completion.add_argument("--detach", action="store_true", help="Return after startup; check it later with status, report or logs.")
|
|
296
|
+
cresume_completion.add_argument("--notify-parent", action="store_true", help="Return after startup; wake the calling Codex parent when the run finishes.")
|
|
297
|
+
_add_json_flag(cresume_controller)
|
|
298
|
+
cresume_controller.add_argument("target", help="Controller name or unique run-id prefix.")
|
|
299
|
+
cresume_controller.add_argument(
|
|
300
|
+
"script_args", nargs="*", help="Arguments after TARGET are passed to the script. Use -- before option-like script arguments."
|
|
301
|
+
)
|
|
302
|
+
cresume_controller.set_defaults(func=controller_commands.cmd_resume)
|
|
303
|
+
|
|
304
|
+
cstop = controller_sub.add_parser("stop", help="Stop a run's process; its workers keep running.")
|
|
305
|
+
cstop.add_argument("target", help="Controller name or unique run-id prefix.")
|
|
306
|
+
cstop.add_argument("--force", action="store_true",
|
|
307
|
+
help="SIGKILL the controller if it has not stopped 5s after SIGTERM.")
|
|
308
|
+
_add_json_flag(cstop)
|
|
309
|
+
cstop.set_defaults(func=controller_commands.cmd_stop)
|
|
310
|
+
|
|
311
|
+
# Private two-hop detached-host plumbing. These are parsed by a fresh CCX
|
|
312
|
+
# process and intentionally omitted from user-facing help.
|
|
313
|
+
for hidden_name, handler in (
|
|
314
|
+
("_launch", controller_commands.cmd_internal_launch),
|
|
315
|
+
("_host", controller_commands.cmd_internal_host),
|
|
316
|
+
):
|
|
317
|
+
hidden = controller_sub.add_parser(hidden_name, help=argparse.SUPPRESS)
|
|
318
|
+
hidden.add_argument("--run-id", required=True)
|
|
319
|
+
hidden.add_argument("--run-dir", required=True)
|
|
320
|
+
hidden.add_argument("--script", required=True)
|
|
321
|
+
hidden.add_argument("--argv-json", required=True)
|
|
322
|
+
hidden.add_argument("--resume", action="store_true")
|
|
323
|
+
if hidden_name == "_launch":
|
|
324
|
+
hidden.add_argument("--startup-timeout", type=float, default=30.0)
|
|
325
|
+
hidden.set_defaults(func=handler)
|
|
326
|
+
controller_sub._choices_actions = [
|
|
327
|
+
action for action in controller_sub._choices_actions
|
|
328
|
+
if action.dest not in {"_launch", "_host"}
|
|
329
|
+
]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _add_events_args(parser) -> None:
|
|
333
|
+
"""The flags of `ccx events`."""
|
|
334
|
+
parser.add_argument("--after", metavar="CURSOR", default=None,
|
|
335
|
+
help="Only events after this cursor (from an earlier event or heartbeat).")
|
|
336
|
+
parser.add_argument("--tail", type=int, default=None, metavar="N",
|
|
337
|
+
help="Start from the newest N events (default 20 without --after; "
|
|
338
|
+
"0 = from now).")
|
|
339
|
+
parser.add_argument("--follow", action="store_true",
|
|
340
|
+
help="Keep printing new events as they happen.")
|
|
341
|
+
parser.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
|
|
342
|
+
help="With --follow, stop after this many seconds.")
|
|
343
|
+
parser.add_argument("--heartbeat", type=float, default=30.0, metavar="SECONDS",
|
|
344
|
+
help="Periodic heartbeat interval while following JSON output (default 30; "
|
|
345
|
+
"0 disables periodic heartbeats). An initial cursor heartbeat is still emitted.")
|
|
346
|
+
parser.add_argument("--kind", action="append", default=None, metavar="KIND",
|
|
347
|
+
help="Only this event kind (repeatable): state, turn.started, turn.ended, "
|
|
348
|
+
"tool.started, tool.finished, input.requested, input.resolved, "
|
|
349
|
+
"compact.started, compact.ended, delivery.changed, background.changed, "
|
|
350
|
+
"context.changed, error.")
|
|
351
|
+
_add_json_flag(parser, help="NDJSON while following (events and heartbeats); "
|
|
352
|
+
"otherwise one page object with next_cursor.")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _add_session_parsers(sub) -> None:
|
|
356
|
+
"""Managed worker creation and turn delivery: new, send, vars, steer, wait, notify."""
|
|
357
|
+
pd = sub.add_parser(
|
|
358
|
+
"new",
|
|
359
|
+
help="Create a Claude or Codex worker, with an optional first prompt.",
|
|
360
|
+
description=(
|
|
361
|
+
"Create a persistent worker in --cwd and print its handle "
|
|
362
|
+
"(claude:<uuid> or codex:<uuid>); keep it for follow-ups. With a "
|
|
363
|
+
"prompt, the first turn starts right away; without one, the worker "
|
|
364
|
+
"waits for `ccx send`. Workers stop after --idle-timeout and revive "
|
|
365
|
+
"with their history on the next send."
|
|
366
|
+
),
|
|
367
|
+
epilog=_examples(
|
|
368
|
+
'ccx new --engine claude --cwd ~/repo -n tests "Fix the tests" --wait',
|
|
369
|
+
'W=$(ccx new --engine codex --cwd ~/repo --file task.md)',
|
|
370
|
+
'ccx new --engine claude "Review the diff" --notify-parent # Codex parent',
|
|
371
|
+
),
|
|
372
|
+
)
|
|
373
|
+
pd.add_argument("--engine", required=True, choices=("claude", "codex"),
|
|
374
|
+
help="Required: which engine runs the worker.")
|
|
375
|
+
pd.add_argument("prompt", nargs="?", help="First prompt (omit to create an idle worker).")
|
|
376
|
+
pd.add_argument("--stdin", action="store_true", help="Read the first prompt from stdin.")
|
|
377
|
+
pd.add_argument("--file", default=None, help="Read the first prompt from a file (`-` means stdin).")
|
|
378
|
+
pd.add_argument("--cwd", default=os.getcwd(), help="Working directory (default: the current one).")
|
|
379
|
+
pd.add_argument("-n", "--name", default=None, help="Display name, shown by `ccx list`.")
|
|
380
|
+
pd.add_argument("--worker-id", default=None,
|
|
381
|
+
help="Use this UUIDv4 as the worker id, so a retry after a lost "
|
|
382
|
+
"reply reaches the same worker instead of creating another.")
|
|
383
|
+
pd.add_argument("--model", default=None,
|
|
384
|
+
help="Model (default: the engine's). Claude: an alias such as opus, "
|
|
385
|
+
"sonnet or haiku. Codex: a full model id from Codex's model list.")
|
|
386
|
+
pd.add_argument("--effort", default=None, choices=["low","medium","high","xhigh","max"],
|
|
387
|
+
help="Reasoning effort (default: the engine's).")
|
|
388
|
+
pd.add_argument("--agent", default=None,
|
|
389
|
+
help="Claude only: run as this Claude Code subagent (a subagent name, not a model).")
|
|
390
|
+
pd.add_argument("--append-system-prompt", default=None,
|
|
391
|
+
help="Claude only: text appended to Claude Code's system prompt.")
|
|
392
|
+
pd.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT_S, metavar="SECONDS",
|
|
393
|
+
help="Stop the worker after N idle seconds (default 1800; 0 never). "
|
|
394
|
+
"The next send revives it.")
|
|
395
|
+
pd.add_argument("--allowed-tools", default=None,
|
|
396
|
+
help="Claude only: passed to claude --allowedTools (comma-separated).")
|
|
397
|
+
pd.add_argument("--disallowed-tools", default=None,
|
|
398
|
+
help="Claude only: passed to claude --disallowedTools (comma-separated).")
|
|
399
|
+
pd.add_argument("--add-dir", action="append", default=None,
|
|
400
|
+
help="Claude only: extra directory Claude may access (repeatable).")
|
|
401
|
+
pd.add_argument("--permission-mode", default=None, choices=["acceptEdits","auto","bypassPermissions","default","dontAsk","plan"],
|
|
402
|
+
help="Claude only: Claude Code permission mode; needs --permissions restricted.")
|
|
403
|
+
pd.add_argument("--permissions", choices=("full", "restricted"), default=None,
|
|
404
|
+
help="full (default): no permission prompts (Claude skips them; Codex "
|
|
405
|
+
"runs with danger-full-access and never asks). restricted: Claude "
|
|
406
|
+
"asks through `ccx approve`/`reject`; Codex uses workspace-write "
|
|
407
|
+
"and asks on request. Not with --sandbox/--approval-policy.")
|
|
408
|
+
pd.add_argument("--sandbox", choices=("read-only", "workspace-write", "danger-full-access"),
|
|
409
|
+
default=None, help="Codex only: sandbox mode (default danger-full-access); not with --permissions.")
|
|
410
|
+
pd.add_argument("--approval-policy", choices=("untrusted", "on-request", "never"),
|
|
411
|
+
default=None, help="Codex only: approval policy (default never); not with --permissions.")
|
|
412
|
+
pd.add_argument("--start-timeout", type=float, default=None, metavar="SECONDS",
|
|
413
|
+
help="Codex only: seconds to wait for the thread to start (up to 90, the default).")
|
|
414
|
+
pd.add_argument(
|
|
415
|
+
"--var",
|
|
416
|
+
action="append",
|
|
417
|
+
default=None,
|
|
418
|
+
metavar="NAME=JSON",
|
|
419
|
+
help="Attach a JSON variable to the worker (repeatable); not with --vars-from. Variables require full-permission worker settings.",
|
|
420
|
+
)
|
|
421
|
+
pd.add_argument(
|
|
422
|
+
"--vars-from",
|
|
423
|
+
default=None,
|
|
424
|
+
metavar="TARGET",
|
|
425
|
+
help="Share the source worker's durable variables; live Python attachments cannot be shared this way. Not with --var. Variables require full-permission worker settings.",
|
|
426
|
+
)
|
|
427
|
+
pd_completion = pd.add_mutually_exclusive_group()
|
|
428
|
+
pd_completion.add_argument("--no-wait", action="store_true",
|
|
429
|
+
help="Return once the worker has started (default).")
|
|
430
|
+
pd_completion.add_argument("--wait", action="store_true",
|
|
431
|
+
help="Wait for the first turn and print the reply (needs a prompt).")
|
|
432
|
+
pd_completion.add_argument("--notify-parent", action="store_true",
|
|
433
|
+
help="Return after startup; wake the calling Codex parent when the first turn settles, or when promptless creation completes.")
|
|
434
|
+
pd.add_argument("--settle", action="store_true",
|
|
435
|
+
help="Codex only, with --notify-parent: also wait for the workers it "
|
|
436
|
+
"started with --notify-parent to settle.")
|
|
437
|
+
pd.add_argument("--timeout", type=float, default=None,
|
|
438
|
+
help="Limit --wait to N seconds (default: no limit).")
|
|
439
|
+
pd.add_argument("--json", action="store_true",
|
|
440
|
+
help="Print a JSON result (worker, worker_id, engine, ...) instead of the handle.")
|
|
441
|
+
_add_cap_flags(pd)
|
|
442
|
+
pd.set_defaults(func=cmd_new)
|
|
443
|
+
|
|
444
|
+
pres = sub.add_parser(
|
|
445
|
+
"resume", help="Reload a stopped worker without starting a turn.",
|
|
446
|
+
description="Reload the worker's Claude session or Codex thread without "
|
|
447
|
+
"sending a prompt. Rarely needed: `ccx send` revives a stopped "
|
|
448
|
+
"worker by itself. To watch a Claude worker, use `ccx attach`.",
|
|
449
|
+
)
|
|
450
|
+
pres.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
451
|
+
_add_json_flag(pres)
|
|
452
|
+
pres.set_defaults(func=cmd_resume)
|
|
453
|
+
|
|
454
|
+
pf = sub.add_parser(
|
|
455
|
+
"fork",
|
|
456
|
+
help="Create a new worker from a copy of another worker's history.",
|
|
457
|
+
description=(
|
|
458
|
+
"The new worker gets the source's history up to its last finished "
|
|
459
|
+
"turn, plus its engine, cwd and settings. The source is unchanged and "
|
|
460
|
+
"must not be mid-turn. Without a prompt nothing is sent and no tokens "
|
|
461
|
+
"are spent. Variables are not copied; use --vars-from to share them."
|
|
462
|
+
),
|
|
463
|
+
epilog=_examples(
|
|
464
|
+
'ccx fork "$W" -n try-b "Try the other approach" --wait',
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
pf.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
468
|
+
pf.add_argument("prompt", nargs="?", help="First prompt for the fork (omit to only fork).")
|
|
469
|
+
pf.add_argument("--stdin", action="store_true", help="Read the first prompt from stdin.")
|
|
470
|
+
pf.add_argument("--file", default=None, help="Read the first prompt from a file (`-` means stdin).")
|
|
471
|
+
pf.add_argument("-n", "--name", default=None, help="Name for the fork (default: '<source name> (fork)').")
|
|
472
|
+
pf.add_argument("--worker-id", default=None,
|
|
473
|
+
help="Use this UUIDv4 as the fork's id; a retry reuses the same fork.")
|
|
474
|
+
pf.add_argument("--delivery-id", default=None,
|
|
475
|
+
help="Only create:initial, the default; to retry a fork, reuse --worker-id.")
|
|
476
|
+
pf.add_argument("--model", default=None, help="Model for the fork (default: the source's).")
|
|
477
|
+
pf.add_argument("--effort", default=None, choices=["low", "medium", "high", "xhigh", "max"],
|
|
478
|
+
help="Reasoning effort for the fork (default: the source's).")
|
|
479
|
+
pf.add_argument("--var", action="append", default=None, metavar="NAME=JSON",
|
|
480
|
+
help="Attach a JSON variable (repeatable); not with --vars-from. Variables require full-permission worker settings.")
|
|
481
|
+
pf.add_argument("--vars-from", default=None, metavar="TARGET",
|
|
482
|
+
help="Share the source worker's durable variables; live Python attachments cannot be shared this way. Not with --var. Variables require full-permission worker settings.")
|
|
483
|
+
pf_completion = pf.add_mutually_exclusive_group()
|
|
484
|
+
pf_completion.add_argument("--no-wait", action="store_true",
|
|
485
|
+
help="Return once the fork exists (default).")
|
|
486
|
+
pf_completion.add_argument("--wait", action="store_true",
|
|
487
|
+
help="Wait for the first turn and print the reply (needs a prompt).")
|
|
488
|
+
pf_completion.add_argument("--notify-parent", action="store_true",
|
|
489
|
+
help="Return after startup; wake the calling Codex parent when the first turn settles, or when promptless creation completes.")
|
|
490
|
+
pf.add_argument("--settle", action="store_true",
|
|
491
|
+
help="Codex only, with --notify-parent: also wait for the workers it "
|
|
492
|
+
"started with --notify-parent to settle.")
|
|
493
|
+
pf.add_argument("--timeout", type=float, default=None,
|
|
494
|
+
help="Limit the whole fork, including --wait, to N seconds.")
|
|
495
|
+
pf.add_argument("--json", action="store_true",
|
|
496
|
+
help="Print a JSON result instead of the handle.")
|
|
497
|
+
_add_cap_flags(pf)
|
|
498
|
+
pf.set_defaults(func=cmd_fork)
|
|
499
|
+
|
|
500
|
+
ps = sub.add_parser(
|
|
501
|
+
"send",
|
|
502
|
+
help="Start a turn on an idle worker (revives a stopped one).",
|
|
503
|
+
description=(
|
|
504
|
+
"Send a prompt as a new turn and, by default, wait for it. Text output "
|
|
505
|
+
"is the worker handle, a delivery_id: line, then the reply; scripts "
|
|
506
|
+
"that only want the reply can use --json or `ccx report`. A stopped "
|
|
507
|
+
"worker is revived first. A busy worker is "
|
|
508
|
+
"refused (use `ccx steer`, or wait); one waiting for input is refused "
|
|
509
|
+
"(use answer, approve, reject or respond)."
|
|
510
|
+
),
|
|
511
|
+
epilog=_examples(
|
|
512
|
+
'ccx send "$W" "Now add a regression test"',
|
|
513
|
+
'ccx send "$W" --no-wait --file task.md',
|
|
514
|
+
'ccx send "$W" "Continue" --notify-parent # Codex parent',
|
|
515
|
+
),
|
|
516
|
+
)
|
|
517
|
+
ps.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
518
|
+
ps.add_argument("text", nargs="?", help="Prompt text (or use --stdin / --file).")
|
|
519
|
+
ps.add_argument("--stdin", action="store_true", help="Read the prompt from stdin.")
|
|
520
|
+
ps.add_argument("--file", default=None, help="Read the prompt from a file (`-` means stdin).")
|
|
521
|
+
ps.add_argument(
|
|
522
|
+
"--var",
|
|
523
|
+
action="append",
|
|
524
|
+
default=None,
|
|
525
|
+
metavar="NAME=JSON",
|
|
526
|
+
help="Attach or replace a JSON variable (repeatable); not with --vars-from. Variables require full-permission worker settings.",
|
|
527
|
+
)
|
|
528
|
+
ps.add_argument(
|
|
529
|
+
"--vars-from",
|
|
530
|
+
default=None,
|
|
531
|
+
metavar="TARGET",
|
|
532
|
+
help="Share the source worker's durable variables; live Python attachments cannot be shared this way. Not with --var. Variables require full-permission worker settings.",
|
|
533
|
+
)
|
|
534
|
+
ps_completion = ps.add_mutually_exclusive_group()
|
|
535
|
+
ps_completion.add_argument("--no-wait", action="store_true",
|
|
536
|
+
help="Return once the message is queued for delivery, without waiting "
|
|
537
|
+
"for the turn; follow up with ccx wait, report or reconcile.")
|
|
538
|
+
ps_completion.add_argument("--wait", action="store_true",
|
|
539
|
+
help="Wait for the turn and print the reply (default).")
|
|
540
|
+
ps_completion.add_argument("--notify-parent", action="store_true",
|
|
541
|
+
help="Return now; wake the calling Codex parent when the turn settles.")
|
|
542
|
+
ps.add_argument("--settle", action="store_true",
|
|
543
|
+
help="Codex only, with --notify-parent: also wait for the workers it "
|
|
544
|
+
"started with --notify-parent to settle.")
|
|
545
|
+
ps.add_argument(
|
|
546
|
+
"--delivery-id", default=None,
|
|
547
|
+
help="Id for this message (generated if omitted). Reusing it never sends "
|
|
548
|
+
"twice; look it up with `ccx reconcile`.",
|
|
549
|
+
)
|
|
550
|
+
ps.add_argument("--timeout", type=float, default=None,
|
|
551
|
+
help="Stop waiting after N seconds (default: no limit); the turn "
|
|
552
|
+
"keeps running and the exit code is 6.")
|
|
553
|
+
_add_json_flag(ps)
|
|
554
|
+
_add_cap_flags(ps)
|
|
555
|
+
ps.set_defaults(func=cmd_send)
|
|
556
|
+
|
|
557
|
+
pvars = sub.add_parser(
|
|
558
|
+
"vars",
|
|
559
|
+
help="Read a worker's variables.",
|
|
560
|
+
description=(
|
|
561
|
+
"Without NAME, list the worker's variable names and metadata. With "
|
|
562
|
+
"NAME, print that variable's JSON value. Variables are attached with "
|
|
563
|
+
"--var or shared with --vars-from on new, fork and send; inside the "
|
|
564
|
+
"worker, `python3` reads and updates them. Live values from a running "
|
|
565
|
+
"controller aren't readable here."
|
|
566
|
+
),
|
|
567
|
+
)
|
|
568
|
+
pvars.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
569
|
+
pvars.add_argument("name", nargs="?", help="Variable to print.")
|
|
570
|
+
pvars.add_argument(
|
|
571
|
+
"--json",
|
|
572
|
+
action="store_true",
|
|
573
|
+
help="Print JSON (recommended for scripts).",
|
|
574
|
+
)
|
|
575
|
+
pvars.set_defaults(func=cmd_vars)
|
|
576
|
+
|
|
577
|
+
pst = sub.add_parser(
|
|
578
|
+
"steer",
|
|
579
|
+
help="Add a message to a busy worker's running turn.",
|
|
580
|
+
description=(
|
|
581
|
+
"For a worker in the middle of a turn; an idle worker needs `ccx "
|
|
582
|
+
"send`.\n\n"
|
|
583
|
+
"Claude: steer watches up to --timeout seconds for where the message "
|
|
584
|
+
"landed. If it's still pending after that, don't resend: check it "
|
|
585
|
+
"with `ccx reconcile` and the same delivery id.\n\n"
|
|
586
|
+
"Codex: the running turn accepts or rejects the message at once.\n\n"
|
|
587
|
+
"Caps on a steer apply to the running turn the message joins."
|
|
588
|
+
),
|
|
589
|
+
)
|
|
590
|
+
pst.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
591
|
+
pst.add_argument("text", nargs="?", help="Message text (or use --stdin / --file).")
|
|
592
|
+
pst.add_argument("--stdin", action="store_true", help="Read the message from stdin.")
|
|
593
|
+
pst.add_argument("--file", default=None, help="Read the message from a file (`-` means stdin).")
|
|
594
|
+
pst.add_argument("--no-wait", action="store_true",
|
|
595
|
+
help="Return as soon as the message is accepted.")
|
|
596
|
+
pst.add_argument("--timeout", type=float, default=None,
|
|
597
|
+
help="Claude only: seconds to watch for where the message landed "
|
|
598
|
+
"(default 120). Running out is not a failure.")
|
|
599
|
+
pst.add_argument("--delivery-id", dest="delivery_id", default=None,
|
|
600
|
+
help="Id for this message (generated if omitted); reusing it "
|
|
601
|
+
"never sends twice.")
|
|
602
|
+
_add_json_flag(pst)
|
|
603
|
+
# Same per-message caps as `send`: a steer's text runs inside a turn, so it
|
|
604
|
+
# can carry the same quota ceiling. The cap is journaled with the delivery.
|
|
605
|
+
_add_cap_flags(pst)
|
|
606
|
+
pst.set_defaults(func=cmd_steer, short=None)
|
|
607
|
+
|
|
608
|
+
prec = sub.add_parser(
|
|
609
|
+
"reconcile",
|
|
610
|
+
help="Look up what happened to an earlier message; never sends.",
|
|
611
|
+
description=(
|
|
612
|
+
"Look up an earlier send or steer without resending it. Use its delivery "
|
|
613
|
+
"ID, or, for Codex, its native turn ID."
|
|
614
|
+
),
|
|
615
|
+
)
|
|
616
|
+
prec.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
617
|
+
source = prec.add_mutually_exclusive_group(required=True)
|
|
618
|
+
source.add_argument("--delivery-id", metavar="ID", default=None,
|
|
619
|
+
help="Delivery id from send or steer.")
|
|
620
|
+
source.add_argument("--turn-id", metavar="TURN_ID", default=None,
|
|
621
|
+
help="Codex only: Codex turn id.")
|
|
622
|
+
_add_json_flag(prec)
|
|
623
|
+
prec.set_defaults(func=cmd_reconcile)
|
|
624
|
+
|
|
625
|
+
pw = sub.add_parser(
|
|
626
|
+
"wait",
|
|
627
|
+
help="Wait until a worker is idle, needs input, or stops.",
|
|
628
|
+
description=(
|
|
629
|
+
"Returns at once if the worker is already idle; otherwise waits with "
|
|
630
|
+
"no time limit unless --timeout is given. The exit code tells the "
|
|
631
|
+
"outcome: 0 idle, 3 dead or stopped, 4 waiting for input, "
|
|
632
|
+
"6 timed out or the turn failed, 7 stopped by a usage cap "
|
|
633
|
+
"(see `ccx --help`). For a person or a Claude parent; a Codex parent "
|
|
634
|
+
"should use `ccx notify` or --notify-parent instead."
|
|
635
|
+
),
|
|
636
|
+
epilog=_examples(
|
|
637
|
+
'ccx wait "$W" --timeout 600',
|
|
638
|
+
'ccx wait "$W" --next-turn # Claude: the turn a background task starts',
|
|
639
|
+
),
|
|
640
|
+
)
|
|
641
|
+
pw.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
642
|
+
_add_json_flag(pw)
|
|
643
|
+
pw.add_argument("--next-turn", dest="next_turn", action="store_true",
|
|
644
|
+
help="Claude only: wait for the next turn that starts after this "
|
|
645
|
+
"command (for example one woken by a background task), even if "
|
|
646
|
+
"the worker is idle now. Also returns on a new input request "
|
|
647
|
+
"(exit 4) or if the worker dies (exit 8/9 for API errors).")
|
|
648
|
+
pw.add_argument("--after-turn", dest="after_turn", default=None, metavar="REF",
|
|
649
|
+
help="Claude only, with --next-turn: the last turn you saw "
|
|
650
|
+
"(`last_settled_turn.turn_ref` from `wait --json`). Returns at "
|
|
651
|
+
"once if a newer turn has finished or input is pending.")
|
|
652
|
+
pw.add_argument("--known-input", dest="known_input", default=None, metavar="ID",
|
|
653
|
+
help="Claude only, with --next-turn: an input request you already saw "
|
|
654
|
+
"(`waiting_for.id`); it doesn't count as new.")
|
|
655
|
+
pw.add_argument("--timeout", type=float, default=None,
|
|
656
|
+
help="Stop waiting after N seconds (default: no limit); exits 6.")
|
|
657
|
+
pw.add_argument("--settle", action="store_true",
|
|
658
|
+
help="Codex only: also wait for descendant notifications and the parent turns they trigger to finish.")
|
|
659
|
+
pw.add_argument("--wake-grace", type=float, default=None,
|
|
660
|
+
help="Codex only, with --settle: seconds a delivered child "
|
|
661
|
+
"notification may take to wake the worker (default 120).")
|
|
662
|
+
pw.set_defaults(func=cmd_wait)
|
|
663
|
+
|
|
664
|
+
pn = sub.add_parser(
|
|
665
|
+
"notify",
|
|
666
|
+
help="Wake the calling Codex parent when a worker's work settles.",
|
|
667
|
+
description=(
|
|
668
|
+
"For a Codex parent: arm a notification for a Claude or Codex "
|
|
669
|
+
"worker's current turn (or running compaction), then end your turn. "
|
|
670
|
+
"CCX resumes you when it settles. Starts no turn. A person or a "
|
|
671
|
+
"Claude parent uses `ccx wait` instead."
|
|
672
|
+
),
|
|
673
|
+
)
|
|
674
|
+
pn.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
675
|
+
pn.add_argument(
|
|
676
|
+
"--next-turn", dest="next_turn", action="store_true",
|
|
677
|
+
help=(
|
|
678
|
+
"Claude only: notify on the worker's next turn instead, for an idle "
|
|
679
|
+
"worker that a background task will wake later."
|
|
680
|
+
),
|
|
681
|
+
)
|
|
682
|
+
_add_json_flag(pn)
|
|
683
|
+
pn.add_argument("--settle", action="store_true",
|
|
684
|
+
help="Codex only: also wait for the workers it started with --notify-parent to settle.")
|
|
685
|
+
pn.set_defaults(func=cmd_notify)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _add_input_parsers(sub) -> None:
|
|
689
|
+
"""Answering a worker's input requests: answer, approve, respond, reject."""
|
|
690
|
+
pans = sub.add_parser(
|
|
691
|
+
"answer",
|
|
692
|
+
help="Answer a Claude worker's question (AskUserQuestion).",
|
|
693
|
+
description=(
|
|
694
|
+
"Claude only. `ccx peek TARGET --status --json` shows the pending question "
|
|
695
|
+
"and its options under waiting_for. "
|
|
696
|
+
"Permission requests use approve/reject; MCP server forms use respond."
|
|
697
|
+
),
|
|
698
|
+
epilog=_examples(
|
|
699
|
+
'ccx answer "$W" "Option label" --wait',
|
|
700
|
+
'ccx answer "$W" --option 1 "Alpha" --option 2 "Beta"',
|
|
701
|
+
),
|
|
702
|
+
)
|
|
703
|
+
pans.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
704
|
+
pans.add_argument("text", nargs="?", help="Answer to a single question: an option label or free text.")
|
|
705
|
+
pans.add_argument("--request-id", default=None,
|
|
706
|
+
help="Question id (default: the pending one; see `ccx peek --status`).")
|
|
707
|
+
pans.add_argument("--stdin", action="store_true", help="Read the answer from stdin.")
|
|
708
|
+
pans.add_argument("--file", default=None, help="Read the answer from a file (`-` means stdin).")
|
|
709
|
+
pans.add_argument(
|
|
710
|
+
"--option",
|
|
711
|
+
nargs=2,
|
|
712
|
+
action="append",
|
|
713
|
+
metavar=("N", "LABEL"),
|
|
714
|
+
default=None,
|
|
715
|
+
help=(
|
|
716
|
+
"Pick option LABEL for question N (1-based). Repeat for "
|
|
717
|
+
"multi-select or several questions."
|
|
718
|
+
),
|
|
719
|
+
)
|
|
720
|
+
pans.add_argument(
|
|
721
|
+
"--text-answer",
|
|
722
|
+
nargs=2,
|
|
723
|
+
action="append",
|
|
724
|
+
metavar=("N", "TEXT"),
|
|
725
|
+
default=None,
|
|
726
|
+
help="Free-text answer for question N (1-based).",
|
|
727
|
+
)
|
|
728
|
+
pans_completion = pans.add_mutually_exclusive_group()
|
|
729
|
+
pans_completion.add_argument("--no-wait", action="store_true", help="Return right away (default).")
|
|
730
|
+
pans_completion.add_argument("--wait", action="store_true", help="Wait for the resumed turn and print the reply.")
|
|
731
|
+
pans_completion.add_argument("--notify-parent", action="store_true", help="Return now; wake the calling Codex parent when the resumed turn settles.")
|
|
732
|
+
pans.add_argument("--timeout", type=float, default=None, help="Limit --wait to N seconds (default: no limit).")
|
|
733
|
+
_add_json_flag(pans)
|
|
734
|
+
pans.set_defaults(func=cmd_answer)
|
|
735
|
+
|
|
736
|
+
pa = sub.add_parser(
|
|
737
|
+
"approve",
|
|
738
|
+
help="Allow a worker's pending permission request.",
|
|
739
|
+
description=(
|
|
740
|
+
"Only workers created with --permissions restricted (or a Codex "
|
|
741
|
+
"approval policy that asks) make permission requests. `ccx peek` "
|
|
742
|
+
"shows the pending request."
|
|
743
|
+
),
|
|
744
|
+
)
|
|
745
|
+
pa.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
746
|
+
pa.add_argument("--request-id", default=None,
|
|
747
|
+
help="Request id (default: the only pending one; see `ccx peek --status`).")
|
|
748
|
+
pa_completion = pa.add_mutually_exclusive_group()
|
|
749
|
+
pa_completion.add_argument("--no-wait", action="store_true", help="Return right away (default).")
|
|
750
|
+
pa_completion.add_argument("--wait", action="store_true", help="Claude only: wait for the resumed turn and print the reply.")
|
|
751
|
+
pa_completion.add_argument("--notify-parent", action="store_true", help="Return now; wake the calling Codex parent when the resumed turn settles.")
|
|
752
|
+
pa.add_argument("--timeout", type=float, default=None, help="Claude only: limit --wait to N seconds (default: no limit).")
|
|
753
|
+
_add_json_flag(pa)
|
|
754
|
+
pa.add_argument("--settle", action="store_true",
|
|
755
|
+
help="Codex only, with --notify-parent: also wait for the workers it "
|
|
756
|
+
"started with --notify-parent to settle.")
|
|
757
|
+
pa.set_defaults(func=cmd_approve)
|
|
758
|
+
|
|
759
|
+
pe = sub.add_parser(
|
|
760
|
+
"respond",
|
|
761
|
+
help="Answer a Claude worker's MCP server form (elicitation).",
|
|
762
|
+
description=(
|
|
763
|
+
"Claude only. An MCP server asked for input mid-tool; `ccx peek` "
|
|
764
|
+
"shows its message and fields. --accept submits the form (fields from "
|
|
765
|
+
"--field and/or --content); --decline says no, which the server may "
|
|
766
|
+
"remember; --cancel dismisses it without answering."
|
|
767
|
+
),
|
|
768
|
+
epilog=_examples(
|
|
769
|
+
'ccx respond "$W" --accept --field name=value --wait',
|
|
770
|
+
'ccx respond "$W" --decline',
|
|
771
|
+
),
|
|
772
|
+
)
|
|
773
|
+
pe.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
774
|
+
pe_action = pe.add_mutually_exclusive_group(required=True)
|
|
775
|
+
pe_action.add_argument("--accept", action="store_true", help="Submit the form.")
|
|
776
|
+
pe_action.add_argument("--decline", action="store_true", help="Say no.")
|
|
777
|
+
pe_action.add_argument("--cancel", action="store_true", help="Dismiss without answering.")
|
|
778
|
+
pe.add_argument("--field", action="append", default=None, metavar="NAME=VALUE",
|
|
779
|
+
help="One form field, typed by the server's schema; repeat for more "
|
|
780
|
+
"(and for multi-select lists).")
|
|
781
|
+
pe.add_argument("--content", default=None, metavar="JSON",
|
|
782
|
+
help="The whole form as a JSON object (--field values override it).")
|
|
783
|
+
pe.add_argument("--request-id", default=None,
|
|
784
|
+
help="Form id (default: the pending one; see `ccx peek --status`).")
|
|
785
|
+
pe_completion = pe.add_mutually_exclusive_group()
|
|
786
|
+
pe_completion.add_argument("--no-wait", action="store_true", help="Return right away (default).")
|
|
787
|
+
pe_completion.add_argument("--wait", action="store_true", help="Wait for the resumed turn and print the reply.")
|
|
788
|
+
pe_completion.add_argument("--notify-parent", action="store_true", help="Return now; wake the calling Codex parent when the resumed turn settles.")
|
|
789
|
+
pe.add_argument("--timeout", type=float, default=None, help="Limit --wait to N seconds (default: no limit).")
|
|
790
|
+
_add_json_flag(pe)
|
|
791
|
+
pe.set_defaults(func=cmd_respond)
|
|
792
|
+
|
|
793
|
+
pr = sub.add_parser(
|
|
794
|
+
"reject",
|
|
795
|
+
help="Deny a worker's pending permission request.",
|
|
796
|
+
description="The worker's turn continues without the denied action. "
|
|
797
|
+
"`ccx peek` shows the pending request.",
|
|
798
|
+
)
|
|
799
|
+
pr.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
800
|
+
pr.add_argument("--request-id", default=None,
|
|
801
|
+
help="Request id (default: the only pending one; see `ccx peek --status`).")
|
|
802
|
+
pr.add_argument("--reason", default=None, help="Claude only: reason shown to Claude.")
|
|
803
|
+
pr_completion = pr.add_mutually_exclusive_group()
|
|
804
|
+
pr_completion.add_argument("--no-wait", action="store_true", help="Return right away (default).")
|
|
805
|
+
pr_completion.add_argument("--wait", action="store_true", help="Claude only: wait for the resumed turn and print the reply.")
|
|
806
|
+
pr_completion.add_argument("--notify-parent", action="store_true", help="Return now; wake the calling Codex parent when the resumed turn settles.")
|
|
807
|
+
pr.add_argument("--timeout", type=float, default=None, help="Claude only: limit --wait to N seconds (default: no limit).")
|
|
808
|
+
_add_json_flag(pr)
|
|
809
|
+
pr.add_argument("--settle", action="store_true",
|
|
810
|
+
help="Codex only, with --notify-parent: also wait for the workers it "
|
|
811
|
+
"started with --notify-parent to settle.")
|
|
812
|
+
pr.set_defaults(func=cmd_reject)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _add_inventory_parsers(sub) -> None:
|
|
816
|
+
"""Finding and reading workers: import, attach, list, logs, report, context;
|
|
817
|
+
plus `skills`, which installs the bundled agent skills."""
|
|
818
|
+
pskills = sub.add_parser(
|
|
819
|
+
"skills",
|
|
820
|
+
help="Install the CCX agent skill for Claude Code, Codex and other agents.",
|
|
821
|
+
description=(
|
|
822
|
+
"Copy the CCX agent skill into agent skill folders: Claude Code's "
|
|
823
|
+
"(~/.claude/skills) when Claude Code is installed, and the shared "
|
|
824
|
+
"~/.agents/skills (Codex and other agents) when Codex is installed or "
|
|
825
|
+
"that folder exists. Copies CCX made are refreshed automatically "
|
|
826
|
+
"after a CCX upgrade; folders CCX didn't create are never replaced "
|
|
827
|
+
"without --force."
|
|
828
|
+
),
|
|
829
|
+
epilog=_examples(
|
|
830
|
+
"ccx skills install",
|
|
831
|
+
"ccx skills install --dir ~/.config/myagent/skills",
|
|
832
|
+
"ccx skills uninstall",
|
|
833
|
+
),
|
|
834
|
+
)
|
|
835
|
+
skills_sub = pskills.add_subparsers(dest="skills_cmd", metavar="{install,uninstall}")
|
|
836
|
+
sinstall = skills_sub.add_parser(
|
|
837
|
+
"install", help="Install or update the skill.",
|
|
838
|
+
description="Install or update the CCX skill in the default folders, or in --dir.")
|
|
839
|
+
sinstall.add_argument("--dir", metavar="DIR",
|
|
840
|
+
help="Install into this skills folder instead of the defaults.")
|
|
841
|
+
sinstall.add_argument("--force", action="store_true",
|
|
842
|
+
help="Replace a skill folder CCX didn't create.")
|
|
843
|
+
suninstall = skills_sub.add_parser(
|
|
844
|
+
"uninstall", help="Remove the skill copies CCX installed.",
|
|
845
|
+
description="Remove the CCX skill from the default folders, or from --dir. "
|
|
846
|
+
"Only folders CCX installed are removed.")
|
|
847
|
+
suninstall.add_argument("--dir", metavar="DIR",
|
|
848
|
+
help="Remove from this skills folder instead of the defaults.")
|
|
849
|
+
pskills.set_defaults(func=skills.cmd_skills, skills_parser=pskills)
|
|
850
|
+
|
|
851
|
+
pi = sub.add_parser(
|
|
852
|
+
"import",
|
|
853
|
+
help="Import a Claude session or copy a Codex thread into a CCX worker.",
|
|
854
|
+
description=(
|
|
855
|
+
"Claude: resume an idle interactive session as a managed worker; stop "
|
|
856
|
+
"using the old terminal. Codex: fork the last completed turn into a "
|
|
857
|
+
"new full-permission CCX worker, preserving inherited history and "
|
|
858
|
+
"leaving the original Codex thread untouched. Importing the same "
|
|
859
|
+
"Codex thread again creates another independent fork."
|
|
860
|
+
),
|
|
861
|
+
)
|
|
862
|
+
pi.add_argument("session", help="Exact full native Claude session or Codex thread UUID.")
|
|
863
|
+
pi.add_argument("--engine", choices=["claude", "codex"],
|
|
864
|
+
help="Select the engine explicitly (default: detect from exact native ID).")
|
|
865
|
+
pi.add_argument("--model", help="Codex child model override.")
|
|
866
|
+
pi.add_argument("--effort", help="Codex child reasoning-effort override.")
|
|
867
|
+
pi.set_defaults(func=cmd_import)
|
|
868
|
+
|
|
869
|
+
pat = sub.add_parser(
|
|
870
|
+
"attach",
|
|
871
|
+
help="Open a Claude or Codex worker in your terminal.",
|
|
872
|
+
description=(
|
|
873
|
+
"Needs a terminal. A stopped worker is revived; CCX commands remain "
|
|
874
|
+
"available while attached. For both engines, Ctrl-Z detaches and "
|
|
875
|
+
"leaves the worker running. The CCX status "
|
|
876
|
+
"bar is shown unless --no-status. Codex runs its real native UI; "
|
|
877
|
+
"/quit also exits. Codex may first show its own startup prompts. "
|
|
878
|
+
"Model/permission changes made there do not "
|
|
879
|
+
"change CCX's recorded settings for its own sends."
|
|
880
|
+
),
|
|
881
|
+
)
|
|
882
|
+
pat.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
883
|
+
pat.add_argument("--no-status", action="store_true",
|
|
884
|
+
help="Hide the CCX status bar for Claude or Codex.")
|
|
885
|
+
pat.set_defaults(func=cmd_attach)
|
|
886
|
+
|
|
887
|
+
pl = sub.add_parser(
|
|
888
|
+
"list",
|
|
889
|
+
help="List workers.",
|
|
890
|
+
description=(
|
|
891
|
+
"List Claude and Codex workers with their handles, names and states. "
|
|
892
|
+
"States come from CCX's records, not a live check."
|
|
893
|
+
),
|
|
894
|
+
)
|
|
895
|
+
_add_json_flag(pl)
|
|
896
|
+
pl.add_argument("--engine", choices=("claude", "codex"), default=None,
|
|
897
|
+
help="Only workers of this engine.")
|
|
898
|
+
pl.add_argument("--cwd", default=None,
|
|
899
|
+
help="Only workers whose cwd is exactly this directory.")
|
|
900
|
+
pl.add_argument("--cwd-prefix", "--dir", dest="cwd_prefix", default=None,
|
|
901
|
+
help="Only workers whose cwd is this directory or inside it.")
|
|
902
|
+
pl.add_argument("--state", default=None,
|
|
903
|
+
choices=("starting", "idle", "busy", "waiting", "stopped",
|
|
904
|
+
"failed", "unavailable"),
|
|
905
|
+
help="Only workers in this state.")
|
|
906
|
+
pl.add_argument("--active", action="store_true",
|
|
907
|
+
help="Only workers recorded as starting, idle, busy or waiting.")
|
|
908
|
+
pl.add_argument("--sendable", action="store_true",
|
|
909
|
+
help="Only workers recorded as idle or stopped (send still checks).")
|
|
910
|
+
pl.add_argument("--recent-hours", dest="recent_hours", type=float, default=None,
|
|
911
|
+
help="Only workers created or active in the last N hours.")
|
|
912
|
+
pl.add_argument("-n", "--limit", type=int, default=None,
|
|
913
|
+
help="Only the N most recently active workers, newest first.")
|
|
914
|
+
pl.set_defaults(func=cmd_list)
|
|
915
|
+
|
|
916
|
+
pg = sub.add_parser(
|
|
917
|
+
"logs",
|
|
918
|
+
help="Print a worker's whole conversation as JSON.",
|
|
919
|
+
description=(
|
|
920
|
+
"Print the conversation as JSON, also for stopped workers; nothing is "
|
|
921
|
+
"sent. Tool results are cut to --max-tool-result-bytes unless --full. "
|
|
922
|
+
"For a quick look, use `ccx peek` or `ccx report`."
|
|
923
|
+
),
|
|
924
|
+
)
|
|
925
|
+
pg.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
926
|
+
pg.add_argument("--tail", type=int, default=None, help="Only the last N turns. Only those turns are read, so Claude numbers them from 1 and Codex leaves them unnumbered.")
|
|
927
|
+
pg.add_argument("--format", choices=("conversation", "rendered"), default="conversation",
|
|
928
|
+
help="conversation (default) or rendered (Claude only: per-turn "
|
|
929
|
+
"transcript with tool calls paired to their results).")
|
|
930
|
+
pg.add_argument("--max-tool-result-bytes", dest="max_tool_result_bytes", type=int,
|
|
931
|
+
default=4096,
|
|
932
|
+
help="Cut each tool result to N bytes (default 4096).")
|
|
933
|
+
pg.add_argument("--full", action="store_true",
|
|
934
|
+
help="Don't cut tool results.")
|
|
935
|
+
pg.add_argument("--jsonl", action="store_true",
|
|
936
|
+
help="Claude only: print Claude's raw JSONL transcript.")
|
|
937
|
+
pg.set_defaults(func=cmd_logs)
|
|
938
|
+
|
|
939
|
+
prep = sub.add_parser(
|
|
940
|
+
"report",
|
|
941
|
+
help="Print the last reply of one or more workers.",
|
|
942
|
+
description=(
|
|
943
|
+
"Print the latest turn's reply. A completed turn with no answer prints "
|
|
944
|
+
"(turn completed; no reply); failed and interrupted turns without "
|
|
945
|
+
"answers have matching markers. When an older confirmed reply is "
|
|
946
|
+
"shown because a newer turn is still running or waiting for input, "
|
|
947
|
+
"native history cannot be read, or a worker failed, a note appears "
|
|
948
|
+
"on stderr (`note` in "
|
|
949
|
+
"JSON). With several workers, "
|
|
950
|
+
"text output has `=== <worker> ===` headers; the exit code is nonzero "
|
|
951
|
+
"if any worker can't be reported, but the others still print."
|
|
952
|
+
),
|
|
953
|
+
)
|
|
954
|
+
prep.add_argument("targets", nargs="+", metavar="target",
|
|
955
|
+
help="One or more workers; an ambiguous prefix rejects the whole batch.")
|
|
956
|
+
prep.add_argument("--max-chars", type=int, default=None,
|
|
957
|
+
help="Cut each reply to N characters.")
|
|
958
|
+
prep.add_argument("--json", action="store_true",
|
|
959
|
+
help="Print JSON; several workers give {reports, errors, ok}.")
|
|
960
|
+
prep.set_defaults(func=cmd_report)
|
|
961
|
+
|
|
962
|
+
pctx = sub.add_parser(
|
|
963
|
+
"context",
|
|
964
|
+
help="Show how much context a worker is carrying.",
|
|
965
|
+
description=(
|
|
966
|
+
"Show the worker's latest context size from CCX's records. Works for "
|
|
967
|
+
"idle, busy and stopped workers and never revives them. Right after "
|
|
968
|
+
"a compaction, tokens_used can be null until the next reading; "
|
|
969
|
+
"`reason` says why."
|
|
970
|
+
),
|
|
971
|
+
)
|
|
972
|
+
pctx.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
973
|
+
pctx.add_argument("--json", action="store_true",
|
|
974
|
+
help="Print {engine, worker, short, name, state, tokens_used, "
|
|
975
|
+
"window_size, percent_used, as_of_turn_ref, as_of_ms, reason}, "
|
|
976
|
+
"the same for both engines.")
|
|
977
|
+
pctx.set_defaults(func=cmd_context)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def _add_control_parsers(sub) -> None:
|
|
981
|
+
"""Session control: kill, compact, set, stop, and machine-level shutdown
|
|
982
|
+
and untrust."""
|
|
983
|
+
pk = sub.add_parser(
|
|
984
|
+
"kill",
|
|
985
|
+
help="Stop a worker's runtime; history is kept and send revives it.",
|
|
986
|
+
description=(
|
|
987
|
+
"Cancel any running turn, then stop the worker and confirm it is "
|
|
988
|
+
"gone: a Claude worker's process exits; a Codex worker is unloaded "
|
|
989
|
+
"from the shared Codex daemon. The history is kept, so the next "
|
|
990
|
+
"`ccx send` continues the same conversation. To cancel only the "
|
|
991
|
+
"current turn, use `ccx stop`."
|
|
992
|
+
),
|
|
993
|
+
)
|
|
994
|
+
pk.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
995
|
+
pk.add_argument("--timeout", type=float, default=KILL_TIMEOUT_DEFAULT_S,
|
|
996
|
+
help="Seconds to confirm it stopped (default 90).")
|
|
997
|
+
pk.add_argument("--json", action="store_true",
|
|
998
|
+
help="Print JSON instead of text.")
|
|
999
|
+
pk.set_defaults(func=cmd_kill)
|
|
1000
|
+
|
|
1001
|
+
pc = sub.add_parser(
|
|
1002
|
+
"compact",
|
|
1003
|
+
help="Compact a worker's context with the engine's own compaction.",
|
|
1004
|
+
description=(
|
|
1005
|
+
"Run the engine's native compaction (Claude's /compact) and wait for "
|
|
1006
|
+
"it. A stopped worker is revived first."
|
|
1007
|
+
),
|
|
1008
|
+
)
|
|
1009
|
+
pc.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
1010
|
+
pc.add_argument(
|
|
1011
|
+
"--operation-id", default=None,
|
|
1012
|
+
help="Claude only: id for this compaction (generated if omitted); reusing "
|
|
1013
|
+
"it checks the earlier one instead of compacting again.",
|
|
1014
|
+
)
|
|
1015
|
+
pc.add_argument("--settle", action="store_true",
|
|
1016
|
+
help="Codex only, with --notify-parent: also wait for the workers it "
|
|
1017
|
+
"started with --notify-parent to settle.")
|
|
1018
|
+
pc_completion = pc.add_mutually_exclusive_group()
|
|
1019
|
+
pc_completion.add_argument("--no-wait", action="store_true",
|
|
1020
|
+
help="Return once compaction has started.")
|
|
1021
|
+
pc_completion.add_argument("--notify-parent", action="store_true",
|
|
1022
|
+
help="Return now; wake the calling Codex parent when compaction settles.")
|
|
1023
|
+
pc.add_argument("--timeout", type=float, default=COMPACT_WAIT_DEFAULT_S,
|
|
1024
|
+
help=f"Seconds to wait for compaction (default {COMPACT_WAIT_DEFAULT_S:g}).")
|
|
1025
|
+
_add_json_flag(pc)
|
|
1026
|
+
pc.set_defaults(func=cmd_compact)
|
|
1027
|
+
|
|
1028
|
+
pset = sub.add_parser(
|
|
1029
|
+
"set",
|
|
1030
|
+
help="Rename a worker or change its model, effort or permissions.",
|
|
1031
|
+
description=(
|
|
1032
|
+
"A new name applies at once, in any state. Codex: model and effort "
|
|
1033
|
+
"need an idle or stopped worker and apply from its next turn. Claude: model, effort and permissions "
|
|
1034
|
+
"apply when the worker next starts, so a running worker is refused "
|
|
1035
|
+
"unless you add --restart (allowed when it is idle)."
|
|
1036
|
+
),
|
|
1037
|
+
)
|
|
1038
|
+
pset.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
1039
|
+
pset.add_argument(
|
|
1040
|
+
"--name",
|
|
1041
|
+
default=None,
|
|
1042
|
+
help='Display name; automatic naming won\'t replace it. --name "" restores '
|
|
1043
|
+
"the default name and automatic naming.",
|
|
1044
|
+
)
|
|
1045
|
+
pset.add_argument(
|
|
1046
|
+
"--model",
|
|
1047
|
+
default=None,
|
|
1048
|
+
help="Model for future turns; 'default' clears Codex's override and is "
|
|
1049
|
+
"passed as the model name for Claude.",
|
|
1050
|
+
)
|
|
1051
|
+
pset.add_argument(
|
|
1052
|
+
"--permissions",
|
|
1053
|
+
choices=["full", "restricted"],
|
|
1054
|
+
default=None,
|
|
1055
|
+
help="Claude only: full (no permission prompts) or restricted (requests "
|
|
1056
|
+
"go to `ccx approve`/`reject`).",
|
|
1057
|
+
)
|
|
1058
|
+
pset.add_argument(
|
|
1059
|
+
"--restart",
|
|
1060
|
+
action="store_true",
|
|
1061
|
+
help="Claude only: apply the changes now by stopping the idle worker; "
|
|
1062
|
+
"the next send revives it with them.",
|
|
1063
|
+
)
|
|
1064
|
+
pset.add_argument(
|
|
1065
|
+
"--effort",
|
|
1066
|
+
default=None,
|
|
1067
|
+
choices=["low", "medium", "high", "xhigh", "max", "default"],
|
|
1068
|
+
help="Reasoning effort for future turns ('default' clears it). On Claude, "
|
|
1069
|
+
"changing it mid-conversation makes the next turn re-read the whole "
|
|
1070
|
+
"context without the prompt cache.",
|
|
1071
|
+
)
|
|
1072
|
+
pset.add_argument("--json", action="store_true",
|
|
1073
|
+
help="Print JSON instead of text.")
|
|
1074
|
+
pset.set_defaults(func=cmd_set)
|
|
1075
|
+
|
|
1076
|
+
pstop = sub.add_parser(
|
|
1077
|
+
"stop",
|
|
1078
|
+
help="Request cancellation of the current turn; keep the worker running.",
|
|
1079
|
+
description=(
|
|
1080
|
+
"Request cancellation of the current turn; keep the worker running. Exit 0 "
|
|
1081
|
+
"means the interrupt was accepted. Inspect `confirmed` and `outcome` in "
|
|
1082
|
+
"--json to see whether cancellation has settled. Refused with exit 5 if the worker is idle, exit 3 if it isn't running."
|
|
1083
|
+
),
|
|
1084
|
+
)
|
|
1085
|
+
pstop.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
1086
|
+
pstop.add_argument("--json", action="store_true",
|
|
1087
|
+
help="Print JSON instead of text.")
|
|
1088
|
+
pstop.set_defaults(func=cmd_stop)
|
|
1089
|
+
|
|
1090
|
+
pshutdown = sub.add_parser(
|
|
1091
|
+
"shutdown",
|
|
1092
|
+
help="Stop all CCX runtimes, or one engine's.",
|
|
1093
|
+
description=(
|
|
1094
|
+
"Without --engine: kill every Claude worker, unload every Codex "
|
|
1095
|
+
"worker and stop the Codex daemon, then stop the notification "
|
|
1096
|
+
"daemon. Histories are kept and the next send revives a worker; "
|
|
1097
|
+
"pending parent notifications are delivered after CCX starts again. "
|
|
1098
|
+
"Claude sessions outside CCX and running controllers are left alone.\n\n"
|
|
1099
|
+
"With --engine, only that engine's workers stop and notifications "
|
|
1100
|
+
"keep running. For Codex, the daemon stops too unless workers CCX "
|
|
1101
|
+
"doesn't know about are still loaded."
|
|
1102
|
+
),
|
|
1103
|
+
)
|
|
1104
|
+
pshutdown.add_argument("--engine", choices=("claude", "codex"),
|
|
1105
|
+
help="Stop only this engine's workers.")
|
|
1106
|
+
pshutdown.add_argument("--dry-run", action="store_true",
|
|
1107
|
+
help="List what would be stopped without stopping it.")
|
|
1108
|
+
pshutdown.add_argument("--timeout", type=float, default=KILL_TIMEOUT_DEFAULT_S,
|
|
1109
|
+
help="Seconds each kill may take (default: %(default)s).")
|
|
1110
|
+
pshutdown.add_argument("--json", action="store_true",
|
|
1111
|
+
help="Print JSON instead of text.")
|
|
1112
|
+
pshutdown.set_defaults(func=cmd_shutdown)
|
|
1113
|
+
|
|
1114
|
+
puntrust = sub.add_parser(
|
|
1115
|
+
"untrust",
|
|
1116
|
+
help="Remove the folder trust CCX added to Claude's config.",
|
|
1117
|
+
description=(
|
|
1118
|
+
"Claude asks to trust each new folder; CCX records the trust it adds "
|
|
1119
|
+
"for worker cwds in .claude.json. This undoes exactly those changes "
|
|
1120
|
+
"(and restores any value CCX overrode). Entries changed since, or "
|
|
1121
|
+
"not added by CCX, are left alone. A running claude process can write "
|
|
1122
|
+
"the file back; if an entry returns, run it again with Claude closed."
|
|
1123
|
+
),
|
|
1124
|
+
)
|
|
1125
|
+
puntrust.add_argument("paths", nargs="*", metavar="PATH",
|
|
1126
|
+
help="Only these folders (default: every recorded one).")
|
|
1127
|
+
puntrust.add_argument("--dry-run", action="store_true",
|
|
1128
|
+
help="Show what would change without writing.")
|
|
1129
|
+
puntrust.add_argument("--json", action="store_true",
|
|
1130
|
+
help="Print JSON instead of text.")
|
|
1131
|
+
puntrust.set_defaults(func=cmd_untrust)
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _add_observability_parsers(sub) -> None:
|
|
1135
|
+
"""Quota and session observation: usage, peek, events."""
|
|
1136
|
+
pu = sub.add_parser(
|
|
1137
|
+
"usage",
|
|
1138
|
+
help="Show your Claude or Codex account usage.",
|
|
1139
|
+
description=(
|
|
1140
|
+
"Claude: 5-hour, 7-day and per-model quota use with reset times. "
|
|
1141
|
+
"Codex: account quota and, with --tokens, token activity."
|
|
1142
|
+
),
|
|
1143
|
+
)
|
|
1144
|
+
pu.add_argument("--engine", choices=("claude", "codex"),
|
|
1145
|
+
help="Read only this account (default: both).")
|
|
1146
|
+
usage_shape = pu.add_mutually_exclusive_group()
|
|
1147
|
+
usage_shape.add_argument("--json", action="store_true", help="Print the full usage data as JSON.")
|
|
1148
|
+
usage_shape.add_argument("--summary", action="store_true", help="Claude only: compact quota JSON (five_hour, seven_day, model_quotas, and extra_usage when present).")
|
|
1149
|
+
usage_shape.add_argument("--set-source", choices=("get_usage", "oauth"),
|
|
1150
|
+
help="Claude only: save the usage source for all CCX processes sharing this root; fetches nothing. oauth is Linux-only.")
|
|
1151
|
+
pu.add_argument("--cached", action="store_true", help="Claude only: reuse a recent shared reading instead of fetching now.")
|
|
1152
|
+
pu.add_argument("--tokens", action="store_true",
|
|
1153
|
+
help="Codex account token activity.")
|
|
1154
|
+
pu.set_defaults(func=cmd_usage)
|
|
1155
|
+
|
|
1156
|
+
ppk = sub.add_parser(
|
|
1157
|
+
"peek",
|
|
1158
|
+
help="Show a worker's recent activity (--status: just its state).",
|
|
1159
|
+
description=(
|
|
1160
|
+
"Show the last user and assistant messages, recent tool calls, any "
|
|
1161
|
+
"pending question or permission request, and warnings. Works in any "
|
|
1162
|
+
"state and never starts a turn. --status skips the conversation and "
|
|
1163
|
+
"reads only CCX's records, for one or many workers."
|
|
1164
|
+
),
|
|
1165
|
+
epilog=_examples(
|
|
1166
|
+
'ccx peek "$W"',
|
|
1167
|
+
'ccx peek "$W1" "$W2" --status --json',
|
|
1168
|
+
),
|
|
1169
|
+
)
|
|
1170
|
+
ppk.add_argument("target", nargs="+", metavar="target", action=_PeekTargets,
|
|
1171
|
+
help=_WORKER_QUERY_HELP +
|
|
1172
|
+
" More targets are allowed only with --status.")
|
|
1173
|
+
ppk.add_argument("--status", action="store_true",
|
|
1174
|
+
help="Only status fields (engine, worker, short, state, turn, "
|
|
1175
|
+
"last_turn_age_s, context with tokens_used, and pending "
|
|
1176
|
+
"input: Claude waiting_for, Codex pending_input), no "
|
|
1177
|
+
"conversation text. --json prints {statuses: [...]}.")
|
|
1178
|
+
ppk.add_argument("--tail", type=int, default=4,
|
|
1179
|
+
help="How many recent tool calls to show (default 4; 0 for none).")
|
|
1180
|
+
ppk.add_argument("--since-turn", type=int, default=None,
|
|
1181
|
+
help="Only turns from number N on (1-based).")
|
|
1182
|
+
ppk.add_argument("--tools-only", action="store_true", help="Show only the recent tool calls.")
|
|
1183
|
+
ppk.add_argument("--text-only", action="store_true", help="Show only the last user and assistant messages.")
|
|
1184
|
+
ppk.add_argument("--full", action="store_true", help="Don't shorten messages or tool errors.")
|
|
1185
|
+
ppk.add_argument("--max-chars", type=int, default=None,
|
|
1186
|
+
help="Character limit for the text view (default 8000; none with --full).")
|
|
1187
|
+
ppk.add_argument("--json", action="store_true", help="Print JSON.")
|
|
1188
|
+
ppk.add_argument("--brief", action="store_true",
|
|
1189
|
+
help="Short view: state, cwd, a preview of the last reply, tool "
|
|
1190
|
+
"names and any pending input. Ignored with --json.")
|
|
1191
|
+
ppk.set_defaults(func=cmd_peek)
|
|
1192
|
+
|
|
1193
|
+
pev = sub.add_parser(
|
|
1194
|
+
"events",
|
|
1195
|
+
help="Read or follow a worker's lifecycle events.",
|
|
1196
|
+
description=(
|
|
1197
|
+
"State changes, turn start and end, tool calls, input requests, "
|
|
1198
|
+
"compactions and errors, without message content. Each event has a "
|
|
1199
|
+
"`cursor`; pass it to --after to continue from there. For "
|
|
1200
|
+
"monitoring; to wait for a result, use `ccx wait` or `ccx notify`."
|
|
1201
|
+
),
|
|
1202
|
+
)
|
|
1203
|
+
pev.add_argument("target", help=_WORKER_QUERY_HELP)
|
|
1204
|
+
_add_events_args(pev)
|
|
1205
|
+
pev.set_defaults(func=cmd_events)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
def _add_internal_parsers(sub) -> None:
|
|
1209
|
+
"""Internal plumbing re-invoked by CCX itself: hooks/statusline relays, the host, live-vars exec."""
|
|
1210
|
+
ph = sub.add_parser("hooks-relay", help=argparse.SUPPRESS)
|
|
1211
|
+
ph.add_argument("short")
|
|
1212
|
+
ph.add_argument("event")
|
|
1213
|
+
ph.set_defaults(func=cmd_hooks_relay)
|
|
1214
|
+
|
|
1215
|
+
psline = sub.add_parser("statusline-relay", help=argparse.SUPPRESS)
|
|
1216
|
+
psline.add_argument("short")
|
|
1217
|
+
psline.set_defaults(func=cmd_statusline_relay)
|
|
1218
|
+
|
|
1219
|
+
phost = sub.add_parser("host", help=argparse.SUPPRESS)
|
|
1220
|
+
phost.add_argument("short")
|
|
1221
|
+
phost.add_argument("--detach", action="store_true",
|
|
1222
|
+
help="Daemonize: double-fork the real host (reparented to "
|
|
1223
|
+
"init), wait for its socket, print a one-line JSON "
|
|
1224
|
+
"verdict, and exit. Used by spawn/revive so library "
|
|
1225
|
+
"callers never fork in their own process.")
|
|
1226
|
+
phost.add_argument("--sock-wait", dest="sock_wait", type=float,
|
|
1227
|
+
default=HOST_SOCK_WAIT_NEW_S,
|
|
1228
|
+
help="With --detach: max seconds to wait for the host "
|
|
1229
|
+
"socket before reporting sock-timeout.")
|
|
1230
|
+
phost.set_defaults(func=cmd_host)
|
|
1231
|
+
|
|
1232
|
+
plivevars = sub.add_parser(
|
|
1233
|
+
"_live-vars-exec",
|
|
1234
|
+
help=argparse.SUPPRESS,
|
|
1235
|
+
add_help=False,
|
|
1236
|
+
)
|
|
1237
|
+
plivevars.add_argument("--root", default=None)
|
|
1238
|
+
plivevars.add_argument("--engine", required=True, choices=["claude", "codex"])
|
|
1239
|
+
plivevars.add_argument("--short", required=True)
|
|
1240
|
+
plivevars.add_argument("--invoked-as", required=True, choices=["python", "python3"])
|
|
1241
|
+
plivevars.add_argument("target_args", nargs=argparse.REMAINDER)
|
|
1242
|
+
plivevars.set_defaults(func=live_vars.cmd_worker_exec)
|
|
1243
|
+
|
|
1244
|
+
|
|
1245
|
+
class _VersionAction(argparse.Action):
|
|
1246
|
+
"""`-V/--version` for --help; resolves the version only when used.
|
|
1247
|
+
|
|
1248
|
+
cli.main answers a bare `ccx --version` before building the parser so it
|
|
1249
|
+
never creates ~/.ccx; this covers the remaining argparse paths.
|
|
1250
|
+
"""
|
|
1251
|
+
|
|
1252
|
+
def __init__(self, option_strings, dest=argparse.SUPPRESS, default=argparse.SUPPRESS, help=None):
|
|
1253
|
+
super().__init__(option_strings, dest=dest, default=default, nargs=0, help=help)
|
|
1254
|
+
|
|
1255
|
+
def __call__(self, parser, namespace, values, option_string=None):
|
|
1256
|
+
print(f"ccx {ccx_version()}")
|
|
1257
|
+
parser.exit()
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def build_parser():
|
|
1261
|
+
p = _CcxArgumentParser(
|
|
1262
|
+
prog="ccx",
|
|
1263
|
+
usage="ccx [-h] [-V] <command> ...",
|
|
1264
|
+
)
|
|
1265
|
+
p.add_argument("-V", "--version", action=_VersionAction, help="Show the ccx version and exit.")
|
|
1266
|
+
sub = p.add_subparsers(dest="cmd", required=True, metavar="<command>", prog="ccx")
|
|
1267
|
+
|
|
1268
|
+
# Fresh CCX only; distinct from I3 migration/cutover of an existing root.
|
|
1269
|
+
# Parsing --help/--version and failing --root must NEVER mutate CCX state.
|
|
1270
|
+
from ccx.cli.commands.create import cmd_init as _cmd_init_full_uuid
|
|
1271
|
+
init = sub.add_parser(
|
|
1272
|
+
"init",
|
|
1273
|
+
help="Create the store up front (optional: the first command creates it).",
|
|
1274
|
+
description=(
|
|
1275
|
+
"Create an empty store at --root, $CCX_ROOT or ~/.ccx. Optional: "
|
|
1276
|
+
"commands that need a store create it when it's missing. Refused if a store "
|
|
1277
|
+
"already exists there. Other commands find the store through "
|
|
1278
|
+
"$CCX_ROOT, so set it when you use --root."
|
|
1279
|
+
),
|
|
1280
|
+
)
|
|
1281
|
+
init.add_argument("--root",
|
|
1282
|
+
help="Absolute store directory (default: $CCX_ROOT or ~/.ccx).")
|
|
1283
|
+
init.add_argument("--json", action="store_true",
|
|
1284
|
+
help="Print the new store's details as JSON.")
|
|
1285
|
+
init.set_defaults(func=_cmd_init_full_uuid)
|
|
1286
|
+
|
|
1287
|
+
prpc = sub.add_parser(
|
|
1288
|
+
"rpc",
|
|
1289
|
+
help="Local JSON protocol for applications (requests on stdin).",
|
|
1290
|
+
description=(
|
|
1291
|
+
"Read one JSON request from stdin and write one JSON response line "
|
|
1292
|
+
"to stdout; with --serve, handle newline-delimited requests until "
|
|
1293
|
+
"stdin closes. Protocol version 2:\n\n"
|
|
1294
|
+
' {"protocol_version": 2, "request_id": "r1", "op": "worker.inspect",\n'
|
|
1295
|
+
' "params": {"target": "<worker>"}}\n\n'
|
|
1296
|
+
"Operations: " + ", ".join(sorted(local_rpc._FULL_UUID_SUPPORTED_OPS))
|
|
1297
|
+
+ "; with --serve also " + local_rpc.CANCEL_OP + "."
|
|
1298
|
+
),
|
|
1299
|
+
)
|
|
1300
|
+
prpc.add_argument(
|
|
1301
|
+
"--serve",
|
|
1302
|
+
action="store_true",
|
|
1303
|
+
help=(
|
|
1304
|
+
"Keep reading requests; responses are written as each completes and "
|
|
1305
|
+
"matched by request_id."
|
|
1306
|
+
),
|
|
1307
|
+
)
|
|
1308
|
+
prpc.set_defaults(func=local_rpc.cmd_rpc)
|
|
1309
|
+
|
|
1310
|
+
_add_controller_parsers(sub)
|
|
1311
|
+
_add_session_parsers(sub)
|
|
1312
|
+
_add_input_parsers(sub)
|
|
1313
|
+
_add_inventory_parsers(sub)
|
|
1314
|
+
_add_control_parsers(sub)
|
|
1315
|
+
_add_observability_parsers(sub)
|
|
1316
|
+
_add_internal_parsers(sub)
|
|
1317
|
+
|
|
1318
|
+
# The grouped overview replaces argparse's flat command list; internal
|
|
1319
|
+
# commands (help=SUPPRESS) still parse but are listed nowhere.
|
|
1320
|
+
p.description = OVERVIEW + "\n\n" + _command_overview(sub)
|
|
1321
|
+
p.epilog = ENVIRONMENT + "\n\n" + EXIT_CODES
|
|
1322
|
+
sub._choices_actions = []
|
|
1323
|
+
sub.help = argparse.SUPPRESS
|
|
1324
|
+
return p
|