@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.42.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +9 -3
  3. package/SECURITY.md +27 -6
  4. package/bin/fabric-agent-adapter.js +71 -6
  5. package/package.json +2 -2
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +68 -24
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +157 -22
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +270 -35
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +36 -16
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +44 -22
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +54 -6
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +28 -7
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +277 -49
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: creating-fabric-agents
3
- description: Use when designing and building a NEW agent or provider that must be Fabric-compatible from its first commit, «создай агента, совместимого с фабрикой Passion Code», «новый агент под Fabric», "create a fabric-compatible agent", "build a new Fabric provider", "fabric-ready agent from scratch", «агент в облаке организации», "which models and OpenRouter key for a new Fabric agent". Runs the intake grill (capability, named consumer, workflow-or-agent, MCP/A2A/local-runner profile, effect declarations, where it runs, models, who pays, how it learns), distils source projects into a knowledge pack whose recorded failures become planted eval fixtures, scaffolds the pinned contract bundle, and sets the two-clock eval expectation with the conformance report. NOT for adapting an existing project (use adapting-projects-to-fabric), building the Fabric host or orchestrator, or generic agent design where Fabric compatibility is not part of the request.
3
+ description: Use when designing and building a NEW agent or provider that must be Fabric-compatible from its first commit, «создай агента, совместимого с фабрикой Passion Code», «новый агент под Fabric», "create a fabric-compatible agent", "build a new Fabric provider", "fabric-ready agent from scratch", «агент в облаке организации», "which models and OpenRouter key for a new Fabric agent". Runs the intake grill (capability, named consumer, workflow or agent, profile, effects, where it runs, models, who pays, how it learns), turns source projects' recorded failures into planted eval fixtures, scaffolds the pinned contract bundle and sets the canary expectation. NOT for adapting an existing project (adapting-projects-to-fabric), making an agent a local or online Fabric service with a dashboard (building-fabric-services), a coding session working through Fabric or its board (working-with-fabric), the Fabric host or orchestrator, or agent design without Fabric.
4
4
  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.40.0"
8
+ version: "0.42.1"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "c55f2d5a11c9a0197e958e87212fca7ae1154769"
11
11
  ---
@@ -24,6 +24,10 @@ Use this skill to design and build a new provider. Do not use it to:
24
24
 
25
25
  - adapt a project that already has a stable API, MCP/A2A surface, or CLI: that is
26
26
  `adapting-projects-to-fabric`, and entering there skips nothing;
27
+ - run an agent as a local or online service with a dashboard on its own: that is
28
+ `building-fabric-services` (step 4 hands over to it for a new agent that needs one);
29
+ - work in a project through Fabric from a coding session (the board, claims): that is
30
+ `working-with-fabric`;
27
31
  - design the Fabric host, registry, scheduler, admission service, or binding runtime;
28
32
  - build a generic agent with no Fabric requirement: nothing here helps a project that
29
33
  will never be a provider;
@@ -99,30 +103,21 @@ authored now: it grows from real traces once the provider runs, and the step-1 f
99
103
  are its seed because a source project's recorded failures count as production.
100
104
 
101
105
  **For a PassionCode.ai repository**, the new agent's repository follows the organization's
