@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.42.1

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