@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.
- package/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- 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 +104 -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 +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- 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 +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- 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 +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -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-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -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 +7 -18
- 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 +11 -68
- 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 +50 -16
- 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 +235 -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 +146 -32
- 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 -2541
- 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 +404 -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 +408 -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 +91 -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_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -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 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- 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 +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -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_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- 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 +285 -109
- 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 +32 -39
- 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_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- 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 +437 -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 +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- 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/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py
ADDED
|
@@ -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
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py
ADDED
|
@@ -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)
|