@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.42.1
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 +160 -0
- package/README.md +9 -3
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +68 -24
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +157 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +270 -35
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +36 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +44 -22
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +54 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +28 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +277 -49
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py
CHANGED
|
@@ -6,17 +6,22 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
8
|
import subprocess
|
|
9
|
+
import sys
|
|
9
10
|
import time
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any
|
|
13
|
+
from typing import Callable
|
|
12
14
|
from typing import Dict
|
|
13
15
|
from typing import List
|
|
14
16
|
from typing import Optional
|
|
17
|
+
from typing import Tuple
|
|
15
18
|
|
|
16
19
|
from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
|
|
17
20
|
BOARD_STATUS_TOOL,
|
|
18
21
|
GIT_TIMEOUT_S,
|
|
19
22
|
HOOK_FEATURE,
|
|
23
|
+
LOCK_STALE_S,
|
|
24
|
+
NO_SESSION_MARK,
|
|
20
25
|
SESSION_ID,
|
|
21
26
|
VERSION,
|
|
22
27
|
WORKER_DEADLINE_S,
|
|
@@ -25,7 +30,6 @@ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
|
|
|
25
30
|
claude_registration,
|
|
26
31
|
fabric_root,
|
|
27
32
|
locate_hub,
|
|
28
|
-
lock_held,
|
|
29
33
|
log,
|
|
30
34
|
read_small,
|
|
31
35
|
)
|
|
@@ -43,21 +47,36 @@ RELEASE_TOOL = "fabric_work_release"
|
|
|
43
47
|
WAITING_TOOL = "fabric_whoami"
|
|
44
48
|
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
#: The Stop and SessionEnd hooks measure the turn's facts inside this total, so the hook stays far under its 5 s.
|
|
51
|
+
FACTS_BUDGET_S = 1.5
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
FACT_KEYS = ("head", "filesChanged", "linesAdded", "linesRemoved")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
HEAD = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def git_facts(cwd: Path, budget_s: Optional[float] = None) -> Dict[str, Optional[int]]:
|
|
61
|
+
"""HEAD, files that differ from it (untracked included) and lines added/removed. No path, no text. With
|
|
62
|
+
`budget_s`, every git call shares that total; a fact git could not give in time is None."""
|
|
48
63
|
facts: Dict[str, Any] = {"head": None, "filesChanged": None, "linesAdded": None, "linesRemoved": None}
|
|
64
|
+
until = None if budget_s is None else time.monotonic() + budget_s
|
|
49
65
|
|
|
50
66
|
def git(*args: str) -> Optional[str]:
|
|
67
|
+
timeout = GIT_TIMEOUT_S if until is None else min(GIT_TIMEOUT_S, until - time.monotonic())
|
|
68
|
+
if timeout <= 0:
|
|
69
|
+
return None
|
|
51
70
|
try:
|
|
52
71
|
done = subprocess.run(["git", "--no-optional-locks", "-C", str(cwd)] + list(args),
|
|
53
72
|
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
54
|
-
timeout=
|
|
73
|
+
timeout=timeout, check=False)
|
|
55
74
|
except (OSError, subprocess.SubprocessError):
|
|
56
75
|
return None
|
|
57
76
|
return done.stdout.decode("utf-8", "replace") if done.returncode == 0 else None
|
|
58
77
|
|
|
59
78
|
head = (git("rev-parse", "--verify", "-q", "HEAD") or "").strip()
|
|
60
|
-
facts["head"] = head if
|
|
79
|
+
facts["head"] = head if HEAD.fullmatch(head) else None
|
|
61
80
|
status = git("status", "--porcelain=v1", "-z", "--untracked-files=normal")
|
|
62
81
|
if status is not None:
|
|
63
82
|
facts["filesChanged"] = _count_status(status)
|
|
@@ -86,19 +105,64 @@ def _count_status(z: str) -> int:
|
|
|
86
105
|
return count
|
|
87
106
|
|
|
88
107
|
|
|
108
|
+
def stored_facts(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
109
|
+
"""The facts a spool entry carries, as the hook measured them when its turn ended (FAA-68): a head of 40 or 64
|
|
110
|
+
hex digits and non-negative counts, each else None. Nothing is measured at delivery."""
|
|
111
|
+
facts = entry.get("facts") if isinstance(entry.get("facts"), dict) else {}
|
|
112
|
+
head = facts.get("head")
|
|
113
|
+
out: Dict[str, Any] = {"head": head if isinstance(head, str) and HEAD.fullmatch(head) else None}
|
|
114
|
+
for key in FACT_KEYS[1:]:
|
|
115
|
+
value = facts.get(key)
|
|
116
|
+
out[key] = value if isinstance(value, int) and not isinstance(value, bool) and 0 <= value < 2 ** 53 else None
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class JsonRpcError(RuntimeError):
|
|
121
|
+
"""An `error` answer to one request: that request failed, the connection did not."""
|
|
122
|
+
|
|
123
|
+
def __init__(self, code: Any):
|
|
124
|
+
self.code = code if isinstance(code, int) and not isinstance(code, bool) else None
|
|
125
|
+
super().__init__("json-rpc error %s" % self.code)
|
|
126
|
+
|
|
127
|
+
def said(self) -> str:
|
|
128
|
+
return "error:%s" % ("unnamed" if self.code is None else self.code)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
#: fabric-mcp --rejoin-only's exit status when no Fabric session holds the runtime id (Fabric 0.3.9, TR-11).
|
|
132
|
+
NO_SESSION_EXIT = 3
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class NoSessionError(TimeoutError):
|
|
136
|
+
"""fabric-mcp --rejoin-only ended with NO_SESSION_EXIT: Fabric holds no session for this id. A TimeoutError, so a
|
|
137
|
+
caller that knows only "no answer" (watch-mail) keeps treating it as one."""
|
|
138
|
+
|
|
139
|
+
|
|
89
140
|
class McpStdio:
|
|
90
141
|
"""A minimal MCP client over stdio (newline-delimited JSON-RPC) with a hard deadline."""
|
|
91
142
|
|
|
92
143
|
def __init__(self, argv: List[str], cwd: str, env: Dict[str, str], deadline: float):
|
|
93
144
|
import threading # only the worker needs it; the hooks' path stays light
|
|
94
145
|
self.deadline = deadline
|
|
146
|
+
# Its own process group (FAA-68): a wrapper (npx, a shell script, fabric-mcp.cmd) keeps the server as a
|
|
147
|
+
# grandchild, and only a group kill reaches it.
|
|
148
|
+
options: Dict[str, Any] = {}
|
|
149
|
+
if sys.platform == "win32":
|
|
150
|
+
options["creationflags"] = 0x00000200 # CREATE_NEW_PROCESS_GROUP
|
|
151
|
+
# CreateProcess applies no PATHEXT: a registration naming `fabric-mcp` means `fabric-mcp.cmd` on PATH.
|
|
152
|
+
import shutil
|
|
153
|
+
found = shutil.which(argv[0], path=env.get("PATH"))
|
|
154
|
+
argv = [found or argv[0]] + list(argv[1:])
|
|
155
|
+
else:
|
|
156
|
+
options["start_new_session"] = True
|
|
95
157
|
self.proc = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
96
|
-
stderr=subprocess.DEVNULL)
|
|
158
|
+
stderr=subprocess.DEVNULL, **options)
|
|
97
159
|
self.messages: List[Dict[str, Any]] = []
|
|
98
160
|
self.cond = threading.Condition()
|
|
99
161
|
self.next_id = 0
|
|
100
162
|
self.tools_changed = False
|
|
101
|
-
|
|
163
|
+
self.tools: List[Dict[str, Any]] = [] # the last tools/list answer, for the tools' input schemas
|
|
164
|
+
self.reader = threading.Thread(target=self._read, daemon=True)
|
|
165
|
+
self.reader.start()
|
|
102
166
|
|
|
103
167
|
def _read(self) -> None:
|
|
104
168
|
assert self.proc.stdout is not None
|
|
@@ -116,10 +180,23 @@ class McpStdio:
|
|
|
116
180
|
with self.cond:
|
|
117
181
|
self.cond.notify_all()
|
|
118
182
|
|
|
183
|
+
def _ended(self) -> None:
|
|
184
|
+
"""The server is gone: raise what its exit status says."""
|
|
185
|
+
if self.proc.returncode == NO_SESSION_EXIT:
|
|
186
|
+
raise NoSessionError("fabric-mcp found no session for this id")
|
|
187
|
+
raise TimeoutError("fabric-mcp ended")
|
|
188
|
+
|
|
119
189
|
def _send(self, message: Dict[str, Any]) -> None:
|
|
120
190
|
assert self.proc.stdin is not None
|
|
121
|
-
|
|
122
|
-
|
|
191
|
+
try:
|
|
192
|
+
self.proc.stdin.write((json.dumps(message) + "\n").encode("utf-8"))
|
|
193
|
+
self.proc.stdin.flush()
|
|
194
|
+
except OSError: # a server that exited closed its stdin: say why it exited, never a broken pipe
|
|
195
|
+
try:
|
|
196
|
+
self.proc.wait(timeout=max(0.0, min(1.0, self.deadline - time.monotonic())))
|
|
197
|
+
except subprocess.TimeoutExpired:
|
|
198
|
+
pass
|
|
199
|
+
self._ended()
|
|
123
200
|
|
|
124
201
|
def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
|
125
202
|
self._send({"jsonrpc": "2.0", "method": method, **({"params": params} if params else {})})
|
|
@@ -134,10 +211,13 @@ class McpStdio:
|
|
|
134
211
|
if message.get("id") == ident:
|
|
135
212
|
self.messages.remove(message)
|
|
136
213
|
if "error" in message:
|
|
137
|
-
|
|
214
|
+
error = message["error"]
|
|
215
|
+
raise JsonRpcError(error.get("code") if isinstance(error, dict) else None)
|
|
138
216
|
return message.get("result") or {}
|
|
139
217
|
left = self.deadline - time.monotonic()
|
|
140
|
-
if
|
|
218
|
+
if self.proc.poll() is not None:
|
|
219
|
+
self._ended()
|
|
220
|
+
if left <= 0:
|
|
141
221
|
raise TimeoutError("no answer to %s" % method)
|
|
142
222
|
self.cond.wait(min(left, 0.25))
|
|
143
223
|
|
|
@@ -148,18 +228,42 @@ class McpStdio:
|
|
|
148
228
|
self.tools_changed = False
|
|
149
229
|
|
|
150
230
|
def close(self) -> None:
|
|
231
|
+
"""End the server: its stdin's end first (fabric-mcp exits on it), then its whole process group, so nothing a
|
|
232
|
+
wrapper started keeps the pipe open. Never blocks on stdout while the reader thread holds it (FAA-68)."""
|
|
233
|
+
grace = max(0.2, min(1.5, self.deadline - time.monotonic()))
|
|
151
234
|
try:
|
|
152
235
|
if self.proc.stdin:
|
|
153
|
-
self.proc.stdin.close()
|
|
154
|
-
|
|
236
|
+
self.proc.stdin.close()
|
|
237
|
+
except Exception: # noqa: BLE001
|
|
238
|
+
pass
|
|
239
|
+
try:
|
|
240
|
+
self.proc.wait(timeout=grace)
|
|
241
|
+
except Exception: # noqa: BLE001
|
|
242
|
+
pass
|
|
243
|
+
self._kill_group() # the wrapper may have exited and left its child behind: always
|
|
244
|
+
try:
|
|
245
|
+
self.proc.wait(timeout=1.0)
|
|
155
246
|
except Exception: # noqa: BLE001
|
|
156
|
-
|
|
247
|
+
pass
|
|
248
|
+
self.reader.join(timeout=1.0)
|
|
249
|
+
if self.proc.stdout and not self.reader.is_alive():
|
|
250
|
+
self.proc.stdout.close() # a reader still blocked keeps it; the process's end closes it
|
|
251
|
+
|
|
252
|
+
def _kill_group(self) -> None:
|
|
253
|
+
if sys.platform == "win32":
|
|
157
254
|
try:
|
|
158
|
-
self.proc.
|
|
159
|
-
|
|
255
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(self.proc.pid)], stdin=subprocess.DEVNULL,
|
|
256
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0, check=False)
|
|
257
|
+
except (OSError, subprocess.SubprocessError):
|
|
160
258
|
pass
|
|
161
|
-
|
|
162
|
-
|
|
259
|
+
if self.proc.poll() is None:
|
|
260
|
+
self.proc.kill()
|
|
261
|
+
return
|
|
262
|
+
import signal
|
|
263
|
+
try:
|
|
264
|
+
os.killpg(self.proc.pid, signal.SIGKILL) # the group the server leads (start_new_session)
|
|
265
|
+
except OSError:
|
|
266
|
+
pass
|
|
163
267
|
|
|
164
268
|
|
|
165
269
|
def _take_lock(directory: Path, wait_s: float = 0.0) -> bool:
|
|
@@ -175,7 +279,14 @@ def _take_lock(directory: Path, wait_s: float = 0.0) -> bool:
|
|
|
175
279
|
time.sleep(0.2)
|
|
176
280
|
|
|
177
281
|
|
|
178
|
-
def
|
|
282
|
+
def _identity(st: os.stat_result) -> Tuple[int, int, int, int]:
|
|
283
|
+
return (st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size) # not ctime: the rename itself moves it
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _try_lock(lock: Path, directory: Path, _between: Optional[Callable[[], None]] = None) -> bool:
|
|
287
|
+
"""Create the lock; take a stale one (older than LOCK_STALE_S) by renaming it to a name of this worker's own and
|
|
288
|
+
checking that the file renamed is the very one judged stale. A lock another worker made in between is put back
|
|
289
|
+
(FAA-68: no check-then-act). `_between` runs after the judgement and before the rename, for the tests."""
|
|
179
290
|
for _ in range(2):
|
|
180
291
|
try:
|
|
181
292
|
fd = os.open(str(lock), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
@@ -183,14 +294,46 @@ def _try_lock(lock: Path, directory: Path) -> bool:
|
|
|
183
294
|
os.close(fd)
|
|
184
295
|
return True
|
|
185
296
|
except FileExistsError:
|
|
186
|
-
|
|
187
|
-
|
|
297
|
+
pass
|
|
298
|
+
except OSError:
|
|
299
|
+
return False
|
|
300
|
+
try:
|
|
301
|
+
seen = os.lstat(str(lock))
|
|
302
|
+
except FileNotFoundError:
|
|
303
|
+
continue
|
|
304
|
+
except OSError:
|
|
305
|
+
return False
|
|
306
|
+
if time.time() - seen.st_mtime < LOCK_STALE_S:
|
|
307
|
+
return False
|
|
308
|
+
if _between is not None:
|
|
309
|
+
_between()
|
|
310
|
+
taken = directory / (".worker.lock.%d.%s" % (os.getpid(), os.urandom(4).hex()))
|
|
311
|
+
try:
|
|
312
|
+
os.rename(str(lock), str(taken))
|
|
313
|
+
except FileNotFoundError:
|
|
314
|
+
continue # another worker took it first; try to create ours
|
|
315
|
+
except OSError:
|
|
316
|
+
return False
|
|
317
|
+
try:
|
|
318
|
+
got = os.lstat(str(taken))
|
|
319
|
+
except OSError:
|
|
320
|
+
return False
|
|
321
|
+
# An inode number alone is not identity: Linux hands a freed one straight to the next file, so the lock another
|
|
322
|
+
# worker just made can carry the stale lock's number. Its write time and size are its own.
|
|
323
|
+
if _identity(got) != _identity(seen):
|
|
188
324
|
try:
|
|
189
|
-
|
|
325
|
+
os.link(str(taken), str(lock)) # put the live lock back, unless a newer one is there already
|
|
190
326
|
except OSError:
|
|
191
|
-
|
|
192
|
-
|
|
327
|
+
pass
|
|
328
|
+
try:
|
|
329
|
+
os.unlink(str(taken))
|
|
330
|
+
except OSError:
|
|
331
|
+
pass
|
|
193
332
|
return False
|
|
333
|
+
try:
|
|
334
|
+
os.unlink(str(taken))
|
|
335
|
+
except OSError:
|
|
336
|
+
pass
|
|
194
337
|
return False
|
|
195
338
|
|
|
196
339
|
|
|
@@ -234,8 +377,33 @@ def board_unread(result: Dict[str, Any]) -> Tuple[Optional[int], str]:
|
|
|
234
377
|
return unread, "answer=ok unread=%d%s" % (unread, " board=degraded" if state == "degraded" else "")
|
|
235
378
|
|
|
236
379
|
|
|
380
|
+
def _read_entry(path: Path) -> Optional[Dict[str, Any]]:
|
|
381
|
+
"""One spool entry, or None when it cannot be read as one: not a small regular file, not UTF-8 JSON, not an
|
|
382
|
+
object, or an event other than `turn` and `end`."""
|
|
383
|
+
try:
|
|
384
|
+
entry = json.loads(read_small(path) or "")
|
|
385
|
+
except ValueError:
|
|
386
|
+
return None
|
|
387
|
+
if not isinstance(entry, dict) or entry.get("event") not in ("turn", "end"):
|
|
388
|
+
return None
|
|
389
|
+
return entry
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _entry_cwd(entries: List[Path]) -> Path:
|
|
393
|
+
"""The folder the session worked in, from the first entry that names an existing one."""
|
|
394
|
+
for path in entries:
|
|
395
|
+
entry = _read_entry(path)
|
|
396
|
+
cwd = entry.get("cwd") if entry else None
|
|
397
|
+
if isinstance(cwd, str) and cwd and "\0" not in cwd and Path(cwd).is_dir():
|
|
398
|
+
return Path(cwd)
|
|
399
|
+
return Path(os.getcwd())
|
|
400
|
+
|
|
401
|
+
|
|
237
402
|
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.
|
|
403
|
+
"""Drain one session's spool through fabric-mcp --rejoin-only. Returns the log lines it wrote. An entry that
|
|
404
|
+
cannot be read, or a call answered with a JSON-RPC error, is logged and dropped; the rest still goes out (FAA-68).
|
|
405
|
+
Only an answer that never came leaves the spool for the next worker: the session's folder goes only once its end
|
|
406
|
+
entry's calls (the claim release and the end observation) were answered, refused or error-coded."""
|
|
239
407
|
env = dict(os.environ if env is None else env)
|
|
240
408
|
said: List[str] = []
|
|
241
409
|
|
|
@@ -250,12 +418,20 @@ def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: f
|
|
|
250
418
|
return said
|
|
251
419
|
client: Optional[McpStdio] = None
|
|
252
420
|
ended = False
|
|
421
|
+
|
|
422
|
+
def call(event: str, tool: str, arguments: Dict[str, Any]) -> None:
|
|
423
|
+
assert client is not None
|
|
424
|
+
try:
|
|
425
|
+
answer = _answer_said(client.request("tools/call", {"name": tool, "arguments": arguments}))
|
|
426
|
+
except JsonRpcError as exc:
|
|
427
|
+
answer = exc.said()
|
|
428
|
+
note("session=%s event=%s tool=%s answer=%s" % (session_id[:8], event, tool, answer))
|
|
429
|
+
|
|
253
430
|
try:
|
|
254
|
-
entries = sorted(directory.glob("*-*.json"))
|
|
431
|
+
entries = _drop_stale_ends(sorted(directory.glob("*-*.json")), note, session_id)
|
|
255
432
|
if not entries:
|
|
256
433
|
return said
|
|
257
|
-
|
|
258
|
-
cwd = Path(first.get("cwd") or os.getcwd())
|
|
434
|
+
cwd = _entry_cwd(entries)
|
|
259
435
|
registration = claude_registration(cwd, env)
|
|
260
436
|
hub = locate_hub(fabric_root(env=env))
|
|
261
437
|
if not registration or hub["state"] != "running" or HOOK_FEATURE not in hub.get("features", []):
|
|
@@ -275,38 +451,61 @@ def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: f
|
|
|
275
451
|
_write_private(directory / "rejoined", "")
|
|
276
452
|
while entries:
|
|
277
453
|
for path in entries:
|
|
278
|
-
entry =
|
|
279
|
-
|
|
454
|
+
entry = _read_entry(path)
|
|
455
|
+
if entry is None:
|
|
456
|
+
note("session=%s outcome=dropped reason=%r" % (session_id[:8], "unreadable entry"))
|
|
457
|
+
path.unlink()
|
|
458
|
+
continue
|
|
459
|
+
event = entry["event"]
|
|
280
460
|
key = "%s:%s" % (session_id, path.name.split("-", 1)[0])
|
|
461
|
+
reason = entry.get("reason") if isinstance(entry.get("reason"), str) else "other"
|
|
281
462
|
if event == "end":
|
|
282
|
-
ended = True
|
|
283
463
|
if RELEASE_TOOL in names:
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
464
|
+
release = {"claims": "session", "outcome": "let-go",
|
|
465
|
+
"reason": "the Claude Code session ended (%s)" % reason,
|
|
466
|
+
"idempotencyKey": key + ":release"}
|
|
467
|
+
# Fabric 0.3.9 refuses an end observed before the session enrolled (`stale-end`, board thread
|
|
468
|
+
# 02bf19c5): say when it was observed, where the tool takes the field.
|
|
469
|
+
if isinstance(entry.get("observedAt"), str) and "observedAt" in _input_fields(client, RELEASE_TOOL):
|
|
470
|
+
release["observedAt"] = entry["observedAt"]
|
|
471
|
+
call(event, RELEASE_TOOL, release)
|
|
289
472
|
else:
|
|
290
473
|
note("session=%s event=end outcome=degraded reason=%r" % (session_id[:8], "no %s" % RELEASE_TOOL))
|
|
291
|
-
if
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
note("session=%s event=%s outcome=degraded reason=%r" % (session_id[:8], event, "no %s" % OBSERVE_TOOL))
|
|
301
|
-
(directory / "observe.degraded").touch()
|
|
474
|
+
if OBSERVE_TOOL in names:
|
|
475
|
+
arguments = dict(stored_facts(entry), event=event, runtime=RUNNER, runtimeSessionId=session_id,
|
|
476
|
+
observedAt=entry.get("observedAt"), idempotencyKey=key)
|
|
477
|
+
if event == "end":
|
|
478
|
+
arguments["reason"] = reason
|
|
479
|
+
call(event, OBSERVE_TOOL, arguments)
|
|
480
|
+
elif not (directory / "observe.degraded").exists():
|
|
481
|
+
note("session=%s event=%s outcome=degraded reason=%r" % (session_id[:8], event, "no %s" % OBSERVE_TOOL))
|
|
482
|
+
(directory / "observe.degraded").touch()
|
|
302
483
|
path.unlink()
|
|
303
|
-
|
|
484
|
+
if event == "end":
|
|
485
|
+
ended = True # only now: a call that timed out above left this entry for the next worker
|
|
486
|
+
entries = _drop_stale_ends(sorted(directory.glob("*-*.json")), note, session_id) # spooled meanwhile
|
|
304
487
|
if not ended and BOARD_STATUS_TOOL in names:
|
|
305
488
|
# The board's unread count for the prompt hook (FAA-12a). A count, never a message.
|
|
306
|
-
|
|
489
|
+
try:
|
|
490
|
+
unread, said_board = board_unread(client.request("tools/call", {"name": BOARD_STATUS_TOOL, "arguments": {}}))
|
|
491
|
+
except JsonRpcError as exc:
|
|
492
|
+
unread, said_board = None, "answer=" + exc.said()
|
|
307
493
|
_write_private(directory / "board.json", json.dumps({
|
|
308
494
|
"unread": unread, "observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}))
|
|
309
495
|
note("session=%s event=board tool=%s %s" % (session_id[:8], BOARD_STATUS_TOOL, said_board))
|
|
496
|
+
except NoSessionError:
|
|
497
|
+
# Recorded once (02bf19c5, ask 4): nothing in the spool can reach a session Fabric does not hold.
|
|
498
|
+
note("session=%s outcome=no-session reason=%r" % (session_id[:8], "Fabric holds no session for this id"))
|
|
499
|
+
for path in directory.glob("*-*.json"):
|
|
500
|
+
ended = ended or path.name.endswith("-end.json") # the session's last hook: its folder goes now
|
|
501
|
+
try:
|
|
502
|
+
path.unlink()
|
|
503
|
+
except OSError:
|
|
504
|
+
pass
|
|
505
|
+
try:
|
|
506
|
+
_write_private(directory / NO_SESSION_MARK, "")
|
|
507
|
+
except OSError as exc: # the worker never raises: without the mark, the next turn tries once more
|
|
508
|
+
note("session=%s outcome=no-session reason=%r" % (session_id[:8], "the mark was not written (%s)" % type(exc).__name__))
|
|
310
509
|
except TimeoutError as exc:
|
|
311
510
|
note("session=%s outcome=unknown reason=%r" % (session_id[:8], str(exc)))
|
|
312
511
|
except Exception as exc: # noqa: BLE001 — the worker never raises
|
|
@@ -323,10 +522,39 @@ def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: f
|
|
|
323
522
|
return said
|
|
324
523
|
|
|
325
524
|
|
|
525
|
+
def _drop_stale_ends(entries: List[Path], note: Callable[[str], None], session_id: str) -> List[Path]:
|
|
526
|
+
"""The entries left once every end followed by a later turn of the same id is dropped (FAA-83; board thread
|
|
527
|
+
02bf19c5, ask 3): `claude --resume` keeps the id, so such an end belongs to an earlier process of the session, and
|
|
528
|
+
delivering it would release the live session's claims. An end that is the newest entry keeps its retry. Entries
|
|
529
|
+
are named by `time_ns`, so the sorted order is the spooling order."""
|
|
530
|
+
kept: List[Path] = []
|
|
531
|
+
for index, path in enumerate(entries):
|
|
532
|
+
if path.name.endswith("-end.json") and any(p.name.endswith("-turn.json") for p in entries[index + 1:]):
|
|
533
|
+
note("session=%s event=end outcome=dropped reason=%r" % (session_id[:8], "a later turn of the same session"))
|
|
534
|
+
try:
|
|
535
|
+
path.unlink()
|
|
536
|
+
except OSError:
|
|
537
|
+
pass
|
|
538
|
+
continue
|
|
539
|
+
kept.append(path)
|
|
540
|
+
return kept
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _input_fields(client: McpStdio, tool: str) -> List[str]:
|
|
544
|
+
"""The property names a listed tool's input schema declares; [] when it declares none."""
|
|
545
|
+
for listed in client.tools:
|
|
546
|
+
if listed.get("name") == tool:
|
|
547
|
+
schema = listed.get("inputSchema")
|
|
548
|
+
props = schema.get("properties") if isinstance(schema, dict) else None
|
|
549
|
+
return [k for k in props if isinstance(k, str)] if isinstance(props, dict) else []
|
|
550
|
+
return []
|
|
551
|
+
|
|
552
|
+
|
|
326
553
|
def _wait_for_session_tools(client: McpStdio, deadline: float) -> List[str]:
|
|
327
554
|
"""fabric-mcp lists only fabric_whoami until it holds a session; then it says its tools changed."""
|
|
328
555
|
while True:
|
|
329
556
|
tools = client.request("tools/list", {}).get("tools") or []
|
|
557
|
+
client.tools = [t for t in tools if isinstance(t, dict)]
|
|
330
558
|
names = [t.get("name") for t in tools if isinstance(t, dict) and isinstance(t.get("name"), str)]
|
|
331
559
|
if [n for n in names if n != WAITING_TOOL]:
|
|
332
560
|
return names
|
|
@@ -105,6 +105,10 @@ def plan(host, path, script, remove):
|
|
|
105
105
|
return [(path, old, openclaw_edit(old, script, remove))]
|
|
106
106
|
if host == 'hermes':
|
|
107
107
|
if path.is_symlink(): raise fs.Refused('plugin directory is a symlink; nothing was changed')
|
|
108
|
+
if remove:
|
|
109
|
+
# Removal reads nothing from the source skill: its hosts/ folder may be gone (FAA-69).
|
|
110
|
+
desired = dict.fromkeys((path/'__init__.py', path/'plugin.yaml'), '')
|
|
111
|
+
return _changes(desired, remove)
|
|
108
112
|
module = str(script.parent / 'hosts/hermes/__init__.py')
|
|
109
113
|
loader = ('# ' + MARKER + '\nimport importlib.util\n'
|
|
110
114
|
'_spec = importlib.util.spec_from_file_location("_fabric_native_hermes", ' + repr(module) + ')\n'
|
|
@@ -114,6 +118,10 @@ def plan(host, path, script, remove):
|
|
|
114
118
|
else:
|
|
115
119
|
module = (script.parent / ('hosts/' + host + '/fabric.mjs')).as_uri()
|
|
116
120
|
desired = {path: '// ' + MARKER + '\nexport { default } from ' + json.dumps(module) + ';\n'}
|
|
121
|
+
return _changes(desired, remove)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _changes(desired, remove):
|
|
117
125
|
changes = []
|
|
118
126
|
for target, text in desired.items():
|
|
119
127
|
old = fs._read_bytes(target)
|
|
@@ -137,7 +145,8 @@ def main(argv=None, env=None, out=print, err=print):
|
|
|
137
145
|
args=parser.parse_args(argv)
|
|
138
146
|
try:
|
|
139
147
|
script=Path(args.script).expanduser().absolute() if args.script else BASE/'fabric_session.py'
|
|
140
|
-
|
|
148
|
+
# Removal needs no helper: the skill it was installed from may be gone (FAA-69).
|
|
149
|
+
if not args.remove and not script.is_file(): raise fs.Refused('shared helper is missing')
|
|
141
150
|
if not args.remove:
|
|
142
151
|
required = {'hermes':['hosts/hermes/__init__.py','hosts/hermes/plugin.yaml'],
|
|
143
152
|
'opencode':['hosts/context.mjs','hosts/opencode/fabric.mjs'],
|
|
@@ -7,6 +7,9 @@ const scriptPath = fileURLToPath(new URL('../fabric_session.py', import.meta.url
|
|
|
7
7
|
const hosts = new Set(['opencode', 'pi', 'openclaw']);
|
|
8
8
|
const cap = 64 * 1024;
|
|
9
9
|
|
|
10
|
+
// Resolves the helper's context, '' when it answered none (or nothing usable), and null when the run failed: an error,
|
|
11
|
+
// a nonzero exit, a timeout, output over the cap, a missing python3 or script. A caller treats null as no context;
|
|
12
|
+
// onceEach keeps no null, so the next request runs the helper again.
|
|
10
13
|
export async function readContext(host, cwd, options = {}) {
|
|
11
14
|
if (!hosts.has(host) || typeof cwd !== 'string' || !cwd || cwd.includes('\0')) return '';
|
|
12
15
|
const payload = {cwd};
|
|
@@ -21,13 +24,33 @@ export async function readContext(host, cwd, options = {}) {
|
|
|
21
24
|
['-S', options.script || scriptPath, 'hook', 'session-start', '--host', host],
|
|
22
25
|
{timeout, killSignal:'SIGKILL', maxBuffer:cap, encoding:'utf8', windowsHide:true},
|
|
23
26
|
(error, stdout) => {
|
|
24
|
-
if (error || typeof stdout !== 'string'
|
|
27
|
+
if (error || typeof stdout !== 'string') return resolve(null);
|
|
28
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(stdout)) return resolve('');
|
|
25
29
|
resolve(stdout.trim());
|
|
26
30
|
});
|
|
27
31
|
// A missing executable or early exit may close stdin before it is written.
|
|
28
32
|
child.stdin?.on('error', () => {});
|
|
29
33
|
child.stdin?.end(input);
|
|
30
|
-
} catch { resolve(
|
|
34
|
+
} catch { resolve(null); }
|
|
31
35
|
});
|
|
32
36
|
}
|
|
37
|
+
|
|
38
|
+
// One helper run per host, workspace and registration in this process (FAA-69): OpenCode and OpenClaw ask on every
|
|
39
|
+
// request, and the answer does not change within a process. A failed run (a rejection, or null from readContext) is
|
|
40
|
+
// not kept, so the next request asks again.
|
|
41
|
+
const cacheCap = 64;
|
|
42
|
+
export function onceEach(reader = readContext) {
|
|
43
|
+
const answers = new Map();
|
|
44
|
+
return (host, cwd, options = {}) => {
|
|
45
|
+
const key = JSON.stringify([host, cwd, options.fabricRegistered === true]);
|
|
46
|
+
if (!answers.has(key)) {
|
|
47
|
+
if (answers.size >= cacheCap) answers.delete(answers.keys().next().value);
|
|
48
|
+
const pending = Promise.resolve().then(() => reader(host, cwd, options));
|
|
49
|
+
answers.set(key, pending);
|
|
50
|
+
const forget = () => { if (answers.get(key) === pending) answers.delete(key); };
|
|
51
|
+
pending.then((value) => { if (value === null || value === undefined) forget(); }, forget);
|
|
52
|
+
}
|
|
53
|
+
return answers.get(key);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
33
56
|
// #endregion native-host-context
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
|
|
2
2
|
// Native OpenClaw prompt hook. Never observes conversation content or sends messages.
|
|
3
|
-
import { readContext } from '../context.mjs';
|
|
3
|
+
import { onceEach, readContext } from '../context.mjs';
|
|
4
4
|
|
|
5
5
|
function fabricRegistered(config) {
|
|
6
6
|
const servers = config?.mcp?.servers;
|
|
@@ -14,6 +14,8 @@ function fabricRegistered(config) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function createPlugin(contextReader = readContext) {
|
|
17
|
+
// The helper runs once per workspace and registration in this process, not per prompt (FAA-69).
|
|
18
|
+
const read = onceEach(contextReader);
|
|
17
19
|
return {
|
|
18
20
|
id: 'fabric-agent-adapter',
|
|
19
21
|
name: 'Fabric Agent Adapter',
|
|
@@ -27,7 +29,7 @@ export function createPlugin(contextReader = readContext) {
|
|
|
27
29
|
// Configured is not connected/admitted. Forward only the boolean,
|
|
28
30
|
// never server configuration, headers, environment or credentials.
|
|
29
31
|
const options = fabricRegistered(api.config) ? { fabricRegistered: true } : {};
|
|
30
|
-
const context = await
|
|
32
|
+
const context = await read('openclaw', ctx.workspaceDir, options);
|
|
31
33
|
ctx.hookInvocation?.assertActive();
|
|
32
34
|
if (typeof context !== 'string' || !context.trim()) return;
|
|
33
35
|
return { prependContext: context };
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
|
|
2
|
-
import {
|
|
2
|
+
import { onceEach } from '../context.mjs';
|
|
3
3
|
|
|
4
4
|
const marker = '[fabric-agent-adapter]';
|
|
5
5
|
|
|
6
6
|
// OpenCode's legacy loader treats every export as a plugin factory.
|
|
7
7
|
export default async function fabricPlugin({directory} = {}) {
|
|
8
8
|
let fabricRegistered = false;
|
|
9
|
+
const readContext = onceEach(); // Python once per workspace and registration, not per request (FAA-69)
|
|
9
10
|
return {
|
|
10
11
|
config: async config => {
|
|
11
12
|
const entry = config?.mcp?.fabric;
|