ccx-native 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +807 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +269 -0
- package/ccx/claude/host/session.py +5244 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1558 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2424 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1454 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/models.py +225 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +296 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import fcntl
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import select
|
|
6
|
+
import struct
|
|
7
|
+
import termios
|
|
8
|
+
import time
|
|
9
|
+
from ccx.constants import (
|
|
10
|
+
CLEAR_KILL_REPEAT,
|
|
11
|
+
POST_CLEAR_SETTLE_S,
|
|
12
|
+
PASTE_INLINE_MAX_NEWLINES,
|
|
13
|
+
PASTE_INLINE_MAX_UNITS,
|
|
14
|
+
PASTE_INLINE_ROWS_RESERVED,
|
|
15
|
+
PASTE_SUBMIT_DELAY_S,
|
|
16
|
+
PASTE_STAGED_BYTES_PER_S,
|
|
17
|
+
PASTE_STAGED_MAX_DELAY_S,
|
|
18
|
+
PASTE_STAGED_THRESHOLD_BYTES,
|
|
19
|
+
PTY_QUIESCENCE_S,
|
|
20
|
+
PTY_WRITE_TIMEOUT_S,
|
|
21
|
+
)
|
|
22
|
+
from ccx.claude.transcript import BARE_IMAGE_PATH_RE
|
|
23
|
+
|
|
24
|
+
# Claude (2.1.319) checks every pasted line that, trimmed, unquoted and with
|
|
25
|
+
# backslash escapes removed, ends in .png, .jpg, .jpeg, .gif or .webp: it
|
|
26
|
+
# looks for the image file asynchronously and inserts the paste only then,
|
|
27
|
+
# after any pastes that arrived meanwhile, and drops an Enter pressed during
|
|
28
|
+
# the check. So a paste boundary goes right after the dot of every image
|
|
29
|
+
# extension. More formats than Claude checks today: extra boundaries are free.
|
|
30
|
+
_IMAGE_EXTENSIONS = ("png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg",
|
|
31
|
+
"avif", "heic", "heif", "tif", "tiff")
|
|
32
|
+
_IMAGE_EXTENSION_DOT = re.compile(
|
|
33
|
+
r"\.(?=\\*(?:%s))" % "|".join(r"\\*".join(ext) for ext in _IMAGE_EXTENSIONS),
|
|
34
|
+
re.IGNORECASE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def set_winsize(fd, rows, cols):
|
|
38
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def paste_bytes(text: str) -> bytes:
|
|
42
|
+
"""Frame text as one bracketed paste, without the submitting Enter.
|
|
43
|
+
|
|
44
|
+
Claude's decoder emits the pasted text only after the closing marker. The
|
|
45
|
+
caller sends Enter separately after this complete payload has drained.
|
|
46
|
+
"""
|
|
47
|
+
return b"\x1b[200~" + text.encode() + b"\x1b[201~"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def paste_submit_delay(text: str) -> float:
|
|
51
|
+
"""How long Ink gets to materialize a paste before CCX presses Enter.
|
|
52
|
+
|
|
53
|
+
Small prompts remain effectively immediate. Large prompts are staged: Ink
|
|
54
|
+
asynchronously turns them into a collapsed ``[Pasted text …]`` attachment,
|
|
55
|
+
and an Enter delivered before that work finishes is silently discarded.
|
|
56
|
+
The caller must wait without blocking the host loop so PTY output continues
|
|
57
|
+
to drain during this interval.
|
|
58
|
+
"""
|
|
59
|
+
size = len(text.encode())
|
|
60
|
+
if size <= PASTE_STAGED_THRESHOLD_BYTES:
|
|
61
|
+
return PASTE_SUBMIT_DELAY_S
|
|
62
|
+
return min(PASTE_STAGED_MAX_DELAY_S, size / PASTE_STAGED_BYTES_PER_S)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_winsize_rows(fd) -> int:
|
|
66
|
+
"""The PTY's current row count (viewers may resize it at any time)."""
|
|
67
|
+
rows, _cols, _x, _y = struct.unpack(
|
|
68
|
+
"HHHH", fcntl.ioctl(fd, termios.TIOCGWINSZ, b"\0" * 8))
|
|
69
|
+
return rows
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def inline_paste_newline_cap(rows: int) -> int:
|
|
73
|
+
"""Line breaks one paste may carry and still be inserted as typed text."""
|
|
74
|
+
return max(0, min(rows - PASTE_INLINE_ROWS_RESERVED, PASTE_INLINE_MAX_NEWLINES))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def paste_chunks(text: str, max_newlines: int,
|
|
78
|
+
max_units: int = PASTE_INLINE_MAX_UNITS) -> list[str] | None:
|
|
79
|
+
"""Split text into pastes small enough for Claude to insert as typed text.
|
|
80
|
+
|
|
81
|
+
Sizes are measured the way Claude's paste handler measures them: UTF-16
|
|
82
|
+
units after it normalizes CRLF/CR to LF and expands tabs to four spaces.
|
|
83
|
+
A CRLF pair is never split. Returns None when the text has a line break but
|
|
84
|
+
no paste may carry one (a PTY under ``PASTE_INLINE_ROWS_RESERVED + 1``
|
|
85
|
+
rows), so the caller falls back to a single paste.
|
|
86
|
+
|
|
87
|
+
No paste holds an image extension whole (see ``_IMAGE_EXTENSION_DOT``),
|
|
88
|
+
except in a prompt that is only an image path, which Claude attaches as
|
|
89
|
+
the image.
|
|
90
|
+
"""
|
|
91
|
+
breaks = (set() if BARE_IMAGE_PATH_RE.match(text.strip())
|
|
92
|
+
else {m.end() for m in _IMAGE_EXTENSION_DOT.finditer(text)})
|
|
93
|
+
chunks: list[str] = []
|
|
94
|
+
cur: list[str] = []
|
|
95
|
+
units = newlines = 0
|
|
96
|
+
i = 0
|
|
97
|
+
while i < len(text):
|
|
98
|
+
ch = text[i]
|
|
99
|
+
if ch == "\r" and text[i + 1:i + 2] == "\n":
|
|
100
|
+
ch = "\r\n"
|
|
101
|
+
is_newline = ch in ("\n", "\r", "\r\n")
|
|
102
|
+
if is_newline and max_newlines < 1:
|
|
103
|
+
return None
|
|
104
|
+
cost = 4 if ch == "\t" else 2 if ord(ch[0]) > 0xFFFF else 1
|
|
105
|
+
if cur and (units + cost > max_units or i in breaks):
|
|
106
|
+
chunks.append("".join(cur))
|
|
107
|
+
cur, units, newlines = [], 0, 0
|
|
108
|
+
cur.append(ch)
|
|
109
|
+
units += cost
|
|
110
|
+
i += len(ch)
|
|
111
|
+
if is_newline:
|
|
112
|
+
newlines += 1
|
|
113
|
+
if newlines >= max_newlines:
|
|
114
|
+
chunks.append("".join(cur))
|
|
115
|
+
cur, units, newlines = [], 0, 0
|
|
116
|
+
if cur:
|
|
117
|
+
chunks.append("".join(cur))
|
|
118
|
+
return chunks
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def prompt_paste(text: str, rows: int) -> tuple[bytes, float, int]:
|
|
122
|
+
"""Frame a prompt for Claude's editor: ``(payload, submit_delay, pastes)``.
|
|
123
|
+
|
|
124
|
+
Normally a run of small pastes (see ``paste_chunks``) that Claude inserts
|
|
125
|
+
verbatim, so the submitted prompt reaches the model as the user's own
|
|
126
|
+
words rather than wrapped in <pasted_content> tags, and Enter can follow
|
|
127
|
+
immediately. A PTY too short for inline line breaks falls back to one
|
|
128
|
+
paste with the size-aware staged submit.
|
|
129
|
+
"""
|
|
130
|
+
chunks = paste_chunks(text, inline_paste_newline_cap(rows))
|
|
131
|
+
if chunks is None:
|
|
132
|
+
return paste_bytes(text), paste_submit_delay(text), 1
|
|
133
|
+
chunks = chunks or [text]
|
|
134
|
+
return b"".join(paste_bytes(c) for c in chunks), PASTE_SUBMIT_DELAY_S, len(chunks)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def clear_input_bytes() -> bytes:
|
|
138
|
+
"""Ctrl-C (Claude's app:interrupt). At an idle prompt it empties the whole
|
|
139
|
+
editor however many lines it holds, where a Ctrl-U burst only removes one
|
|
140
|
+
wrapped visual line per press. On an empty editor it only arms Claude's
|
|
141
|
+
exit confirmation, so a second press within 800ms would exit the worker:
|
|
142
|
+
the host spaces these presses at least ``CLEAR_INPUT_MIN_GAP_S`` apart."""
|
|
143
|
+
return b"\x03"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def stalled_paste_clear_action(slot: dict, now: float, last_pty: float) -> str | None:
|
|
147
|
+
"""Only reject a partial PTY write after an explicit quiet editor clear."""
|
|
148
|
+
if not last_pty or now - last_pty < PTY_QUIESCENCE_S:
|
|
149
|
+
return None
|
|
150
|
+
unstuck = slot.get("stalled_unstuck_at")
|
|
151
|
+
if unstuck is None:
|
|
152
|
+
return "unstick"
|
|
153
|
+
if now - unstuck < POST_CLEAR_SETTLE_S:
|
|
154
|
+
return None
|
|
155
|
+
cleared = slot.get("stalled_cleared_at")
|
|
156
|
+
if cleared is None:
|
|
157
|
+
return "clear"
|
|
158
|
+
if now - cleared < POST_CLEAR_SETTLE_S:
|
|
159
|
+
return None
|
|
160
|
+
return "reject"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_all(fd: int, payload: bytes, *, timeout_s: float = PTY_WRITE_TIMEOUT_S,
|
|
164
|
+
absolute_timeout_s: float | None = None,
|
|
165
|
+
on_partial=None, on_read=None) -> bool:
|
|
166
|
+
"""Drain short PTY writes, reading peer output to avoid bidirectional stalls.
|
|
167
|
+
|
|
168
|
+
The timeout bounds *lack of write progress*, not total payload time. A busy
|
|
169
|
+
Ink renderer can fill the opposite PTY buffer, stop reading input, and
|
|
170
|
+
deadlock a write-only host. ``on_read`` drains that output through the same
|
|
171
|
+
path as the host loop; it returns False on EOF. PTY write boundaries do not
|
|
172
|
+
change the logical bracketed pastes. ``absolute_timeout_s`` is an
|
|
173
|
+
optional non-renewable elapsed-time budget used only by attached-terminal
|
|
174
|
+
input; normal semantic writes preserve their progress-based timeout.
|
|
175
|
+
"""
|
|
176
|
+
start = time.monotonic()
|
|
177
|
+
deadline = start + timeout_s
|
|
178
|
+
absolute_deadline = (start + absolute_timeout_s
|
|
179
|
+
if absolute_timeout_s is not None else None)
|
|
180
|
+
sent = 0
|
|
181
|
+
while sent < len(payload):
|
|
182
|
+
if absolute_deadline is not None and time.monotonic() >= absolute_deadline:
|
|
183
|
+
return False
|
|
184
|
+
try:
|
|
185
|
+
written = os.write(fd, payload[sent:])
|
|
186
|
+
except InterruptedError:
|
|
187
|
+
continue
|
|
188
|
+
except BlockingIOError:
|
|
189
|
+
written = 0
|
|
190
|
+
except OSError:
|
|
191
|
+
return False
|
|
192
|
+
|
|
193
|
+
if written > 0:
|
|
194
|
+
sent += written
|
|
195
|
+
deadline = time.monotonic() + timeout_s
|
|
196
|
+
if sent < len(payload) and on_partial is not None:
|
|
197
|
+
on_partial(written, sent, len(payload))
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
remaining = deadline - time.monotonic()
|
|
201
|
+
if absolute_deadline is not None:
|
|
202
|
+
remaining = min(remaining, absolute_deadline - time.monotonic())
|
|
203
|
+
if remaining <= 0:
|
|
204
|
+
return False
|
|
205
|
+
try:
|
|
206
|
+
readable, writable, _ = select.select(
|
|
207
|
+
[fd] if on_read is not None else [], [fd], [], remaining
|
|
208
|
+
)
|
|
209
|
+
except InterruptedError:
|
|
210
|
+
continue
|
|
211
|
+
except OSError:
|
|
212
|
+
return False
|
|
213
|
+
if readable and on_read() is False:
|
|
214
|
+
return False
|
|
215
|
+
if not readable and not writable:
|
|
216
|
+
return False
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_ANSI_RE = re.compile(
|
|
221
|
+
rb"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)"
|
|
222
|
+
rb"|\x1b\[[0-9;?]*[ -/]*[@-~]"
|
|
223
|
+
rb"|\x1b[@-Z\\-_]"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def pty_tail_reason(buf: bytes | bytearray, *, max_chars: int = 300) -> str | None:
|
|
228
|
+
"""Distill a dead worker's last PTY output into a one-line failure reason.
|
|
229
|
+
|
|
230
|
+
A worker that dies at boot (an unknown `--agent`, a rejected `--model`, an
|
|
231
|
+
onboarding/trust gate) prints why on the PTY and exits. Nothing else records
|
|
232
|
+
that: the host log holds only the launcher's stdio, so without this the
|
|
233
|
+
record kept an opaque state=crashed with lastError=None and the operator had
|
|
234
|
+
no way to tell a typo'd flag from a real crash.
|
|
235
|
+
|
|
236
|
+
Returns None when there is nothing quotable. Box-drawing and spinner residue
|
|
237
|
+
from a half-rendered TUI is dropped — a line must carry alphanumeric text to
|
|
238
|
+
count as a message — and consecutive duplicate lines (Ink re-renders the same
|
|
239
|
+
frame) collapse to one.
|
|
240
|
+
"""
|
|
241
|
+
text = _ANSI_RE.sub(b"", bytes(buf)).decode("utf-8", "replace")
|
|
242
|
+
lines: list[str] = []
|
|
243
|
+
for raw in text.replace("\r", "\n").split("\n"):
|
|
244
|
+
line = "".join(c for c in raw if c.isprintable()).strip()
|
|
245
|
+
if not line or not any(c.isalnum() for c in line):
|
|
246
|
+
continue
|
|
247
|
+
if not lines or lines[-1] != line:
|
|
248
|
+
lines.append(line)
|
|
249
|
+
if not lines:
|
|
250
|
+
return None
|
|
251
|
+
return " ".join(lines[-3:])[:max_chars]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def unstick_bytes() -> bytes:
|
|
255
|
+
"""A lone closing bracketed-paste marker (\\x1b[201~). If a lost first paste
|
|
256
|
+
left Claude's decoder stuck IN_PASTE (its closing marker never arrived),
|
|
257
|
+
control keys are swallowed into the paste buffer and ignored; this marker
|
|
258
|
+
forces the decoder back to normal and materializes whatever partial it held
|
|
259
|
+
into the visible editor, where a kill key can then remove it."""
|
|
260
|
+
return b"\x1b[201~"
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def drain_bytes(kill_repeat: int = CLEAR_KILL_REPEAT) -> bytes:
|
|
264
|
+
"""A burst of Ctrl-U (\\x15), used for the single-line ``/compact``
|
|
265
|
+
residue. Ctrl-U is bound to delete-to-line-start, not whole-buffer clear:
|
|
266
|
+
each press removes one wrapped visual line, so it cannot empty a long
|
|
267
|
+
multi-line prompt (see clear_input_bytes()). No-op once the buffer is
|
|
268
|
+
empty."""
|
|
269
|
+
return b"\x15" * kill_repeat
|