@passioncode-ai/fabric-agent-adapter 0.39.1 → 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 +203 -0
- package/README.md +18 -6
- 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 +42 -33
- 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 +104 -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 +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- 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 +5 -4
- 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 +129 -0
- 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/references/usage.md +2 -0
- 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 +11 -184
- 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/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -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-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -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 +7 -18
- 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 +15 -46
- 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 +11 -68
- 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 +50 -16
- 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 +235 -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 +146 -32
- 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 +404 -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 +408 -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 +91 -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_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -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 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- 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 +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -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_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- 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 +285 -109
- 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 +32 -39
- 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_secrets.py +1 -1
- 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 +437 -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 +1621 -0
- 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/models-and-providers.md +3 -0
- 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 +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -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
|
@@ -0,0 +1,491 @@
|
|
|
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 sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from typing import Callable
|
|
14
|
+
from typing import Dict
|
|
15
|
+
from typing import List
|
|
16
|
+
from typing import Optional
|
|
17
|
+
from typing import Tuple
|
|
18
|
+
|
|
19
|
+
from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
|
|
20
|
+
BOARD_STATUS_TOOL,
|
|
21
|
+
GIT_TIMEOUT_S,
|
|
22
|
+
HOOK_FEATURE,
|
|
23
|
+
LOCK_STALE_S,
|
|
24
|
+
SESSION_ID,
|
|
25
|
+
VERSION,
|
|
26
|
+
WORKER_DEADLINE_S,
|
|
27
|
+
_remove_tree,
|
|
28
|
+
_write_private,
|
|
29
|
+
claude_registration,
|
|
30
|
+
fabric_root,
|
|
31
|
+
locate_hub,
|
|
32
|
+
log,
|
|
33
|
+
read_small,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
RUNNER = "claude-code"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
OBSERVE_TOOL = "fabric_session_observe"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
RELEASE_TOOL = "fabric_work_release"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
WAITING_TOOL = "fabric_whoami"
|
|
47
|
+
|
|
48
|
+
|
|
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."""
|
|
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
|
|
64
|
+
|
|
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
|
|
69
|
+
try:
|
|
70
|
+
done = subprocess.run(["git", "--no-optional-locks", "-C", str(cwd)] + list(args),
|
|
71
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
72
|
+
timeout=timeout, check=False)
|
|
73
|
+
except (OSError, subprocess.SubprocessError):
|
|
74
|
+
return None
|
|
75
|
+
return done.stdout.decode("utf-8", "replace") if done.returncode == 0 else None
|
|
76
|
+
|
|
77
|
+
head = (git("rev-parse", "--verify", "-q", "HEAD") or "").strip()
|
|
78
|
+
facts["head"] = head if HEAD.fullmatch(head) else None
|
|
79
|
+
status = git("status", "--porcelain=v1", "-z", "--untracked-files=normal")
|
|
80
|
+
if status is not None:
|
|
81
|
+
facts["filesChanged"] = _count_status(status)
|
|
82
|
+
if facts["head"]:
|
|
83
|
+
numstat = git("diff", "--numstat", "HEAD")
|
|
84
|
+
if numstat is not None:
|
|
85
|
+
added = removed = 0
|
|
86
|
+
for line in numstat.splitlines():
|
|
87
|
+
parts = line.split("\t", 2)
|
|
88
|
+
if len(parts) == 3 and parts[0].isdigit() and parts[1].isdigit():
|
|
89
|
+
added, removed = added + int(parts[0]), removed + int(parts[1])
|
|
90
|
+
facts["linesAdded"], facts["linesRemoved"] = added, removed
|
|
91
|
+
return facts
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _count_status(z: str) -> int:
|
|
95
|
+
records, count, skip = z.split("\0"), 0, False
|
|
96
|
+
for record in records:
|
|
97
|
+
if skip:
|
|
98
|
+
skip = False
|
|
99
|
+
continue
|
|
100
|
+
if len(record) < 4:
|
|
101
|
+
continue
|
|
102
|
+
count += 1
|
|
103
|
+
skip = record[0] in "RC" # a rename or copy carries its old path in the next record
|
|
104
|
+
return count
|
|
105
|
+
|
|
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
|
+
|
|
130
|
+
class McpStdio:
|
|
131
|
+
"""A minimal MCP client over stdio (newline-delimited JSON-RPC) with a hard deadline."""
|
|
132
|
+
|
|
133
|
+
def __init__(self, argv: List[str], cwd: str, env: Dict[str, str], deadline: float):
|
|
134
|
+
import threading # only the worker needs it; the hooks' path stays light
|
|
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
|
|
147
|
+
self.proc = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
148
|
+
stderr=subprocess.DEVNULL, **options)
|
|
149
|
+
self.messages: List[Dict[str, Any]] = []
|
|
150
|
+
self.cond = threading.Condition()
|
|
151
|
+
self.next_id = 0
|
|
152
|
+
self.tools_changed = False
|
|
153
|
+
self.reader = threading.Thread(target=self._read, daemon=True)
|
|
154
|
+
self.reader.start()
|
|
155
|
+
|
|
156
|
+
def _read(self) -> None:
|
|
157
|
+
assert self.proc.stdout is not None
|
|
158
|
+
for raw in self.proc.stdout:
|
|
159
|
+
try:
|
|
160
|
+
message = json.loads(raw.decode("utf-8"))
|
|
161
|
+
except ValueError:
|
|
162
|
+
continue
|
|
163
|
+
with self.cond:
|
|
164
|
+
if isinstance(message, dict) and message.get("method") == "notifications/tools/list_changed":
|
|
165
|
+
self.tools_changed = True
|
|
166
|
+
elif isinstance(message, dict):
|
|
167
|
+
self.messages.append(message)
|
|
168
|
+
self.cond.notify_all()
|
|
169
|
+
with self.cond:
|
|
170
|
+
self.cond.notify_all()
|
|
171
|
+
|
|
172
|
+
def _send(self, message: Dict[str, Any]) -> None:
|
|
173
|
+
assert self.proc.stdin is not None
|
|
174
|
+
self.proc.stdin.write((json.dumps(message) + "\n").encode("utf-8"))
|
|
175
|
+
self.proc.stdin.flush()
|
|
176
|
+
|
|
177
|
+
def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
|
|
178
|
+
self._send({"jsonrpc": "2.0", "method": method, **({"params": params} if params else {})})
|
|
179
|
+
|
|
180
|
+
def request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
181
|
+
self.next_id += 1
|
|
182
|
+
ident = self.next_id
|
|
183
|
+
self._send({"jsonrpc": "2.0", "id": ident, "method": method, "params": params})
|
|
184
|
+
with self.cond:
|
|
185
|
+
while True:
|
|
186
|
+
for message in self.messages:
|
|
187
|
+
if message.get("id") == ident:
|
|
188
|
+
self.messages.remove(message)
|
|
189
|
+
if "error" in message:
|
|
190
|
+
error = message["error"]
|
|
191
|
+
raise JsonRpcError(error.get("code") if isinstance(error, dict) else None)
|
|
192
|
+
return message.get("result") or {}
|
|
193
|
+
left = self.deadline - time.monotonic()
|
|
194
|
+
if left <= 0 or self.proc.poll() is not None:
|
|
195
|
+
raise TimeoutError("no answer to %s" % method)
|
|
196
|
+
self.cond.wait(min(left, 0.25))
|
|
197
|
+
|
|
198
|
+
def wait_tools_changed(self, seconds: float) -> None:
|
|
199
|
+
with self.cond:
|
|
200
|
+
if not self.tools_changed:
|
|
201
|
+
self.cond.wait(max(0.0, min(seconds, self.deadline - time.monotonic())))
|
|
202
|
+
self.tools_changed = False
|
|
203
|
+
|
|
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()))
|
|
208
|
+
try:
|
|
209
|
+
if self.proc.stdin:
|
|
210
|
+
self.proc.stdin.close()
|
|
211
|
+
except Exception: # noqa: BLE001
|
|
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":
|
|
228
|
+
try:
|
|
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):
|
|
232
|
+
pass
|
|
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
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _take_lock(directory: Path, wait_s: float = 0.0) -> bool:
|
|
244
|
+
"""One worker per session. A second one waits for the first (bounded), so an `end` entry spooled while a
|
|
245
|
+
worker was finishing is still delivered."""
|
|
246
|
+
lock = directory / "worker.lock"
|
|
247
|
+
until = time.monotonic() + wait_s
|
|
248
|
+
while True:
|
|
249
|
+
if _try_lock(lock, directory):
|
|
250
|
+
return True
|
|
251
|
+
if time.monotonic() >= until:
|
|
252
|
+
return False
|
|
253
|
+
time.sleep(0.2)
|
|
254
|
+
|
|
255
|
+
|
|
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."""
|
|
264
|
+
for _ in range(2):
|
|
265
|
+
try:
|
|
266
|
+
fd = os.open(str(lock), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
267
|
+
os.write(fd, str(os.getpid()).encode())
|
|
268
|
+
os.close(fd)
|
|
269
|
+
return True
|
|
270
|
+
except FileExistsError:
|
|
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):
|
|
298
|
+
try:
|
|
299
|
+
os.link(str(taken), str(lock)) # put the live lock back, unless a newer one is there already
|
|
300
|
+
except OSError:
|
|
301
|
+
pass
|
|
302
|
+
try:
|
|
303
|
+
os.unlink(str(taken))
|
|
304
|
+
except OSError:
|
|
305
|
+
pass
|
|
306
|
+
return False
|
|
307
|
+
try:
|
|
308
|
+
os.unlink(str(taken))
|
|
309
|
+
except OSError:
|
|
310
|
+
pass
|
|
311
|
+
return False
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _answer_json(result: Dict[str, Any]) -> Dict[str, Any]:
|
|
315
|
+
"""The JSON an answer carries: structuredContent, else its first text block parsed (the board's tools answer
|
|
316
|
+
that way). {} when it carries none."""
|
|
317
|
+
if isinstance(result.get("structuredContent"), dict):
|
|
318
|
+
return result["structuredContent"]
|
|
319
|
+
content = result.get("content")
|
|
320
|
+
if isinstance(content, list) and content and isinstance(content[0], dict) and isinstance(content[0].get("text"), str):
|
|
321
|
+
try:
|
|
322
|
+
value = json.loads(content[0]["text"])
|
|
323
|
+
except ValueError:
|
|
324
|
+
return {}
|
|
325
|
+
return value if isinstance(value, dict) else {}
|
|
326
|
+
return {}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _answer_said(result: Dict[str, Any]) -> str:
|
|
330
|
+
"""What an answer was, in a log-safe word: never its text."""
|
|
331
|
+
if not result.get("isError"):
|
|
332
|
+
return "ok"
|
|
333
|
+
error = _answer_json(result).get("error")
|
|
334
|
+
code = error.get("code") if isinstance(error, dict) and isinstance(error.get("code"), str) else None
|
|
335
|
+
return "refused:%s" % (code if code and re.fullmatch(r"[a-z0-9._-]{1,64}", code) else "unnamed")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def board_unread(result: Dict[str, Any]) -> Tuple[Optional[int], str]:
|
|
339
|
+
"""`com.status` (comms-status.schema.json) → (the unread count, a log-safe word). A board that is `unavailable`,
|
|
340
|
+
or an answer without a count, is None: "cannot be read" is never reported as zero. A `degraded` board that still
|
|
341
|
+
gives the reader's count is read (unread counts belong to the reader: the contract's project-comms.md)."""
|
|
342
|
+
said = _answer_said(result)
|
|
343
|
+
if said != "ok":
|
|
344
|
+
return None, "answer=" + said
|
|
345
|
+
doc = _answer_json(result)
|
|
346
|
+
board = doc.get("board") if isinstance(doc.get("board"), dict) else {}
|
|
347
|
+
unread = doc.get("unread")
|
|
348
|
+
state = board.get("state")
|
|
349
|
+
if state not in ("ready", "degraded") or not isinstance(unread, int) or isinstance(unread, bool) or unread < 0:
|
|
350
|
+
return None, "board=unavailable"
|
|
351
|
+
return unread, "answer=ok unread=%d%s" % (unread, " board=degraded" if state == "degraded" else "")
|
|
352
|
+
|
|
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
|
+
|
|
376
|
+
def deliver(directory: Path, env: Optional[Dict[str, str]] = None, deadline_s: float = WORKER_DEADLINE_S) -> List[str]:
|
|
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."""
|
|
381
|
+
env = dict(os.environ if env is None else env)
|
|
382
|
+
said: List[str] = []
|
|
383
|
+
|
|
384
|
+
def note(line: str) -> None:
|
|
385
|
+
said.append(line)
|
|
386
|
+
log(line, env)
|
|
387
|
+
|
|
388
|
+
session_id = directory.name
|
|
389
|
+
if not SESSION_ID.fullmatch(session_id) or not directory.is_dir():
|
|
390
|
+
return said
|
|
391
|
+
if not _take_lock(directory, wait_s=deadline_s + 2.0):
|
|
392
|
+
return said
|
|
393
|
+
client: Optional[McpStdio] = None
|
|
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
|
+
|
|
404
|
+
try:
|
|
405
|
+
entries = sorted(directory.glob("*-*.json"))
|
|
406
|
+
if not entries:
|
|
407
|
+
return said
|
|
408
|
+
cwd = _entry_cwd(entries)
|
|
409
|
+
registration = claude_registration(cwd, env)
|
|
410
|
+
hub = locate_hub(fabric_root(env=env))
|
|
411
|
+
if not registration or hub["state"] != "running" or HOOK_FEATURE not in hub.get("features", []):
|
|
412
|
+
note("session=%s outcome=dropped reason=%r" % (session_id[:8], "Fabric or its registration went away"))
|
|
413
|
+
for entry in entries:
|
|
414
|
+
entry.unlink()
|
|
415
|
+
ended = any(e.name.endswith("-end.json") for e in entries)
|
|
416
|
+
return said
|
|
417
|
+
deadline = time.monotonic() + deadline_s
|
|
418
|
+
child_env = dict(env, CLAUDE_CODE_SESSION_ID=session_id)
|
|
419
|
+
argv = [registration["command"]] + registration["args"] + ["--rejoin-only", "--runtime-session-id", session_id]
|
|
420
|
+
client = McpStdio(argv, str(cwd), child_env, deadline)
|
|
421
|
+
client.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
422
|
+
"clientInfo": {"name": "fabric-agent-adapter-hooks", "version": VERSION}})
|
|
423
|
+
client.notify("notifications/initialized")
|
|
424
|
+
names = _wait_for_session_tools(client, deadline)
|
|
425
|
+
_write_private(directory / "rejoined", "")
|
|
426
|
+
while entries:
|
|
427
|
+
for path in entries:
|
|
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"]
|
|
434
|
+
key = "%s:%s" % (session_id, path.name.split("-", 1)[0])
|
|
435
|
+
reason = entry.get("reason") if isinstance(entry.get("reason"), str) else "other"
|
|
436
|
+
if event == "end":
|
|
437
|
+
if RELEASE_TOOL in names:
|
|
438
|
+
call(event, RELEASE_TOOL, {
|
|
439
|
+
"claims": "session", "outcome": "let-go",
|
|
440
|
+
"reason": "the Claude Code session ended (%s)" % reason,
|
|
441
|
+
"idempotencyKey": key + ":release"})
|
|
442
|
+
else:
|
|
443
|
+
note("session=%s event=end outcome=degraded reason=%r" % (session_id[:8], "no %s" % RELEASE_TOOL))
|
|
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()
|
|
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
|
|
456
|
+
entries = sorted(directory.glob("*-*.json")) # turns spooled while this worker ran
|
|
457
|
+
if not ended and BOARD_STATUS_TOOL in names:
|
|
458
|
+
# The board's unread count for the prompt hook (FAA-12a). A count, never a message.
|
|
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()
|
|
463
|
+
_write_private(directory / "board.json", json.dumps({
|
|
464
|
+
"unread": unread, "observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}))
|
|
465
|
+
note("session=%s event=board tool=%s %s" % (session_id[:8], BOARD_STATUS_TOOL, said_board))
|
|
466
|
+
except TimeoutError as exc:
|
|
467
|
+
note("session=%s outcome=unknown reason=%r" % (session_id[:8], str(exc)))
|
|
468
|
+
except Exception as exc: # noqa: BLE001 — the worker never raises
|
|
469
|
+
note("session=%s outcome=failed reason=%r" % (session_id[:8], type(exc).__name__))
|
|
470
|
+
finally:
|
|
471
|
+
if client is not None:
|
|
472
|
+
client.close()
|
|
473
|
+
try:
|
|
474
|
+
(directory / "worker.lock").unlink()
|
|
475
|
+
except OSError:
|
|
476
|
+
pass
|
|
477
|
+
if ended:
|
|
478
|
+
_remove_tree(directory)
|
|
479
|
+
return said
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _wait_for_session_tools(client: McpStdio, deadline: float) -> List[str]:
|
|
483
|
+
"""fabric-mcp lists only fabric_whoami until it holds a session; then it says its tools changed."""
|
|
484
|
+
while True:
|
|
485
|
+
tools = client.request("tools/list", {}).get("tools") or []
|
|
486
|
+
names = [t.get("name") for t in tools if isinstance(t, dict) and isinstance(t.get("name"), str)]
|
|
487
|
+
if [n for n in names if n != WAITING_TOOL]:
|
|
488
|
+
return names
|
|
489
|
+
if time.monotonic() >= deadline:
|
|
490
|
+
raise TimeoutError("fabric-mcp did not join the session (not enrolled, or Fabric did not answer)")
|
|
491
|
+
client.wait_tools_changed(0.5)
|
|
@@ -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,12 +145,14 @@ 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'],
|
|
144
153
|
'pi':['hosts/context.mjs','hosts/pi/fabric.mjs'],
|
|
145
154
|
'openclaw':['hosts/context.mjs','hosts/openclaw/index.mjs','hosts/openclaw/package.json','hosts/openclaw/openclaw.plugin.json']}[args.host]
|
|
155
|
+
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
156
|
if any(not (script.parent/p).is_file() for p in required):
|
|
147
157
|
raise fs.Refused('native adapter source is missing; update the complete skill before installing hooks')
|
|
148
158
|
path=Path(args.config).expanduser().absolute() if args.config else destination(args.host,env)
|
|
@@ -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;
|