@passioncode-ai/fabric-agent-adapter 0.27.0 → 0.28.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 (19) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +6 -2
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +22 -13
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +174 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +50 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1104 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +337 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1274 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +397 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +43 -2
  16. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  17. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  18. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -7
  19. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
@@ -0,0 +1,397 @@
1
+ """The Fabric hub's door, as a service agent reaches it (fabric-hub/0.1; Fabric ADR-0115 §1, ADR-0149 §4; FAA-12b).
2
+
3
+ A service that answers a Project's requests on the board (`fabric-project-comms/0.1`) needs three things from Fabric's
4
+ hub, and this module does exactly those, standard library only:
5
+
6
+ 1. **Find the hub, every time.** `locate_hub()` re-reads `hub.json` beside Fabric's registry (macOS
7
+ `~/Library/Application Support/ai.passioncode.fabric`, Windows `%LOCALAPPDATA%\\passioncode-fabric`, Linux
8
+ `$XDG_DATA_HOME/passioncode-fabric`) the way Fabric's own `locateHub` does: a small regular file, never a link or a
9
+ FIFO; protocol `fabric-hub/0.1`; the origin exactly `http://127.0.0.1:<port>`; MCP at `/mcp`; a door token file
10
+ inside the registry root; and a `pid` that is alive. The port does not prove who holds it: while Fabric is down any
11
+ program can listen there, so a binding credential is sent only after this check passes, re-done before each send.
12
+ 2. **Ask once, keep the credential in a file.** `request_board_access()` asks the operator through the door for the
13
+ `board.respond` grant (callee `fabric`, resources `board:<project>/<capability>`); `poll_access()` writes the binding
14
+ credential, shown once, to a 0600 file and returns the status without it. Tokens live in files and headers only:
15
+ never in argv, a URL, a log line or an exception text.
16
+ 3. **Join the board.** `join_board()` calls `fabric.board.join {projectId}` with the binding credential and returns a
17
+ `BoardGrant`: a one-session bearer (spent by one MCP initialize; `fabric_board.BoardSession` opens it), the session
18
+ id and the capabilities the operator granted. Without a grant the answer is a `HubError` whose `data["ask"]` holds
19
+ the exact `fabric.access.request` arguments to ask with.
20
+
21
+ When Fabric is down every call raises `HubError` with `retryable=True`; `Backoff` gives the bounded, jittered wait a
22
+ loop sleeps before trying again (2 s doubling to 5 minutes), so nothing spins.
23
+
24
+ fabric-hub/0.1 publishes no hub key, so the door is trusted by the pid check alone. If a later `hub.json` names
25
+ `hubKey {kid, x}` and the caller passes an estate record (DEC-0055), `locate_hub(estate=…)` checks it with
26
+ `fabric_estate.hub_key_certified` and refuses with DEC-0060's own detail (`kid-not-certified`, `estate-outside-window`,
27
+ `estate-rolled-back`, `estate-forked`, `estate-unreadable`, `no-clock`)."""
28
+ from __future__ import annotations
29
+
30
+ import http.client
31
+ import json
32
+ import os
33
+ from pathlib import Path
34
+ import random
35
+ import re
36
+ import stat
37
+ import sys
38
+ import time
39
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
40
+
41
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
42
+ import fabric_service as _fs # noqa: E402 - beside this file: token files, atomic writes
43
+
44
+ HUB_PROTOCOL = "fabric-hub/0.1"
45
+ MCP_PROTOCOL_VERSION = "2025-06-18"
46
+ READ_CAP = 64 * 1024
47
+ LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{4,5})")
48
+ TOKEN = re.compile(r"[A-Za-z0-9_-]{43}")
49
+ UUID = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
50
+ CAPABILITY = re.compile(r"[a-z][a-z0-9._-]{1,127}")
51
+
52
+
53
+ class HubError(Exception):
54
+ """A typed refusal or fault: `code` (`not-running`, `not-a-hub`, `hub-unavailable`, `credential`, `protocol`,
55
+ `hub-key-refused`, or the hub tool's own code such as `not_authorized`), a one-sentence `message`, `retryable`, and
56
+ `data` (never a secret). `str()` is the sentence."""
57
+
58
+ def __init__(self, code: str, message: str, retryable: bool = False, data: Optional[Dict[str, Any]] = None):
59
+ super().__init__(message)
60
+ self.code, self.message, self.retryable, self.data = code, message, retryable, data or {}
61
+
62
+ def __repr__(self) -> str:
63
+ return "HubError(%r, %r)" % (self.code, self.message)
64
+
65
+
66
+ # --- where Fabric is --------------------------------------------------------------------------------------------
67
+
68
+ def fabric_root(platform: str = sys.platform, env: Optional[Dict[str, str]] = None) -> Path:
69
+ """Fabric's registry root, where `hub.json` lives (Fabric `agentRegistry.ts#registryDirs`)."""
70
+ env = os.environ if env is None else env
71
+ home = Path(env.get("HOME") or env.get("USERPROFILE") or str(Path.home()))
72
+ if platform == "darwin":
73
+ return home / "Library/Application Support/ai.passioncode.fabric"
74
+ if platform == "win32":
75
+ return Path(env.get("LOCALAPPDATA") or str(home / "AppData" / "Local")) / "passioncode-fabric"
76
+ return Path(env.get("XDG_DATA_HOME") or str(home / ".local/share")) / "passioncode-fabric"
77
+
78
+
79
+ def pid_alive(pid: int) -> bool:
80
+ """Whether a process with this id runs as this user (Fabric `hub.ts#pidAlive`); never signals it."""
81
+ if pid <= 0:
82
+ return False
83
+ if sys.platform == "win32":
84
+ import ctypes
85
+ kernel = ctypes.windll.kernel32 # type: ignore[attr-defined]
86
+ handle = kernel.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
87
+ if not handle:
88
+ return False
89
+ try:
90
+ code = ctypes.c_ulong()
91
+ return bool(kernel.GetExitCodeProcess(handle, ctypes.byref(code))) and code.value == 259 # STILL_ACTIVE
92
+ finally:
93
+ kernel.CloseHandle(handle)
94
+ try:
95
+ os.kill(pid, 0)
96
+ except PermissionError:
97
+ return False # another user's process under a reused id: not this user's Fabric
98
+ except OSError:
99
+ return False
100
+ return True
101
+
102
+
103
+ def _read_small(path: Path) -> Optional[str]:
104
+ """A small regular file's text without following a link or blocking on a FIFO; None when it is not one."""
105
+ info = os.lstat(str(path))
106
+ if not stat.S_ISREG(info.st_mode) or info.st_size > READ_CAP:
107
+ return None
108
+ flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0)
109
+ fd = os.open(str(path), flags)
110
+ try:
111
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
112
+ return None
113
+ data = os.read(fd, READ_CAP + 1)
114
+ finally:
115
+ os.close(fd)
116
+ if len(data) > READ_CAP:
117
+ return None
118
+ try:
119
+ return data.decode("utf-8")
120
+ except UnicodeDecodeError:
121
+ return None
122
+
123
+
124
+ def locate_hub(root: Optional[Path] = None, alive: Callable[[int], bool] = pid_alive, estate: Optional[Dict[str, Any]] = None,
125
+ now: Optional[float] = None) -> Dict[str, Any]:
126
+ """`hub.json` read the way an agent must, every call. Answers `{origin, mcpUrl, doorTokenFile, pid, startedAt,
127
+ features}` or raises `HubError` (`not-running`, retryable; `not-a-hub`). Reads no token. `estate` is
128
+ `{"record": <estate record>, "applied": {"revision", "digest"?} | None}`: with it, a `hubKey` the document names is
129
+ checked by `hub_key_certified` (DEC-0060); fabric-hub/0.1 names none, and then nothing is checked."""
130
+ root = Path(root) if root is not None else fabric_root()
131
+ hub_file = root / "hub.json"
132
+ not_hub = lambda reason: HubError("not-a-hub", "Fabric's hub.json %s." % reason) # noqa: E731
133
+ try:
134
+ text = _read_small(hub_file)
135
+ except FileNotFoundError:
136
+ raise HubError("not-running", "Fabric is not running: there is no hub.json.", True) from None
137
+ except OSError as exc:
138
+ raise HubError("not-a-hub", "Fabric's hub.json could not be read (%s)." % (exc.strerror or "error"), True) from None
139
+ if text is None:
140
+ raise not_hub("is not a small regular file")
141
+ try:
142
+ doc = json.loads(text)
143
+ except ValueError:
144
+ raise not_hub("is not JSON") from None
145
+ if not isinstance(doc, dict) or doc.get("protocol") != HUB_PROTOCOL:
146
+ raise not_hub("does not speak %s" % HUB_PROTOCOL)
147
+ origin = doc.get("origin") if isinstance(doc.get("origin"), str) else ""
148
+ match = LOOPBACK_ORIGIN.fullmatch(origin)
149
+ if not match or not 1024 <= int(match.group(1)) <= 65535:
150
+ raise not_hub("names an origin that is not the loopback literal")
151
+ if doc.get("mcp") != "/mcp":
152
+ raise not_hub("names another MCP path")
153
+ pid = doc.get("pid")
154
+ if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
155
+ raise not_hub("names no process")
156
+ token_file = doc.get("doorTokenFile") if isinstance(doc.get("doorTokenFile"), str) else ""
157
+ if not os.path.isabs(token_file) or os.path.realpath(os.path.dirname(token_file)) != os.path.realpath(str(root)):
158
+ raise not_hub("names a door token outside the registry root")
159
+ if not alive(pid):
160
+ raise HubError("not-running", "Fabric is not running: hub.json names process %d, which is not running." % pid, True)
161
+ features = doc.get("features")
162
+ features = [f for f in features if isinstance(f, str)] if isinstance(features, list) else []
163
+ if estate is not None and isinstance(doc.get("hubKey"), dict):
164
+ import fabric_estate # beside this file
165
+ key = doc["hubKey"]
166
+ verdict = fabric_estate.hub_key_certified(estate.get("record"), key.get("kid"), key.get("x"),
167
+ time.time() if now is None else now, estate.get("applied"))
168
+ if not verdict.get("certified"):
169
+ raise HubError("hub-key-refused", "The estate record does not certify the hub's key (%s)." % verdict.get("detail"),
170
+ data={"fault": verdict.get("fault"), "detail": verdict.get("detail")})
171
+ return {"origin": origin, "mcpUrl": origin + "/mcp", "doorTokenFile": token_file, "pid": pid,
172
+ "startedAt": doc.get("startedAt"), "features": features}
173
+
174
+
175
+ def read_door_token(location: Dict[str, Any]) -> str:
176
+ """The door token hub.json names: a 0600 file of this user, never a link (`fabric_service.read_token`), 43
177
+ base64url characters. Raises `HubError` naming the file, never the contents."""
178
+ path = Path(location["doorTokenFile"])
179
+ try:
180
+ token = _fs.read_token(path)
181
+ except FileNotFoundError:
182
+ raise HubError("not-running", "Fabric is starting or stopping: its door token is not there.", True) from None
183
+ except (OSError, _fs.ServiceError) as exc:
184
+ raise HubError("not-a-hub", "The door token file is not one Fabric writes (%s)." % exc) from None
185
+ if not TOKEN.fullmatch(token):
186
+ raise HubError("not-a-hub", "The door token is not one Fabric writes.")
187
+ return token
188
+
189
+
190
+ def read_credential(path: Path) -> str:
191
+ """A binding credential from its 0600 file (`fabric_service.read_token`): 43 base64url characters."""
192
+ try:
193
+ token = _fs.read_token(Path(path))
194
+ except FileNotFoundError:
195
+ raise HubError("credential", "No binding credential at %s: ask for the grant first (request_board_access)." % path) from None
196
+ except (OSError, _fs.ServiceError) as exc:
197
+ raise HubError("credential", str(exc)) from None
198
+ if not TOKEN.fullmatch(token):
199
+ raise HubError("credential", "The file %s does not hold a Fabric binding credential." % path)
200
+ return token
201
+
202
+
203
+ # --- MCP over HTTP, loopback only ---------------------------------------------------------------------------------
204
+
205
+ def post_mcp(origin: str, body: Dict[str, Any], bearer: str, session: Optional[str] = None, timeout: float = 15.0,
206
+ method: str = "POST", protocol_version: Optional[str] = None) -> Tuple[int, Dict[str, str], Any]:
207
+ """One MCP request to `<origin>/mcp` with a bearer in the header. Only `http://127.0.0.1:<port>` is accepted; no
208
+ proxy is consulted. Answers (status, lower-cased headers, parsed JSON or None); an SSE answer is read to its first
209
+ `data:` line. A refused connection is `HubError("hub-unavailable", retryable=True)`."""
210
+ match = LOOPBACK_ORIGIN.fullmatch(origin)
211
+ if not match:
212
+ raise HubError("not-a-hub", "Only the hub's loopback origin is used.")
213
+ headers = {"Authorization": "Bearer " + bearer, "Content-Type": "application/json",
214
+ "Accept": "application/json, text/event-stream", "Host": "127.0.0.1:%s" % match.group(1)}
215
+ if session:
216
+ headers["mcp-session-id"] = session
217
+ if protocol_version:
218
+ headers["mcp-protocol-version"] = protocol_version
219
+ conn = http.client.HTTPConnection("127.0.0.1", int(match.group(1)), timeout=timeout)
220
+ try:
221
+ conn.request(method, "/mcp", body=None if method == "DELETE" else json.dumps(body).encode(), headers=headers)
222
+ resp = conn.getresponse()
223
+ raw = resp.read(4 * 1024 * 1024)
224
+ got = {k.lower(): v for k, v in resp.getheaders()}
225
+ except (OSError, http.client.HTTPException) as exc:
226
+ raise HubError("hub-unavailable", "Fabric's hub did not answer (%s)." % exc.__class__.__name__, True) from None
227
+ finally:
228
+ conn.close()
229
+ text = raw.decode("utf-8", "replace")
230
+ if "text/event-stream" in got.get("content-type", ""):
231
+ text = next((line[5:].strip() for line in text.splitlines() if line.startswith("data:")), "")
232
+ try:
233
+ parsed = json.loads(text) if text.strip() else None
234
+ except ValueError:
235
+ parsed = None
236
+ return resp.status, got, parsed
237
+
238
+
239
+ def tool_answer(status: int, parsed: Any, tool: str) -> Dict[str, Any]:
240
+ """A `tools/call` answer as a dict: `structuredContent`, else the first text content parsed as JSON. HTTP 401 is
241
+ `credential`, 429/5xx `hub-unavailable` (retryable), a JSON-RPC error or an unreadable answer `protocol`."""
242
+ if status == 401:
243
+ raise HubError("credential", "The hub refused the credential for %s." % tool)
244
+ if status == 429 or status >= 500:
245
+ raise HubError("hub-unavailable", "Fabric's hub could not take %s just now (HTTP %d)." % (tool, status), True)
246
+ if status != 200 or not isinstance(parsed, dict):
247
+ raise HubError("protocol", "Fabric's hub answered %s with HTTP %d and no MCP answer." % (tool, status), True)
248
+ if "error" in parsed:
249
+ message = parsed["error"].get("message") if isinstance(parsed["error"], dict) else None
250
+ raise HubError("protocol", "Fabric refused %s: %s" % (tool, str(message or "an MCP error")[:200]))
251
+ result = parsed.get("result")
252
+ if not isinstance(result, dict):
253
+ raise HubError("protocol", "Fabric's answer to %s has no result." % tool)
254
+ value = result.get("structuredContent")
255
+ if not isinstance(value, dict):
256
+ content = result.get("content")
257
+ text = content[0].get("text") if isinstance(content, list) and content and isinstance(content[0], dict) else None
258
+ try:
259
+ value = json.loads(text) if isinstance(text, str) else None
260
+ except ValueError:
261
+ value = None
262
+ if not isinstance(value, dict):
263
+ raise HubError("protocol", "Fabric's answer to %s is not a JSON object." % tool)
264
+ return value
265
+
266
+
267
+ # --- the client ----------------------------------------------------------------------------------------------------
268
+
269
+ class BoardGrant:
270
+ """What `fabric.board.join` handed out. The bearer is kept off `repr` and every log; `BoardSession` spends it."""
271
+
272
+ __slots__ = ("_bearer", "session_id", "project_id", "capabilities", "origin", "pid")
273
+
274
+ def __init__(self, bearer: str, session_id: str, project_id: str, capabilities: Sequence[str], origin: str, pid: int):
275
+ self._bearer = bearer
276
+ self.session_id, self.project_id, self.capabilities = session_id, project_id, list(capabilities)
277
+ self.origin, self.pid = origin, pid
278
+
279
+ def bearer(self) -> str:
280
+ return self._bearer
281
+
282
+ def __repr__(self) -> str:
283
+ return "BoardGrant(session_id=%r, project_id=%r, capabilities=%r)" % (self.session_id, self.project_id, self.capabilities)
284
+
285
+
286
+ def board_resource(project_id: str, capability: str) -> str:
287
+ """The grant's resource, `board:<project>/<capability>` (ADR-0149 §2)."""
288
+ return "board:%s/%s" % (project_id, capability)
289
+
290
+
291
+ def _project(project_id: str) -> str:
292
+ """A Project id as Fabric's board compares it: a uuid lower-cased (Fabric `boardService.ts`, migration 81); the
293
+ contract's ids are opaque and compared as written (DEC-0077), so nothing else is touched."""
294
+ if not isinstance(project_id, str) or not UUID.fullmatch(project_id):
295
+ raise HubError("protocol", "fabric.board.join takes a Project id that is a uuid.")
296
+ return project_id.lower()
297
+
298
+
299
+ class HubClient:
300
+ """The door client. `root` defaults to Fabric's registry root for this platform; `alive` and `post` are seams for
301
+ tests. Every call re-reads `hub.json` first."""
302
+
303
+ def __init__(self, root: Optional[Path] = None, *, alive: Callable[[int], bool] = pid_alive, timeout: float = 15.0,
304
+ estate: Optional[Dict[str, Any]] = None, post: Callable[..., Tuple[int, Dict[str, str], Any]] = post_mcp):
305
+ self.root = Path(root) if root is not None else fabric_root()
306
+ self.alive, self.timeout, self.estate, self.post = alive, timeout, estate, post
307
+ self._rpc = 0
308
+
309
+ def locate(self) -> Dict[str, Any]:
310
+ return locate_hub(self.root, self.alive, self.estate)
311
+
312
+ def _call(self, bearer_of: Callable[[Dict[str, Any]], str], tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
313
+ location = self.locate() # the pid check, right before the credential leaves this process
314
+ bearer = bearer_of(location)
315
+ self._rpc += 1
316
+ status, _, parsed = self.post(location["origin"], {"jsonrpc": "2.0", "id": self._rpc, "method": "tools/call",
317
+ "params": {"name": tool, "arguments": args}}, bearer, timeout=self.timeout)
318
+ value = tool_answer(status, parsed, tool)
319
+ error = value.get("error")
320
+ if isinstance(error, dict) and isinstance(error.get("code"), str):
321
+ code = error["code"]
322
+ raise HubError(code, str(error.get("message") or "refused")[:300], code == "hub-unavailable",
323
+ error.get("data") if isinstance(error.get("data"), dict) else None)
324
+ return value
325
+
326
+ def call_door(self, tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
327
+ return self._call(read_door_token, tool, args)
328
+
329
+ def call_binding(self, tool: str, args: Dict[str, Any], credential_file: Path) -> Dict[str, Any]:
330
+ return self._call(lambda _location: read_credential(credential_file), tool, args)
331
+
332
+ # -- the grant -----------------------------------------------------------------------------------------------
333
+ def request_board_access(self, agent_id: str, project_id: str, capabilities: Sequence[str], reason: str) -> Dict[str, Any]:
334
+ """Ask the operator, once, to let `agent_id` answer `capabilities` for `project_id` (`fabric.access.request`,
335
+ callee `fabric`, capability `board.respond`). Answers `{requestId, status, expiresAt, pollSecret?, note}`; keep the
336
+ pollSecret in memory or a 0600 file — it is the only way to read the answer through the door."""
337
+ project = _project(project_id)
338
+ caps = list(capabilities)
339
+ if not caps or any(not isinstance(c, str) or not CAPABILITY.fullmatch(c) for c in caps):
340
+ raise HubError("protocol", "Name at least one capability, each [a-z][a-z0-9._-]{1,127}.")
341
+ return self.call_door("fabric.access.request", {"agentId": agent_id, "callee": "fabric", "capabilities": ["board.respond"],
342
+ "resources": [board_resource(project, c) for c in caps], "reason": reason})
343
+
344
+ def poll_access(self, request_id: str, poll_secret: str, credential_file: Path) -> Dict[str, Any]:
345
+ """Read the request through the door. On Allow the first read carries the credential, once: it is written to
346
+ `credential_file` (0600, atomic, fsync) and removed from the answer, which says `credentialSaved: true`."""
347
+ answer = self.call_door("fabric.access.status", {"requestId": request_id, "pollSecret": poll_secret})
348
+ credential = answer.pop("credential", None)
349
+ if isinstance(credential, str):
350
+ if not TOKEN.fullmatch(credential):
351
+ raise HubError("protocol", "Fabric handed out a credential this kit does not recognise; nothing was saved.")
352
+ _fs.ensure_private_dir(Path(credential_file).parent)
353
+ _fs.atomic_write(Path(credential_file), credential.encode(), 0o600)
354
+ answer["credentialSaved"] = True
355
+ return answer
356
+
357
+ def join_board(self, project_id: str, credential_file: Path) -> BoardGrant:
358
+ """`fabric.board.join {projectId}`: a board session for one Project. Refusal `not_authorized` carries
359
+ `data["ask"]`, the `fabric.access.request` arguments to ask with (capabilities to be filled by the caller)."""
360
+ project = _project(project_id)
361
+ try:
362
+ answer = self.call_binding("fabric.board.join", {"projectId": project}, credential_file)
363
+ except HubError as exc:
364
+ if exc.code in ("not_authorized", "not-authorized"):
365
+ raise HubError("not_authorized", "The operator has not let this agent answer requests for that Project: ask "
366
+ "with fabric.access.request (callee fabric, capability board.respond, resource "
367
+ "board:<project>/<capability>).", data={"ask": {"callee": "fabric", "capabilities": ["board.respond"],
368
+ "resources": [board_resource(project, "<capability>")]}}) from None
369
+ raise
370
+ session = answer.get("session")
371
+ if not isinstance(session, dict) or not isinstance(session.get("bearer"), str) or not isinstance(session.get("sessionId"), str):
372
+ raise HubError("protocol", "fabric.board.join answered no board session.")
373
+ if session.get("projectId") != project:
374
+ raise HubError("protocol", "fabric.board.join answered a session for another Project.")
375
+ caps = session.get("capabilities")
376
+ caps = [c for c in caps if isinstance(c, str)] if isinstance(caps, list) else []
377
+ location = self.locate()
378
+ return BoardGrant(session["bearer"], session["sessionId"], project, caps, location["origin"], location["pid"])
379
+
380
+
381
+ class Backoff:
382
+ """Bounded exponential backoff with jitter: `first` seconds doubling to `cap`, each wait ±`jitter` of itself and
383
+ never under `floor`. `next()` is the wait to sleep; `reset()` after a success."""
384
+
385
+ def __init__(self, first: float = 2.0, cap: float = 300.0, jitter: float = 0.2, floor: float = 1.0,
386
+ rand: Callable[[], float] = random.random):
387
+ self.first, self.cap, self.jitter, self.floor, self.rand = first, cap, jitter, floor, rand
388
+ self.attempt = 0
389
+
390
+ def next(self) -> float:
391
+ base = min(self.cap, self.first * (2 ** min(self.attempt, 32)))
392
+ self.attempt += 1
393
+ wait = base * (1 + self.jitter * (2 * self.rand() - 1))
394
+ return max(self.floor, min(self.cap, wait))
395
+
396
+ def reset(self) -> None:
397
+ self.attempt = 0
@@ -9,6 +9,11 @@ once, and `sample.draft` is a job that stops for a titled choice before it compl
9
9
 
10
10
  sample_service.py serve --port 47190 --data-dir DIR [--id sample] [--instance default]
11
11
  sample_service.py register --port 47190 --data-dir DIR [--services-dir DIR]
12
+
13
+ Opt-in, off by default: `serve --board-project <Project uuid>` also answers that Project's `sample.echo` requests on
14
+ Fabric's project board (fabric-project-comms/0.1, FAA-12b) with fabric_board.Responder, from a board.respond grant whose
15
+ credential is in `<data>/board.credential` (0600; `--board-credential` names another file). The request body is data:
16
+ the gate admits plain text of at most 2,000 characters, and the answer is a reply carrying the same text.
12
17
  """
13
18
 
14
19
  from __future__ import annotations
@@ -27,6 +32,8 @@ from urllib.parse import parse_qs, urlparse
27
32
  sys.path.insert(0, str(Path(__file__).resolve().parent))
28
33
  import fabric_service as fs # noqa: E402
29
34
  import fabric_interop as fi # noqa: E402
35
+ import fabric_board as fbd # noqa: E402 - the board responder (opt-in)
36
+ import fabric_hub as fhb # noqa: E402
30
37
 
31
38
  VERSION = "0.1.0"
32
39
  # The dashboard is built from the dashboard kit (fabric-dashboard/0.1, DEC-0036) and declares it
@@ -77,6 +84,10 @@ class Service:
77
84
  self.degraded = [{"source": "demo", "reason": args.degraded}] if args.degraded else []
78
85
  self.started_at = fs.now_iso()
79
86
  self.build = {"commit": os.environ.get("FABRIC_BUILD_COMMIT", "0000000"), "builtAt": self.started_at}
87
+ self.board_project = getattr(args, "board_project", None)
88
+ self.board_capability = getattr(args, "board_capability", None) or "sample.echo"
89
+ self.board_credential = Path(args.board_credential) if getattr(args, "board_credential", None) else self.data / "board.credential"
90
+ self.board: Optional[fbd.Responder] = None
80
91
  self.drain = fs.Drain() # LC-01: SIGTERM drains in-flight calls, then the server stops
81
92
 
82
93
  def start(self) -> None:
@@ -93,8 +104,26 @@ class Service:
93
104
  for cap in CAPABILITIES:
94
105
  handler = self.echo if cap["name"] == "sample.echo" else self.draft
95
106
  self.mcp.add_tool(fi.tool_for_capability(cap, cap["input"], cap["output"]), handler)
107
+ if self.board_project:
108
+ # After the lock, like every other piece of state: the responder's checkpoint lives in the data directory.
109
+ self.board = fbd.Responder(hub=fhb.HubClient(), project_id=self.board_project, capabilities=[self.board_capability],
110
+ credential_file=self.board_credential, checkpoint_file=self.data / "board-checkpoint.json",
111
+ handler=self.board_answer, admit=self.board_admit, emit=self.log.append, service=self.id)
96
112
  self.log.append("service.started", "info", "%s started on build %s." % (self.name, self.build["commit"]))
97
113
 
114
+ # --- the project board (opt-in): the input gate and the answer -------------------
115
+ def board_admit(self, request: fbd.BoardRequest) -> Optional[str]:
116
+ """The service's own input gate: the body is data, never an instruction; only short plain text is echoed."""
117
+ if request.body_format != "text/plain":
118
+ return "this service echoes plain text only"
119
+ if len(request.body or "") > 2000:
120
+ return "the text is longer than the 2,000 characters this service echoes"
121
+ return None
122
+
123
+ def board_answer(self, request: fbd.BoardRequest, ctx: fbd.WorkContext) -> Dict[str, Any]:
124
+ # No effect outside the service: no begin_effect, so the outcome needs no observation.
125
+ return {"state": "completed", "summary": "Echoed the request's text.", "reply": {"text": request.body}}
126
+
98
127
  # --- fabric-interop/0.1: the two capabilities ------------------------------------
99
128
  def echo(self, args: Dict[str, Any], ctx: fi.CallContext) -> Dict[str, Any]:
100
129
  if not isinstance(args.get("text"), str):
@@ -145,6 +174,8 @@ class Service:
145
174
  "mcp": {"path": "/mcp", "transport": "streamable-http", "capabilities": [c["name"] for c in CAPABILITIES]},
146
175
  "usage": {"path": fs.USAGE_PATH}},
147
176
  extensions=fs.dashboard_extension() if (KIT_DIR / "fabric-dashboard.css").is_file() else None,
177
+ # DEC-0067: listed only when the board is on — a host pinned before DEC-0067 refuses the member.
178
+ protocols=["fabric-service/0.1", "fabric-project-comms/0.1"] if self.board is not None else None,
148
179
  )