102
- [repository standard](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/repository-standard.md) (`knowledge/repository-standard.md`
103
- in the organization's knowledge base; a clone has it at `fabric-workspace/knowledge/`) from its
104
- first commit:
105
-
106
- - `LICENSE`, `COMMERCIAL-LICENSE.md` and `CLA.md` copied byte for byte from the knowledge base's
107
- `templates/`, and `AGPL-3.0-only OR LicenseRef-PassionCode-Commercial` in every manifest and
108
- every SKILL.md `license:`; `SECURITY.md` when the repository is public;
109
- - a README whose first heading is the full name, with `## Quick start for a new teammate`
110
- (Install; Configure, key names only; MCP, the registration command and one proving tool call,
111
- verified with a real client in a temporary config; Develop) and `## License`;
112
- - an `AGENTS.md` that opens with the template's *Read first* block and closes with *After work*,
113
- and a `CLAUDE.md` whose first line is `@AGENTS.md`.
114
-
115
- **Every credential the agent uses comes from Project Observatory, by name**, from its first
116
- commit: the README's Configure section lists key NAMES; the agent finds them with
117
- `observatory_credentials` and runs with `use_secret.py run --vault-only` (a long-running
118
- service: `use_secret.py serve`), with `OBSERVATORY_VAULT_ONLY=1` in its environment. No `.env`
119
- of its own, no key file beside its data, no value in code, configuration or a commit. A
120
- workflow names the keys it needs in its checkpoint (`credentials`), never their values.
121
-
122
- org-index `scripts/check_format.py` reports 0 findings for it before it is called done. **An agent
123
- someone builds for themselves is not a PassionCode.ai repository:** its licence is its owner's
124
- choice, nothing about it is published or listed by the organization, and none of these files is
125
- required of it, though the verified MCP quick start is still how anyone learns to drive it.
106
+ repository standard (`knowledge/repository-standard.md`) from its first commit: the licence files,
107
+ the README quick start verified with a real client, `AGENTS.md` and `CLAUDE.md`, as
108
+ [the operator channel and the repository standard](references/operator-channel-and-repository.md)
109
+ lists them. An agent someone builds for themselves is not a PassionCode.ai repository: its licence
110
+ is its owner's choice and none of those files is required of it.
111
+
112
+ **Every credential the agent uses is read by name, never held as a value**, from its first commit:
113
+ the README's Configure section lists key NAMES. With Project Observatory installed, the agent finds
114
+ them with `observatory_credentials` and runs under
115
+ `python3 "$(project-observatory full-path)/tools/use_secret.py" run --vault-only <project> <NAME> -- <command>`
116
+ (a long-running service: `use_secret.py serve`), with `OBSERVATORY_VAULT_ONLY=1` in its environment.
117
+ Without it, the person's OS keychain or secret manager injects the key into the agent's environment
118
+ at start. Either way: no `.env` of its own, no key file beside its data, no value in argv, code,
119
+ configuration or a commit. A workflow names the keys it needs in its checkpoint (`credentials`),
120
+ never their values.
126
121
 
127
122
  ## Step 3: scaffold the provider bundle
128
123
 
@@ -193,31 +188,11 @@ purpose. Then record one expectation the report must carry:
193
188
  ## Step 6: propose the operator channel
194
189
 
195
190
  If the agent has a human stop (a choice, an approval, a confirmation) or emits `notify: true`
196
- events, propose its **operator channel** to the operator once, now. The channel is the agent's
197
- own messenger channel, first Telegram, that sends those events and answers its stops with
198
- buttons: `fabric-operator-channel/0.1` (contract DEC-0034,
199
- `docs/specification/operator-channel.md`), whose rule OC-11 this step follows. Give the
200
- operator's four steps:
201
-
202
- 1. create a bot with @BotFather;
203
- 2. put its token into the secret store (Project Observatory) under the name the agent reads
204
- (for example `EXAMPLE_AGENT_TELEGRAM_BOT_TOKEN`), never into a chat, a file or the bundle;
205
- 3. run the agent's link command;
206
- 4. type the code it shows in the chat with the bot.
207
-
208
- In a group shared with other bots the channel follows OC-14…OC-17 (DEC-0035): it stays silent
209
- unless addressed (`/command@ThisBot`, a reply to its own message or a press of its own button; a
210
- bare `/command` in a group is not addressed), and a channel nobody receives on is `degraded`
211
- (`receiver-missing`). Say so in the proposal when the operator means to bind a shared group.
212
-
213
- Record the answer (`accepted`, `declined` or `later`) in the agent's state and in the
214
- report, and do not ask again; an agent that serves `surfaces.operatorChannel` also shows it as
215
- `proposal` in its status document. Creating the bot, storing the token and enabling the channel are the
216
- operator's action: never create the bot, ask for or store the token's value, or enable the
217
- channel yourself. The default pin carries DEC-0034 and DEC-0035: a served status document is checked against
218
- the pinned `schemas/operator-channel-status.schema.json`; the channel's runtime rules have no
219
- probe rule here yet, so report them `NOT_RUN`. An agent with neither stops nor notify
220
- events skips this step.
191
+ events, propose its operator channel (`fabric-operator-channel/0.1`, OC-11) to the operator once,
192
+ now, as the shared reference linked at step 2 describes: the
193
+ operator's four steps, the answer recorded and never asked again, and the bot, the token and
194
+ enabling the channel left to the operator. An agent with neither stops nor notify events skips
195
+ this step.
221
196
 
222
197
  ## Resilience and autonomy
223
198
 
@@ -239,12 +214,6 @@ operator channel answer (step 6); and the exact next command. Never summarize th
239
214
 
240
215
  ## Dashboard handoff: also for consumers
241
216
 
242
- When handing a registered service dashboard to a person, or writing the generated
243
- agent's completion/notification instructions, read and apply
244
- [dashboard links](references/dashboard-links.md): use the host's `open_link`,
245
- check the target device and host, and run the bundled output gate before delivery.
246
- An installed host failure never means browser fallback. This applies in Claude
247
- Code, Codex and other providers; it needs no host-specific hook. MCP unavailable:
248
- report the unresolved capability or use an approved project resolver; no automatic
249
- client configuration. Python unavailable: perform the reference's manual checks
250
- and report the executable gate NOT_RUN. Headless agents need no dashboard.
217
+ When handing a registered service dashboard to a person, or writing the generated agent's
218
+ completion or notification instructions, read and apply [dashboard links](references/dashboard-links.md)
219
+ and run its output gate before delivery, in any host. Headless agents need no dashboard.
@@ -83,8 +83,10 @@ or instructions alone do not prove all final answers from every agent are filter
83
83
  path is required. Skills must be activated/loaded; install does not intercept every answer.
84
84
  - MCP absent: report unresolved host capability once, use the project's approved
85
85
  resolver/CLI if available. Do not write machine or agent MCP config automatically.
86
- - Python absent: perform the comparisons above manually; mark the executable gate
87
- NOT_RUN. Do not label the output mechanically validated.
86
+ - Python absent: perform the comparisons above manually, including the gate's own rules (a
87
+ path with a `.` or `..` segment, an encoded or backslash separator, or a query or fragment
88
+ name or value that reads as a credential, such as `code`, `key`, `auth` or `api_key`, is
89
+ refused); mark the executable gate NOT_RUN. Do not label the output mechanically validated.
88
90
 
89
91
  Syntax is owned by Fabric Dashboards and `@passioncode-ai/fabric-service-host`:
