agent-runtime-py 0.1.0__py3-none-any.whl
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.
- agent_runtime/__init__.py +5 -0
- agent_runtime/bridge.py +78 -0
- agent_runtime/claude.py +254 -0
- agent_runtime/hooks.py +82 -0
- agent_runtime/monitor_worker.py +145 -0
- agent_runtime/monitors.py +219 -0
- agent_runtime/operations.py +129 -0
- agent_runtime/sessions.py +201 -0
- agent_runtime/store.py +277 -0
- agent_runtime/worker.py +290 -0
- agent_runtime_py-0.1.0.dist-info/METADATA +66 -0
- agent_runtime_py-0.1.0.dist-info/RECORD +15 -0
- agent_runtime_py-0.1.0.dist-info/WHEEL +4 -0
- agent_runtime_py-0.1.0.dist-info/entry_points.txt +2 -0
- agent_runtime_py-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Detached shell observations and a durable event ledger independent of model turns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import psutil
|
|
16
|
+
|
|
17
|
+
from agent_runtime.claude import process_alive
|
|
18
|
+
from agent_runtime.sessions import Registry, current_identity, permission_class
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Monitors:
|
|
22
|
+
def __init__(self, directory: Path | None = None) -> None:
|
|
23
|
+
self.registry = Registry(directory)
|
|
24
|
+
self.store = self.registry.store
|
|
25
|
+
with self.store.connect() as db:
|
|
26
|
+
db.executescript("""
|
|
27
|
+
CREATE TABLE IF NOT EXISTS monitors (
|
|
28
|
+
id TEXT PRIMARY KEY, session_id TEXT, value TEXT);
|
|
29
|
+
CREATE TABLE IF NOT EXISTS monitor_events (
|
|
30
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, monitor_id TEXT, session_id TEXT,
|
|
31
|
+
text TEXT, status TEXT, created REAL, error TEXT
|
|
32
|
+
);
|
|
33
|
+
CREATE INDEX IF NOT EXISTS monitor_delivery ON monitor_events(session_id,status,id);
|
|
34
|
+
""")
|
|
35
|
+
|
|
36
|
+
def get(self, monitor_id: str) -> dict[str, Any]:
|
|
37
|
+
with self.store.connect() as db:
|
|
38
|
+
row = db.execute("SELECT value FROM monitors WHERE id=?", (monitor_id,)).fetchone()
|
|
39
|
+
if row is None:
|
|
40
|
+
raise KeyError("unknown monitor")
|
|
41
|
+
value = json.loads(row[0])
|
|
42
|
+
if (
|
|
43
|
+
value["status"] in {"running", "starting"}
|
|
44
|
+
and value.get("pid")
|
|
45
|
+
and not process_alive(value)
|
|
46
|
+
):
|
|
47
|
+
value = self.update(monitor_id, status="interrupted", error="monitor worker exited")
|
|
48
|
+
if (
|
|
49
|
+
value["status"] == "starting"
|
|
50
|
+
and not value.get("pid")
|
|
51
|
+
and time.time() - value["created_at"] > 60
|
|
52
|
+
):
|
|
53
|
+
value = self.update(
|
|
54
|
+
monitor_id, status="interrupted", error="monitor worker did not start"
|
|
55
|
+
)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
def update(self, monitor_id: str, **fields: Any) -> dict[str, Any]:
|
|
59
|
+
with self.store.connect() as db:
|
|
60
|
+
db.execute("BEGIN IMMEDIATE")
|
|
61
|
+
row = db.execute("SELECT value FROM monitors WHERE id=?", (monitor_id,)).fetchone()
|
|
62
|
+
if row is None:
|
|
63
|
+
raise KeyError("unknown monitor")
|
|
64
|
+
value = {**json.loads(row[0]), **fields, "updated_at": time.time()}
|
|
65
|
+
db.execute("UPDATE monitors SET value=? WHERE id=?", (json.dumps(value), monitor_id))
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
def list(self, session_id: str) -> list[dict[str, Any]]:
|
|
69
|
+
with self.store.connect() as db:
|
|
70
|
+
ids = [
|
|
71
|
+
row[0]
|
|
72
|
+
for row in db.execute("SELECT id FROM monitors WHERE session_id=?", (session_id,))
|
|
73
|
+
]
|
|
74
|
+
return [self.get(identity) for identity in ids]
|
|
75
|
+
|
|
76
|
+
def start(
|
|
77
|
+
self,
|
|
78
|
+
command: str,
|
|
79
|
+
*,
|
|
80
|
+
description: str,
|
|
81
|
+
persistent: bool = False,
|
|
82
|
+
cwd: Path | None = None,
|
|
83
|
+
timeout_ms: int = 300_000,
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
provider, session_id = current_identity()
|
|
86
|
+
if provider != "claude_code":
|
|
87
|
+
raise ValueError("use the Codex monitor adapter for a Codex session")
|
|
88
|
+
target = self.registry.get(provider, session_id)
|
|
89
|
+
if not process_alive(target):
|
|
90
|
+
raise RuntimeError("current Claude process is not live")
|
|
91
|
+
bridge = target.get("bridge")
|
|
92
|
+
if (
|
|
93
|
+
not isinstance(bridge, dict)
|
|
94
|
+
or not process_alive(bridge)
|
|
95
|
+
or bridge.get("native_pid") != target["pid"]
|
|
96
|
+
):
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
"the native monitor delivery bridge is not running; check agent-runtime hooks"
|
|
99
|
+
)
|
|
100
|
+
if not command.strip() or not description.strip():
|
|
101
|
+
raise ValueError("command and description must be nonempty")
|
|
102
|
+
if isinstance(timeout_ms, bool) or timeout_ms <= 0 or not math.isfinite(timeout_ms):
|
|
103
|
+
raise ValueError("timeout_ms must be positive and finite")
|
|
104
|
+
directory = (cwd or Path(target["cwd"])).resolve(strict=True)
|
|
105
|
+
if not directory.is_dir():
|
|
106
|
+
raise ValueError("cwd must be a directory")
|
|
107
|
+
identity = "m" + uuid.uuid4().hex
|
|
108
|
+
value = {
|
|
109
|
+
"monitor_id": identity,
|
|
110
|
+
"session_id": session_id,
|
|
111
|
+
"command": command,
|
|
112
|
+
"description": description,
|
|
113
|
+
"cwd": str(directory),
|
|
114
|
+
"timeout_ms": timeout_ms,
|
|
115
|
+
"persistent": persistent,
|
|
116
|
+
"status": "starting",
|
|
117
|
+
"created_at": time.time(),
|
|
118
|
+
"permission_class": permission_class(target.get("permission_mode")),
|
|
119
|
+
"owner": {"pid": target["pid"], "process_created_at": target["process_created_at"]},
|
|
120
|
+
}
|
|
121
|
+
with self.store.connect() as db:
|
|
122
|
+
db.execute(
|
|
123
|
+
"INSERT INTO monitors VALUES(?,?,?)", (identity, session_id, json.dumps(value))
|
|
124
|
+
)
|
|
125
|
+
environment = os.environ.copy()
|
|
126
|
+
for name in ("CLAUDE_CODE_MESSAGING_TOKEN", "CLAUDE_CODE_MESSAGING_SOCKET"):
|
|
127
|
+
environment.pop(name, None)
|
|
128
|
+
try:
|
|
129
|
+
fd = os.open(
|
|
130
|
+
self.store.root / f"{identity}.log", os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600
|
|
131
|
+
)
|
|
132
|
+
with os.fdopen(fd, "ab", buffering=0) as output:
|
|
133
|
+
child = subprocess.Popen(
|
|
134
|
+
[
|
|
135
|
+
sys.executable,
|
|
136
|
+
"-m",
|
|
137
|
+
"agent_runtime.monitor_worker",
|
|
138
|
+
str(self.store.root),
|
|
139
|
+
identity,
|
|
140
|
+
],
|
|
141
|
+
stdin=subprocess.DEVNULL,
|
|
142
|
+
stdout=output,
|
|
143
|
+
stderr=output,
|
|
144
|
+
env=environment,
|
|
145
|
+
start_new_session=True,
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
created = psutil.Process(child.pid).create_time()
|
|
149
|
+
except psutil.NoSuchProcess:
|
|
150
|
+
child.wait()
|
|
151
|
+
else:
|
|
152
|
+
self.update(identity, pid=child.pid, process_created_at=created)
|
|
153
|
+
except Exception as error:
|
|
154
|
+
self.update(identity, status="failed", error=str(error))
|
|
155
|
+
raise
|
|
156
|
+
return self.get(identity)
|
|
157
|
+
|
|
158
|
+
def stop(self, monitor_id: str) -> dict[str, Any]:
|
|
159
|
+
value = self.get(monitor_id)
|
|
160
|
+
if value["status"] in {"starting", "running"}:
|
|
161
|
+
value = self.update(monitor_id, stop_requested=True)
|
|
162
|
+
return value
|
|
163
|
+
|
|
164
|
+
def append(self, monitor_id: str, text: str) -> int:
|
|
165
|
+
value = self.get(monitor_id)
|
|
166
|
+
with self.store.connect() as db:
|
|
167
|
+
cursor = db.execute(
|
|
168
|
+
"INSERT INTO monitor_events(monitor_id,session_id,text,status,created) "
|
|
169
|
+
"VALUES(?,?,?,'pending',?)",
|
|
170
|
+
(monitor_id, value["session_id"], text, time.time()),
|
|
171
|
+
)
|
|
172
|
+
assert cursor.lastrowid is not None
|
|
173
|
+
return cursor.lastrowid
|
|
174
|
+
|
|
175
|
+
def events(self, session_id: str, *, after: int = 0) -> list[dict[str, Any]]:
|
|
176
|
+
with self.store.connect() as db:
|
|
177
|
+
return [
|
|
178
|
+
dict(row)
|
|
179
|
+
for row in db.execute(
|
|
180
|
+
"SELECT * FROM monitor_events WHERE session_id=? AND id>? ORDER BY id",
|
|
181
|
+
(session_id, after),
|
|
182
|
+
)
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
def claim(self, session_id: str) -> list[dict[str, Any]]:
|
|
186
|
+
with self.store.connect() as db:
|
|
187
|
+
db.execute("BEGIN IMMEDIATE")
|
|
188
|
+
rows = db.execute(
|
|
189
|
+
"SELECT * FROM monitor_events WHERE session_id=? AND status='pending' "
|
|
190
|
+
"ORDER BY id LIMIT 32",
|
|
191
|
+
(session_id,),
|
|
192
|
+
).fetchall()
|
|
193
|
+
total = 0
|
|
194
|
+
selected = []
|
|
195
|
+
for row in rows:
|
|
196
|
+
if total + len(row["text"]) > 65536 and selected:
|
|
197
|
+
break
|
|
198
|
+
selected.append(dict(row))
|
|
199
|
+
total += len(row["text"])
|
|
200
|
+
db.executemany(
|
|
201
|
+
"UPDATE monitor_events SET status='dispatching' WHERE id=?",
|
|
202
|
+
[(row["id"],) for row in selected],
|
|
203
|
+
)
|
|
204
|
+
return selected
|
|
205
|
+
|
|
206
|
+
def delivered(self, identity: int, status: str, error: str | None = None) -> None:
|
|
207
|
+
with self.store.connect() as db:
|
|
208
|
+
db.execute(
|
|
209
|
+
"UPDATE monitor_events SET status=?,error=? WHERE id=?", (status, error, identity)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def interrupted_delivery(self, session_id: str) -> None:
|
|
213
|
+
with self.store.connect() as db:
|
|
214
|
+
db.execute(
|
|
215
|
+
"UPDATE monitor_events SET status='uncertain',"
|
|
216
|
+
"error='delivery bridge exited during socket write' "
|
|
217
|
+
"WHERE session_id=? AND status='dispatching'",
|
|
218
|
+
(session_id,),
|
|
219
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Provider adapters for the user's existing monitors and local agent messaging."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from contextlib import suppress
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from agent_runtime.monitors import Monitors
|
|
12
|
+
from agent_runtime.sessions import Provider, Registry, current_identity, send_claude_peer
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def codex_command(*arguments: str) -> Any:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
["codexr", *arguments], check=True, capture_output=True, text=True, timeout=15
|
|
18
|
+
)
|
|
19
|
+
return json.loads(result.stdout)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def identity() -> tuple[Provider, str]:
|
|
23
|
+
provider, session_id = current_identity()
|
|
24
|
+
if provider == "codex":
|
|
25
|
+
value = codex_command("agents", "whoami")
|
|
26
|
+
if not isinstance(value, dict) or not isinstance(value.get("session_id"), str):
|
|
27
|
+
raise RuntimeError("current Codex root is unavailable")
|
|
28
|
+
session_id = value["session_id"]
|
|
29
|
+
return provider, session_id
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def monitor_start(
|
|
33
|
+
command: str,
|
|
34
|
+
*,
|
|
35
|
+
description: str,
|
|
36
|
+
persistent: bool = False,
|
|
37
|
+
cwd: Path | None = None,
|
|
38
|
+
timeout_ms: int = 300_000,
|
|
39
|
+
) -> dict[str, Any]:
|
|
40
|
+
provider, session_id = identity()
|
|
41
|
+
if provider == "claude_code":
|
|
42
|
+
return Monitors().start(
|
|
43
|
+
command, description=description, persistent=persistent, cwd=cwd, timeout_ms=timeout_ms
|
|
44
|
+
)
|
|
45
|
+
arguments = [
|
|
46
|
+
"monitors",
|
|
47
|
+
"start",
|
|
48
|
+
session_id,
|
|
49
|
+
"--command",
|
|
50
|
+
command,
|
|
51
|
+
"--description",
|
|
52
|
+
description,
|
|
53
|
+
"--timeout-ms",
|
|
54
|
+
str(timeout_ms),
|
|
55
|
+
]
|
|
56
|
+
if persistent:
|
|
57
|
+
arguments.append("--persistent")
|
|
58
|
+
if cwd is not None:
|
|
59
|
+
arguments.extend(("--cwd", str(cwd)))
|
|
60
|
+
return codex_command(*arguments)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def monitor_list() -> list[dict[str, Any]]:
|
|
64
|
+
provider, session_id = identity()
|
|
65
|
+
if provider == "claude_code":
|
|
66
|
+
return Monitors().list(session_id)
|
|
67
|
+
return codex_command("monitors", "list", session_id)["monitors"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def monitor_get(monitor_id: str) -> dict[str, Any]:
|
|
71
|
+
provider, session_id = identity()
|
|
72
|
+
if provider == "codex":
|
|
73
|
+
return codex_command("monitors", "get", session_id, monitor_id)
|
|
74
|
+
value = Monitors().get(monitor_id)
|
|
75
|
+
if value["session_id"] != session_id:
|
|
76
|
+
raise ValueError("monitor belongs to another session")
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def monitor_stop(monitor_id: str) -> dict[str, Any]:
|
|
81
|
+
provider, session_id = identity()
|
|
82
|
+
if provider == "codex":
|
|
83
|
+
return codex_command("monitors", "stop", session_id, monitor_id)
|
|
84
|
+
monitor_get(monitor_id)
|
|
85
|
+
return Monitors().stop(monitor_id)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def monitor_events(*, after: int = 0) -> list[dict[str, Any]]:
|
|
89
|
+
provider, session_id = identity()
|
|
90
|
+
if provider != "claude_code":
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"Codex retains output in monitor_get; its native monitor has no event cursor"
|
|
93
|
+
)
|
|
94
|
+
return Monitors().events(session_id, after=after)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def agents() -> list[dict[str, Any]]:
|
|
98
|
+
values = [{**value, "provider": "claude_code"} for value in Registry().claude_sessions()]
|
|
99
|
+
with suppress(FileNotFoundError):
|
|
100
|
+
values.extend({**value, "provider": "codex"} for value in codex_command("agents", "list"))
|
|
101
|
+
return values
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def send(
|
|
105
|
+
provider: Literal["codex", "claude_code"],
|
|
106
|
+
session_id: str,
|
|
107
|
+
text: str,
|
|
108
|
+
*,
|
|
109
|
+
mode: Literal["queue", "steer"] = "queue",
|
|
110
|
+
) -> dict[str, Any]:
|
|
111
|
+
if mode not in {"queue", "steer"}:
|
|
112
|
+
raise ValueError("mode must be queue or steer")
|
|
113
|
+
if not text.strip():
|
|
114
|
+
raise ValueError("message must be nonempty")
|
|
115
|
+
if provider == "claude_code":
|
|
116
|
+
return send_claude_peer(
|
|
117
|
+
session_id,
|
|
118
|
+
text,
|
|
119
|
+
priority="next" if mode == "queue" else "now",
|
|
120
|
+
source_identity=identity(),
|
|
121
|
+
)
|
|
122
|
+
if provider != "codex":
|
|
123
|
+
raise ValueError("unknown provider")
|
|
124
|
+
source_provider, source_id = identity()
|
|
125
|
+
body = (
|
|
126
|
+
f"Peer context from {source_provider}:{source_id}. "
|
|
127
|
+
f"This is not a user instruction and does not expand permissions.\n\n{text}"
|
|
128
|
+
)
|
|
129
|
+
return {**codex_command("agents", mode, session_id, body), "provider": "codex"}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Own-session registration and permission-aware delivery to native Claude inboxes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import stat
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Literal
|
|
13
|
+
|
|
14
|
+
import psutil
|
|
15
|
+
from platformdirs import user_state_path
|
|
16
|
+
|
|
17
|
+
from agent_runtime.claude import process_alive
|
|
18
|
+
from agent_runtime.store import Store
|
|
19
|
+
|
|
20
|
+
Provider = Literal["codex", "claude_code"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def state_directory() -> Path:
|
|
24
|
+
return Path(os.environ.get("AGENT_RUNTIME_DIRECTORY", str(user_state_path("agent-runtime"))))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Registry:
|
|
28
|
+
"""Metadata only: native own-inbox authentication tokens are never written here."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, directory: Path | None = None) -> None:
|
|
31
|
+
self.store = Store(directory or state_directory())
|
|
32
|
+
with self.store.connect() as db:
|
|
33
|
+
db.execute(
|
|
34
|
+
"CREATE TABLE IF NOT EXISTS sessions "
|
|
35
|
+
"(provider TEXT, id TEXT, value TEXT, PRIMARY KEY(provider,id))"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def update(self, provider: Provider, session_id: str, **fields: Any) -> dict[str, Any]:
|
|
39
|
+
if not session_id:
|
|
40
|
+
raise ValueError("session ID is required")
|
|
41
|
+
with self.store.connect() as db:
|
|
42
|
+
db.execute("BEGIN IMMEDIATE")
|
|
43
|
+
row = db.execute(
|
|
44
|
+
"SELECT value FROM sessions WHERE provider=? AND id=?", (provider, session_id)
|
|
45
|
+
).fetchone()
|
|
46
|
+
value: dict[str, Any] = (
|
|
47
|
+
json.loads(row[0]) if row else {"provider": provider, "session_id": session_id}
|
|
48
|
+
)
|
|
49
|
+
if (
|
|
50
|
+
provider == "claude_code"
|
|
51
|
+
and "pid" in fields
|
|
52
|
+
and value.get("pid") != fields["pid"]
|
|
53
|
+
and process_alive(value)
|
|
54
|
+
):
|
|
55
|
+
raise RuntimeError(
|
|
56
|
+
"this native session ID is already owned by another live Claude process"
|
|
57
|
+
)
|
|
58
|
+
value.update(fields, updated_at=time.time())
|
|
59
|
+
db.execute(
|
|
60
|
+
"INSERT INTO sessions VALUES(?,?,?) ON CONFLICT(provider,id) "
|
|
61
|
+
"DO UPDATE SET value=excluded.value",
|
|
62
|
+
(provider, session_id, json.dumps(value)),
|
|
63
|
+
)
|
|
64
|
+
return value
|
|
65
|
+
|
|
66
|
+
def get(self, provider: Provider, session_id: str) -> dict[str, Any]:
|
|
67
|
+
with self.store.connect() as db:
|
|
68
|
+
row = db.execute(
|
|
69
|
+
"SELECT value FROM sessions WHERE provider=? AND id=?", (provider, session_id)
|
|
70
|
+
).fetchone()
|
|
71
|
+
if row is None:
|
|
72
|
+
raise KeyError(
|
|
73
|
+
f"session has not registered agent-runtime hooks: {provider}:{session_id}"
|
|
74
|
+
)
|
|
75
|
+
return json.loads(row[0])
|
|
76
|
+
|
|
77
|
+
def claude_sessions(self) -> list[dict[str, Any]]:
|
|
78
|
+
with self.store.connect() as db:
|
|
79
|
+
rows = db.execute("SELECT value FROM sessions WHERE provider='claude_code'").fetchall()
|
|
80
|
+
return [record for row in rows if process_alive(record := json.loads(row[0]))]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def current_identity() -> tuple[Provider, str]:
|
|
84
|
+
provider = os.environ.get("AGENT_SESSION_PROVIDER")
|
|
85
|
+
if provider == "claude_code" and (identity := os.environ.get("AGENT_SESSION_ID")):
|
|
86
|
+
return "claude_code", identity
|
|
87
|
+
if identity := os.environ.get("CLAUDE_CODE_SESSION_ID"):
|
|
88
|
+
return "claude_code", identity
|
|
89
|
+
if identity := os.environ.get("CODEX_THREAD_ID"):
|
|
90
|
+
return "codex", identity
|
|
91
|
+
if provider == "codex" and (identity := os.environ.get("AGENT_SESSION_ID")):
|
|
92
|
+
return "codex", identity
|
|
93
|
+
raise RuntimeError("native session identity is unavailable")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def permission_class(mode: str | None) -> Literal["bypass", "prompting"]:
|
|
97
|
+
if mode == "bypassPermissions":
|
|
98
|
+
return "bypass"
|
|
99
|
+
if mode in {"default", "acceptEdits", "plan", "dontAsk", "auto"}:
|
|
100
|
+
return "prompting"
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
"source permission mode is unknown; refusing to send an unclassified peer message"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def own_claude_registration(
|
|
107
|
+
session_id: str, cwd: str, permission_mode: str | None, *, registry: Registry | None = None
|
|
108
|
+
) -> dict[str, Any]:
|
|
109
|
+
"""Register the current hook's inherited identity without reading other sessions' secrets."""
|
|
110
|
+
inherited_id = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
111
|
+
if inherited_id and inherited_id != session_id:
|
|
112
|
+
raise RuntimeError("hook session ID disagrees with inherited Claude identity")
|
|
113
|
+
pid = int(os.environ["CLAUDE_PID"])
|
|
114
|
+
process = psutil.Process(pid)
|
|
115
|
+
if process.uids().real != os.getuid():
|
|
116
|
+
raise RuntimeError("native Claude process belongs to a different user")
|
|
117
|
+
return (registry or Registry()).update(
|
|
118
|
+
"claude_code",
|
|
119
|
+
session_id,
|
|
120
|
+
cwd=cwd,
|
|
121
|
+
permission_mode=permission_mode,
|
|
122
|
+
pid=pid,
|
|
123
|
+
process_created_at=process.create_time(),
|
|
124
|
+
socket=os.environ["CLAUDE_CODE_MESSAGING_SOCKET"],
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def write_inbox(
|
|
129
|
+
target: dict[str, Any],
|
|
130
|
+
text: str,
|
|
131
|
+
*,
|
|
132
|
+
priority: Literal["next", "now"] = "next",
|
|
133
|
+
own_token: str | None = None,
|
|
134
|
+
message_id: str | None = None,
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
"""Write a native frame. Transport completion is not a delivery acknowledgement."""
|
|
137
|
+
if not text.strip():
|
|
138
|
+
raise ValueError("message must be nonempty")
|
|
139
|
+
if priority not in {"next", "now"}:
|
|
140
|
+
raise ValueError("priority must be next or now")
|
|
141
|
+
if own_token is not None and (
|
|
142
|
+
own_token != os.environ.get("CLAUDE_CODE_MESSAGING_TOKEN")
|
|
143
|
+
or target["session_id"] != os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
144
|
+
or target["socket"] != os.environ.get("CLAUDE_CODE_MESSAGING_SOCKET")
|
|
145
|
+
):
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
"own-event authentication can only use the current inherited native inbox"
|
|
148
|
+
)
|
|
149
|
+
if not process_alive(target):
|
|
150
|
+
raise RuntimeError("destination Claude process is no longer live")
|
|
151
|
+
path = Path(target["socket"])
|
|
152
|
+
metadata = path.lstat()
|
|
153
|
+
if not stat.S_ISSOCK(metadata.st_mode) or metadata.st_uid != os.getuid():
|
|
154
|
+
raise RuntimeError("destination is not an owned Unix socket")
|
|
155
|
+
identity = str(uuid.UUID(message_id)) if message_id else str(uuid.uuid4())
|
|
156
|
+
frame = {
|
|
157
|
+
"type": "user",
|
|
158
|
+
"session_id": target["session_id"],
|
|
159
|
+
"uuid": identity,
|
|
160
|
+
"priority": priority,
|
|
161
|
+
"message": {"role": "user", "content": text},
|
|
162
|
+
}
|
|
163
|
+
lines = ([{"type": "auth", "token": own_token}] if own_token else []) + [frame]
|
|
164
|
+
data = "".join(json.dumps(line) + "\n" for line in lines).encode()
|
|
165
|
+
if len(data) > 1024 * 1024:
|
|
166
|
+
raise ValueError("message exceeds the 1 MiB transport limit")
|
|
167
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
168
|
+
connection.settimeout(10)
|
|
169
|
+
connection.connect(str(path))
|
|
170
|
+
connection.sendall(data)
|
|
171
|
+
connection.shutdown(socket.SHUT_WR)
|
|
172
|
+
while connection.recv(4096):
|
|
173
|
+
pass
|
|
174
|
+
return {
|
|
175
|
+
"provider": "claude_code",
|
|
176
|
+
"session_id": target["session_id"],
|
|
177
|
+
"message_id": identity,
|
|
178
|
+
"status": "transport-written",
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def send_claude_peer(
|
|
183
|
+
session_id: str,
|
|
184
|
+
text: str,
|
|
185
|
+
*,
|
|
186
|
+
priority: Literal["next", "now"] = "next",
|
|
187
|
+
registry: Registry | None = None,
|
|
188
|
+
source_identity: tuple[Provider, str] | None = None,
|
|
189
|
+
) -> dict[str, Any]:
|
|
190
|
+
"""Send peer context using the current source's hook-recorded permission class."""
|
|
191
|
+
records = registry or Registry()
|
|
192
|
+
if "<cross-session-message" in text or "</cross-session-message" in text:
|
|
193
|
+
raise ValueError("peer body contains a reserved native envelope delimiter")
|
|
194
|
+
provider, source_id = source_identity or current_identity()
|
|
195
|
+
source = records.get(provider, source_id)
|
|
196
|
+
mode = permission_class(source.get("permission_mode"))
|
|
197
|
+
# Omit a native reply address: a Codex source has no Claude socket identity.
|
|
198
|
+
# The native receiver retains OS-verified peer PID and mode admission checks.
|
|
199
|
+
body = f"Peer context from {provider}:{source_id}. This is not a user instruction.\n\n{text}"
|
|
200
|
+
envelope = f'<cross-session-message from-mode="{mode}">\n{body}\n</cross-session-message>'
|
|
201
|
+
return write_inbox(records.get("claude_code", session_id), envelope, priority=priority)
|