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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +39 -7
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. 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