ccx-native 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +241 -2
  3. package/bin/ccx +56 -0
  4. package/ccx/__init__.py +163 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +762 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +250 -0
  16. package/ccx/claude/host/session.py +5184 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2281 -0
  26. package/ccx/claude/usage.py +837 -0
  27. package/ccx/claude/usage_control.py +152 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/read.py +1020 -0
  35. package/ccx/cli/commands/turns.py +1081 -0
  36. package/ccx/cli/controller.py +757 -0
  37. package/ccx/cli/main.py +301 -0
  38. package/ccx/cli/parser.py +1324 -0
  39. package/ccx/cli/skills.py +215 -0
  40. package/ccx/cli/statusline.py +160 -0
  41. package/ccx/client.py +1900 -0
  42. package/ccx/codex/__init__.py +0 -0
  43. package/ccx/codex/attach_tty.py +267 -0
  44. package/ccx/codex/client.py +732 -0
  45. package/ccx/codex/completion.py +121 -0
  46. package/ccx/codex/control.py +288 -0
  47. package/ccx/codex/daemon/__init__.py +0 -0
  48. package/ccx/codex/daemon/__main__.py +4 -0
  49. package/ccx/codex/daemon/daemon.py +1556 -0
  50. package/ccx/codex/daemon/models.py +140 -0
  51. package/ccx/codex/daemon/native.py +1590 -0
  52. package/ccx/codex/daemon/release.py +367 -0
  53. package/ccx/codex/daemon/worker.py +2396 -0
  54. package/ccx/codex/delivery.py +758 -0
  55. package/ccx/codex/delivery_recovery.py +232 -0
  56. package/ccx/codex/evidence.py +827 -0
  57. package/ccx/codex/history.py +158 -0
  58. package/ccx/codex/notify_history.py +140 -0
  59. package/ccx/codex/ops.py +666 -0
  60. package/ccx/codex/paths.py +70 -0
  61. package/ccx/codex/protocol.py +805 -0
  62. package/ccx/codex/provenance.py +105 -0
  63. package/ccx/codex/read.py +29 -0
  64. package/ccx/codex/records.py +51 -0
  65. package/ccx/codex/reports.py +177 -0
  66. package/ccx/codex/runtime.py +1412 -0
  67. package/ccx/codex/settle.py +363 -0
  68. package/ccx/codex/telemetry.py +792 -0
  69. package/ccx/codex/types.py +659 -0
  70. package/ccx/codex/worker_environment.py +25 -0
  71. package/ccx/constants.py +288 -0
  72. package/ccx/controllers/__init__.py +0 -0
  73. package/ccx/controllers/controller.py +1449 -0
  74. package/ccx/controllers/live_vars.py +1706 -0
  75. package/ccx/controllers/live_vars_runner.py +216 -0
  76. package/ccx/controllers/notify.py +213 -0
  77. package/ccx/controllers/orchestrator.py +193 -0
  78. package/ccx/controllers/policy.py +57 -0
  79. package/ccx/controllers/store.py +681 -0
  80. package/ccx/errors.py +634 -0
  81. package/ccx/notify/__init__.py +0 -0
  82. package/ccx/notify/__main__.py +5 -0
  83. package/ccx/notify/client.py +116 -0
  84. package/ccx/notify/daemon.py +480 -0
  85. package/ccx/notify/deliver.py +304 -0
  86. package/ccx/notify/paths.py +51 -0
  87. package/ccx/notify/registry.py +649 -0
  88. package/ccx/notify/select.py +750 -0
  89. package/ccx/notify/settle.py +684 -0
  90. package/ccx/ops/__init__.py +0 -0
  91. package/ccx/ops/background.py +115 -0
  92. package/ccx/ops/cancel.py +289 -0
  93. package/ccx/ops/codex_import.py +96 -0
  94. package/ccx/ops/common.py +397 -0
  95. package/ccx/ops/compact.py +337 -0
  96. package/ccx/ops/compact_ops.py +550 -0
  97. package/ccx/ops/context_events.py +48 -0
  98. package/ccx/ops/conversation.py +1540 -0
  99. package/ccx/ops/conversation_paging.py +1338 -0
  100. package/ccx/ops/create.py +56 -0
  101. package/ccx/ops/creation.py +952 -0
  102. package/ccx/ops/creation_steps.py +579 -0
  103. package/ccx/ops/event_feed.py +710 -0
  104. package/ccx/ops/forking.py +711 -0
  105. package/ccx/ops/input.py +556 -0
  106. package/ccx/ops/maintenance.py +295 -0
  107. package/ccx/ops/report.py +770 -0
  108. package/ccx/ops/semantics.py +490 -0
  109. package/ccx/ops/send.py +961 -0
  110. package/ccx/ops/steer_ops.py +867 -0
  111. package/ccx/ops/terminate.py +383 -0
  112. package/ccx/ops/turn_reports.py +306 -0
  113. package/ccx/ops/wait.py +762 -0
  114. package/ccx/ops/worker_events.py +636 -0
  115. package/ccx/rpc/__init__.py +0 -0
  116. package/ccx/rpc/local_rpc.py +1602 -0
  117. package/ccx/rpc/rpc.py +559 -0
  118. package/ccx/rpc/serve.py +374 -0
  119. package/ccx/rpc/transport.py +409 -0
  120. package/ccx/rpc/view.py +125 -0
  121. package/ccx/store/__init__.py +0 -0
  122. package/ccx/store/discovery.py +170 -0
  123. package/ccx/store/identity.py +105 -0
  124. package/ccx/store/journal_io.py +79 -0
  125. package/ccx/store/liveness.py +194 -0
  126. package/ccx/store/paths.py +254 -0
  127. package/ccx/store/record_cache.py +40 -0
  128. package/ccx/store/records.py +309 -0
  129. package/ccx/store/root.py +804 -0
  130. package/ccx/store/runtime_presence.py +137 -0
  131. package/ccx/store/store.py +2332 -0
  132. package/ccx/store/worker_state.py +86 -0
  133. package/ccx/system/__init__.py +0 -0
  134. package/ccx/system/bounded.py +296 -0
  135. package/ccx/system/cwd_paths.py +25 -0
  136. package/ccx/system/engine_binary.py +59 -0
  137. package/ccx/system/executable.py +50 -0
  138. package/ccx/system/oslayer.py +667 -0
  139. package/ccx/system/pipes.py +46 -0
  140. package/ccx/system/sanitize.py +43 -0
  141. package/ccx/types.py +705 -0
  142. package/ccx/version.py +29 -0
  143. package/ccx/workers.py +2081 -0
  144. package/ccx.py +9 -0
  145. package/package.json +40 -4
  146. package/pyproject.toml +87 -0
  147. package/skills/ccx/SKILL.md +155 -0
  148. package/skills/ccx/references/controllers.md +244 -0
  149. package/skills/ccx/references/troubleshooting.md +94 -0
  150. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+ from ccx.system import oslayer
