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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +18 -6
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  77. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
  78. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  79. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
  80. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
  81. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
  82. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  83. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
  84. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
  85. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  86. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  87. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
  88. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  89. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  90. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  91. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  92. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  93. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
  94. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  95. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  96. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
  97. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  98. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  99. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  100. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  101. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
  102. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  103. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  104. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
  105. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  106. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  107. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  108. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  109. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  110. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  111. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  112. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  113. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  114. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
  115. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
  116. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
  117. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
  118. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
  119. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
  120. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
  121. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  122. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  123. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -0,0 +1,324 @@
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 re
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
14
+ CONFIG_CAP,
15
+ HOSTS,
16
+ NATIVE_PLUGIN_HOSTS,
17
+ _home,
18
+ )
19
+
20
+
21
+ try: # Python 3.11+; only install-host-hooks --host kimi needs it, and refuses to write without it
22
+ import tomllib
23
+ except ImportError: # pragma: no cover — exercised by a test that removes it
24
+ tomllib = None # type: ignore[assignment]
25
+
26
+
27
+ class Refused(Exception):
28
+ pass
29
+
30
+
31
+ def default_hook_script(env: Optional[Dict[str, str]] = None) -> Path:
32
+ """The skills install (stable across updates) when present, else this script's own path, symlinks kept."""
33
+ installed = _home(env) / ".agents" / "skills" / "working-with-fabric" / "scripts" / "fabric_session.py"
34
+ return installed if installed.is_file() else Path(__file__).absolute().with_name("fabric_session.py") # the entry point
35
+
36
+
37
+ def hook_command(script: str, host: str) -> str:
38
+ import shlex
39
+ quoted = '"%s"' % script if sys.platform == "win32" else shlex.quote(script)
40
+ return "python3 -S %s hook session-start --host %s" % (quoted, host)
41
+
42
+
43
+ def _is_ours(command: Any, host: str) -> bool:
44
+ return isinstance(command, str) and "fabric_session.py" in command and \
45
+ command.rstrip().endswith("hook session-start --host %s" % host)
46
+
47
+
48
+ def _digest(data: Optional[bytes]) -> str:
49
+ return hashlib.sha256(data).hexdigest() if data is not None else "absent"
50
+
51
+
52
+ def _read_bytes(path: Path) -> Optional[bytes]:
53
+ try:
54
+ st = os.lstat(str(path))
55
+ except FileNotFoundError:
56
+ return None
57
+ import stat as _stat
58
+ if not _stat.S_ISREG(st.st_mode) or st.st_size > CONFIG_CAP:
59
+ raise Refused("%s is not a regular file of a reasonable size; nothing was changed" % path)
60
+ with open(str(path), "rb") as handle:
61
+ return handle.read()
62
+
63
+
64
+ def _codex_doc(data: Optional[bytes]) -> Dict[str, Any]:
65
+ if data is None:
66
+ return {"hooks": {}}
67
+ try:
68
+ doc = json.loads(data.decode("utf-8"))
69
+ except (ValueError, UnicodeDecodeError):
70
+ raise Refused("the file is not JSON, so its shape cannot be checked; nothing was changed")
71
+ shaped = isinstance(doc, dict) and isinstance(doc.get("hooks", {}), dict) and all(
72
+ isinstance(groups, list) and all(isinstance(g, dict) and isinstance(g.get("hooks", []), list)
73
+ and all(isinstance(h, dict) for h in g.get("hooks", [])) for g in groups)
74
+ for groups in doc.get("hooks", {}).values())
75
+ if not shaped:
76
+ raise Refused('the file has another shape than {"hooks": {"<Event>": [{"hooks": [...]}]}}; nothing was changed')
77
+ return doc
78
+
79
+
80
+ def _codex_has(doc: Dict[str, Any], host: str) -> bool:
81
+ return any(_is_ours(h.get("command"), host) for g in doc.get("hooks", {}).get("SessionStart", [])
82
+ for h in g.get("hooks", []))
83
+
84
+
85
+ def _kimi_hooks(data: Optional[bytes]) -> List[Any]:
86
+ if tomllib is None:
87
+ raise Refused("reading config.toml safely needs Python 3.11 (tomllib); nothing was changed; "
88
+ "add the block above by hand, or run this with Python 3.11 or later")
89
+ try:
90
+ doc = tomllib.loads((data or b"").decode("utf-8"))
91
+ except (ValueError, UnicodeDecodeError) as exc:
92
+ raise Refused("the file is not valid TOML (%s); nothing was changed" % type(exc).__name__)
93
+ hooks = doc.get("hooks", [])
94
+ if not isinstance(hooks, list) or not all(isinstance(h, dict) for h in hooks):
95
+ raise Refused("`hooks` has another shape than an array of [[hooks]] tables; nothing was changed")
96
+ return hooks
97
+
98
+
99
+ def _kimi_block(command: str) -> str:
100
+ return ("\n# fabric-agent-adapter (working-with-fabric): the Fabric session-start preamble. Remove it with\n"
101
+ "# fabric_session.py install-host-hooks --host kimi --remove\n"
102
+ "[[hooks]]\nevent = \"SessionStart\"\ncommand = %s\ntimeout = 5\n" % json.dumps(command))
103
+
104
+
105
+ _KIMI_COMMENT = _kimi_block("").split("[[hooks]]", 1)[0].lstrip("\n").splitlines(True) # our two comment lines
106
+
107
+
108
+ def _kimi_spans(text: str, host: str) -> List[Tuple[int, int]]:
109
+ """Where each `[[hooks]]` table whose command is ours sits in `text`, as line ranges [header, end). A table ends at
110
+ the next header; trailing blank and comment lines belong to what follows. Each table is read on its own with
111
+ tomllib, so a hand-touched table (another order, a comment, another timeout) is still found by what it runs."""
112
+ lines = text.splitlines(True)
113
+ headers = [i for i, line in enumerate(lines) if line.startswith("[")]
114
+ spans = []
115
+ for n, start in enumerate(headers):
116
+ if not re.match(r"\[\[\s*hooks\s*\]\]\s*(#.*)?\Z", lines[start].rstrip("\r\n")):
117
+ continue
118
+ end = headers[n + 1] if n + 1 < len(headers) else len(lines)
119
+ while end > start + 1 and (not lines[end - 1].strip() or lines[end - 1].lstrip().startswith("#")):
120
+ end -= 1
121
+ try:
122
+ table = tomllib.loads("".join(lines[start:end]))["hooks"][0]
123
+ except (ValueError, KeyError, IndexError, TypeError):
124
+ continue
125
+ if isinstance(table, dict) and _is_ours(table.get("command"), host):
126
+ spans.append((start, end))
127
+ return spans
128
+
129
+
130
+ def _kimi_edit(text: str, host: str, command: Optional[str]) -> str:
131
+ """`text` with our one `[[hooks]]` table pointed at `command`, or taken out (None) with the comment lines and the
132
+ blank line `_kimi_block` put before it. Refused when there is not exactly one."""
133
+ spans = _kimi_spans(text, host)
134
+ if len(spans) != 1:
135
+ raise Refused("found %d Fabric hook tables to edit; remove the extra ones by hand" % len(spans))
136
+ lines = text.splitlines(True)
137
+ start, end = spans[0]
138
+ if command is not None:
139
+ found = [i for i in range(start + 1, end) if re.match(r"\s*command\s*=", lines[i])]
140
+ if len(found) != 1:
141
+ raise Refused("the Fabric hook's command could not be found line by line; edit it by hand")
142
+ lines[found[0]] = "command = %s\n" % json.dumps(command)
143
+ return "".join(lines)
144
+ if lines[max(0, start - len(_KIMI_COMMENT)):start] == _KIMI_COMMENT:
145
+ start -= len(_KIMI_COMMENT)
146
+ if start > 0 and lines[start - 1] == "\n":
147
+ start -= 1
148
+ return "".join(lines[:start] + lines[end:])
149
+
150
+
151
+ def _replace_file(path: Path, data: bytes, seen: Optional[bytes]) -> Optional[Path]:
152
+ """Back the file up, then write `data` atomically with the file's own mode. Refused when the file changed
153
+ since it was read. Returns the backup."""
154
+ if _read_bytes(path) != seen:
155
+ raise Refused("%s changed while this ran; nothing was changed" % path)
156
+ backup = None
157
+ mode = 0o600
158
+ if seen is not None:
159
+ mode = os.stat(str(path)).st_mode & 0o777
160
+ stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
161
+ for n in range(100): # never overwrite an earlier backup, even one made the same second
162
+ backup = path.with_name("%s.%s%s.bak" % (path.name, stamp, "-%d" % n if n else ""))
163
+ if not os.path.lexists(str(backup)):
164
+ break
165
+ fd = os.open(str(backup), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
166
+ with os.fdopen(fd, "wb") as handle:
167
+ handle.write(seen)
168
+ path.parent.mkdir(parents=True, exist_ok=True)
169
+ tmp = path.with_name(".%s.%d.tmp" % (path.name, os.getpid()))
170
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
171
+ with os.fdopen(fd, "wb") as handle:
172
+ handle.write(data)
173
+ os.chmod(str(tmp), mode)
174
+ os.replace(str(tmp), str(path))
175
+ return backup
176
+
177
+
178
+ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] = None,
179
+ out: Callable[[str], None] = print,
180
+ err: Callable[[str], None] = lambda line: sys.stderr.write(line + "\n")) -> int:
181
+ import argparse
182
+ env = os.environ if env is None else env
183
+ parser = argparse.ArgumentParser(prog="fabric_session.py install-host-hooks",
184
+ description="Add (or remove) the Fabric session-start preamble hook in Codex or "
185
+ "Kimi Code. A dry run unless --apply or --remove.")
186
+ parser.add_argument("--host", required=True)
187
+ mode = parser.add_mutually_exclusive_group()
188
+ mode.add_argument("--dry-run", action="store_true", help="show the edit and write nothing (the default)")
189
+ mode.add_argument("--apply", action="store_true", help="make the edit, after backing the file up")
190
+ mode.add_argument("--remove", action="store_true", help="take this hook out again, after backing the file up")
191
+ parser.add_argument("--config", default=None, help="the file to edit (default: the host's own)")
192
+ parser.add_argument("--script", default=None, help="the fabric_session.py the hook runs")
193
+ parser.add_argument("--expect-sha256", default=None, help="refuse unless the file still has this digest")
194
+ try:
195
+ args = parser.parse_args(list(argv))
196
+ except SystemExit as exc:
197
+ return int(exc.code or 2)
198
+ if args.host in NATIVE_PLUGIN_HOSTS:
199
+ import importlib.util
200
+ spec = importlib.util.spec_from_file_location("_fabric_native_installer", Path(__file__).with_name("host_hooks.py"))
201
+ installer = importlib.util.module_from_spec(spec)
202
+ spec.loader.exec_module(installer)
203
+ return installer.main(list(argv), env=env, out=out, err=err)
204
+ if args.host not in ("codex", "kimi"):
205
+ err("install-host-hooks: --host must be codex, kimi, hermes, opencode, openclaw or pi (Claude uses its plugin)")
206
+ return 2
207
+ if args.config:
208
+ path = Path(args.config).expanduser()
209
+ elif args.host == "codex":
210
+ path = Path(env.get("CODEX_HOME") or str(_home(env) / ".codex")) / "hooks.json"
211
+ else:
212
+ path = _home(env) / ".kimi-code" / "config.toml"
213
+ script = Path(args.script).expanduser().absolute() if args.script else default_hook_script(env)
214
+ command = hook_command(str(script), args.host)
215
+ shifted = 0 # Codex hook groups after ours, whose position a removal moves
216
+ updated = False # our own hook was there with another command, and is rewritten (FAA-69)
217
+ try:
218
+ if not args.remove and not script.is_file():
219
+ # A hook pointing at nothing fails at every session start; removal needs no script.
220
+ raise Refused("--script %s is not a file; pass the fabric_session.py of a complete installed skill" % script)
221
+ data = _read_bytes(path)
222
+ digest = _digest(data)
223
+ if args.expect_sha256 and args.expect_sha256 != digest:
224
+ raise Refused("%s changed since the dry run (sha256 now %s); nothing was changed" % (path, digest))
225
+ if args.host == "codex":
226
+ doc = _codex_doc(data)
227
+ present = _codex_has(doc, args.host)
228
+ entry = {"hooks": [{"type": "command", "command": command, "timeout": 5}]}
229
+ if args.remove:
230
+ if not present:
231
+ out("install-host-hooks: no Fabric preamble hook in %s; nothing to change" % path)
232
+ return 0
233
+ groups, dropped = [], None
234
+ for index, group in enumerate(doc["hooks"]["SessionStart"]):
235
+ kept = [h for h in group.get("hooks", []) if not _is_ours(h.get("command"), args.host)]
236
+ if kept or not group.get("hooks"):
237
+ groups.append(dict(group, hooks=kept) if kept != group.get("hooks") else group)
238
+ elif dropped is None:
239
+ dropped = index # a group that held only our hook goes; the groups after it move up
240
+ if dropped is not None:
241
+ shifted = len(doc["hooks"]["SessionStart"]) - dropped - 1
242
+ if groups:
243
+ doc["hooks"]["SessionStart"] = groups
244
+ else:
245
+ del doc["hooks"]["SessionStart"]
246
+ new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
247
+ elif present:
248
+ owned = [h for g in doc["hooks"]["SessionStart"] for h in g.get("hooks", [])
249
+ if _is_ours(h.get("command"), args.host)]
250
+ if len(owned) != 1:
251
+ raise Refused("multiple Fabric hooks are configured; preserve their matchers and resolve duplicates before updating")
252
+ if owned[0]["command"] == command:
253
+ out("install-host-hooks: the Fabric preamble hook is already in %s; nothing to change" % path)
254
+ return 0
255
+ owned[0]["command"] = command
256
+ updated = True
257
+ new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
258
+ else:
259
+ doc.setdefault("hooks", {}).setdefault("SessionStart", []).append(entry)
260
+ new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
261
+ change = ("remove from" if args.remove else "update in" if updated else "add to") + " hooks.SessionStart"
262
+ shown = json.dumps(entry, indent=2)
263
+ else:
264
+ block = _kimi_block(command)
265
+ text = (data or b"").decode("utf-8", "replace")
266
+ # Our table is found by what it runs (_is_ours), not by its exact text, so a stale path is repaired and a
267
+ # hand-touched table is still removed (FAA-69). A dry run without a TOML reader shows the plain append.
268
+ readable = tomllib is not None or args.apply or args.remove
269
+ owned = [h for h in _kimi_hooks(data) if _is_ours(h.get("command"), args.host)] if readable else []
270
+ if len(owned) > 1:
271
+ raise Refused("multiple Fabric hooks are configured in %s; remove the extra ones by hand" % path)
272
+ if args.remove:
273
+ if not owned:
274
+ out("install-host-hooks: no Fabric preamble hook in %s; nothing to change" % path)
275
+ return 0
276
+ new_text = _kimi_edit(text, args.host, None)
277
+ elif owned and owned[0].get("command") == command:
278
+ out("install-host-hooks: the Fabric preamble hook is already in %s; nothing to change" % path)
279
+ return 0
280
+ elif owned:
281
+ new_text = _kimi_edit(text, args.host, command)
282
+ updated = True
283
+ else:
284
+ new_text = text + ("" if not text or text.endswith("\n") else "\n") + block
285
+ new = new_text.encode("utf-8")
286
+ if args.apply or args.remove:
287
+ after = _kimi_hooks(new)
288
+ mine = [h.get("command") for h in after if _is_ours(h.get("command"), args.host)]
289
+ growth = -1 if args.remove else 0 if updated else 1
290
+ if len(after) != len(_kimi_hooks(data)) + growth or mine != ([] if args.remove else [command]):
291
+ raise Refused("the edited file would not read back as expected; nothing was changed")
292
+ change = "remove from" if args.remove else "update in" if updated else "append to"
293
+ shown = block.strip("\n")
294
+ if not (args.apply or args.remove):
295
+ out("install-host-hooks: dry run, nothing is written.")
296
+ out("file: %s" % path)
297
+ out("sha256: %s" % digest)
298
+ out("would %s:" % change)
299
+ out(shown)
300
+ out("Apply it, on the person's go-ahead: fabric_session.py install-host-hooks --host %s --apply "
301
+ "--expect-sha256 %s" % (args.host, digest))
302
+ out("The hook only prints the preamble at session start (no turn observation, no unread count: %s "
303
+ "cannot rejoin its Fabric session yet). It is silent in a session Fabric launched.%s" % (
304
+ HOSTS[args.host], " Codex may ask you to trust the new hook (/hooks) before it runs."
305
+ if args.host == "codex" else ""))
306
+ return 0
307
+ backup = _replace_file(path, new, data)
308
+ out("install-host-hooks: %s %s %s." % ("removed the Fabric preamble hook from" if args.remove
309
+ else "updated the Fabric preamble hook in" if updated
310
+ else "added the Fabric preamble hook to", path,
311
+ "(backup: %s)" % backup if backup else "(new file)"))
312
+ out("Undo: fabric_session.py install-host-hooks --host %s %s" % (args.host, "--apply" if args.remove else "--remove"))
313
+ if shifted:
314
+ # Codex keys a hook's trust by its event, group and index: the groups after ours moved up one, so Codex asks
315
+ # the person to trust them again in /hooks before they run.
316
+ out("Codex trusts a hook by its position: %d SessionStart hook group(s) after the removed one moved, and "
317
+ "Codex will ask you to approve them again in /hooks." % shifted)
318
+ return 0
319
+ except Refused as exc:
320
+ err("install-host-hooks: refused: %s" % exc)
321
+ return 1
322
+ except OSError as exc:
323
+ err("install-host-hooks: %s could not be written (%s); nothing was changed" % (path, type(exc).__name__))
324
+ return 1
@@ -0,0 +1,173 @@
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
+ _write_private,
19
+ claude_registration,
20
+ fabric_launched,
21
+ fabric_root,
22
+ locate_hub,
23
+ rejoin_wait,
24
+ session_dir,
25
+ )
26
+ from fabric_session_mcp import ( # noqa: E402 - the kit (FAA-65)
27
+ McpStdio,
28
+ _wait_for_session_tools,
29
+ board_unread,
30
+ )
31
+
32
+
33
+ WATCH_MIN_INTERVAL_S = 10
34
+
35
+
36
+ WATCH_MAX_BACKOFF_S = 900
37
+
38
+
39
+ def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None,
40
+ deadline_s: float = WORKER_DEADLINE_S) -> Tuple[str, Any]:
41
+ """One look at the board through `fabric-mcp --rejoin-only`: ("ok", count), ("unavailable", why) when Fabric
42
+ answers but the board cannot be read, ("down", why) when Fabric cannot be reached, ("wait", seconds) when the
43
+ session rejoined within the last minute (the Stop worker's mark, or this one's): the rejoin budget is shared, so
44
+ no fabric-mcp starts then (FAA-68). No fabric-mcp starts unless the running Fabric advertises session-hooks/1
45
+ (the rejoin). A successful rejoin renews the mark in the session's folder when the hooks made one."""
46
+ env = dict(os.environ if env is None else env)
47
+ directory = session_dir(session_id, env)
48
+ left = rejoin_wait(directory)
49
+ if left > 0:
50
+ return "wait", left
51
+ hub = locate_hub(fabric_root(env=env))
52
+ if hub["state"] != "running":
53
+ return "down", "Fabric is not running (%s)" % hub["reason"]
54
+ if HOOK_FEATURE not in hub.get("features", []):
55
+ return "down", "this Fabric does not advertise %s, so nothing can rejoin the session" % HOOK_FEATURE
56
+ registration = claude_registration(cwd, env)
57
+ if not registration:
58
+ return "down", "Claude Code has no MCP server named fabric"
59
+ client: Optional[McpStdio] = None
60
+ try:
61
+ deadline = time.monotonic() + deadline_s
62
+ argv = [registration["command"]] + registration["args"] + ["--rejoin-only", "--runtime-session-id", session_id]
63
+ client = McpStdio(argv, str(cwd), dict(env, CLAUDE_CODE_SESSION_ID=session_id), deadline)
64
+ client.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
65
+ "clientInfo": {"name": "fabric-agent-adapter-watch-mail", "version": VERSION}})
66
+ client.notify("notifications/initialized")
67
+ names = _wait_for_session_tools(client, deadline)
68
+ if directory.is_dir(): # the hooks made it, after their launch check; never made here
69
+ try:
70
+ _write_private(directory / "rejoined", "")
71
+ except OSError:
72
+ pass
73
+ if BOARD_STATUS_TOOL not in names:
74
+ return "unavailable", "this Fabric serves no %s" % BOARD_STATUS_TOOL
75
+ unread, said = board_unread(client.request("tools/call", {"name": BOARD_STATUS_TOOL, "arguments": {}}))
76
+ return ("ok", unread) if unread is not None else ("unavailable", "the project board cannot be read (%s)" % said)
77
+ except TimeoutError:
78
+ return "down", "fabric-mcp did not join this session in time"
79
+ except Exception as exc: # noqa: BLE001 — a poll never raises
80
+ return "down", "fabric-mcp failed (%s)" % type(exc).__name__
81
+ finally:
82
+ if client is not None:
83
+ client.close()
84
+
85
+
86
+ def watch_mail(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None, interval: float = 60, *,
87
+ poll: Optional[Callable[[], Tuple[str, Any]]] = None, out: Callable[[str], None] = print,
88
+ wait: Callable[[float], bool], stop: Callable[[], bool], rng: Callable[[], float]) -> int:
89
+ """Poll the board every `interval` seconds (±20 % jitter; the delay after jitter stays within WATCH_MIN_INTERVAL_S
90
+ and WATCH_MAX_BACKOFF_S) and print ONE line each time the unread count rises. While Fabric is down or the board
91
+ unreadable, say so once and back off, doubling up to WATCH_MAX_BACKOFF_S. A look the shared rejoin mark holds back
92
+ ("wait") changes nothing and waits it out. `wait(seconds)` returns True when it was told to stop."""
93
+ interval = max(float(WATCH_MIN_INTERVAL_S), float(interval))
94
+ poll = poll or (lambda: poll_unread(session_id, cwd, env))
95
+ last, trouble, backoff = 0, None, interval
96
+ while not stop():
97
+ kind, value = poll()
98
+ if kind == "wait":
99
+ delay = float(value)
100
+ elif kind == "ok":
101
+ if value > last:
102
+ out(UNREAD_LINE % value)
103
+ last, trouble, backoff = value, None, interval
104
+ delay = interval
105
+ else:
106
+ backoff = min(backoff * 2, float(WATCH_MAX_BACKOFF_S))
107
+ if trouble != kind:
108
+ out("watch-mail: %s; trying again, backing off up to %d minutes" % (value, WATCH_MAX_BACKOFF_S // 60))
109
+ trouble = kind
110
+ delay = backoff
111
+ jittered = delay if kind == "wait" else delay * (0.8 + 0.4 * rng())
112
+ if wait(min(max(jittered, float(WATCH_MIN_INTERVAL_S)), float(WATCH_MAX_BACKOFF_S))):
113
+ break
114
+ return 0
115
+
116
+
117
+ def watch_mail_cli(argv: Sequence[str], env: Optional[Dict[str, str]] = None) -> int:
118
+ import argparse
119
+ import random
120
+ import signal
121
+ import stat as _stat
122
+ import threading
123
+ env = os.environ if env is None else env
124
+ parser = argparse.ArgumentParser(prog="fabric_session.py watch-mail",
125
+ description="Print one line each time the project board's unread count rises.")
126
+ parser.add_argument("--interval", type=int, default=60, help="seconds between looks (at least %d)" % WATCH_MIN_INTERVAL_S)
127
+ parser.add_argument("--cwd", default=".")
128
+ parser.add_argument("--session-id", default=None, help="the Claude Code session id (default: CLAUDE_CODE_SESSION_ID)")
129
+ args = parser.parse_args(list(argv))
130
+ session_id = args.session_id or env.get("CLAUDE_CODE_SESSION_ID") or ""
131
+ if not SESSION_ID.fullmatch(session_id):
132
+ sys.stderr.write("watch-mail needs the Claude Code session id: run it from Claude Code (it sets "
133
+ "CLAUDE_CODE_SESSION_ID) or pass --session-id. Other agents cannot rejoin their session yet.\n")
134
+ return 2
135
+ if fabric_launched(env):
136
+ sys.stderr.write("watch-mail: this session was launched by Fabric; call com.status with your own tools.\n")
137
+ return 2
138
+ cwd = Path(args.cwd).expanduser()
139
+ if not claude_registration(cwd, env):
140
+ sys.stderr.write("watch-mail: Claude Code has no MCP server named fabric for %s; see working-with-fabric.\n" % cwd)
141
+ return 2
142
+ stopped = threading.Event()
143
+ for name in ("SIGTERM", "SIGINT", "SIGHUP"):
144
+ if hasattr(signal, name):
145
+ signal.signal(getattr(signal, name), lambda *_: stopped.set())
146
+ try: # a pipe from the watcher that started us: its end is our end. /dev/null or a terminal is not.
147
+ mode = os.fstat(sys.stdin.fileno()).st_mode if sys.stdin else 0
148
+ except (OSError, ValueError):
149
+ mode = 0
150
+ if _stat.S_ISFIFO(mode) or _stat.S_ISSOCK(mode):
151
+ def drain() -> None:
152
+ try:
153
+ while sys.stdin.buffer.read1(4096) if hasattr(sys.stdin.buffer, "read1") else sys.stdin.read(4096):
154
+ pass
155
+ except (OSError, ValueError):
156
+ pass
157
+ stopped.set()
158
+ threading.Thread(target=drain, daemon=True).start()
159
+ parent = os.getppid()
160
+
161
+ def wait(seconds: float) -> bool:
162
+ until = time.monotonic() + seconds
163
+ while not stopped.is_set():
164
+ left = until - time.monotonic()
165
+ if left <= 0:
166
+ return False
167
+ stopped.wait(min(left, 2.0))
168
+ if os.getppid() != parent: # orphaned: whoever started us is gone
169
+ stopped.set()
170
+ return True
171
+
172
+ return watch_mail(session_id, cwd, env, args.interval, out=lambda line: print(line, flush=True),
173
+ wait=wait, stop=stopped.is_set, rng=random.random)