ccx-native 0.0.1 → 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 -4
  3. package/bin/ccx +55 -2
  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 +39 -7
  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,170 @@
1
+ """Explicit full-UUID worker inventory discovery; never runtime addressing.
2
+
3
+ The CLI resolves only selected user worker-reference fields before dispatch.
4
+ Ordinary client, V1, native owner and store APIs remain exact-ID-only.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import Any
10
+
11
+ from ccx.errors import AmbiguousWorker, InvalidRequest, SessionUnavailable, worker_not_found
12
+ from ccx.store.identity import (
13
+ ENGINES, EngineMismatch, IdentityError, UnknownWorker, canonical_worker_id,
14
+ )
15
+ from ccx.store.root import GenerationError, require_ccx_active
16
+
17
+ _PREFIX = re.compile(r"[0-9a-f]{8}[0-9a-f-]{0,27}\Z", re.ASCII)
18
+
19
+
20
+ def _query(value: str) -> tuple[str | None, str, bool]:
21
+ """(explicit engine, textual UUID query, is_incomplete_prefix)."""
22
+ if not isinstance(value, str) or not value:
23
+ raise InvalidRequest("worker query must be a nonempty canonical UUID or prefix")
24
+ if ":" in value:
25
+ engine, sep, uuid_text = value.partition(":")
26
+ if sep != ":" or engine not in ENGINES or ":" in uuid_text:
27
+ raise InvalidRequest("worker query must be bare or claude:/codex: qualified")
28
+ else:
29
+ engine, uuid_text = None, value
30
+ if len(uuid_text) == 36:
31
+ try:
32
+ canonical_worker_id(uuid_text)
33
+ except IdentityError as exc:
34
+ raise InvalidRequest(
35
+ "complete worker UUID must be canonical lowercase UUIDv4"
36
+ ) from exc
37
+ return engine, uuid_text, False
38
+ if not _PREFIX.fullmatch(uuid_text):
39
+ raise InvalidRequest(
40
+ "worker prefix must start with 8 lowercase hex characters, "
41
+ "continue with lowercase hex/hyphens, and be at most 35 characters; "
42
+ "no names, casefolding or compact-UUID aliases"
43
+ )
44
+ return engine, uuid_text, True
45
+
46
+
47
+ def _candidates(store, engine: str | None, prefix: str) -> list[str]:
48
+ """Validated active workers whose full UUID starts with ``prefix``.
49
+
50
+ Only owner files whose NAME matches are read and validated, so a lookup
51
+ costs O(matches), not a validation of the whole fleet.
52
+ """
53
+ matches = []
54
+ try:
55
+ for worker_id in store.owner_ids(prefix=prefix):
56
+ try:
57
+ owner = store.resolve(f"{engine}:{worker_id}" if engine else worker_id)
58
+ except (UnknownWorker, EngineMismatch):
59
+ continue # reserved/quarantined, or the other engine
60
+ matches.append(owner.worker)
61
+ except (GenerationError, IdentityError, OSError) as exc:
62
+ raise SessionUnavailable(f"managed worker inventory unavailable: {exc}") from exc
63
+ return sorted(matches)
64
+
65
+
66
+ def _revalidate(store) -> None:
67
+ try:
68
+ store.revalidate()
69
+ except (GenerationError, OSError) as exc:
70
+ raise SessionUnavailable(f"managed worker generation changed: {exc}") from exc
71
+
72
+
73
+ def _match(query: str, engine: str | None, prefix: str, store) -> str:
74
+ matches = _candidates(store, engine, prefix)
75
+ if not matches and engine is not None:
76
+ other = _candidates(store, None, prefix)
77
+ if len(other) == 1:
78
+ found, _, worker_id = other[0].partition(":")
79
+ raise InvalidRequest(f"{query} is a {found.capitalize()} worker; use "
80
+ f"{found}:{worker_id[:8]} or the bare prefix")
81
+ if not matches:
82
+ raise worker_not_found(query)
83
+ if len(matches) > 1:
84
+ raise AmbiguousWorker(query, matches)
85
+ return matches[0]
86
+
87
+
88
+ def resolve_worker(query: str) -> str:
89
+ """Explicitly resolve a full UUID or unique textual prefix to engine:UUID.
90
+
91
+ This is a current read-only inventory query, NOT permission to pass the
92
+ prefix to ordinary Python methods, V1, host/daemon or persisted references.
93
+ """
94
+ from ccx import constants
95
+
96
+ engine, body, incomplete = _query(query)
97
+ try:
98
+ store = require_ccx_active(constants.CCX_ROOT)
99
+ except (GenerationError, OSError) as exc:
100
+ raise SessionUnavailable(f"ACTIVE managed worker inventory unavailable: {exc}") from exc
101
+ if not incomplete:
102
+ try:
103
+ owner = store.resolve(query)
104
+ except UnknownWorker as exc:
105
+ raise worker_not_found(query) from exc
106
+ except EngineMismatch as exc:
107
+ raise InvalidRequest(str(exc)) from exc
108
+ except (GenerationError, OSError) as exc:
109
+ raise SessionUnavailable(f"managed worker lookup unavailable: {exc}") from exc
110
+ resolved = owner.worker
111
+ else:
112
+ resolved = _match(query, engine, body, store)
113
+ _revalidate(store)
114
+ return resolved
115
+
116
+
117
+ def resolve_cli_fields(namespace: Any, store, fields: tuple[str, ...], *,
118
+ check_exact: bool = False) -> None:
119
+ """All-or-nothing namespace preflight; prefixes read only matching owners.
120
+
121
+ Complete exact UUIDs stay on the handler's fast path unless ``check_exact``
122
+ requests a missing-owner preflight. Multi-target report batches preserve
123
+ their existing per-row error behavior.
124
+ """
125
+ prepared: dict[str, list[tuple[str, str | None, str, bool]]] = {}
126
+ any_prefix = False
127
+ for field in fields:
128
+ value = getattr(namespace, field, None)
129
+ if value is None:
130
+ continue
131
+ values = value if isinstance(value, list) else [value]
132
+ parsed = []
133
+ for item in values:
134
+ engine, uuid_text, incomplete = _query(item)
135
+ parsed.append((item, engine, uuid_text, incomplete))
136
+ any_prefix = any_prefix or incomplete
137
+ prepared[field] = parsed
138
+ if check_exact:
139
+ for specs in prepared.values():
140
+ for original, _engine, _uuid_text, incomplete in specs:
141
+ if incomplete:
142
+ continue
143
+ try:
144
+ store.resolve(original)
145
+ except UnknownWorker as exc:
146
+ if str(exc) == "full UUID is not present in global owner index":
147
+ raise worker_not_found(original) from exc
148
+ # Reserved/quarantined identities retain the verb's own
149
+ # refusal; they are not missing global owner entries.
150
+ except EngineMismatch as exc:
151
+ raise InvalidRequest(str(exc)) from exc
152
+ except (GenerationError, OSError):
153
+ # Other refusal reasons retain the handler's existing type,
154
+ # message and exit code rather than being normalized here.
155
+ pass
156
+ if not any_prefix:
157
+ return
158
+
159
+ rewritten: dict[str, str | list[str]] = {}
160
+ for field, specs in prepared.items():
161
+ resolved = [
162
+ _match(original, engine, uuid_text, store) if incomplete else original
163
+ for original, engine, uuid_text, incomplete in specs
164
+ ]
165
+ rewritten[field] = (resolved if isinstance(getattr(namespace, field), list)
166
+ else resolved[0])
167
+ _revalidate(store)
168
+ # Nothing from any field was applied until every prefix succeeded.
169
+ for field, resolved in rewritten.items():
170
+ setattr(namespace, field, resolved)
@@ -0,0 +1,105 @@
1
+ """Exact CCX worker identity and engine-qualified target validation.
2
+
3
+ Pure canonical UUID and owner contracts; no file I/O, provider discovery,
4
+ environment lookup, or runtime compatibility aliases.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from uuid import UUID, uuid4
10
+
11
+ IDENTITY_SCHEMA_VERSION = 2
12
+ MANIFEST_SCHEMA_VERSION = 1
13
+ ENGINES = frozenset({"claude", "codex"})
14
+ OWNER_STATES = frozenset({"worker", "indeterminate-reserved", "quarantined"})
15
+
16
+
17
+ class IdentityError(ValueError):
18
+ """Unusable or contradictory CCX-managed identity."""
19
+
20
+
21
+ class InvalidTarget(IdentityError):
22
+ """Not a complete canonical managed UUID or qualified UUID."""
23
+
24
+
25
+ class UnknownWorker(IdentityError):
26
+ """Canonical UUID has no CCX-owned record or is reserved."""
27
+
28
+
29
+ class EngineMismatch(IdentityError):
30
+ """An explicit engine disagrees with the immutable CCX owner."""
31
+
32
+
33
+ class OwnerConflict(IdentityError):
34
+ """One full UUID has two inconsistent owners/reservations."""
35
+
36
+
37
+ def canonical_worker_id(value: str) -> str:
38
+ """Require lowercase, hyphenated RFC 4122 UUIDv4 (including variant)."""
39
+ if not isinstance(value, str):
40
+ raise InvalidTarget("worker id must be a canonical lowercase UUIDv4")
41
+ try:
42
+ value_uuid = UUID(value)
43
+ except (ValueError, TypeError, AttributeError) as exc:
44
+ raise InvalidTarget("worker id must be a canonical lowercase UUIDv4") from exc
45
+ if value_uuid.version != 4 or value_uuid.variant != "specified in RFC 4122" or str(value_uuid) != value:
46
+ raise InvalidTarget("worker id must be a canonical lowercase UUIDv4")
47
+ return value
48
+
49
+
50
+ def mint_worker_id() -> str:
51
+ """Independently random UUIDv4; NEVER preserve or derive an old short."""
52
+ return str(uuid4())
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ManagedTarget:
57
+ worker_id: str
58
+ qualified_engine: str | None = None
59
+
60
+ def __post_init__(self) -> None:
61
+ canonical_worker_id(self.worker_id)
62
+ if self.qualified_engine is not None and self.qualified_engine not in ENGINES:
63
+ raise InvalidTarget("unsupported managed engine")
64
+
65
+ def qualified(self, owner_engine: str) -> str:
66
+ if owner_engine not in ENGINES:
67
+ raise IdentityError("invalid CCX engine")
68
+ if self.qualified_engine is not None and self.qualified_engine != owner_engine:
69
+ raise EngineMismatch("managed target engine does not match CCX ownership")
70
+ return f"{owner_engine}:{self.worker_id}"
71
+
72
+
73
+ def parse_managed_target(target: str) -> ManagedTarget:
74
+ """Only a canonical full UUID or engine:full UUID; never a display alias."""
75
+ if not isinstance(target, str):
76
+ raise InvalidTarget("managed target must be a canonical full worker UUID")
77
+ if ":" not in target:
78
+ return ManagedTarget(canonical_worker_id(target))
79
+ engine, separator, worker_id = target.partition(":")
80
+ if separator != ":" or engine not in ENGINES:
81
+ raise InvalidTarget("managed target must be a bare or engine-qualified full UUID")
82
+ return ManagedTarget(canonical_worker_id(worker_id), engine)
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class WorkerOwner:
87
+ worker_id: str
88
+ engine: str
89
+ state: str = "worker"
90
+
91
+ def __post_init__(self) -> None:
92
+ canonical_worker_id(self.worker_id)
93
+ if self.engine not in ENGINES:
94
+ raise IdentityError("unsupported CCX worker engine")
95
+ if self.state not in OWNER_STATES:
96
+ raise IdentityError("invalid CCX owner reservation state")
97
+
98
+ @property
99
+ def worker(self) -> str:
100
+ return f"{self.engine}:{self.worker_id}"
101
+
102
+ @property
103
+ def short(self) -> str:
104
+ """Nonunique display field, never a key for routing."""
105
+ return self.worker_id[:8]
@@ -0,0 +1,79 @@
1
+ """Bounded reentrant locks and durable publication for Claude journals."""
2
+
3
+ import contextlib
4
+ import fcntl
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import tempfile
9
+ import threading
10
+ import time
11
+
12
+ _LOCAL = threading.local()
13
+ _THREAD_LOCKS: dict[Path, threading.RLock] = {}
14
+ _THREAD_LOCKS_GUARD = threading.Lock()
15
+
16
+
17
+ @contextlib.contextmanager
18
+ def journal_lock(path: Path, *, timeout: float, busy_error: type[Exception], label: str):
19
+ # Key by lock path so different journals and roots cannot share ownership.
20
+ path = path.resolve()
21
+ held = getattr(_LOCAL, "held", None)
22
+ if held is None:
23
+ held = _LOCAL.held = set()
24
+ if path in held:
25
+ yield
26
+ return
27
+ budget = max(0.0, float(timeout))
28
+ deadline = time.monotonic() + budget
29
+ path.parent.mkdir(parents=True, exist_ok=True)
30
+ with _THREAD_LOCKS_GUARD:
31
+ thread_lock = _THREAD_LOCKS.setdefault(path, threading.RLock())
32
+ acquired = (thread_lock.acquire(timeout=budget) if budget > 0
33
+ else thread_lock.acquire(blocking=False))
34
+ if not acquired:
35
+ raise busy_error(f"{label} is locked by this process (waited {budget:g}s)")
36
+ try:
37
+ fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
38
+ try:
39
+ while True:
40
+ try:
41
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
42
+ break
43
+ except OSError:
44
+ if time.monotonic() >= deadline:
45
+ raise busy_error(
46
+ f"{label} is held by another process (waited {budget:g}s)"
47
+ ) from None
48
+ time.sleep(min(0.02, max(0.0, deadline - time.monotonic())))
49
+ held.add(path)
50
+ try:
51
+ yield
52
+ finally:
53
+ held.discard(path)
54
+ finally:
55
+ os.close(fd)
56
+ finally:
57
+ thread_lock.release()
58
+
59
+
60
+ def write_json(path: Path, value: dict) -> None:
61
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
62
+ try:
63
+ with os.fdopen(fd, "w") as handle:
64
+ json.dump(value, handle, indent=2)
65
+ handle.flush()
66
+ os.fsync(handle.fileno())
67
+ os.replace(tmp_name, path)
68
+ # Persist the rename as well as the contents before acknowledging it.
69
+ dir_fd = os.open(path.parent, os.O_RDONLY)
70
+ try:
71
+ os.fsync(dir_fd)
72
+ finally:
73
+ os.close(dir_fd)
74
+ except BaseException:
75
+ try:
76
+ os.unlink(tmp_name)
77
+ except FileNotFoundError:
78
+ pass
79
+ raise
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import pathlib
5
+ from dataclasses import dataclass
6
+
7
+ from ccx.system import oslayer
8
+ from ccx.constants import CLAUDE_HOME
9
+
10
+ def pid_alive(pid: int | None) -> bool:
11
+ if not pid:
12
+ return False
13
+ # Keep the historical EPERM-is-present projection, unlike controller runs.
14
+ return oslayer.probe_pid(pid) in ("exists", "denied")
15
+
16
+
17
+ def owned_process(pid: int | None, start_token: str | int | None) -> oslayer.Presence:
18
+ """Compare an original PID/start identity with the current OS observation.
19
+
20
+ A PID without a start token cannot prove ownership; kernel-confirmed
21
+ absence still proves the original process is gone.
22
+ """
23
+ if not pid:
24
+ return "dead"
25
+ if not start_token:
26
+ return "dead" if oslayer.probe_pid(pid) == "missing" else "unprovable"
27
+ info = oslayer.process_info(pid)
28
+ if info.presence == "dead":
29
+ return "dead"
30
+ if info.presence != "alive" or info.start_token is None:
31
+ return "unprovable"
32
+ return "alive" if str(info.start_token) == str(start_token) else "dead"
33
+
34
+
35
+ def owned_pid_alive(pid: int | None, start_token: str | int | None = None) -> bool:
36
+ """Is ``pid`` alive and still the process CCX recorded?
37
+
38
+ A PID can be reused after the original process exits, crashes or after a
39
+ reboot. A bare ``kill(pid, 0)`` can therefore report a stranger's process,
40
+ including another user's process (EPERM still means that PID exists).
41
+ Treating that as ownership could block a safe revive or signal a stranger.
42
+
43
+ Only the original PID plus its kernel start token proves a living owner.
44
+ Without the token, absence can be proven, but ownership cannot.
45
+ """
46
+ return owned_process(pid, start_token) == "alive"
47
+
48
+
49
+ def lifecycle_host_alive(lifecycle: dict | None) -> bool:
50
+ """Whether a record's recorded managed host process is still that host."""
51
+ lifecycle = lifecycle or {}
52
+ return owned_pid_alive(lifecycle.get("hostPid"), lifecycle.get("hostStart"))
53
+
54
+
55
+ def recorded_host_loaded(engine: str, lifecycle: dict | None) -> bool | None:
56
+ """RPC-free host presence, returning True/False only with identity proof.
57
+
58
+ Claude's recorded host PID/start identifies the original host as alive or
59
+ gone. Codex is served by the shared codexd: the daemon being alive says
60
+ nothing about whether this particular thread is loaded, so Codex returns
61
+ None (unobserved), not a fabricated True/False.
62
+ """
63
+ lifecycle = lifecycle if isinstance(lifecycle, dict) else {}
64
+ if engine != "claude" or not lifecycle.get("hostPid"):
65
+ return None
66
+ observed = owned_process(lifecycle.get("hostPid"), lifecycle.get("hostStart"))
67
+ return None if observed == "unprovable" else observed == "alive"
68
+
69
+
70
+ def proc_argv(pid: int | None) -> list[str] | None:
71
+ """Full argv of a live pid from /proc/<pid>/cmdline, or None if unreadable.
72
+
73
+ Reads the whole cmdline — a Claude session-id passed via `--session-id`/
74
+ `--resume` sits well past the first argv tokens, so a truncated read would
75
+ miss it."""
76
+ raw = oslayer.read_argv(pid)
77
+ if raw is None:
78
+ return None
79
+ parts = [p.decode("utf-8", "replace") for p in raw.split(b"\x00") if p]
80
+ return parts or None
81
+
82
+
83
+ def session_pid_alive(pid: int | None, session_id: str | None = None) -> bool:
84
+ """Is `pid` alive and actually the Claude session it claims to be?
85
+
86
+ `~/.claude/sessions/<pid>.json` is pid-keyed and accumulates stale files
87
+ (pids recycle). A bare `pid_alive` check could accept an unrelated process.
88
+ Accept either of these identity checks:
89
+
90
+ 1. the live process's argv contains the 8-char session short — definitive,
91
+ and present whenever the session was launched with `--session-id` or
92
+ `--resume <id>` (bg agents, ccx-managed, resumed sessions); or
93
+ 2. argv0 is the `claude` binary (plain `claude` interactive sessions don't
94
+ carry the id in argv). Claude rewrites its own pid-keyed session-meta on
95
+ startup, so a live claude at this pid means the meta is its own.
96
+
97
+ A recycled non-claude pid fails both gates, so its stale meta is rejected."""
98
+ if not pid_alive(pid):
99
+ return False
100
+ argv = proc_argv(pid)
101
+ if not argv:
102
+ return False
103
+ if session_id and session_id[:8] in " ".join(argv):
104
+ return True
105
+ argv0 = argv[0]
106
+ base = argv0.rsplit("/", 1)[-1].lower()
107
+ # `claude`, `~/.local/bin/claude`, or the SEA `.../claude/versions/<v>`.
108
+ return base == "claude" or "/claude/versions/" in argv0 or "/claude/launcher" in argv0
109
+
110
+
111
+ @dataclass(frozen=True)
112
+ class NativeClaudeState:
113
+ """One validated snapshot from Claude's pid-keyed interactive registry."""
114
+
115
+ pid: int
116
+ session_id: str
117
+ kind: str
118
+ status: str | None
119
+ status_updated_at: int | None
120
+ waiting_for: object | None
121
+ proc_start: str | int | None
122
+
123
+
124
+ def proc_start_token(pid: int | None) -> str | None:
125
+ """Linux /proc start-time token for one live pid, or ``None``.
126
+
127
+ Field 22 is process start time in clock ticks since boot. It is stable for a
128
+ process lifetime and rejects a stale Claude pid-file after PID reuse.
129
+ """
130
+ return oslayer.process_info(pid).start_token
131
+
132
+
133
+ def read_managed_claude_native_state(
134
+ pid: int | None,
135
+ session_id: str | None,
136
+ *,
137
+ claude_home: pathlib.Path | None = None,
138
+ ) -> NativeClaudeState | None:
139
+ """Read an exact managed Claude process's native session snapshot.
140
+
141
+ The registry is an upstream-internal compatibility surface, so this helper is
142
+ deliberately strict: any identity mismatch, unsupported kind, dead process,
143
+ stale process-start token, or unreadable JSON is simply ``None`` (no native
144
+ evidence). The caller must fall back to ordinary CCX semantic evidence.
145
+ """
146
+ if not pid or not session_id:
147
+ return None
148
+ root = claude_home or CLAUDE_HOME
149
+ try:
150
+ value = json.loads((root / "sessions" / f"{int(pid)}.json").read_text())
151
+ except (OSError, ValueError, TypeError):
152
+ return None
153
+ if not isinstance(value, dict):
154
+ return None
155
+ meta_pid = value.get("pid")
156
+ if meta_pid is not None:
157
+ try:
158
+ if int(meta_pid) != int(pid):
159
+ return None
160
+ except (TypeError, ValueError, OverflowError):
161
+ return None
162
+ if value.get("sessionId") != session_id:
163
+ return None
164
+ # Managed PTY workers are ordinary interactive Claude sessions. Future or
165
+ # missing kinds are not silently treated as equivalent.
166
+ kind = value.get("kind")
167
+ if kind != "interactive":
168
+ return None
169
+ meta_start = value.get("procStart")
170
+ if meta_start is None:
171
+ meta_start = value.get("procStartFt")
172
+ if meta_start is not None:
173
+ live_start = proc_start_token(pid)
174
+ if not oslayer.provider_start_matches(
175
+ meta_start, live_start, value.get("pidDomain")):
176
+ return None
177
+ elif not session_pid_alive(pid, session_id):
178
+ # Legacy local metadata may lack the provider token. It is not a
179
+ # Darwin authorization; only Linux's historical argv corroboration
180
+ # can supply the optional diagnostic snapshot.
181
+ return None
182
+ status = value.get("status")
183
+ status_updated_at = value.get("statusUpdatedAt")
184
+ if not isinstance(status_updated_at, int) or isinstance(status_updated_at, bool):
185
+ status_updated_at = None
186
+ return NativeClaudeState(
187
+ pid=int(pid),
188
+ session_id=session_id,
189
+ kind=kind,
190
+ status=status if isinstance(status, str) else None,
191
+ status_updated_at=status_updated_at,
192
+ waiting_for=value.get("waitingFor"),
193
+ proc_start=meta_start,
194
+ )