@passioncode-ai/fabric-agent-adapter 0.48.2 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +2 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +10 -10
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +4 -1
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -1
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +4 -1
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/profiles.md +3 -1
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +8 -5
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +9 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +21 -4
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +13 -5
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -3
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +10 -4
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +15 -7
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +15 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +14 -6
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +158 -6
  22. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +2 -2
  23. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +13 -12
  24. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +4 -2
  25. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -0
  26. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +13 -3
  27. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
  28. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +36 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,73 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.49.0 (2026-10-24)
4
+
5
+ The operator's sweep (FAA-111): blind seam and product readings of 0.47.0 to 0.48.3, their findings fixed, documented
6
+ or filed ([dispositions](docs/evidence/reports/2026-10-24-faa-111-tiers/README.md)), and three open rows closed.
7
+
8
+ ### Changed
9
+
10
+ - **The session start says less in a folder that does not use Fabric** (FAA-60). The plugin's `SessionStart` hook
11
+ printed the whole Fabric preamble (about 900 characters) in every session wherever `fabric` was registered, a project
12
+ Fabric never served included. It now prints the preamble only where `project_uses_fabric` finds a sign (a contract
13
+ file up to the checkout root, a project-scope registration, a service descriptor naming the checkout's origin), and
14
+ elsewhere one line that points at `fabric_whoami`, so a project Fabric serves without a contract file still finds it.
15
+ - **`watch-mail` delivers the turns the hooks spooled** (FAA-89). A look that finds spooled turns runs the worker's own
16
+ `deliver` (one rejoin that reports the turns and reads the unread count) instead of a second rejoin beside them, so
17
+ the once-a-minute budget no longer holds a turn back while an agent waits on the board. The count it reads must be a
18
+ whole number of zero or more, and a look that delivered the session's end says the session ended.
19
+ - **The trigger descriptions of three skills name their boundaries** (FAA-88): `building-fabric-services`,
20
+ `creating-fabric-agents` and `working-with-fabric`, after their first trigger evals against a real client
21
+ ([report](docs/evidence/evals/2026-10-24-trigger-run.md): 26/26, 44/45, 38/39 with Claude Code 2.1.299 and haiku).
22
+ - **`fabric_kit.py build` refuses to rewrite a released version's digests**: when `kit.json`'s `"version"` already has
23
+ a release tag and the sources differ, it stops and says to move the version first (the mistake happened three times).
24
+
25
+ ### Fixed
26
+
27
+ - **A resumed board handler ran twice** (a regression of 0.48.2). FAA-86 kept a resumed request first in the queue
28
+ until its resume returned; a handler that finished inside the resume (a synchronous `spawn`, the kit's fixture among
29
+ them) settled, the settle resumed the queue again and found the same request still first, so it ran the handler and
30
+ sent `com.effect_begin` a second time. Both twins now take the request off before resuming it and put it back first
31
+ only when the resume raises and no effect has begun (a request whose effect began waits for reconcile), which keeps
32
+ FAA-86's behaviour.
33
+ - **The OpenRouter ceiling counts money only** (DEC-0110). `Client.spent_usd` / `spentUsd` and `totals()` counted a
34
+ `subscription` receipt's cost in a shared ledger as spent, while the usage report has counted money only since 0.48.0.
35
+ A `subscription` receipt is now never money; `totals()` answers `spentUsd: 0` when every call was on a subscription
36
+ or there was no call.
37
+ - **The network guards no longer pass a 403 that carries the well-known document.** `guard_verdict` passed any 403;
38
+ a 403 whose JSON body holds the document or a part of it now fails, judged by the same shape as the no-token check.
39
+ - **The no-token check reads the document by its shape** (D4 of FAA-111). A proxy's JSON error with a `service` string
40
+ of its own failed as "the well-known document"; only a `protocol` of the extension, a `service` object with an `id` or
41
+ a `process` object with a `pid` counts. `references/remote-placement.md` lists the two false FAILs that remain.
42
+
43
+ ### Documentation
44
+
45
+ - `references/usage.md`: a service built with a kit older than 0.48.0 counts a subscription's estimate until it
46
+ re-vendors `fabric_usage`; a self-stated `spentUsd` on the twin of `budget` must match the receipts or the report is
47
+ refused; a counted `null` means unknown. `references/profiles.md` and `references/protocol.md`: a `subscription`
48
+ row's cost is an estimate, and `platform-metered` is a route.
49
+ - Probe order on a remote placement: `auth.token-file` now comes before `well-known.requires-token` (since 0.48.0); a
50
+ reader that indexes the results by position should read them by rule id.
51
+ - `test/validate.py` scans the recipes' README for em dashes. The FAA-107 and FAA-84 backlog rows are corrected.
52
+ - Filed from the seam reading: FAA-112 (FAC-SEM-137 has no verify-path vectors) and FAA-113 (the remaining Python
53
+ estate lookups that mirror a Node `Set`).
54
+
55
+ ## 0.48.3 (2026-10-24)
56
+
57
+ ### Fixed
58
+
59
+ - **The Node package reader narrows the window for a folder swapped for a link** (FAA-91). `readFolder` checked a
60
+ folder by lstat before and after listing it; a swap that lasted while a file was read and was undone before the
61
+ closing check read that file through the link. It now checks the folder after each file it reads too, and refuses it
62
+ as a symlink. Node has no openat, so a swap made and undone between two checks is still not seen; the Python kit holds
63
+ each folder open and is not affected. `references/package.md` says so.
64
+
65
+ ### Documentation
66
+
67
+ - **The support recipe's limits name the file names that read as links** (FAA-84): `README.md`, `setup.py`,
68
+ `install.sh` end in country codes, so a reply naming such a file goes to a person unless a cited entry names it; the
69
+ country codes stay so that a host on them is still caught. A test pins both sides.
70
+
3
71
  ## 0.48.2 (2026-10-23)
4
72
 
5
73
  ### Fixed
package/README.md CHANGED
@@ -34,7 +34,8 @@ python3 plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_s
34
34
  ```
35
35
 
36
36
  In Claude Code the plugin adds four session hooks: SessionStart (at startup and `/clear`, never on compact or
37
- resume) puts that preamble into the session when the MCP server `fabric` is registered and Fabric runs; when Fabric
37
+ resume) puts that preamble into the session when the MCP server `fabric` is registered, Fabric runs and the project
38
+ uses Fabric (one line pointing at `fabric_whoami` in a folder with no sign of it); when Fabric
38
39
  is down it adds one line (skip `fabric_whoami`, start Fabric through the broker if the task needs it), and only in a
39
40
  project that uses Fabric; Stop records each turn's facts (HEAD, files changed, lines added and removed, never content)
40
41
  and the board's unread count; UserPromptSubmit adds one line only when that count rose; SessionEnd releases what the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.48.2",
3
+ "version": "0.49.0",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.48.2",
6
+ "version": "0.49.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.48.2"
8
+ version: "0.49.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "2f020a696453ae299b123517f14957f6a1354379"
11
11
  ---
@@ -1,20 +1,20 @@
1
1
  ---
2
2
  name: building-fabric-services
3
3
  description: >-
4
- Use when running an agent as a Fabric service (fabric-service/0.1, its kits and probe): a local
5
- service (launchd, systemd, Windows), an ONLINE agent or dashboard on https, its dashboard link,
6
- spend report, answering a Project's board requests, or packing and signing it. «сделай агенту
7
- дашборд», «локальный сервис агента», «подключи агента к Fabric Dashboards», «отвечать на запросы с
8
- доски проекта», «расходы агента на модели», "make this agent a local service", "make an online dashboard a Fabric service",
9
- "add the well-known endpoint". NOT for a one-off script or cron job, a hosted product with no
10
- agent, the provider manifest (adapting-projects-to-fabric), a new agent's design
11
- (creating-fabric-agents), a session using Fabric or its board (working-with-fabric), or building
12
- Fabric Dashboards.
4
+ Use when running an agent as a Fabric service (fabric-service/0.1, its kits and probe): a local service (launchd,
5
+ systemd, Windows), an ONLINE agent or dashboard on https, its dashboard link and kit (narrow panel, themes), data
6
+ and cache paths, how callers reach and authenticate it, long jobs (fabric.job.*), spend report and limits, runner
7
+ routes and model profiles, the estate record and delegation tokens, answering a Project's board requests, or packing
8
+ and signing it. «сделай агенту дашборд», «локальный сервис агента», «подключи агента к Fabric Dashboards», «отвечать
9
+ на запросы с доски проекта», «расходы агента на модели», "make this agent a local service", "make an online
10
+ dashboard a Fabric service", "add the well-known endpoint". NOT for a one-off script or cron job, a hosted product
11
+ with no agent, the provider manifest (adapting-projects-to-fabric), a new agent's design (creating-fabric-agents), a
12
+ session using Fabric or its board (working-with-fabric), or building the Dashboards app (window, tray).
13
13
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
14
14
  compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); 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.
15
15
  metadata:
16
16
  author: PassionCode.ai
17
- version: "0.48.2"
17
+ version: "0.49.0"
18
18
  contract-version: "0.1.0"
19
19
  extension: "fabric-service/0.1"
20
20
  extension-commit: "2f020a696453ae299b123517f14957f6a1354379"
@@ -31,7 +31,10 @@ country prefix may be left out).
31
31
  Limits: the check reads digits, links, addresses and phone numbers, not meaning. A promise written in words
32
32
  ("your full refund is approved", "fifty dollars", "thirty days") is not caught, and neither is a host whose
33
33
  top-level domain is not on the list. A word run into the next sentence without a space (`Settings.No`) can read as a
34
- link; that reply is handed to a person, which costs a review, never a wrong answer.
34
+ link; that reply is handed to a person, which costs a review, never a wrong answer. So does a file name whose extension
35
+ is also a country code (`README.md`, `setup.py`, `install.sh`: Moldova, Paraguay, Saint Helena): the list keeps those
36
+ codes, because a host on them must still be caught, so such a reply is handed to a person unless an entry it cites
37
+ names the same file.
35
38
 
36
39
  ## Install into a service
37
40
 
@@ -25,7 +25,7 @@ python3 $K/fabric_kit.py install <service-dir> --lang node # fabric-ser
25
25
  python3 $K/fabric_kit.py install <service-dir> --module fabric_openrouter --module fabric_board
26
26
  python3 $K/fabric_kit.py install <service-dir> --all --dry-run # every module, nothing written
27
27
  python3 $K/fabric_kit.py status <service-dir> [--json] # current, older (the release), modified, missing
28
- python3 $K/fabric_kit.py build [--check] # maintainers: refresh kit.json from the sources
28
+ python3 $K/fabric_kit.py build [--check] # maintainers: refresh kit.json from the sources (refuses a tagged version)
29
29
  ```
30
30
 
31
31
  - `install` copies the closure of the named modules and nothing else. It refuses to overwrite a file it does not
@@ -45,7 +45,10 @@ python3 scripts/fabric_package.py pack <folder> --apply # writes fabric.packag
45
45
  It refuses to list what must not ship, and names each: a symlink, a special file, `.git`, `__pycache__`, `.DS_Store`,
46
46
  `*.pyc`, and files that are credentials by name (`.env` and `.env.*` but `.env.example|sample|template`, `*.pem`, `*.key`,
47
47
  `*.p12`, `*.pfx`, `*.jks`, `*.keystore`, `id_rsa`-style keys, `.npmrc`, `.netrc`, `.pypirc`). Remove them or pack a clean
48
- copy. A member named twice in `fabric.package.json` is refused too.
48
+ copy. A member named twice in `fabric.package.json` is refused too. A folder swapped for a link while it is read is refused as a
49
+ symlink. The Python kit reads each folder through an open handle, so a swap cannot redirect it. The Node kit has no
50
+ such call: it checks each folder before listing it, after each file it reads and after listing it (FAA-91), and does not
51
+ see a swap made and undone between two of those checks. Pack from a folder only you can write to.
49
52
 
50
53
  Then every problem is listed, each with its `rule`: `schema` (the contract's schema), `FAC-SEM-064` (whole: its own
51
54
  digest, each path once, every named file listed with the same SHA-256), `FAC-SEM-065` (no secret), `FAC-SEM-066`
@@ -110,4 +110,6 @@ A probe result in an event is one of `PROBE_RESULTS`; a host never appends a run
110
110
  ## Usage by route
111
111
 
112
112
  `make_usage_receipt(..., route="subscription"|"key")` records how the call was paid: the operator's own login in its
113
- vendor's runner, or an issued key. The usage report keeps the rows of one model on two routes apart.
113
+ vendor's runner, or an issued key. The usage report keeps the rows of one model on two routes apart. A subscription
114
+ row's `costUsd` is an estimate at list price, never money spent: a limit's `spentUsd` leaves it out (DEC-0110,
115
+ [usage](usage.md)).
@@ -84,7 +84,7 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
84
84
  (a key not entered, a model not chosen). It is not an outage, and a watcher does not alert on it. The kits pass
85
85
  entries through unchanged.
86
86
  - `configuration` (DEC-0046) MAY say which runner and model profile the service resolved and from where
87
- (`request`, `agent`, `binding`, `estate`); a usage receipt MAY carry the `route` (`subscription` or `key`). The kits
87
+ (`request`, `agent`, `binding`, `estate`); a usage receipt MAY carry the `route` (`subscription`, `key` or `platform-metered`; a `subscription` row's cost is an estimate, never money, DEC-0110). The kits
88
88
  build both: `fabric_profiles.configuration` and `make_usage_receipt(route=)` ([profiles](profiles.md)).
89
89
  - `build` needs `commit` or `digest` (`sha256:<64 hex>`).
90
90
  - `summary`: at most six tiles; `attention: true` counts toward the host's badge.
@@ -62,17 +62,20 @@ watch the gate fail.
62
62
  (Heroku, Fly, Render, a CDN) a request that names a foreign `Host` never reaches the process: the
63
63
  router answers it, usually `404` or `421`. The probe counts that as the refusal it is, as long as
64
64
  the body is not the well-known document. The Origin and cross-site checks name the right `Host`,
65
- reach the service, and still need its own `403`.
65
+ reach the service, and still need its own `403`. No refusal carries the document: a `403` whose JSON body
66
+ holds the document or a part of it (the shapes below) fails (FAA-111).
66
67
 
67
68
  Without the token the well-known route answers `401` with none of the service's data (DEC-0109). The kit's server
68
69
  answers it with an empty body. An identity proxy in front of the service (Cloudflare Access, for one) may answer that
69
70
  `401` with its own sign-in page: `well-known.requires-token` passes it when the page neither parses as the well-known
70
71
  document nor names the service's identity values (its id, `id.instance`, build, or pid, as whole words). The probe
71
72
  takes the service's own host name out first, because a sign-in page names the address it guards, and does not look for
72
- a value shorter than four characters, because a platform's pid is often `1`. A copy of the document anywhere inside a JSON body
73
- fails, however short the id. One false FAIL remains possible: when the document was read, a proxy page that happens to
74
- contain the service's pid as a whole number (an inline script's constant, say) fails the rule; the evidence names the
75
- value it found.
73
+ a value shorter than four characters, because a platform's pid is often `1`. A copy of the document, or of its parts,
74
+ anywhere inside a JSON body fails, however short the id: an object whose `protocol` is the extension's, a `service`
75
+ object with an `id`, a `process` object with a `pid`. A proxy's own JSON error with a `service` string passes. Two false
76
+ FAILs remain possible, and the evidence names the value it found: a proxy page that names the service's id or
77
+ `id.instance` (an application the proxy titles after the service), and, when the document was read, a page that happens
78
+ to contain the service's pid as a whole number (an inline script's constant, say).
76
79
 
