@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.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 (86) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +9 -3
  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 +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -0,0 +1,335 @@
1
+ """working-with-fabric, part of fabric_session.py (FAA-65): the detached worker that delivers a spool through
2
+ fabric-mcp --rejoin-only over stdio, with a deadline. Standard library only."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+ from typing import Dict
13
+ from typing import List
14
+ from typing import Optional
15
+
16
+ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
17
+ BOARD_STATUS_TOOL,
18
+ GIT_TIMEOUT_S,
19
+ HOOK_FEATURE,
20
+ SESSION_ID,
21
+ VERSION,
22
+ WORKER_DEADLINE_S,
23
+ _remove_tree,
24
+ _write_private,
25
+ claude_registration,
26
+ fabric_root,
27
+ locate_hub,
28
+ lock_held,
29
+ log,
30
+ read_small,
31
+ )
32
+
33
+
34
+ RUNNER = "claude-code"
35
+
36
+
37
+ OBSERVE_TOOL = "fabric_session_observe"
38
+
39
+
40
+ RELEASE_TOOL = "fabric_work_release"
41
+
42
+
43
+ WAITING_TOOL = "fabric_whoami"
44
+
45
+
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."""
48
+ facts: Dict[str, Any] = {"head": None, "filesChanged": None, "linesAdded": None, "linesRemoved": None}
49
+
50
+ def git(*args: str) -> Optional[str]:
51
+ try:
52
+ done = subprocess.run(["git", "--no-optional-locks", "-C", str(cwd)] + list(args),
53
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
54
+ timeout=GIT_TIMEOUT_S, check=False)
55
+ except (OSError, subprocess.SubprocessError):
56
+ return None
57
+ return done.stdout.decode("utf-8", "replace") if done.returncode == 0 else None
58
+
59
+ 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
61
+ status = git("status", "--porcelain=v1", "-z", "--untracked-files=normal")
62
+ if status is not None:
63
+ facts["filesChanged"] = _count_status(status)
64
+ if facts["head"]:
65
+ numstat = git("diff", "--numstat", "HEAD")
66
+ if numstat is not None:
67
+ added = removed = 0
68
+ for line in numstat.splitlines():
69
+ parts = line.split("\t", 2)
70
+ if len(parts) == 3 and parts[0].isdigit() and parts[1].isdigit():
71
+ added, removed = added + int(parts[0]), removed + int(parts[1])
72
+ facts["linesAdded"], facts["linesRemoved"] = added, removed
73
+ return facts
74
+
75
+
76
+ def _count_status(z: str) -> int:
77
+ records, count, skip = z.split("\0"), 0, False
78
+ for record in records:
79
+ if skip:
80
+ skip = False
81
+ continue
82
+ if len(record) < 4:
83
+ continue
84
+ count += 1
85
+ skip = record[0] in "RC" # a rename or copy carries its old path in the next record
86
+ return count
87
+
88
+
89
+ class McpStdio:
90
+ """A minimal MCP client over stdio (newline-delimited JSON-RPC) with a hard deadline."""
91
+
92
+ def __init__(self, argv: List[str], cwd: str, env: Dict[str, str], deadline: float):
93
+ import threading # only the worker needs it; the hooks' path stays light
94
+ self.deadline = deadline
95
+ self.proc = subprocess.Popen(argv, cwd=cwd, env=env, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
96
+ stderr=subprocess.DEVNULL)
97
+ self.messages: List[Dict[str, Any]] = []
98
+ self.cond = threading.Condition()
99
+ self.next_id = 0
100
+ self.tools_changed = False
101
+ threading.Thread(target=self._read, daemon=True).start()
102
+
103
+ def _read(self) -> None:
104
+ assert self.proc.stdout is not None
105
+ for raw in self.proc.stdout:
106
+ try:
107
+ message = json.loads(raw.decode("utf-8"))
108
+ except ValueError:
109
+ continue
110
+ with self.cond:
111
+ if isinstance(message, dict) and message.get("method") == "notifications/tools/list_changed":
112
+ self.tools_changed = True
113
+ elif isinstance(message, dict):
114
+ self.messages.append(message)
115
+ self.cond.notify_all()
116
+ with self.cond:
117
+ self.cond.notify_all()
118
+
119
+ def _send(self, message: Dict[str, Any]) -> None:
120
+ assert self.proc.stdin is not None
121
+ self.proc.stdin.write((json.dumps(message) + "\n").encode("utf-8"))
122
+ self.proc.stdin.flush()
123
+
124
+ def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None:
125
+ self._send({"jsonrpc": "2.0", "method": method, **({"params": params} if params else {})})
126
+
127
+ def request(self, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
128
+ self.next_id += 1
129
+ ident = self.next_id
130
+ self._send({"jsonrpc": "2.0", "id": ident, "method": method, "params": params})
131
+ with self.cond:
132
+ while True:
133
+ for message in self.messages:
134
+ if message.get("id") == ident:
135
+ self.messages.remove(message)
136
+ if "error" in message:
137
+ raise RuntimeError("json-rpc error %s" % (message["error"] or {}).get("code"))
138
+ return message.get("result") or {}
139
+ left = self.deadline - time.monotonic()
140
+ if left <= 0 or self.proc.poll() is not None:
141
+ raise TimeoutError("no answer to %s" % method)
142
+ self.cond.wait(min(left, 0.25))
143
+
144
+ def wait_tools_changed(self, seconds: float) -> None:
145
+ with self.cond:
146
+ if not self.tools_changed:
147
+ self.cond.wait(max(0.0, min(seconds, self.deadline - time.monotonic())))
148
+ self.tools_changed = False
149
+
150
+ def close(self) -> None:
151
+ try:
152
+ if self.proc.stdin:
153
+ self.proc.stdin.close() # fabric-mcp ends on stdin's end
154
+ self.proc.wait(timeout=1.5)
155
+ except Exception: # noqa: BLE001
156
+ self.proc.kill()
157
+ try:
158
+ self.proc.wait(timeout=1.0)
159
+ except Exception: # noqa: BLE001
160
+ pass
161
+ if self.proc.stdout:
162
+ self.proc.stdout.close()
163
+
164
+
165
+ def _take_lock(directory: Path, wait_s: float = 0.0) -> bool:
166
+ """One worker per session. A second one waits for the first (bounded), so an `end` entry spooled while a
167
+ worker was finishing is still delivered."""
168
+ lock = directory / "worker.lock"
169
+ until = time.monotonic() + wait_s
170
+ while True:
171
+ if _try_lock(lock, directory):
172
+ return True
173
+ if time.monotonic() >= until:
174
+ return False
175
+ time.sleep(0.2)
176
+
177
+
178
+ def _try_lock(lock: Path, directory: Path) -> bool:
179
+ for _ in range(2):
180
+ try:
181
+ fd = os.open(str(lock), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
182
+ os.write(fd, str(os.getpid()).encode())
183
+ os.close(fd)
184
+ return True
185
+ except FileExistsError:
186
+ if lock_held(directory):
187
+ return False
188
+ try:
189
+ lock.unlink()
190
+ except OSError:
191
+ return False
192
+ except OSError:
193
+ return False
194
+ return False
195
+
196
+
197
+ def _answer_json(result: Dict[str, Any]) -> Dict[str, Any]:
198
+ """The JSON an answer carries: structuredContent, else its first text block parsed (the board's tools answer
199
+ that way). {} when it carries none."""
200
+ if isinstance(result.get("structuredContent"), dict):
201
+ return result["structuredContent"]
202
+ content = result.get("content")
203
+ if isinstance(content, list) and content and isinstance(content[0], dict) and isinstance(content[0].get("text"), str):
204
+ try:
205
+ value = json.loads(content[0]["text"])
206
+ except ValueError:
207
+ return {}
208
+ return value if isinstance(value, dict) else {}
209
+ return {}
210
+
211
+
212
+ def _answer_said(result: Dict[str, Any]) -> str:
213
+ """What an answer was, in a log-safe word: never its text."""
214
+ if not result.get("isError"):
215
+ return "ok"
216
+ error = _answer_json(result).get("error")
217
+ code = error.get("code") if isinstance(error, dict) and isinstance(error.get("code"), str) else None
218
+ return "refused:%s" % (code if code and re.fullmatch(r"[a-z0-9._-]{1,64}", code) else "unnamed")
219
+
220
+
221
+ def board_unread(result: Dict[str, Any]) -> Tuple[Optional[int], str]:
222
+ """`com.status` (comms-status.schema.json) → (the unread count, a log-safe word). A board that is `unavailable`,
223
+ or an answer without a count, is None: "cannot be read" is never reported as zero. A `degraded` board that still
224
+ gives the reader's count is read (unread counts belong to the reader: the contract's project-comms.md)."""
225
+ said = _answer_said(result)
226
+ if said != "ok":
227
+ return None, "answer=" + said
228
+ doc = _answer_json(result)
229
+ board = doc.get("board") if isinstance(doc.get("board"), dict) else {}
230
+ unread = doc.get("unread")
231
+ state = board.get("state")
232
+ if state not in ("ready", "degraded") or not isinstance(unread, int) or isinstance(unread, bool) or unread < 0:
233
+ return None, "board=unavailable"
234
+ return unread, "answer=ok unread=%d%s" % (unread, " board=degraded" if state == "degraded" else "")
235
+
236
+
237
+ 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."""
239
+ env = dict(os.environ if env is None else env)
240
+ said: List[str] = []
241
+
242
+ def note(line: str) -> None:
243
+ said.append(line)
244
+ log(line, env)
245
+
246
+ session_id = directory.name
247
+ if not SESSION_ID.fullmatch(session_id) or not directory.is_dir():
248
+ return said
249
+ if not _take_lock(directory, wait_s=deadline_s + 2.0):
250
+ return said
251
+ client: Optional[McpStdio] = None
252
+ ended = False
253
+ try:
254
+ entries = sorted(directory.glob("*-*.json"))
255
+ if not entries:
256
+ return said
257
+ first = json.loads(read_small(entries[0]) or "{}")
258
+ cwd = Path(first.get("cwd") or os.getcwd())
259
+ registration = claude_registration(cwd, env)
260
+ hub = locate_hub(fabric_root(env=env))
261
+ if not registration or hub["state"] != "running" or HOOK_FEATURE not in hub.get("features", []):
262
+ note("session=%s outcome=dropped reason=%r" % (session_id[:8], "Fabric or its registration went away"))
263
+ for entry in entries:
264
+ entry.unlink()
265
+ ended = any(e.name.endswith("-end.json") for e in entries)
266
+ return said
267
+ deadline = time.monotonic() + deadline_s
268
+ child_env = dict(env, CLAUDE_CODE_SESSION_ID=session_id)
269
+ argv = [registration["command"]] + registration["args"] + ["--rejoin-only", "--runtime-session-id", session_id]
270
+ client = McpStdio(argv, str(cwd), child_env, deadline)
271
+ client.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
272
+ "clientInfo": {"name": "fabric-agent-adapter-hooks", "version": VERSION}})
273
+ client.notify("notifications/initialized")
274
+ names = _wait_for_session_tools(client, deadline)
275
+ _write_private(directory / "rejoined", "")
276
+ while entries:
277
+ for path in entries:
278
+ entry = json.loads(read_small(path) or "{}")
279
+ event = entry.get("event")
280
+ key = "%s:%s" % (session_id, path.name.split("-", 1)[0])
281
+ if event == "end":
282
+ ended = True
283
+ if RELEASE_TOOL in names:
284
+ result = client.request("tools/call", {"name": RELEASE_TOOL, "arguments": {
285
+ "claims": "session", "outcome": "let-go",
286
+ "reason": "the Claude Code session ended (%s)" % entry.get("reason", "other"),
287
+ "idempotencyKey": key + ":release"}})
288
+ note("session=%s event=end tool=%s answer=%s" % (session_id[:8], RELEASE_TOOL, _answer_said(result)))
289
+ else:
290
+ 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()
302
+ path.unlink()
303
+ entries = sorted(directory.glob("*-*.json")) # turns spooled while this worker ran
304
+ if not ended and BOARD_STATUS_TOOL in names:
305
+ # 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": {}}))
307
+ _write_private(directory / "board.json", json.dumps({
308
+ "unread": unread, "observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}))
309
+ note("session=%s event=board tool=%s %s" % (session_id[:8], BOARD_STATUS_TOOL, said_board))
310
+ except TimeoutError as exc:
311
+ note("session=%s outcome=unknown reason=%r" % (session_id[:8], str(exc)))
312
+ except Exception as exc: # noqa: BLE001 — the worker never raises
313
+ note("session=%s outcome=failed reason=%r" % (session_id[:8], type(exc).__name__))
314
+ finally:
315
+ if client is not None:
316
+ client.close()
317
+ try:
318
+ (directory / "worker.lock").unlink()
319
+ except OSError:
320
+ pass
321
+ if ended:
322
+ _remove_tree(directory)
323
+ return said
324
+
325
+
326
+ def _wait_for_session_tools(client: McpStdio, deadline: float) -> List[str]:
327
+ """fabric-mcp lists only fabric_whoami until it holds a session; then it says its tools changed."""
328
+ while True:
329
+ tools = client.request("tools/list", {}).get("tools") or []
330
+ names = [t.get("name") for t in tools if isinstance(t, dict) and isinstance(t.get("name"), str)]
331
+ if [n for n in names if n != WAITING_TOOL]:
332
+ return names
333
+ if time.monotonic() >= deadline:
334
+ raise TimeoutError("fabric-mcp did not join the session (not enrolled, or Fabric did not answer)")
335
+ client.wait_tools_changed(0.5)
@@ -143,6 +143,7 @@ def main(argv=None, env=None, out=print, err=print):
143
143
  'opencode':['hosts/context.mjs','hosts/opencode/fabric.mjs'],
144
144
  'pi':['hosts/context.mjs','hosts/pi/fabric.mjs'],
145
145
  'openclaw':['hosts/context.mjs','hosts/openclaw/index.mjs','hosts/openclaw/package.json','hosts/openclaw/openclaw.plugin.json']}[args.host]
146
+ required += ['fabric_session_env.py','fabric_session_mcp.py','fabric_session_mail.py','fabric_session_hosts.py','fabric_session_cli.py'] # the helper's own modules (FAA-65)
146
147
  if any(not (script.parent/p).is_file() for p in required):
147
148
  raise fs.Refused('native adapter source is missing; update the complete skill before installing hooks')
148
149
  path=Path(args.config).expanduser().absolute() if args.config else destination(args.host,env)