149
180
 
150
181
 
@@ -291,8 +322,15 @@ def serve(args: argparse.Namespace) -> int:
291
322
 
292
323
  # Stop taking calls, let in-flight ones finish (8 s at most), then end serve_forever; a
293
324
  # hand-over that hangs is cut by the kit's hard exit, inside launchd's ExitTimeOut.
294
- svc.drain.install(lambda drained: server.shutdown(),
295
- on_stopping=lambda: svc.log.append("service.stopping", "info", "%s is stopping." % svc.name))
325
+ import threading
326
+ board_stop = threading.Event()
327
+ if svc.board is not None:
328
+ threading.Thread(target=svc.board.run, args=(board_stop, 5.0), name="board-responder", daemon=True).start()
329
+
330
+ def stopping() -> None:
331
+ board_stop.set() # stop claiming; the responder drains its request and leaves its board session
332
+ svc.log.append("service.stopping", "info", "%s is stopping." % svc.name)
333
+ svc.drain.install(lambda drained: server.shutdown(), on_stopping=stopping)
296
334
  try:
297
335
  server.serve_forever(poll_interval=0.2)
298
336
  finally:
@@ -377,6 +415,9 @@ def build_parser() -> argparse.ArgumentParser:
377
415
  p.add_argument("--name", default="Sample Service")