77
80
  ## What a host does and will not do
78
81
 
@@ -82,8 +82,15 @@ ledger.report(service_id=ID, budget={"period": "month", "limitUsd": 100}, budget
82
82
  a `subscription` route's `costUsd` is an estimate at list price, never spent money, so a window
83
83
  spent only on a subscription spends `0`, and money is `null` only while no call off a subscription
84
84
  is priced. `usage_spend(rows)` / `usageSpend(rows)` is the contract's `usageSpend` over
85
- `days[].byModel[]` rows: money and the subscription's estimate apart. A per-order limit
86
- (`per_job`, `approval`) gets `spentUsd: null`. Every other limit states its own spend.
85
+ `days[].byModel[]` rows: money and the subscription's estimate apart. A day's `costUsd` still sums every
86
+ route, so beside a subscription it is larger than the limit's `spentUsd`. A service built with a kit older than
87
+ 0.48.0 keeps counting the estimate as spent until it re-vendors `fabric_usage`
88
+ (`fabric_kit.py install <dir> --module fabric_usage`, or `--lang node --module fabric-usage`). A per-order limit
89
+ (`per_job`, `approval`) gets `spentUsd: null`. Every other limit states its own spend. The machine twin of
90
+ `budget` may leave `spentUsd` out (the kit counts it) or state the same number the receipts give; a different
91
+ number makes the twins disagree, and the report is refused (`FAC-SEM-031`). A counted `spentUsd` of `null` means
92
+ unknown, never zero: no call off a subscription in the window is priced, or one receipt in it names a `route` the
93
+ contract does not know or a cost its schema refuses, and then the whole window is unknown rather than guessed.
87
94
  - `scope` is `machine`, `project`, `pool` or `job`; a `project` or `pool` limit names its
88
95
  `subject`. A limit has at most one window (`period`, `windowSeconds` or `since`), and it agrees
89
96
  with the kind (`daily`: the day or 86400 s; `monthly`: the month or 28–31 days; `velocity`: a
@@ -79,22 +79,39 @@ def priority_problems(plist: dict) -> list:
79
79
  PLATFORM_HOST_REFUSALS = (400, 404, 421)
80
80
 
81
81
 
82
+ def _body_carries_document(body: bytes) -> bool:
83
+ """Whether a raw answer body parses as JSON that carries the well-known document or a part of it."""
84
+ try:
85
+ return _carries_document(json.loads((body or b"").decode("utf-8", "replace")))
86
+ except RecursionError:
87
+ return True
88
+ except ValueError:
89
+ return False
90
+
91
+
82
92
  def guard_verdict(rule: str, remote: bool, status: int, body: bytes) -> Tuple[str, str]:
93
+ """A guard refuses with the service's own 403, and no refusal carries the well-known document (a 403 whose body is
94
+ the document refused nothing). Elsewhere the substring `fabric-service/` alone already counts as disclosure."""
83
95
  if status == 403:
96
+ if _body_carries_document(body):
97
+ return "FAIL", "HTTP 403, and the body carries the well-known document"
84
98
  return "PASS", "HTTP 403"
85
- disclosed = b"fabric-service/" in (body or b"")
99
+ disclosed = b"fabric-service/" in (body or b"") or _body_carries_document(body)
86
100
  if rule == "network.host-check" and remote and status in PLATFORM_HOST_REFUSALS and not disclosed:
87
101
  return "PASS", "HTTP %d from the platform router: a foreign Host never reaches the service" % status
88
102
  return "FAIL", "HTTP %d%s" % (status, ", and the body is the well-known document" if disclosed else "")
89
103
 
90
104
 
91
105
  def _carries_document(value: Any, depth: int = 0) -> bool:
92
- """Whether a parsed body is, or holds anywhere inside it, an object that reads as the well-known document: its
93
- `protocol` is the extension's, or it carries `service` or `process` (D3 of FAA-109)."""
106
+ """Whether a parsed body is, or holds anywhere inside it, an object that reads as the well-known document or a part
107
+ of it: its `protocol` is the extension's, its `service` is an object with an `id`, or its `process` an object with
108
+ a `pid` (D3 of FAA-109). A proxy's own error with a `service` string is none of these."""
94
109
  if depth > 64:
95
110
  return True # nothing honest nests that deep; refuse rather than recurse without bound
96
111
  if isinstance(value, dict):
97
- if value.get("protocol") == fs.PROTOCOL or "service" in value or "process" in value:
112
+ service, process = value.get("service"), value.get("process")
113
+ if value.get("protocol") == fs.PROTOCOL or (isinstance(service, dict) and "id" in service) \
114
+ or (isinstance(process, dict) and "pid" in process):
98
115
  return True
99
116
  return any(_carries_document(v, depth + 1) for v in value.values())
100
117
  if isinstance(value, list):
@@ -318,9 +318,10 @@ export class Responder {
318
318
  }
319
319
 
320
320
  /**
321
- * Resume a transferred request while nothing runs; each idle tick takes the next (finding 39). A request leaves the
322
- * queue only once it has resumed: a lost answer, of its probe (finding 35) or of the `com.complete` that sends a known
323
- * outcome (FAA-86), keeps it first in line, and the next tick sends the same keys again from the checkpoint.
321
+ * Resume a transferred request while nothing runs; each idle tick takes the next (finding 39). A lost answer, of its
322
+ * probe (finding 35) or of the `com.complete` that sends a known outcome (FAA-86), puts the request back first in
323
+ * line, and the next tick sends the same keys again from the checkpoint. It leaves the queue before it resumes, so a
324
+ * handler that settles inside the resume (and reaches `replace`, which resumes the queue again) never runs it twice.
324
325
  */
325
326
  async resumeNext() {
326
327
  while (!this.current && this.toResume.length && !this.stopping) {
@@ -331,8 +332,15 @@ export class Responder {
331
332
  }
332
333
  const entry = JSON.parse(JSON.stringify(stored));
333
334
  if (entry.probe) await this.repliedBefore(entry);
334
- await this.resume(entry);
335
- if (this.toResume[0] === entry.request) this.toResume.shift();
335
+ this.toResume.shift();
336
+ try {
337
+ await this.resume(entry);
338
+ } catch (error) {
339
+ // Back first only while it can still be resumed: once its effect began it waits for reconcile.
340
+ const held = this.checkpoint.entries[entry.request];
341
+ if (held && !held.effect && held.step !== 'unknown' && !this.toResume.includes(entry.request)) this.toResume.unshift(entry.request);
342
+ throw error;
343
+ }
336
344
  }
337
345
  }
338
346
 
@@ -142,6 +142,7 @@ export class Client {
142
142
  let total = 0;
143
143
  for (const r of this.ledger.receipts()) {
144
144
  if (!isObject(r) || !this.inWindow(r.at, today)) continue;
145
+ if (r.route === 'subscription') continue; // an estimate at list price, never money spent (DEC-0110)
145
146
  if (typeof r.costUsd === 'number' && price(r.costUsd) !== null) total += r.costUsd;
146
147
  else total += this.unmeasuredWorst.get(`${r.at} ${r.model}`) ?? unmeasuredUsd; // known here, else unmeasuredUsd
147
148
  }
@@ -322,14 +323,22 @@ export class Client {
322
323
  }
323
324
  }
324
325
 
325
- // Calls, priced spend and unpriced calls over receipts; spentUsd is null when every call is unpriced.
326
+ // Calls, money spent and unpriced calls over receipts. A subscription receipt's cost is an estimate, never money
327
+ // (DEC-0110): spentUsd is null only when calls off a subscription exist and none is priced, 0 when all were on one.
326
328
  export function totals(receipts) {
327
329
  let calls = 0;
328
330
  let priced = 0;
331
+ let paid = 0;
332
+ let paidPriced = 0;
329
333
  let spent = 0;
330
334
  for (const r of receipts) {
331
335
  calls += 1;
332
- if (typeof r.costUsd === 'number' && price(r.costUsd) !== null) { priced += 1; spent += r.costUsd; }
336
+ const onSubscription = r.route === 'subscription';
337
+ if (!onSubscription) paid += 1;
338
+ if (typeof r.costUsd === 'number' && price(r.costUsd) !== null) {
339
+ priced += 1;
340
+ if (!onSubscription) { paidPriced += 1; spent += r.costUsd; }
341
+ }
333
342
  }
334
- return { calls, spentUsd: priced ? spent : null, unpricedCalls: calls - priced };
343
+ return { calls, spentUsd: paid && !paidPriced ? null : spent, unpricedCalls: calls - priced };
335
344
  }
@@ -393,7 +393,8 @@ function readRegular(file) {
393
393
  * cache litter, and a file that is a credential by its name, each named, so the person can remove it or pack a clean
394
394
  * copy. A file or folder it cannot read is a refusal naming it (`Cannot read <path>: EACCES.`), never a crash (FAA-78,
395
395
  * finding 41); a file is opened without following a link and read only when it is a regular file (finding 42). Node
396
- * has no openat, so a folder swapped for a link mid-walk is checked by lstat before and after it is listed; the Python
396
+ * has no openat, so a folder swapped for a link mid-walk is checked by lstat before it is listed, after each file it
397
+ * reads and after it is listed (FAA-91); a swap made and undone between two of those checks is not seen. The Python
397
398
  * twin holds each folder open instead.
398
399
  */
399
400
  export function readFolder(folder) {
@@ -402,6 +403,11 @@ export function readFolder(folder) {
402
403
  if (!stat?.isDirectory()) throw new PackageError(`${folder} is not a folder.`);
403
404
  const files = new Map();
404
405
  const refused = [];
406
+ const moved = (dir, before) => {
407
+ let now;
408
+ try { now = fs.lstatSync(dir); } catch { return true; }
409
+ return !now.isDirectory() || now.ino !== before.ino || now.dev !== before.dev;
410
+ };
405
411
  const walk = (dir, rel) => {
406
412
  let entries;
407
413
  let before;
@@ -419,15 +425,15 @@ export function readFolder(folder) {
419
425
  if (!why) {
420
426
  let read;
421
427
  try { read = readRegular(path.join(dir, entry.name)); } catch (error) { throw unreadable(where, error); }
428
+ if (before && moved(dir, before)) { refused.push(`${rel} (a symlink)`); return; } // read through a swapped folder
422
429
  if (read.why === undefined) { files.set(where, read.bytes); continue; }
423
430
  why = read.why;
424
431
  }
425
432
  refused.push(`${where} (${why})`);
426
433
  }
427
434
  if (before) {
428
- let after;
429
- try { after = fs.lstatSync(dir); } catch (error) { throw unreadable(rel, error); }
430
- if (!after.isDirectory() || after.ino !== before.ino || after.dev !== before.dev) refused.push(`${rel} (a symlink)`);
435
+ try { fs.lstatSync(dir); } catch (error) { throw unreadable(rel, error); }
436
+ if (moved(dir, before)) refused.push(`${rel} (a symlink)`);
431
437
  }
432
438
  };
433
439
  walk(folder, '');
@@ -377,19 +377,27 @@ class Responder:
377
377
  self.checkpoint.put(entry)
378
378
 
379
379
  def _resume_next(self) -> None:
380
- """Resume a transferred request while nothing runs; each idle tick takes the next (finding 39). A request leaves
381
- the queue only once it has resumed: a lost answer, of its probe (finding 35) or of the `com.complete` that sends
382
- a known outcome (FAA-86), keeps it first in line, and the next tick sends the same keys again from the
383
- checkpoint."""
380
+ """Resume a transferred request while nothing runs; each idle tick takes the next (finding 39). A lost answer,
381
+ of its probe (finding 35) or of the `com.complete` that sends a known outcome (FAA-86), puts the request back
382
+ first in line, and the next tick sends the same keys again from the checkpoint. It leaves the queue before it
383
+ resumes, so a handler that settles inside the resume (and reaches `_replace`, which resumes the queue again)
384
+ never runs it twice."""
384
385
  while self.current is None and self._to_resume and not self.stopping:
385
386
  entry = self.checkpoint.entries.get(self._to_resume[0])
386
387
  if entry is not None and self.slots.get(entry["capability"], {}).get("state") == "held":
387
388
  entry = json.loads(json.dumps(entry))
388
389
  if entry.get("probe"):
389
390
  self._replied_before(entry)
390
- self._resume(entry)
391
- if self._to_resume and self._to_resume[0] == entry["request"]:
392
- self._to_resume.pop(0)
391
+ self._to_resume.pop(0)
392
+ try:
393
+ self._resume(entry)
394
+ except BaseException:
395
+ # Back first only while it can still be resumed: once its effect began it waits for reconcile.
396
+ held = self.checkpoint.entries.get(entry["request"])
397
+ if held is not None and not held.get("effect") and held.get("step") != "unknown" \
398
+ and entry["request"] not in self._to_resume:
399
+ self._to_resume.insert(0, entry["request"])
400
+ raise
393
401
  else:
394
402
  self._to_resume.pop(0)
395
403
 
@@ -23,6 +23,7 @@ import json
23
23
  import os
24
24
  import re
25
25
  import shutil
26
+ import subprocess
26
27
  import sys
27
28
  from pathlib import Path
28
29
  from typing import Dict, List
@@ -61,6 +62,16 @@ def load_manifest() -> Dict:
61
62
  return json.loads(MANIFEST.read_text(encoding="utf-8"))
62
63
 
63
64
 
65
+ def _released(version: str) -> bool:
66
+ """Whether the checkout the kit sits in carries the release tag `v<version>` (False outside a Git checkout)."""
67
+ try:
68
+ listed = subprocess.run(["git", "-C", str(HERE), "tag", "--list", "v%s" % version],
69
+ capture_output=True, text=True, timeout=10)
70
+ except (OSError, subprocess.SubprocessError):
71
+ return False
72
+ return listed.returncode == 0 and bool(listed.stdout.strip())
73
+
74
+
64
75
  def build(check: bool = False) -> List[str]:
65
76
  """Refresh every module's imports and sha256 in kit.json, and record this release's digests in its history.
66
77
  Returns what changed (with check=True nothing is written)."""
@@ -83,6 +94,10 @@ def build(check: bool = False) -> List[str]:
83
94
  current = {e["file"]: e["sha256"] for e in manifest["python"] + manifest["node"]}
84
95
  current.update(digests)
85
96
  if manifest.setdefault("history", {}).get(manifest["version"]) != current:
97
+ if not check and manifest["version"] in manifest["history"] and _released(manifest["version"]):
98
+ # A build before the version moves on would rewrite a released version's digests (FAA-111).
99
+ raise SystemExit("kit.json's version %s is released (tag v%s): move its \"version\" to the next release, "
100
+ "then build" % (manifest["version"], manifest["version"]))
86
101
  manifest["history"][manifest["version"]] = current
87
102
  changed.append("kit.json history %s" % manifest["version"])
88
103
  if changed and not check:
@@ -212,12 +212,15 @@ class Client:
212
212
  return day == today if self.window == "day" else day[:7] == today[:7]
213
213
 
214
214
  def spent_usd(self, *, unmeasured_usd: float = 0.0) -> float:
215
- """What the ledger records for the current window; a receipt without a cost counts as `unmeasured_usd`."""
215
+ """What the ledger records for the current window; a receipt without a cost counts as `unmeasured_usd`. A
216
+ `subscription` receipt in a shared ledger is an estimate at list price, never money spent (DEC-0110)."""
216
217
  today = fabric_time.utc_day(fabric_time.now_ms(self._now))
217
218
  total = 0.0
218
219
  for receipt in self.ledger.receipts():
219
220
  if not isinstance(receipt, dict) or not self._in_window(receipt.get("at"), today):
220
221
  continue
222
+ if receipt.get("route") == "subscription":
223
+ continue
221
224
  cost = receipt.get("costUsd")
222
225
  measured = isinstance(cost, (int, float)) and _price(cost) is not None
223
226
  if measured:
@@ -419,13 +422,18 @@ class Client:
419
422
 
420
423
 
421
424
  def totals(receipts: Iterable[Mapping[str, Any]]) -> Dict[str, Any]:
422
- """Calls, priced spend and unpriced calls over receipts (what a dashboard line shows); `spentUsd` is None when
423
- every call is unpriced."""
424
- calls = priced = 0
425
+ """Calls, money spent and unpriced calls over receipts (what a dashboard line shows). A `subscription` receipt's
426
+ cost is an estimate, never money (DEC-0110): `spentUsd` is None only when calls off a subscription exist and none of
427
+ them is priced, and 0.0 when every call was on a subscription."""
428
+ calls = priced = paid = paid_priced = 0
425
429
  spent = 0.0
426
430
  for r in receipts:
427
431
  calls += 1
432
+ on_subscription = r.get("route") == "subscription"
433
+ paid += 0 if on_subscription else 1
428
434
  if isinstance(r.get("costUsd"), (int, float)) and _price(r.get("costUsd")) is not None:
429
435
  priced += 1
430
- spent += float(r["costUsd"])
431
- return {"calls": calls, "spentUsd": spent if priced else None, "unpricedCalls": calls - priced}
436
+ if not on_subscription:
437
+ paid_priced += 1
438
+ spent += float(r["costUsd"])
439
+ return {"calls": calls, "spentUsd": None if paid and not paid_priced else spent, "unpricedCalls": calls - priced}
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fabric-service-kit",
3
- "version": "0.48.2",
3
+ "version": "0.49.0",
4
4
  "about": "The service kit's modules in dependency order: a module imports only modules listed before it. Written by fabric_kit.py build; edit the order and layers by hand, never the digests.",
5
5
  "python": [
6
6
  {
@@ -355,7 +355,7 @@
355
355
  "fabric_hub",
356
356
  "fabric_service"
357
357
  ],
358
- "sha256": "6fa205f7aa80f920cb910b52516086a54f0a06ce88c01dc6c39dbeabacc779d2"
358
+ "sha256": "2845d7d758b06ac4588bdf825a471c201a46ce535a631805697b1e35495fead6"
359
359
  },
360
360
  {
361
361
  "module": "fabric_board_fixture",
@@ -397,7 +397,7 @@
397
397
  "fabric_time",
398
398
  "fabric_usage"
399
399
  ],
400
- "sha256": "2017d898c17001d7275f58267de653be7ecf6acd17ad8d9b24ecb7a43e5617d4"
400
+ "sha256": "309f588f9c976b1061323f3161e35d86248afd5b59b16391afbfc4e1099ecd65"
401
401
  }
402
402
  ],
403
403
  "node": [
@@ -535,7 +535,7 @@
535
535
  "fabric-package-schema",
536
536
  "fabric-secrets"
537
537
  ],
538
- "sha256": "49b757eefcc50e4c91585d5a5694bf3c83d0b03d112c1837f1c9f01466797ab5"
538
+ "sha256": "13913ea6d12056c3a31b8222b6c4aab8395941fe519913692e1a06d8672e1c88"
539
539
  },
540
540
  {
541
541
  "module": "fabric-protocols",
@@ -747,7 +747,7 @@
747
747
  "fabric-hub",
748
748
  "fabric-service"
749
749
  ],
750
- "sha256": "82961581c6a5b6872dec0c18c2afca77286ab86dae5d21300aeea9903ec785a4"
750
+ "sha256": "26c9b0b2818a4cedc5248b08ea6732e2ee4ee81e31f6fee2b572a019db813c93"
751
751
  },
752
752
  {
753
753
  "module": "fabric-board-fixture",
@@ -788,7 +788,7 @@
788
788
  "fabric-time",
789
789
  "fabric-usage"
790
790
  ],
791
- "sha256": "f8f0168691f968b7cd85f36aa98e2167e88cc44f1104ff514fd0c7699b339253"
791
+ "sha256": "ab6c4c7251d563980d895061ee19976bde2f364c21b6354efdd9a9027b1ea139"
792
792
  }
