@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,265 @@
1
+ """working-with-fabric, part of fabric_session.py (FAA-65): install-host-hooks for Codex and Kimi Code, which writes
2
+ only with --apply and refuses what it cannot read safely. Standard library only."""
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+
12
+ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
13
+ CONFIG_CAP,
14
+ HOSTS,
15
+ NATIVE_PLUGIN_HOSTS,
16
+ _home,
17
+ )
18
+
19
+
20
+ try: # Python 3.11+; only install-host-hooks --host kimi needs it, and refuses to write without it
21
+ import tomllib
22
+ except ImportError: # pragma: no cover — exercised by a test that removes it
23
+ tomllib = None # type: ignore[assignment]
24
+
25
+
26
+ class Refused(Exception):
27
+ pass
28
+
29
+
30
+ def default_hook_script(env: Optional[Dict[str, str]] = None) -> Path:
31
+ """The skills install (stable across updates) when present, else this script's own path, symlinks kept."""
32
+ installed = _home(env) / ".agents" / "skills" / "working-with-fabric" / "scripts" / "fabric_session.py"
33
+ return installed if installed.is_file() else Path(__file__).absolute().with_name("fabric_session.py") # the entry point
34
+
35
+
36
+ def hook_command(script: str, host: str) -> str:
37
+ import shlex
38
+ quoted = '"%s"' % script if sys.platform == "win32" else shlex.quote(script)
39
+ return "python3 -S %s hook session-start --host %s" % (quoted, host)
40
+
41
+
42
+ def _is_ours(command: Any, host: str) -> bool:
43
+ return isinstance(command, str) and "fabric_session.py" in command and \
44
+ command.rstrip().endswith("hook session-start --host %s" % host)
45
+
46
+
47
+ def _digest(data: Optional[bytes]) -> str:
48
+ return hashlib.sha256(data).hexdigest() if data is not None else "absent"
49
+
50
+
51
+ def _read_bytes(path: Path) -> Optional[bytes]:
52
+ try:
53
+ st = os.lstat(str(path))
54
+ except FileNotFoundError:
55
+ return None
56
+ import stat as _stat
57
+ if not _stat.S_ISREG(st.st_mode) or st.st_size > CONFIG_CAP:
58
+ raise Refused("%s is not a regular file of a reasonable size; nothing was changed" % path)
59
+ with open(str(path), "rb") as handle:
60
+ return handle.read()
61
+
62
+
63
+ def _codex_doc(data: Optional[bytes]) -> Dict[str, Any]:
64
+ if data is None:
65
+ return {"hooks": {}}
66
+ try:
67
+ doc = json.loads(data.decode("utf-8"))
68
+ except (ValueError, UnicodeDecodeError):
69
+ raise Refused("the file is not JSON, so its shape cannot be checked; nothing was changed")
70
+ shaped = isinstance(doc, dict) and isinstance(doc.get("hooks", {}), dict) and all(
71
+ isinstance(groups, list) and all(isinstance(g, dict) and isinstance(g.get("hooks", []), list)
72
+ and all(isinstance(h, dict) for h in g.get("hooks", [])) for g in groups)
73
+ for groups in doc.get("hooks", {}).values())
74
+ if not shaped:
75
+ raise Refused('the file has another shape than {"hooks": {"<Event>": [{"hooks": [...]}]}}; nothing was changed')
76
+ return doc
77
+
78
+
79
+ def _codex_has(doc: Dict[str, Any], host: str) -> bool:
80
+ return any(_is_ours(h.get("command"), host) for g in doc.get("hooks", {}).get("SessionStart", [])
81
+ for h in g.get("hooks", []))
82
+
83
+
84
+ def _kimi_hooks(data: Optional[bytes]) -> List[Any]:
85
+ if tomllib is None:
86
+ raise Refused("reading config.toml safely needs Python 3.11 (tomllib); nothing was changed; "
87
+ "add the block above by hand, or run this with Python 3.11 or later")
88
+ try:
89
+ doc = tomllib.loads((data or b"").decode("utf-8"))
90
+ except (ValueError, UnicodeDecodeError) as exc:
91
+ raise Refused("the file is not valid TOML (%s); nothing was changed" % type(exc).__name__)
92
+ hooks = doc.get("hooks", [])
93
+ if not isinstance(hooks, list) or not all(isinstance(h, dict) for h in hooks):
94
+ raise Refused("`hooks` has another shape than an array of [[hooks]] tables; nothing was changed")
95
+ return hooks
96
+
97
+
98
+ def _kimi_block(command: str) -> str:
99
+ return ("\n# fabric-agent-adapter (working-with-fabric): the Fabric session-start preamble. Remove it with\n"
100
+ "# fabric_session.py install-host-hooks --host kimi --remove\n"
101
+ "[[hooks]]\nevent = \"SessionStart\"\ncommand = %s\ntimeout = 5\n" % json.dumps(command))
102
+
103
+
104
+ def _replace_file(path: Path, data: bytes, seen: Optional[bytes]) -> Optional[Path]:
105
+ """Back the file up, then write `data` atomically with the file's own mode. Refused when the file changed
106
+ since it was read. Returns the backup."""
107
+ if _read_bytes(path) != seen:
108
+ raise Refused("%s changed while this ran; nothing was changed" % path)
109
+ backup = None
110
+ mode = 0o600
111
+ if seen is not None:
112
+ mode = os.stat(str(path)).st_mode & 0o777
113
+ stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
114
+ for n in range(100): # never overwrite an earlier backup, even one made the same second
115
+ backup = path.with_name("%s.%s%s.bak" % (path.name, stamp, "-%d" % n if n else ""))
116
+ if not os.path.lexists(str(backup)):
117
+ break
118
+ fd = os.open(str(backup), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
119
+ with os.fdopen(fd, "wb") as handle:
120
+ handle.write(seen)
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ tmp = path.with_name(".%s.%d.tmp" % (path.name, os.getpid()))
123
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
124
+ with os.fdopen(fd, "wb") as handle:
125
+ handle.write(data)
126
+ os.chmod(str(tmp), mode)
127
+ os.replace(str(tmp), str(path))
128
+ return backup
129
+
130
+
131
+ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] = None,
132
+ out: Callable[[str], None] = print,
133
+ err: Callable[[str], None] = lambda line: sys.stderr.write(line + "\n")) -> int:
134
+ import argparse
135
+ env = os.environ if env is None else env
136
+ parser = argparse.ArgumentParser(prog="fabric_session.py install-host-hooks",
137
+ description="Add (or remove) the Fabric session-start preamble hook in Codex or "
138
+ "Kimi Code. A dry run unless --apply or --remove.")
139
+ parser.add_argument("--host", required=True)
140
+ mode = parser.add_mutually_exclusive_group()
141
+ mode.add_argument("--dry-run", action="store_true", help="show the edit and write nothing (the default)")
142
+ mode.add_argument("--apply", action="store_true", help="make the edit, after backing the file up")
143
+ mode.add_argument("--remove", action="store_true", help="take this hook out again, after backing the file up")
144
+ parser.add_argument("--config", default=None, help="the file to edit (default: the host's own)")
145
+ parser.add_argument("--script", default=None, help="the fabric_session.py the hook runs")
146
+ parser.add_argument("--expect-sha256", default=None, help="refuse unless the file still has this digest")
147
+ try:
148
+ args = parser.parse_args(list(argv))
149
+ except SystemExit as exc:
150
+ return int(exc.code or 2)
151
+ if args.host in NATIVE_PLUGIN_HOSTS:
152
+ import importlib.util
153
+ spec = importlib.util.spec_from_file_location("_fabric_native_installer", Path(__file__).with_name("host_hooks.py"))
154
+ installer = importlib.util.module_from_spec(spec)
155
+ spec.loader.exec_module(installer)
156
+ return installer.main(list(argv), env=env, out=out, err=err)
157
+ if args.host not in ("codex", "kimi"):
158
+ err("install-host-hooks: --host must be codex, kimi, hermes, opencode, openclaw or pi (Claude uses its plugin)")
159
+ return 2
160
+ if args.config:
161
+ path = Path(args.config).expanduser()
162
+ elif args.host == "codex":
163
+ path = Path(env.get("CODEX_HOME") or str(_home(env) / ".codex")) / "hooks.json"
164
+ else:
165
+ path = _home(env) / ".kimi-code" / "config.toml"
166
+ command = hook_command(str(args.script or default_hook_script(env)), args.host)
167
+ shifted = 0 # Codex hook groups after ours, whose position a removal moves
168
+ try:
169
+ data = _read_bytes(path)
170
+ digest = _digest(data)
171
+ if args.expect_sha256 and args.expect_sha256 != digest:
172
+ raise Refused("%s changed since the dry run (sha256 now %s); nothing was changed" % (path, digest))
173
+ if args.host == "codex":
174
+ doc = _codex_doc(data)
175
+ present = _codex_has(doc, args.host)
176
+ entry = {"hooks": [{"type": "command", "command": command, "timeout": 5}]}
177
+ if args.remove:
178
+ if not present:
179
+ out("install-host-hooks: no Fabric preamble hook in %s; nothing to change" % path)
180
+ return 0
181
+ groups, dropped = [], None
182
+ for index, group in enumerate(doc["hooks"]["SessionStart"]):
183
+ kept = [h for h in group.get("hooks", []) if not _is_ours(h.get("command"), args.host)]
184
+ if kept or not group.get("hooks"):
185
+ groups.append(dict(group, hooks=kept) if kept != group.get("hooks") else group)
186
+ elif dropped is None:
187
+ dropped = index # a group that held only our hook goes; the groups after it move up
188
+ if dropped is not None:
189
+ shifted = len(doc["hooks"]["SessionStart"]) - dropped - 1
190
+ if groups:
191
+ doc["hooks"]["SessionStart"] = groups
192
+ else:
193
+ del doc["hooks"]["SessionStart"]
194
+ new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
195
+ elif present:
196
+ owned = [h for g in doc["hooks"]["SessionStart"] for h in g.get("hooks", [])
197
+ if _is_ours(h.get("command"), args.host)]
198
+ if len(owned) != 1:
199
+ raise Refused("multiple Fabric hooks are configured; preserve their matchers and resolve duplicates before updating")
200
+ if owned[0]["command"] == command:
201
+ out("install-host-hooks: the Fabric preamble hook is already in %s; nothing to change" % path)
202
+ return 0
203
+ owned[0]["command"] = command
204
+ new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
205
+ else:
206
+ doc.setdefault("hooks", {}).setdefault("SessionStart", []).append(entry)
207
+ new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
208
+ change = "remove from hooks.SessionStart" if args.remove else "add to hooks.SessionStart"
209
+ shown = json.dumps(entry, indent=2)
210
+ else:
211
+ block = _kimi_block(command)
212
+ text = (data or b"").decode("utf-8", "replace")
213
+ if not (args.apply or args.remove):
214
+ present = tomllib is not None and any(_is_ours(h.get("command"), args.host) for h in _kimi_hooks(data))
215
+ else:
216
+ present = any(_is_ours(h.get("command"), args.host) for h in _kimi_hooks(data))
217
+ if args.remove:
218
+ if not present:
219
+ out("install-host-hooks: no Fabric preamble hook in %s; nothing to change" % path)
220
+ return 0
221
+ if block not in text:
222
+ raise Refused("the Fabric hook in %s was edited by hand; remove its [[hooks]] table yourself" % path)
223
+ new_text = text.replace(block, "", 1)
224
+ elif present:
225
+ out("install-host-hooks: the Fabric preamble hook is already in %s; nothing to change" % path)
226
+ return 0
227
+ else:
228
+ new_text = text + ("" if not text or text.endswith("\n") else "\n") + block
229
+ new = new_text.encode("utf-8")
230
+ if args.apply or args.remove:
231
+ after = _kimi_hooks(new)
232
+ if len(after) != len(_kimi_hooks(data)) + (-1 if args.remove else 1):
233
+ raise Refused("the edited file would not read back as expected; nothing was changed")
234
+ change = "remove from" if args.remove else "append to"
235
+ shown = block.strip("\n")
236
+ if not (args.apply or args.remove):
237
+ out("install-host-hooks: dry run, nothing is written.")
238
+ out("file: %s" % path)
239
+ out("sha256: %s" % digest)
240
+ out("would %s:" % change)
241
+ out(shown)
242
+ out("Apply it, on the person's go-ahead: fabric_session.py install-host-hooks --host %s --apply "
243
+ "--expect-sha256 %s" % (args.host, digest))
244
+ out("The hook only prints the preamble at session start (no turn observation, no unread count: %s "
245
+ "cannot rejoin its Fabric session yet). It is silent in a session Fabric launched.%s" % (
246
+ HOSTS[args.host], " Codex may ask you to trust the new hook (/hooks) before it runs."
247
+ if args.host == "codex" else ""))
248
+ return 0
249
+ backup = _replace_file(path, new, data)
250
+ out("install-host-hooks: %s %s %s." % ("removed the Fabric preamble hook from" if args.remove
251
+ else "added the Fabric preamble hook to", path,
252
+ "(backup: %s)" % backup if backup else "(new file)"))
253
+ out("Undo: fabric_session.py install-host-hooks --host %s %s" % (args.host, "--apply" if args.remove else "--remove"))
254
+ if shifted:
255
+ # Codex keys a hook's trust by its event, group and index: the groups after ours moved up one, so Codex asks
256
+ # the person to trust them again in /hooks before they run.
257
+ out("Codex trusts a hook by its position: %d SessionStart hook group(s) after the removed one moved, and "
258
+ "Codex will ask you to approve them again in /hooks." % shifted)
259
+ return 0
260
+ except Refused as exc:
261
+ err("install-host-hooks: refused: %s" % exc)
262
+ return 1
263
+ except OSError as exc:
264
+ err("install-host-hooks: %s could not be written (%s); nothing was changed" % (path, type(exc).__name__))
265
+ return 1
@@ -0,0 +1,155 @@
1
+ """working-with-fabric, part of fabric_session.py (FAA-65): watch-mail, one look at the board per interval while an
2
+ agent waits on it, with backoff while Fabric is down. Standard library only."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
12
+ BOARD_STATUS_TOOL,
13
+ HOOK_FEATURE,
14
+ SESSION_ID,
15
+ UNREAD_LINE,
16
+ VERSION,
17
+ WORKER_DEADLINE_S,
18
+ claude_registration,
19
+ fabric_launched,
20
+ fabric_root,
21
+ locate_hub,
22
+ )
23
+ from fabric_session_mcp import ( # noqa: E402 - the kit (FAA-65)
24
+ McpStdio,
25
+ _wait_for_session_tools,
26
+ board_unread,
27
+ )
28
+
29
+
30
+ WATCH_MIN_INTERVAL_S = 10
31
+
32
+
33
+ WATCH_MAX_BACKOFF_S = 900
34
+
35
+
36
+ def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None,
37
+ deadline_s: float = WORKER_DEADLINE_S) -> Tuple[str, Any]:
38
+ """One look at the board through `fabric-mcp --rejoin-only`: ("ok", count), ("unavailable", why) when Fabric
39
+ answers but the board cannot be read, ("down", why) when Fabric cannot be reached. No fabric-mcp starts unless
40
+ the running Fabric advertises session-hooks/1 (the rejoin)."""
41
+ env = dict(os.environ if env is None else env)
42
+ hub = locate_hub(fabric_root(env=env))
43
+ if hub["state"] != "running":
44
+ return "down", "Fabric is not running (%s)" % hub["reason"]
45
+ if HOOK_FEATURE not in hub.get("features", []):
46
+ return "down", "this Fabric does not advertise %s, so nothing can rejoin the session" % HOOK_FEATURE
47
+ registration = claude_registration(cwd, env)
48
+ if not registration:
49
+ return "down", "Claude Code has no MCP server named fabric"
50
+ client: Optional[McpStdio] = None
51
+ try:
52
+ deadline = time.monotonic() + deadline_s
53
+ argv = [registration["command"]] + registration["args"] + ["--rejoin-only", "--runtime-session-id", session_id]
54
+ client = McpStdio(argv, str(cwd), dict(env, CLAUDE_CODE_SESSION_ID=session_id), deadline)
55
+ client.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
56
+ "clientInfo": {"name": "fabric-agent-adapter-watch-mail", "version": VERSION}})
57
+ client.notify("notifications/initialized")
58
+ names = _wait_for_session_tools(client, deadline)
59
+ if BOARD_STATUS_TOOL not in names:
60
+ return "unavailable", "this Fabric serves no %s" % BOARD_STATUS_TOOL
61
+ unread, said = board_unread(client.request("tools/call", {"name": BOARD_STATUS_TOOL, "arguments": {}}))
62
+ return ("ok", unread) if unread is not None else ("unavailable", "the project board cannot be read (%s)" % said)
63
+ except TimeoutError:
64
+ return "down", "fabric-mcp did not join this session in time"
65
+ except Exception as exc: # noqa: BLE001 — a poll never raises
66
+ return "down", "fabric-mcp failed (%s)" % type(exc).__name__
67
+ finally:
68
+ if client is not None:
69
+ client.close()
70
+
71
+
72
+ def watch_mail(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None, interval: float = 60, *,
73
+ poll: Optional[Callable[[], Tuple[str, Any]]] = None, out: Callable[[str], None] = print,
74
+ wait: Callable[[float], bool], stop: Callable[[], bool], rng: Callable[[], float]) -> int:
75
+ """Poll the board every `interval` seconds (±20 % jitter, never under WATCH_MIN_INTERVAL_S) and print ONE line each
76
+ time the unread count rises. While Fabric is down or the board unreadable, say so once and back off, doubling up
77
+ to WATCH_MAX_BACKOFF_S. `wait(seconds)` returns True when it was told to stop."""
78
+ interval = max(float(WATCH_MIN_INTERVAL_S), float(interval))
79
+ poll = poll or (lambda: poll_unread(session_id, cwd, env))
80
+ last, trouble, backoff = 0, None, interval
81
+ while not stop():
82
+ kind, value = poll()
83
+ if kind == "ok":
84
+ if value > last:
85
+ out(UNREAD_LINE % value)
86
+ last, trouble, backoff = value, None, interval
87
+ delay = interval
88
+ else:
89
+ backoff = min(backoff * 2, float(WATCH_MAX_BACKOFF_S))
90
+ if trouble != kind:
91
+ out("watch-mail: %s; trying again, backing off up to %d minutes" % (value, WATCH_MAX_BACKOFF_S // 60))
92
+ trouble = kind
93
+ delay = backoff
94
+ if wait(delay * (0.8 + 0.4 * rng())):
95
+ break
96
+ return 0
97
+
98
+
99
+ def watch_mail_cli(argv: Sequence[str], env: Optional[Dict[str, str]] = None) -> int:
100
+ import argparse
101
+ import random
102
+ import signal
103
+ import stat as _stat
104
+ import threading
105
+ env = os.environ if env is None else env
106
+ parser = argparse.ArgumentParser(prog="fabric_session.py watch-mail",
107
+ description="Print one line each time the project board's unread count rises.")
108
+ parser.add_argument("--interval", type=int, default=60, help="seconds between looks (at least %d)" % WATCH_MIN_INTERVAL_S)
109
+ parser.add_argument("--cwd", default=".")
110
+ parser.add_argument("--session-id", default=None, help="the Claude Code session id (default: CLAUDE_CODE_SESSION_ID)")
111
+ args = parser.parse_args(list(argv))
112
+ session_id = args.session_id or env.get("CLAUDE_CODE_SESSION_ID") or ""
113
+ if not SESSION_ID.fullmatch(session_id):
114
+ sys.stderr.write("watch-mail needs the Claude Code session id: run it from Claude Code (it sets "
115
+ "CLAUDE_CODE_SESSION_ID) or pass --session-id. Other agents cannot rejoin their session yet.\n")
116
+ return 2
117
+ if fabric_launched(env):
118
+ sys.stderr.write("watch-mail: this session was launched by Fabric; call com.status with your own tools.\n")
119
+ return 2
120
+ cwd = Path(args.cwd).expanduser()
121
+ if not claude_registration(cwd, env):
122
+ sys.stderr.write("watch-mail: Claude Code has no MCP server named fabric for %s; see working-with-fabric.\n" % cwd)
123
+ return 2
124
+ stopped = threading.Event()
125
+ for name in ("SIGTERM", "SIGINT", "SIGHUP"):
126
+ if hasattr(signal, name):
127
+ signal.signal(getattr(signal, name), lambda *_: stopped.set())
128
+ try: # a pipe from the watcher that started us: its end is our end. /dev/null or a terminal is not.
129
+ mode = os.fstat(sys.stdin.fileno()).st_mode if sys.stdin else 0
130
+ except (OSError, ValueError):
131
+ mode = 0
132
+ if _stat.S_ISFIFO(mode) or _stat.S_ISSOCK(mode):
133
+ def drain() -> None:
134
+ try:
135
+ while sys.stdin.buffer.read1(4096) if hasattr(sys.stdin.buffer, "read1") else sys.stdin.read(4096):
136
+ pass
137
+ except (OSError, ValueError):
138
+ pass
139
+ stopped.set()
140
+ threading.Thread(target=drain, daemon=True).start()
141
+ parent = os.getppid()
142
+
143
+ def wait(seconds: float) -> bool:
144
+ until = time.monotonic() + seconds
145
+ while not stopped.is_set():
146
+ left = until - time.monotonic()
147
+ if left <= 0:
148
+ return False
149
+ stopped.wait(min(left, 2.0))
150
+ if os.getppid() != parent: # orphaned: whoever started us is gone
151
+ stopped.set()
152
+ return True
153
+
154
+ return watch_mail(session_id, cwd, env, args.interval, out=lambda line: print(line, flush=True),
155
+ wait=wait, stop=stopped.is_set, rng=random.random)