90
92
  [ADR-0005](https://github.com/passioncode-ai/fabric-dashboards/blob/51a7a80acf42781d7bec304cc87c655d48cc309b/docs/adr/0005-service-links.md).
@@ -0,0 +1,55 @@
1
+ # The operator channel and the repository standard
2
+
3
+ Shared by `adapting-projects-to-fabric` and `creating-fabric-agents`; the two copies are byte-identical
4
+ (`test/test_shared_references.py`). "The agent" is the provider being adapted or created.
5
+
6
+ ## The operator channel
7
+
8
+ If the agent has a human stop (a choice, an approval, a confirmation) or emits `notify: true`
9
+ events, propose its **operator channel** to the operator once, at the step that links here. The
10
+ channel is the agent's own messenger channel, first Telegram, that sends those events and answers
11
+ its stops with buttons: `fabric-operator-channel/0.1` (contract DEC-0034,
12
+ `docs/specification/operator-channel.md`), whose rule OC-11 this follows. Give the operator's four
13
+ steps:
14
+
15
+ 1. create a bot with @BotFather;
16
+ 2. put its token into the operator's secret store (Project Observatory where it is installed) under
17
+ the name the agent reads (for example `EXAMPLE_AGENT_TELEGRAM_BOT_TOKEN`), never into a chat, a
18
+ file or the bundle;
19
+ 3. run the agent's link command;
20
+ 4. type the code it shows in the chat with the bot.
21
+
22
+ In a group shared with other bots the channel follows OC-14 to OC-17 (DEC-0035): it stays silent
23
+ unless addressed (`/command@ThisBot`, a reply to its own message or a press of its own button; a
24
+ bare `/command` in a group is not addressed), and a channel nobody receives on is `degraded`
25
+ (`receiver-missing`). Say so in the proposal when the operator means to bind a shared group.
26
+
27
+ Record the answer (`accepted`, `declined` or `later`) in the agent's state and in its conformance
28
+ report, and do not ask again; an agent that serves `surfaces.operatorChannel` also shows it as
29
+ `proposal` in its status document. Creating the bot, storing the token and enabling the channel
30
+ are the operator's action: never create the bot, ask for or store the token's value, or enable the
31
+ channel yourself. The default pin carries DEC-0034 and DEC-0035: a served status document is
32
+ checked against the pinned `schemas/operator-channel-status.schema.json`; the channel's runtime
33
+ rules have no probe rule here yet, so report them `NOT_RUN`. An agent with neither stops nor notify
34
+ events skips this.
35
+
36
+ ## The repository standard
37
+
38
+ For a PassionCode.ai repository, the agent's repository follows the organization's
39
+ [repository standard](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/repository-standard.md)
40
+ (`knowledge/repository-standard.md` in the organization's knowledge base; a clone has it at
41
+ `fabric-workspace/knowledge/`) from its first commit:
42
+
43
+ - `LICENSE`, `COMMERCIAL-LICENSE.md` and `CLA.md` copied byte for byte from the knowledge base's
44
+ `templates/`, and `AGPL-3.0-only OR LicenseRef-PassionCode-Commercial` in every manifest and
45
+ every SKILL.md `license:`; `SECURITY.md` when the repository is public;
46
+ - a README whose first heading is the full name, with `## Quick start for a new teammate`
47
+ (Install; Configure, key names only; MCP, the registration command and one proving tool call,
48
+ verified with a real client in a temporary config; Develop) and `## License`;
49
+ - an `AGENTS.md` that opens with the template's *Read first* block and closes with *After work*,
50
+ and a `CLAUDE.md` whose first line is `@AGENTS.md`.
51
+
52
+ org-index `scripts/check_format.py` reports 0 findings for it before it is called done. An agent
53
+ someone builds for themselves is not a PassionCode.ai repository: its licence is its owner's
54
+ choice, nothing about it is published or listed by the organization, and none of these files is
55
+ required of it, though the verified MCP quick start is still how anyone learns to drive it.
@@ -7,7 +7,7 @@ This is an output correctness gate, not authentication, discovery, or a relay.
7
7
  import json
8
8
  import re
9
9
  import sys
10
- from urllib.parse import parse_qs, urlsplit
10
+ from urllib.parse import parse_qs, parse_qsl, unquote, urlsplit
11
11
 
12
12
 
13
13
  def require(condition, reason):
@@ -15,6 +15,63 @@ def require(condition, reason):
15
15
  raise ValueError(reason)
16
16
 
17
17
 
18
+ # Credential names and values: the same rules as fabric-button.js (credentialName, secretValue).
19
+ CREDENTIAL_WORDS = {
20
+ 'token', 'secret', 'password', 'passwd', 'pass', 'passphrase', 'credential', 'credentials', 'cookie',
21
+ 'authorization', 'bearer', 'jwt', 'otp', 'apikey', 'signature', 'sig', 'accesstoken', 'idtoken', 'authtoken',
22
+ 'refreshtoken', 'sessionid', 'sessiontoken', 'logincode', 'authcode', 'privatekey', 'clientsecret', 'secretkey',
23
+ 'accesskey'}
24
+ CREDENTIAL_PAIRS = {
25
+ 'key': {'private', 'api', 'access', 'secret', 'session', 'auth', 'signing', 'encryption', 'master', 'client', 'login',
26
+ 'refresh'},
27
+ 'auth': {'token', 'code', 'header'},
28
+ 'session': {'token', 'id'},
29
+ 'code': {'login', 'auth', 'access', 'session'}}
30
+ JWT_SHAPE = re.compile(r'eyJ[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]{2,}\.[A-Za-z0-9_-]*')
31
+ SECRET_VALUE = re.compile(r'(^bearer\s.+|sk-[A-Za-z0-9_-]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}'
32
+ r'|AKIA[0-9A-Z]{16}|glpat-[A-Za-z0-9_-]{20,})', re.I | re.S)
33
+ LONG_RUN = re.compile(r'[A-Za-z0-9+/=_-]{40,}')
34
+
35
+
36
+ def name_words(name):
37
+ name = re.sub(r'([a-z0-9])([A-Z])', r'\1 \2', name)
38
+ name = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1 \2', name)
39
+ return [w.lower() for w in re.split(r'[\s._-]+', name) if w]
40
+
41
+
42
+ def credential_name(name):
43
+ split = name_words(name)
44
+ words = set(split) | {w[:-1] for w in split if len(w) > 2 and w.endswith('s')}
45
+ if words & CREDENTIAL_WORDS:
46
+ return True
47
+ if len(split) == 1 and words & set(CREDENTIAL_PAIRS):
48
+ return True
49
+ return any(w in words and words & pairs for w, pairs in CREDENTIAL_PAIRS.items())
50
+
51
+
52
+ def secret_value(value):
53
+ long_secret = any(re.search(r'[a-z]', run) and re.search(r'[A-Z]', run) and re.search(r'[0-9]', run)
54
+ for run in LONG_RUN.findall(value))
55
+ return bool(JWT_SHAPE.search(value) or SECRET_VALUE.search(value) or long_secret)
56
+
57
+
58
+ def carries_credential(text):
59
+ # A query or fragment whose names or values could be a credential: refused even with an empty value.
60
+ return (secret_value(text) or secret_value(unquote(text))
61
+ or any(credential_name(name) or secret_value(name) or secret_value(value)
62
+ for name, value in parse_qsl(text, keep_blank_values=True)))
63
+
64
+
65
+ def login_path(path):
66
+ # The path a server routes: refuse what normalises differently in different servers, then compare.
67
+ decoded = unquote(path)
68
+ require(not re.search(r'%2f|%5c', path, re.I) and '\\' not in decoded, 'encoded_separator')
69
+ segments = decoded.split('/')[1:]
70
+ require(not any(seg in ('.', '..') for seg in segments), 'dot_segment')
71
+ require(all(seg for seg in segments[:-1]), 'empty_segment')
72
+ return '/'.join([''] + [seg for seg in segments if seg]).lower() == '/fabric/v1/login'
73
+
74
+
18
75
  def safe_path(path):
19
76
  return (isinstance(path, str) and path.startswith('/') and not path.startswith('//')
20
77
  and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path))