793
793
  ],
794
794
  "history": {
@@ -2799,6 +2799,158 @@
2799
2799
  "fabric-openrouter.mjs": "f8f0168691f968b7cd85f36aa98e2167e88cc44f1104ff514fd0c7699b339253",
2800
2800
  "fabric-package.schema.json": "2ba799f576fad0f608029223c4ccccd122152f1c5dbfa8ce8b31cd63ac92ad44",
2801
2801
  "share-registration.schema.json": "34b3997eeaf936ebc861342a23045634a7c18c19f01d7ed4198310d81989f5b0"
2802
+ },
2803
+ "0.48.3": {
2804
+ "fabric_jsvalue.py": "ff1bbfae999fb5683e59e31189143f1c85fa9d7780040b5a6952e060a143fc18",
2805
+ "fabric_time.py": "588f383da4ac6a243ea95b6ffc3a0aa99ca94245732dc4937734f190b7af4ae9",
2806
+ "fabric_identifiers.py": "0fd814d521ac3aa39907458265870753046a73f48af3d8581ab864b93b211607",
2807
+ "fabric_secrets.py": "68a0b5e9e90c468ec9ac67b2f0fd1848e08813d6ec1ec14a2a47337eb1a98911",
2808
+ "fabric_crypto.py": "9c34aee20dbe4d6742e4fdbaab68ceb934bbe7dbd3a904c0010824ace16cdeaa",
2809
+ "fabric_base.py": "f62bcc2ed9912660fa875d27a09f60867260cc4925f79873501fd9620be59611",
2810
+ "fabric_platform.py": "a9350dff55ef0fb3a23cdb4d0320a15868b15a8deb10d2eb74d72c58740c284b",
2811
+ "fabric_paths.py": "6c92af34292baddf7e10d3134d0a5b0bb23387741812a9f79c409cf1628bad23",
2812
+ "fabric_estate.py": "f493cd424dae6380d1caad91ffdca2764e51a49a6b1051a77731e3d8755277cd",
2813
+ "fabric_delegation.py": "6638a6039befe3b43f59018bab292e050931af21b544df1044cd5059572ded36",
2814
+ "fabric_share.py": "78506fe1a60829c525dbf6266a0aa0749a2164290ed94da8249a20b4e9be5605",
2815
+ "fabric_ceremonies.py": "a59e83cf4adb2669a9b98008e3d3a0cba991a35cbff97bc604fc82fe81cfd49f",
2816
+ "fabric_package_schema.py": "e623f0408d38e3343ed66222fb812907c6c52e1a286eb1c1176ab2e64640c9ad",
2817
+ "fabric_package.py": "ff533f1b3f92b4ea0f532fcec50b68c610d47549930eef8ad5247692a64dfb3c",
2818
+ "fabric_protocols.py": "28e693282cddff8e4396ae39ce6fa935e875e8ddac7375bea2c189dd6fb04970",
2819
+ "fabric_connections.py": "77d3ccf863c70e9209d21ef45c46a6db9c06237019bc20d54f8d3946873f3f11",
2820
+ "fabric_lock.py": "ba2399d2137c7a7791b7133b9db9cca36693f4eb7e30955b25a75ade814e95ba",
2821
+ "fabric_usage.py": "b1f335cab4625171a616cf68cf3fd892b18bdadcf4baec3a3a0fdcf12785c170",
2822
+ "fabric_backups.py": "02dc5eb66ec956723d3ddba07b6dd15f6a9726d901f4d60edfd02eee57880ceb",
2823
+ "fabric_descriptor.py": "daf16f95b1810283b1386488e006e940a0cdfa5c3c8d09386ac33d29d58df78d",
2824
+ "fabric_auth.py": "55794587750f6c9f40935b74295d83d7096cb4b26a0b36ac292334cd87d8f853",
2825
+ "fabric_events.py": "0086886c2c29dbd5c9d152be9d375c5bdf4a29f89b8095baff8490c2bcd64e47",
2826
+ "fabric_logs.py": "86dc55bfafa2a57264722e2403510a249b95de0e4a722b2e4bc3948e3bd93da9",
2827
+ "fabric_supervisors.py": "a2dbf15aa1c4eb9de42996b413103fa4814afd2e4e66d1ab7315f30e06ab6841",
2828
+ "fabric_service.py": "103ab4026f96e127c01cd91e4ad44f6b0ce2424ff33f078ed106b39e773a10e3",
2829
+ "fabric_profiles.py": "26e21ec29c016000f8ca8053b7b489184854a73be1b7fdc0a18d12990203db9b",
2830
+ "fabric_interop_rules.py": "b5f0ecb1791b4c50c5ac151f987f692ed7001bc74841a8a161125ec775145e09",
2831
+ "fabric_interop.py": "f92fa111328fe3811438f733d8f0eb5295e6941c774123a78e26318bdcb35fc3",
2832
+ "fabric_hub.py": "b9ac852e601b1d54cf61351677a628afc69faeed7d1fc74ebe541c1cf38e0c2a",
2833
+ "fabric_board_shapes.py": "0a0e8d3d40e33dfd8c457aedb85330b2ff25f8c49a33eb6cbd5a5cc8a5007f7e",
2834
+ "fabric_board_link.py": "b5bff994cb615c6a4bb0d88d020e3a59504166180e1fa1aea3be93224f2aeccb",
2835
+ "fabric_board_session.py": "6fa205f7aa80f920cb910b52516086a54f0a06ce88c01dc6c39dbeabacc779d2",
2836
+ "fabric_board_fixture.py": "69761ce205ec640762ec9b591c6903e5a87f844e8d423b041681924027e39d1e",
2837
+ "fabric_board.py": "0f075894b3e352194fcaf3fc7d08dee4e9f1c215e859769ed85fa5010e4a1599",
2838
+ "fabric_http.py": "5d7f72af3a73fdbe8c1f487ecf60cef23a7c46289eba542b03aff4706b8218d4",
2839
+ "fabric_openrouter.py": "2017d898c17001d7275f58267de653be7ecf6acd17ad8d9b24ecb7a43e5617d4",
2840
+ "fabric-jsvalue.mjs": "dfbdb8c41584972c9389750a41719374a1cfc5652ed438dd1623025190b0106e",
2841
+ "fabric-time.mjs": "3ff932069038a720df1c68c6492976906737b2e67e5c12d9c81c418597f661de",
2842
+ "fabric-identifiers.mjs": "a18c2a501a4aa170bfd3d99e571ad6b7662649b4d3b9d2a540c002ea42811bbf",
2843
+ "fabric-secrets.mjs": "6ad8ab18ac899ecc2384e8ec6a3fb689b92be3b9a5ce3a59b92a37d52da5d766",
2844
+ "fabric-crypto.mjs": "038e03689701e3c9721a1e7f8e0a4d02a52d6a5d1661b0dc84035edc3ebbfcf6",
2845
+ "fabric-base.mjs": "7804b2df7715dbbf9702a9b61b6b8cef2110f9a52b04cabd8fb0a66458d87512",
2846
+ "fabric-platform.mjs": "a8119d95ebba4d589a9e401f5d6fc8dda62d59d44e4c1ff811d97fac3771ac0c",
2847
+ "fabric-paths.mjs": "2201b4ed7126f260931b63e8386377b4317d8dcad419d109bf22c683e44b7f29",
2848
+ "fabric-estate.mjs": "b6610aaad9d00d8a7f42955627fb81725950841455bcedfc050abb0e079f0f93",
2849
+ "fabric-delegation.mjs": "dafde9ea327c88d92dc73d1400282b43dc6f84dd345a2d2b9b154304f65fc9d7",
2850
+ "fabric-share.mjs": "d40a7405d7bd4c3213ed691330b7549afe8e023db0753b582e249714e672010d",
2851
+ "fabric-ceremonies.mjs": "117d4374c9e95c930a38de7adbd4323774798f4fe6ec6f24afcd6f2c7298ca44",
2852
+ "fabric-package-schema.mjs": "cd28092375db0fc21ffab1f0bb7794f7f8979ee08c64d3b9faf4b3845357caa6",
2853
+ "fabric-package.mjs": "13913ea6d12056c3a31b8222b6c4aab8395941fe519913692e1a06d8672e1c88",
2854
+ "fabric-protocols.mjs": "dc039ec3612d0090c9ba6a3a949b0b7df54712bead300dcb6cc8b9bc305cd183",
2855
+ "fabric-connections.mjs": "303432e547ce563e90ca9ffcd827f5612f51d3582929834e1add0fdaf6485dcc",
2856
+ "fabric-lock.mjs": "ac219ab0a326d5c43c7c4c255e0c3843d4794de795a60e3af661e7d5350ea052",
2857
+ "fabric-usage.mjs": "1c4d18004639409624ac3516a02f2b60bbf55abdb612e9a96c37e83666eb87a7",
2858
+ "fabric-backups.mjs": "849acdc6eba0fff648c8158e61f6b68cccd7bedf0880f7d5ec03a6a20f1e7005",
2859
+ "fabric-descriptor.mjs": "95c0b7ff36c29d7c748f1fa6fb1767d6fe8c97fca7f1ee404559930abbd3cc84",
2860
+ "fabric-auth.mjs": "3ea6796470fcd829bc72a772fb7e4c37532a09d1e37ff41592dcf1a1a3074ca2",
2861
+ "fabric-events.mjs": "c463d6ffcae24229a5c608d8fcea4268d2ccc5024b19fa10e5a452284068fa71",
2862
+ "fabric-logs.mjs": "58467e5719c16e099d05bbedec47a95712506ab03c33f3cd4be1252e8498a946",
2863
+ "fabric-supervisors.mjs": "9d61c1a17538a5e2453e33e5ae347337336748041bc054478a96dd51f3d4fc51",
2864
+ "fabric-service.mjs": "1b4639a10c530c1241d317c5e9cf3a10cd96e5d9af8b29fc1b1844de5c63bfec",
2865
+ "fabric-profiles.mjs": "314376cf2deb87f235fb18b11d3b532e211f585b9af0a9302b5f4cc39971b532",
2866
+ "fabric-interop-rules.mjs": "57a8081307956e8c381119b361fa5232a7bf791815be8e3541eb5492820e2ea5",
2867
+ "fabric-interop.mjs": "581983ec3e197e5c2340ad25e8468320c69a2459c90097c7fcf2699766b4fb26",
2868
+ "fabric-hub.mjs": "c005115a864cfd67daa4862097bb14acfe5a60bf653897e5bf8a570dd80f1070",
2869
+ "fabric-board-shapes.mjs": "c1db7b7bd0d58d82bb6d8dfbc3cd1547a256a333052d367e82ff1b3edf2804d7",
2870
+ "fabric-board-link.mjs": "4cd6d4e21713a38c8e56f7d59db3148638b8c27c08e774d96a0f6283c6049e0a",
2871
+ "fabric-board-session.mjs": "82961581c6a5b6872dec0c18c2afca77286ab86dae5d21300aeea9903ec785a4",
2872
+ "fabric-board-fixture.mjs": "83c4b7e33ee47b68385faf2569d38d5c8a0c5cb0f8656090f41e1b0eed2f436e",
2873
+ "fabric-board.mjs": "9e7bfe4d412521982224ef12bebfc2ff255eeeaf8d96cf40234ac18de68e7a59",
2874
+ "fabric-http.mjs": "f886bcb49c4353d039387ac9568738dda26697a94dd07fc6a6204500f0bc27bf",
2875
+ "fabric-openrouter.mjs": "f8f0168691f968b7cd85f36aa98e2167e88cc44f1104ff514fd0c7699b339253",
2876
+ "fabric-package.schema.json": "2ba799f576fad0f608029223c4ccccd122152f1c5dbfa8ce8b31cd63ac92ad44",
2877
+ "share-registration.schema.json": "34b3997eeaf936ebc861342a23045634a7c18c19f01d7ed4198310d81989f5b0"
2878
+ },
2879
+ "0.49.0": {
2880
+ "fabric_jsvalue.py": "ff1bbfae999fb5683e59e31189143f1c85fa9d7780040b5a6952e060a143fc18",
2881
+ "fabric_time.py": "588f383da4ac6a243ea95b6ffc3a0aa99ca94245732dc4937734f190b7af4ae9",
2882
+ "fabric_identifiers.py": "0fd814d521ac3aa39907458265870753046a73f48af3d8581ab864b93b211607",
2883
+ "fabric_secrets.py": "68a0b5e9e90c468ec9ac67b2f0fd1848e08813d6ec1ec14a2a47337eb1a98911",
2884
+ "fabric_crypto.py": "9c34aee20dbe4d6742e4fdbaab68ceb934bbe7dbd3a904c0010824ace16cdeaa",
2885
+ "fabric_base.py": "f62bcc2ed9912660fa875d27a09f60867260cc4925f79873501fd9620be59611",
2886
+ "fabric_platform.py": "a9350dff55ef0fb3a23cdb4d0320a15868b15a8deb10d2eb74d72c58740c284b",
2887
+ "fabric_paths.py": "6c92af34292baddf7e10d3134d0a5b0bb23387741812a9f79c409cf1628bad23",
2888
+ "fabric_estate.py": "f493cd424dae6380d1caad91ffdca2764e51a49a6b1051a77731e3d8755277cd",
2889
+ "fabric_delegation.py": "6638a6039befe3b43f59018bab292e050931af21b544df1044cd5059572ded36",
2890
+ "fabric_share.py": "78506fe1a60829c525dbf6266a0aa0749a2164290ed94da8249a20b4e9be5605",
2891
+ "fabric_ceremonies.py": "a59e83cf4adb2669a9b98008e3d3a0cba991a35cbff97bc604fc82fe81cfd49f",
2892
+ "fabric_package_schema.py": "e623f0408d38e3343ed66222fb812907c6c52e1a286eb1c1176ab2e64640c9ad",
2893
+ "fabric_package.py": "ff533f1b3f92b4ea0f532fcec50b68c610d47549930eef8ad5247692a64dfb3c",
2894
+ "fabric_protocols.py": "28e693282cddff8e4396ae39ce6fa935e875e8ddac7375bea2c189dd6fb04970",
2895
+ "fabric_connections.py": "77d3ccf863c70e9209d21ef45c46a6db9c06237019bc20d54f8d3946873f3f11",
2896
+ "fabric_lock.py": "ba2399d2137c7a7791b7133b9db9cca36693f4eb7e30955b25a75ade814e95ba",
2897
+ "fabric_usage.py": "b1f335cab4625171a616cf68cf3fd892b18bdadcf4baec3a3a0fdcf12785c170",
2898
+ "fabric_backups.py": "02dc5eb66ec956723d3ddba07b6dd15f6a9726d901f4d60edfd02eee57880ceb",
2899
+ "fabric_descriptor.py": "daf16f95b1810283b1386488e006e940a0cdfa5c3c8d09386ac33d29d58df78d",
2900
+ "fabric_auth.py": "55794587750f6c9f40935b74295d83d7096cb4b26a0b36ac292334cd87d8f853",
2901
+ "fabric_events.py": "0086886c2c29dbd5c9d152be9d375c5bdf4a29f89b8095baff8490c2bcd64e47",
2902
+ "fabric_logs.py": "86dc55bfafa2a57264722e2403510a249b95de0e4a722b2e4bc3948e3bd93da9",
2903
+ "fabric_supervisors.py": "a2dbf15aa1c4eb9de42996b413103fa4814afd2e4e66d1ab7315f30e06ab6841",
2904
+ "fabric_service.py": "103ab4026f96e127c01cd91e4ad44f6b0ce2424ff33f078ed106b39e773a10e3",
2905
+ "fabric_profiles.py": "26e21ec29c016000f8ca8053b7b489184854a73be1b7fdc0a18d12990203db9b",
2906
+ "fabric_interop_rules.py": "b5f0ecb1791b4c50c5ac151f987f692ed7001bc74841a8a161125ec775145e09",
2907
+ "fabric_interop.py": "f92fa111328fe3811438f733d8f0eb5295e6941c774123a78e26318bdcb35fc3",
2908
+ "fabric_hub.py": "b9ac852e601b1d54cf61351677a628afc69faeed7d1fc74ebe541c1cf38e0c2a",
2909
+ "fabric_board_shapes.py": "0a0e8d3d40e33dfd8c457aedb85330b2ff25f8c49a33eb6cbd5a5cc8a5007f7e",
2910
+ "fabric_board_link.py": "b5bff994cb615c6a4bb0d88d020e3a59504166180e1fa1aea3be93224f2aeccb",
2911
+ "fabric_board_session.py": "2845d7d758b06ac4588bdf825a471c201a46ce535a631805697b1e35495fead6",
2912
+ "fabric_board_fixture.py": "69761ce205ec640762ec9b591c6903e5a87f844e8d423b041681924027e39d1e",
2913
+ "fabric_board.py": "0f075894b3e352194fcaf3fc7d08dee4e9f1c215e859769ed85fa5010e4a1599",
2914
+ "fabric_http.py": "5d7f72af3a73fdbe8c1f487ecf60cef23a7c46289eba542b03aff4706b8218d4",
2915
+ "fabric_openrouter.py": "309f588f9c976b1061323f3161e35d86248afd5b59b16391afbfc4e1099ecd65",
2916
+ "fabric-jsvalue.mjs": "dfbdb8c41584972c9389750a41719374a1cfc5652ed438dd1623025190b0106e",
2917
+ "fabric-time.mjs": "3ff932069038a720df1c68c6492976906737b2e67e5c12d9c81c418597f661de",
2918
+ "fabric-identifiers.mjs": "a18c2a501a4aa170bfd3d99e571ad6b7662649b4d3b9d2a540c002ea42811bbf",
2919
+ "fabric-secrets.mjs": "6ad8ab18ac899ecc2384e8ec6a3fb689b92be3b9a5ce3a59b92a37d52da5d766",
2920
+ "fabric-crypto.mjs": "038e03689701e3c9721a1e7f8e0a4d02a52d6a5d1661b0dc84035edc3ebbfcf6",
2921
+ "fabric-base.mjs": "7804b2df7715dbbf9702a9b61b6b8cef2110f9a52b04cabd8fb0a66458d87512",
2922
+ "fabric-platform.mjs": "a8119d95ebba4d589a9e401f5d6fc8dda62d59d44e4c1ff811d97fac3771ac0c",
2923
+ "fabric-paths.mjs": "2201b4ed7126f260931b63e8386377b4317d8dcad419d109bf22c683e44b7f29",
2924
+ "fabric-estate.mjs": "b6610aaad9d00d8a7f42955627fb81725950841455bcedfc050abb0e079f0f93",
2925
+ "fabric-delegation.mjs": "dafde9ea327c88d92dc73d1400282b43dc6f84dd345a2d2b9b154304f65fc9d7",
2926
+ "fabric-share.mjs": "d40a7405d7bd4c3213ed691330b7549afe8e023db0753b582e249714e672010d",
2927
+ "fabric-ceremonies.mjs": "117d4374c9e95c930a38de7adbd4323774798f4fe6ec6f24afcd6f2c7298ca44",
2928
+ "fabric-package-schema.mjs": "cd28092375db0fc21ffab1f0bb7794f7f8979ee08c64d3b9faf4b3845357caa6",
2929
+ "fabric-package.mjs": "13913ea6d12056c3a31b8222b6c4aab8395941fe519913692e1a06d8672e1c88",
2930
+ "fabric-protocols.mjs": "dc039ec3612d0090c9ba6a3a949b0b7df54712bead300dcb6cc8b9bc305cd183",
2931
+ "fabric-connections.mjs": "303432e547ce563e90ca9ffcd827f5612f51d3582929834e1add0fdaf6485dcc",
2932
+ "fabric-lock.mjs": "ac219ab0a326d5c43c7c4c255e0c3843d4794de795a60e3af661e7d5350ea052",
2933
+ "fabric-usage.mjs": "1c4d18004639409624ac3516a02f2b60bbf55abdb612e9a96c37e83666eb87a7",
2934
+ "fabric-backups.mjs": "849acdc6eba0fff648c8158e61f6b68cccd7bedf0880f7d5ec03a6a20f1e7005",
2935
+ "fabric-descriptor.mjs": "95c0b7ff36c29d7c748f1fa6fb1767d6fe8c97fca7f1ee404559930abbd3cc84",
2936
+ "fabric-auth.mjs": "3ea6796470fcd829bc72a772fb7e4c37532a09d1e37ff41592dcf1a1a3074ca2",
2937
+ "fabric-events.mjs": "c463d6ffcae24229a5c608d8fcea4268d2ccc5024b19fa10e5a452284068fa71",
2938
+ "fabric-logs.mjs": "58467e5719c16e099d05bbedec47a95712506ab03c33f3cd4be1252e8498a946",
2939
+ "fabric-supervisors.mjs": "9d61c1a17538a5e2453e33e5ae347337336748041bc054478a96dd51f3d4fc51",
2940
+ "fabric-service.mjs": "1b4639a10c530c1241d317c5e9cf3a10cd96e5d9af8b29fc1b1844de5c63bfec",
2941
+ "fabric-profiles.mjs": "314376cf2deb87f235fb18b11d3b532e211f585b9af0a9302b5f4cc39971b532",
2942
+ "fabric-interop-rules.mjs": "57a8081307956e8c381119b361fa5232a7bf791815be8e3541eb5492820e2ea5",
2943
+ "fabric-interop.mjs": "581983ec3e197e5c2340ad25e8468320c69a2459c90097c7fcf2699766b4fb26",
2944
+ "fabric-hub.mjs": "c005115a864cfd67daa4862097bb14acfe5a60bf653897e5bf8a570dd80f1070",
2945
+ "fabric-board-shapes.mjs": "c1db7b7bd0d58d82bb6d8dfbc3cd1547a256a333052d367e82ff1b3edf2804d7",
2946
+ "fabric-board-link.mjs": "4cd6d4e21713a38c8e56f7d59db3148638b8c27c08e774d96a0f6283c6049e0a",
2947
+ "fabric-board-session.mjs": "26c9b0b2818a4cedc5248b08ea6732e2ee4ee81e31f6fee2b572a019db813c93",
2948
+ "fabric-board-fixture.mjs": "83c4b7e33ee47b68385faf2569d38d5c8a0c5cb0f8656090f41e1b0eed2f436e",
2949
+ "fabric-board.mjs": "9e7bfe4d412521982224ef12bebfc2ff255eeeaf8d96cf40234ac18de68e7a59",
2950
+ "fabric-http.mjs": "f886bcb49c4353d039387ac9568738dda26697a94dd07fc6a6204500f0bc27bf",
2951
+ "fabric-openrouter.mjs": "ab6c4c7251d563980d895061ee19976bde2f364c21b6354efdd9a9027b1ea139",
2952
+ "fabric-package.schema.json": "2ba799f576fad0f608029223c4ccccd122152f1c5dbfa8ce8b31cd63ac92ad44",
2953
+ "share-registration.schema.json": "34b3997eeaf936ebc861342a23045634a7c18c19f01d7ed4198310d81989f5b0"
2802
2954
  }
