@passioncode-ai/fabric-agent-adapter 0.39.0 → 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.
- package/CHANGELOG.md +126 -1
- package/README.md +25 -17
- package/bin/fabric-agent-adapter.js +6 -6
- 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 +13 -13
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +56 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/demo.html +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +12 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +13 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +11 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +6 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +8 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +26 -26
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +10 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +13 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +7 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -209
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +3 -13
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2521
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +16 -181
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +22 -44
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3430
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +33 -33
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +27 -27
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +31 -31
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +9 -9
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +8 -8
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py
ADDED
|
@@ -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
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py
ADDED
|
@@ -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)
|