@@ -42,10 +99,14 @@ def check(data):
42
99
  and not plain.username and not plain.password, 'invalid_http_fallback')
43
100
  http_page = plain.path + ('?' + plain.query if plain.query else '') + ('#' + plain.fragment if plain.fragment else '')
44
101
  require(safe_path(http_page) and http_page == (page or '/'), 'mismatched_page')
45
- sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code'}
46
- page_query = parse_qs(urlsplit(http_page).query)
47
- require(not sensitive.intersection(k.lower() for k in page_query), 'credential_query')
48
- require(urlsplit(http_page).path != '/fabric/v1/login', 'login_link_is_not_dashboard')
102
+ sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code', 'code', 'api_key',
103
+ 'apikey', 'key', 'auth'}
104
+ parts = urlsplit(http_page)
105
+ places = [parts.query, parts.fragment] + parts.fragment.split('?', 1)[1:]
106
+ require(not any(sensitive.intersection(k.lower() for k in parse_qs(p, keep_blank_values=True)) for p in places),
107
+ 'credential_query')
108
+ require(not any(carries_credential(p) for p in places), 'credential_query')
109
+ require(not login_path(parts.path), 'login_link_is_not_dashboard')
49
110
  state = host.get('state')
50
111
  require(state in ('available', 'not_installed', 'unknown', 'incompatible', 'handler_mismatch', 'unsupported'), 'unknown_host_state')
51
112
  target, viewer = data.get('target_device'), data.get('viewer_device')
@@ -19,7 +19,7 @@ compatibility: >-
19
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.40.0"
22
+ version: "0.42.1"
23
23
  ---
24
24
 
25
25
  # Working with Fabric
@@ -225,8 +225,8 @@ and do the work without claiming Fabric saw it.
225
225
  In Claude Code this plugin ships four: at session start and `/clear` (not on compact or resume) the preamble above,
226
226
  or, when Fabric is down and the project uses Fabric, one line pointing at Step 1; after each turn the turn's facts (HEAD, files changed, lines added
227
227
  and removed) and the board's unread count; at each prompt one line only when that count rose; at the end, the
228
- release of what the session left claimed. The turn, prompt and end hooks act only when the running Fabric advertises
229
- `session-hooks/1`. All are silent in a session Fabric launched, never block the session and read no token. Read
228
+ release of what the session left claimed. The turn, prompt and end hooks spool and call Fabric only when the running
229
+ Fabric advertises `session-hooks/1`; the turn and end hooks always prune their own stale spool. All are silent in a session Fabric launched, never block the session and read no token. Read
230
230
  [session hooks](references/session-hooks.md) when a person asks what the hooks do, where they log, how to turn them
231
231
  off, or what they need from Fabric.
232
232
 
@@ -26,7 +26,7 @@ python3 <skill-dir>/scripts/fabric_session.py install-host-hooks --host opencode
26
26
  python3 <skill-dir>/scripts/fabric_session.py install-host-hooks --host opencode --remove
