@passioncode-ai/fabric-agent-adapter 0.34.1 → 0.35.1
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 +41 -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/building-fabric-services/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +10 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +207 -0
- 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 +11 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +70 -13
- 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 +141 -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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.35.1 - 2026-10-17
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`creating-fabric-agents` → `references/resilience-and-autonomy.md`**: the rules that kept about twenty production
|
|
8
|
+
agents working through limits, model errors and restarts, from their retrospectives of 2026-10-17 — limits as
|
|
9
|
+
pauses and resume-first workers, an acceptance parser per model call, cross-vendor fallbacks and checkers, spend as
|
|
10
|
+
a persisted ledger with idempotency keys, decisions recorded instead of asked, tests watched failing, run ownership,
|
|
11
|
+
owner-tagged resources and secrets kept out of every child's environment. The skill reads it before the first
|
|
12
|
+
stage that calls a model or spends money and reports its checklist.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **The Hermes native adapter dropped a helper's answer** (FAA-51): a helper that exits without reading its input broke
|
|
17
|
+
the pipe while the payload was written, and the broken pipe discarded the helper's output. It no longer does.
|
|
18
|
+
- **`test/validate.py` validated other checkouts as this tree** (FAA-50): its walks entered `.worktrees/` (git
|
|
19
|
+
worktrees of this repository, git-ignored), so the main checkout failed the pin check — 442 errors — whenever a
|
|
20
|
+
worktree of another branch sat beside it. Every walk now skips `.git` and `.worktrees`.
|
|
21
|
+
- **The tests left directories behind** (FAA-50): the Node tests made their temporary directories with `mkdtempSync`
|
|
22
|
+
and removed none, and five Python tests used `mkdtemp` without a cleanup, so every run left about 40 directories in
|
|
23
|
+
the system's temp folder. The Node tests take theirs from `test/temp-dir.mjs`, removed when the process exits; the
|
|
24
|
+
Python tests register `addCleanup`. A full run now leaves none.
|
|
25
|
+
- **The session hooks' spool grew without bound** (FAA-50): a session folder was removed only when its worker delivered
|
|
26
|
+
the session's end, so a failed delivery or a session that ended without SessionEnd left its folder forever. The next
|
|
27
|
+
spooling hook now removes folders untouched for 7 days, never its own.
|
|
28
|
+
- **`install-host-hooks --host codex --remove`** names the SessionStart hook groups after ours that it moves: Codex
|
|
29
|
+
keys a hook's trust by its position, and asks the person to approve those again in `/hooks` (FAA-49).
|
|
30
|
+
|
|
31
|
+
## 0.35.0 - 2026-10-16
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- 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`.
|
|
36
|
+
- The Codex hook installer updates a stale owned command while preserving its matcher, timeout and unrelated hooks; ambiguous duplicate entries are refused.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- 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.
|
|
41
|
+
- 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.
|
|
42
|
+
- 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.
|
|
43
|
+
|
|
3
44
|
## 0.34.1 - 2026-10-15
|
|
4
45
|
|
|
5
46
|
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`
|
|
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.
|
|
3
|
+
"version": "0.35.1",
|
|
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.
|
|
6
|
+
"version": "0.35.1",
|
|
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.
|
|
8
|
+
version: "0.35.1"
|
|
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.
|
|
21
|
+
version: "0.35.1"
|
|
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.
|
|
8
|
+
version: "0.35.1"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "d8146cfb0a1c74fc6432284c4356bb0c864b37b5"
|
|
11
11
|
---
|
|
@@ -201,6 +201,15 @@ the pinned `schemas/operator-channel-status.schema.json`; the channel's runtime
|
|
|
201
201
|
probe rule here yet, so report them `NOT_RUN`. An agent with neither stops nor notify
|
|
202
202
|
events skips this step.
|
|
203
203
|
|
|
204
|
+
## Resilience and autonomy
|
|
205
|
+
|
|
206
|
+
Before the first stage that calls a model or spends money, read
|
|
207
|
+
[resilience and autonomy](references/resilience-and-autonomy.md) and design its answers in:
|
|
208
|
+
a limit pauses and resumes, every model answer passes the call site's own acceptance, fallbacks
|
|
209
|
+
and checkers cross vendors, spend is a persisted ledger with idempotency keys, open questions
|
|
210
|
+
become recorded decisions, and every fix has a test seen failing. Report its checklist with a
|
|
211
|
+
file and a test per line, or `NOT_RUN` where the agent has no such stage.
|
|
212
|
+
|
|
204
213
|
## Completion format
|
|
205
214
|
|
|
206
215
|
Report: capability, consumer, profile with the step-0 grill answers; the knowledge pack
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Resilience and autonomy — what kept agents working
|
|
2
|
+
|
|
3
|
+
Read when designing an agent's loop, its model calls, its spend and its stops — before the first
|
|
4
|
+
stage that calls a model or spends money. These rules come from a retrospective of about twenty
|
|
5
|
+
production agents (2026-10-17): each rule names the failure that taught it, generalized, and the
|
|
6
|
+
mechanism that held after the fix. Every rule is cheaper to build in than to retrofit.
|
|
7
|
+
|
|
8
|
+
## Contents
|
|
9
|
+
|
|
10
|
+
- Limits are pauses
|
|
11
|
+
- Model answers are untrusted until accepted
|
|
12
|
+
- Money is a ledger
|
|
13
|
+
- Autonomy: decide, record, continue
|
|
14
|
+
- Verification you can believe
|
|
15
|
+
- Operations that survive restarts
|
|
16
|
+
- Secrets and confinement
|
|
17
|
+
- Checklist
|
|
18
|
+
|
|
19
|
+
## Limits are pauses
|
|
20
|
+
|
|
21
|
+
1. **A provider or subscription limit pauses the job; it never fails it.** Store a typed pause
|
|
22
|
+
(`quota_exhausted`, `retryable: true`, the reset time when known) and resume from the same
|
|
23
|
+
stage. Failing it cost callers whole jobs and resubmits.
|
|
24
|
+
2. **Resume first.** Every worker reads its own state — checkpoint, worktree, branches, open
|
|
25
|
+
pull requests, notes — before it acts, and continues from there. Relaunching from scratch
|
|
26
|
+
after a limit recreated work over committed work and re-issued credentials already held.
|
|
27
|
+
3. **A silent worker is a limit until proven otherwise.** A watcher that sees no progress for N
|
|
28
|
+
minutes, or limit text in the last output, resumes the run with the *same* run id.
|
|
29
|
+
4. **Keep prompt cache keys stable across a resume.** One changed argument that every prompt
|
|
30
|
+
carries (today's date) re-keyed a finished stage and paid for it again.
|
|
31
|
+
5. **Count only the job's own failures toward its retry limit.** A lost lease, a busy lock or a
|
|
32
|
+
provider outage is bounded by a time budget instead; five infrastructure stops a day ended a
|
|
33
|
+
healthy phase as FAILED.
|
|
34
|
+
6. **Match concurrency to the account's rate, and cool down after a 429** per provider door: ten
|
|
35
|
+
parallel workers met a 429 about hourly and each lost a whole batch; three to five heavy ones,
|
|
36
|
+
resumed in batches, did not. A "busy" or "still starting" dependency is retried a bounded number
|
|
37
|
+
of times with spacing (a handful, seconds apart, only on refusals classified as transient) —
|
|
38
|
+
never once, never forever.
|
|
39
|
+
7. **Inside a fallback chain, do not retry a provider that answered 429 or timed out** — move to
|
|
40
|
+
the next one. Retries there stalled a waiting user. Stagger many resumes; bound each worker's
|
|
41
|
+
working set (a narrow time window, a prefix), so one stop does not become a burst.
|
|
42
|
+
|
|
43
|
+
## Model answers are untrusted until accepted
|
|
44
|
+
|
|
45
|
+
1. **"Answered" is not "usable".** Each call site has its own `accept(text)` — the parser the
|
|
46
|
+
result must pass. Empty content, a refusal, a truncation (`finish_reason: length`) or an
|
|
47
|
+
unparseable answer is a failure, and only an accepted answer may be cached. A cached
|
|
48
|
+
unreadable answer once served "0 labelled" for hours.
|
|
49
|
+
2. **Fallbacks cross vendors.** A chain is an ordered list of models from different vendors; a
|
|
50
|
+
same-vendor fallback refuses the same inputs. Build the chain where a test can refuse a lane
|
|
51
|
+
with no other-vendor route, and act on a new fallback only after measuring it.
|
|
52
|
+
3. **The checker is never the drafter's vendor.** Choose it per turn from the model that
|
|
53
|
+
*actually* drafted (a fallback may have), in the router, not in configuration. No checker
|
|
54
|
+
left, or a checker outage, means hold or stay silent — never ship unchecked.
|
|
55
|
+
4. **Size the output budget for the answer, not the input.** Reasoning tokens eat `max_tokens`:
|
|
56
|
+
a judge capped at 800 tokens returned no JSON in 8 of 14 calls. Give judges room or turn
|
|
57
|
+
reasoning off for that route; on `length`, retry once with a larger cap.
|
|
58
|
+
5. **Run code checks before model checks**, and degrade to a fixed safe reply with a reason code.
|
|
59
|
+
6. **A model's word verifies nothing.** A fact is verified by stored source text (or two truly
|
|
60
|
+
independent sources); an effect by the system of record it lands in — never by the page, a
|
|
61
|
+
flag the model set, or its "done".
|
|
62
|
+
7. **A coding agent runs only on a model it knows.** One CLI agent on an unfamiliar vendor's model
|
|
63
|
+
read its instructions and ended its turn with nothing written; measure each (agent, model)
|
|
64
|
+
pair live before making it a default.
|
|
65
|
+
8. **Pin what the agent says about itself.** A short block of true facts (what it can see, its
|
|
66
|
+
pipeline, its limits) lives in code; a test fails when a fact drifts. Forbid promises the
|
|
67
|
+
pipeline cannot keep.
|
|
68
|
+
|
|
69
|
+
## Money is a ledger
|
|
70
|
+
|
|
71
|
+
1. **Unknown is unknown, never zero** — in cost, in verdicts, in health — and nothing acts on it.
|
|
72
|
+
A missing price is a high estimate, a missing field is `NOT_RUN`, never a pass; a probe that
|
|
73
|
+
timed out is "unreadable", not "credential rejected"; a cooldown that could not be read is not
|
|
74
|
+
"never sent". An owner's refusal inside a successful response (`ok: false` over a 200) is a
|
|
75
|
+
refusal, never an empty result.
|
|
76
|
+
2. **Persist the attempt before dispatch** — its route and a hash of its prompt — and finalize it
|
|
77
|
+
in `finally`; spend is Σ actual + Σ estimate for attempts without an actual. In-memory counters
|
|
78
|
+
reset on every resume. A call that broke before the provider answered is reconciled against
|
|
79
|
+
the provider's own history by that route and hash, never guessed; one provider request id
|
|
80
|
+
belongs to exactly one ledger row.
|
|
81
|
+
3. **Check the cap before every HTTP attempt, retries included**, with one stop rule; record the
|
|
82
|
+
provider's own reported cost per call; count reserved or held tokens as spent.
|
|
83
|
+
4. **A resend of an unknown outcome reuses its idempotency key — or does not happen.** Derive
|
|
84
|
+
keys from the intent (action and entity, or run and step), never from a clock or a random
|
|
85
|
+
value, and make them unique in storage: time-and-random keys let redelivered requests charge
|
|
86
|
+
again. A write whose effect is uncertain stops as `outcome_unknown`; a new key there pays twice.
|
|
87
|
+
5. **A client error at submit is a rejection, not a lost task.** Settle it with known usage (zero
|
|
88
|
+
if none), release the reserved money in the same transaction, and never resend the identical
|
|
89
|
+
request; a 4xx while polling is still a lost task.
|
|
90
|
+
6. **A failed outer job cancels the paid work it started**, and a cancel is a row the worker reads
|
|
91
|
+
at every claim and checkpoint — never an in-memory flag that a restart forgets.
|
|
92
|
+
7. **Reuse what was paid for.** A restarted stage looks up the job's successful call with the
|
|
93
|
+
same route, prompt version and inputs before calling again (one restart made 117 paid calls
|
|
94
|
+
where 49 sufficed). Cache keys are content plus definition, never an identity alone.
|
|
95
|
+
8. **Deny spending tools by enumerated name, and test the list** — a provider's newer tools were
|
|
96
|
+
missing from a deny list while older ones were denied.
|
|
97
|
+
|
|
98
|
+
## Autonomy: decide, record, continue
|
|
99
|
+
|
|
100
|
+
1. **An open question is a decision to make.** Decide from the recorded decisions, then the
|
|
101
|
+
goal, then the safest reversible option; write the decision and its source where the work
|
|
102
|
+
lives; batch the questions only a person can answer (sign-in, consent, payment, a typed
|
|
103
|
+
secret) and keep working around them.
|
|
104
|
+
2. **Nothing in a user's path waits for a human approval.** Send now or not at all; when unsure,
|
|
105
|
+
say so, open a recorded question, and answer again automatically when the knowledge changes.
|
|
106
|
+
Drafts awaiting approval waited for days.
|
|
107
|
+
3. **Every wait lives in durable storage**, rebuilt on start from a "debt" query — timers in
|
|
108
|
+
process memory died with every restart. A wait that must outlive a session (a credential that
|
|
109
|
+
has not landed yet) is an operating-system job, not a session's background task.
|
|
110
|
+
4. **Stop at the third round.** Tuning wording or patching one heuristic a third time means
|
|
111
|
+
splitting the change or replacing the mechanism with one invariant and a property test.
|
|
112
|
+
Write a review's exit criterion before its rounds start, and block only on what breaks a
|
|
113
|
+
documented promise; the rest becomes a board row with its evidence.
|
|
114
|
+
5. **Find the real cause before telling a person what to do** — a full disk once read as "the
|
|
115
|
+
service stopped" while the service was healthy; check free space and the service's own health
|
|
116
|
+
first.
|
|
117
|
+
6. **Background chores never hold a finished command open**, unattended runs skip them, and an
|
|
118
|
+
unattended `--yes` never reverses a "no" a person saved.
|
|
119
|
+
|
|
120
|
+
## Verification you can believe
|
|
121
|
+
|
|
122
|
+
1. **A test counts once it was watched failing against a planted defect**; a mutation battery
|
|
123
|
+
asserts its own reach ("no tests ran" is not "all plants survived"). Exercise every guard
|
|
124
|
+
with a must-catch and a must-pass case.
|
|
125
|
+
2. **Write expectations out; never compute them from the code under test**, and run a reference
|
|
126
|
+
function and its schema on the same inputs in one test — the most common defect a first reading
|
|
127
|
+
found was a function returning what its own schema refuses. A wall-clock bound sits far above a
|
|
128
|
+
loaded machine and far below the defect it guards.
|
|
129
|
+
3. **Test against the real deployed instance behind its platform**, not a sample the same run
|
|
130
|
+
wrote: a probe passed the shipped sample and failed the first real service behind a router.
|
|
131
|
+
4. **After each wave, three blind readings** — the changed units, everything that reaches them,
|
|
132
|
+
and the documented behaviour — then one fix round. Green suites hid six to ten real breaks.
|
|
133
|
+
5. **Read a gate's exit code directly**, never through a pipe in the same command (`| tail` hid a
|
|
134
|
+
failed gate as success): write the output to a file, take the status, then read the file.
|
|
135
|
+
6. **"Could not measure" is its own outcome.** An overloaded machine makes a gate refuse, and a
|
|
136
|
+
timeout under load is unmeasured, not failed or passed.
|
|
137
|
+
7. **Measure noise before calling a regression**; release a retrieval change only with zero new
|
|
138
|
+
confident wrong answers against the previous version.
|
|
139
|
+
8. **Compute a status from the whole group**, never incrementally; verify a release by the
|
|
140
|
+
commit it serves, from a fresh checkout of that commit.
|
|
141
|
+
9. **Probe live after every deploy on every consumer channel**, not the default one; read the
|
|
142
|
+
nightly full-suite result first when resuming work, and release from a full local run, not a
|
|
143
|
+
selected subset.
|
|
144
|
+
10. **Read an external contract from its raw documentation or a recorded live snapshot**, never
|
|
145
|
+
from a summarising fetch; fail closed on a field nobody documented.
|
|
146
|
+
|
|
147
|
+
## Operations that survive restarts
|
|
148
|
+
|
|
149
|
+
1. **One owner per run, named.** Only the holder of the run's lock changes its lifecycle, and each
|
|
150
|
+
concurrent worker has its own identity; a refused claim names the run that holds the task, never
|
|
151
|
+
a guessed race against a lease that lapsed long ago; a copy that a
|
|
152
|
+
supervisor started backs off on a held lock instead of exiting into a respawn loop. A failed
|
|
153
|
+
lease renewal is not a lost lease — stop only when the remaining margin runs out.
|
|
154
|
+
2. **Health treats "never finished" as a fault.** Age each phase from its last completion; a
|
|
155
|
+
phase that only ever yielded looked healthy for fifteen days.
|
|
156
|
+
3. **Everything a run creates carries its owner** (run id, pid); the next start sweeps what a dead
|
|
157
|
+
owner left — markers, test databases, temp roots, container volumes. Nothing a long job needs
|
|
158
|
+
lives in a temp directory; a cleaner removed a test database mid-deploy.
|
|
159
|
+
4. **Check disk and load before heavy work**, and serialize heavy suites behind a lock held for
|
|
160
|
+
the whole run. A disk cleanup never deletes files version control tracks.
|
|
161
|
+
5. **State that must survive a restart or a second instance lives in the shared store**, never in
|
|
162
|
+
process memory or on one instance's disk; a per-process cache is a cache with a TTL.
|
|
163
|
+
6. **Every scheduled job leaves a trace when it dies, stalls or never starts**: a heartbeat at the
|
|
164
|
+
start of each tick, a queryable error row, re-arming in `finally`, an alert when quiet for two
|
|
165
|
+
intervals. Serialize every use of a connection threads share, reads included.
|
|
166
|
+
7. **Bound every wait on a shared lock and survive a lost connection.** A lock timeout (a few
|
|
167
|
+
seconds) answers a retryable "busy", never a crash or a deadlock; every pooled connection has
|
|
168
|
+
an error listener, and readiness reports the store while liveness reports the process, so a
|
|
169
|
+
database blip takes the instance out of traffic instead of restarting it.
|
|
170
|
+
8. **Every accumulating output has a cap in the code that writes it** — logs, receipts, caches —
|
|
171
|
+
with both bounds tested.
|
|
172
|
+
9. **Name the account and context on every cloud CLI call** and verify the account before a write;
|
|
173
|
+
a saved default context once pointed a correct token at another team.
|
|
174
|
+
10. **Log by error name and code**, never a driver's raw message, which can carry connection
|
|
175
|
+
details. A failed command logs its cause — deadline, signal, exit code, missing program — never a
|
|
176
|
+
bare "command failed"; a watched dependency logs once when it fails and once when it recovers,
|
|
177
|
+
never on every poll.
|
|
178
|
+
11. **Admit work only when the machine can carry it** — free disk, swap, a cap on concurrent
|
|
179
|
+
sessions — and say why when you defer.
|
|
180
|
+
|
|
181
|
+
## Secrets and confinement
|
|
182
|
+
|
|
183
|
+
1. **A secret never enters an environment, argv, URL or log a child can read.** Hand it over a
|
|
184
|
+
pipe or file descriptor, or through a vault runner per command; never print a configuration
|
|
185
|
+
dump, and never filter one yourself.
|
|
186
|
+
2. **Confine what an agent runs by the operating system**, per run (filesystem, network, ports),
|
|
187
|
+
and treat prompt rules and command guards as tripwires, not boundaries.
|
|
188
|
+
3. **Fail closed.** When a reduced view or a scope cannot be built, answer an error, never the
|
|
189
|
+
wider view; a new matching rule fires only when every neighbouring input is on its allow-list.
|
|
190
|
+
4. **Run the private-name gate chained before every public push** (`<gate> && git push`), not only
|
|
191
|
+
before merge: a `;` once let a private path reach a public branch.
|
|
192
|
+
5. **Freeze every input at acceptance**, nested references included: copy it by hash and work on
|
|
193
|
+
the copy, so a file swapped between acceptance and use is never read.
|
|
194
|
+
6. **Treat what a run wrote as untrusted** before the service copies, reads or renders it: a
|
|
195
|
+
regular file whose real path is inside the run's own tree.
|
|
196
|
+
7. **An artefact missing a secret refuses to start**, never runs unauthenticated: validation rejects
|
|
197
|
+
placeholder-shaped values, and a test renders the configuration without the value and expects
|
|
198
|
+
an error.
|
|
199
|
+
8. **Widen an access configuration first, code second**, so nothing is over-scoped even briefly.
|
|
200
|
+
|
|
201
|
+
## Checklist
|
|
202
|
+
|
|
203
|
+
Before the first release, answer each with a file and a test: where a limit pauses and resumes;
|
|
204
|
+
what `accept` each model call has; which vendors its fallback and checker use; where the attempt
|
|
205
|
+
ledger and idempotency keys live; what happens to an unknown outcome; which waits are durable;
|
|
206
|
+
who owns the run lock; what the next start sweeps; how a cancel reaches paid inner work; which
|
|
207
|
+
outputs are capped; how a secret reaches a child; which test was seen failing for each fix.
|
|
@@ -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.1"
|
|
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).
|
|
@@ -42,10 +43,15 @@ would add — never a value already in the file — and the `--apply --expect-sh
|
|
|
42
43
|
backs the file up (`<file>.<UTC stamp>.bak`, never overwriting an earlier backup), keeps the file's mode, refuses a
|
|
43
44
|
file whose shape is not `{"hooks": {"<Event>": [{"hooks": [...]}]}}` (Codex) or an array of `[[hooks]]` tables
|
|
44
45
|
(Kimi Code, read back with `tomllib`: Python 3.11+), and refuses one that changed since the dry run. `--remove` takes
|
|
45
|
-
the entry out again. Codex may ask the person to trust a new hook (`/hooks`) before it runs.
|
|
46
|
+
the entry out again. Codex may ask the person to trust a new hook (`/hooks`) before it runs. Codex keys that trust by event, group and index, so
|
|
47
|
+
`--remove` names the SessionStart groups after the removed one that it moves, which Codex asks the person to approve
|
|
48
|
+
again. The hook runs the
|
|
46
49
|
`fabric_session.py` of the skills install (`~/.agents/skills/working-with-fabric/`) when there is one, else this
|
|
47
50
|
script's own path; a plugin cache path changes with each update, so pass `--script <a stable path>` there.
|
|
48
51
|
|
|
52
|
+
Native Hermes, OpenCode, OpenClaw and Pi bridges are described in [native host hooks](native-host-hooks.md).
|
|
53
|
+
They consume the same context through their own APIs. They never use the Claude-only rejoin/turn lifecycle.
|
|
54
|
+
|
|
49
55
|
## `watch-mail`
|
|
50
56
|
|
|
51
57
|
`fabric_session.py watch-mail [--interval 60]` is for an idle Claude Code session waiting on the board; the agent
|
|
@@ -89,7 +95,9 @@ only variable names are looked at, never a value (two of them carry the session'
|
|
|
89
95
|
One line per event that did something, never content: `~/Library/Caches/ai.passioncode.fabric-agent-adapter/hooks.log`
|
|
90
96
|
(macOS), `$XDG_CACHE_HOME/fabric-agent-adapter/hooks.log` (Linux, default `~/.cache`),
|
|
91
97
|
`%LOCALAPPDATA%\fabric-agent-adapter\hooks.log` (Windows). Mode 0600, rotated at 1 MB to `hooks.log.1`. The
|
|
92
|
-
per-session spool is `sessions/<session id>/` beside it (turn entries capped at 50, `board.json`, `board.shown
|
|
98
|
+
per-session spool is `sessions/<session id>/` beside it (turn entries capped at 50, `board.json`, `board.shown`; a
|
|
99
|
+
folder untouched for 7 days, whose worker failed or whose session ended without SessionEnd, is removed by the next
|
|
100
|
+
spooling hook),
|
|
93
101
|
removed at the session's end.
|
|
94
102
|
|
|
95
103
|
Turn them off with Claude Code's own switch (`/hooks`, or `"disableAllHooks": true` in settings), or remove the
|
|
@@ -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,
|
|
@@ -53,6 +53,9 @@ PS_TIMEOUT_S = 0.5
|
|
|
53
53
|
LOG_CAP_BYTES = 1_000_000
|
|
54
54
|
SPOOL_CAP = 50
|
|
55
55
|
LOCK_STALE_S = 30.0
|
|
56
|
+
#: A session folder untouched this long belongs to a session that will not spool again: its worker failed, or the
|
|
57
|
+
#: session ended without SessionEnd. Nothing retries it, so the next spooling hook removes it (FAA-50).
|
|
58
|
+
SESSION_STALE_S = 7 * 86400
|
|
56
59
|
READ_CAP = 64 * 1024
|
|
57
60
|
CONFIG_CAP = 32 * 1024 * 1024
|
|
58
61
|
WATCH_MIN_INTERVAL_S = 10
|
|
@@ -60,8 +63,10 @@ WATCH_MAX_BACKOFF_S = 900
|
|
|
60
63
|
SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
|
|
61
64
|
LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
|
|
62
65
|
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"
|
|
66
|
+
VERSION = "0.35.1"
|
|
67
|
+
HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
|
|
68
|
+
"hermes": "Hermes", "opencode": "OpenCode", "openclaw": "OpenClaw", "pi": "Pi"}
|
|
69
|
+
NATIVE_PLUGIN_HOSTS = frozenset(("hermes", "opencode", "openclaw", "pi"))
|
|
65
70
|
# A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
|
|
66
71
|
# what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value
|
|
67
72
|
# — two of them carry the session's credential.
|
|
@@ -69,7 +74,8 @@ LAUNCH_MARKER = "FABRIC_LAUNCHED_SESSION"
|
|
|
69
74
|
LAUNCH_SIGNALS = ("FABRIC_ACP_SESSION", "FABRIC_BRIDGE_URL", "KILO_CONFIG_CONTENT")
|
|
70
75
|
|
|
71
76
|
PREAMBLE = (
|
|
72
|
-
"[fabric-agent-adapter] Fabric is running and this {host} has its MCP server `fabric
|
|
77
|
+
"[fabric-agent-adapter] Fabric is running and this {host} has its MCP server `fabric` registered. "
|
|
78
|
+
"Registration is not proof of connection or admission. "
|
|
73
79
|
"Call `fabric_whoami` first. Then read the project board: `com.status` (is it ready, how many unread), "
|
|
74
80
|
"`com.list` for the messages, and `com.read_ack` only for what you actually read. "
|
|
75
81
|
"Before you start work, call `fabric_work_list` (who is working on what here) and "
|
|
@@ -410,6 +416,30 @@ def spool(directory: Path, entry: Dict[str, Any]) -> Path:
|
|
|
410
416
|
return path
|
|
411
417
|
|
|
412
418
|
|
|
419
|
+
def prune_stale_sessions(env: Optional[Dict[str, str]] = None, now: Optional[float] = None,
|
|
420
|
+
keep: Optional[str] = None) -> int:
|
|
421
|
+
"""Remove session folders whose newest file is older than SESSION_STALE_S (a running worker's lock is a fresh file,
|
|
422
|
+
so its folder is never stale); never the folder of the session `keep`. Returns how many were removed. Never
|
|
423
|
+
raises."""
|
|
424
|
+
now = time.time() if now is None else now
|
|
425
|
+
removed = 0
|
|
426
|
+
try:
|
|
427
|
+
folders = list((state_root(env=env) / "sessions").iterdir())
|
|
428
|
+
except OSError:
|
|
429
|
+
return 0
|
|
430
|
+
for folder in folders:
|
|
431
|
+
try:
|
|
432
|
+
if folder.name == keep or not SESSION_ID.fullmatch(folder.name) or not folder.is_dir():
|
|
433
|
+
continue
|
|
434
|
+
newest = max([folder.stat().st_mtime] + [f.stat().st_mtime for f in folder.iterdir()])
|
|
435
|
+
if now - newest > SESSION_STALE_S:
|
|
436
|
+
_remove_tree(folder)
|
|
437
|
+
removed += 1
|
|
438
|
+
except OSError:
|
|
439
|
+
continue
|
|
440
|
+
return removed
|
|
441
|
+
|
|
442
|
+
|
|
413
443
|
def lock_held(directory: Path) -> bool:
|
|
414
444
|
lock = directory / "worker.lock"
|
|
415
445
|
try:
|
|
@@ -443,14 +473,17 @@ def read_hook_input() -> Dict[str, Any]:
|
|
|
443
473
|
|
|
444
474
|
def hook_session_start(payload: Dict[str, Any], env: Optional[Dict[str, str]] = None,
|
|
445
475
|
host: str = "claude") -> Optional[str]:
|
|
446
|
-
"""What to print, or None: Claude
|
|
476
|
+
"""What to print, or None: Claude/Codex hook JSON, plain context for native plugins and Kimi. The preamble when the
|
|
447
477
|
host has `fabric` registered (one line pointing at the broker when Fabric is down), and the machine's
|
|
448
478
|
descriptor-count line when it has Fabric services. Silent in a session Fabric launched."""
|
|
449
479
|
if host not in HOSTS or fabric_launched(env):
|
|
450
480
|
return None
|
|
451
481
|
cwd = Path(payload.get("cwd") or os.getcwd())
|
|
452
482
|
parts: List[str] = []
|
|
453
|
-
|
|
483
|
+
registered = host_registered(host, cwd, env)
|
|
484
|
+
if host in NATIVE_PLUGIN_HOSTS and payload.get("fabric_registered") is True:
|
|
485
|
+
registered = True # Native bridge's boolean observation, never enrollment or authorization.
|
|
486
|
+
if registered:
|
|
454
487
|
hub = locate_hub(fabric_root(env=env))
|
|
455
488
|
if hub["state"] == "running":
|
|
456
489
|
parts.append(PREAMBLE.format(host=HOSTS[host]))
|
|
@@ -462,7 +495,7 @@ def hook_session_start(payload: Dict[str, Any], env: Optional[Dict[str, str]] =
|
|
|
462
495
|
if not parts or launched_by_ancestor(env):
|
|
463
496
|
return None
|
|
464
497
|
context = "\n".join(parts)
|
|
465
|
-
if host
|
|
498
|
+
if host not in ("claude", "codex"):
|
|
466
499
|
return context
|
|
467
500
|
return json.dumps({"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": context}})
|
|
468
501
|
|
|
@@ -522,6 +555,7 @@ def hook_spool(payload: Dict[str, Any], event: str, env: Optional[Dict[str, str]
|
|
|
522
555
|
return "skipped: launched by Fabric"
|
|
523
556
|
entry = {"event": event, "runtimeSessionId": session_id, "cwd": str(cwd),
|
|
524
557
|
"observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
|
|
558
|
+
prune_stale_sessions(env, keep=session_id)
|
|
525
559
|
if event == "end" and isinstance(payload.get("reason"), str):
|
|
526
560
|
entry["reason"] = payload["reason"][:64]
|
|
527
561
|
spool(directory, entry)
|
|
@@ -1083,8 +1117,14 @@ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] =
|
|
|
1083
1117
|
args = parser.parse_args(list(argv))
|
|
1084
1118
|
except SystemExit as exc:
|
|
1085
1119
|
return int(exc.code or 2)
|
|
1120
|
+
if args.host in NATIVE_PLUGIN_HOSTS:
|
|
1121
|
+
import importlib.util
|
|
1122
|
+
spec = importlib.util.spec_from_file_location("_fabric_native_installer", Path(__file__).with_name("host_hooks.py"))
|
|
1123
|
+
installer = importlib.util.module_from_spec(spec)
|
|
1124
|
+
spec.loader.exec_module(installer)
|
|
1125
|
+
return installer.main(list(argv), env=env, out=out, err=err)
|
|
1086
1126
|
if args.host not in ("codex", "kimi"):
|
|
1087
|
-
err("install-host-hooks: --host must be codex or
|
|
1127
|
+
err("install-host-hooks: --host must be codex, kimi, hermes, opencode, openclaw or pi (Claude uses its plugin)")
|
|
1088
1128
|
return 2
|
|
1089
1129
|
if args.config:
|
|
1090
1130
|
path = Path(args.config).expanduser()
|
|
@@ -1093,6 +1133,7 @@ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] =
|
|
|
1093
1133
|
else:
|
|
1094
1134
|
path = _home(env) / ".kimi-code" / "config.toml"
|
|
1095
1135
|
command = hook_command(str(args.script or default_hook_script(env)), args.host)
|
|
1136
|
+
shifted = 0 # Codex hook groups after ours, whose position a removal moves
|
|
1096
1137
|
try:
|
|
1097
1138
|
data = _read_bytes(path)
|
|
1098
1139
|
digest = _digest(data)
|
|
@@ -1106,19 +1147,30 @@ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] =
|
|
|
1106
1147
|
if not present:
|
|
1107
1148
|
out("install-host-hooks: no Fabric preamble hook in %s; nothing to change" % path)
|
|
1108
1149
|
return 0
|
|
1109
|
-
groups = []
|
|
1110
|
-
for group in doc["hooks"]["SessionStart"]:
|
|
1150
|
+
groups, dropped = [], None
|
|
1151
|
+
for index, group in enumerate(doc["hooks"]["SessionStart"]):
|
|
1111
1152
|
kept = [h for h in group.get("hooks", []) if not _is_ours(h.get("command"), args.host)]
|
|
1112
1153
|
if kept or not group.get("hooks"):
|
|
1113
1154
|
groups.append(dict(group, hooks=kept) if kept != group.get("hooks") else group)
|
|
1155
|
+
elif dropped is None:
|
|
1156
|
+
dropped = index # a group that held only our hook goes; the groups after it move up
|
|
1157
|
+
if dropped is not None:
|
|
1158
|
+
shifted = len(doc["hooks"]["SessionStart"]) - dropped - 1
|
|
1114
1159
|
if groups:
|
|
1115
1160
|
doc["hooks"]["SessionStart"] = groups
|
|
1116
1161
|
else:
|
|
1117
1162
|
del doc["hooks"]["SessionStart"]
|
|
1118
1163
|
new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
|
|
1119
1164
|
elif present:
|
|
1120
|
-
|
|
1121
|
-
|
|
1165
|
+
owned = [h for g in doc["hooks"]["SessionStart"] for h in g.get("hooks", [])
|
|
1166
|
+
if _is_ours(h.get("command"), args.host)]
|
|
1167
|
+
if len(owned) != 1:
|
|
1168
|
+
raise Refused("multiple Fabric hooks are configured; preserve their matchers and resolve duplicates before updating")
|
|
1169
|
+
if owned[0]["command"] == command:
|
|
1170
|
+
out("install-host-hooks: the Fabric preamble hook is already in %s; nothing to change" % path)
|
|
1171
|
+
return 0
|
|
1172
|
+
owned[0]["command"] = command
|
|
1173
|
+
new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
|
|
1122
1174
|
else:
|
|
1123
1175
|
doc.setdefault("hooks", {}).setdefault("SessionStart", []).append(entry)
|
|
1124
1176
|
new = (json.dumps(doc, indent=2) + "\n").encode("utf-8")
|
|
@@ -1168,6 +1220,11 @@ def install_host_hooks_cli(argv: Sequence[str], env: Optional[Dict[str, str]] =
|
|
|
1168
1220
|
else "added the Fabric preamble hook to", path,
|
|
1169
1221
|
"(backup: %s)" % backup if backup else "(new file)"))
|
|
1170
1222
|
out("Undo: fabric_session.py install-host-hooks --host %s %s" % (args.host, "--apply" if args.remove else "--remove"))
|
|
1223
|
+
if shifted:
|
|
1224
|
+
# Codex keys a hook's trust by its event, group and index: the groups after ours moved up one, so Codex asks
|
|
1225
|
+
# the person to trust them again in /hooks before they run.
|
|
1226
|
+
out("Codex trusts a hook by its position: %d SessionStart hook group(s) after the removed one moved, and "
|
|
1227
|
+
"Codex will ask you to approve them again in /hooks." % shifted)
|
|
1171
1228
|
return 0
|
|
1172
1229
|
except Refused as exc:
|
|
1173
1230
|
err("install-host-hooks: refused — %s" % exc)
|
|
@@ -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
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/hermes/__init__.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
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
|
+
try:
|
|
57
|
+
process.stdin.write(payload)
|
|
58
|
+
process.stdin.close()
|
|
59
|
+
except BrokenPipeError:
|
|
60
|
+
pass # FAA-51: a helper may exit without reading its input; its output still counts
|
|
61
|
+
process.wait(timeout=max(0.001, deadline - time.monotonic()))
|
|
62
|
+
reader.join(timeout=max(0, deadline - time.monotonic()))
|
|
63
|
+
if reader.is_alive() or invalid.is_set() or process.returncode != 0:
|
|
64
|
+
return None
|
|
65
|
+
return bytes(output)
|
|
66
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
67
|
+
return None
|
|
68
|
+
finally:
|
|
69
|
+
# Own process group only: also close a pipe inherited by an owned child.
|
|
70
|
+
_kill_owned(process)
|
|
71
|
+
process.wait()
|
|
72
|
+
reader.join(timeout=0.2)
|
|
73
|
+
if not reader.is_alive():
|
|
74
|
+
process.stdout.close()
|
|
75
|
+
try:
|
|
76
|
+
process.stdin.close()
|
|
77
|
+
except BrokenPipeError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fabric_registered(config=None):
|
|
82
|
+
"""Read only the native registration flag; never forward server config or credentials."""
|
|
83
|
+
try:
|
|
84
|
+
if config is None:
|
|
85
|
+
from hermes_cli.config import load_config_readonly
|
|
86
|
+
config = load_config_readonly()
|
|
87
|
+
servers = config.get("mcp_servers") if isinstance(config, Mapping) else None
|
|
88
|
+
server = servers.get("fabric") if isinstance(servers, Mapping) else None
|
|
89
|
+
if not isinstance(server, Mapping):
|
|
90
|
+
return False
|
|
91
|
+
enabled = server.get("enabled", True)
|
|
92
|
+
return enabled is True or isinstance(enabled, str) and enabled.lower().strip() in ("true", "1", "yes", "on")
|
|
93
|
+
except (ImportError, OSError, TypeError, ValueError):
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def context_for_session(session_info, session_script=None):
|
|
98
|
+
"""Read the shared preamble through its bounded plain-text host protocol."""
|
|
99
|
+
if not isinstance(session_info, Mapping):
|
|
100
|
+
return ""
|
|
101
|
+
cwd = session_info.get("cwd", "")
|
|
102
|
+
if not isinstance(cwd, str) or len(cwd) > MAX_INPUT_BYTES // 2 or "\x00" in cwd:
|
|
103
|
+
return ""
|
|
104
|
+
try:
|
|
105
|
+
if session_script is None:
|
|
106
|
+
script = Path(__file__).resolve().parents[2] / "fabric_session.py"
|
|
107
|
+
elif isinstance(session_script, str) and Path(session_script).is_absolute():
|
|
108
|
+
script = Path(session_script)
|
|
109
|
+
else:
|
|
110
|
+
return ""
|
|
111
|
+
if not script.is_file():
|
|
112
|
+
return ""
|
|
113
|
+
result = _capture(
|
|
114
|
+
[sys.executable, "-S", str(script), "hook", "session-start", "--host", "hermes"],
|
|
115
|
+
json.dumps({"cwd": cwd or os.getcwd(), "fabric_registered": fabric_registered()},
|
|
116
|
+
ensure_ascii=False).encode("utf-8"),
|
|
117
|
+
)
|
|
118
|
+
if result is None:
|
|
119
|
+
return ""
|
|
120
|
+
text = result.decode("utf-8").strip()
|
|
121
|
+
if len(text) > MAX_CONTEXT_CHARS or any(ord(char) < 32 and char not in "\n\r\t" for char in text):
|
|
122
|
+
return ""
|
|
123
|
+
return text
|
|
124
|
+
except (OSError, ValueError, subprocess.TimeoutExpired):
|
|
125
|
+
# Context is best effort; never leak configuration, diagnostics or hook input.
|
|
126
|
+
return ""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def register(ctx):
|
|
130
|
+
"""Use Hermes' cache-safe prompt section API, not observer hook returns."""
|
|
131
|
+
if not callable(getattr(ctx, "register_system_prompt_section", None)):
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
script = ctx.get_config("session_script", None)
|
|
135
|
+
except (AttributeError, OSError, TypeError, ValueError):
|
|
136
|
+
return
|
|
137
|
+
ctx.register_system_prompt_section(
|
|
138
|
+
"fabric.context", lambda info: context_for_session(info, script),
|
|
139
|
+
position="after_memory", max_chars=MAX_CONTEXT_CHARS,
|
|
140
|
+
)
|
|
141
|
+
# #endregion native-host-context
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs
ADDED
|
@@ -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
|
+
}
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/package.json
ADDED
|
@@ -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
|
+
}
|
package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs
ADDED
|
@@ -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
|