@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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/CHANGELOG.md +79 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""working-with-fabric, part of fabric_session.py (FAA-65): the detached worker that delivers a spool through
|
|
2
|
+
fabric-mcp --rejoin-only over stdio, with a deadline. Standard library only."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from typing import Dict
|
|
13
|
+
from typing import List
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
|
|
17
|
+
BOARD_STATUS_TOOL,
|
|
18
|
+
GIT_TIMEOUT_S,
|
|
19
|
+
HOOK_FEATURE,
|
|
20
|
+
SESSION_ID,
|
|
21
|
+
VERSION,
|
|
22
|
+
WORKER_DEADLINE_S,
|
|
23
|
+
_remove_tree,
|
|
24
|
+
_write_private,
|
|
25
|
+
claude_registration,
|
|
26
|
+
fabric_root,
|
|
27
|
+
locate_hub,
|
|
28
|
+
lock_held,
|
|
29
|
+
log,
|
|
30
|
+
read_small,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
RUNNER = "claude-code"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
OBSERVE_TOOL = "fabric_session_observe"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
RELEASE_TOOL = "fabric_work_release"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
WAITING_TOOL = "fabric_whoami"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def git_facts(cwd: Path) -> Dict[str, Optional[int]]:
|
|
47
|
+
"""HEAD, files that differ from it (untracked included) and lines added/removed. No path, no text."""
|
|
48
|
+
facts: Dict[str, Any] = {"head": None, "filesChanged": None, "linesAdded": None, "linesRemoved": None}
|
|
49
|
+
|
|
50
|
+
def git(*args: str) -> Optional[str]:
|
|
51
|
+
try:
|
|
52
|
+
done = subprocess.run(["git", "--no-optional-locks", "-C", str(cwd)] + list(args),
|
|
53
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
54
|
+
timeout=GIT_TIMEOUT_S, check=False)
|
|
55
|
+
except (OSError, subprocess.SubprocessError):
|
|
56
|
+
return None
|
|
57
|
+
return done.stdout.decode("utf-8", "replace") if done.returncode == 0 else None
|
|
58
|
+
|
|
59
|
+
head = (git("rev-parse", "--verify", "-q", "HEAD") or "").strip()
|
|
60
|
+
facts["head"] = head if re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", head) else None
|
|
61
|
+
status = git("status", "--porcelain=v1", "-z", "--untracked-files=normal")
|
|
62
|
+
if status is not None:
|
|
63
|
+
facts["filesChanged"] = _count_status(status)
|
|
64
|
+
if facts["head"]:
|
|
65
|
+
numstat = git("diff", "--numstat", "HEAD")
|
|
66
|
+
if numstat is not None:
|
|
67
|
+
added = removed = 0
|
|
68
|
+
for line in numstat.splitlines():
|
|
69
|
+
parts = line.split("\t", 2)
|
|
70
|
+
if len(parts) == 3 and parts[0].isdigit() and parts[1].isdigit():
|
|
71
|
+
added, removed = added + int(parts[0]), removed + int(parts[1])
|
|
72
|
+
facts["linesAdded"], facts["linesRemoved"] = added, removed
|
|
73
|
+
return facts
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _count_status(z: str) -> int:
|
|
77
|
+
records, count, skip = z.split("\0"), 0, False
|
|
78
|
+
for record in records:
|
|
79
|
+
if skip:
|
|
80
|
+
skip = False
|
|
81
|
+
continue
|
|
82
|
+
if len(record) < 4:
|
|
83
|
+
continue
|
|
84
|
+
count += 1
|
|
85
|
+
skip = record[0] in "RC" # a rename or copy carries its old path in the next record
|
|
86
|
+
return count
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class McpStdio:
|
|
90
|
+
"""A minimal MCP client over stdio (newline-delimited JSON-RPC) with a hard deadline."""
|
|
91
|
+
|
|
92
|
+
def __init__(self, argv: List[str], cwd: str, env: Dict[str, str], deadline: float):
|
|
93
|
+
import threading # only the worker needs it; the hooks' path stays light
|
|
94
|
+
self.deadline = deadline
|
|
95
|
+
self.proc = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
96
|
+
stderr=subprocess.DEVNULL)
|
|
97
|
+
self.messages: List[Dict[str, Any]] = []
|
|
98
|
+
self.cond = threading.Condition()
|
|
99
|
+
self.next_id = 0
|
|
100
|
+
self.tools_changed = False
|
|
101
|
+
threading.Thread(target=self._read, daemon=True).start()
|
|
102
|
+
|
|
103
|
+
def _read(self) -> None:
|
|
104
|
+
assert self.proc.stdout is not None
|
|
105
|
+
for raw in self.proc.stdout:
|
|
106
|
+
try:
|
|
107
|
+
message = json.loads(raw.decode("utf-8"))
|
|
108
|
+
except ValueError:
|
|
109
|
+
continue
|
|
110
|
+
with self.cond:
|
|
111
|
+
if isinstance(message, dict) and message.get("method") == "notifications/tools/list_changed":
|
|
112
|
+
self.tools_changed = True
|
|
113
|
+
elif isinstance(message, dict):
|
|
114
|
+
self.messages.append(message)
|
|
115
|
+
self.cond.notify_all()
|
|
116
|
+
with self.cond:
|
|
117
|
+
self.cond.notify_all()
|
|
118
|
+
|
|
119
|
+
def _send(self, message: Dict[str, Any]) -> None:
|
|
120
|
+
assert self.proc.stdin is not None
|
|
121
|
+
self.proc.stdin.write((json.dumps(message) + "\n").encode("utf-8"))
|
|
122
|
+
self.proc.stdin.flush()
|
|
123
|
+
|
|
124
|
+
def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
|
125
|
+
self._send({"jsonrpc": "2.0", "method": method, **({"params": params} if params else {})})
|
|
126
|
+
|
|
127
|
+
def request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
128
|
+
self.next_id += 1
|
|
129
|
+
ident = self.next_id
|
|
130
|
+
self._send({"jsonrpc": "2.0", "id": ident, "method": method, "params": params})
|
|
131
|
+
with self.cond:
|
|
132
|
+
while True:
|
|
133
|
+
for message in self.messages:
|
|
134
|
+
if message.get("id") == ident:
|
|
135
|
+
self.messages.remove(message)
|
|
136
|
+
if "error" in message:
|
|
137
|
+
raise RuntimeError("json-rpc error %s" % (message["error"] or {}).get("code"))
|
|
138
|
+
return message.get("result") or {}
|
|
139
|
+
left = self.deadline - time.monotonic()
|
|
140
|
+
if left <= 0 or self.proc.poll() is not None:
|
|
141
|
+
raise TimeoutError("no answer to %s" % method)
|
|
142
|
+
self.cond.wait(min(left, 0.25))
|
|
143
|
+
|
|
144
|
+
def wait_tools_changed(self, seconds: float) -> None:
|
|
145
|
+
with self.cond:
|
|
146
|
+
if not self.tools_changed:
|
|
147
|
+
self.cond.wait(max(0.0, min(seconds, self.deadline - time.monotonic())))
|
|
148
|
+
self.tools_changed = False
|
|
149
|
+
|
|
150
|
+
def close(self) -> None:
|
|
151
|
+
try:
|
|
152
|
+
if self.proc.stdin:
|
|
153
|
+
self.proc.stdin.close() # fabric-mcp ends on stdin's end
|
|
154
|
+
self.proc.wait(timeout=1.5)
|
|
155
|
+
except Exception: # noqa: BLE001
|
|
156
|
+
self.proc.kill()
|
|
157
|
+
try:
|
|
158
|
+
self.proc.wait(timeout=1.0)
|
|
159
|
+
except Exception: # noqa: BLE001
|
|
160
|
+
pass
|
|
161
|
+
if self.proc.stdout:
|
|
162
|
+
self.proc.stdout.close()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _take_lock(directory: Path, wait_s: float = 0.0) -> bool:
|
|
166
|
+
"""One worker per session. A second one waits for the first (bounded), so an `end` entry spooled while a
|
|
167
|
+
worker was finishing is still delivered."""
|
|
168
|
+
lock = directory / "worker.lock"
|
|
169
|
+
until = time.monotonic() + wait_s
|
|
170
|
+
while True:
|
|
171
|
+
if _try_lock(lock, directory):
|
|
172
|
+
return True
|
|
173
|
+
if time.monotonic() >= until:
|
|
174
|
+
return False
|
|
175
|
+
time.sleep(0.2)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _try_lock(lock: Path, directory: Path) -> bool:
|
|
179
|
+
for _ in range(2):
|
|
180
|
+
try:
|
|
181
|
+
fd = os.open(str(lock), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
182
|
+
os.write(fd, str(os.getpid()).encode())
|
|
183
|
+
os.close(fd)
|
|
184
|
+
return True
|
|
185
|
+
except FileExistsError:
|
|
186
|
+
if lock_held(directory):
|
|
187
|
+
return False
|
|
188
|
+
try:
|
|
189
|
+
lock.unlink()
|
|
190
|
+
except OSError:
|
|
191
|
+
return False
|
|
192
|
+
except OSError:
|
|
193
|
+
return False
|
|
194
|
+
return False
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _answer_json(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
198
|
+
"""The JSON an answer carries: structuredContent, else its first text block parsed (the board's tools answer
|
|
199
|
+
that way). {} when it carries none."""
|
|
200
|
+
if isinstance(result.get("structuredContent"), dict):
|
|
201
|
+
return result["structuredContent"]
|
|
202
|
+
content = result.get("content")
|
|
203
|
+
if isinstance(content, list) and content and isinstance(content[0], dict) and isinstance(content[0].get("text"), str):
|
|
204
|
+
try:
|
|
205
|
+
value = json.loads(content[0]["text"])
|
|
206
|
+
except ValueError:
|
|
207
|
+
return {}
|
|
208
|
+
return value if isinstance(value, dict) else {}
|
|
209
|
+
return {}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _answer_said(result: Dict[str, Any]) -> str:
|
|
213
|
+
"""What an answer was, in a log-safe word: never its text."""
|
|
214
|
+
if not result.get("isError"):
|
|
215
|
+
return "ok"
|
|
216
|
+
error = _answer_json(result).get("error")
|
|
217
|
+
code = error.get("code") if isinstance(error, dict) and isinstance(error.get("code"), str) else None
|
|
218
|
+
return "refused:%s" % (code if code and re.fullmatch(r"[a-z0-9._-]{1,64}", code) else "unnamed")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def board_unread(result: Dict[str, Any]) -> Tuple[Optional[int], str]:
|
|
222
|
+
"""`com.status` (comms-status.schema.json) → (the unread count, a log-safe word). A board that is `unavailable`,
|
|
223
|
+
or an answer without a count, is None: "cannot be read" is never reported as zero. A `degraded` board that still
|
|
224
|
+
gives the reader's count is read (unread counts belong to the reader: the contract's project-comms.md)."""
|
|
225
|
+
said = _answer_said(result)
|
|
226
|
+
if said != "ok":
|
|
227
|
+
return None, "answer=" + said
|
|
228
|
+
doc = _answer_json(result)
|
|
229
|
+
board = doc.get("board") if isinstance(doc.get("board"), dict) else {}
|
|
230
|
+
unread = doc.get("unread")
|
|
231
|
+
state = board.get("state")
|
|
232
|
+
if state not in ("ready", "degraded") or not isinstance(unread, int) or isinstance(unread, bool) or unread < 0:
|
|
233
|
+
return None, "board=unavailable"
|
|
234
|
+
return unread, "answer=ok unread=%d%s" % (unread, " board=degraded" if state == "degraded" else "")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: float = WORKER_DEADLINE_S) -> List[str]:
|
|
238
|
+
"""Drain one session's spool through fabric-mcp --rejoin-only. Returns the log lines it wrote."""
|
|
239
|
+
env = dict(os.environ if env is None else env)
|
|
240
|
+
said: List[str] = []
|
|
241
|
+
|
|
242
|
+
def note(line: str) -> None:
|
|
243
|
+
said.append(line)
|
|
244
|
+
log(line, env)
|
|
245
|
+
|
|
246
|
+
session_id = directory.name
|
|
247
|
+
if not SESSION_ID.fullmatch(session_id) or not directory.is_dir():
|
|
248
|
+
return said
|
|
249
|
+
if not _take_lock(directory, wait_s=deadline_s + 2.0):
|
|
250
|
+
return said
|
|
251
|
+
client: Optional[McpStdio] = None
|
|
252
|
+
ended = False
|
|
253
|
+
try:
|
|
254
|
+
entries = sorted(directory.glob("*-*.json"))
|
|
255
|
+
if not entries:
|
|
256
|
+
return said
|
|
257
|
+
first = json.loads(read_small(entries[0]) or "{}")
|
|
258
|
+
cwd = Path(first.get("cwd") or os.getcwd())
|
|
259
|
+
registration = claude_registration(cwd, env)
|
|
260
|
+
hub = locate_hub(fabric_root(env=env))
|
|
261
|
+
if not registration or hub["state"] != "running" or HOOK_FEATURE not in hub.get("features", []):
|
|
262
|
+
note("session=%s outcome=dropped reason=%r" % (session_id[:8], "Fabric or its registration went away"))
|
|
263
|
+
for entry in entries:
|
|
264
|
+
entry.unlink()
|
|
265
|
+
ended = any(e.name.endswith("-end.json") for e in entries)
|
|
266
|
+
return said
|
|
267
|
+
deadline = time.monotonic() + deadline_s
|
|
268
|
+
child_env = dict(env, CLAUDE_CODE_SESSION_ID=session_id)
|
|
269
|
+
argv = [registration["command"]] + registration["args"] + ["--rejoin-only", "--runtime-session-id", session_id]
|
|
270
|
+
client = McpStdio(argv, str(cwd), child_env, deadline)
|
|
271
|
+
client.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
272
|
+
"clientInfo": {"name": "fabric-agent-adapter-hooks", "version": VERSION}})
|
|
273
|
+
client.notify("notifications/initialized")
|
|
274
|
+
names = _wait_for_session_tools(client, deadline)
|
|
275
|
+
_write_private(directory / "rejoined", "")
|
|
276
|
+
while entries:
|
|
277
|
+
for path in entries:
|
|
278
|
+
entry = json.loads(read_small(path) or "{}")
|
|
279
|
+
event = entry.get("event")
|
|
280
|
+
key = "%s:%s" % (session_id, path.name.split("-", 1)[0])
|
|
281
|
+
if event == "end":
|
|
282
|
+
ended = True
|
|
283
|
+
if RELEASE_TOOL in names:
|
|
284
|
+
result = client.request("tools/call", {"name": RELEASE_TOOL, "arguments": {
|
|
285
|
+
"claims": "session", "outcome": "let-go",
|
|
286
|
+
"reason": "the Claude Code session ended (%s)" % entry.get("reason", "other"),
|
|
287
|
+
"idempotencyKey": key + ":release"}})
|
|
288
|
+
note("session=%s event=end tool=%s answer=%s" % (session_id[:8], RELEASE_TOOL, _answer_said(result)))
|
|
289
|
+
else:
|
|
290
|
+
note("session=%s event=end outcome=degraded reason=%r" % (session_id[:8], "no %s" % RELEASE_TOOL))
|
|
291
|
+
if event in ("turn", "end"):
|
|
292
|
+
if OBSERVE_TOOL in names:
|
|
293
|
+
arguments = dict(git_facts(cwd), event=event, runtime=RUNNER, runtimeSessionId=session_id,
|
|
294
|
+
observedAt=entry.get("observedAt"), idempotencyKey=key)
|
|
295
|
+
if event == "end":
|
|
296
|
+
arguments["reason"] = entry.get("reason", "other")
|
|
297
|
+
result = client.request("tools/call", {"name": OBSERVE_TOOL, "arguments": arguments})
|
|
298
|
+
note("session=%s event=%s tool=%s answer=%s" % (session_id[:8], event, OBSERVE_TOOL, _answer_said(result)))
|
|
299
|
+
elif not (directory / "observe.degraded").exists():
|
|
300
|
+
note("session=%s event=%s outcome=degraded reason=%r" % (session_id[:8], event, "no %s" % OBSERVE_TOOL))
|
|
301
|
+
(directory / "observe.degraded").touch()
|
|
302
|
+
path.unlink()
|
|
303
|
+
entries = sorted(directory.glob("*-*.json")) # turns spooled while this worker ran
|
|
304
|
+
if not ended and BOARD_STATUS_TOOL in names:
|
|
305
|
+
# The board's unread count for the prompt hook (FAA-12a). A count, never a message.
|
|
306
|
+
unread, said_board = board_unread(client.request("tools/call", {"name": BOARD_STATUS_TOOL, "arguments": {}}))
|
|
307
|
+
_write_private(directory / "board.json", json.dumps({
|
|
308
|
+
"unread": unread, "observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}))
|
|
309
|
+
note("session=%s event=board tool=%s %s" % (session_id[:8], BOARD_STATUS_TOOL, said_board))
|
|
310
|
+
except TimeoutError as exc:
|
|
311
|
+
note("session=%s outcome=unknown reason=%r" % (session_id[:8], str(exc)))
|
|
312
|
+
except Exception as exc: # noqa: BLE001 — the worker never raises
|
|
313
|
+
note("session=%s outcome=failed reason=%r" % (session_id[:8], type(exc).__name__))
|
|
314
|
+
finally:
|
|
315
|
+
if client is not None:
|
|
316
|
+
client.close()
|
|
317
|
+
try:
|
|
318
|
+
(directory / "worker.lock").unlink()
|
|
319
|
+
except OSError:
|
|
320
|
+
pass
|
|
321
|
+
if ended:
|
|
322
|
+
_remove_tree(directory)
|
|
323
|
+
return said
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _wait_for_session_tools(client: McpStdio, deadline: float) -> List[str]:
|
|
327
|
+
"""fabric-mcp lists only fabric_whoami until it holds a session; then it says its tools changed."""
|
|
328
|
+
while True:
|
|
329
|
+
tools = client.request("tools/list", {}).get("tools") or []
|
|
330
|
+
names = [t.get("name") for t in tools if isinstance(t, dict) and isinstance(t.get("name"), str)]
|
|
331
|
+
if [n for n in names if n != WAITING_TOOL]:
|
|
332
|
+
return names
|
|
333
|
+
if time.monotonic() >= deadline:
|
|
334
|
+
raise TimeoutError("fabric-mcp did not join the session (not enrolled, or Fabric did not answer)")
|
|
335
|
+
client.wait_tools_changed(0.5)
|
|
@@ -143,6 +143,7 @@ def main(argv=None, env=None, out=print, err=print):
|
|
|
143
143
|
'opencode':['hosts/context.mjs','hosts/opencode/fabric.mjs'],
|
|
144
144
|
'pi':['hosts/context.mjs','hosts/pi/fabric.mjs'],
|
|
145
145
|
'openclaw':['hosts/context.mjs','hosts/openclaw/index.mjs','hosts/openclaw/package.json','hosts/openclaw/openclaw.plugin.json']}[args.host]
|
|
146
|
+
required += ['fabric_session_env.py','fabric_session_mcp.py','fabric_session_mail.py','fabric_session_hosts.py','fabric_session_cli.py'] # the helper's own modules (FAA-65)
|
|
146
147
|
if any(not (script.parent/p).is_file() for p in required):
|
|
147
148
|
raise fs.Refused('native adapter source is missing; update the complete skill before installing hooks')
|
|
148
149
|
path=Path(args.config).expanduser().absolute() if args.config else destination(args.host,env)
|