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 CHANGED
@@ -1,11 +1,10 @@
1
1
  # ccx
2
2
 
3
- **Control and monitor native Claude Code and Codex sessions from the CLI, Python
4
- or RPC.**
3
+ **Run native Claude Code and Codex as persistent shared workers.**
5
4
 
6
- You, your agents and your scripts share the same persistent sessions. They run
7
- on your own Claude and ChatGPT subscriptions with your existing setup, and you
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",
@@ -1,4 +1,4 @@
1
- """One authenticated, bounded shared terminal stream for a managed Claude PTY.
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 lease and byte queues. Host invokes all methods on its I/O loop."""
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.conn = None
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.conn is not None
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.attached:
78
- return {"ok": False, "error": "another terminal is already attached"}
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 = 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)
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": self.token, "epoch": epoch}
133
+ "token": viewer.token, "epoch": epoch}
100
134
 
101
135
  def owns(self, conn, msg):
102
- return (self.conn is conn and isinstance(msg.get("token"), str)
103
- and msg["token"] == self.token)
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
- if len(self.input) + len(raw) > MAX_PENDING_INPUT:
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
- self.input.extend(raw)
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
- rows, cols = dimensions(msg.get("rows"), msg.get("cols"))
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(rows, cols)
156
+ self.resize(*self.size())
122
157
 
123
158
  def tick(self, write):
124
- if self.conn is None:
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.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)]
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.attached or not data:
176
+ if not self.viewers or not data:
140
177
  return
141
178
  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)
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
- if not self.attached or not self.output:
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(self.output[:32768])
192
+ sent = conn.send(viewer.output[:32768])
155
193
  if not sent:
156
194
  raise OSError("attachment connection closed")
157
- del self.output[:sent]
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 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:
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
- fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, saved)
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":
@@ -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
- render_transcript_json,
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 (f"An earlier message (delivery {delivery_id}) is still unresolved; "
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
- if state.get("semantic_delivery") is not None:
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 is conn and len(buf) > MAX_ATTACH_LINE
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 is conn and len(line) > MAX_ATTACH_LINE:
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 is 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 is 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 whole-history transcript rendering off the host's event loop.
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
- render_transcript_json(short)
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
- set_state(S.IDLE_KILLED, reason=f"idle for {idle_for:.0f}s > timeout {idle_timeout_s}s")
4891
- broadcast({"type": "idle_killed", "idle_for_s": idle_for})
4892
- break
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 = [hub.conn] if hub.attached and hub.output else []
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.attached and (hub.input or hub.output or hub.repaint_at):
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
- if hub.attached and hub.conn in writable:
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 is conn and len(buf) > MAX_ATTACH_LINE
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 is conn and attach_frames >= 64:
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 is conn and len(line) > MAX_ATTACH_LINE:
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 is 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 is 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
- if hub.attached:
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