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,762 @@
|
|
|
1
|
+
"""One authenticated, bounded shared terminal stream for a managed Claude PTY.
|
|
2
|
+
|
|
3
|
+
Only the host select loop reads/writes the PTY. This module owns framing and
|
|
4
|
+
local TTY lifecycle; it never launches, parks or resumes a native Claude.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import binascii
|
|
10
|
+
import fcntl
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import select
|
|
14
|
+
import signal
|
|
15
|
+
import socket
|
|
16
|
+
import struct
|
|
17
|
+
import sys
|
|
18
|
+
import termios
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import tty
|
|
22
|
+
import uuid
|
|
23
|
+
|
|
24
|
+
VERSION = 1
|
|
25
|
+
MAX_INPUT_FRAME = 32 * 1024
|
|
26
|
+
MAX_PENDING_INPUT = 64 * 1024
|
|
27
|
+
MAX_PENDING_OUTPUT = 1024 * 1024
|
|
28
|
+
MAX_ATTACH_LINE = 48 * 1024 # 32 KiB base64 + envelope
|
|
29
|
+
MAX_DIMENSION = 1000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def frame(op: str, **fields) -> bytes:
|
|
33
|
+
return (json.dumps({"op": op, **fields}, separators=(",", ":")) + "\n").encode()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def dimensions(rows, cols) -> tuple[int, int]:
|
|
37
|
+
if (type(rows) is not int or type(cols) is not int
|
|
38
|
+
or not 1 <= rows <= MAX_DIMENSION or not 1 <= cols <= MAX_DIMENSION):
|
|
39
|
+
raise ValueError("invalid terminal dimensions")
|
|
40
|
+
return rows, cols
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def decode_input(data) -> bytes:
|
|
44
|
+
if (not isinstance(data, str) or len(data) > ((MAX_INPUT_FRAME + 2) // 3) * 4):
|
|
45
|
+
raise ValueError("attach input frame too large")
|
|
46
|
+
try:
|
|
47
|
+
raw = base64.b64decode(data, validate=True)
|
|
48
|
+
except (ValueError, binascii.Error) as exc:
|
|
49
|
+
raise ValueError("invalid attach base64 input") from exc
|
|
50
|
+
if len(raw) > MAX_INPUT_FRAME:
|
|
51
|
+
raise ValueError("attach input frame too large")
|
|
52
|
+
return raw
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class HostAttachment:
|
|
56
|
+
"""Hot-only lease and byte queues. Host invokes all methods on its I/O loop."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, master_fd, resize, event, disconnect, on_release):
|
|
59
|
+
self.master_fd = master_fd
|
|
60
|
+
self.resize = resize
|
|
61
|
+
self.event = event
|
|
62
|
+
self.disconnect = disconnect
|
|
63
|
+
self.on_release = on_release
|
|
64
|
+
self.conn = None
|
|
65
|
+
self.token = None
|
|
66
|
+
self.input = bytearray()
|
|
67
|
+
self.output = bytearray()
|
|
68
|
+
self.saved_size = None
|
|
69
|
+
self.requested = None
|
|
70
|
+
self.repaint_at = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def attached(self):
|
|
74
|
+
return self.conn is not None
|
|
75
|
+
|
|
76
|
+
def open(self, conn, msg, *, epoch):
|
|
77
|
+
if self.attached:
|
|
78
|
+
return {"ok": False, "error": "another terminal is already attached"}
|
|
79
|
+
if msg.get("version") != VERSION or type(msg.get("version")) is not int:
|
|
80
|
+
return {"ok": False, "error": "unsupported shared attach protocol version"}
|
|
81
|
+
try:
|
|
82
|
+
rows, cols = dimensions(msg.get("rows"), msg.get("cols"))
|
|
83
|
+
saved = fcntl.ioctl(self.master_fd, termios.TIOCGWINSZ, b"\0" * 8)
|
|
84
|
+
# Save the complete ioctl payload, including pixel dimensions.
|
|
85
|
+
if len(saved) != 8:
|
|
86
|
+
raise OSError("PTY size unavailable")
|
|
87
|
+
self.resize(rows, cols - 1 if cols > 1 else 2)
|
|
88
|
+
except (ValueError, OSError) as exc:
|
|
89
|
+
return {"ok": False, "error": str(exc)}
|
|
90
|
+
self.conn = conn
|
|
91
|
+
self.token = uuid.uuid4().hex
|
|
92
|
+
self.saved_size = saved
|
|
93
|
+
self.requested = (rows, cols)
|
|
94
|
+
self.repaint_at = time.monotonic() + .12
|
|
95
|
+
self.input.clear()
|
|
96
|
+
self.output.clear()
|
|
97
|
+
self.event({"type": "attach_opened"})
|
|
98
|
+
return {"ok": True, "op": "attach-opened", "version": VERSION,
|
|
99
|
+
"token": self.token, "epoch": epoch}
|
|
100
|
+
|
|
101
|
+
def owns(self, conn, msg):
|
|
102
|
+
return (self.conn is conn and isinstance(msg.get("token"), str)
|
|
103
|
+
and msg["token"] == self.token)
|
|
104
|
+
|
|
105
|
+
def accept_input(self, conn, msg):
|
|
106
|
+
if not self.owns(conn, msg):
|
|
107
|
+
raise ValueError("invalid attachment token or connection")
|
|
108
|
+
raw = decode_input(msg.get("data"))
|
|
109
|
+
if len(self.input) + len(raw) > MAX_PENDING_INPUT:
|
|
110
|
+
self.release(conn, "input-overflow")
|
|
111
|
+
self.disconnect(conn)
|
|
112
|
+
return
|
|
113
|
+
self.input.extend(raw)
|
|
114
|
+
|
|
115
|
+
def accept_resize(self, conn, msg):
|
|
116
|
+
if not self.owns(conn, msg):
|
|
117
|
+
raise ValueError("invalid attachment token or connection")
|
|
118
|
+
rows, cols = dimensions(msg.get("rows"), msg.get("cols"))
|
|
119
|
+
self.requested = (rows, cols)
|
|
120
|
+
if self.repaint_at is None:
|
|
121
|
+
self.resize(rows, cols)
|
|
122
|
+
|
|
123
|
+
def tick(self, write):
|
|
124
|
+
if self.conn is None:
|
|
125
|
+
return
|
|
126
|
+
if self.repaint_at is not None and time.monotonic() >= self.repaint_at:
|
|
127
|
+
self.repaint_at = None
|
|
128
|
+
self.resize(*self.requested)
|
|
129
|
+
if self.input:
|
|
130
|
+
# Fairness: at most one small PTY write, then serve ordinary RPCs.
|
|
131
|
+
part = bytes(self.input[:4096])
|
|
132
|
+
del self.input[:len(part)]
|
|
133
|
+
if not write(part):
|
|
134
|
+
conn = self.conn
|
|
135
|
+
self.release(conn, "pty-write-error")
|
|
136
|
+
self.disconnect(conn)
|
|
137
|
+
|
|
138
|
+
def feed_output(self, data: bytes):
|
|
139
|
+
if not self.attached or not data:
|
|
140
|
+
return
|
|
141
|
+
packet = frame("attach-output", data=base64.b64encode(data).decode("ascii"))
|
|
142
|
+
if len(self.output) + len(packet) > MAX_PENDING_OUTPUT:
|
|
143
|
+
conn = self.conn
|
|
144
|
+
self.release(conn, "output-overflow")
|
|
145
|
+
self.disconnect(conn)
|
|
146
|
+
else:
|
|
147
|
+
self.output.extend(packet)
|
|
148
|
+
|
|
149
|
+
def flush(self):
|
|
150
|
+
if not self.attached or not self.output:
|
|
151
|
+
return
|
|
152
|
+
conn = self.conn
|
|
153
|
+
try:
|
|
154
|
+
sent = conn.send(self.output[:32768])
|
|
155
|
+
if not sent:
|
|
156
|
+
raise OSError("attachment connection closed")
|
|
157
|
+
del self.output[:sent]
|
|
158
|
+
except BlockingIOError:
|
|
159
|
+
return
|
|
160
|
+
except OSError:
|
|
161
|
+
self.release(conn, "connection-lost")
|
|
162
|
+
self.disconnect(conn)
|
|
163
|
+
|
|
164
|
+
def release(self, conn, reason):
|
|
165
|
+
if self.conn is not conn or conn is None:
|
|
166
|
+
return
|
|
167
|
+
self.conn = None # exactly-once restore, even when callbacks re-enter
|
|
168
|
+
self.token = None
|
|
169
|
+
self.input.clear()
|
|
170
|
+
self.output.clear()
|
|
171
|
+
self.requested = None
|
|
172
|
+
self.repaint_at = None
|
|
173
|
+
saved, self.saved_size = self.saved_size, None
|
|
174
|
+
if saved is not None:
|
|
175
|
+
try:
|
|
176
|
+
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, saved)
|
|
177
|
+
except OSError:
|
|
178
|
+
pass
|
|
179
|
+
self.on_release()
|
|
180
|
+
self.event({"type": "attach_closed", "reason": reason})
|
|
181
|
+
if reason != "client-eof":
|
|
182
|
+
try:
|
|
183
|
+
conn.send(frame("attach-ended", reason=reason))
|
|
184
|
+
except (OSError, BlockingIOError):
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class DetachFilter:
|
|
189
|
+
"""Ctrl-Z detaches (instead of suspending); keys inside a paste pass through.
|
|
190
|
+
|
|
191
|
+
Paste markers are decoded across fragmented reads, with no examination of
|
|
192
|
+
any characters inside a bracketed paste. A lone ESC can be flushed on timeout.
|
|
193
|
+
"""
|
|
194
|
+
def __init__(self):
|
|
195
|
+
self.paste = False
|
|
196
|
+
self.escape = bytearray()
|
|
197
|
+
self.detached = False
|
|
198
|
+
|
|
199
|
+
def _key(self, key: int, result: bytearray):
|
|
200
|
+
if key == 0x1a and not self.paste:
|
|
201
|
+
self.detached = True
|
|
202
|
+
else:
|
|
203
|
+
result.append(key)
|
|
204
|
+
|
|
205
|
+
def feed(self, chunk: bytes) -> bytes:
|
|
206
|
+
output = bytearray()
|
|
207
|
+
marker = b"\x1b[201~" if self.paste else b"\x1b[200~"
|
|
208
|
+
for key in chunk:
|
|
209
|
+
if self.detached:
|
|
210
|
+
break
|
|
211
|
+
self.escape.append(key)
|
|
212
|
+
while self.escape and not marker.startswith(self.escape):
|
|
213
|
+
self._key(self.escape.pop(0), output)
|
|
214
|
+
if self.detached:
|
|
215
|
+
break
|
|
216
|
+
if self.escape == marker:
|
|
217
|
+
output.extend(self.escape)
|
|
218
|
+
self.escape.clear()
|
|
219
|
+
self.paste = not self.paste
|
|
220
|
+
marker = b"\x1b[201~" if self.paste else b"\x1b[200~"
|
|
221
|
+
return bytes(output)
|
|
222
|
+
|
|
223
|
+
def flush_escape(self):
|
|
224
|
+
result = bytearray()
|
|
225
|
+
while self.escape:
|
|
226
|
+
self._key(self.escape.pop(0), result)
|
|
227
|
+
return bytes(result)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def status_label(reply) -> str | None:
|
|
231
|
+
"""One short live state from the existing host `status` reply, or None."""
|
|
232
|
+
if not isinstance(reply, dict) or reply.get("ok") is not True:
|
|
233
|
+
return None
|
|
234
|
+
state = reply.get("state")
|
|
235
|
+
if reply.get("compacting") is True:
|
|
236
|
+
state = "compacting"
|
|
237
|
+
elif state == "waiting":
|
|
238
|
+
waiting = reply.get("waitingFor")
|
|
239
|
+
waiting = waiting if isinstance(waiting, dict) else {}
|
|
240
|
+
tool = waiting.get("tool")
|
|
241
|
+
if waiting.get("type") == "elicitation":
|
|
242
|
+
state = "waiting for input"
|
|
243
|
+
elif tool == "AskUserQuestion":
|
|
244
|
+
state = "waiting for answer"
|
|
245
|
+
elif waiting.get("type") == "permission" and isinstance(tool, str):
|
|
246
|
+
state = f"waiting for permission ({tool})"
|
|
247
|
+
if not isinstance(state, str):
|
|
248
|
+
return None
|
|
249
|
+
return "".join(ch for ch in state if ch.isprintable())[:80] or None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
GROUND, ESCAPE, ESCAPE_INTERMEDIATE, CSI, STRING, STRING_ESCAPE = range(6)
|
|
253
|
+
_ALT_SCREENS = {b"47", b"1047", b"1049"}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _utf8_complete(tail: bytes) -> bool:
|
|
257
|
+
for back in range(1, len(tail) + 1):
|
|
258
|
+
byte = tail[-back]
|
|
259
|
+
if byte < 0x80:
|
|
260
|
+
return True
|
|
261
|
+
if byte >= 0xC0:
|
|
262
|
+
return back >= (2 if byte < 0xE0 else 3 if byte < 0xF0 else 4)
|
|
263
|
+
return True # continuation bytes only: complete (or malformed)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class StatusBar:
|
|
267
|
+
"""Reserve the outer terminal's last row for one CCX status line.
|
|
268
|
+
|
|
269
|
+
Claude's PTY is one row shorter and DECSTBM keeps outer scrolling above
|
|
270
|
+
the bar. The bar is only drawn where the outer stream is in ground state:
|
|
271
|
+
never inside an escape/string sequence, a synchronized update or a UTF-8
|
|
272
|
+
character. Output that resets the margins or clears/switches the screen
|
|
273
|
+
is followed at once by a redraw. The label comes from a polling thread.
|
|
274
|
+
"""
|
|
275
|
+
MIN_ROWS, MIN_COLS = 6, 20
|
|
276
|
+
|
|
277
|
+
def __init__(self, short_id: str, fetch=None, *, interval=2.0):
|
|
278
|
+
self.short_id = short_id
|
|
279
|
+
self.fetch = fetch
|
|
280
|
+
self.interval = interval
|
|
281
|
+
self.polled = None # written only by the poll thread
|
|
282
|
+
self.label = None
|
|
283
|
+
self.rows = self.cols = 0
|
|
284
|
+
self.active = False
|
|
285
|
+
self.pending = False
|
|
286
|
+
self.stale_row = None
|
|
287
|
+
self.margins = False # outer DECSTBM currently ours
|
|
288
|
+
self.state = GROUND
|
|
289
|
+
self.seq = bytearray()
|
|
290
|
+
self.sync = False
|
|
291
|
+
self.tail = b""
|
|
292
|
+
self._stop = threading.Event()
|
|
293
|
+
|
|
294
|
+
# --- status poll (off the select loop) --------------------------------
|
|
295
|
+
def start(self):
|
|
296
|
+
if self.fetch is not None:
|
|
297
|
+
threading.Thread(target=self._poll, name="ccx-attach-status", daemon=True).start()
|
|
298
|
+
|
|
299
|
+
def stop(self):
|
|
300
|
+
self._stop.set()
|
|
301
|
+
|
|
302
|
+
def _poll(self):
|
|
303
|
+
while not self._stop.is_set():
|
|
304
|
+
try:
|
|
305
|
+
self.polled = status_label(self.fetch())
|
|
306
|
+
except Exception: # a failed poll only hides the state
|
|
307
|
+
self.polled = None
|
|
308
|
+
self._stop.wait(self.interval)
|
|
309
|
+
|
|
310
|
+
def update(self):
|
|
311
|
+
label = self.polled
|
|
312
|
+
if label != self.label:
|
|
313
|
+
self.label = label
|
|
314
|
+
self.pending = self.pending or self.active
|
|
315
|
+
|
|
316
|
+
# --- geometry and painting --------------------------------------------
|
|
317
|
+
def resize(self, rows: int, cols: int) -> int:
|
|
318
|
+
"""Record the outer size; return the row count Claude's PTY gets."""
|
|
319
|
+
was_active, old_rows = self.active, self.rows
|
|
320
|
+
self.rows, self.cols = rows, cols
|
|
321
|
+
self.active = rows >= self.MIN_ROWS and cols >= self.MIN_COLS
|
|
322
|
+
if was_active and self.active and old_rows < rows:
|
|
323
|
+
self.stale_row = old_rows # the old bar row is now Claude's
|
|
324
|
+
self.pending = self.pending or self.active or was_active
|
|
325
|
+
return rows - 1 if self.active else rows
|
|
326
|
+
|
|
327
|
+
def text(self) -> str:
|
|
328
|
+
parts = ["ccx " + self.short_id] + ([self.label] if self.label else [])
|
|
329
|
+
return (" " + " · ".join(parts + ["Ctrl-Z detach"]))[:self.cols - 1]
|
|
330
|
+
|
|
331
|
+
def _paint(self) -> bytes:
|
|
332
|
+
out = bytearray(b"\x1b7") # DECSC/DECRC: Claude never uses them
|
|
333
|
+
if self.stale_row is not None:
|
|
334
|
+
out += b"\x1b[%d;1H\x1b[0m\x1b[2K" % self.stale_row
|
|
335
|
+
self.stale_row = None
|
|
336
|
+
if self.active:
|
|
337
|
+
out += b"\x1b[1;%dr\x1b[%d;1H\x1b[0m\x1b[2K\x1b[7m" % (self.rows - 1, self.rows)
|
|
338
|
+
out += self.text().ljust(self.cols - 1).encode() + b"\x1b[0m"
|
|
339
|
+
else:
|
|
340
|
+
out += b"\x1b[r"
|
|
341
|
+
self.margins = self.active
|
|
342
|
+
return bytes(out + b"\x1b8")
|
|
343
|
+
|
|
344
|
+
def poll(self, *, sequence_end=False) -> bytes:
|
|
345
|
+
"""The pending redraw, if the stream is at a safe point now."""
|
|
346
|
+
if (not self.pending or self.state != GROUND or self.sync
|
|
347
|
+
or not (sequence_end or _utf8_complete(self.tail))):
|
|
348
|
+
return b""
|
|
349
|
+
self.pending = False
|
|
350
|
+
return self._paint()
|
|
351
|
+
|
|
352
|
+
def restore(self) -> bytes:
|
|
353
|
+
"""Exit: abort any partial sequence, reset margins, clear the bar."""
|
|
354
|
+
if not (self.margins or self.active):
|
|
355
|
+
return b"" # never drawn: exactly like --no-status
|
|
356
|
+
out = b"\x18\x1b[r"
|
|
357
|
+
if self.active:
|
|
358
|
+
out += b"\x1b[%d;1H\x1b[0m\x1b[2K" % self.rows
|
|
359
|
+
return out
|
|
360
|
+
|
|
361
|
+
# --- output tracking --------------------------------------------------
|
|
362
|
+
def _completed(self) -> bool:
|
|
363
|
+
"""Classify the just-finished sequence; True if it breaks the bar."""
|
|
364
|
+
seq = bytes(self.seq)
|
|
365
|
+
self.state = GROUND
|
|
366
|
+
if seq == b"\x1bc": # RIS
|
|
367
|
+
self.sync = False
|
|
368
|
+
return True
|
|
369
|
+
if not seq.startswith(b"\x1b[") or len(seq) < 3:
|
|
370
|
+
return False
|
|
371
|
+
final, body = seq[-1:], seq[2:-1]
|
|
372
|
+
if (final == b"J" or (final == b"r" and not body.startswith((b"?", b">")))
|
|
373
|
+
or (final == b"p" and body == b"!")): # ED, DECSTBM, DECSTR
|
|
374
|
+
return True
|
|
375
|
+
if body[:1] == b"?" and final in b"hl":
|
|
376
|
+
modes = set(body[1:].split(b";"))
|
|
377
|
+
if b"2026" in modes:
|
|
378
|
+
self.sync = final == b"h"
|
|
379
|
+
return bool(modes & _ALT_SCREENS)
|
|
380
|
+
return False
|
|
381
|
+
|
|
382
|
+
def feed(self, data: bytes) -> bytes:
|
|
383
|
+
"""Pass Claude's bytes through, with any redraw at a safe point."""
|
|
384
|
+
out = bytearray()
|
|
385
|
+
start = i = 0
|
|
386
|
+
while i < len(data):
|
|
387
|
+
if self.state == GROUND:
|
|
388
|
+
i = data.find(b"\x1b", i)
|
|
389
|
+
if i < 0:
|
|
390
|
+
break
|
|
391
|
+
byte = data[i]
|
|
392
|
+
i += 1
|
|
393
|
+
if self.state == STRING:
|
|
394
|
+
if byte == 0x07:
|
|
395
|
+
self.state = GROUND
|
|
396
|
+
elif byte == 0x1b:
|
|
397
|
+
self.state = STRING_ESCAPE
|
|
398
|
+
continue
|
|
399
|
+
if self.state == STRING_ESCAPE:
|
|
400
|
+
if byte == 0x5c:
|
|
401
|
+
self.state = GROUND
|
|
402
|
+
continue
|
|
403
|
+
self.state = ESCAPE # ESC aborted the string: new sequence
|
|
404
|
+
self.seq = bytearray(b"\x1b")
|
|
405
|
+
if byte == 0x1b:
|
|
406
|
+
self.state, self.seq = ESCAPE, bytearray(b"\x1b")
|
|
407
|
+
continue
|
|
408
|
+
if byte in (0x18, 0x1a): # CAN/SUB abort a sequence
|
|
409
|
+
self.state = GROUND
|
|
410
|
+
continue
|
|
411
|
+
if byte < 0x20 or byte == 0x7f:
|
|
412
|
+
continue # C0 controls execute without ending the sequence
|
|
413
|
+
if len(self.seq) < 64:
|
|
414
|
+
self.seq.append(byte)
|
|
415
|
+
done = False
|
|
416
|
+
if self.state == ESCAPE:
|
|
417
|
+
if byte == 0x5b:
|
|
418
|
+
self.state = CSI
|
|
419
|
+
elif byte in b"]P_^X":
|
|
420
|
+
self.state = STRING
|
|
421
|
+
elif byte < 0x30:
|
|
422
|
+
self.state = ESCAPE_INTERMEDIATE
|
|
423
|
+
else:
|
|
424
|
+
done = True
|
|
425
|
+
elif self.state == ESCAPE_INTERMEDIATE:
|
|
426
|
+
done = byte >= 0x30
|
|
427
|
+
else: # CSI
|
|
428
|
+
done = byte >= 0x40
|
|
429
|
+
if not done:
|
|
430
|
+
continue
|
|
431
|
+
if self._completed() and self.active:
|
|
432
|
+
self.pending = True
|
|
433
|
+
if self.pending and not self.sync: # also: a sync update ended
|
|
434
|
+
out += data[start:i]
|
|
435
|
+
out += self.poll(sequence_end=True)
|
|
436
|
+
start = i
|
|
437
|
+
out += data[start:]
|
|
438
|
+
self.tail = (self.tail + data[-3:])[-3:]
|
|
439
|
+
out += self.poll()
|
|
440
|
+
return bytes(out)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _read_line(sock, pending: bytearray, *, max_size=16384):
|
|
444
|
+
"""Read one reply without eating the following PTY stream's bytes."""
|
|
445
|
+
while b"\n" not in pending:
|
|
446
|
+
if len(pending) > max_size:
|
|
447
|
+
raise ValueError("oversized attachment reply")
|
|
448
|
+
chunk = sock.recv(4096)
|
|
449
|
+
if not chunk:
|
|
450
|
+
raise ConnectionError("managed host closed the connection")
|
|
451
|
+
pending.extend(chunk)
|
|
452
|
+
line, _, rest = pending.partition(b"\n")
|
|
453
|
+
if len(line) > max_size:
|
|
454
|
+
raise ValueError("oversized attachment reply")
|
|
455
|
+
pending[:] = rest
|
|
456
|
+
result = json.loads(line)
|
|
457
|
+
if not isinstance(result, dict):
|
|
458
|
+
raise ValueError("invalid attachment reply")
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def open_stream(handle, *, rows, cols, initial_output: bytearray):
|
|
463
|
+
from ccx import constants
|
|
464
|
+
from ccx.store.root import require_ccx_active
|
|
465
|
+
from ccx.rpc.transport import plan_transport, handshake_payload, verify_handshake
|
|
466
|
+
|
|
467
|
+
store = require_ccx_active(constants.CCX_ROOT)
|
|
468
|
+
plan = plan_transport(store, target=handle)
|
|
469
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
470
|
+
try:
|
|
471
|
+
sock.settimeout(5.0)
|
|
472
|
+
sock.connect(str(plan.final))
|
|
473
|
+
sock.sendall(frame("handshake", identity=handshake_payload(plan)))
|
|
474
|
+
buffered = bytearray()
|
|
475
|
+
hello = _read_line(sock, buffered)
|
|
476
|
+
if not hello.get("ok"):
|
|
477
|
+
raise RuntimeError("unverified managed host: " + str(hello.get("error")))
|
|
478
|
+
verify_handshake(store, plan, received=hello.get("identity"), connected_socket=sock)
|
|
479
|
+
store.revalidate()
|
|
480
|
+
sock.sendall(frame("attach-open", version=VERSION, rows=rows, cols=cols))
|
|
481
|
+
reply = _read_line(sock, buffered)
|
|
482
|
+
if not reply.get("ok"):
|
|
483
|
+
error = str(reply.get("error", "no attach response"))
|
|
484
|
+
if "unknown op" in error or "no attach response" in error:
|
|
485
|
+
error = "host is too old for shared attach; restart the worker"
|
|
486
|
+
raise RuntimeError(error)
|
|
487
|
+
if (reply.get("op") != "attach-opened" or reply.get("version") != VERSION
|
|
488
|
+
or not isinstance(reply.get("token"), str) or len(reply["token"]) != 32):
|
|
489
|
+
raise RuntimeError("host has no compatible shared attach protocol; restart the worker")
|
|
490
|
+
initial_output.extend(buffered) # complete and partial first-paint frames
|
|
491
|
+
sock.setblocking(False)
|
|
492
|
+
return sock, reply["token"]
|
|
493
|
+
except BaseException:
|
|
494
|
+
sock.close()
|
|
495
|
+
raise
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _tty_size(fd):
|
|
499
|
+
rows, cols, _, _ = struct.unpack("HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\0" * 8))
|
|
500
|
+
return dimensions(rows, cols)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _write_tty(fd, data, *, timeout_s=0.1):
|
|
504
|
+
"""Best-effort terminal *cleanup* only; never block termios restoration."""
|
|
505
|
+
deadline = time.monotonic() + timeout_s
|
|
506
|
+
pos = 0
|
|
507
|
+
while pos < len(data):
|
|
508
|
+
if time.monotonic() >= deadline:
|
|
509
|
+
raise TimeoutError("terminal output backpressure")
|
|
510
|
+
try:
|
|
511
|
+
written = os.write(fd, data[pos:])
|
|
512
|
+
except (InterruptedError, BlockingIOError):
|
|
513
|
+
written = 0
|
|
514
|
+
if written > 0:
|
|
515
|
+
pos += written
|
|
516
|
+
continue
|
|
517
|
+
if written < 0:
|
|
518
|
+
raise OSError("terminal output unavailable")
|
|
519
|
+
select.select([], [fd], [], min(0.05, max(0, deadline - time.monotonic())))
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def run_terminal(sock, token, *, input_fd=0, output_fd=1, initial_output=b"",
|
|
523
|
+
status_bar: StatusBar | None = None):
|
|
524
|
+
"""Restore terminal and signal dispositions on every exit, including TERM/HUP.
|
|
525
|
+
|
|
526
|
+
Without `status_bar` the native PTY output is passed through byte-exact.
|
|
527
|
+
"""
|
|
528
|
+
bar = status_bar
|
|
529
|
+
old_attrs = termios.tcgetattr(input_fd)
|
|
530
|
+
old_flags = fcntl.fcntl(output_fd, fcntl.F_GETFL)
|
|
531
|
+
old_handlers = {}
|
|
532
|
+
resized = [False]
|
|
533
|
+
stopping = [False]
|
|
534
|
+
|
|
535
|
+
def on_resize(sig, frame_):
|
|
536
|
+
resized[0] = True
|
|
537
|
+
|
|
538
|
+
def on_stop(sig, frame_):
|
|
539
|
+
stopping[0] = True
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
for sig, handler in ((signal.SIGWINCH, on_resize),
|
|
543
|
+
(signal.SIGTERM, on_stop), (signal.SIGHUP, on_stop)):
|
|
544
|
+
old_handlers[sig] = signal.getsignal(sig)
|
|
545
|
+
signal.signal(sig, handler)
|
|
546
|
+
fcntl.fcntl(output_fd, fcntl.F_SETFL, old_flags | os.O_NONBLOCK)
|
|
547
|
+
tty.setraw(input_fd)
|
|
548
|
+
if bar is not None:
|
|
549
|
+
bar.start()
|
|
550
|
+
# Render the native PTY through one nonblocking, bounded local queue.
|
|
551
|
+
# Paused terminal consumers must backpressure the host, not cause a
|
|
552
|
+
# time-based client exit. The finalizer alone uses a bounded tty write.
|
|
553
|
+
tty_output = bytearray(b"\x1b[?1049h\x1b[?2004h\x1b[?25h\x1b[H\x1b[2J")
|
|
554
|
+
pending = bytearray()
|
|
555
|
+
incoming = bytearray(initial_output)
|
|
556
|
+
filter_ = DetachFilter()
|
|
557
|
+
escape_at = None
|
|
558
|
+
ended_reason = None
|
|
559
|
+
ended_at = None
|
|
560
|
+
socket_ended = False
|
|
561
|
+
host_writable = True
|
|
562
|
+
resized[0] = True
|
|
563
|
+
|
|
564
|
+
def render_frames(limit=64):
|
|
565
|
+
nonlocal ended_at
|
|
566
|
+
for _ in range(limit):
|
|
567
|
+
newline = incoming.find(b"\n")
|
|
568
|
+
if newline < 0:
|
|
569
|
+
return None
|
|
570
|
+
if newline > 8192:
|
|
571
|
+
raise ValueError("oversized attachment output frame")
|
|
572
|
+
msg = json.loads(incoming[:newline])
|
|
573
|
+
if not isinstance(msg, dict):
|
|
574
|
+
raise ValueError("invalid attachment output frame")
|
|
575
|
+
op = msg.get("op")
|
|
576
|
+
if op == "attach-ended":
|
|
577
|
+
if ended_at is None:
|
|
578
|
+
ended_at = time.monotonic()
|
|
579
|
+
del incoming[:newline + 1]
|
|
580
|
+
return msg.get("reason") or "ended"
|
|
581
|
+
if op != "attach-output":
|
|
582
|
+
raise ValueError("unexpected attachment message")
|
|
583
|
+
raw = base64.b64decode(msg["data"], validate=True)
|
|
584
|
+
if len(raw) > MAX_PENDING_OUTPUT:
|
|
585
|
+
raise ValueError("oversized attachment output frame")
|
|
586
|
+
if len(tty_output) + len(raw) > MAX_PENDING_OUTPUT:
|
|
587
|
+
# Do not consume this NDJSON frame until the output queue
|
|
588
|
+
# can accept all its bytes in order. No partial replay.
|
|
589
|
+
return None
|
|
590
|
+
del incoming[:newline + 1]
|
|
591
|
+
tty_output.extend(raw if bar is None else bar.feed(raw))
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
while not stopping[0]:
|
|
595
|
+
if ended_reason is None:
|
|
596
|
+
found_end = render_frames()
|
|
597
|
+
if found_end is not None:
|
|
598
|
+
ended_reason = found_end
|
|
599
|
+
if socket_ended and ended_reason is None and b"\n" not in incoming:
|
|
600
|
+
ended_reason = "connection-ended"
|
|
601
|
+
# EOF establishes the end-drain deadline even with complete
|
|
602
|
+
# output frames held behind a full terminal buffer. It must never
|
|
603
|
+
# depend on render_frames() consuming the final NDJSON newline.
|
|
604
|
+
if ended_at is not None and time.monotonic() - ended_at > 3.0:
|
|
605
|
+
return ended_reason or "connection-ended"
|
|
606
|
+
if ended_reason is not None and not tty_output:
|
|
607
|
+
return ended_reason
|
|
608
|
+
if resized[0] and ended_reason is None and not socket_ended and host_writable:
|
|
609
|
+
resized[0] = False
|
|
610
|
+
rows, cols = _tty_size(output_fd)
|
|
611
|
+
if bar is not None:
|
|
612
|
+
rows = bar.resize(rows, cols)
|
|
613
|
+
pending.extend(frame("attach-resize", token=token, rows=rows, cols=cols))
|
|
614
|
+
if bar is not None:
|
|
615
|
+
bar.update()
|
|
616
|
+
tty_output.extend(bar.poll())
|
|
617
|
+
|
|
618
|
+
# The output queue stays capped at 1 MiB. The existing bounded
|
|
619
|
+
# NDJSON input buffer can absorb remaining socket frames while
|
|
620
|
+
# the terminal is full, so EOF just behind the backlog is still
|
|
621
|
+
# observable; once both queues are full, backpressure reaches the
|
|
622
|
+
# host's own bounded output queue. No live-connection timeout.
|
|
623
|
+
readers = []
|
|
624
|
+
if (ended_reason is None and not socket_ended
|
|
625
|
+
and len(incoming) < MAX_PENDING_OUTPUT + 8192):
|
|
626
|
+
readers.append(sock)
|
|
627
|
+
# Even after the host ends, keep reading the detach escape. All
|
|
628
|
+
# other keystrokes are discarded once no host can receive them.
|
|
629
|
+
if (ended_reason is not None or socket_ended or not host_writable
|
|
630
|
+
or len(pending) < 65536):
|
|
631
|
+
readers.append(input_fd)
|
|
632
|
+
writers = []
|
|
633
|
+
if tty_output:
|
|
634
|
+
writers.append(output_fd)
|
|
635
|
+
if pending and ended_reason is None and not socket_ended and host_writable:
|
|
636
|
+
writers.append(sock)
|
|
637
|
+
# Buffered complete frames may advance without another host read.
|
|
638
|
+
ready, writable, _ = select.select(
|
|
639
|
+
readers, writers, [],
|
|
640
|
+
0 if (ended_reason is None and b"\n" in incoming
|
|
641
|
+
and len(tty_output) < MAX_PENDING_OUTPUT - 32768) else .05)
|
|
642
|
+
if output_fd in writable:
|
|
643
|
+
try:
|
|
644
|
+
written = os.write(output_fd, tty_output[:16384])
|
|
645
|
+
if not written:
|
|
646
|
+
raise OSError("terminal output unavailable")
|
|
647
|
+
del tty_output[:written]
|
|
648
|
+
except (InterruptedError, BlockingIOError):
|
|
649
|
+
pass
|
|
650
|
+
if sock in writable and ended_reason is None and not socket_ended and host_writable:
|
|
651
|
+
try:
|
|
652
|
+
sent = sock.send(pending[:16384])
|
|
653
|
+
if not sent:
|
|
654
|
+
raise OSError("attachment host no longer receives input")
|
|
655
|
+
del pending[:sent]
|
|
656
|
+
except BlockingIOError:
|
|
657
|
+
pass
|
|
658
|
+
except OSError:
|
|
659
|
+
# A closed host may still have final output already in the
|
|
660
|
+
# socket receive buffer. Stop writes, not reads: consume
|
|
661
|
+
# every available output frame through the normal EOF/end
|
|
662
|
+
# drain, with the existing three-second deadline beginning
|
|
663
|
+
# at this first terminal host-write failure.
|
|
664
|
+
host_writable = False
|
|
665
|
+
pending.clear()
|
|
666
|
+
if ended_at is None:
|
|
667
|
+
ended_at = time.monotonic()
|
|
668
|
+
if input_fd in ready:
|
|
669
|
+
try:
|
|
670
|
+
data = os.read(input_fd, 16384)
|
|
671
|
+
except BlockingIOError:
|
|
672
|
+
data = None
|
|
673
|
+
if data == b"":
|
|
674
|
+
break
|
|
675
|
+
if data:
|
|
676
|
+
output = filter_.feed(data)
|
|
677
|
+
escape_at = time.monotonic() if filter_.escape else None
|
|
678
|
+
if ended_reason is None and not socket_ended and host_writable and output:
|
|
679
|
+
pending.extend(frame("attach-input", token=token,
|
|
680
|
+
data=base64.b64encode(output).decode("ascii")))
|
|
681
|
+
if filter_.detached:
|
|
682
|
+
# An explicit human detach may discard still-buffered
|
|
683
|
+
# final paint. Do not force the terminal to resume.
|
|
684
|
+
if ended_reason is None and not socket_ended and host_writable:
|
|
685
|
+
try:
|
|
686
|
+
if pending:
|
|
687
|
+
sock.send(pending)
|
|
688
|
+
except (OSError, BlockingIOError):
|
|
689
|
+
pass
|
|
690
|
+
break
|
|
691
|
+
if sock in ready:
|
|
692
|
+
try:
|
|
693
|
+
data = sock.recv(min(
|
|
694
|
+
32768, MAX_PENDING_OUTPUT + 8192 - len(incoming)))
|
|
695
|
+
except BlockingIOError:
|
|
696
|
+
data = None
|
|
697
|
+
except OSError:
|
|
698
|
+
# A closed/reset host cannot send anything more. Keep any
|
|
699
|
+
# frames already read in `incoming` for the bounded drain.
|
|
700
|
+
data = b""
|
|
701
|
+
if data == b"":
|
|
702
|
+
socket_ended = True
|
|
703
|
+
if ended_at is None:
|
|
704
|
+
ended_at = time.monotonic()
|
|
705
|
+
elif data:
|
|
706
|
+
incoming.extend(data)
|
|
707
|
+
if len(incoming) > MAX_PENDING_OUTPUT + 8192:
|
|
708
|
+
raise ValueError("oversized attachment output")
|
|
709
|
+
# A lone Escape must still reach Claude without a following key.
|
|
710
|
+
if filter_.escape and escape_at is not None and time.monotonic() - escape_at >= .2:
|
|
711
|
+
output = filter_.flush_escape()
|
|
712
|
+
escape_at = None
|
|
713
|
+
if ended_reason is None and not socket_ended and host_writable and output:
|
|
714
|
+
pending.extend(frame("attach-input", token=token,
|
|
715
|
+
data=base64.b64encode(output).decode("ascii")))
|
|
716
|
+
return "detached" if filter_.detached else "connection-ended"
|
|
717
|
+
except InterruptedError:
|
|
718
|
+
return "connection-ended"
|
|
719
|
+
finally:
|
|
720
|
+
# Terminal mode restoration is independent of output drain, or the
|
|
721
|
+
# presence of a responsive host. Preserve the original FD flags too.
|
|
722
|
+
if bar is not None:
|
|
723
|
+
bar.stop()
|
|
724
|
+
try:
|
|
725
|
+
sock.send(frame("attach-close", token=token))
|
|
726
|
+
except OSError:
|
|
727
|
+
pass
|
|
728
|
+
sock.close()
|
|
729
|
+
try:
|
|
730
|
+
try:
|
|
731
|
+
_write_tty(output_fd, (b"" if bar is None else bar.restore()) +
|
|
732
|
+
b"\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1004l\x1b[?25h\x1b[?1049l",
|
|
733
|
+
timeout_s=0.1)
|
|
734
|
+
except (OSError, TimeoutError, InterruptedError):
|
|
735
|
+
pass # terminal reader may be completely stalled or gone
|
|
736
|
+
finally:
|
|
737
|
+
try:
|
|
738
|
+
termios.tcsetattr(input_fd, termios.TCSANOW, old_attrs)
|
|
739
|
+
finally:
|
|
740
|
+
try:
|
|
741
|
+
fcntl.fcntl(output_fd, fcntl.F_SETFL, old_flags)
|
|
742
|
+
finally:
|
|
743
|
+
for sig, handler in old_handlers.items():
|
|
744
|
+
signal.signal(sig, handler)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def attach(handle, *, status=True):
|
|
748
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
749
|
+
raise RuntimeError("attach requires a terminal on both stdin and stdout")
|
|
750
|
+
rows, cols = _tty_size(sys.stdout.fileno())
|
|
751
|
+
bar = None
|
|
752
|
+
if status:
|
|
753
|
+
from ccx.rpc.rpc import _rpc
|
|
754
|
+
bar = StatusBar(handle.rpartition(":")[2][:8],
|
|
755
|
+
lambda: _rpc(handle, {"op": "status"}, timeout=1.0))
|
|
756
|
+
rows = bar.resize(rows, cols)
|
|
757
|
+
initial_output = bytearray()
|
|
758
|
+
sock, token = open_stream(handle, rows=rows, cols=cols, initial_output=initial_output)
|
|
759
|
+
try:
|
|
760
|
+
return run_terminal(sock, token, initial_output=initial_output, status_bar=bar)
|
|
761
|
+
finally:
|
|
762
|
+
sock.close() # also covers an early tcgetattr failure before raw mode
|