@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.
Files changed (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. 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
- if not script.is_file(): raise fs.Refused('shared helper is missing')
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' || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(stdout)) return resolve('');
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
@@ -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 contextReader('openclaw', ctx.workspaceDir, options);
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 };
@@ -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 { readContext } from '../context.mjs';
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;