@passioncode-ai/fabric-agent-adapter 0.34.0 → 0.35.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 +61 -0
- package/README.md +4 -1
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +48 -31
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +40 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +8 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +67 -65
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +8 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +53 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +5 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +30 -11
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +178 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +33 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/hermes/__init__.py +135 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/hermes/plugin.yaml +5 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +45 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/openclaw.plugin.json +11 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/package.json +10 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +24 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/pi/fabric.mjs +36 -0
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py
CHANGED
|
@@ -43,6 +43,34 @@ def _list(value: Any) -> List[Dict[str, Any]]:
|
|
|
43
43
|
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
_MISSING_KEY = _estate._MISSING
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _at(value: Any, key: str) -> Any:
|
|
50
|
+
"""value[key] as JavaScript reads a member: absent is distinct from null (``_MISSING_KEY``)."""
|
|
51
|
+
return _estate._get(value, key)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _json_equal(a: Any, b: Any) -> bool:
|
|
55
|
+
"""The contract's jsonEqual on parsed JSON: arrays by item, objects by member whatever the order, anything else by
|
|
56
|
+
===, so 1 and true differ and NaN equals nothing."""
|
|
57
|
+
if isinstance(a, list) or isinstance(b, list):
|
|
58
|
+
return isinstance(a, list) and isinstance(b, list) and len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b))
|
|
59
|
+
if isinstance(a, dict) or isinstance(b, dict):
|
|
60
|
+
return (isinstance(a, dict) and isinstance(b, dict) and len(a) == len(b)
|
|
61
|
+
and all(k in b and _json_equal(v, b[k]) for k, v in a.items()))
|
|
62
|
+
return _estate._same(a, b) # NaN != NaN in Python too
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _without_nan(value: Any) -> Any:
|
|
66
|
+
"""A resolution as JSON writes it: NaN and an infinity become null."""
|
|
67
|
+
if isinstance(value, dict):
|
|
68
|
+
return {k: _without_nan(v) for k, v in value.items()}
|
|
69
|
+
if isinstance(value, list):
|
|
70
|
+
return [_without_nan(v) for v in value]
|
|
71
|
+
return None if isinstance(value, float) and not math.isfinite(value) else value
|
|
72
|
+
|
|
73
|
+
|
|
46
74
|
def _indexed(value: Any) -> List[tuple]:
|
|
47
75
|
"""The object items of an array with their index as written, so an instancePath names the item even after a
|
|
48
76
|
non-object (the contract's ``indexed``)."""
|
|
@@ -59,7 +87,7 @@ def _set_key(value: Any) -> Any:
|
|
|
59
87
|
if isinstance(value, bool):
|
|
60
88
|
return ("boolean", value)
|
|
61
89
|
if isinstance(value, (int, float)):
|
|
62
|
-
return ("number",
|
|
90
|
+
return ("number", _estate._double(value))
|
|
63
91
|
if isinstance(value, str):
|
|
64
92
|
return ("string", value)
|
|
65
93
|
return ("object", id(value))
|
|
@@ -76,40 +104,12 @@ def _strings(value: Any) -> List[str]:
|
|
|
76
104
|
return [item for item in value if isinstance(item, str)] if isinstance(value, list) else []
|
|
77
105
|
|
|
78
106
|
|
|
79
|
-
def
|
|
80
|
-
"""
|
|
81
|
-
|
|
82
|
-
return "undefined"
|
|
83
|
-
if value is True:
|
|
84
|
-
return "true"
|
|
85
|
-
if value is False:
|
|
86
|
-
return "false"
|
|
87
|
-
if isinstance(value, float) and value.is_integer():
|
|
88
|
-
return str(int(value))
|
|
89
|
-
if isinstance(value, (dict, list)):
|
|
90
|
-
return "[object Object]" if isinstance(value, dict) else ",".join(_js_string(v) for v in value)
|
|
91
|
-
return str(value)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _js_number(value: Any) -> Optional[float]:
|
|
95
|
-
"""JavaScript's Number(value); NaN is None (JSON writes it as null)."""
|
|
96
|
-
if isinstance(value, bool):
|
|
97
|
-
return 1 if value else 0
|
|
98
|
-
if isinstance(value, (int, float)):
|
|
99
|
-
return value if not (isinstance(value, float) and math.isnan(value)) else None
|
|
100
|
-
if isinstance(value, str):
|
|
101
|
-
text = value.strip()
|
|
102
|
-
if text == "":
|
|
103
|
-
return 0
|
|
104
|
-
try:
|
|
105
|
-
number = float(text)
|
|
106
|
-
except ValueError:
|
|
107
|
-
return None
|
|
108
|
-
return int(number) if number.is_integer() else number
|
|
109
|
-
return None
|
|
107
|
+
def resolve_model_profile(*, catalogue: Any = None, profiles: Any = None, choices: Any = None, policy: Any = None) -> Dict[str, Any]:
|
|
108
|
+
"""FAC-SEM-060, as JSON writes the contract's answer (a policy revision that is not a number is null)."""
|
|
109
|
+
return _without_nan(_resolve(catalogue=catalogue, profiles=profiles, choices=choices, policy=policy))
|
|
110
110
|
|
|
111
111
|
|
|
112
|
-
def
|
|
112
|
+
def _resolve(*, catalogue: Any = None, profiles: Any = None, choices: Any = None, policy: Any = None) -> Dict[str, Any]:
|
|
113
113
|
"""FAC-SEM-060: the request's profile, else the agent's, else the binding's or project's, else the estate default.
|
|
114
114
|
When the project's policy names `modelClasses`, a model stays only if every class the catalogue gives it is
|
|
115
115
|
allowed; the descriptive class `open-vendor` is not checked (DEC-0083), and a model with no other class does not stay.
|
|
@@ -137,7 +137,8 @@ def resolve_model_profile(*, catalogue: Any = None, profiles: Any = None, choice
|
|
|
137
137
|
if within:
|
|
138
138
|
return {"from": level, "profile": profile_id, "models": within}
|
|
139
139
|
meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
|
|
140
|
-
return {"refused": "model-classes-empty", "profile": profile_id,
|
|
140
|
+
return {"refused": "model-classes-empty", "profile": profile_id,
|
|
141
|
+
"policy": {"id": _estate._show(_at(meta, "id")), "revision": _estate._js_to_number(_at(meta, "revision"))}}
|
|
141
142
|
|
|
142
143
|
|
|
143
144
|
def _finding(code: str, path: str, message: str) -> Dict[str, str]:
|
|
@@ -170,16 +171,16 @@ def _model_profiles(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
170
171
|
def _runner_profile(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
171
172
|
findings = []
|
|
172
173
|
if value.get("route") == "subscription" and isinstance(value.get("subscription"), dict):
|
|
173
|
-
login =
|
|
174
|
-
runner = SUBSCRIPTION_RUNNER.get(login)
|
|
175
|
-
if runner
|
|
174
|
+
login = _estate._show(_at(value["subscription"], "login"))
|
|
175
|
+
runner = SUBSCRIPTION_RUNNER.get(login, _MISSING_KEY)
|
|
176
|
+
if not _estate._same(runner, _at(value, "runnerKind")):
|
|
176
177
|
findings.append(_finding("FAC-SEM-059", "/runnerKind", "a %s subscription runs only in %s, not in %s; another runner takes the key route"
|
|
177
|
-
% (login, runner if runner is not
|
|
178
|
+
% (login, runner if runner is not _MISSING_KEY else "its vendor's runner", _estate._show(_at(value, "runnerKind")))))
|
|
178
179
|
if value.get("route") == "key" and isinstance(value.get("key"), dict):
|
|
179
180
|
key = value["key"]
|
|
180
|
-
if
|
|
181
|
+
if not _estate._same(_at(key, "modelAllowList"), _at(value, "modelProfile")):
|
|
181
182
|
findings.append(_finding("FAC-SEM-059", "/key/modelAllowList", "the key admits the models of %s, but the profile runs %s"
|
|
182
|
-
% (
|
|
183
|
+
% (_estate._show(_at(key, "modelAllowList")), _estate._show(_at(value, "modelProfile")))))
|
|
183
184
|
if isinstance(key.get("ref"), str) and _secrets.secret_value(key["ref"]):
|
|
184
185
|
findings.append(_finding("FAC-SEM-059", "/key/ref", "the key is named by its secret reference, never by its value"))
|
|
185
186
|
return findings
|
|
@@ -203,9 +204,9 @@ def _profile_resolution(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
203
204
|
if isinstance(model, str) and model not in catalogued:
|
|
204
205
|
findings.append(_finding("FAC-SEM-060", "/profiles/%d/models/%d" % (i, j), "profile %s names %s, which the catalogue does not hold" % (_show_id(profile), model)))
|
|
205
206
|
choices = value.get("choices") if isinstance(value.get("choices"), dict) else {}
|
|
206
|
-
expected =
|
|
207
|
-
if
|
|
208
|
-
findings.append(_finding("FAC-SEM-060", "/resolved", "the resolution is %s" %
|
|
207
|
+
expected = _resolve(catalogue=catalogue, profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
|
|
208
|
+
if not _json_equal(_at(value, "resolved"), expected):
|
|
209
|
+
findings.append(_finding("FAC-SEM-060", "/resolved", "the resolution is %s" % _estate._json_stringify(_without_nan(expected))))
|
|
209
210
|
return findings
|
|
210
211
|
|
|
211
212
|
|
|
@@ -346,18 +347,20 @@ def runner_check(entry: Dict[str, Any], *, path: Optional[str] = None,
|
|
|
346
347
|
|
|
347
348
|
def model_usable(model: str, catalogue: Any, policy: Any = None) -> List[str]:
|
|
348
349
|
"""Problems with running `model` here: not in the catalogue, or a class the project's policy does not allow."""
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
350
|
+
# The last row of an id wins, as a JavaScript Map built from the rows; only a string id can match a model's name.
|
|
351
|
+
rows = {row["id"]: row for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None) if isinstance(row.get("id"), str)}
|
|
352
|
+
if not isinstance(model, str) or model not in rows:
|
|
353
|
+
return ["model %s is not in the model catalogue" % _estate._show(model)]
|
|
352
354
|
if isinstance(policy, dict) and isinstance(policy.get("modelClasses"), list):
|
|
353
355
|
allowed = set(_strings(policy["modelClasses"]))
|
|
354
356
|
meta = policy.get("meta") if isinstance(policy.get("meta"), dict) else {}
|
|
357
|
+
policy_id = _estate._show(_at(meta, "id"))
|
|
355
358
|
admission = [c for c in _strings(rows[model].get("classes")) if c != OPEN_VENDOR] # DEC-0083: descriptive
|
|
356
359
|
if not admission:
|
|
357
|
-
return ["model %s has no class but %s, which admits nothing, so policy %s does not allow it" % (model, OPEN_VENDOR,
|
|
360
|
+
return ["model %s has no class but %s, which admits nothing, so policy %s does not allow it" % (model, OPEN_VENDOR, policy_id)]
|
|
358
361
|
outside = [c for c in admission if c not in allowed]
|
|
359
362
|
if outside:
|
|
360
|
-
return ["model %s has class %s, which policy %s does not allow" % (model, ", ".join(outside),
|
|
363
|
+
return ["model %s has class %s, which policy %s does not allow" % (model, ", ".join(outside), policy_id)]
|
|
361
364
|
return []
|
|
362
365
|
|
|
363
366
|
|
|
@@ -401,13 +404,9 @@ _MISSING = object()
|
|
|
401
404
|
|
|
402
405
|
|
|
403
406
|
def _field(value: Dict[str, Any], key: str) -> str:
|
|
404
|
-
"""
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
return "undefined"
|
|
408
|
-
if found is None:
|
|
409
|
-
return "null"
|
|
410
|
-
return _js_string(found)
|
|
407
|
+
"""The contract's show(value[key]): `undefined` for a missing key, `null` for null, and an object whose own
|
|
408
|
+
`toString` is data as its JSON, where String() would throw."""
|
|
409
|
+
return _estate._show(_at(value, key))
|
|
411
410
|
|
|
412
411
|
|
|
413
412
|
def _object_at(value: Any, key: str) -> Dict[str, Any]:
|
|
@@ -496,11 +495,14 @@ def _catalogue_agreement(catalogue: List[Dict[str, Any]], route: Dict[str, Any])
|
|
|
496
495
|
return findings
|
|
497
496
|
|
|
498
497
|
|
|
499
|
-
def _index_of(ref: Dict[str, Any]) ->
|
|
498
|
+
def _index_of(ref: Dict[str, Any]) -> float:
|
|
499
|
+
"""The contract's indexOf: a JSON number that is an integer, as JavaScript holds it (a double; beyond the double
|
|
500
|
+
range it is Infinity, which is no integer), else -1."""
|
|
500
501
|
index = ref.get("index")
|
|
501
|
-
if isinstance(index, bool) or not isinstance(index, (int, float))
|
|
502
|
+
if isinstance(index, bool) or not isinstance(index, (int, float)):
|
|
502
503
|
return -1
|
|
503
|
-
|
|
504
|
+
number = _estate._double(index)
|
|
505
|
+
return number if math.isfinite(number) and number.is_integer() else -1
|
|
504
506
|
|
|
505
507
|
|
|
506
508
|
def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
@@ -521,8 +523,8 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
521
523
|
|
|
522
524
|
def candidate_at(ref: Dict[str, Any], path: str) -> Optional[Dict[str, Any]]:
|
|
523
525
|
index = _index_of(ref)
|
|
524
|
-
candidate = candidates[index] if 0 <= index < len(candidates) else None
|
|
525
|
-
if (candidate is None or
|
|
526
|
+
candidate = candidates[int(index)] if 0 <= index < len(candidates) else None
|
|
527
|
+
if (candidate is None or not _estate._same(_at(candidate, "runnerKind"), _at(ref, "runnerKind"))
|
|
526
528
|
or ("provider" in ref and not _same_ref(candidate.get("provider"), ref["provider"]))):
|
|
527
529
|
wrong(path, "candidate %s (%s) is not that position of the route" % (_field(ref, "index"), _field(ref, "runnerKind")))
|
|
528
530
|
return None
|
|
@@ -536,12 +538,12 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
536
538
|
if candidate is not None and probe.get("result") == "spawn-failed" and _object_at(candidate, "session").get("spawn") != "allowed":
|
|
537
539
|
wrong("/probes/%d/result" % position, "%s has spawn: never, so it cannot have failed to spawn" % _field(probe, "runnerKind"))
|
|
538
540
|
|
|
539
|
-
def expect_probes(count:
|
|
541
|
+
def expect_probes(count: float) -> None:
|
|
540
542
|
indexes = [_index_of(p) for p in probes]
|
|
541
|
-
expected =
|
|
542
|
-
if indexes != expected:
|
|
543
|
+
expected = range(int(max(count, 0))) # Array.from({length: count}): ToLength, built only where it is read
|
|
544
|
+
if len(indexes) != len(expected) or any(index != expected[position] for position, index in enumerate(indexes)):
|
|
543
545
|
wrong("/probes", "the walk must record candidates %s in order; it records %s"
|
|
544
|
-
% (", ".join(map(
|
|
546
|
+
% (", ".join(map(_estate._js_string, expected)) or "none", ", ".join(map(_estate._js_string, indexes)) or "none"))
|
|
545
547
|
|
|
546
548
|
if event.get("kind") == "runner-exhausted":
|
|
547
549
|
expect_probes(len(candidates))
|
|
@@ -557,7 +559,7 @@ def _route_event(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
557
559
|
wrong("/selected/session", "%s has attach: never, so the host cannot attach it" % _field(selected, "runnerKind"))
|
|
558
560
|
if selected.get("session") == "spawned" and session.get("spawn") != "allowed":
|
|
559
561
|
wrong("/selected/session", "%s has spawn: never, so the host cannot spawn it" % _field(selected, "runnerKind"))
|
|
560
|
-
if "drive" in chosen and "drive" in selected and selected["drive"]
|
|
562
|
+
if "drive" in chosen and "drive" in selected and not _estate._same(selected["drive"], chosen["drive"]):
|
|
561
563
|
wrong("/selected/drive", "the route runs %s through %s, not %s" % (_field(selected, "runnerKind"), _field(chosen, "drive"), _field(selected, "drive")))
|
|
562
564
|
if event.get("sticky") is True:
|
|
563
565
|
# A sticky relaunch keeps the conversation's runner and walks nothing above it.
|
|
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.35.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
|
|
11
11
|
---
|
|
@@ -16,10 +16,10 @@ compatibility: >-
|
|
|
16
16
|
Needs the Fabric desktop app (0.4.0 or later for fabric-mcp and the fabric_work tools) and an MCP client.
|
|
17
17
|
The status script needs Python 3.9+ (3.11+ to edit Kimi Code's config.toml). Starting Fabric needs the
|
|
18
18
|
local-lifecycle MCP server; without it the person opens Fabric. The full session hooks run in Claude Code from
|
|
19
|
-
this plugin; Codex
|
|
19
|
+
this plugin; Codex/Kimi use command hooks; Hermes/OpenCode/OpenClaw/Pi use native context plugins after opt-in.
|
|
20
20
|
metadata:
|
|
21
21
|
author: PassionCode.ai
|
|
22
|
-
version: "0.
|
|
22
|
+
version: "0.35.0"
|
|
23
23
|
---
|
|
24
24
|
|
|
25
25
|
# Working with Fabric
|
|
@@ -202,17 +202,19 @@ Never tell the person a call failed or succeeded when its outcome is unknown.
|
|
|
202
202
|
## Codex, Kimi Code and other agents
|
|
203
203
|
|
|
204
204
|
Register with the agent's own CLI (Step 2). They cannot rejoin their Fabric session from a hook yet, so they get one
|
|
205
|
-
|
|
206
|
-
person agrees:
|
|
205
|
+
context bridge only. Inspect its dry run, then apply within the person's existing authorization:
|
|
207
206
|
|
|
208
207
|
```bash
|
|
209
|
-
python3 <skill-dir>/scripts/fabric_session.py install-host-hooks --host codex # or
|
|
208
|
+
python3 <skill-dir>/scripts/fabric_session.py install-host-hooks --host codex # kimi, hermes, opencode, openclaw or pi; dry run
|
|
210
209
|
python3 <skill-dir>/scripts/fabric_session.py install-host-hooks --host codex --apply --expect-sha256 <digest>
|
|
211
210
|
```
|
|
212
211
|
|
|
213
212
|
It edits `~/.codex/hooks.json` (`hooks.SessionStart`) or appends one `[[hooks]]` table to `~/.kimi-code/config.toml`,
|
|
214
213
|
backs the file up first, refuses a file of another shape or one that changed since the dry run, and `--remove` takes
|
|
215
|
-
it out again.
|
|
214
|
+
it out again. Hermes/OpenCode/OpenClaw/Pi use their native plugin loaders, not Claude hook JSON.
|
|
215
|
+
Hermes also needs `hermes plugins enable fabric-agent-adapter`; removal uses `hermes plugins disable fabric-agent-adapter`.
|
|
216
|
+
Read [native host hooks](references/native-host-hooks.md) for exact paths, permission behavior and verification.
|
|
217
|
+
Registration and a loaded hook do not prove live Fabric admission. Never edit those files by hand for the person. Without the hook, put the preamble into the first prompt
|
|
216
218
|
yourself: "Call `fabric_whoami` first; `com.status` and `com.list`; `fabric_work_list`, then `fabric_work_claim`,
|
|
217
219
|
before you start; `fabric_work_release` when you stop." Without an MCP client at all there is no way in: say so,
|
|
218
220
|
and do the work without claiming Fabric saw it.
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Native host context hooks (FAA-48)
|
|
2
|
+
|
|
3
|
+
Use this when installing, upgrading or diagnosing startup context outside Claude Code. Hook delivery and Fabric admission are separate: the agent still calls `fabric_whoami` and reads its actual answer. No adapter here enrolls a session, starts Fabric or calls a model.
|
|
4
|
+
|
|
5
|
+
## Supported adapters
|
|
6
|
+
|
|
7
|
+
| Host | Native integration | Output consumed by host | Activation |
|
|
8
|
+
|---|---|---|---|
|
|
9
|
+
| Claude Code | Plugin `hooks/hooks.json` | SessionStart `hookSpecificOutput.additionalContext` JSON | Enable the Claude plugin; reload the session |
|
|
10
|
+
| Codex | `hooks.SessionStart` in `$CODEX_HOME/hooks.json` | SessionStart JSON, including the event name | Install command hook; inspect `/hooks` if its exact definition needs trust |
|
|
11
|
+
| Kimi Code | `[[hooks]]` in `~/.kimi-code/config.toml` | Plain stdout; existing adapter retained | Install command hook; start a new session; current native consumption is not covered by the six-host fixture |
|
|
12
|
+
| Hermes | Native plugin prompt section `fabric.context` | Session-frozen section, maximum 4000 characters | Install files, then `hermes plugins enable fabric-agent-adapter` |
|
|
13
|
+
| OpenCode | Native `experimental.chat.system.transform` | Appends one unique system string | Install `.js` loader; start a new instance |
|
|
14
|
+
| OpenClaw | Plugin `before_prompt_build` | `{prependContext}` | Install native plugin path/config; host runtime must be installed; reload through its owner |
|
|
15
|
+
| Pi | Extension `before_agent_start` | Structured section, or preserved forced/legacy system prompt | Install `.js` loader; use the host's extension reload or a new instance |
|
|
16
|
+
|
|
17
|
+
These adapters were checked against Codex 0.162.1, Hermes 0.21.4 (2026.9.21), OpenCode 1.19.0, OpenClaw 2026.10.4 and Pi 1.1.0. Claude Code 2.1.299 also executed SessionStart through an isolated SDK initialization, without a user turn. A parser/loader fixture does not establish a model run or live Fabric connection.
|
|
18
|
+
|
|
19
|
+
Codex accepts plain text only when it does not look like JSON. The old `[fabric-agent-adapter]` prefix begins with `[`, so Codex tries JSON parsing and reports `hook returned invalid session start JSON output`. Structured JSON removes that ambiguity.
|
|
20
|
+
|
|
21
|
+
## Install, update and remove
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
python3 <skill-dir>/scripts/fabric_session.py install-host-hooks --host opencode
|
|
25
|
+
python3 <skill-dir>/scripts/fabric_session.py install-host-hooks --host opencode --apply --expect-sha256 <printed-digest>
|
|
26
|
+
python3 <skill-dir>/scripts/fabric_session.py install-host-hooks --host opencode --remove
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Use `codex`, `kimi`, `hermes`, `opencode`, `openclaw` or `pi`. Default is a dry run. `--config` overrides the native destination, `--script` selects an absolute `fabric_session.py` in a **complete installed skill**. Keep that path stable; a versioned plugin cache disappears on updates. Reapply after moving the skill. The native installer checks the helper and every required host module before writing a loader.
|
|
30
|
+
|
|
31
|
+
| Host | Default destination |
|
|
32
|
+
|---|---|
|
|
33
|
+
| Hermes | `$HERMES_HOME/plugins/fabric-agent-adapter/`, default `~/.hermes/plugins/` |
|
|
34
|
+
| OpenCode | `$XDG_CONFIG_HOME/opencode/plugins/fabric-agent-adapter.js`, default `~/.config/` |
|
|
35
|
+
| OpenClaw | `$OPENCLAW_CONFIG_PATH`, otherwise `$OPENCLAW_STATE_DIR/openclaw.json`, default `~/.openclaw/` |
|
|
36
|
+
| Pi | `$PI_CODING_AGENT_DIR/extensions/fabric-agent-adapter.js`, default `~/.pi/agent/` |
|
|
37
|
+
|
|
38
|
+
Hermes activation remains its own explicit CLI step. Remove its activation with `hermes plugins disable fabric-agent-adapter`, then remove the loader. The installer does not parse or rewrite arbitrary Hermes YAML.
|
|
39
|
+
|
|
40
|
+
OpenClaw uses strict JSON. For a JSON5 configuration, use OpenClaw's own config commands; this installer refuses it without guessing. An existing master/entry disable, deny list or denied hook permission is preserved and reported. This adapter needs both `allowConversationAccess` and `allowPromptInjection`; it does not read messages. Removing it drops its recognized load path and owned entry; customized entries are disabled. An existing allow-list entry is retained to avoid changing broader discovery policy. Removal is ownership-limited, not an exact rollback of the whole config.
|
|
41
|
+
|
|
42
|
+
OpenCode and Pi loader files carry a managed marker. A foreign file at the same destination is refused, not overwritten. Existing unrelated extensions/settings remain. Config replacement uses a backup and a stale-plan hash check; Hermes's multi-file loader is preflighted before writes, but the operation is not a filesystem-wide transaction.
|
|
43
|
+
|
|
44
|
+
## Runtime and failure boundaries
|
|
45
|
+
|
|
46
|
+
- Native bridges give the Python helper only a working directory and, when observed, a boolean Fabric registration. Hermes uses its native read-only config, OpenCode its resolved enabled MCP entry, OpenClaw its resolved MCP servers and Pi an exact callable `fabric_whoami` tool name. Config registration is not admission.
|
|
47
|
+
- Missing helper/Python, malformed values, excessive output or a four-second deadline produce no context. Existing host errors and permission failures are not silenced globally.
|
|
48
|
+
- Fabric-launched sessions are silent. Other native events are not advertised: turn observations, unread counts and rejoin-only release remain Claude-specific and feature-gated.
|
|
49
|
+
- A host in Switchboard's catalog may support MCP, proxy or launch while exposing no startup context hook. Never copy this configuration to another host solely because it can launch an agent.
|
|
50
|
+
|
|
51
|
+
## Verification
|
|
52
|
+
|
|
53
|
+
The repository has focused tests in `test/test_fabric_session.py`, `test/test_native_hook_install.py`, `test/test_hermes_host.py`, `test/host-hooks-opencode-pi.test.mjs` and `test/openclaw-hooks.test.mjs`. Optional native probes use isolated configuration and neutral fixtures, without paid inference. The task's evidence and full 31-host survey live in the owning repository under `docs/evidence/specs/host-hooks-2026-10-16/`; these repository paths are not required to run the installed skill.
|
|
@@ -32,7 +32,8 @@ zero; a `degraded` board that still gives the count is read.
|
|
|
32
32
|
|
|
33
33
|
## Codex and Kimi Code
|
|
34
34
|
|
|
35
|
-
`fabric_session.py hook session-start --host codex|kimi`
|
|
35
|
+
`fabric_session.py hook session-start --host codex|kimi` emits structured SessionStart JSON for Codex and plain text
|
|
36
|
+
for Kimi when the host has `fabric`
|
|
36
37
|
registered (`[mcp_servers.fabric]` in `$CODEX_HOME/config.toml`; `mcpServers.fabric` in `~/.kimi-code/mcp.json`),
|
|
37
38
|
plus the service line. Nothing else: no turn observation and no unread line, because a rejoin needs the runtime
|
|
38
39
|
session id Fabric keeps with the enrollment, which only Claude Code supplies today (D1).
|
|
@@ -46,6 +47,9 @@ the entry out again. Codex may ask the person to trust a new hook (`/hooks`) bef
|
|
|
46
47
|
`fabric_session.py` of the skills install (`~/.agents/skills/working-with-fabric/`) when there is one, else this
|
|
47
48
|
script's own path; a plugin cache path changes with each update, so pass `--script <a stable path>` there.
|
|
48
49
|
|
|
50
|
+
Native Hermes, OpenCode, OpenClaw and Pi bridges are described in [native host hooks](native-host-hooks.md).
|
|
51
|
+
They consume the same context through their own APIs. They never use the Claude-only rejoin/turn lifecycle.
|
|
52
|
+
|
|
49
53
|
## `watch-mail`
|
|
50
54
|
|
|
51
55
|
`fabric_session.py watch-mail [--interval 60]` is for an idle Claude Code session waiting on the board; the agent
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
COM-04; FAA-12a).
|
|
4
4
|
|
|
5
5
|
fabric_session.py status [--json] [--cwd DIR] # Fabric, fabric-mcp, the registrations; ONE next action
|
|
6
|
-
fabric_session.py hook session-start [--host H] # H: claude
|
|
6
|
+
fabric_session.py hook session-start [--host H] # H: claude, codex, kimi, hermes, opencode, openclaw, pi
|
|
7
7
|
fabric_session.py hook stop|session-end|user-prompt-submit # Claude Code only: hook JSON on stdin, always exit 0
|
|
8
8
|
fabric_session.py deliver SESSION_DIR # the detached worker the stop and session-end hooks start
|
|
9
9
|
fabric_session.py watch-mail [--interval 60] # Claude Code: one line each time the unread count rises
|
|
10
|
-
fabric_session.py install-host-hooks --host
|
|
10
|
+
fabric_session.py install-host-hooks --host HOST [--apply | --remove] # dry run unless --apply/--remove
|
|
11
11
|
|
|
12
12
|
Standard library only. Nothing here reads a token or a credential: Fabric's door token and enrollment credentials
|
|
13
13
|
stay with `fabric-mcp`. Nothing secret goes into argv or the log. A turn observation is facts only: the HEAD commit,
|
|
@@ -60,8 +60,10 @@ WATCH_MAX_BACKOFF_S = 900
|
|
|
60
60
|
SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
|
|
61
61
|
LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
|
|
62
62
|
LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
|
|
63
|
-
VERSION = "0.
|
|
64
|
-
HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"
|
|
63
|
+
VERSION = "0.35.0"
|
|
64
|
+
HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
|
|
65
|
+
"hermes": "Hermes", "opencode": "OpenCode", "openclaw": "OpenClaw", "pi": "Pi"}
|
|
66
|
+
NATIVE_PLUGIN_HOSTS = frozenset(("hermes", "opencode", "openclaw", "pi"))
|
|
65
67
|
# A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
|
|
66
68
|
# what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value
|
|
67
69
|
# — two of them carry the session's credential.
|
|
@@ -69,7 +71,8 @@ LAUNCH_MARKER = "FABRIC_LAUNCHED_SESSION"
|
|
|
69
71
|
LAUNCH_SIGNALS = ("FABRIC_ACP_SESSION", "FABRIC_BRIDGE_URL", "KILO_CONFIG_CONTENT")
|
|
70
72
|
|
|
71
73
|
PREAMBLE = (
|
|
72
|
-
"[fabric-agent-adapter] Fabric is running and this {host} has its MCP server `fabric
|
|
74
|
+
"[fabric-agent-adapter] Fabric is running and this {host} has its MCP server `fabric` registered. "
|
|
75
|
+
"Registration is not proof of connection or admission. "
|
|
73
76
|
"Call `fabric_whoami` first. Then read the project board: `com.status` (is it ready, how many unread), "
|
|
74
77
|
"`com.list` for the messages, and `com.read_ack` only for what you actually read. "
|
|
75
78
|
"Before you start work, call `fabric_work_list` (who is working on what here) and "
|
|
@@ -443,14 +446,17 @@ def read_hook_input() -> Dict[str, Any]:
|
|
|
443
446
|
|
|
444
447
|
def hook_session_start(payload: Dict[str, Any], env: Optional[Dict[str, str]] = None,
|
|
445
448
|
host: str = "claude") -> Optional[str]:
|
|
446
|
-
"""What to print, or None: Claude
|
|
449
|
+
"""What to print, or None: Claude/Codex hook JSON, plain context for native plugins and Kimi. The preamble when the
|
|
447
450
|
host has `fabric` registered (one line pointing at the broker when Fabric is down), and the machine's
|
|
448
451
|
descriptor-count line when it has Fabric services. Silent in a session Fabric launched."""
|
|
449
452
|
if host not in HOSTS or fabric_launched(env):
|
|
450
453
|
return None
|
|
451
454
|
cwd = Path(payload.get("cwd") or os.getcwd())
|
|
452
455
|
parts: List[str] = []
|
|
453
|
-
|
|
456
|
+
registered = host_registered(host, cwd, env)
|
|
457
|
+
if host in NATIVE_PLUGIN_HOSTS and payload.get("fabric_registered") is True:
|
|
458
|
+
registered = True # Native bridge's boolean observation, never enrollment or authorization.
|
|
459
|
+
if registered:
|
|
454
460
|
hub = locate_hub(fabric_root(env=env))
|
|
455
461
|
if hub["state"] == "running":
|
|
456
462
|
parts.append(PREAMBLE.format(host=HOSTS[host]))
|
|
@@ -462,7 +468,7 @@ def hook_session_start(payload: Dict[str, Any], env: Optional[Dict[str, str]] =
|
|
|
462
468
|
if not parts or launched_by_ancestor(env):
|
|
463
469
|
return None
|
|
464
470
|
context = "\n".join(parts)
|
|
465
|
-
if host
|
|
471
|
+
if host not in ("claude", "codex"):
|
|
466
472
|
return context
|
|
467
473
|
return json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}})
|
|
468
474
|
|
|
@@ -1083,8 +1089,14 @@ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] =
|
|
|
1083
1089
|
args = parser.parse_args(list(argv))
|
|
1084
1090
|
except SystemExit as exc:
|
|
1085
1091
|
return int(exc.code or 2)
|
|
1092
|
+
if args.host in NATIVE_PLUGIN_HOSTS:
|
|
1093
|
+
import importlib.util
|
|
1094
|
+
spec = importlib.util.spec_from_file_location("_fabric_native_installer", Path(__file__).with_name("host_hooks.py"))
|
|
1095
|
+
installer = importlib.util.module_from_spec(spec)
|
|
1096
|
+
spec.loader.exec_module(installer)
|
|
1097
|
+
return installer.main(list(argv), env=env, out=out, err=err)
|
|
1086
1098
|
if args.host not in ("codex", "kimi"):
|
|
1087
|
-
err("install-host-hooks: --host must be codex or
|
|
1099
|
+
err("install-host-hooks: --host must be codex, kimi, hermes, opencode, openclaw or pi (Claude uses its plugin)")
|
|
1088
1100
|
return 2
|
|
1089
1101
|
if args.config:
|
|
1090
1102
|
path = Path(args.config).expanduser()
|
|
@@ -1117,8 +1129,15 @@ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] =
|
|
|
1117
1129
|
del doc["hooks"]["SessionStart"]
|
|
1118
1130
|
new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
|
|
1119
1131
|
elif present:
|
|
1120
|
-
|
|
1121
|
-
|
|
1132
|
+
owned = [h for g in doc["hooks"]["SessionStart"] for h in g.get("hooks", [])
|
|
1133
|
+
if _is_ours(h.get("command"), args.host)]
|
|
1134
|
+
if len(owned) != 1:
|
|
1135
|
+
raise Refused("multiple Fabric hooks are configured; preserve their matchers and resolve duplicates before updating")
|
|
1136
|
+
if owned[0]["command"] == command:
|
|
1137
|
+
out("install-host-hooks: the Fabric preamble hook is already in %s; nothing to change" % path)
|
|
1138
|
+
return 0
|
|
1139
|
+
owned[0]["command"] = command
|
|
1140
|
+
new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
|
|
1122
1141
|
else:
|
|
1123
1142
|
doc.setdefault("hooks", {}).setdefault("SessionStart", []).append(entry)
|
|
1124
1143
|
new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
|