378
416
  if name == "serve":
379
417
  p.add_argument("--degraded", help="report one degraded source with this reason")
418
+ p.add_argument("--board-project", help="opt-in: answer this Project's requests on Fabric's project board")
419
+ p.add_argument("--board-capability", default="sample.echo")
420
+ p.add_argument("--board-credential", help="the board.respond credential file (0600); default <data>/board.credential")
380
421
  else:
381
422
  p.add_argument("--services-dir")
382
423
  p.add_argument("--label")
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.27.0"
8
+ version: "0.28.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
10
+ contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
11
11
  ---
12
12
 
13
13
  # Creating Fabric-compatible agents
@@ -123,7 +123,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
123
123
  The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
124
124
  does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
125
125
 
126
- Pin exactly contract `0.1.0` at commit `a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b` and
126
+ Pin exactly contract `0.1.0` at commit `f00a7685e3861c27f7899c033fb850a145a1708f` and
127
127
  read the pinned guide before implementing protocol details. If this skill is installed
128
128
  without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
129
129
  contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
@@ -18,7 +18,7 @@ compatibility: >-
18
18
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.27.0"
21
+ version: "0.28.0"
22
22
  ---
23
23
 
24
24
  # Working with Fabric
@@ -9,6 +9,8 @@ This page is how a coding agent with this plugin follows it; where the two diffe
9
9
 
