ccx-native 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +807 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +269 -0
- package/ccx/claude/host/session.py +5244 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1558 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2424 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1454 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/models.py +225 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +296 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Install the bundled agent skills into agent skill folders.
|
|
2
|
+
|
|
3
|
+
A wheel carries the skills as package data in ``ccx/_skills``; a source
|
|
4
|
+
checkout or editable install uses the repository's ``skills/`` directory.
|
|
5
|
+
Installs are copies with a marker file, so CCX only ever replaces or removes
|
|
6
|
+
folders it created, and refreshes them after an upgrade.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import uuid
|
|
19
|
+
|
|
20
|
+
MARKER = ".ccx-skill.json"
|
|
21
|
+
_IGNORE = shutil.ignore_patterns("__pycache__", "*.pyc", MARKER)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def skills_dir() -> Path:
|
|
25
|
+
package = Path(__file__).resolve().parents[1]
|
|
26
|
+
bundled = package / "_skills"
|
|
27
|
+
if bundled.is_dir():
|
|
28
|
+
return bundled
|
|
29
|
+
return package.parent / "skills"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def bundled_skills() -> dict[str, Path]:
|
|
33
|
+
root = skills_dir()
|
|
34
|
+
if not root.is_dir():
|
|
35
|
+
return {}
|
|
36
|
+
return {p.name: p for p in sorted(root.iterdir()) if (p / "SKILL.md").is_file()}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _digest(folder: Path) -> str:
|
|
40
|
+
h = hashlib.sha256()
|
|
41
|
+
for path in sorted(folder.rglob("*")):
|
|
42
|
+
rel = path.relative_to(folder)
|
|
43
|
+
if not path.is_file() or rel.name == MARKER or "__pycache__" in rel.parts \
|
|
44
|
+
or rel.suffix == ".pyc":
|
|
45
|
+
continue
|
|
46
|
+
h.update(str(rel).encode() + b"\0" + path.read_bytes() + b"\0")
|
|
47
|
+
return h.hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _claude_skills_dir() -> Path:
|
|
51
|
+
config = os.environ.get("CLAUDE_CONFIG_DIR")
|
|
52
|
+
return (Path(config).expanduser() if config else Path.home() / ".claude") / "skills"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _agents_skills_dir() -> Path:
|
|
56
|
+
return Path.home() / ".agents" / "skills"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _installed(engine: str) -> bool:
|
|
60
|
+
from ccx.errors import CcxError
|
|
61
|
+
from ccx.system.engine_binary import resolve_engine_binary
|
|
62
|
+
try:
|
|
63
|
+
resolve_engine_binary(engine)
|
|
64
|
+
except CcxError:
|
|
65
|
+
return False
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def default_targets() -> list[Path]:
|
|
70
|
+
"""Claude Code's folder, and the shared ~/.agents/skills (Codex and others)."""
|
|
71
|
+
targets = []
|
|
72
|
+
if _installed("claude"):
|
|
73
|
+
targets.append(_claude_skills_dir())
|
|
74
|
+
agents = _agents_skills_dir()
|
|
75
|
+
if _installed("codex") or agents.is_dir():
|
|
76
|
+
targets.append(agents)
|
|
77
|
+
return targets
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _marker(folder: Path) -> dict | None:
|
|
81
|
+
if folder.is_symlink() or not folder.is_dir():
|
|
82
|
+
return None
|
|
83
|
+
try:
|
|
84
|
+
data = json.loads((folder / MARKER).read_text(encoding="utf-8"))
|
|
85
|
+
except (OSError, ValueError):
|
|
86
|
+
return None
|
|
87
|
+
return data if isinstance(data, dict) and data.get("installedBy") == "ccx" else None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _remove(folder: Path) -> None:
|
|
91
|
+
if folder.is_symlink() or folder.is_file():
|
|
92
|
+
folder.unlink()
|
|
93
|
+
elif folder.exists():
|
|
94
|
+
shutil.rmtree(folder)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _copy(source: Path, dest: Path, digest: str) -> None:
|
|
98
|
+
"""Copy next to dest, then swap it in, so readers never see half a skill."""
|
|
99
|
+
from ccx.version import ccx_version
|
|
100
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
staging = dest.parent / f".{dest.name}.ccx-{uuid.uuid4().hex[:8]}"
|
|
102
|
+
shutil.copytree(source, staging, ignore=_IGNORE)
|
|
103
|
+
(staging / MARKER).write_text(json.dumps(
|
|
104
|
+
{"installedBy": "ccx", "version": ccx_version(), "digest": digest},
|
|
105
|
+
indent=2) + "\n", encoding="utf-8")
|
|
106
|
+
old = None
|
|
107
|
+
if dest.exists() or dest.is_symlink():
|
|
108
|
+
old = dest.parent / f".{dest.name}.ccx-old-{uuid.uuid4().hex[:8]}"
|
|
109
|
+
os.rename(dest, old)
|
|
110
|
+
os.rename(staging, dest)
|
|
111
|
+
if old is not None:
|
|
112
|
+
_remove(old)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def install(targets: list[Path], *, force: bool = False) -> list[tuple[str, Path, str]]:
|
|
116
|
+
rows = []
|
|
117
|
+
for target in targets:
|
|
118
|
+
for name, source in bundled_skills().items():
|
|
119
|
+
dest = target / name
|
|
120
|
+
digest = _digest(source)
|
|
121
|
+
marker = _marker(dest)
|
|
122
|
+
if marker is None and (dest.exists() or dest.is_symlink()) and not force:
|
|
123
|
+
rows.append(("skipped", dest, "not created by ccx; use --force to replace"))
|
|
124
|
+
continue
|
|
125
|
+
if marker is not None and marker.get("digest") == digest:
|
|
126
|
+
rows.append(("unchanged", dest, ""))
|
|
127
|
+
continue
|
|
128
|
+
action = ("updated" if marker is not None
|
|
129
|
+
else "replaced" if dest.exists() or dest.is_symlink() else "installed")
|
|
130
|
+
_copy(source, dest, digest)
|
|
131
|
+
rows.append((action, dest, ""))
|
|
132
|
+
return rows
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def uninstall(targets: list[Path]) -> list[tuple[str, Path, str]]:
|
|
136
|
+
rows = []
|
|
137
|
+
for target in targets:
|
|
138
|
+
for name in bundled_skills():
|
|
139
|
+
dest = target / name
|
|
140
|
+
if _marker(dest) is not None:
|
|
141
|
+
_remove(dest)
|
|
142
|
+
rows.append(("removed", dest, ""))
|
|
143
|
+
elif dest.exists() or dest.is_symlink():
|
|
144
|
+
rows.append(("skipped", dest, "not created by ccx"))
|
|
145
|
+
return rows
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def refresh_installed() -> None:
|
|
149
|
+
"""Bring CCX-installed copies in the default folders up to date.
|
|
150
|
+
|
|
151
|
+
Runs on ordinary commands after an upgrade. Only marked folders are
|
|
152
|
+
touched, nothing is created, and any failure leaves things as they were.
|
|
153
|
+
"""
|
|
154
|
+
try:
|
|
155
|
+
skills = bundled_skills()
|
|
156
|
+
stale = []
|
|
157
|
+
for target in (_claude_skills_dir(), _agents_skills_dir()):
|
|
158
|
+
for name, source in skills.items():
|
|
159
|
+
marker = _marker(target / name)
|
|
160
|
+
if marker is not None and marker.get("digest") != _digest(source):
|
|
161
|
+
stale.append((source, target / name))
|
|
162
|
+
if not stale:
|
|
163
|
+
return
|
|
164
|
+
import fcntl
|
|
165
|
+
lock_path = Path(tempfile.gettempdir()) / f"ccx-skills-{os.getuid()}.lock"
|
|
166
|
+
with open(lock_path, "a") as lock:
|
|
167
|
+
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
168
|
+
for source, dest in stale:
|
|
169
|
+
digest = _digest(source)
|
|
170
|
+
marker = _marker(dest)
|
|
171
|
+
if marker is not None and marker.get("digest") != digest:
|
|
172
|
+
_copy(source, dest, digest)
|
|
173
|
+
sys.stderr.write(f"[ccx] refreshed the {dest.name} skill in {dest.parent}\n")
|
|
174
|
+
except Exception:
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _print(rows) -> None:
|
|
179
|
+
home = str(Path.home())
|
|
180
|
+
for action, path, note in rows:
|
|
181
|
+
shown = str(path)
|
|
182
|
+
if shown == home or shown.startswith(home + os.sep):
|
|
183
|
+
shown = "~" + shown[len(home):]
|
|
184
|
+
print(f"{action:<10} {shown}" + (f" ({note})" if note else ""))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _targets(args) -> list[Path]:
|
|
188
|
+
if getattr(args, "dir", None):
|
|
189
|
+
return [Path(args.dir).expanduser().resolve()]
|
|
190
|
+
targets = default_targets()
|
|
191
|
+
if not targets:
|
|
192
|
+
sys.stderr.write(
|
|
193
|
+
"[ccx] skills: neither Claude Code nor Codex was found and ~/.agents/skills "
|
|
194
|
+
"doesn't exist; pass --dir with your agent's skills folder\n")
|
|
195
|
+
raise SystemExit(1)
|
|
196
|
+
return targets
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def cmd_skills(args) -> None:
|
|
200
|
+
if not bundled_skills():
|
|
201
|
+
sys.stderr.write(f"[ccx] skills: no bundled skills found in {skills_dir()}\n")
|
|
202
|
+
raise SystemExit(1)
|
|
203
|
+
command = getattr(args, "skills_cmd", None)
|
|
204
|
+
if command == "install":
|
|
205
|
+
_print(install(_targets(args), force=args.force))
|
|
206
|
+
elif command == "uninstall":
|
|
207
|
+
targets = ([Path(args.dir).expanduser().resolve()] if args.dir
|
|
208
|
+
else [_claude_skills_dir(), _agents_skills_dir()])
|
|
209
|
+
rows = uninstall(targets)
|
|
210
|
+
if rows:
|
|
211
|
+
_print(rows)
|
|
212
|
+
else:
|
|
213
|
+
print("no ccx-installed skills found")
|
|
214
|
+
else:
|
|
215
|
+
args.skills_parser.print_help()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from ccx.constants import SELF_ENGINE_ENV, SELF_WORKER_ID_ENV
|
|
14
|
+
|
|
15
|
+
SCHEMA_VERSION = 1
|
|
16
|
+
_MAX_STDIN = 32768
|
|
17
|
+
_USER_COMMAND_TIMEOUT_S = 2.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_payload(short: str, payload: dict) -> dict:
|
|
21
|
+
model = payload.get("model")
|
|
22
|
+
if not isinstance(model, dict):
|
|
23
|
+
model = {}
|
|
24
|
+
cw = payload.get("context_window")
|
|
25
|
+
if not isinstance(cw, dict):
|
|
26
|
+
cw = None
|
|
27
|
+
return {
|
|
28
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
29
|
+
"writtenAtMs": int(time.time() * 1000),
|
|
30
|
+
"short": short,
|
|
31
|
+
"sessionId": payload.get("session_id"),
|
|
32
|
+
"model": {
|
|
33
|
+
"id": model.get("id"),
|
|
34
|
+
"display_name": model.get("display_name"),
|
|
35
|
+
},
|
|
36
|
+
"statuslineVersion": payload.get("version"),
|
|
37
|
+
"contextWindow": cw,
|
|
38
|
+
"exceeds200k": payload.get("exceeds_200k_tokens"),
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def write_statusline_sidecar(short: str, payload: dict) -> str | None:
|
|
43
|
+
"""Capture only the original owned Claude session's advisory statusline."""
|
|
44
|
+
from ccx import constants
|
|
45
|
+
from ccx.store.root import StorageIntegrityError, _write_json, require_ccx_active
|
|
46
|
+
|
|
47
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
48
|
+
owner = store.resolve(short) # Exact global full UUID; never display/short/native.
|
|
49
|
+
if (owner.engine != "claude"
|
|
50
|
+
or os.environ.get(SELF_WORKER_ID_ENV) != owner.worker_id
|
|
51
|
+
or os.environ.get(SELF_ENGINE_ENV) != "claude"):
|
|
52
|
+
raise StorageIntegrityError("statusline worker binding mismatch")
|
|
53
|
+
rec = store.record(owner.worker)
|
|
54
|
+
native = rec.get("native")
|
|
55
|
+
native_sid = native.get("sessionId") if isinstance(native, dict) else None
|
|
56
|
+
fingerprint = rec.get("createSha256")
|
|
57
|
+
if (not isinstance(native_sid, str) or not isinstance(fingerprint, str)
|
|
58
|
+
or payload.get("session_id") != native_sid):
|
|
59
|
+
raise StorageIntegrityError("statusline original native session mismatch")
|
|
60
|
+
original = store.creation_binding(
|
|
61
|
+
owner.worker, engine="claude", fingerprint=fingerprint,
|
|
62
|
+
)
|
|
63
|
+
if not isinstance(original, dict) or original.get("sessionId") != native_sid:
|
|
64
|
+
raise StorageIntegrityError("statusline original native binding unproven")
|
|
65
|
+
doc = _normalize_payload(owner.worker_id, payload)
|
|
66
|
+
with store.lease():
|
|
67
|
+
current_owner, current = store._owned(owner.worker)
|
|
68
|
+
if (current_owner != owner or not isinstance(current.get("native"), dict)
|
|
69
|
+
or current["native"].get("sessionId") != native_sid
|
|
70
|
+
or current.get("createSha256") != fingerprint):
|
|
71
|
+
raise StorageIntegrityError("statusline owner changed during capture")
|
|
72
|
+
with store._gen("workers", "claude", owner.worker_id) as fd:
|
|
73
|
+
_write_json(fd, "statusline.json", doc)
|
|
74
|
+
return rec.get("cwd") if isinstance(rec.get("cwd"), str) else None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def nudge_statusline_host(short: str, session_id: str) -> None:
|
|
78
|
+
"""One best-effort local wake. The host, not this relay, reads and journals."""
|
|
79
|
+
from ccx.store.paths import sock_path
|
|
80
|
+
try:
|
|
81
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
82
|
+
sock.setblocking(False)
|
|
83
|
+
# Never select/wait for a busy host and never read an ACK.
|
|
84
|
+
if sock.connect_ex(str(sock_path(short))) != 0:
|
|
85
|
+
return
|
|
86
|
+
sock.send((json.dumps({
|
|
87
|
+
"op": "statusline-updated", "worker_id": short,
|
|
88
|
+
"session_id": session_id,
|
|
89
|
+
}) + "\n").encode())
|
|
90
|
+
except (OSError, ValueError):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _configured_statusline(cwd: Path) -> str | None:
|
|
95
|
+
"""Resolve Claude's user -> project -> local command, omitting our sidecar.
|
|
96
|
+
|
|
97
|
+
This is a separate projection of the caller's settings. Do not read CCX's
|
|
98
|
+
generated per-worker ``--settings`` file, which points back at this relay.
|
|
99
|
+
Later layers override earlier ones, including explicit null/disabled values.
|
|
100
|
+
"""
|
|
101
|
+
from ccx import constants
|
|
102
|
+
|
|
103
|
+
selected = None
|
|
104
|
+
for path in (constants.CLAUDE_HOME / "settings.json",
|
|
105
|
+
cwd / ".claude" / "settings.json",
|
|
106
|
+
cwd / ".claude" / "settings.local.json"):
|
|
107
|
+
try:
|
|
108
|
+
cfg = json.loads(path.read_text(encoding="utf-8"))
|
|
109
|
+
except (OSError, ValueError, UnicodeError):
|
|
110
|
+
continue
|
|
111
|
+
if isinstance(cfg, dict) and "statusLine" in cfg:
|
|
112
|
+
selected = cfg["statusLine"]
|
|
113
|
+
if not isinstance(selected, dict) or selected.get("type") != "command":
|
|
114
|
+
return None
|
|
115
|
+
command = selected.get("command")
|
|
116
|
+
return command if isinstance(command, str) and command.strip() else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_statusline_relay(args) -> None:
|
|
120
|
+
"""Capture exact original statusline, then pass stdin to user configuration.
|
|
121
|
+
|
|
122
|
+
The user-configured command is unmodified and runs from the owned worker's
|
|
123
|
+
cwd with the original statusline JSON bytes. Its stderr is not displayed or
|
|
124
|
+
logged. Any missing configuration, failed command or timeout is fail-open.
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
raw = sys.stdin.read(_MAX_STDIN + 1)
|
|
128
|
+
if len(raw) > _MAX_STDIN:
|
|
129
|
+
return
|
|
130
|
+
payload = json.loads(raw or "{}")
|
|
131
|
+
if not isinstance(payload, dict):
|
|
132
|
+
return
|
|
133
|
+
cwd_string = write_statusline_sidecar(args.short, payload)
|
|
134
|
+
if not cwd_string:
|
|
135
|
+
return
|
|
136
|
+
nudge_statusline_host(args.short, payload.get("session_id"))
|
|
137
|
+
cwd = Path(cwd_string)
|
|
138
|
+
command = _configured_statusline(cwd)
|
|
139
|
+
if command is None:
|
|
140
|
+
return
|
|
141
|
+
# Shell commands can launch children/pipelines. Own a fresh process
|
|
142
|
+
# group and kill the whole group on timeout, not just the shell, so
|
|
143
|
+
# statusline refreshes never strand a slow background child.
|
|
144
|
+
child = subprocess.Popen(
|
|
145
|
+
command, shell=True, cwd=cwd, text=True,
|
|
146
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
147
|
+
stderr=subprocess.PIPE, start_new_session=True,
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
stdout, _stderr = child.communicate(input=raw, timeout=_USER_COMMAND_TIMEOUT_S)
|
|
151
|
+
except subprocess.TimeoutExpired:
|
|
152
|
+
with contextlib.suppress(OSError):
|
|
153
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
154
|
+
with contextlib.suppress(OSError, subprocess.TimeoutExpired):
|
|
155
|
+
child.communicate(timeout=1.0)
|
|
156
|
+
return
|
|
157
|
+
if child.returncode == 0:
|
|
158
|
+
sys.stdout.write(stdout)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|