@passioncode-ai/fabric-agent-adapter 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +6 -2
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +22 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +174 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +50 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +337 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1274 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +397 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +43 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +20 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +37 -7
- 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
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py
CHANGED
|
@@ -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
|
-
|
|
295
|
-
|
|
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.
|
|
8
|
+
version: "0.29.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
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 `
|
|
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
|
|
@@ -7,7 +7,8 @@ description: >-
|
|
|
7
7
|
com.submit; unread-mail lines, watch-mail), claiming and releasing work (fabric_work_claim, fabric_work_release),
|
|
8
8
|
reports, handoffs, refusals. Triggers - "connect to Fabric", "register fabric-mcp", "claim the task in Fabric",
|
|
9
9
|
"check the project board", "ask the other project through Fabric", "Fabric is not running", «подключись к
|
|
10
|
-
Fabric», «возьми задачу в Fabric», «проверь доску проекта»,
|
|
10
|
+
Fabric», «возьми задачу в Fabric», «проверь доску проекта»,
|
|
11
|
+
«Fabric не запущен», "hit your limit, resets at". Explains the plugin's session
|
|
11
12
|
hooks. NOT for building or adapting an agent or a board responder (creating-fabric-agents,
|
|
12
13
|
adapting-projects-to-fabric, building-fabric-services), changing Fabric's own code, or messaging people.
|
|
13
14
|
license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
@@ -18,7 +19,7 @@ compatibility: >-
|
|
|
18
19
|
this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
|
|
19
20
|
metadata:
|
|
20
21
|
author: PassionCode.ai
|
|
21
|
-
version: "0.
|
|
22
|
+
version: "0.29.0"
|
|
22
23
|
---
|
|
23
24
|
|
|
24
25
|
# Working with Fabric
|
|
@@ -146,6 +147,23 @@ A Fabric older than 0.4.0 has no `fabric_work_*` tools: use `fabric_tasks_list`
|
|
|
146
147
|
- `fabric_question_ask` for a decision you are not entitled to make; `fabric_effect_request` before spending,
|
|
147
148
|
deleting or publishing — refused until a person agrees.
|
|
148
149
|
|
|
150
|
+
### A lane stopped on a limit: restart it at once
|
|
151
|
+
|
|
152
|
+
A workflow lane, subagent or session can stop on *You've hit your session / usage limit · resets <time>* or a
|
|
153
|
+
rate-limit error. It can also go silent: no transcript write for 15–20 minutes, its task unfinished, and no command
|
|
154
|
+
of its own still running. In each case, do not wait for the reset ([principle 10, item 6](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/principles.md#10-a-free-agent-takes-the-next-task)).
|
|
155
|
+
Restart it from where it stopped:
|
|
156
|
+
|
|
157
|
+
- a workflow: stop it (`TaskStop`), then call `Workflow` with `resumeFromRunId` and **the same args**. Finished
|
|
158
|
+
agents replay from the cache; the failed or stalled ones run again;
|
|
159
|
+
- a subagent: `SendMessage` «resume» to its id, so it keeps its context;
|
|
160
|
+
- a session: re-prompt it with «continue from where you stopped».
|
|
161
|
+
|
|
162
|
+
The stop is usually stale by then. Switchboard moves a limited account to the pool's next account within a minute
|
|
163
|
+
(SB-98). If a restart stops again at once, retry after 2, 5, 10, then every 15 minutes, and do other work meanwhile.
|
|
164
|
+
A spend, credit or billing limit is not retried on a timer: it goes to the operator once, as a payment gate. A long
|
|
165
|
+
build or test in flight is not a stall. Note each restart (what stopped, when, attempt) with `fabric_task_note`.
|
|
166
|
+
|
|
149
167
|
## Step 6 — tasks link to backlog rows, never copy them
|
|
150
168
|
|
|
151
169
|
A backlog row in a repository stays the status source. File the Fabric task that runs it with the row's address:
|
|
@@ -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:
|
|
41
|
-
|
|
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`.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
63
|
+
VERSION = "0.29.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
|