2803
2955
  },
2804
2956
  "data": {
@@ -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, 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.
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 an agent, bot or A2A server that need not speak 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.48.2"
8
+ version: "0.49.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "2f020a696453ae299b123517f14957f6a1354379"
11
11
  ---
@@ -2,15 +2,15 @@
2
2
  name: working-with-fabric
3
3
  description: >-
4
4
  Use when a coding agent (Claude Code, Codex, Kimi Code, Kilo, Hermes) works in a project through Fabric from a
5
- terminal: finding Fabric, having the lifecycle broker start it, registering fabric-mcp once with no secret,
6
- fabric_whoami first, the project board (com.status, com.list, com.read_ack; asking another Project only through
7
- com.submit; unread-mail lines, watch-mail), claiming and releasing work (fabric_work_claim, fabric_work_release),
8
- reports, handoffs, refusals. Triggers: "connect to Fabric", "register fabric-mcp", "claim the task in Fabric",
9
- "check the project board", "ask the other project through Fabric", "Fabric is not running", «подключись к
10
- Fabric», «возьми задачу в Fabric», «проверь доску проекта»,
11
- «Fabric не запущен», "hit your limit, resets at". Explains the plugin's session
12
- hooks. NOT for building or adapting an agent or a board responder (creating-fabric-agents,
13
- adapting-projects-to-fabric, building-fabric-services), changing Fabric's own code, or messaging people.
5
+ terminal: finding Fabric, having the lifecycle broker start Fabric (not other apps), registering fabric-mcp once
6
+ with no secret, fabric_whoami first, the project board (com.status, com.list, com.read_ack; asking another Project
7
+ only through com.submit; unread-mail lines, watch-mail), claiming and releasing work (fabric_work_claim,
8
+ fabric_work_release), reports, handoffs, refusals. Triggers: "connect to Fabric", "register fabric-mcp", "claim the
9
+ task in Fabric", "check the project board", "ask the other project through Fabric", "Fabric is not running",
10
+ «подключись к Fabric», «возьми задачу в Fabric», «проверь доску проекта», «Fabric не запущен», "hit your limit,
11
+ resets at". Explains the plugin's session hooks. NOT for building or adapting an agent or a board responder
12
+ (creating-fabric-agents, adapting-projects-to-fabric, building-fabric-services), changing Fabric's own code, or
13
+ messaging people.
14
14
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
15
15
  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.
@@ -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.48.2"
22
+ version: "0.49.0"
23
23
  ---
24
24
 
25
25
  # Working with Fabric
@@ -227,8 +227,9 @@ and do the work without claiming Fabric saw it.
227
227
 
228
228
  ## The session hooks
229
229
 
230
- In Claude Code this plugin ships four: at session start and `/clear` (not on compact or resume) the preamble above,
231
- 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
230
+ In Claude Code this plugin ships four: at session start and `/clear` (not on compact or resume) the preamble above
231
+ in a project that uses Fabric, one line pointing at `fabric_whoami` in a folder with no sign of it, or, when Fabric is
232
+ down and the project uses Fabric, one line pointing at Step 1; after each turn the turn's facts (HEAD, files changed, lines added
232
233
  and removed) and the board's unread count; at each prompt one line only when that count rose; at the end, the
