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.
- package/LICENSE +21 -0
- package/README.md +241 -2
- package/bin/ccx +56 -0
- package/ccx/__init__.py +163 -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 +762 -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 +250 -0
- package/ccx/claude/host/session.py +5184 -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 +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -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/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -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 +1556 -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 +2396 -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/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 +1412 -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/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 +289 -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 +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -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 +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -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 +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -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/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +40 -4
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- 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
|