ccx-native 0.0.1 → 0.2.0

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