233
234
  release of what the session left claimed. The turn, prompt and end hooks spool and call Fabric only when the running
234
235
  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
@@ -19,7 +19,7 @@ need from Fabric. In Claude Code they live in the plugin's `hooks/hooks.json` an
19
19
 
20
20
  | Event | When it acts | What it does |
21
21
  |---|---|---|
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. |
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, in a project that uses Fabric (below): 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 running in a folder with no such sign (a user-scope registration reaches every folder): one line pointing at `fabric_whoami`, which names the project when Fabric serves the folder anyway (FAA-60). Fabric down, in a project that uses Fabric: 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
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
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) |
@@ -74,7 +74,9 @@ starts it under a background watcher. It rejoins the session (`CLAUDE_CODE_SESSI
74
74
  `fabric-mcp --rejoin-only` for each look, prints one line each time the unread count rises, and waits `interval`
75
75
  ± 20 % between looks (after the jitter never under 10 s and never over 15 minutes). It shares the session's
76
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
77
+ its own) waits instead, and its own rejoin renews that mark when the session's folder exists. Because its looks keep
78
+ that mark fresh, a Stop-hook turn usually waits in the spool while it runs: a look that finds spooled turns delivers
79
+ them through its own rejoin, as the worker would, and reads the board's count the worker writes (FAA-89). While Fabric is down or
78
80
  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,
79
81
  or when its parent is gone. It refuses to start in a session Fabric launched, and without a `fabric` registration.
80
82
 
@@ -118,6 +118,7 @@ from fabric_session_hosts import ( # noqa: F401
118
118
  from fabric_session_cli import ( # noqa: F401
119
119
  REJOIN_MIN_INTERVAL_S,
120
120
  PREAMBLE,
121
+ POINTER,
121
122
  DOWN_NOTICE,
122
123
  QUIET_SOURCES,
123
124
  read_hook_input,
@@ -65,6 +65,16 @@ PREAMBLE = (
65
65
  )
66
66
 
67
67
 
68
+ #: FAA-60: one line where Fabric runs and `fabric` is registered, but nothing says this folder works through it (a
69
+ #: user-scope registration reaches every folder of the machine). It hides nothing: `fabric_whoami` names the project
70
+ #: when Fabric serves the folder all the same.
71
+ POINTER = (
72
+ "[fabric-agent-adapter] Fabric is running and this {host} has `fabric` registered; this folder carries no sign that "
73
+ "it works through Fabric. If it does, call `fabric_whoami`: it names the project. The working-with-fabric skill has "
74
+ "the routine."
75
+ )
76
+
77
+
68
78
  #: One line, and only in a project that uses Fabric (project_uses_fabric): everywhere else a Fabric that is down is
69
79
  #: nothing the session needs to hear.
70
80
  DOWN_NOTICE = (
@@ -89,8 +99,8 @@ def read_hook_input() -> Dict[str, Any]:
89
99
  def hook_session_start(payload: Dict[str, Any], env: Optional[Dict[str, str]] = None,
90
100
  host: str = "claude") -> Optional[str]:
91
101
  """What to print, or None: Claude/Codex hook JSON, plain context for native plugins and Kimi. The preamble when the
92
- host has `fabric` registered and Fabric runs; when it is down, one line, and only in a project that uses Fabric
93
- (project_uses_fabric). Silent on a compacted or resumed session (`source`), which already holds what it was told,
102
+ host has `fabric` registered, Fabric runs and the project uses Fabric (project_uses_fabric); a one-line pointer
103
+ when the project shows no sign (FAA-60); when Fabric is down, one line, and only in a project that uses Fabric. Silent on a compacted or resumed session (`source`), which already holds what it was told,
94
104
  and in a session Fabric launched. How to read the machine's Fabric services is the host instructions' to say."""
95
105
  if host not in HOSTS or payload.get("source") in QUIET_SOURCES or fabric_launched(env):
96
106
  return None
@@ -102,7 +112,7 @@ def hook_session_start(payload: Dict[str, Any], env: Optional[Dict[str, str]] =
102
112
  if not registered:
103
113
  return None
104
114
  if locate_hub(fabric_root(env=env))["state"] == "running":
105
- context = PREAMBLE.format(host=HOSTS[host])
115
+ context = (PREAMBLE if project_uses_fabric(cwd, env, registration) else POINTER).format(host=HOSTS[host])
106
116
  elif project_uses_fabric(cwd, env, registration):
107
117
  context = DOWN_NOTICE
108
118
  else:
@@ -57,7 +57,7 @@ SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
57
57
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
58
58
 
59
59
 
60
- VERSION = "0.48.2"
60
+ VERSION = "0.49.0"
61
61
 
62
62
 
63
63
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code",
@@ -2,11 +2,12 @@
2
2
  agent waits on it, with backoff while Fabric is down. Standard library only."""
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import os
6
7
  import sys
7
8
  import time
8
9
  from pathlib import Path
9
- from typing import Optional
10
+ from typing import Any, Dict, Optional, Tuple
10
11
 
11
12
  from fabric_session_env import ( # noqa: E402 - the kit (FAA-65)
12
13
  BOARD_STATUS_TOOL,
@@ -28,6 +29,7 @@ from fabric_session_mcp import ( # noqa: E402 - the kit (FAA-65)
28
29
  NoSessionError,
29
30
  _wait_for_session_tools,
30
31
  board_unread,
32
+ deliver,
31
33
  )
32
34
 
33
35
 
@@ -43,7 +45,8 @@ def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None
43
45
  answers but the board cannot be read, ("down", why) when Fabric cannot be reached, ("wait", seconds) when the
44
46
  session rejoined within the last minute (the Stop worker's mark, or this one's): the rejoin budget is shared, so
45
47
  no fabric-mcp starts then (FAA-68). No fabric-mcp starts unless the running Fabric advertises session-hooks/1
46
- (the rejoin). A successful rejoin renews the mark in the session's folder when the hooks made one."""
48
+ (the rejoin). A successful rejoin renews the mark in the session's folder when the hooks made one. Turns the hooks
49
+ spooled while the mark held them back are delivered by this look, through the same rejoin (FAA-89)."""
47
50
  env = dict(os.environ if env is None else env)
48
51
  directory = session_dir(session_id, env)
49
52
  left = rejoin_wait(directory)
@@ -57,6 +60,8 @@ def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None
57
60
  registration = claude_registration(cwd, env)
58
61
  if not registration:
59
62
  return "down", "Claude Code has no MCP server named fabric"
63
+ if directory.is_dir() and any(directory.glob("*-*.json")):
64
+ return _deliver_and_count(directory, env, deadline_s)
60
65
  client: Optional[McpStdio] = None
61
66
  try:
62
67
  deadline = time.monotonic() + deadline_s
@@ -86,6 +91,35 @@ def poll_unread(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None
86
91
  client.close()
87
92
 
88
93
 
94
+ def _deliver_and_count(directory: Path, env: Dict[str, str], deadline_s: float) -> Tuple[str, Any]:
95
+ """FAA-89: the hooks' worker drains the spool through one rejoin and writes the board's unread count beside it;
96
+ this look reads that count instead of rejoining a second time within the minute."""
97
+ started = time.time()
98
+ deliver(directory, env, deadline_s)
99
+ if not directory.is_dir(): # the look delivered the session's end, and the worker removed its folder
100
+ return "down", "the session ended: its spooled turns were delivered"
101
+ board = directory / "board.json"
102
+ try:
103
+ fresh = board.stat().st_mtime >= started - 1
104
+ except OSError:
105
+ fresh = False
106
+ unread = None
107
+ if fresh:
108
+ try:
109
+ data = json.loads(board.read_text(encoding="utf-8"))
110
+ unread = data.get("unread") if isinstance(data, dict) else None
111
+ except (OSError, ValueError):
112
+ unread = None
113
+ if isinstance(unread, int) and not isinstance(unread, bool) and unread >= 0:
114
+ return "ok", unread
115
+ if fresh:
116
+ return "unavailable", "the project board cannot be read"
117
+ left = rejoin_wait(directory)
118
+ if left > 0:
119
+ return "wait", left
120
+ return "down", "the spooled turns could not be delivered now"
121
+
122
+
89
123
  def watch_mail(session_id: str, cwd: Path, env: Optional[Dict[str, str]] = None, interval: float = 60, *,
90
124
  poll: Optional[Callable[[], Tuple[str, Any]]] = None, out: Callable[[str], None] = print,
91
125
  wait: Callable[[float], bool], stop: Callable[[], bool], rng: Callable[[], float]) -> int: