@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.41.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 +124 -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 +48 -19
- 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 +144 -20
- 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 +130 -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 +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +30 -22
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +37 -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 +25 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +199 -43
- 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_mail.py
CHANGED
|
@@ -15,10 +15,13 @@ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
|
|
|
15
15
|
UNREAD_LINE,
|
|
16
16
|
VERSION,
|
|
17
17
|
WORKER_DEADLINE_S,
|
|
18
|
+
_write_private,
|
|
18
19
|
claude_registration,
|
|
19
20
|
fabric_launched,
|
|
20
21
|
fabric_root,
|
|
21
22
|
locate_hub,
|
|
23
|
+
rejoin_wait,
|
|
24
|
+
session_dir,
|
|
22
25
|
)
|
|
23
26
|
from fabric_session_mcp import ( # noqa: E402 - the kit (FAA-65)
|
|
24
27
|
McpStdio,
|
|
@@ -36,9 +39,15 @@ WATCH_MAX_BACKOFF_S = 900
|
|
|
36
39
|
def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None,
|
|
37
40
|
deadline_s: float = WORKER_DEADLINE_S) -> Tuple[str, Any]:
|
|
38
41
|
"""One look at the board through `fabric-mcp --rejoin-only`: ("ok", count), ("unavailable", why) when Fabric
|
|
39
|
-
answers but the board cannot be read, ("down", why) when Fabric cannot be reached
|
|
40
|
-
|
|
42
|
+
answers but the board cannot be read, ("down", why) when Fabric cannot be reached, ("wait", seconds) when the
|
|
43
|
+
session rejoined within the last minute (the Stop worker's mark, or this one's): the rejoin budget is shared, so
|
|
44
|
+
no fabric-mcp starts then (FAA-68). No fabric-mcp starts unless the running Fabric advertises session-hooks/1
|
|
45
|
+
(the rejoin). A successful rejoin renews the mark in the session's folder when the hooks made one."""
|
|
41
46
|
env = dict(os.environ if env is None else env)
|
|
47
|
+
directory = session_dir(session_id, env)
|
|
48
|
+
left = rejoin_wait(directory)
|
|
49
|
+
if left > 0:
|
|
50
|
+
return "wait", left
|
|
42
51
|
hub = locate_hub(fabric_root(env=env))
|
|
43
52
|
if hub["state"] != "running":
|
|
44
53
|
return "down", "Fabric is not running (%s)" % hub["reason"]
|
|
@@ -56,6 +65,11 @@ def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None
|
|
|
56
65
|
"clientInfo": {"name": "fabric-agent-adapter-watch-mail", "version": VERSION}})
|
|
57
66
|
client.notify("notifications/initialized")
|
|
58
67
|
names = _wait_for_session_tools(client, deadline)
|
|
68
|
+
if directory.is_dir(): # the hooks made it, after their launch check; never made here
|
|
69
|
+
try:
|
|
70
|
+
_write_private(directory / "rejoined", "")
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
59
73
|
if BOARD_STATUS_TOOL not in names:
|
|
60
74
|
return "unavailable", "this Fabric serves no %s" % BOARD_STATUS_TOOL
|
|
61
75
|
unread, said = board_unread(client.request("tools/call", {"name": BOARD_STATUS_TOOL, "arguments": {}}))
|
|
@@ -72,15 +86,18 @@ def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None
|
|
|
72
86
|
def watch_mail(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None, interval: float = 60, *,
|
|
73
87
|
poll: Optional[Callable[[], Tuple[str, Any]]] = None, out: Callable[[str], None] = print,
|
|
74
88
|
wait: Callable[[float], bool], stop: Callable[[], bool], rng: Callable[[], float]) -> int:
|
|
75
|
-
"""Poll the board every `interval` seconds (±20 % jitter
|
|
76
|
-
time the unread count rises. While Fabric is down or the board
|
|
77
|
-
to WATCH_MAX_BACKOFF_S.
|
|
89
|
+
"""Poll the board every `interval` seconds (±20 % jitter; the delay after jitter stays within WATCH_MIN_INTERVAL_S
|
|
90
|
+
and WATCH_MAX_BACKOFF_S) and print ONE line each time the unread count rises. While Fabric is down or the board
|
|
91
|
+
unreadable, say so once and back off, doubling up to WATCH_MAX_BACKOFF_S. A look the shared rejoin mark holds back
|
|
92
|
+
("wait") changes nothing and waits it out. `wait(seconds)` returns True when it was told to stop."""
|
|
78
93
|
interval = max(float(WATCH_MIN_INTERVAL_S), float(interval))
|
|
79
94
|
poll = poll or (lambda: poll_unread(session_id, cwd, env))
|
|
80
95
|
last, trouble, backoff = 0, None, interval
|
|
81
96
|
while not stop():
|
|
82
97
|
kind, value = poll()
|
|
83
|
-
if kind == "
|
|
98
|
+
if kind == "wait":
|
|
99
|
+
delay = float(value)
|
|
100
|
+
elif kind == "ok":
|
|
84
101
|
if value > last:
|
|
85
102
|
out(UNREAD_LINE % value)
|
|
86
103
|
last, trouble, backoff = value, None, interval
|
|
@@ -91,7 +108,8 @@ def watch_mail(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None,
|
|
|
91
108
|
out("watch-mail: %s; trying again, backing off up to %d minutes" % (value, WATCH_MAX_BACKOFF_S // 60))
|
|
92
109
|
trouble = kind
|
|
93
110
|
delay = backoff
|
|
94
|
-
if wait
|
|
111
|
+
jittered = delay if kind == "wait" else delay * (0.8 + 0.4 * rng())
|
|
112
|
+
if wait(min(max(jittered, float(WATCH_MIN_INTERVAL_S)), float(WATCH_MAX_BACKOFF_S))):
|
|
95
113
|
break
|
|
96
114
|
return 0
|
|
97
115
|
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py
CHANGED
|
@@ -6,17 +6,21 @@ 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,
|
|
20
24
|
SESSION_ID,
|
|
21
25
|
VERSION,
|
|
22
26
|
WORKER_DEADLINE_S,
|
|
@@ -25,7 +29,6 @@ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
|
|
|
25
29
|
claude_registration,
|
|
26
30
|
fabric_root,
|
|
27
31
|
locate_hub,
|
|
28
|
-
lock_held,
|
|
29
32
|
log,
|
|
30
33
|
read_small,
|
|
31
34
|
)
|
|
@@ -43,21 +46,36 @@ RELEASE_TOOL = "fabric_work_release"
|
|
|
43
46
|
WAITING_TOOL = "fabric_whoami"
|
|
44
47
|
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
49
|
+
#: The Stop and SessionEnd hooks measure the turn's facts inside this total, so the hook stays far under its 5 s.
|
|
50
|
+
FACTS_BUDGET_S = 1.5
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
FACT_KEYS = ("head", "filesChanged", "linesAdded", "linesRemoved")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
HEAD = re.compile(r"[0-9a-f]{40}|[0-9a-f]{64}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def git_facts(cwd: Path, budget_s: Optional[float] = None) -> Dict[str, Optional[int]]:
|
|
60
|
+
"""HEAD, files that differ from it (untracked included) and lines added/removed. No path, no text. With
|
|
61
|
+
`budget_s`, every git call shares that total; a fact git could not give in time is None."""
|
|
48
62
|
facts: Dict[str, Any] = {"head": None, "filesChanged": None, "linesAdded": None, "linesRemoved": None}
|
|
63
|
+
until = None if budget_s is None else time.monotonic() + budget_s
|
|
49
64
|
|
|
50
65
|
def git(*args: str) -> Optional[str]:
|
|
66
|
+
timeout = GIT_TIMEOUT_S if until is None else min(GIT_TIMEOUT_S, until - time.monotonic())
|
|
67
|
+
if timeout <= 0:
|
|
68
|
+
return None
|
|
51
69
|
try:
|
|
52
70
|
done = subprocess.run(["git", "--no-optional-locks", "-C", str(cwd)] + list(args),
|
|
53
71
|
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
54
|
-
timeout=
|
|
72
|
+
timeout=timeout, check=False)
|
|
55
73
|
except (OSError, subprocess.SubprocessError):
|
|
56
74
|
return None
|
|
57
75
|
return done.stdout.decode("utf-8", "replace") if done.returncode == 0 else None
|
|
58
76
|
|
|
59
77
|
head = (git("rev-parse", "--verify", "-q", "HEAD") or "").strip()
|
|
60
|
-
facts["head"] = head if
|
|
78
|
+
facts["head"] = head if HEAD.fullmatch(head) else None
|
|
61
79
|
status = git("status", "--porcelain=v1", "-z", "--untracked-files=normal")
|
|
62
80
|
if status is not None:
|
|
63
81
|
facts["filesChanged"] = _count_status(status)
|
|
@@ -86,19 +104,54 @@ def _count_status(z: str) -> int:
|
|
|
86
104
|
return count
|
|
87
105
|
|
|
88
106
|
|
|
107
|
+
def stored_facts(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
108
|
+
"""The facts a spool entry carries, as the hook measured them when its turn ended (FAA-68): a head of 40 or 64
|
|
109
|
+
hex digits and non-negative counts, each else None. Nothing is measured at delivery."""
|
|
110
|
+
facts = entry.get("facts") if isinstance(entry.get("facts"), dict) else {}
|
|
111
|
+
head = facts.get("head")
|
|
112
|
+
out: Dict[str, Any] = {"head": head if isinstance(head, str) and HEAD.fullmatch(head) else None}
|
|
113
|
+
for key in FACT_KEYS[1:]:
|
|
114
|
+
value = facts.get(key)
|
|
115
|
+
out[key] = value if isinstance(value, int) and not isinstance(value, bool) and 0 <= value < 2 ** 53 else None
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class JsonRpcError(RuntimeError):
|
|
120
|
+
"""An `error` answer to one request: that request failed, the connection did not."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, code: Any):
|
|
123
|
+
self.code = code if isinstance(code, int) and not isinstance(code, bool) else None
|
|
124
|
+
super().__init__("json-rpc error %s" % self.code)
|
|
125
|
+
|
|
126
|
+
def said(self) -> str:
|
|
127
|
+
return "error:%s" % ("unnamed" if self.code is None else self.code)
|
|
128
|
+
|
|
129
|
+
|
|
89
130
|
class McpStdio:
|
|
90
131
|
"""A minimal MCP client over stdio (newline-delimited JSON-RPC) with a hard deadline."""
|
|
91
132
|
|
|
92
133
|
def __init__(self, argv: List[str], cwd: str, env: Dict[str, str], deadline: float):
|
|
93
134
|
import threading # only the worker needs it; the hooks' path stays light
|
|
94
135
|
self.deadline = deadline
|
|
136
|
+
# Its own process group (FAA-68): a wrapper (npx, a shell script, fabric-mcp.cmd) keeps the server as a
|
|
137
|
+
# grandchild, and only a group kill reaches it.
|
|
138
|
+
options: Dict[str, Any] = {}
|
|
139
|
+
if sys.platform == "win32":
|
|
140
|
+
options["creationflags"] = 0x00000200 # CREATE_NEW_PROCESS_GROUP
|
|
141
|
+
# CreateProcess applies no PATHEXT: a registration naming `fabric-mcp` means `fabric-mcp.cmd` on PATH.
|
|
142
|
+
import shutil
|
|
143
|
+
found = shutil.which(argv[0], path=env.get("PATH"))
|
|
144
|
+
argv = [found or argv[0]] + list(argv[1:])
|
|
145
|
+
else:
|
|
146
|
+
options["start_new_session"] = True
|
|
95
147
|
self.proc = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
96
|
-
stderr=subprocess.DEVNULL)
|
|
148
|
+
stderr=subprocess.DEVNULL, **options)
|
|
97
149
|
self.messages: List[Dict[str, Any]] = []
|
|
98
150
|
self.cond = threading.Condition()
|
|
99
151
|
self.next_id = 0
|
|
100
152
|
self.tools_changed = False
|
|
101
|
-
threading.Thread(target=self._read, daemon=True)
|
|
153
|
+
self.reader = threading.Thread(target=self._read, daemon=True)
|
|
154
|
+
self.reader.start()
|
|
102
155
|
|
|
103
156
|
def _read(self) -> None:
|
|
104
157
|
assert self.proc.stdout is not None
|
|
@@ -134,7 +187,8 @@ class McpStdio:
|
|
|
134
187
|
if message.get("id") == ident:
|
|
135
188
|
self.messages.remove(message)
|
|
136
189
|
if "error" in message:
|
|
137
|
-
|
|
190
|
+
error = message["error"]
|
|
191
|
+
raise JsonRpcError(error.get("code") if isinstance(error, dict) else None)
|
|
138
192
|
return message.get("result") or {}
|
|
139
193
|
left = self.deadline - time.monotonic()
|
|
140
194
|
if left <= 0 or self.proc.poll() is not None:
|
|
@@ -148,18 +202,42 @@ class McpStdio:
|
|
|
148
202
|
self.tools_changed = False
|
|
149
203
|
|
|
150
204
|
def close(self) -> None:
|
|
205
|
+
"""End the server: its stdin's end first (fabric-mcp exits on it), then its whole process group, so nothing a
|
|
206
|
+
wrapper started keeps the pipe open. Never blocks on stdout while the reader thread holds it (FAA-68)."""
|
|
207
|
+
grace = max(0.2, min(1.5, self.deadline - time.monotonic()))
|
|
151
208
|
try:
|
|
152
209
|
if self.proc.stdin:
|
|
153
|
-
self.proc.stdin.close()
|
|
154
|
-
self.proc.wait(timeout=1.5)
|
|
210
|
+
self.proc.stdin.close()
|
|
155
211
|
except Exception: # noqa: BLE001
|
|
156
|
-
|
|
212
|
+
pass
|
|
213
|
+
try:
|
|
214
|
+
self.proc.wait(timeout=grace)
|
|
215
|
+
except Exception: # noqa: BLE001
|
|
216
|
+
pass
|
|
217
|
+
self._kill_group() # the wrapper may have exited and left its child behind: always
|
|
218
|
+
try:
|
|
219
|
+
self.proc.wait(timeout=1.0)
|
|
220
|
+
except Exception: # noqa: BLE001
|
|
221
|
+
pass
|
|
222
|
+
self.reader.join(timeout=1.0)
|
|
223
|
+
if self.proc.stdout and not self.reader.is_alive():
|
|
224
|
+
self.proc.stdout.close() # a reader still blocked keeps it; the process's end closes it
|
|
225
|
+
|
|
226
|
+
def _kill_group(self) -> None:
|
|
227
|
+
if sys.platform == "win32":
|
|
157
228
|
try:
|
|
158
|
-
self.proc.
|
|
159
|
-
|
|
229
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(self.proc.pid)], stdin=subprocess.DEVNULL,
|
|
230
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2.0, check=False)
|
|
231
|
+
except (OSError, subprocess.SubprocessError):
|
|
160
232
|
pass
|
|
161
|
-
|
|
162
|
-
|
|
233
|
+
if self.proc.poll() is None:
|
|
234
|
+
self.proc.kill()
|
|
235
|
+
return
|
|
236
|
+
import signal
|
|
237
|
+
try:
|
|
238
|
+
os.killpg(self.proc.pid, signal.SIGKILL) # the group the server leads (start_new_session)
|
|
239
|
+
except OSError:
|
|
240
|
+
pass
|
|
163
241
|
|
|
164
242
|
|
|
165
243
|
def _take_lock(directory: Path, wait_s: float = 0.0) -> bool:
|
|
@@ -175,7 +253,14 @@ def _take_lock(directory: Path, wait_s: float = 0.0) -> bool:
|
|
|
175
253
|
time.sleep(0.2)
|
|
176
254
|
|
|
177
255
|
|
|
178
|
-
def
|
|
256
|
+
def _identity(st: os.stat_result) -> Tuple[int, int, int, int]:
|
|
257
|
+
return (st.st_dev, st.st_ino, st.st_mtime_ns, st.st_size) # not ctime: the rename itself moves it
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _try_lock(lock: Path, directory: Path, _between: Optional[Callable[[], None]] = None) -> bool:
|
|
261
|
+
"""Create the lock; take a stale one (older than LOCK_STALE_S) by renaming it to a name of this worker's own and
|
|
262
|
+
checking that the file renamed is the very one judged stale. A lock another worker made in between is put back
|
|
263
|
+
(FAA-68: no check-then-act). `_between` runs after the judgement and before the rename, for the tests."""
|
|
179
264
|
for _ in range(2):
|
|
180
265
|
try:
|
|
181
266
|
fd = os.open(str(lock), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
@@ -183,14 +268,46 @@ def _try_lock(lock: Path, directory: Path) -> bool:
|
|
|
183
268
|
os.close(fd)
|
|
184
269
|
return True
|
|
185
270
|
except FileExistsError:
|
|
186
|
-
|
|
187
|
-
|
|
271
|
+
pass
|
|
272
|
+
except OSError:
|
|
273
|
+
return False
|
|
274
|
+
try:
|
|
275
|
+
seen = os.lstat(str(lock))
|
|
276
|
+
except FileNotFoundError:
|
|
277
|
+
continue
|
|
278
|
+
except OSError:
|
|
279
|
+
return False
|
|
280
|
+
if time.time() - seen.st_mtime < LOCK_STALE_S:
|
|
281
|
+
return False
|
|
282
|
+
if _between is not None:
|
|
283
|
+
_between()
|
|
284
|
+
taken = directory / (".worker.lock.%d.%s" % (os.getpid(), os.urandom(4).hex()))
|
|
285
|
+
try:
|
|
286
|
+
os.rename(str(lock), str(taken))
|
|
287
|
+
except FileNotFoundError:
|
|
288
|
+
continue # another worker took it first; try to create ours
|
|
289
|
+
except OSError:
|
|
290
|
+
return False
|
|
291
|
+
try:
|
|
292
|
+
got = os.lstat(str(taken))
|
|
293
|
+
except OSError:
|
|
294
|
+
return False
|
|
295
|
+
# An inode number alone is not identity: Linux hands a freed one straight to the next file, so the lock another
|
|
296
|
+
# worker just made can carry the stale lock's number. Its write time and size are its own.
|
|
297
|
+
if _identity(got) != _identity(seen):
|
|
188
298
|
try:
|
|
189
|
-
|
|
299
|
+
os.link(str(taken), str(lock)) # put the live lock back, unless a newer one is there already
|
|
190
300
|
except OSError:
|
|
191
|
-
|
|
192
|
-
|
|
301
|
+
pass
|
|
302
|
+
try:
|
|
303
|
+
os.unlink(str(taken))
|
|
304
|
+
except OSError:
|
|
305
|
+
pass
|
|
193
306
|
return False
|
|
307
|
+
try:
|
|
308
|
+
os.unlink(str(taken))
|
|
309
|
+
except OSError:
|
|
310
|
+
pass
|
|
194
311
|
return False
|
|
195
312
|
|
|
196
313
|
|
|
@@ -234,8 +351,33 @@ def board_unread(result: Dict[str, Any]) -> Tuple[Optional[int], str]:
|
|
|
234
351
|
return unread, "answer=ok unread=%d%s" % (unread, " board=degraded" if state == "degraded" else "")
|
|
235
352
|
|
|
236
353
|
|
|
354
|
+
def _read_entry(path: Path) -> Optional[Dict[str, Any]]:
|
|
355
|
+
"""One spool entry, or None when it cannot be read as one: not a small regular file, not UTF-8 JSON, not an
|
|
356
|
+
object, or an event other than `turn` and `end`."""
|
|
357
|
+
try:
|
|
358
|
+
entry = json.loads(read_small(path) or "")
|
|
359
|
+
except ValueError:
|
|
360
|
+
return None
|
|
361
|
+
if not isinstance(entry, dict) or entry.get("event") not in ("turn", "end"):
|
|
362
|
+
return None
|
|
363
|
+
return entry
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _entry_cwd(entries: List[Path]) -> Path:
|
|
367
|
+
"""The folder the session worked in, from the first entry that names an existing one."""
|
|
368
|
+
for path in entries:
|
|
369
|
+
entry = _read_entry(path)
|
|
370
|
+
cwd = entry.get("cwd") if entry else None
|
|
371
|
+
if isinstance(cwd, str) and cwd and "\0" not in cwd and Path(cwd).is_dir():
|
|
372
|
+
return Path(cwd)
|
|
373
|
+
return Path(os.getcwd())
|
|
374
|
+
|
|
375
|
+
|
|
237
376
|
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.
|
|
377
|
+
"""Drain one session's spool through fabric-mcp --rejoin-only. Returns the log lines it wrote. An entry that
|
|
378
|
+
cannot be read, or a call answered with a JSON-RPC error, is logged and dropped; the rest still goes out (FAA-68).
|
|
379
|
+
Only an answer that never came leaves the spool for the next worker: the session's folder goes only once its end
|
|
380
|
+
entry's calls (the claim release and the end observation) were answered, refused or error-coded."""
|
|
239
381
|
env = dict(os.environ if env is None else env)
|
|
240
382
|
said: List[str] = []
|
|
241
383
|
|
|
@@ -250,12 +392,20 @@ def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: f
|
|
|
250
392
|
return said
|
|
251
393
|
client: Optional[McpStdio] = None
|
|
252
394
|
ended = False
|
|
395
|
+
|
|
396
|
+
def call(event: str, tool: str, arguments: Dict[str, Any]) -> None:
|
|
397
|
+
assert client is not None
|
|
398
|
+
try:
|
|
399
|
+
answer = _answer_said(client.request("tools/call", {"name": tool, "arguments": arguments}))
|
|
400
|
+
except JsonRpcError as exc:
|
|
401
|
+
answer = exc.said()
|
|
402
|
+
note("session=%s event=%s tool=%s answer=%s" % (session_id[:8], event, tool, answer))
|
|
403
|
+
|
|
253
404
|
try:
|
|
254
405
|
entries = sorted(directory.glob("*-*.json"))
|
|
255
406
|
if not entries:
|
|
256
407
|
return said
|
|
257
|
-
|
|
258
|
-
cwd = Path(first.get("cwd") or os.getcwd())
|
|
408
|
+
cwd = _entry_cwd(entries)
|
|
259
409
|
registration = claude_registration(cwd, env)
|
|
260
410
|
hub = locate_hub(fabric_root(env=env))
|
|
261
411
|
if not registration or hub["state"] != "running" or HOOK_FEATURE not in hub.get("features", []):
|
|
@@ -275,35 +425,41 @@ def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: f
|
|
|
275
425
|
_write_private(directory / "rejoined", "")
|
|
276
426
|
while entries:
|
|
277
427
|
for path in entries:
|
|
278
|
-
entry =
|
|
279
|
-
|
|
428
|
+
entry = _read_entry(path)
|
|
429
|
+
if entry is None:
|
|
430
|
+
note("session=%s outcome=dropped reason=%r" % (session_id[:8], "unreadable entry"))
|
|
431
|
+
path.unlink()
|
|
432
|
+
continue
|
|
433
|
+
event = entry["event"]
|
|
280
434
|
key = "%s:%s" % (session_id, path.name.split("-", 1)[0])
|
|
435
|
+
reason = entry.get("reason") if isinstance(entry.get("reason"), str) else "other"
|
|
281
436
|
if event == "end":
|
|
282
|
-
ended = True
|
|
283
437
|
if RELEASE_TOOL in names:
|
|
284
|
-
|
|
438
|
+
call(event, RELEASE_TOOL, {
|
|
285
439
|
"claims": "session", "outcome": "let-go",
|
|
286
|
-
"reason": "the Claude Code session ended (%s)" %
|
|
287
|
-
"idempotencyKey": key + ":release"}
|
|
288
|
-
note("session=%s event=end tool=%s answer=%s" % (session_id[:8], RELEASE_TOOL, _answer_said(result)))
|
|
440
|
+
"reason": "the Claude Code session ended (%s)" % reason,
|
|
441
|
+
"idempotencyKey": key + ":release"})
|
|
289
442
|
else:
|
|
290
443
|
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()
|
|
444
|
+
if OBSERVE_TOOL in names:
|
|
445
|
+
arguments = dict(stored_facts(entry), event=event, runtime=RUNNER, runtimeSessionId=session_id,
|
|
446
|
+
observedAt=entry.get("observedAt"), idempotencyKey=key)
|
|
447
|
+
if event == "end":
|
|
448
|
+
arguments["reason"] = reason
|
|
449
|
+
call(event, OBSERVE_TOOL, arguments)
|
|
450
|
+
elif not (directory / "observe.degraded").exists():
|
|
451
|
+
note("session=%s event=%s outcome=degraded reason=%r" % (session_id[:8], event, "no %s" % OBSERVE_TOOL))
|
|
452
|
+
(directory / "observe.degraded").touch()
|
|
302
453
|
path.unlink()
|
|
454
|
+
if event == "end":
|
|
455
|
+
ended = True # only now: a call that timed out above left this entry for the next worker
|
|
303
456
|
entries = sorted(directory.glob("*-*.json")) # turns spooled while this worker ran
|
|
304
457
|
if not ended and BOARD_STATUS_TOOL in names:
|
|
305
458
|
# The board's unread count for the prompt hook (FAA-12a). A count, never a message.
|
|
306
|
-
|
|
459
|
+
try:
|
|
460
|
+
unread, said_board = board_unread(client.request("tools/call", {"name": BOARD_STATUS_TOOL, "arguments": {}}))
|
|
461
|
+
except JsonRpcError as exc:
|
|
462
|
+
unread, said_board = None, "answer=" + exc.said()
|
|
307
463
|
_write_private(directory / "board.json", json.dumps({
|
|
308
464
|
"unread": unread, "observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}))
|
|
309
465
|
note("session=%s event=board tool=%s %s" % (session_id[:8], BOARD_STATUS_TOOL, said_board))
|
|
@@ -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;
|