27
27
  ```
28
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.
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: an install refuses a `--script` that is not an existing file, and Codex and Kimi Code rewrite their own hook in place to the new path (`updated`), finding a Kimi `[[hooks]]` table by the command it runs, not by its exact text. The native installer checks the helper and every required host module before writing a loader. Removal needs neither: it reads nothing from the skill it was installed from, so it works after that skill moved or lost its `hosts/` folder.
30
30
 
31
31
  | Host | Default destination |
32
32
  |---|---|
@@ -45,6 +45,7 @@ OpenCode and Pi loader files carry a managed marker. A foreign file at the same
45
45
 
46
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
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
+ - OpenCode and OpenClaw ask for context on every request; their bridges keep the helper's answer per workspace and registration for the life of the host process, so Python runs once, not per request. A failed run (an error, a nonzero exit, a timeout, no `python3`) reads as `null`, distinct from an empty answer `''`, and is not kept, so the next request asks again. A Fabric started later is seen by a new host instance.
48
49
  - 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
50
  - 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
 
@@ -20,9 +20,16 @@ need from Fabric. In Claude Code they live in the plugin's `hooks/hooks.json` an
20
20
  | Event | When it acts | What it does |
21
21
  |---|---|---|
22
22
  | SessionStart (`startup`, `clear`; the plugin's matcher, and the script ignores `compact` and `resume` for every host) | Claude Code has an MCP server named `fabric` (local, project or user scope) | Fabric running: adds the preamble: `fabric_whoami` first; `com.status` and `com.list`, `com.read_ack` only what was read; `fabric_work_list`, then `fabric_work_claim`; `fabric_work_release` at the end; ask another Project only through `com.submit`. Fabric down, in a project that uses Fabric (below): one line, skip `fabric_whoami` and the board, start Fabric through the lifecycle broker only if the task needs it. Fabric down anywhere else, or not registered: nothing. |
23
- | Stop (each turn) | as for SessionStart, and the running Fabric's `hub.json` lists `"session-hooks/1"` in `features` | spools one entry and starts a detached worker that sends `fabric_session_observe {event: "turn", head, filesChanged, linesAdded, linesRemoved, …}`, then reads `com.status` and keeps the unread count in the session's spool (`board.json`) |
23
+ | Stop (each turn) | as for SessionStart, and the running Fabric's `hub.json` lists `"session-hooks/1"` in `features` | measures the turn's facts (HEAD, files changed, lines added and removed: counts and ids only, git sharing 1.5 s), spools them in one entry and starts a detached worker that sends `fabric_session_observe {event: "turn", head, filesChanged, linesAdded, linesRemoved, …}` as stored, then reads `com.status` and keeps the unread count in the session's spool (`board.json`) |
24
24
  | UserPromptSubmit | the session's spool holds a count | adds ONE line, `N unread on the project board: com.list`, only when the count rose since the last line; a fall or the same count says nothing. It reads two small files and never calls Fabric. |
25
- | SessionEnd | as for Stop | spools the end; the worker sends `fabric_work_release {claims: "session", outcome: "let-go", …}`, then `fabric_session_observe {event: "end", reason}`, and removes the session's spool |
25
+ | SessionEnd | as for Stop | spools the end; the worker sends `fabric_work_release {claims: "session", outcome: "let-go", …}` (with `observedAt`, the time the hook fired, where the tool's input schema names it: Fabric 0.3.9 then refuses an end from before the session enrolled), then `fabric_session_observe {event: "end", reason}`, and removes the session's spool. Reason `clear` ends nothing: `/clear` keeps the agent process and its fabric-mcp, so no claim is released and no end observed; turns already spooled still go out (since 0.42.0) |
26
+
27
+ **A resumed session and a session Fabric does not hold** (since 0.42.0, board thread `02bf19c5`). `claude --resume`
28
+ keeps the session id, so an end that was kept for a retry (its release got no answer) would be delivered to the resumed
29
+ process and release its live claims: the worker drops, with a log line, every end followed by a later turn of the same
30
+ id, and keeps the retry only while the end is the newest entry. When `fabric-mcp --rejoin-only` exits 3 (Fabric 0.3.9:
31
+ no session holds the id), the worker drops the spool, writes a `no-session` mark in the session's folder and logs it
32
+ once; for 15 minutes the hooks spool nothing for that id (an end removes the folder), then one rejoin tries again.
26
33
 
27
34
  **A project that uses Fabric** (`project_uses_fabric`), cheapest sign first: `fabric` registered at local or project
28
35
  scope for the folder (user scope is every folder's, so it says nothing about this one); a `fabric-agent.json`,
@@ -31,8 +38,8 @@ outside a checkout; never above it); or a service descriptor whose `source.repos
31
38
  `remote.origin.url`. The machine's service descriptors are no longer counted into the session: the host's own
32
39
  instructions say how to read Fabric services (since 0.38.0).
33
40
 
34
- Without `session-hooks/1`, Stop and SessionEnd read `hub.json`, return, and write nothing, so UserPromptSubmit has
35
- nothing to show; `fabric_session.py status` says "Session hooks: inert". That is every Fabric before the release
41
+ Without `session-hooks/1`, Stop and SessionEnd read `hub.json`, return, and spool nothing (they only remove stale
42
+ session folders, below), so UserPromptSubmit has nothing to show; `fabric_session.py status` says "Session hooks: inert". That is every Fabric before the release
36
43
  that ships the dependencies below. A board that cannot be read (`unavailable`, a refusal) leaves no count, never a
37
44
  zero; a `degraded` board that still gives the count is read.
38
45
 
@@ -48,8 +55,10 @@ session id Fabric keeps with the enrollment, which only Claude Code supplies tod
48
55
  would add (never a value already in the file) and the `--apply --expect-sha256 <digest>` command. With `--apply` it
49
56
  backs the file up (`<file>.<UTC stamp>.bak`, never overwriting an earlier backup), keeps the file's mode, refuses a
50
57
  file whose shape is not `{"hooks": {"<Event>": [{"hooks": [...]}]}}` (Codex) or an array of `[[hooks]]` tables
