@passioncode-ai/fabric-agent-adapter 0.34.1 → 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 (20) hide show
  1. package/CHANGELOG.md +13 -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/building-fabric-services/SKILL.md +1 -1
  7. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  8. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +8 -6
  9. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +53 -0
  10. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +5 -1
  11. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +30 -11
  12. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +178 -0
  13. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +33 -0
  14. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/hermes/__init__.py +135 -0
  15. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/hermes/plugin.yaml +5 -0
  16. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +45 -0
  17. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/openclaw.plugin.json +11 -0
  18. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/package.json +10 -0
  19. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +24 -0
  20. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/pi/fabric.mjs +36 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.35.0 - 2026-10-16
4
+
5
+ ### Fixed
6
+
7
+ - Codex SessionStart emits explicit JSON. Its preamble began with `[fabric-agent-adapter]`, which Codex treats as JSON-looking output and rejected with `hook returned invalid session start JSON output`.
8
+ - The Codex hook installer updates a stale owned command while preserving its matcher, timeout and unrelated hooks; ambiguous duplicate entries are refused.
9
+
10
+ ### Added
11
+
12
+ - Native bounded context adapters for Hermes prompt sections, OpenCode system transforms, OpenClaw `before_prompt_build`, and Pi `before_agent_start`; no model calls or lifecycle enrollment from these hooks.
13
+ - Dry-run native installers with source preflight, config hash fences, managed-file checks and preservation of unrelated settings and explicit OpenClaw denials. Hermes opt-in uses its own CLI.
14
+ - Optional native loader probes, negative permission and timeout tests, and a sourced capability survey of all 30 Switchboard hosts plus Tabby. Native context loading is separate from live Fabric admission.
15
+
3
16
  ## 0.34.1 - 2026-10-15
4
17
 
5
18
  A review of 0.33.0 and 0.34.0 found the profile and route rules disagreeing with the contract on JSON they never met in
package/README.md CHANGED
@@ -40,7 +40,10 @@ and the board's unread count; UserPromptSubmit adds one line only when that coun
40
40
  session left claimed. Stop, UserPromptSubmit and SessionEnd act only when the running Fabric advertises
41
41
  `session-hooks/1`. All are silent in a session Fabric launched, never block the session, read no token and always
