ccx-native 0.1.0 → 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/README.md +4 -5
- package/ccx/__init__.py +4 -0
- package/ccx/claude/attach.py +103 -58
- package/ccx/claude/host/pty.py +20 -1
- package/ccx/claude/host/session.py +95 -35
- package/ccx/claude/transcript.py +321 -67
- package/ccx/claude/usage.py +5 -46
- package/ccx/claude/usage_control.py +93 -41
- package/ccx/cli/commands/control.py +1 -1
- package/ccx/cli/commands/models.py +53 -0
- package/ccx/cli/commands/read.py +90 -6
- package/ccx/cli/main.py +3 -2
- package/ccx/cli/parser.py +122 -7
- package/ccx/client.py +8 -4
- package/ccx/codex/client.py +6 -0
- package/ccx/codex/daemon/daemon.py +3 -1
- package/ccx/codex/daemon/worker.py +41 -13
- package/ccx/codex/history_store.py +131 -0
- package/ccx/codex/runtime.py +73 -31
- package/ccx/models.py +225 -0
- package/ccx/ops/cancel.py +18 -11
- package/ccx/ops/conversation.py +67 -14
- package/ccx/ops/conversation_paging.py +15 -14
- package/ccx/ops/creation_steps.py +0 -4
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/report.py +8 -5
- package/ccx/ops/turn_reports.py +47 -5
- package/ccx/ops/worker_events.py +5 -1
- package/ccx/store/paths.py +0 -4
- package/ccx/store/root.py +6 -21
- package/ccx/store/store.py +2 -2
- package/ccx/system/private_json.py +54 -0
- package/ccx/types.py +104 -0
- package/ccx/workers.py +70 -58
- package/package.json +2 -2
- package/pyproject.toml +2 -2
- package/skills/ccx/SKILL.md +12 -1
- package/skills/ccx/references/history.md +50 -0
package/README.md
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
# ccx
|
|
2
2
|
|
|
3
|
-
**
|
|
4
|
-
or RPC.**
|
|
3
|
+
**Run native Claude Code and Codex as persistent shared workers.**
|
|
5
4
|
|
|
6
|
-
You, your agents and your scripts
|
|
7
|
-
on your own Claude and ChatGPT subscriptions with your
|
|
8
|
-
can open any of them live with `ccx attach`.
|
|
5
|
+
You, your agents and your scripts can all start, message, steer and check on the
|
|
6
|
+
same workers. They run on your own Claude and ChatGPT subscriptions with your
|
|
7
|
+
existing setup, and you can open any of them live with `ccx attach`.
|
|
9
8
|
|
|
10
9
|
```bash
|
|
11
10
|
npm install -g ccx-native
|
package/ccx/__init__.py
CHANGED
|
@@ -56,6 +56,8 @@ from .types import (
|
|
|
56
56
|
CompactOperationResult,
|
|
57
57
|
CompactResult,
|
|
58
58
|
Event,
|
|
59
|
+
ModelCatalog,
|
|
60
|
+
ModelInfo,
|
|
59
61
|
PendingInput,
|
|
60
62
|
Question,
|
|
61
63
|
Report,
|
|
@@ -146,6 +148,8 @@ __all__ = [
|
|
|
146
148
|
"CompactOperationResult",
|
|
147
149
|
"WaitResult",
|
|
148
150
|
"Event",
|
|
151
|
+
"ModelCatalog",
|
|
152
|
+
"ModelInfo",
|
|
149
153
|
"Usage",
|
|
150
154
|
# Codex types
|
|
151
155
|
"CodexSessionInfo",
|
package/ccx/claude/attach.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Authenticated, bounded shared terminal streams for a managed Claude PTY.
|
|
2
2
|
|
|
3
3
|
Only the host select loop reads/writes the PTY. This module owns framing and
|
|
4
4
|
local TTY lifecycle; it never launches, parks or resumes a native Claude.
|
|
@@ -27,6 +27,7 @@ MAX_PENDING_INPUT = 64 * 1024
|
|
|
27
27
|
MAX_PENDING_OUTPUT = 1024 * 1024
|
|
28
28
|
MAX_ATTACH_LINE = 48 * 1024 # 32 KiB base64 + envelope
|
|
29
29
|
MAX_DIMENSION = 1000
|
|
30
|
+
MAX_VIEWERS = 8 # terminals attached to one worker at once
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
def frame(op: str, **fields) -> bytes:
|
|
@@ -52,8 +53,23 @@ def decode_input(data) -> bytes:
|
|
|
52
53
|
return raw
|
|
53
54
|
|
|
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
|
+
|
|
55
66
|
class HostAttachment:
|
|
56
|
-
"""Hot-only
|
|
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
|
+
"""
|
|
57
73
|
|
|
58
74
|
def __init__(self, master_fd, resize, event, disconnect, on_release):
|
|
59
75
|
self.master_fd = master_fd
|
|
@@ -61,121 +77,150 @@ class HostAttachment:
|
|
|
61
77
|
self.event = event
|
|
62
78
|
self.disconnect = disconnect
|
|
63
79
|
self.on_release = on_release
|
|
64
|
-
self.
|
|
65
|
-
self.token = None
|
|
66
|
-
self.input = bytearray()
|
|
67
|
-
self.output = bytearray()
|
|
80
|
+
self.viewers: dict = {}
|
|
68
81
|
self.saved_size = None
|
|
69
|
-
self.requested = None
|
|
70
82
|
self.repaint_at = None
|
|
71
83
|
|
|
72
84
|
@property
|
|
73
85
|
def attached(self):
|
|
74
|
-
return 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()))
|
|
75
102
|
|
|
76
103
|
def open(self, conn, msg, *, epoch):
|
|
77
|
-
if self.
|
|
78
|
-
return {"ok": False, "error": "
|
|
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})"}
|
|
79
108
|
if msg.get("version") != VERSION or type(msg.get("version")) is not int:
|
|
80
109
|
return {"ok": False, "error": "unsupported shared attach protocol version"}
|
|
81
110
|
try:
|
|
82
111
|
rows, cols = dimensions(msg.get("rows"), msg.get("cols"))
|
|
83
|
-
saved =
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
88
127
|
except (ValueError, OSError) as exc:
|
|
89
128
|
return {"ok": False, "error": str(exc)}
|
|
90
|
-
self.conn = conn
|
|
91
|
-
self.token = uuid.uuid4().hex
|
|
92
129
|
self.saved_size = saved
|
|
93
|
-
self.requested = (rows, cols)
|
|
94
130
|
self.repaint_at = time.monotonic() + .12
|
|
95
|
-
self.input.clear()
|
|
96
|
-
self.output.clear()
|
|
97
131
|
self.event({"type": "attach_opened"})
|
|
98
132
|
return {"ok": True, "op": "attach-opened", "version": VERSION,
|
|
99
|
-
"token":
|
|
133
|
+
"token": viewer.token, "epoch": epoch}
|
|
100
134
|
|
|
101
135
|
def owns(self, conn, msg):
|
|
102
|
-
|
|
103
|
-
|
|
136
|
+
viewer = self.viewers.get(conn)
|
|
137
|
+
return (viewer is not None and isinstance(msg.get("token"), str)
|
|
138
|
+
and msg["token"] == viewer.token)
|
|
104
139
|
|
|
105
140
|
def accept_input(self, conn, msg):
|
|
106
141
|
if not self.owns(conn, msg):
|
|
107
142
|
raise ValueError("invalid attachment token or connection")
|
|
108
143
|
raw = decode_input(msg.get("data"))
|
|
109
|
-
|
|
144
|
+
viewer = self.viewers[conn]
|
|
145
|
+
if len(viewer.input) + len(raw) > MAX_PENDING_INPUT:
|
|
110
146
|
self.release(conn, "input-overflow")
|
|
111
147
|
self.disconnect(conn)
|
|
112
148
|
return
|
|
113
|
-
|
|
149
|
+
viewer.input.extend(raw)
|
|
114
150
|
|
|
115
151
|
def accept_resize(self, conn, msg):
|
|
116
152
|
if not self.owns(conn, msg):
|
|
117
153
|
raise ValueError("invalid attachment token or connection")
|
|
118
|
-
|
|
119
|
-
self.requested = (rows, cols)
|
|
154
|
+
self.viewers[conn].size = dimensions(msg.get("rows"), msg.get("cols"))
|
|
120
155
|
if self.repaint_at is None:
|
|
121
|
-
self.resize(
|
|
156
|
+
self.resize(*self.size())
|
|
122
157
|
|
|
123
158
|
def tick(self, write):
|
|
124
|
-
if self.
|
|
159
|
+
if not self.viewers:
|
|
125
160
|
return
|
|
126
161
|
if self.repaint_at is not None and time.monotonic() >= self.repaint_at:
|
|
127
162
|
self.repaint_at = None
|
|
128
|
-
self.resize(*self.
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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)]
|
|
133
171
|
if not write(part):
|
|
134
|
-
conn = self.conn
|
|
135
172
|
self.release(conn, "pty-write-error")
|
|
136
173
|
self.disconnect(conn)
|
|
137
174
|
|
|
138
175
|
def feed_output(self, data: bytes):
|
|
139
|
-
if not self.
|
|
176
|
+
if not self.viewers or not data:
|
|
140
177
|
return
|
|
141
178
|
packet = frame("attach-output", data=base64.b64encode(data).decode("ascii"))
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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)
|
|
148
186
|
|
|
149
|
-
def flush(self):
|
|
150
|
-
|
|
187
|
+
def flush(self, conn):
|
|
188
|
+
viewer = self.viewers.get(conn)
|
|
189
|
+
if viewer is None or not viewer.output:
|
|
151
190
|
return
|
|
152
|
-
conn = self.conn
|
|
153
191
|
try:
|
|
154
|
-
sent = conn.send(
|
|
192
|
+
sent = conn.send(viewer.output[:32768])
|
|
155
193
|
if not sent:
|
|
156
194
|
raise OSError("attachment connection closed")
|
|
157
|
-
del
|
|
195
|
+
del viewer.output[:sent]
|
|
158
196
|
except BlockingIOError:
|
|
159
197
|
return
|
|
160
198
|
except OSError:
|
|
161
199
|
self.release(conn, "connection-lost")
|
|
162
200
|
self.disconnect(conn)
|
|
163
201
|
|
|
202
|
+
def release_all(self, reason):
|
|
203
|
+
for conn in list(self.viewers):
|
|
204
|
+
self.release(conn, reason)
|
|
205
|
+
self.disconnect(conn)
|
|
206
|
+
|
|
164
207
|
def release(self, conn, reason):
|
|
165
|
-
if
|
|
166
|
-
return
|
|
167
|
-
self.
|
|
168
|
-
|
|
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:
|
|
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.
|
|
175
212
|
try:
|
|
176
|
-
|
|
213
|
+
self.resize(*self.size())
|
|
177
214
|
except OSError:
|
|
178
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
|
|
179
224
|
self.on_release()
|
|
180
225
|
self.event({"type": "attach_closed", "reason": reason})
|
|
181
226
|
if reason != "client-eof":
|
package/ccx/claude/host/pty.py
CHANGED
|
@@ -19,6 +19,19 @@ from ccx.constants import (
|
|
|
19
19
|
PTY_QUIESCENCE_S,
|
|
20
20
|
PTY_WRITE_TIMEOUT_S,
|
|
21
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)
|
|
22
35
|
|
|
23
36
|
|
|
24
37
|
def set_winsize(fd, rows, cols):
|
|
@@ -70,7 +83,13 @@ def paste_chunks(text: str, max_newlines: int,
|
|
|
70
83
|
A CRLF pair is never split. Returns None when the text has a line break but
|
|
71
84
|
no paste may carry one (a PTY under ``PASTE_INLINE_ROWS_RESERVED + 1``
|
|
72
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.
|
|
73
90
|
"""
|
|
91
|
+
breaks = (set() if BARE_IMAGE_PATH_RE.match(text.strip())
|
|
92
|
+
else {m.end() for m in _IMAGE_EXTENSION_DOT.finditer(text)})
|
|
74
93
|
chunks: list[str] = []
|
|
75
94
|
cur: list[str] = []
|
|
76
95
|
units = newlines = 0
|
|
@@ -83,7 +102,7 @@ def paste_chunks(text: str, max_newlines: int,
|
|
|
83
102
|
if is_newline and max_newlines < 1:
|
|
84
103
|
return None
|
|
85
104
|
cost = 4 if ch == "\t" else 2 if ord(ch[0]) > 0xFFFF else 1
|
|
86
|
-
if cur and units + cost > max_units:
|
|
105
|
+
if cur and (units + cost > max_units or i in breaks):
|
|
87
106
|
chunks.append("".join(cur))
|
|
88
107
|
cur, units, newlines = [], 0, 0
|
|
89
108
|
cur.append(ch)
|
|
@@ -127,9 +127,10 @@ from ccx.claude.transcript import (
|
|
|
127
127
|
is_compact_command_text,
|
|
128
128
|
normalize_semantic_prompt_text,
|
|
129
129
|
prompt_matches,
|
|
130
|
+
prompt_mismatch_event,
|
|
130
131
|
queued_prompt_accepted,
|
|
131
132
|
read_ai_title,
|
|
132
|
-
|
|
133
|
+
refresh_context_observation,
|
|
133
134
|
)
|
|
134
135
|
from ccx.claude.usage import _merge_caps, cap_tripped, fetch_usage, read_usage_cached, unknown_cap_metrics
|
|
135
136
|
from ccx.claude.host.events import _count_lines, _elicitation_request, _hook_tool_use_id, _oldest_native_gate, _open_event_journal, _permission_preview, _rotate_events_file, _stamp_event, _subagent_finished_event, _tool_finished_event, _tool_started_event
|
|
@@ -250,6 +251,39 @@ def adopt_ai_title(short: str, transcript_path) -> bool | None:
|
|
|
250
251
|
return True
|
|
251
252
|
|
|
252
253
|
|
|
254
|
+
def _interrupt_refusal_for_delivery(slot: dict | None) -> str | None:
|
|
255
|
+
"""Why an interrupt must wait for the in-flight delivery, or None.
|
|
256
|
+
|
|
257
|
+
A delivery CCX still holds (nothing written to the PTY yet) is unaffected
|
|
258
|
+
by Escape: once the turn ends, a held steer falls back to the next turn
|
|
259
|
+
and a held send goes out when the worker is idle. Only text that may
|
|
260
|
+
already be in Claude's editor or queue makes Escape ambiguous, and that
|
|
261
|
+
window lasts seconds.
|
|
262
|
+
"""
|
|
263
|
+
if slot is None:
|
|
264
|
+
return None
|
|
265
|
+
if not slot.get("delivery_id"):
|
|
266
|
+
return ("An earlier message is unresolved; inspect the worker and "
|
|
267
|
+
"reconcile its delivery before interrupting.")
|
|
268
|
+
if not slot.get("maybe_written"):
|
|
269
|
+
return None
|
|
270
|
+
if slot.get("route") == "steer":
|
|
271
|
+
return "a steer is being handed to Claude; retry in a few seconds"
|
|
272
|
+
return "a message is being handed to Claude; retry in a few seconds"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def unresolved_delivery_refusal(delivery_id: str, receipt: dict | None) -> str:
|
|
276
|
+
"""Why a send is refused while an earlier delivery is unresolved."""
|
|
277
|
+
if (receipt or {}).get("evidenceStatus") == "foreign-start-after-possible-write":
|
|
278
|
+
return (f"Claude started a turn whose text didn't match an earlier message "
|
|
279
|
+
f"(delivery {delivery_id}), so CCX can't tell whether that message "
|
|
280
|
+
"was delivered. `ccx kill` the worker to clear it (the conversation "
|
|
281
|
+
"is kept).")
|
|
282
|
+
return (f"An earlier message (delivery {delivery_id}) is still unresolved; "
|
|
283
|
+
"wait for it, or if it is stuck, `ccx kill` the worker to clear it "
|
|
284
|
+
"(the conversation is kept).")
|
|
285
|
+
|
|
286
|
+
|
|
253
287
|
def _semantic_slot_from_receipt(receipt: dict, hot: dict | None = None) -> dict:
|
|
254
288
|
"""Recover the original immutable cap even if the hot journal was evicted."""
|
|
255
289
|
hot = hot or {}
|
|
@@ -999,6 +1033,16 @@ def _run_host(args):
|
|
|
999
1033
|
continue
|
|
1000
1034
|
new_items = current_bg_items()
|
|
1001
1035
|
changes = diff_items(bg["items"], new_items)
|
|
1036
|
+
if (state["current"] == S.IDLE
|
|
1037
|
+
and any(item["status"] == "running" for item in bg["items"])
|
|
1038
|
+
and not any(item["status"] == "running" for item in new_items)):
|
|
1039
|
+
# Native background work was not user idleness. Its final
|
|
1040
|
+
# completion starts a NEW configured idle window, allowing
|
|
1041
|
+
# Claude's queued completion notification to begin its turn.
|
|
1042
|
+
# No new polling, fixed grace duration, state/turn invention,
|
|
1043
|
+
# or replayed bootstrap facts; only this fresh native edge.
|
|
1044
|
+
state["last_activity"] = time.time()
|
|
1045
|
+
state["bg_idle_grace"] = False
|
|
1002
1046
|
bg["items"] = new_items
|
|
1003
1047
|
if publish and changes:
|
|
1004
1048
|
publish_bg(new_items, changes)
|
|
@@ -1158,9 +1202,7 @@ def _run_host(args):
|
|
|
1158
1202
|
if prior is not None and prior.get("state") in ("accepted", "rejected", "delivered", "abandoned"):
|
|
1159
1203
|
state["semantic_delivery"] = None
|
|
1160
1204
|
else:
|
|
1161
|
-
return (
|
|
1162
|
-
"wait for it, or if it is stuck, `ccx kill` the worker to clear it "
|
|
1163
|
-
"(the conversation is kept).")
|
|
1205
|
+
return unresolved_delivery_refusal(delivery_id, prior)
|
|
1164
1206
|
if state.get("compacting") is not None or state.get("compact_clear") is not None:
|
|
1165
1207
|
return "Compaction is in progress; try again after it finishes."
|
|
1166
1208
|
if state.get("pending_permission"):
|
|
@@ -2656,6 +2698,8 @@ def _run_host(args):
|
|
|
2656
2698
|
|
|
2657
2699
|
# A mismatched prompt is foreign. If our write may already exist, this
|
|
2658
2700
|
# only makes the semantic placement ambiguous; it never permits resend.
|
|
2701
|
+
if slot.get("maybe_written"):
|
|
2702
|
+
broadcast(prompt_mismatch_event(slot["delivery_id"], expected, prompt))
|
|
2659
2703
|
semantic_foreign_turn_start("user-prompt-submit")
|
|
2660
2704
|
return False
|
|
2661
2705
|
|
|
@@ -2760,6 +2804,13 @@ def _run_host(args):
|
|
|
2760
2804
|
return None
|
|
2761
2805
|
if slot.get("maybe_written"):
|
|
2762
2806
|
return {"ok": True, "delivered": False, "pending": True}
|
|
2807
|
+
interrupting = state.get("interrupt_observation") or {}
|
|
2808
|
+
if (interrupting.get("turn_ref") == target and interrupting.get("passive") is False
|
|
2809
|
+
and interrupting.get("interrupt_at") is not None):
|
|
2810
|
+
# CCX sent Escape to this turn: keep the steer for the next turn
|
|
2811
|
+
# rather than queueing it into a turn that is stopping.
|
|
2812
|
+
return {"ok": True, "delivered": False, "pending": True,
|
|
2813
|
+
"reason": "target turn is being interrupted"}
|
|
2763
2814
|
|
|
2764
2815
|
transcript_path = data.get("transcript_path")
|
|
2765
2816
|
if not transcript_path:
|
|
@@ -2791,8 +2842,19 @@ def _run_host(args):
|
|
|
2791
2842
|
state["active_cap"] = _merge_caps([state.get("active_cap"), slot.get("cap")])
|
|
2792
2843
|
state["last_cap_poll"] = 0.0
|
|
2793
2844
|
deadline = now + STEER_BOUNDARY_ACK_S
|
|
2845
|
+
# Scan only what Claude wrote after the paste fence. The fence is a
|
|
2846
|
+
# size of the worker's own transcript; any other file is scanned whole.
|
|
2847
|
+
fence = slot.get("paste_offset")
|
|
2848
|
+
try:
|
|
2849
|
+
same_file = (transcript_path == rec["transcript"]
|
|
2850
|
+
or os.path.samefile(transcript_path, rec["transcript"]))
|
|
2851
|
+
except OSError:
|
|
2852
|
+
same_file = False
|
|
2853
|
+
if not same_file or type(fence) is not int or fence < 0:
|
|
2854
|
+
fence = 0
|
|
2794
2855
|
while time.time() < deadline:
|
|
2795
|
-
if queued_prompt_accepted(transcript_path, slot["text"], after_ms=before_ms
|
|
2856
|
+
if queued_prompt_accepted(transcript_path, slot["text"], after_ms=before_ms,
|
|
2857
|
+
after_offset=fence):
|
|
2796
2858
|
reconcile_semantic_delivery()
|
|
2797
2859
|
return {"ok": True, "delivered": True,
|
|
2798
2860
|
"delivery_id": slot["delivery_id"]}
|
|
@@ -3972,13 +4034,13 @@ def _run_host(args):
|
|
|
3972
4034
|
"turns": state["turns"],
|
|
3973
4035
|
"activeTurnRef": state.get("active_turn_ref"),
|
|
3974
4036
|
"lastTurnAt": r.get("lastTurnAt"),
|
|
3975
|
-
"transcript": r.get("transcriptJson"),
|
|
3976
4037
|
"pendingDeliveries": pending_steers,
|
|
3977
4038
|
"steerJournalError": steer_journal_error,
|
|
3978
4039
|
"waitingFor": current_waiting_preview(),
|
|
3979
4040
|
# Brokered requests waiting behind `waitingFor`, oldest first.
|
|
3980
4041
|
"queuedInputs": len(state["permission_queue"]),
|
|
3981
4042
|
"attached": hub.attached,
|
|
4043
|
+
"attachedTerminals": len(hub.viewers),
|
|
3982
4044
|
# Is the local /compact pump still running? The pump is bounded
|
|
3983
4045
|
# by its own deadline and can give up without writing a
|
|
3984
4046
|
# boundary, so a client waiting for one needs to know when to
|
|
@@ -4017,16 +4079,12 @@ def _run_host(args):
|
|
|
4017
4079
|
"state": state["current"], "turnRef": current_ref,
|
|
4018
4080
|
}) + "\n").encode())
|
|
4019
4081
|
return
|
|
4020
|
-
|
|
4082
|
+
refusal = _interrupt_refusal_for_delivery(state.get("semantic_delivery"))
|
|
4083
|
+
if refusal is not None:
|
|
4021
4084
|
client_sock.sendall((json.dumps({
|
|
4022
4085
|
"ok": False,
|
|
4023
4086
|
"definitive": True,
|
|
4024
|
-
"error":
|
|
4025
|
-
"queued steer is still pending; ccx kill <worker> discards "
|
|
4026
|
-
"the queue after checking the transcript (delivered or abandoned)"
|
|
4027
|
-
if (state["semantic_delivery"] or {}).get("route") == "steer"
|
|
4028
|
-
else "semantic delivery in flight; retry after it settles"
|
|
4029
|
-
),
|
|
4087
|
+
"error": refusal,
|
|
4030
4088
|
"state": state["current"],
|
|
4031
4089
|
}) + "\n").encode())
|
|
4032
4090
|
return
|
|
@@ -4332,7 +4390,7 @@ def _run_host(args):
|
|
|
4332
4390
|
continue
|
|
4333
4391
|
progress = True
|
|
4334
4392
|
buf.extend(data)
|
|
4335
|
-
if (hub.conn
|
|
4393
|
+
if (hub.has(conn) and len(buf) > MAX_ATTACH_LINE
|
|
4336
4394
|
and b"\n" not in buf):
|
|
4337
4395
|
hub.release(conn, "oversized-attachment-frame")
|
|
4338
4396
|
drop_client(conn)
|
|
@@ -4345,20 +4403,20 @@ def _run_host(args):
|
|
|
4345
4403
|
buf.extend(rest)
|
|
4346
4404
|
if not line.strip():
|
|
4347
4405
|
continue
|
|
4348
|
-
if hub.conn
|
|
4406
|
+
if hub.has(conn) and len(line) > MAX_ATTACH_LINE:
|
|
4349
4407
|
hub.release(conn, "oversized-attachment-frame")
|
|
4350
4408
|
drop_client(conn)
|
|
4351
4409
|
break
|
|
4352
4410
|
try:
|
|
4353
4411
|
msg = json.loads(line)
|
|
4354
4412
|
except (UnicodeError, ValueError):
|
|
4355
|
-
if hub.conn
|
|
4413
|
+
if hub.has(conn):
|
|
4356
4414
|
hub.release(conn, "malformed-attachment-frame")
|
|
4357
4415
|
drop_client(conn)
|
|
4358
4416
|
break
|
|
4359
4417
|
continue
|
|
4360
4418
|
if not isinstance(msg, dict):
|
|
4361
|
-
if hub.conn
|
|
4419
|
+
if hub.has(conn):
|
|
4362
4420
|
hub.release(conn, "malformed-attachment-frame")
|
|
4363
4421
|
drop_client(conn)
|
|
4364
4422
|
break
|
|
@@ -4396,7 +4454,7 @@ def _run_host(args):
|
|
|
4396
4454
|
threading.Thread(target=_usage_refresher, name="ccx-usage-refresh",
|
|
4397
4455
|
daemon=True).start()
|
|
4398
4456
|
|
|
4399
|
-
# Keep
|
|
4457
|
+
# Keep the context/model observation refresh off the host's event loop.
|
|
4400
4458
|
refresh_wanted = threading.Event()
|
|
4401
4459
|
refresh_stop = threading.Event()
|
|
4402
4460
|
|
|
@@ -4408,7 +4466,7 @@ def _run_host(args):
|
|
|
4408
4466
|
refresh_wanted.wait()
|
|
4409
4467
|
refresh_wanted.clear()
|
|
4410
4468
|
try:
|
|
4411
|
-
|
|
4469
|
+
refresh_context_observation(short)
|
|
4412
4470
|
except Exception:
|
|
4413
4471
|
pass
|
|
4414
4472
|
if refresh_stop.is_set() and not refresh_wanted.is_set():
|
|
@@ -4887,9 +4945,14 @@ def _run_host(args):
|
|
|
4887
4945
|
state["bg_idle_grace"] = True
|
|
4888
4946
|
broadcast({"type": "idle_grace_bg", "idle_for_s": idle_for})
|
|
4889
4947
|
else:
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4948
|
+
# The forced read above can observe the last task's
|
|
4949
|
+
# completion and restart the idle clock. Do not reuse
|
|
4950
|
+
# the pre-read overdue value and kill on that edge.
|
|
4951
|
+
idle_for = time.time() - state["last_activity"]
|
|
4952
|
+
if idle_for > idle_timeout_s:
|
|
4953
|
+
set_state(S.IDLE_KILLED, reason=f"idle for {idle_for:.0f}s > timeout {idle_timeout_s}s")
|
|
4954
|
+
broadcast({"type": "idle_killed", "idle_for_s": idle_for})
|
|
4955
|
+
break
|
|
4893
4956
|
|
|
4894
4957
|
# Boot watchdog. BOOTING only ends on a SessionStart hook; if Claude
|
|
4895
4958
|
# blocks before that (un-pre-seeded --add-dir trust dialog, onboarding
|
|
@@ -4941,7 +5004,7 @@ def _run_host(args):
|
|
|
4941
5004
|
part, timeout_s=.01, absolute_timeout_s=.01))
|
|
4942
5005
|
# Build select set
|
|
4943
5006
|
rlist = [server, master_fd] + list(state["clients"].keys())
|
|
4944
|
-
wlist =
|
|
5007
|
+
wlist = hub.pending()
|
|
4945
5008
|
try:
|
|
4946
5009
|
fast_observe = (
|
|
4947
5010
|
state.get("interrupt_observation") is not None
|
|
@@ -4949,14 +5012,14 @@ def _run_host(args):
|
|
|
4949
5012
|
or state.get("successor_pending") is not None
|
|
4950
5013
|
)
|
|
4951
5014
|
select_timeout = INTERRUPT_NATIVE_POLL_S if fast_observe else 1.0
|
|
4952
|
-
if hub.
|
|
5015
|
+
if hub.busy():
|
|
4953
5016
|
select_timeout = min(select_timeout, .05)
|
|
4954
5017
|
r, writable, _ = select.select(rlist, wlist, [], select_timeout)
|
|
4955
5018
|
except (InterruptedError, OSError):
|
|
4956
5019
|
continue
|
|
4957
5020
|
|
|
4958
|
-
|
|
4959
|
-
hub.flush()
|
|
5021
|
+
for conn in writable:
|
|
5022
|
+
hub.flush(conn)
|
|
4960
5023
|
for src in r:
|
|
4961
5024
|
if src is server:
|
|
4962
5025
|
try:
|
|
@@ -4989,7 +5052,7 @@ def _run_host(args):
|
|
|
4989
5052
|
drop_client(conn)
|
|
4990
5053
|
continue
|
|
4991
5054
|
buf.extend(data)
|
|
4992
|
-
if (hub.conn
|
|
5055
|
+
if (hub.has(conn) and len(buf) > MAX_ATTACH_LINE
|
|
4993
5056
|
and b"\n" not in buf):
|
|
4994
5057
|
hub.release(conn, "oversized-attachment-frame")
|
|
4995
5058
|
drop_client(conn)
|
|
@@ -4998,7 +5061,7 @@ def _run_host(args):
|
|
|
4998
5061
|
while b"\n" in buf:
|
|
4999
5062
|
if fd not in state["clients"]:
|
|
5000
5063
|
break # attach-close or overflow already removed this fd
|
|
5001
|
-
if hub.conn
|
|
5064
|
+
if hub.has(conn) and attach_frames >= 64:
|
|
5002
5065
|
hub.release(conn, "input-frame-flood")
|
|
5003
5066
|
drop_client(conn)
|
|
5004
5067
|
break
|
|
@@ -5008,20 +5071,20 @@ def _run_host(args):
|
|
|
5008
5071
|
buf.extend(rest)
|
|
5009
5072
|
if not line.strip():
|
|
5010
5073
|
continue
|
|
5011
|
-
if hub.conn
|
|
5074
|
+
if hub.has(conn) and len(line) > MAX_ATTACH_LINE:
|
|
5012
5075
|
hub.release(conn, "oversized-attachment-frame")
|
|
5013
5076
|
drop_client(conn)
|
|
5014
5077
|
break
|
|
5015
5078
|
try:
|
|
5016
5079
|
msg = json.loads(line)
|
|
5017
5080
|
except (UnicodeError, ValueError):
|
|
5018
|
-
if hub.conn
|
|
5081
|
+
if hub.has(conn):
|
|
5019
5082
|
hub.release(conn, "malformed-attachment-frame")
|
|
5020
5083
|
drop_client(conn)
|
|
5021
5084
|
break
|
|
5022
5085
|
continue
|
|
5023
5086
|
if not isinstance(msg, dict):
|
|
5024
|
-
if hub.conn
|
|
5087
|
+
if hub.has(conn):
|
|
5025
5088
|
hub.release(conn, "malformed-attachment-frame")
|
|
5026
5089
|
drop_client(conn)
|
|
5027
5090
|
break
|
|
@@ -5049,10 +5112,7 @@ def _run_host(args):
|
|
|
5049
5112
|
state["pending_last_error"] = reason
|
|
5050
5113
|
break
|
|
5051
5114
|
|
|
5052
|
-
|
|
5053
|
-
conn = hub.conn
|
|
5054
|
-
hub.release(conn, "host-exit")
|
|
5055
|
-
drop_client(conn)
|
|
5115
|
+
hub.release_all("host-exit")
|
|
5056
5116
|
|
|
5057
5117
|
# Shutdown. Reconcile an active compact before tearing down its host/PTY.
|
|
5058
5118
|
# A boundary may have landed on the final loop tick; if not, preserve the
|