51
- (Kimi Code, read back with `tomllib`: Python 3.11+), and refuses one that changed since the dry run. `--remove` takes
52
- 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
58
+ (Kimi Code, read back with `tomllib`: Python 3.11+), and refuses one that changed since the dry run. It refuses a
59
+ `--script` that is not an existing file. Our own entry, found by what it runs (for Kimi Code each `[[hooks]]` table is
60
+ read on its own, so a hand-touched one is still found), is rewritten in place to the new script (`updated`) rather
61
+ than added a second time. `--remove` takes the entry out again, stale or not, and needs no script. Codex may ask the person to trust a new hook (`/hooks`) before it runs. Codex keys that trust by event, group and index, so
53
62
  `--remove` names the SessionStart groups after the removed one that it moves, which Codex asks the person to approve
54
63
  again. The hook runs the
55
64
  `fabric_session.py` of the skills install (`~/.agents/skills/working-with-fabric/`) when there is one, else this
@@ -63,8 +72,10 @@ They consume the same context through their own APIs. They never use the Claude-
63
72
  `fabric_session.py watch-mail [--interval 60]` is for an idle Claude Code session waiting on the board; the agent
64
73
  starts it under a background watcher. It rejoins the session (`CLAUDE_CODE_SESSION_ID`, or `--session-id`) through
65
74
  `fabric-mcp --rejoin-only` for each look, prints one line each time the unread count rises, and waits `interval`
66
- ± 20 % between looks (never under 10 s). While Fabric is down or the board unreadable it says so once and backs off,
67
- doubling to at most 15 minutes. It exits on its stdin's end (when started with a pipe), SIGTERM, SIGINT or SIGHUP,
75
+ ± 20 % between looks (after the jitter never under 10 s and never over 15 minutes). It shares the session's
76
+ once-a-minute rejoin with the Stop worker: a look within a minute of the last rejoin (the worker's `rejoined` mark or
77
+ its own) waits instead, and its own rejoin renews that mark when the session's folder exists. While Fabric is down or
78
+ the board unreadable it says so once and backs off, doubling to at most 15 minutes. It exits on its stdin's end (when started with a pipe), SIGTERM, SIGINT or SIGHUP,
68
79
  or when its parent is gone. It refuses to start in a session Fabric launched, and without a `fabric` registration.
69
80
 
70
81
  ## A session Fabric launched
@@ -78,13 +89,18 @@ only variable names are looked at, never a value (two of them carry the session'
78
89
  `<Fabric's folder>/sessions/` (Codex).
79
90
  3. Claude Code launched by Fabric has no variable yet: its command line holds `--mcp-config
80
91
  <Fabric's folder>/sessions/<id>/mcp.json`. SessionStart, and Stop once per session, look for that in a few
81
- ancestor processes (`ps`, bounded to 0.5 s each); only the match is used, nothing is kept or logged.
92
+ ancestor processes (`ps`, bounded to 0.5 s each); only the match is used, nothing is logged. A match is kept as
93
+ an empty `launched` file in the session's folder, so the walk runs once and nothing is spooled after it. A `ps`
94
+ that fails or runs out of time is "unknown", never "not launched": that hook spools nothing (so no worker can
95
+ release a launched session's claims) and the next one looks again.
82
96
 
83
97
  ## What they never do
84
98
 
85
99
  - **Block or slow the session.** Each hook has a 5 s ceiling in `hooks.json`; the script reads a few small files and
86
- returns in tens of milliseconds. Every call to Fabric happens in a detached worker with a 10 s deadline. Every
87
- hook exits 0, on every path.
100
+ returns in tens of milliseconds, plus the turn's git facts in Stop and SessionEnd (at most 1.5 s, shared by every
101
+ git call). Every call to Fabric happens in a detached worker with a 10 s deadline; at its end the worker closes
102
+ `fabric-mcp`'s stdin and then ends its whole process group, so a wrapper (`npx`, a shell script,
103
+ `fabric-mcp.cmd`) cannot keep it waiting. Every hook exits 0, on every path.
88
104
  - **Read a secret.** The hooks never open Fabric's door token or an enrollment credential; `fabric-mcp` holds