3
+ from ccx.claude.host.events import _event_preview, _hook_tool_result, _hook_tool_use_id, _tool_ok
4
+
5
+
6
+ def _hook_subagent_id(data) -> str | None:
7
+ """The originating subagent of a hook payload, or None for the parent."""
8
+ if not isinstance(data, dict):
9
+ return None
10
+ for key in ("agent_id", "subagent_id", "agentId", "subagentId"):
11
+ value = data.get(key)
12
+ if isinstance(value, str) and value:
13
+ return value
14
+ return None
15
+
16
+
17
+ def _hook_subagent_type(data: dict) -> str | None:
18
+ for key in ("agent_type", "subagent_type", "agentType", "subagentType"):
19
+ value = data.get(key)
20
+ if isinstance(value, str) and value:
21
+ return value
22
+ return None
23
+
24
+
25
+ def _subagent_tool_started_event(data: dict) -> dict:
26
+ """A subagent's tool start. Distinct from parent `tool_started`, and carries
27
+ no parent `turn`: subagent work belongs to no parent turn."""
28
+ return {
29
+ "type": "subagent_tool_started",
30
+ "agent_id": _hook_subagent_id(data),
31
+ "agent_type": _hook_subagent_type(data),
32
+ "tool_use_id": _hook_tool_use_id(data),
33
+ "tool": data.get("tool_name"),
34
+ "input_preview": _event_preview(data.get("tool_input")),
35
+ }
36
+
37
+
38
+ def _subagent_tool_finished_event(data: dict, duration_ms: int | None) -> dict:
39
+ return {
40
+ "type": "subagent_tool_finished",
41
+ "agent_id": _hook_subagent_id(data),
42
+ "agent_type": _hook_subagent_type(data),
43
+ "tool_use_id": _hook_tool_use_id(data),
44
+ "tool": data.get("tool_name"),
45
+ "duration_ms": duration_ms,
46
+ "ok": _tool_ok(data),
47
+ "result_preview": _event_preview(_hook_tool_result(data)),
48
+ }
49
+
50
+
51
+ def subagent_boundary_reply(data) -> dict | None:
52
+ """Hook-event reply for a subagent PostToolUse/SubagentStop, else None.
53
+
54
+ Steering is pasted only at the parent's own tool boundaries, where Claude's
55
+ main loop is paused on the hook."""
56
+ if _hook_subagent_id(data) is None:
57
+ return None
58
+ return {"ok": True, "delivered": False, "reason": "subagent hook"}
59
+
60
+
61
+ def notification_sets_waiting(data) -> bool:
62
+ """Whether a Notification hook means the parent input surface is blocked.
63
+
64
+ Only the parent `permission_prompt` does. `worker_permission_prompt` is a
65
+ worker's prompt; its payload carries no `agent_id`, so it cannot be owned by
66
+ the parent and stays informational."""
67
+ return isinstance(data, dict) and data.get("notification_type") == "permission_prompt"
68
+
69
+
70
+ ELICITATION_DIALOG_NOTIFICATIONS = ("elicitation_dialog", "elicitation_url_dialog")
71
+
72
+
73
+ ELICITATION_SETTLED_NOTIFICATIONS = ("elicitation_response", "elicitation_complete")
74
+
75
+
76
+ def notification_elicitation_dialog(data) -> bool:
77
+ """Whether a Notification hook reports an unbrokered elicitation dialog."""
78
+ return (isinstance(data, dict)
79
+ and data.get("notification_type") in ELICITATION_DIALOG_NOTIFICATIONS)
80
+
81
+
82
+ def notification_elicitation_settled(data) -> bool:
83
+ """Whether a Notification hook reports that an elicitation was answered."""
84
+ return (isinstance(data, dict)
85
+ and data.get("notification_type") in ELICITATION_SETTLED_NOTIFICATIONS)
86
+
87
+
88
+ def hook_fired_ms(data, received_ms: int) -> int:
89
+ """When the hook fired: the relay's stamp, else the host's receipt time."""
90
+ stamp = data.get("_ccx_relay_ms") if isinstance(data, dict) else None
91
+ if isinstance(stamp, int) and not isinstance(stamp, bool) and 0 < stamp <= received_ms:
92
+ return stamp
93
+ return received_ms
94
+
95
+
96
+ def note_subagent_taint(taint: dict, event_name: str, data, now_ms: int) -> bool:
97
+ """Record evidence that a subagent may be running; True if taint began.
98
+
99
+ Claude's native presence (`sessions/<pid>.json` status) stays `busy` while
100
+ any subagent runs, so it cannot attribute activity to the parent. Every
101
+ subagent hook — including SubagentStop, since the agent was live until
102
+ then and presence lags — taints native evidence, as does a running
103
+ subagent in a parent Stop's `background_tasks` snapshot. Taint is never
104
+ removed by hook order (relays can reorder); only fresh native quiescence
105
+ clears it (clear_subagent_taint). `now_ms` is when the hook fired
106
+ (hook_fired_ms), so a late-delivered hook cannot outdate a later native
107
+ idle transition."""
108
+ was_tainted = bool(taint)
109
+ data = data if isinstance(data, dict) else {}
110
+ agent_id = _hook_subagent_id(data)
111
+ if agent_id is not None:
112
+ taint[agent_id] = max(taint.get(agent_id, 0), now_ms)
113
+ elif event_name == "Stop" and isinstance(data.get("background_tasks"), list):
114
+ for t in data["background_tasks"]:
115
+ if (
116
+ isinstance(t, dict) and t.get("type") == "subagent"
117
+ and t.get("status") == "running"
118
+ and isinstance(t.get("id"), str) and t.get("id")
119
+ ):
120
+ # Reordered delivery must never lower a fence.
121
+ taint[t["id"]] = max(taint.get(t["id"], 0), now_ms)
122
+ return bool(taint) and not was_tainted
123
+
124
+
125
+ def clear_subagent_taint(taint: dict, value) -> bool:
126
+ """Clear the taint on native quiescence newer than every taint; True if cleared.
127
+
128
+ `idle`/`shell` means nothing — parent or subagent — is running, but only a
129
+ status written strictly after the latest subagent evidence proves it: an
130
+ older sample may predate an agent that has started since, and a
131
+ same-millisecond one cannot be ordered against it (the next native
132
+ transition will clear)."""
133
+ if not taint or value is None or value.status not in {"idle", "shell"}:
134
+ return False
135
+ updated = value.status_updated_at
136
+ if updated is None or updated <= max(taint.values()):
137
+ return False
138
+ taint.clear()
139
+ return True
140
+
141
+
142
+ def _native_hook_descendant(peer_pid: int, worker_pid: int | None,
143
+ worker_start: str | int | None) -> bool:
144
+ """Linux SO_PEERCRED peer must be the original native child or its relay.
145
+
146
+ Native hooks run either in the Claude process or a process it spawned.
147
+ Resolve at most 16 current /proc parent links with an immutable original
148
+ worker PID+start-time anchor, rejecting recycled PIDs and detached peers.
149
+ No owner/name/short/native-id inference is made from arbitrary processes.
150
+ """
151
+ from ccx.store.liveness import proc_start_token
152
+ if (not isinstance(peer_pid, int) or peer_pid < 2
153
+ or not isinstance(worker_pid, int) or worker_pid < 2
154
+ or not worker_start
155
+ or str(proc_start_token(worker_pid)) != str(worker_start)):
156
+ return False
157
+ seen = set()
158
+ current = peer_pid
159
+ for _ in range(16):
160
+ if current in seen or current < 2 or not proc_start_token(current):
161
+ return False
162
+ if current == worker_pid:
163
+ return str(proc_start_token(worker_pid)) == str(worker_start)
164
+ seen.add(current)
165
+ parent = oslayer.process_info(current).ppid # kernel field 4
166
+ if parent is None:
167
+ return False
168
+ current = parent
169
+ return False
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+ from ccx.constants import (
3
+ S,
4
+ )
5
+ from ccx.claude.transcript import (
6
+ is_compact_command_text,
7
+ )
8
+
9
+
10
+ def input_ready_pty_read(last_pty_read: float, subagent_tainted: bool,
11
+ now: float, quiescence_s: float) -> float:
12
+ """The PTY-read time the Ink-ready quiescence gates should see.
13
+
14
+ A live subagent keeps Claude's TUI redrawing its progress while the parent
15
+ is idle, so PTY output stops proving Ink is mid-render and the gates would
16
+ starve every write until the agent ends. While tainted, the post-IDLE
17
+ `earliest_send_at` settle alone gates readiness; the paste ack/retry path
18
+ still catches a dropped paste."""
19
+ if subagent_tainted and last_pty_read > 0:
20
+ return min(last_pty_read, now - quiescence_s)
21
+ return last_pty_read
22
+
23
+
24
+ def settling_grace_elapsed(state: dict, obs: dict | None, *,
25
+ now: float, grace_s: float) -> bool:
26
+ """After draining ready hooks, only the same still-settling turn may commit.
27
+
28
+ A parent hook may reset the idle candidate while ingress is being drained.
29
+ Its missing timestamp must never be interpreted as a grace beginning at 0.
30
+ """
31
+ return bool(
32
+ obs is not None and obs is state.get("interrupt_observation")
33
+ and obs.get("settling") is True
34
+ and type(obs.get("settling_at")) in (float, int)
35
+ and obs["settling_at"] > 0
36
+ and state.get("settling_turn_ref") == obs.get("turn_ref")
37
+ and state.get("active_turn_ref") == obs.get("turn_ref")
38
+ and now - obs["settling_at"] >= grace_s
39
+ )
40
+
41
+
42
+ def interrupt_native_idle_after_escape(state: dict, obs: dict | None, value) -> bool:
43
+ """An exact active turn is inactive on original native IDLE after ESC.
44
+
45
+ Claude's interactive session status describes all work, including agents:
46
+ native BUSY is ambiguous under subagent taint, but IDLE/SHELL means nothing
47
+ is running. No prior BUSY observation or taint-clear is required. Preserve
48
+ exact target identity and the actual ESC-write timestamp as the only fence.
49
+ """
50
+ return bool(
51
+ obs and not obs.get("settling")
52
+ and (state.get("current") in (S.BUSY, S.WAITING)
53
+ if obs.get("passive") else state.get("current") == S.BUSY)
54
+ and isinstance(obs.get("turn_ref"), str) and obs["turn_ref"]
55
+ and state.get("active_turn_ref") == obs["turn_ref"]
56
+ and type(obs.get("interrupt_at_ms")) is int
57
+ and obs["interrupt_at_ms"] > 0
58
+ and value is not None
59
+ and (value.status == "idle" if obs.get("passive")
60
+ else value.status in {"idle", "shell"})
61
+ and (not obs.get("passive") or not getattr(value, "waiting_for", None))
62
+ and type(value.status_updated_at) is int
63
+ and value.status_updated_at > obs["interrupt_at_ms"]
64
+ )
65
+
66
+
67
+ def bind_accepted_original_new_turn(
68
+ state: dict, receipt: dict, *, turn_ref: str,
69
+ native_user_event: dict | None,
70
+ delivery_host_epoch: str | None, current_host_epoch: str,
71
+ ) -> bool:
72
+ """Bind a late-accepted original to its already started generic turn.
73
+
74
+ The caller matched this event against the pinned original Claude native
75
+ transcript. Never attribute a steer, old-host recovery or second original
76
+ to an active turn: the actual session-bound Stop still must settle it.
77
+ """
78
+ if (
79
+ state.get("current") != S.BUSY
80
+ or state.get("active_turn_ref") != turn_ref
81
+ or state.get("active_turn_delivery_id") is not None
82
+ or not isinstance(native_user_event, dict)
83
+ or type(native_user_event.get("offset")) is not int
84
+ or native_user_event["offset"] <= 0
85
+ or not current_host_epoch
86
+ or delivery_host_epoch != current_host_epoch
87
+ or receipt.get("state") != "accepted"
88
+ or receipt.get("route") != "new_turn"
89
+ or receipt.get("placement") != "new_turn"
90
+ or receipt.get("turnRef") != turn_ref
91
+ or receipt.get("evidence") not in {"user-event", "started"}
92
+ # Hot `started` includes queued/dequeue hints. Only a separately
93
+ # re-read original ordinary USER row may bind the active turn.
94
+ or (receipt.get("evidence") == "started"
95
+ and native_user_event.get("kind") != "native-started-user")
96
+ or receipt.get("evidenceOffset") != native_user_event["offset"]
97
+ or receipt.get("evidenceUuid") != native_user_event.get("uuid")
98
+ ):
99
+ return False
100
+ delivery_id = receipt.get("deliveryId")
101
+ if not isinstance(delivery_id, str) or not delivery_id:
102
+ return False
103
+ state["active_turn_delivery_id"] = delivery_id
104
+ return True
105
+
106
+
107
+ def hook_prompt_id(data: dict) -> str | None:
108
+ """Claude's per-user-prompt id carried by parent turn hooks, if any."""
109
+ value = data.get("prompt_id")
110
+ return value if isinstance(value, str) and value else None
111
+
112
+
113
+ def guarded_hook_is_late_while_tainted(
114
+ event_name: str,
115
+ active_turn_ref: str | None,
116
+ hook_prompt_id: str | None,
117
+ settled_prompt_ids,
118
+ ) -> bool:
119
+ """Late-hook classification while native presence is subagent-tainted.
120
+
121
+ Native busy edges cannot be attributed, so the hook's own `prompt_id`
122
+ decides when both it and the settled target's are known: a hook of a
123
+ prompt the settled turn carried is late, any other is genuine (e.g. a fast
124
+ successor's Stop). Without that correlation the guard stays conservative for its short
125
+ backstop window: with a successor ref an old Stop is suppressed; without
126
+ one, a Stop/PreToolUse is treated as the settled target's delayed hook."""
127
+ if hook_prompt_id and settled_prompt_ids:
128
+ return hook_prompt_id in settled_prompt_ids
129
+ if active_turn_ref:
130
+ return event_name == "Stop"
131
+ return True
132
+
133
+
134
+ def compact_settle_fence(operation: dict) -> int:
135
+ """Transcript offset past which a user prompt belongs to a new turn.
136
+
137
+ A settled compact operation records ``end_offset``: the byte just past its
138
+ own boundary record. Anything after that is not compaction's doing. Fall
139
+ back to the pre-paste ``fence_offset`` when a verdict carries no end offset
140
+ (a no-op has no boundary to end), which is conservative in the right
141
+ direction — a wider window can only include more of compaction's own
142
+ bookkeeping, and `foreign_prompt_since` already discounts all of it.
143
+ """
144
+ result = operation.get("result") or {}
145
+ for candidate in (result.get("end_offset"), operation.get("fence_offset")):
146
+ if isinstance(candidate, int) and candidate >= 0:
147
+ return candidate
148
+ return 0
149
+
150
+
151
+ def successor_hold_should_release(
152
+ successor_pending: dict | None, active_turn_ref: str | None, native_status: str | None
153
+ ) -> bool:
154
+ """Should a BUSY/no-ref successor hold fall back to IDLE?"""
155
+ return bool(
156
+ successor_pending is not None
157
+ and not active_turn_ref
158
+ and native_status in {"idle", "shell"}
159
+ )
160
+
161
+
162
+ def queued_steer_needs_successor_hold(slot: dict | None, turn_ref: str | None) -> bool:
163
+ """A steer queued before Stop may start without a second prompt hook."""
164
+ return bool(
165
+ slot is not None
166
+ and slot.get("route") == "steer"
167
+ and slot.get("target_turn_ref") == turn_ref
168
+ and slot.get("maybe_written")
169
+ and slot.get("phase") in {"steer_boundary", "await_evidence"}
170
+ )
171
+
172
+
173
+ def successor_hold_quiescence_is_fresh(
174
+ successor_pending: dict, value, taint: dict[str, int],
175
+ ) -> bool:
176
+ """Is an inactive native sample fresh enough to release a successor hold?
177
+
178
+ Untainted holds keep the baseline rule (any inactive sample). A hold that is
179
+ subagent-ambiguous, or any hold while a subagent is live (including one
180
+ created before taint began), needs quiescence ordered strictly after both
181
+ the subagent evidence and the hold's own busy edge.
182
+ """
183
+ if not taint and not successor_pending.get("subagent_ambiguous"):
184
+ return True
185
+ updated = value.status_updated_at if value is not None else None
186
+ if updated is None:
187
+ return False
188
+ if taint and updated <= max(taint.values()):
189
+ return False
190
+ busy_token = successor_pending.get("native_busy_status_updated_at")
191
+ return busy_token is None or updated > busy_token
192
+
193
+
194
+ def input_surface_write_blocked(state: dict) -> bool:
195
+ """Whether CCX-owned cleanup currently forbids prompt bytes reaching Ink.
196
+
197
+ Admission stays open: durable semantic sends may be owned while this is true,
198
+ but every PTY write pump/boundary writer must wait until cleanup releases.
199
+ """
200
+ return state.get("compact_clear") is not None or state.get("cancel_clear") is not None
201
+
202
+
203
+ def post_compact_hold_may_release(
204
+ now: float, last_activity: float, last_pty_read: float, tainted: bool,
205
+ grace_s: float,
206
+ ) -> bool:
207
+ """May the post-compact BUSY hold fall back to IDLE on silence?
208
+
209
+ The hold is backed by evidence of a parent turn. Untainted, combined hook
210
+ and PTY silence past the grace proves no turn runs. While a subagent is
211
+ live neither signal is parent-specific: the child keeps Ink redrawing, and
212
+ a parent in a long tool or model wait is silent in hooks and transcript.
213
+ The hold then waits for parent terminal evidence or fresh quiescence
214
+ (which clears the taint and restores the silence rule).
215
+ """
216
+ if tainted:
217
+ return False
218
+ return now - max(last_activity, last_pty_read) > grace_s
219
+
220
+
221
+ def successor_attribution(inactive_token: int | None, value) -> tuple[bool, int | None]:
222
+ """Classify a native busy edge seen after a settling target went inactive.
223
+
224
+ Returns ``(successor, successor_token)``: a busy token newer than the
225
+ target's inactive edge may be queued parent work. With a subagent live the
226
+ aggregate presence cannot tell parent from child, and neither IDLE nor a
227
+ timeout is safe, so the caller still holds BUSY (marked ambiguous) until a
228
+ parent UPS attributes it or fresh native quiescence releases it.
229
+ """
230
+ if (
231
+ value is None or value.status != "busy"
232
+ or value.status_updated_at is None
233
+ or value.status_updated_at <= int(inactive_token or 0)
234
+ ):
235
+ return False, None
236
+ return True, value.status_updated_at
237
+
238
+
239
+ def compact_displaced_by_prompt(state: dict, prompt) -> bool:
240
+ """Did an ordinary user turn just take the input surface from our paste?
241
+
242
+ ``/compact`` is pasted without its Enter and submitted by a delayed,
243
+ retried keypress (Ink's autocomplete menu eats a same-write ``\\r``). That
244
+ leaves a window in which Claude can flush a prompt it had queued from an
245
+ earlier steer: the queued prompt runs as an ordinary turn and our command
246
+ text is left sitting in the editor, never to be submitted.
247
+
248
+ A ``UserPromptSubmit`` carrying non-``/compact`` text during that window is
249
+ positive proof of exactly that. It is the only signal that distinguishes
250
+ the case from native compaction (which also emits hook traffic while it
251
+ summarizes), and without it the operation can only die at its timeout —
252
+ twenty minutes of a caller blocking on a command that already failed.
253
+
254
+ Deliberately narrow. Once submission is acknowledged the native command
255
+ owns the surface and later hook traffic is compaction's own, so an
256
+ acknowledged operation is never displaced.
257
+ """
258
+ compacting = state.get("compacting")
259
+ if compacting is None:
260
+ return False
261
+ if compacting.get("submitted"):
262
+ return False
263
+ if compacting.get("pasted_at") is None:
264
+ # Nothing has been written to the editor yet, so nothing was displaced;
265
+ # the pump's own gate still owns the decision to paste.
266
+ return False
267
+ return not is_compact_command_text(prompt)
@@ -0,0 +1,240 @@
1
+ """Authoritative managed-Claude turn boundaries from the host event sidecar.
2
+
3
+ The managed host owns the worker-turn counter. Transcript prompts are not turn
4
+ identities: Claude can persist /compact, task notifications, queued commands and
5
+ other user-like records that do not correspond 1:1 with host turn boundaries.
6
+
7
+ ``turn_complete`` is semantic completion/interruption evidence.
8
+ ``turn_settled`` is liveness-only terminal evidence whose semantic outcome is
9
+ unknown; it never proves a semantic completion. Exact report windows come from
10
+ the transcript byte offsets recorded at each managed turn's start, not from
11
+ terminal timestamps.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import uuid
18
+ from typing import Any
19
+
20
+ from ccx.store.paths import events_ndjson_path
21
+
22
+
23
+ def new_turn_ref() -> str:
24
+ """Mint one opaque, start-unique managed-Claude turn identity."""
25
+ return f"ccx-turn:claude:v1:{uuid.uuid4()}"
26
+
27
+
28
+ def _ts_ms(value: Any) -> int | None:
29
+ if not isinstance(value, (int, float)) or isinstance(value, bool):
30
+ return None
31
+ # Host broadcast timestamps are wall-clock seconds. Accept milliseconds as
32
+ # well so fixtures/future writers can use the record convention directly.
33
+ return int(value * 1000) if abs(float(value)) < 10_000_000_000 else int(value)
34
+
35
+
36
+ def _terminal_events(short: str) -> list[dict[str, Any]] | None:
37
+ """Normalized retained managed-turn terminals in sidecar order.
38
+
39
+ ``None`` means the sidecar itself is unavailable. ``[]`` means it exists but
40
+ currently contains no retained managed turn terminal.
41
+ """
42
+ path = events_ndjson_path(short)
43
+ if not path.exists():
44
+ return None
45
+ out: list[dict[str, Any]] = []
46
+ try:
47
+ with path.open(encoding="utf-8") as handle:
48
+ for line in handle:
49
+ try:
50
+ value = json.loads(line)
51
+ except (TypeError, ValueError):
52
+ continue
53
+ if not isinstance(value, dict):
54
+ continue
55
+ event_type = value.get("type")
56
+ if event_type not in {"turn_complete", "turn_settled"}:
57
+ continue
58
+ turn = value.get("turn")
59
+ if not isinstance(turn, int) or isinstance(turn, bool) or turn <= 0:
60
+ continue
61
+ settled_at_ms = _ts_ms(value.get("ts"))
62
+ if settled_at_ms is None:
63
+ continue
64
+ if event_type == "turn_complete":
65
+ interrupted = bool(value.get("interrupted", False))
66
+ outcome = "interrupted" if interrupted else "completed"
67
+ else:
68
+ interrupted = None
69
+ outcome = value.get("outcome") or "unknown"
70
+ if outcome not in {"completed", "interrupted", "unknown"}:
71
+ outcome = "unknown"
72
+ item: dict[str, Any] = {
73
+ "turn": turn,
74
+ "settledAtMs": settled_at_ms,
75
+ "epoch": value.get("epoch"),
76
+ "seq": value.get("seq"),
77
+ "eventType": event_type,
78
+ "outcome": outcome,
79
+ }
80
+ if interrupted is not None:
81
+ item["interrupted"] = interrupted
82
+ item["completedAtMs"] = settled_at_ms
83
+ if isinstance(value.get("turn_ref"), str) and value.get("turn_ref"):
84
+ item["turnRef"] = value["turn_ref"]
85
+ if event_type == "turn_settled":
86
+ if isinstance(value.get("evidence"), str) and value.get("evidence"):
87
+ item["evidence"] = value["evidence"]
88
+ if isinstance(value.get("native_status"), str) and value.get("native_status"):
89
+ item["nativeStatus"] = value["native_status"]
90
+ out.append(item)
91
+ except OSError:
92
+ return None
93
+ return out
94
+
95
+
96
+ def turn_settlements(short: str) -> list[dict[str, Any]] | None:
97
+ """Return all retained managed turn terminal boundaries in sidecar order."""
98
+ values = _terminal_events(short)
99
+ return None if values is None else [dict(value) for value in values]
100
+
101
+
102
+ def latest_settled_turn(
103
+ short: str, *, fallback: int = 0, record_last_settled_at_ms: int | None = None
104
+ ) -> int:
105
+ """Latest host-owned settled turn, with a fail-open record fallback.
106
+
107
+ ``lastSettledAt`` is written before the terminal event. If the retained
108
+ sidecar's latest terminal predates that record timestamp, the sidecar is
109
+ known stale/incomplete and must not decrement a valid persisted ordinal.
110
+ """
111
+ values = turn_settlements(short)
112
+ if values:
113
+ latest = values[-1]
114
+ if (
115
+ isinstance(record_last_settled_at_ms, int)
116
+ and int(latest["settledAtMs"]) < record_last_settled_at_ms
117
+ ):
118
+ return max(0, int(fallback or 0))
119
+ return int(latest["turn"])
120
+ return max(0, int(fallback or 0))
121
+
122
+
123
+ def _with_previous_settlement(values: list[dict[str, Any]], index: int) -> dict[str, Any]:
124
+ result = dict(values[index])
125
+ result["previousSettledAtMs"] = (
126
+ int(values[index - 1]["settledAtMs"]) if index > 0 else None
127
+ )
128
+ return result
129
+
130
+
131
+ def settlement_for_turn(
132
+ short: str, turn_number: int
133
+ ) -> tuple[dict[str, Any] | None, bool]:
134
+ """Exact retained managed terminal for one lifecycle ordinal."""
135
+ values = turn_settlements(short)
136
+ if values is None:
137
+ return None, False
138
+ for index in range(len(values) - 1, -1, -1):
139
+ if values[index]["turn"] == turn_number:
140
+ return _with_previous_settlement(values, index), True
141
+ return None, True
142
+
143
+
144
+ def settlement_for_turn_ref(
145
+ short: str, turn_ref: str
146
+ ) -> tuple[dict[str, Any] | None, bool]:
147
+ """Exact retained managed terminal for one start-unique turn ref."""
148
+ values = turn_settlements(short)
149
+ if values is None:
150
+ return None, False
151
+ for index in range(len(values) - 1, -1, -1):
152
+ if values[index].get("turnRef") == turn_ref:
153
+ return _with_previous_settlement(values, index), True
154
+ return None, True
155
+
156
+
157
+ def report_window_for_turn_ref(
158
+ short: str, turn_ref: str
159
+ ) -> tuple[dict[str, Any] | None, bool]:
160
+ """Exact retained Claude transcript window metadata for one managed turn.
161
+
162
+ ``None, False`` means the sidecar is unavailable. ``None, True`` means the
163
+ sidecar exists but has never heard of this turn ref. A returned dict may have
164
+ ``startOffset=None`` when CCX only recovered the turn identity after output
165
+ had already begun; callers must treat that as unavailable rather than guess.
166
+
167
+ The upper bound is the first later managed-turn identity. If that later turn
168
+ lacks an exact start offset, ``endOffsetKnown`` is false: reading to current
169
+ EOF would cross a known turn boundary and is forbidden.
170
+ """
171
+ path = events_ndjson_path(short)
172
+ if not path.exists():
173
+ return None, False
174
+ starts: list[dict[str, Any]] = []
175
+ terminal: dict[str, Any] | None = None
176
+ seen_ref = False
177
+ try:
178
+ with path.open(encoding="utf-8") as handle:
179
+ for index, line in enumerate(handle):
180
+ try:
181
+ value = json.loads(line)
182
+ except (TypeError, ValueError):
183
+ continue
184
+ if not isinstance(value, dict):
185
+ continue
186
+ ref = value.get("turn_ref")
187
+ if not isinstance(ref, str) or not ref:
188
+ continue
189
+ event_type = value.get("type")
190
+ if event_type in {"turn_start", "turn_identity_recovered"}:
191
+ raw_offset = value.get("report_start_offset")
192
+ offset = (
193
+ raw_offset
194
+ if isinstance(raw_offset, int)
195
+ and not isinstance(raw_offset, bool)
196
+ and raw_offset >= 0
197
+ else None
198
+ )
199
+ starts.append({
200
+ "turnRef": ref,
201
+ "startOffset": offset,
202
+ "lineIndex": index,
203
+ })
204
+ if ref == turn_ref:
205
+ seen_ref = True
206
+ continue
207
+ if event_type in {"turn_complete", "turn_settled"} and ref == turn_ref:
208
+ terminal = dict(value)
209
+ terminal["_ccx_line_index"] = index
210
+ seen_ref = True
211
+ except OSError:
212
+ return None, False
213
+
214
+ if not seen_ref:
215
+ return None, True
216
+
217
+ start = next((value for value in starts if value["turnRef"] == turn_ref), None)
218
+ start_offset = start.get("startOffset") if start is not None else None
219
+ terminal_index = (
220
+ terminal.get("_ccx_line_index") if isinstance(terminal, dict) else None
221
+ )
222
+ end_offset = None
223
+ end_offset_known = True
224
+ if isinstance(terminal_index, int):
225
+ for value in starts:
226
+ if value["lineIndex"] <= terminal_index or value["turnRef"] == turn_ref:
227
+ continue
228
+ if value.get("startOffset") is None:
229
+ end_offset_known = False
230
+ else:
231
+ end_offset = value["startOffset"]
232
+ break
233
+
234
+ return {
235
+ "turnRef": turn_ref,
236
+ "startOffset": start_offset,
237
+ "endOffset": end_offset,
238
+ "endOffsetKnown": end_offset_known,
239
+ "settled": terminal is not None,
240
+ }, True