@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.
Files changed (26) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +4 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_identifiers.py +8 -1
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +3 -1
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +48 -31
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +40 -5
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_identifiers.py +8 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +67 -65
  13. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  14. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +8 -6
  15. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +53 -0
  16. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +5 -1
  17. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +30 -11
  18. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +178 -0
  19. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +33 -0
  20. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/hermes/__init__.py +135 -0
  21. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/hermes/plugin.yaml +5 -0
  22. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +45 -0
  23. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/openclaw.plugin.json +11 -0
  24. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/package.json +10 -0
  25. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +24 -0
  26. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/pi/fabric.mjs +36 -0
@@ -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", float(value))
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 _js_string(value: Any) -> str:
80
- """JavaScript's String(value), which the contract's messages and policy ids use."""
81
- if value is None:
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 resolve_model_profile(*, catalogue: Any = None, profiles: Any = None, choices: Any = None, policy: Any = None) -> Dict[str, Any]:
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, "policy": {"id": _js_string(meta.get("id")), "revision": _js_number(meta.get("revision"))}}
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 = _js_string(value["subscription"].get("login"))
174
- runner = SUBSCRIPTION_RUNNER.get(login)
175
- if runner != value.get("runnerKind"):
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 None else "its vendor's runner", _js_string(value.get("runnerKind")))))
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 key.get("modelAllowList") != value.get("modelProfile") or ("modelAllowList" in key) != ("modelProfile" in value):
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
- % (_js_string(key.get("modelAllowList")), _js_string(value.get("modelProfile")))))
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 = resolve_model_profile(catalogue=catalogue, profiles=value.get("profiles"), choices=choices, policy=value.get("policy"))
207
- if "resolved" not in value or value["resolved"] != expected:
208
- findings.append(_finding("FAC-SEM-060", "/resolved", "the resolution is %s" % _compact(expected)))
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
- rows = {row.get("id"): row for row in _list(catalogue.get("models") if isinstance(catalogue, dict) else None)}
350
- if model not in rows:
351
- return ["model %s is not in the model catalogue" % model]
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, _js_string(meta.get("id")))]
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), _js_string(meta.get("id")))]
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
- """String(value[key]) as JavaScript writes it: `undefined` for a missing key, `null` for null."""
405
- found = value.get(key, _MISSING) if isinstance(value, dict) else _MISSING
406
- if found is _MISSING:
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]) -> int:
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)) or not float(index).is_integer():
502
+ if isinstance(index, bool) or not isinstance(index, (int, float)):
502
503
  return -1
503
- return int(index)
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 candidate.get("runnerKind", _MISSING) != ref.get("runnerKind", _MISSING)
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: int) -> None:
541
+ def expect_probes(count: float) -> None:
540
542
  indexes = [_index_of(p) for p in probes]
541
- expected = list(range(max(count, 0)))
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(str, expected)) or "none", ", ".join(map(str, indexes)) or "none"))
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"] != chosen["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.34.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 and Kimi Code get the session-start preamble hook on the person's consent.
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.34.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
- hook only: the session-start preamble, which the person adds on consent. Show the edit first, then apply it when the
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 kimi; a dry run, writes nothing
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. Never edit those files by hand for the person. Without the hook, put the preamble into the first prompt
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.
@@ -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` prints the preamble as plain text when the host has `fabric`
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 (default), codex, kimi — the preamble
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 codex|kimi [--apply | --remove] # dry run unless --apply/--remove
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.34.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 Code's hook JSON, or plain text for Codex and Kimi Code. The preamble when the
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
- if host_registered(host, cwd, env):
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 != "claude":
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 kimi (Claude Code gets its hooks from the plugin)")
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
- out("install-host-hooks: the Fabric preamble hook is already in %s; nothing to change" % path)
1121
- return 0
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")