89
105
  those. Nothing secret is in argv (the worker's argv is the script and a spool folder) or in the log.
90
106
  - **Send or read content.** A turn is the HEAD commit, the number of files that differ from it (untracked included)
@@ -96,8 +112,10 @@ only variable names are looked at, never a value (two of them carry the session'
96
112
  - **Rejoin more than once a minute.** Every rejoin spends the agent's Fabric request budget, which every session of
97
113
  the machine shares. A turn within 60 s of the session's last successful rejoin (the worker marks it as `rejoined`
98
114
  in the session's folder) is spooled and waits for the next worker; the end of a session always starts one (FAA-53).
99
- - **Retry in a loop.** A refused call is logged by its error code and dropped; an answer that never came is logged
100
- as `outcome=unknown`. The calls carry an `idempotencyKey`, so a repeat is the same event.
115
+ - **Retry in a loop.** A refused call, or one answered with a JSON-RPC error, is logged by its error code and its
116
+ entry dropped; an entry that cannot be read is logged as `outcome=dropped` and dropped. The entries after it, the
117
+ end's release and the end observation still go out. An answer that never came is logged as `outcome=unknown` and
118
+ leaves the spool to the next worker. The calls carry an `idempotencyKey`, so a repeat is the same event.
101
119
 
102
120
  ## Where they log, and how to turn them off
103
121
 
@@ -106,7 +124,9 @@ One line per event that did something, never content: `~/Library/Caches/ai.passi
106
124
  `%LOCALAPPDATA%\fabric-agent-adapter\hooks.log` (Windows). Mode 0600, rotated at 1 MB to `hooks.log.1`. The
107
125
  per-session spool is `sessions/<session id>/` beside it (turn entries capped at 50, `board.json`, `board.shown`; a
108
126
  folder untouched for 7 days, whose worker failed or whose session ended without SessionEnd, is removed by the next
109
- spooling hook),
127
+ Stop or SessionEnd hook, even while the hooks are inert; an end entry whose release or observation got no answer stays
128
+ for the next worker, and the folder goes only once the end is delivered; a stale worker lock is taken by renaming it, never by a
129
+ check and a delete),
110
130
  removed at the session's end.
111
131
 
112
132
  Turn them off with Claude Code's own switch (`/hooks`, or `"disableAllHooks": true` in settings), or remove the
@@ -121,8 +141,8 @@ launched-session check exact.
121
141
  | # | Need | Shape |
122
142
  |---|---|---|
123
143
  | D1 | `fabric-mcp` enrolls with the runtime session id | for `--runner claude-code`, `runtimeSessionId` = env `CLAUDE_CODE_SESSION_ID` in `fabric.session.enroll {cwd, runtimeSessionId}` (Claude Code 2.1.296 gives MCP stdio servers and Bash commands that variable, equal to the hooks' `session_id`) |
124
- | D2 | rejoin only | `fabric-mcp --runner claude-code --rejoin-only --runtime-session-id <id>` attaches to that runtime session's live enrolled session; never asks, never mints; otherwise only `fabric_whoami`, as not enrolled |
144
+ | D2 | rejoin only | `fabric-mcp --runner claude-code --rejoin-only --runtime-session-id <id>` attaches to that runtime session's live enrolled session; never asks, never mints; otherwise only `fabric_whoami`, as not enrolled; Fabric 0.3.9 exits 3 at once when no session holds the id |
125
145
  | D3 | the flag | `hub.json` `"features": ["session-hooks/1"]` once D1, D2, D4 and D5 ship |
126
146
  | D4 | `fabric_session_observe` | `{event: "turn" or "end", runtime: "claude-code", runtimeSessionId, observedAt, head, filesChanged, linesAdded, linesRemoved, reason (end only), idempotencyKey}` → `session.turn.observed@1`, `session.ended@1` |
127
- | D5 | release a whole session | `fabric_work_release {claims: "session", outcome: "let-go", reason, idempotencyKey}` |
147
+ | D5 | release a whole session | `fabric_work_release {claims: "session", outcome: "let-go", reason, idempotencyKey}`; `observedAt` too where its input schema names it (Fabric 0.3.9: `stale-end` for an end observed before the session enrolled) |
128
148
  | D6 | a launched-session marker | `FABRIC_LAUNCHED_SESSION=1` in the environment of every session Fabric launches, whatever its adapter; until then, the inference above |
@@ -34,6 +34,8 @@ from fabric_session_env import ( # noqa: F401
34
34
  LAUNCH_SIGNALS,
35
35
  FABRIC_MARKERS,
36
36
  UNREAD_LINE,
37
+ LAUNCHED_MARK,
38
+ rejoin_wait,
37
39
  _home,
38
40
  fabric_root,
39
41
  state_root,
@@ -65,12 +67,19 @@ from fabric_session_env import ( # noqa: F401
65
67
  lock_held,
66
68
  spawn_worker,
67
69
  _remove_tree,
70
+ NO_SESSION_MARK,
71
+ NO_SESSION_HOLD_S,
72
+ no_session_held,
68
73
  )
69
74
  from fabric_session_mcp import ( # noqa: F401
70
75
  RUNNER,
71
76
  OBSERVE_TOOL,
72
77
  RELEASE_TOOL,
73
78
  WAITING_TOOL,
79
+ FACTS_BUDGET_S,
80
+ FACT_KEYS,
81
+ JsonRpcError,
82
+ stored_facts,
74
83
  git_facts,
75
84
  _count_status,
76
85
  McpStdio,
@@ -81,6 +90,8 @@ from fabric_session_mcp import ( # noqa: F401
81
90
  board_unread,
82
91
  deliver,
83
92
  _wait_for_session_tools,
93
+ NO_SESSION_EXIT,
94
+ NoSessionError,
84
95
  )
85
96
  from fabric_session_mail import ( # noqa: F401
86
97
  WATCH_MIN_INTERVAL_S,
@@ -11,9 +11,12 @@ from pathlib import Path
11
11
  from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
12
12
  HOOK_FEATURE,
13
13
  HOSTS,
14
+ LAUNCHED_MARK,
14
15
  NATIVE_PLUGIN_HOSTS,
16
+ REJOIN_MIN_INTERVAL_S,
15
17
  SESSION_ID,
16
18
  UNREAD_LINE,
19
+ _remove_tree,
17
20
  _write_private,
18
21
  claude_registration,
19
22
  codex_registered,
@@ -26,15 +29,19 @@ from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
26
29
  locate_hub,
27
30
  lock_held,
28
31
  log,
32
+ no_session_held,
29
33
  project_uses_fabric,
30
34
  prune_stale_sessions,
31
35
  read_small,
36
+ rejoined_recently,
32
37
  session_dir,
33
38
  spawn_worker,
34
39
  spool,
35
40
  )
36
41
  from fabric_session_mcp import ( # noqa: E402 - the kit (FAA-65)
42
+ FACTS_BUDGET_S,
37
43
  deliver,
44
+ git_facts,
38
45
  )
39
46
  from fabric_session_mail import ( # noqa: E402 - the kit (FAA-65)
40
47
  watch_mail_cli,
@@ -44,11 +51,6 @@ from fabric_session_hosts import ( # noqa: E402 - the kit (FAA-65)
44
51
  )
45
52
 
46
53
 
47
- #: A session rejoins at most once in this span (FAA-53): every rejoin spends Fabric's per-agent request budget, which
48
- #: every session of the machine shares; a turn inside it waits in the spool for the next worker, an end never waits.
49
- REJOIN_MIN_INTERVAL_S = 60.0
50
-
51
-
52
54
  PREAMBLE = (
53
55
  "[fabric-agent-adapter] Fabric is running and this {host} has its MCP server `fabric` registered. "
54
56
  "Registration is not proof of connection or admission. "
@@ -144,15 +146,17 @@ def hook_user_prompt_submit(payload: Dict[str, Any], env: Optional[Dict[str, str
144
146
 
145
147
 
146
148
  def hook_spool(payload: Dict[str, Any], event: str, env: Optional[Dict[str, str]] = None, spawn=spawn_worker) -> str:
147
- """Stop (`turn`) and SessionEnd (`end`): spool one entry and start the worker. Returns what happened, for the
148
- tests. Nothing runs, and nothing is written, unless the running Fabric advertises `session-hooks/1` and
149
- `fabric` is registered — hub.json is read first because it is the cheap check that fails for every Fabric
150
- before the one that ships the hooks' dependency (`status` says so: "Session hooks: inert")."""
149
+ """Stop (`turn`) and SessionEnd (`end`): spool one entry, with the turn's facts measured now, and start the worker.
150
+ Returns what happened, for the tests. Nothing is spooled and no worker runs unless the running Fabric advertises
151
+ `session-hooks/1` and `fabric` is registered: hub.json is read first because it is the cheap check that fails for
152
+ every Fabric before the one that ships the hooks' dependency (`status` says so: "Session hooks: inert"). Stale
153
+ session folders are pruned before that check, so an inert Fabric does not keep them (FAA-68)."""
151
154
  session_id = payload.get("session_id")
152
155
  if not isinstance(session_id, str) or not SESSION_ID.fullmatch(session_id):
153
156
  return "skipped: no session id"
154
157
  if fabric_launched(env):
155
158
  return "skipped: launched by Fabric"
159
+ prune_stale_sessions(env, keep=session_id)
156
160
  hub = locate_hub(fabric_root(env=env))
157
161
  if hub["state"] != "running":
158
162
  return "inert: Fabric is not running"
@@ -162,12 +166,39 @@ def hook_spool(payload: Dict[str, Any], event: str, env: Optional[Dict[str, str]
162
166
  if not claude_registration(cwd, env):
163
167
  return "skipped: fabric is not registered"
164
168
  directory = session_dir(session_id, env)
165
- # The ancestor check costs a process-table read or two: only once per session, when its folder is first made.
166
- if not directory.is_dir() and launched_by_ancestor(env):
169
+ if (directory / LAUNCHED_MARK).exists():
170
+ if event == "end":
171
+ _remove_tree(directory)
167
172
  return "skipped: launched by Fabric"
173
+ if event == "end" and payload.get("reason") == "clear":
174
+ # /clear keeps the agent process and its fabric-mcp; Fabric 0.3.9 renames the session to the new id (board
175
+ # thread 02bf19c5, ask 2). No claim is released and no end observed; turns already spooled still go out.
176
+ # Spawned even while a worker runs: that one may have read the spool before the last turn landed, and no
177
+ # later hook comes for this id. The new worker waits for the lock and finds an empty spool at worst.
178
+ if any(directory.glob("*-turn.json")):
179
+ spawn(directory)
180
+ return "skipped: /clear keeps the Fabric session"
181
+ if no_session_held(directory):
182
+ # A rejoin found no Fabric session for this id (02bf19c5, ask 4): recorded once, never retried every turn.
183
+ if event == "end":
184
+ _remove_tree(directory)
185
+ return "skipped: Fabric holds no session for it"
186
+ # The ancestor check costs a process-table read or two: only once per session, when its folder is first made. A
187
+ # positive answer is kept in the folder; an unknown one (a `ps` that failed) spools nothing, so no worker can
188
+ # release a launched session's claims, and is asked again at the next hook.
189
+ if not directory.is_dir():
190
+ launched = launched_by_ancestor(env)
191
+ if launched is None:
192
+ return "skipped: whether Fabric launched this session is unknown"
193
+ if launched:
194
+ if event != "end":
195
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
196
+ _write_private(directory / LAUNCHED_MARK, "")
197
+ return "skipped: launched by Fabric"
198
+ # The turn's own facts, measured when it ended (counts and ids only, never a path), not when a worker runs.
168
199
  entry = {"event": event, "runtimeSessionId": session_id, "cwd": str(cwd),
169
- "observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
170
- prune_stale_sessions(env, keep=session_id)
200
+ "observedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
201
+ "facts": git_facts(cwd, budget_s=FACTS_BUDGET_S)}
171
202
  if event == "end" and isinstance(payload.get("reason"), str):
172
203
  entry["reason"] = payload["reason"][:64]
173
204
  spool(directory, entry)
@@ -179,15 +210,6 @@ def hook_spool(payload: Dict[str, Any], event: str, env: Optional[Dict[str, str]
179
210
  return "spooled: worker started"
180
211
 
181
212
 
182
- def rejoined_recently(directory: Path, now: Optional[float] = None) -> bool:
183
- """Whether this session's last rejoin succeeded within REJOIN_MIN_INTERVAL_S (the worker marks it)."""
184
- try:
185
- mark = (directory / "rejoined").stat().st_mtime
186
- except OSError:
187
- return False
188
- return 0 <= (time.time() if now is None else now) - mark < REJOIN_MIN_INTERVAL_S
189
-
190
-
191
213
  def status(cwd: Path, env: Optional[Dict[str, str]] = None, platform: str = sys.platform) -> Dict[str, Any]:
192
214
  root = fabric_root(platform, env)
193
215
  binary = find_fabric_bin(platform, env)