42
42
  exit 0. `fabric_session.py watch-mail` waits for board mail under a background watcher, and `install-host-hooks
43
- --host codex|kimi` adds the session-start preamble to Codex or Kimi Code (a dry run unless `--apply`). The
43
+ --host codex|kimi|hermes|opencode|openclaw|pi` installs each host's native context bridge (dry run unless `--apply`).
44
+ Codex receives structured SessionStart JSON; Hermes, OpenCode, OpenClaw and Pi use native plugin APIs.
45
+ See the [native host matrix](plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md)
46
+ for activation, reload and verification limits. The
44
47
  [hooks reference](plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md) lists what
45
48
  they need from Fabric; the [board reference](plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md)
46
49
  is the board-first workflow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.34.1",
3
+ "version": "0.35.0",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.34.1",
6
+ "version": "0.35.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -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.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.34.1"
8
+ version: "0.35.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
11
11
  ---
@@ -18,7 +18,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
18
18
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.34.1"
21
+ version: "0.35.0"
22
22
  contract-version: "0.1.0"
23
23
  extension: "fabric-service/0.1"
24
24
  extension-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
@@ -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.1"
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.1"
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.1"
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")
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env python3
2
+ # #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
3
+ """Install bounded native context bridges. Dry-run by default; no model/service calls.
4
+
5
+ OpenCode/Pi load a managed shim. Hermes loads a managed plugin wrapper and needs
6
+ its native `hermes plugins enable fabric-agent-adapter` opt-in separately.
7
+ OpenClaw loads the bundled plugin through its native configuration. Removal
8
+ keeps unrelated settings and disables only this entry; existing allow/deny policy
9
+ is never loosened. JSON5 configuration is refused, not rewritten as guessed JSON.
10
+ """
11
+ import argparse
12
+ import hashlib
13
+ import importlib.util
14
+ import json
15
+ import os
16
+ from pathlib import Path
17
+
18
+ BASE = Path(__file__).resolve().parent
19
+ spec = importlib.util.spec_from_file_location('_fabric_hook_install_common', BASE / 'fabric_session.py')
20
+ fs = importlib.util.module_from_spec(spec)
21
+ spec.loader.exec_module(fs)
22
+ MARKER = 'fabric-agent-adapter managed host hook v1'
23
+ IDENTITY = 'fabric-agent-adapter'
24
+
25
+
26
+ def destination(host, env):
27
+ home = fs._home(env)
28
+ if host == 'opencode':
29
+ return Path(env.get('XDG_CONFIG_HOME') or home / '.config') / 'opencode/plugins/fabric-agent-adapter.js'
30
+ if host == 'pi':
31
+ return Path(env.get('PI_CODING_AGENT_DIR') or home / '.pi/agent') / 'extensions/fabric-agent-adapter.js'
32
+ if host == 'hermes':
33
+ return Path(env.get('HERMES_HOME') or home / '.hermes') / 'plugins/fabric-agent-adapter'
34
+ return Path(env.get('OPENCLAW_CONFIG_PATH') or Path(env.get('OPENCLAW_STATE_DIR') or home / '.openclaw') / 'openclaw.json')
35
+
36
+
37
+ def _object(parent, key):
38
+ if key not in parent: parent[key] = {}
39
+ if not isinstance(parent[key], dict): raise fs.Refused('unexpected object shape at ' + key)
40
+ return parent[key]
41
+
42
+
43
+ def _list(parent, key):
44
+ if key not in parent: parent[key] = []
45
+ if not isinstance(parent[key], list) or not all(isinstance(x, str) for x in parent[key]):
46
+ raise fs.Refused('unexpected string-list shape at ' + key)
47
+ return parent[key]
48
+
49
+
50
+ def openclaw_edit(data, script, remove):
51
+ try:
52
+ doc = json.loads(data.decode('utf-8')) if data else {}
53
+ except (ValueError, UnicodeError):
54
+ raise fs.Refused('OpenClaw config is not strict JSON; use the native config CLI for JSON5')
55
+ if not isinstance(doc, dict): raise fs.Refused('OpenClaw config must be an object')
56
+ target = str(script.parent / 'hosts/openclaw')
57
+ def ours(value):
58
+ if value == target:
59
+ return True
60
+ if not value.replace('\\', '/').endswith('/working-with-fabric/scripts/hosts/openclaw'):
61
+ return False
62
+ try:
63
+ manifest = fs.read_small(Path(value) / 'openclaw.plugin.json')
64
+ return isinstance(manifest, str) and json.loads(manifest).get('id') == IDENTITY
65
+ except (ValueError, AttributeError):
66
+ return False
67
+ if remove:
68
+ existing = doc.get('plugins', {})
69
+ if isinstance(existing, dict):
70
+ existing_entries = existing.get('entries', {})
71
+ existing_load = existing.get('load', {})
72
+ existing_paths = existing_load.get('paths', []) if isinstance(existing_load, dict) else []
73
+ has_entry = isinstance(existing_entries, dict) and IDENTITY in existing_entries
74
+ has_path = isinstance(existing_paths, list) and any(isinstance(p, str) and ours(p) for p in existing_paths)
75
+ if not has_entry and not has_path:
76
+ return data
77
+ plugins = _object(doc, 'plugins')
78
+ if not remove and (plugins.get('enabled') is False or IDENTITY in _list(plugins, 'deny')):
79
+ raise fs.Refused('existing OpenClaw plugin policy disables this adapter; policy was preserved')
80
+ entries = _object(plugins, 'entries')
81
+ entry = _object(entries, IDENTITY)
82
+ hooks = _object(entry, 'hooks')
83
+ if not remove and (entry.get('enabled') is False or any(hooks.get(k) is False for k in ('allowConversationAccess','allowPromptInjection'))):
84
+ raise fs.Refused('existing OpenClaw entry denies this hook; policy was preserved')
85
+ load = _object(plugins, 'load')
86
+ paths = _list(load, 'paths')
87
+ kept = [p for p in paths if not ours(p)]
88
+ load['paths'] = kept if remove else kept + [target]
89
+ entry['enabled'] = not remove
90
+ if remove and set(entry) <= {'enabled','hooks'} and hooks == {'allowConversationAccess':True,'allowPromptInjection':True}:
91
+ del entries[IDENTITY]
92
+ if not remove:
93
+ hooks.update(allowConversationAccess=True, allowPromptInjection=True)
94
+ if 'allow' in plugins:
95
+ allow = _list(plugins, 'allow')
96
+ if IDENTITY not in allow: allow.append(IDENTITY)
97
+ return (json.dumps(doc, indent=2) + '\n').encode()
98
+
99
+
100
+ def plan(host, path, script, remove):
101
+ """Return [(path, expected bytes, desired bytes|None)] before any mutation."""
102
+ if host == 'openclaw':
103
+ old = fs._read_bytes(path)
104
+ if remove and old is None: return []
105
+ return [(path, old, openclaw_edit(old, script, remove))]
106
+ if host == 'hermes':
107
+ if path.is_symlink(): raise fs.Refused('plugin directory is a symlink; nothing was changed')
108
+ module = str(script.parent / 'hosts/hermes/__init__.py')
109
+ loader = ('# ' + MARKER + '\nimport importlib.util\n'
110
+ '_spec = importlib.util.spec_from_file_location("_fabric_native_hermes", ' + repr(module) + ')\n'
111
+ '_module = importlib.util.module_from_spec(_spec)\n_spec.loader.exec_module(_module)\nregister = _module.register\n')
112
+ manifest = '# ' + MARKER + '\n' + (script.parent / 'hosts/hermes/plugin.yaml').read_text()
113
+ desired = {path/'__init__.py':loader, path/'plugin.yaml':manifest}
114
+ else:
115
+ module = (script.parent / ('hosts/' + host + '/fabric.mjs')).as_uri()
116
+ desired = {path: '// ' + MARKER + '\nexport { default } from ' + json.dumps(module) + ';\n'}
117
+ changes = []
118
+ for target, text in desired.items():
119
+ old = fs._read_bytes(target)
120
+ if old is not None and MARKER.encode() not in old.split(b'\n', 1)[0]:
121
+ raise fs.Refused('destination is not an adapter-managed file: ' + str(target))
122
+ changes.append((target, old, None if remove else text.encode()))
123
+ return changes
124
+
125
+
126
+ def plan_digest(changes):
127
+ return hashlib.sha256(json.dumps([(str(p),fs._digest(old)) for p,old,_ in changes]).encode()).hexdigest()
128
+
129
+
130
+ def main(argv=None, env=None, out=print, err=print):
131
+ env = os.environ if env is None else env
132
+ parser = argparse.ArgumentParser(description=__doc__)
133
+ parser.add_argument('--host', choices=['hermes','opencode','openclaw','pi'], required=True)
134
+ mode=parser.add_mutually_exclusive_group()
135
+ mode.add_argument('--apply',action='store_true');mode.add_argument('--remove',action='store_true');mode.add_argument('--dry-run',action='store_true')
136
+ parser.add_argument('--config');parser.add_argument('--script');parser.add_argument('--expect-sha256')
137
+ args=parser.parse_args(argv)
138
+ try:
139
+ script=Path(args.script).expanduser().absolute() if args.script else BASE/'fabric_session.py'
140
+ if not script.is_file(): raise fs.Refused('shared helper is missing')
141
+ if not args.remove:
142
+ required = {'hermes':['hosts/hermes/__init__.py','hosts/hermes/plugin.yaml'],
143
+ 'opencode':['hosts/context.mjs','hosts/opencode/fabric.mjs'],
144
+ 'pi':['hosts/context.mjs','hosts/pi/fabric.mjs'],
145
+ 'openclaw':['hosts/context.mjs','hosts/openclaw/index.mjs','hosts/openclaw/package.json','hosts/openclaw/openclaw.plugin.json']}[args.host]
146
+ if any(not (script.parent/p).is_file() for p in required):
147
+ raise fs.Refused('native adapter source is missing; update the complete skill before installing hooks')
148
+ path=Path(args.config).expanduser().absolute() if args.config else destination(args.host,env)
149
+ changes=plan(args.host,path,script,args.remove)
150
+ digest=plan_digest(changes)
151
+ if args.expect_sha256 and digest != args.expect_sha256: raise fs.Refused('configuration changed since the plan')
152
+ out('host=%s sha256=%s action=%s' % (args.host,digest,'remove' if args.remove else 'install'))
153
+ for p,old,new in changes: out('%s %s' % ('unchanged' if old==new else 'would change',p))
154
+ if args.apply or args.remove:
155
+ # Preflight every destination before changing even the first one.
156
+ if any(fs._read_bytes(p) != old for p,old,_ in changes): raise fs.Refused('destination changed during planning')
157
+ for p,old,new in changes:
158
+ if old==new: continue
159
+ if new is None:
160
+ if fs._read_bytes(p) != old: raise fs.Refused('destination changed during removal')
161
+ if old is not None: p.unlink()
162
+ else: fs._replace_file(p,new,old)
163
+ if args.host=='hermes' and args.remove:
164
+ # A foreign addition in this directory is preserved.
165
+ try:path.rmdir()
166
+ except OSError:pass
167
+ out('native loader files updated; existing sessions must reload to use them')
168
+ else: out('dry run; no files changed. Apply with --apply --expect-sha256 ' + digest)
169
+ if args.host=='hermes':
170
+ out('native activation: hermes plugins %s fabric-agent-adapter' % ('disable' if args.remove else 'enable'))
171
+ return 0
172
+ except (fs.Refused,OSError,ValueError) as exc:
173
+ # Refused has only our shape diagnostics; parser failures never echo config bytes.
174
+ err('host-hooks: refused: ' + (str(exc) if isinstance(exc,fs.Refused) else type(exc).__name__))
175
+ return 1
176
+
177
+ if __name__=='__main__':raise SystemExit(main())
178
+ # #endregion native-host-context
@@ -0,0 +1,33 @@
1
+ // #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
2
+ // Shared, non-resident bridge. A context hook never enrolls or calls a model.
3
+ import { execFile } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const scriptPath = fileURLToPath(new URL('../fabric_session.py', import.meta.url));
7
+ const hosts = new Set(['opencode', 'pi', 'openclaw']);
8
+ const cap = 64 * 1024;
9
+
10
+ export async function readContext(host, cwd, options = {}) {
11
+ if (!hosts.has(host) || typeof cwd !== 'string' || !cwd || cwd.includes('\0')) return '';
12
+ const payload = {cwd};
13
+ if (options.fabricRegistered === true) payload.fabric_registered = true;
14
+ const input = JSON.stringify(payload);
15
+ if (Buffer.byteLength(input) > cap) return '';
16
+ const timeout = Number.isFinite(options.timeoutMs)
17
+ ? Math.max(1, Math.min(4000, options.timeoutMs)) : 4000;
18
+ return new Promise(resolve => {
19
+ try {
20
+ const child = execFile(options.python || 'python3',
21
+ ['-S', options.script || scriptPath, 'hook', 'session-start', '--host', host],
22
+ {timeout, killSignal:'SIGKILL', maxBuffer:cap, encoding:'utf8', windowsHide:true},
23
+ (error, stdout) => {
24
+ if (error || typeof stdout !== 'string' || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(stdout)) return resolve('');
25
+ resolve(stdout.trim());
26
+ });
27
+ // A missing executable or early exit may close stdin before it is written.
28
+ child.stdin?.on('error', () => {});
29
+ child.stdin?.end(input);
30
+ } catch { resolve(''); }
31
+ });
32
+ }
33
+ // #endregion native-host-context
@@ -0,0 +1,135 @@
1
+ # #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
2
+ """Hermes native, session-frozen Fabric context; no model or lifecycle worker."""
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import subprocess
7
+ import sys
8
+ import signal
9
+ import threading
10
+ import time
11
+ from collections.abc import Mapping
12
+
13
+ MAX_CONTEXT_CHARS = 4000
14
+ TIMEOUT_SECONDS = 4
15
+ MAX_OUTPUT_BYTES = 16000
16
+ MAX_INPUT_BYTES = 4096
17
+
18
+
19
+ def _kill_owned(process):
20
+ try:
21
+ if os.name == "posix":
22
+ os.killpg(process.pid, signal.SIGKILL)
23
+ else:
24
+ process.kill()
25
+ except (OSError, ProcessLookupError):
26
+ pass
27
+
28
+
29
+ def _capture(command, payload):
30
+ """Bound output while reading, not after buffering a noisy helper's entire stdout."""
31
+ if len(payload) > MAX_INPUT_BYTES:
32
+ return None
33
+ process = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
34
+ stderr=subprocess.DEVNULL, start_new_session=os.name == "posix")
35
+ output = bytearray()
36
+ invalid = threading.Event()
37
+ deadline = time.monotonic() + TIMEOUT_SECONDS
38
+
39
+ def read_output():
40
+ try:
41
+ while True:
42
+ chunk = process.stdout.read(min(1024, MAX_OUTPUT_BYTES + 1 - len(output)))
43
+ if not chunk:
44
+ return
45
+ output.extend(chunk)
46
+ if len(output) > MAX_OUTPUT_BYTES:
47
+ invalid.set()
48
+ _kill_owned(process)
49
+ return
50
+ except (OSError, ValueError):
51
+ invalid.set()
52
+
53
+ reader = threading.Thread(target=read_output, daemon=True)
54
+ reader.start()
55
+ try:
56
+ process.stdin.write(payload)
57
+ process.stdin.close()
58
+ process.wait(timeout=max(0.001, deadline - time.monotonic()))
59
+ reader.join(timeout=max(0, deadline - time.monotonic()))
60
+ if reader.is_alive() or invalid.is_set() or process.returncode != 0:
61
+ return None
62
+ return bytes(output)
63
+ except (OSError, subprocess.TimeoutExpired):
64
+ return None
65
+ finally:
66
+ # Own process group only: also close a pipe inherited by an owned child.
67
+ _kill_owned(process)
68
+ process.wait()
69
+ reader.join(timeout=0.2)
70
+ if not reader.is_alive():
71
+ process.stdout.close()
72
+ process.stdin.close()
73
+
74
+
75
+ def fabric_registered(config=None):
76
+ """Read only the native registration flag; never forward server config or credentials."""
77
+ try:
78
+ if config is None:
79
+ from hermes_cli.config import load_config_readonly
80
+ config = load_config_readonly()
81
+ servers = config.get("mcp_servers") if isinstance(config, Mapping) else None
82
+ server = servers.get("fabric") if isinstance(servers, Mapping) else None
83
+ if not isinstance(server, Mapping):
84
+ return False
85
+ enabled = server.get("enabled", True)
86
+ return enabled is True or isinstance(enabled, str) and enabled.lower().strip() in ("true", "1", "yes", "on")
87
+ except (ImportError, OSError, TypeError, ValueError):
88
+ return False
89
+
90
+
91
+ def context_for_session(session_info, session_script=None):
92
+ """Read the shared preamble through its bounded plain-text host protocol."""
93
+ if not isinstance(session_info, Mapping):
94
+ return ""
95
+ cwd = session_info.get("cwd", "")
96
+ if not isinstance(cwd, str) or len(cwd) > MAX_INPUT_BYTES // 2 or "\x00" in cwd:
97
+ return ""
98
+ try:
99
+ if session_script is None:
100
+ script = Path(__file__).resolve().parents[2] / "fabric_session.py"
101
+ elif isinstance(session_script, str) and Path(session_script).is_absolute():
102
+ script = Path(session_script)
103
+ else:
104
+ return ""
105
+ if not script.is_file():
106
+ return ""
107
+ result = _capture(
108
+ [sys.executable, "-S", str(script), "hook", "session-start", "--host", "hermes"],
109
+ json.dumps({"cwd": cwd or os.getcwd(), "fabric_registered": fabric_registered()},
110
+ ensure_ascii=False).encode("utf-8"),
111
+ )
112
+ if result is None:
113
+ return ""
114
+ text = result.decode("utf-8").strip()
115
+ if len(text) > MAX_CONTEXT_CHARS or any(ord(char) < 32 and char not in "\n\r\t" for char in text):
116
+ return ""
117
+ return text
118
+ except (OSError, ValueError, subprocess.TimeoutExpired):
119
+ # Context is best effort; never leak configuration, diagnostics or hook input.
120
+ return ""
121
+
122
+
123
+ def register(ctx):
124
+ """Use Hermes' cache-safe prompt section API, not observer hook returns."""
125
+ if not callable(getattr(ctx, "register_system_prompt_section", None)):
126
+ return
127
+ try:
128
+ script = ctx.get_config("session_script", None)
129
+ except (AttributeError, OSError, TypeError, ValueError):
130
+ return
131
+ ctx.register_system_prompt_section(
132
+ "fabric.context", lambda info: context_for_session(info, script),
133
+ position="after_memory", max_chars=MAX_CONTEXT_CHARS,
134
+ )
135
+ # #endregion native-host-context
@@ -0,0 +1,5 @@
1
+ name: fabric-agent-adapter
2
+ description: Bounded Fabric context frozen into the Hermes session prompt.
3
+ kind: standalone
4
+ requires_hermes: '>=0.21.4'
5
+ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
@@ -0,0 +1,45 @@
1
+ // #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
2
+ // Native OpenClaw prompt hook. Never observes conversation content or sends messages.
3
+ import { readContext } from '../context.mjs';
4
+
5
+ function fabricRegistered(config) {
6
+ const servers = config?.mcp?.servers;
7
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return false;
8
+ return ['fabric', 'fabric-mcp'].some(name => {
9
+ const entry = Object.hasOwn(servers, name) ? servers[name] : undefined;
10
+ return entry && typeof entry === 'object' && !Array.isArray(entry)
11
+ && entry.enabled !== false
12
+ && [entry.command, entry.url].some(value => typeof value === 'string' && value.trim());
13
+ });
14
+ }
15
+
16
+ export function createPlugin(contextReader = readContext) {
17
+ return {
18
+ id: 'fabric-agent-adapter',
19
+ name: 'Fabric Agent Adapter',
20
+ description: 'Adds bounded Fabric context through the native prompt hook.',
21
+ register(api) {
22
+ api.on('before_prompt_build', async (_event, ctx) => {
23
+ // workspaceDir is optional in the host contract. Do not substitute the
24
+ // Gateway process directory, which can belong to a different project.
25
+ if (typeof ctx?.workspaceDir !== 'string' || !ctx.workspaceDir.trim()) return;
26
+ try {
27
+ // Configured is not connected/admitted. Forward only the boolean,
28
+ // never server configuration, headers, environment or credentials.
29
+ const options = fabricRegistered(api.config) ? { fabricRegistered: true } : {};
30
+ const context = await contextReader('openclaw', ctx.workspaceDir, options);
31
+ ctx.hookInvocation?.assertActive();
32
+ if (typeof context !== 'string' || !context.trim()) return;
33
+ return { prependContext: context };
34
+ } catch {
35
+ // Missing dependencies, unavailable context and expired invocations
36
+ // must not stop an unrelated turn or leak diagnostics into its prompt.
37
+ return;
38
+ }
39
+ });
40
+ },
41
+ };
42
+ }
43
+
44
+ export default createPlugin();
45
+ // #endregion native-host-context
@@ -0,0 +1,11 @@
1
+ {
2
+ "id": "fabric-agent-adapter",
3
+ "name": "Fabric Agent Adapter",
4
+ "description": "Bounded Fabric context using the native OpenClaw prompt hook.",
5
+ "activation": { "onStartup": true },
6
+ "configSchema": {
7
+ "type": "object",
8
+ "additionalProperties": false,
9
+ "properties": {}
10
+ }
11
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@passioncode-ai/fabric-agent-adapter-openclaw",
3
+ "private": true,
4
+ "type": "module",
5
+ "license": "AGPL-3.0-only OR LicenseRef-PassionCode-Commercial",
6
+ "openclaw": {
7
+ "extensions": ["./index.mjs"],
8
+ "compat": { "pluginApi": ">=2026.10.4" }
9
+ }
10
+ }
@@ -0,0 +1,24 @@
1
+ // #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
2
+ import { readContext } from '../context.mjs';
3
+
4
+ const marker = '[fabric-agent-adapter]';
5
+
6
+ // OpenCode's legacy loader treats every export as a plugin factory.
7
+ export default async function fabricPlugin({directory} = {}) {
8
+ let fabricRegistered = false;
9
+ return {
10
+ config: async config => {
11
+ const entry = config?.mcp?.fabric;
12
+ // Observe the host's resolved configuration; never read or forward secrets.
13
+ fabricRegistered = !!entry && typeof entry === 'object' && entry.enabled !== false &&
14
+ ((entry.type === 'local' && Array.isArray(entry.command) && entry.command.length > 0) ||
15
+ (entry.type === 'remote' && typeof entry.url === 'string' && entry.url.length > 0));
16
+ },
17
+ 'experimental.chat.system.transform': async (_input, output) => {
18
+ if (!Array.isArray(output?.system) || output.system.some(part => typeof part === 'string' && part.includes(marker))) return;
19
+ const context = await readContext('opencode', directory, {fabricRegistered});
20
+ if (context && !output.system.includes(context)) output.system.push(context);
21
+ },
22
+ };
23
+ }
24
+ // #endregion native-host-context
@@ -0,0 +1,36 @@
1
+ // #region native-host-context — docs: plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md
2
+ import { readContext } from '../context.mjs';
3
+
4
+ const marker = '[fabric-agent-adapter]';
5
+
6
+ export default function fabricExtension(pi) {
7
+ pi.on('before_agent_start', async (event, ctx) => {
8
+ if (!event || !ctx) return;
9
+ const options = event.systemPromptOptions;
10
+ if (typeof options?.forceSystemPrompt === 'string' && options.forceSystemPrompt.includes(marker)) return;
11
+ if (!options && typeof event.systemPrompt === 'string' && event.systemPrompt.includes(marker)) return;
12
+ if (options?.sections?.fabric_agent_adapter) return;
13
+ let fabricRegistered = false;
14
+ try {
15
+ const tools = pi.getAllTools?.();
16
+ fabricRegistered = Array.isArray(tools) && tools.some(tool =>
17
+ tool?.exposure !== 'hidden' &&
18
+ (tool?.name === 'fabric_whoami' || tool?.name === 'mcp__fabric__fabric_whoami'));
19
+ } catch { /* Early startup or a disabled tool registry is not registration. */ }
20
+ const context = await readContext('pi', ctx.cwd, {fabricRegistered});
21
+ if (!context) return;
22
+ if (typeof options?.forceSystemPrompt === 'string') {
23
+ if (options.forceSystemPrompt.includes(context)) return;
24
+ options.forceSystemPrompt += '\n\n' + context;
25
+ } else if (options && typeof options === 'object') {
26
+ options.sections ??= {};
27
+ if (typeof options.sections !== 'object' || Array.isArray(options.sections)) return;
28
+ options.sections.fabric_agent_adapter = context;
29
+ } else if (typeof event.systemPrompt === 'string') {
30
+ if (event.systemPrompt.includes(context)) return;
31
+ // Older Pi versions expose only the replacement-prompt return contract.
32
+ return {systemPrompt:event.systemPrompt + '\n\n' + context};
33
+ }
34
+ });
35
+ }
36
+ // #endregion native-host-context