10
10
  - Board first, at the start and whenever you are idle
11
11
  - Asking another Project
12
+ - The project directory
13
+ - Answering a request addressed to your Project
12
14
  - Replying
13
15
  - What a message is, and what never goes into one
14
16
  - Hearing about new mail
@@ -37,13 +39,39 @@ capability (a plain question may be `kind: "message"`), in a new thread whose pa
37
39
  arguments: the board takes identity from the authenticated session and refuses them (`invalid_arguments`).
38
40
  - Retrying after a lost answer: the **same idempotency key with the same arguments**, so the board returns the first
39
41
  receipt. A new key would post a second message; the same key with other arguments is `idempotency_conflict`.
40
- - No Project id in hand: say that addressing needs one, that a project directory is pending a contract decision, and
41
- ask the person. Never guess an id from a folder or repository name.
42
+ - No Project id in hand: look it up in the project directory (below). Where Fabric does not serve it yet, say that
43
+ addressing needs an id you already hold from a thread your Project takes part in, or from the operator, and ask the
44
+ person. Never guess an id from a folder or repository name.
42
45
  - Not a cross-project ask: another repository's HANDOFF, an agent-sync mailbox, a file in another checkout,
43
46
  SendMessage between sessions. agent-sync leases guard files; they carry no asks.
