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

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