cloudagent-daemon 0.2.1__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.
- cloudagent_daemon/__init__.py +1 -0
- cloudagent_daemon/__main__.py +4 -0
- cloudagent_daemon/claude_runner.py +1068 -0
- cloudagent_daemon/cli.py +833 -0
- cloudagent_daemon/config.py +197 -0
- cloudagent_daemon/doctor.py +534 -0
- cloudagent_daemon/frame_dispatch.py +216 -0
- cloudagent_daemon/http_outbound.py +80 -0
- cloudagent_daemon/jsonl_logging.py +98 -0
- cloudagent_daemon/login.py +34 -0
- cloudagent_daemon/main_loop.py +891 -0
- cloudagent_daemon/mcp_bridge.py +441 -0
- cloudagent_daemon/metrics.py +315 -0
- cloudagent_daemon/metrics_server.py +149 -0
- cloudagent_daemon/outbound_queue.py +289 -0
- cloudagent_daemon/paths.py +204 -0
- cloudagent_daemon/protocol.py +141 -0
- cloudagent_daemon/runtime_detect.py +135 -0
- cloudagent_daemon/service.py +355 -0
- cloudagent_daemon/spawn_outbox.py +443 -0
- cloudagent_daemon/spawn_queue.py +200 -0
- cloudagent_daemon/sse_client.py +439 -0
- cloudagent_daemon/transport.py +105 -0
- cloudagent_daemon/unix_socket.py +278 -0
- cloudagent_daemon/workspace.py +441 -0
- cloudagent_daemon/ws_client.py +431 -0
- cloudagent_daemon-0.2.1.dist-info/METADATA +322 -0
- cloudagent_daemon-0.2.1.dist-info/RECORD +31 -0
- cloudagent_daemon-0.2.1.dist-info/WHEEL +4 -0
- cloudagent_daemon-0.2.1.dist-info/entry_points.txt +2 -0
- cloudagent_daemon-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Stdio MCP server invoked by the claude subprocess.
|
|
2
|
+
|
|
3
|
+
Architecture (see spec §6.2 / §4.7):
|
|
4
|
+
|
|
5
|
+
claude (subprocess of ClaudeRunner)
|
|
6
|
+
│ MCP stdio (NDJSON, mcp protocol)
|
|
7
|
+
▼
|
|
8
|
+
mcp_bridge subprocess (`cloudagent-daemon mcp-bridge ...`)
|
|
9
|
+
│ unix socket (line-delimited JSON request/response)
|
|
10
|
+
▼
|
|
11
|
+
daemon-main process
|
|
12
|
+
|
|
13
|
+
Each tool the bridge registers, when invoked by claude, opens a unix-socket
|
|
14
|
+
connection back to daemon-main and tunnels the call as an IPC ``tool_call``
|
|
15
|
+
op. The daemon-main process is the only place that owns the WebSocket.
|
|
16
|
+
|
|
17
|
+
Instance-level isolation (spec §6.4): ``build_server`` reads
|
|
18
|
+
``identity.json`` and only registers the 4 chief-only tools when
|
|
19
|
+
``is_chief is True``. Staff agents never see those tools, so the LLM
|
|
20
|
+
cannot attempt calls it is not allowed to make — no reliance on LLM
|
|
21
|
+
self-restraint. Server-side enforcement (chief-hallucination-guard in
|
|
22
|
+
``daemon_dispatcher.py``) still provides a second line of defence.
|
|
23
|
+
|
|
24
|
+
Session ID (1k hotfix): the bridge reads the cloudagent business
|
|
25
|
+
``session_id`` for the current wake from
|
|
26
|
+
``~/.cloudagent/agents/<agent_id>/.meta/current-session.txt``. The
|
|
27
|
+
daemon (claude_runner) writes that file BEFORE spawning ``claude`` and
|
|
28
|
+
removes it AFTER the wake completes. When the file is missing (legacy
|
|
29
|
+
clients, cleanup race, or stale spawns) the bridge falls back to ``None``
|
|
30
|
+
and the backend dispatcher will reject the call with INVALID_ARGS — far
|
|
31
|
+
preferable to a silent cross-session mix-up.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import logging
|
|
37
|
+
import sys
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from mcp.server.fastmcp import FastMCP
|
|
42
|
+
|
|
43
|
+
from .unix_socket import DEFAULT_SOCKET_PATH, UnixSocketClient
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
# Tool set split per spec §6.4 instance-level isolation.
|
|
48
|
+
COMMON_TOOLS: frozenset[str] = frozenset(
|
|
49
|
+
{"send_message", "list_messages", "upload_file", "schedule_reminder"}
|
|
50
|
+
)
|
|
51
|
+
CHIEF_ONLY_TOOLS: frozenset[str] = frozenset(
|
|
52
|
+
{"delegate_to_staff", "close_task", "request_user_approval", "set_company_status"}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_identity(agent_id: str) -> dict:
|
|
57
|
+
"""Defensive read of ``~/.cloudagent/agents/<id>/.meta/identity.json``.
|
|
58
|
+
|
|
59
|
+
Returns the parsed dict on success. On any failure (missing file,
|
|
60
|
+
invalid JSON, OS error) returns ``{}`` which causes ``build_server``
|
|
61
|
+
to treat the agent as a staff member (``is_chief`` defaults to
|
|
62
|
+
``False`` — the safe/secure default).
|
|
63
|
+
"""
|
|
64
|
+
path = Path.home() / ".cloudagent" / "agents" / agent_id / ".meta" / "identity.json"
|
|
65
|
+
try:
|
|
66
|
+
return json.loads(path.read_text())
|
|
67
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _query_daemon_session(
|
|
72
|
+
agent_id: str, socket_path: Path
|
|
73
|
+
) -> str | None:
|
|
74
|
+
"""(c) IPC architecture upgrade — query daemon in-memory session map.
|
|
75
|
+
|
|
76
|
+
Sends a ``get_current_session`` request over the unix socket and
|
|
77
|
+
returns the session_id from the daemon's memory (set by ClaudeRunner
|
|
78
|
+
before spawning this very bridge). Faster and more reliable than the
|
|
79
|
+
file-based path because it bypasses filesystem caching / mode issues.
|
|
80
|
+
|
|
81
|
+
Returns ``None`` on any failure (socket missing, timeout, old daemon
|
|
82
|
+
without the op) so the caller can fall back to the file path.
|
|
83
|
+
"""
|
|
84
|
+
try:
|
|
85
|
+
async with UnixSocketClient(socket_path) as ipc:
|
|
86
|
+
result = await ipc.call(
|
|
87
|
+
"get_current_session",
|
|
88
|
+
{"agent_id": agent_id},
|
|
89
|
+
timeout=5.0,
|
|
90
|
+
)
|
|
91
|
+
sid = result.get("session_id")
|
|
92
|
+
return sid if isinstance(sid, str) and sid else None
|
|
93
|
+
except Exception: # noqa: BLE001
|
|
94
|
+
# Socket missing, old daemon (UNKNOWN_OP), or timeout — fall
|
|
95
|
+
# through to file-based path. Not an error: backward compat.
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _read_current_session(
|
|
100
|
+
agent_id: str, workspace_root: Path | None = None
|
|
101
|
+
) -> str | None:
|
|
102
|
+
"""Defensive read of ``.meta/current-session.txt`` (1k hotfix).
|
|
103
|
+
|
|
104
|
+
Returns the session_id string if present and non-empty, ``None``
|
|
105
|
+
otherwise. Tolerates a missing file — that situation is normal for
|
|
106
|
+
legacy daemons that haven't been redeployed yet, and we'd rather
|
|
107
|
+
let the backend reject with INVALID_ARGS than crash the bridge.
|
|
108
|
+
|
|
109
|
+
Profile-aware (2026-05-18 fix): named profiles store agents under
|
|
110
|
+
``~/.cloudagent/agents-<profile>/`` (see paths.profile_workspace_root).
|
|
111
|
+
Without this resolution, the bridge read the default-profile path and
|
|
112
|
+
always missed current-session.txt for daily/staging/etc → every MCP
|
|
113
|
+
tool call dispatched ``session_id=None`` → backend rejected as
|
|
114
|
+
"missing required arg" → claude retried 30+s per turn.
|
|
115
|
+
|
|
116
|
+
(c) fallback: called after ``_query_daemon_session`` fails (old
|
|
117
|
+
daemon without the ``get_current_session`` IPC op or socket missing).
|
|
118
|
+
"""
|
|
119
|
+
if workspace_root is None:
|
|
120
|
+
# Read CLOUDAGENT_DAEMON_PROFILE env var written by service.py /
|
|
121
|
+
# main_loop.py for spawned children (claude inherits it).
|
|
122
|
+
from .paths import profile_workspace_root
|
|
123
|
+
import os
|
|
124
|
+
profile_env = os.environ.get("CLOUDAGENT_DAEMON_PROFILE")
|
|
125
|
+
workspace_root = profile_workspace_root(profile_env)
|
|
126
|
+
p = workspace_root / agent_id / ".meta" / "current-session.txt"
|
|
127
|
+
try:
|
|
128
|
+
sid = p.read_text().strip()
|
|
129
|
+
return sid or None
|
|
130
|
+
except (FileNotFoundError, OSError):
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _ipc_tool_call(
|
|
135
|
+
socket_path: Path,
|
|
136
|
+
agent_id: str,
|
|
137
|
+
tool_name: str,
|
|
138
|
+
tool_args: dict,
|
|
139
|
+
session_id: str | None,
|
|
140
|
+
) -> dict:
|
|
141
|
+
"""Single round-trip: open socket → send tool_call → return result."""
|
|
142
|
+
# 1k #55 — log the tool dispatch BEFORE we touch the socket so we can
|
|
143
|
+
# tell apart "claude never invoked the tool" vs "tool invoked but
|
|
144
|
+
# bridge couldn't reach the daemon".
|
|
145
|
+
logger.info(
|
|
146
|
+
"mcp_bridge: tool dispatch ENTER tool=%s agent=%s session=%s",
|
|
147
|
+
tool_name, agent_id, session_id,
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
async with UnixSocketClient(socket_path) as ipc:
|
|
151
|
+
logger.info(
|
|
152
|
+
"mcp_bridge: ipc connected socket=%s tool=%s agent=%s session=%s",
|
|
153
|
+
socket_path, tool_name, agent_id, session_id,
|
|
154
|
+
)
|
|
155
|
+
result = await ipc.call(
|
|
156
|
+
"tool_call",
|
|
157
|
+
{
|
|
158
|
+
"agent_id": agent_id,
|
|
159
|
+
"session_id": session_id,
|
|
160
|
+
"tool_name": tool_name,
|
|
161
|
+
"tool_args": tool_args,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
logger.info(
|
|
165
|
+
"mcp_bridge: tool dispatch EXIT tool=%s ok=%s",
|
|
166
|
+
tool_name, "ok" in str(result) or result,
|
|
167
|
+
)
|
|
168
|
+
return result
|
|
169
|
+
except Exception:
|
|
170
|
+
logger.exception(
|
|
171
|
+
"mcp_bridge: tool dispatch FAILED tool=%s agent=%s",
|
|
172
|
+
tool_name, agent_id,
|
|
173
|
+
)
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_server(
|
|
178
|
+
agent_id: str,
|
|
179
|
+
socket_path: Path = DEFAULT_SOCKET_PATH,
|
|
180
|
+
identity: dict | None = None,
|
|
181
|
+
session_id: str | None = None,
|
|
182
|
+
) -> FastMCP:
|
|
183
|
+
"""Construct a FastMCP server with tools filtered by ``is_chief``.
|
|
184
|
+
|
|
185
|
+
Split out from ``run_bridge`` so unit tests can introspect the tool
|
|
186
|
+
list without spinning up stdio.
|
|
187
|
+
|
|
188
|
+
Chief agents get all 8 tools (4 common + 4 chief-only).
|
|
189
|
+
Staff agents (``is_chief`` absent or ``False``) get only the 4 common
|
|
190
|
+
tools — the LLM never sees the chief-only names, implementing the
|
|
191
|
+
instance-level isolation required by spec §6.4.
|
|
192
|
+
|
|
193
|
+
``session_id`` (1k hotfix): the cloudagent business session_id that
|
|
194
|
+
every IPC ``tool_call`` payload must carry. Reads the current
|
|
195
|
+
deliver's session_id; the daemon's tool dispatcher requires it to
|
|
196
|
+
route the tool result back to the right business conversation.
|
|
197
|
+
Pass ``None`` only when no session is known (the backend will then
|
|
198
|
+
reject with INVALID_ARGS — that's the expected behaviour and
|
|
199
|
+
surfaces the bug rather than masking it).
|
|
200
|
+
"""
|
|
201
|
+
if identity is None:
|
|
202
|
+
identity = _read_identity(agent_id)
|
|
203
|
+
|
|
204
|
+
is_chief: bool = identity.get("is_chief") is True # False when missing or non-True
|
|
205
|
+
|
|
206
|
+
server = FastMCP(name=f"cloudagent-bridge-{agent_id}")
|
|
207
|
+
|
|
208
|
+
# ---- Common tools (all agents) ----------------------------------------
|
|
209
|
+
|
|
210
|
+
@server.tool(
|
|
211
|
+
description=(
|
|
212
|
+
"Send a message to another agent or the user. When replying "
|
|
213
|
+
"(kind=\"reply\" or \"question\"), MUST set parent_msg_id to the "
|
|
214
|
+
"inbound message's message_id (shown in the [meta] header of "
|
|
215
|
+
"your stdin) so the sender's wait_for_replies satisfies; "
|
|
216
|
+
"leave parent_msg_id=None for unsolicited kind=\"task\" / "
|
|
217
|
+
"\"report\" sends."
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
async def send_message(
|
|
221
|
+
target: str,
|
|
222
|
+
body: str,
|
|
223
|
+
kind: str = "task",
|
|
224
|
+
parent_msg_id: int | None = None,
|
|
225
|
+
) -> dict[str, Any]:
|
|
226
|
+
# Hot-fix 2026-05-12 — local_daemon staff replies were dropping
|
|
227
|
+
# ``parent_msg_id``, which left the chief's ``wait_for_replies``
|
|
228
|
+
# forever stuck on missing parents (cloud_pod staff already
|
|
229
|
+
# passed the id by virtue of going through ``send_message`` on
|
|
230
|
+
# the chief's server-side session; the daemon path didn't have
|
|
231
|
+
# the parameter on its tool surface). Now exposed on the tool
|
|
232
|
+
# AND threaded into the IPC payload — backend
|
|
233
|
+
# ``/v1/daemon/tool-call`` keeps the field on the dispatcher
|
|
234
|
+
# call to ``message_service.send`` (林逸舟 wire-in).
|
|
235
|
+
tool_args: dict[str, Any] = {
|
|
236
|
+
"target": target,
|
|
237
|
+
"body": body,
|
|
238
|
+
"kind": kind,
|
|
239
|
+
}
|
|
240
|
+
if parent_msg_id is not None:
|
|
241
|
+
tool_args["parent_msg_id"] = parent_msg_id
|
|
242
|
+
return await _ipc_tool_call(
|
|
243
|
+
socket_path,
|
|
244
|
+
agent_id,
|
|
245
|
+
"send_message",
|
|
246
|
+
tool_args,
|
|
247
|
+
session_id,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
@server.tool(description="List recent messages in the agent's inbox.")
|
|
251
|
+
async def list_messages(limit: int = 50) -> dict[str, Any]:
|
|
252
|
+
return await _ipc_tool_call(
|
|
253
|
+
socket_path, agent_id, "list_messages", {"limit": limit}, session_id
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
@server.tool(description="Upload a local file and attach it to the conversation.")
|
|
257
|
+
async def upload_file(path: str) -> dict[str, Any]:
|
|
258
|
+
return await _ipc_tool_call(
|
|
259
|
+
socket_path, agent_id, "upload_file", {"path": path}, session_id
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
@server.tool(description="Schedule a reminder for the agent at a future time.")
|
|
263
|
+
async def schedule_reminder(when: str, body: str) -> dict[str, Any]:
|
|
264
|
+
return await _ipc_tool_call(
|
|
265
|
+
socket_path,
|
|
266
|
+
agent_id,
|
|
267
|
+
"schedule_reminder",
|
|
268
|
+
{"when": when, "body": body},
|
|
269
|
+
session_id,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# ---- Chief-only tools (registered only when is_chief is True) ---------
|
|
273
|
+
|
|
274
|
+
if is_chief:
|
|
275
|
+
|
|
276
|
+
@server.tool(description="[chief] Delegate work to a staff agent.")
|
|
277
|
+
async def delegate_to_staff(staff_id: str, body: str) -> dict[str, Any]:
|
|
278
|
+
return await _ipc_tool_call(
|
|
279
|
+
socket_path,
|
|
280
|
+
agent_id,
|
|
281
|
+
"delegate_to_staff",
|
|
282
|
+
{"staff_id": staff_id, "body": body},
|
|
283
|
+
session_id,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
@server.tool(description="[chief] Mark a task closed with a summary.")
|
|
287
|
+
async def close_task(task_id: str, summary: str) -> dict[str, Any]:
|
|
288
|
+
return await _ipc_tool_call(
|
|
289
|
+
socket_path,
|
|
290
|
+
agent_id,
|
|
291
|
+
"close_task",
|
|
292
|
+
{"task_id": task_id, "summary": summary},
|
|
293
|
+
session_id,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
@server.tool(description="[chief] Ask the user for explicit approval.")
|
|
297
|
+
async def request_user_approval(question: str) -> dict[str, Any]:
|
|
298
|
+
return await _ipc_tool_call(
|
|
299
|
+
socket_path,
|
|
300
|
+
agent_id,
|
|
301
|
+
"request_user_approval",
|
|
302
|
+
{"question": question},
|
|
303
|
+
session_id,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
@server.tool(description="[chief] Update the company-wide status string.")
|
|
307
|
+
async def set_company_status(status: str) -> dict[str, Any]:
|
|
308
|
+
return await _ipc_tool_call(
|
|
309
|
+
socket_path,
|
|
310
|
+
agent_id,
|
|
311
|
+
"set_company_status",
|
|
312
|
+
{"status": status},
|
|
313
|
+
session_id,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
return server
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _configure_bridge_logging() -> None:
|
|
320
|
+
"""Send bridge logs to stderr — stdout is reserved for MCP stdio framing.
|
|
321
|
+
|
|
322
|
+
``claude`` invokes the bridge as a stdio MCP subprocess. Anything we
|
|
323
|
+
print to stdout would corrupt the MCP wire protocol (NDJSON-framed).
|
|
324
|
+
We therefore route Python logging to stderr only. ``claude`` captures
|
|
325
|
+
bridge stderr and surfaces it on its own stderr (visible to the
|
|
326
|
+
daemon's spawn supervisor and to manual ``claude --mcp-debug`` runs).
|
|
327
|
+
"""
|
|
328
|
+
root = logging.getLogger()
|
|
329
|
+
# Don't double-attach if a parent already wired one.
|
|
330
|
+
if any(
|
|
331
|
+
isinstance(h, logging.StreamHandler) and h.stream is sys.stderr
|
|
332
|
+
for h in root.handlers
|
|
333
|
+
):
|
|
334
|
+
return
|
|
335
|
+
handler = logging.StreamHandler(stream=sys.stderr)
|
|
336
|
+
handler.setFormatter(
|
|
337
|
+
logging.Formatter(
|
|
338
|
+
"%(asctime)s %(levelname)s mcp_bridge: %(message)s"
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
root.addHandler(handler)
|
|
342
|
+
root.setLevel(logging.INFO)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def run_bridge(
|
|
346
|
+
agent_id: str,
|
|
347
|
+
token_file: Path | None = None,
|
|
348
|
+
socket_path: Path | None = None,
|
|
349
|
+
profile: str | None = None,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Run the bridge until claude closes stdin.
|
|
352
|
+
|
|
353
|
+
Phase 1 (1i Bug B): the per-spawn token file is **not required**.
|
|
354
|
+
The original spec §6.6 token-file scheme was never wired end-to-end
|
|
355
|
+
(claude_runner only deletes the token post-spawn, never writes it
|
|
356
|
+
pre-spawn), and the bridge does not send the token on IPC calls
|
|
357
|
+
anyway. For Phase 1 we rely on filesystem-level access control —
|
|
358
|
+
the unix socket is mode 0o600 (owner-only). Task 11/1c will revisit
|
|
359
|
+
bridge↔daemon authentication when the token scheme is real.
|
|
360
|
+
|
|
361
|
+
Behaviour:
|
|
362
|
+
* If ``token_file`` is ``None`` or the file is missing → log a
|
|
363
|
+
warning and continue (do NOT abort the bridge — that would crash
|
|
364
|
+
every spawn and the user would see "MCP server cloudagent failed").
|
|
365
|
+
* If the file exists → read it (we don't use the value yet, but
|
|
366
|
+
reading proves we have access; useful for future auth wiring).
|
|
367
|
+
"""
|
|
368
|
+
_configure_bridge_logging()
|
|
369
|
+
|
|
370
|
+
# Resolve socket_path lazily so the default reflects the active
|
|
371
|
+
# profile (CLOUDAGENT_DAEMON_PROFILE env var if --profile not
|
|
372
|
+
# passed). Existing callers that pass a Path keep their behaviour.
|
|
373
|
+
if socket_path is None:
|
|
374
|
+
from .unix_socket import default_socket_path
|
|
375
|
+
|
|
376
|
+
socket_path = default_socket_path(profile)
|
|
377
|
+
|
|
378
|
+
logger.info(
|
|
379
|
+
"mcp_bridge: starting agent=%s socket=%s token_file=%s",
|
|
380
|
+
agent_id,
|
|
381
|
+
socket_path,
|
|
382
|
+
token_file,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if token_file is not None and token_file.exists():
|
|
386
|
+
try:
|
|
387
|
+
_ = token_file.read_text()
|
|
388
|
+
logger.info("mcp_bridge: token file present (Phase 1: not validated)")
|
|
389
|
+
except OSError as exc:
|
|
390
|
+
logger.warning(
|
|
391
|
+
"mcp_bridge: could not read token file %s: %s "
|
|
392
|
+
"(Phase 1: continuing without token)",
|
|
393
|
+
token_file,
|
|
394
|
+
exc,
|
|
395
|
+
)
|
|
396
|
+
else:
|
|
397
|
+
logger.info(
|
|
398
|
+
"mcp_bridge: no token file (Phase 1: continuing — auth via "
|
|
399
|
+
"unix-socket 0o600 ownership)"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
identity = _read_identity(agent_id)
|
|
403
|
+
# (c) IPC architecture upgrade — try daemon memory query first
|
|
404
|
+
# (faster, not subject to filesystem caching / mode issues on the
|
|
405
|
+
# freshly-written current-session.txt). Fall back to file on any
|
|
406
|
+
# failure so old daemons without the ``get_current_session`` op
|
|
407
|
+
# keep working.
|
|
408
|
+
current_session_id = await _query_daemon_session(agent_id, socket_path)
|
|
409
|
+
if current_session_id is not None:
|
|
410
|
+
logger.info(
|
|
411
|
+
"mcp_bridge: agent=%s current_session=%s (via socket IPC)",
|
|
412
|
+
agent_id,
|
|
413
|
+
current_session_id,
|
|
414
|
+
)
|
|
415
|
+
else:
|
|
416
|
+
# Fallback: file-based path (old daemon or socket unavailable).
|
|
417
|
+
current_session_id = _read_current_session(agent_id)
|
|
418
|
+
if current_session_id is not None:
|
|
419
|
+
logger.info(
|
|
420
|
+
"mcp_bridge: agent=%s current_session=%s (via file fallback)",
|
|
421
|
+
agent_id,
|
|
422
|
+
current_session_id,
|
|
423
|
+
)
|
|
424
|
+
else:
|
|
425
|
+
logger.warning(
|
|
426
|
+
"mcp_bridge: agent=%s current_session=None "
|
|
427
|
+
"(falling back to file IPC — socket miss + no file)",
|
|
428
|
+
agent_id,
|
|
429
|
+
)
|
|
430
|
+
server = build_server(
|
|
431
|
+
agent_id=agent_id,
|
|
432
|
+
socket_path=socket_path,
|
|
433
|
+
identity=identity,
|
|
434
|
+
session_id=current_session_id,
|
|
435
|
+
)
|
|
436
|
+
logger.info(
|
|
437
|
+
"mcp_bridge: ready agent=%s is_chief=%s — entering stdio loop",
|
|
438
|
+
agent_id,
|
|
439
|
+
identity.get("is_chief") is True,
|
|
440
|
+
)
|
|
441
|
+
await server.run_stdio_async()
|