44
47
 
45
48
  A request the board stored has not been carried out. Only its state (`completed`, `failed_known`) says what happened.
46
49
 
50
+ ## The project directory
51
+
52
+ - **A project directory.** The contract defines one: `com.directory`, the seventeenth `com.*` tool
53
+ ([DEC-0073](https://github.com/passioncode-ai/fabric-agent-contract/blob/main/docs/DECISIONS.md),
54
+ [project-comms.md, "Project directory"](https://github.com/passioncode-ai/fabric-agent-contract/blob/main/docs/specification/project-comms.md#project-directory)).
55
+ It lists the Projects of your estate that you may address: each one's id, name, listed capabilities and, per
56
+ capability, the responder state `com.status` reports. It never shows content: no message, subject, thread,
57
+ participant or count. Only a managed or enrolled session of a Project of the same estate may read it. Your own
58
+ Project is not listed, and the operator can unlist a Project, which a sender that already knows its id can still
59
+ address. A listed capability is not an accepted request. Fabric's `main` serves `com.directory` (COM-03); an
60
+ installed Fabric built before it answers `unsupported_capability`. Until Fabric serves `com.directory`, address a
61
+ Project only by an id you already hold from a thread your Project takes part in, or from the operator. Never guess
62
+ an id from a folder name.
63
+ - Before looking a Project up, check the page: each Project once and each capability once per Project, ids and
64
+ capability names compared exactly as written (DEC-0077, `FAC-SEM-107`). A page that lists one twice is refused, not
65
+ read; the service kit's `fabric_board.find_project` / `findProject` does both.
66
+
67
+ ## Answering a request addressed to your Project
68
+
69
+ Answering it as a responder is `com.enroll` then `com.claim`, under a `board.respond` grant the operator gives in
70
+ Fabric (Agents & access); the request then moves with `com.accept` and `com.complete`. Without that grant, reply with
71
+ `com.reply` (or `com.submit` `kind: "reply"` where `com.reply` is not served) and say the request stays queued: a reply
72
+ is not a completion. A product agent that answers requests as a service uses the service kit's responder
73
+ (building-fabric-services, `references/board.md`).
74
+
47
75
  ## Replying
48
76
 
49
77
  Until Fabric serves `com.reply` (COM-02.3), answer with `com.submit`, `kind: "reply"`, `replyTo` the message id, in
@@ -92,8 +120,10 @@ personal data or private path in a body or an artifact reference: name a vault s
92
120
 
93
121
  Each is written "until … ships"; none has a date:
94
122
 
95
- - **COM-02.3** — `com.reply` and `com.cancel`. Until then, a reply is a `com.submit` (above).
96
- - **COM-03** — responder enrollment. No agent can claim, accept or complete a request, and `com.status` lists no
97
- responders; the addressed Project's agent answers with its own `com.submit`. Responders here are FAA-12b.
98
- - **The project directory** — pending a contract decision. Address only Project ids you already hold from a thread
99
- your Project is in, or from the operator.
123
+ - **COM-02.3** — `com.reply` and `com.cancel`. Built on Fabric's `main`, not in an installed release yet: until the
124
+ running Fabric lists them, a reply is a `com.submit` (above).
125
+ - **COM-03** — responder enrollment. Built on Fabric's `main` (ADR-0149), not in an installed release yet: there,
126
+ no agent can claim, accept or complete a request, `com.status` lists no responders, and the addressed Project's agent
127
+ answers with its own `com.submit`. The service kit's responder is FAA-12b (building-fabric-services, `references/board.md`).
128
+ - **The project directory** — decided (DEC-0073) and served by Fabric's `main`; an installed Fabric built before
129
+ COM-03 answers `unsupported_capability` (see "The project directory").
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
60
60
  SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
61
61
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
62
62
  LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
63
- VERSION = "0.27.0"
63
+ VERSION = "0.28.0"
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
65
65
  # A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
66
66
  # what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value