@passioncode-ai/fabric-agent-adapter 0.6.3 → 0.8.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 (19) hide show
  1. package/CHANGELOG.md +104 -1
  2. package/README.md +28 -5
  3. package/bin/fabric-agent-adapter.js +18 -0
  4. package/package.json +1 -1
  5. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +9 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +39 -10
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -10
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +77 -14
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +5 -3
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +6 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +63 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +106 -3
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +42 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +317 -8
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +53 -1
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +472 -15
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +23 -10
  19. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +7 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,109 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0 - 2026-10-04
4
+
5
+ Agents report what they spend (Fabric Agent Contract DEC-0021), and a service may keep its MCP
6
+ credential apart from the host's token (DEC-0024). The contract pin moves to the DEC-0024 merge;
7
+ bundles issued under the DEC-0021, DEC-0020 and DEC-0019 revisions stay valid.
8
+
9
+ ### Added
10
+
11
+ - **Usage report in both kits.** `make_usage_receipt` / `makeUsageReceipt` record one model call
12
+ from the provider's own numbers (no prompt, output or caller). `usage_report` / `usageReport`
13
+ build the `service-usage.schema.json` answer: 31 UTC days per provider and model, day totals
14
+ that are the sums of the rows (FAC-SEM-025), and an unknown cost as `null`, never `0`.
15
+ `JsonlUsageLedger` keeps receipts as 0600 JSON lines pruned to the window. A line torn by a
16
+ killed writer is skipped and never glued to the next receipt. The Python and Node twins
17
+ produce the same report from the same receipts (`test/fixtures/usage/receipts.json`).
18
+ - `sample_service.py` declares `surfaces.usage` and serves the report behind the token.
19
+ - `check_service.py` adds `usage.requires-token` and `usage.report` when a service declares the
20
+ surface.
21
+ - `references/usage.md`; SKILL.md Step 5 and the protocol reference name the surface; trigger
22
+ and scenario evals `agent-reports-its-own-spend`.
23
+
24
+ ### Added (DEC-0024)
25
+
26
+ - **A service may keep its MCP credential apart from the host's token.** When the well-known
27
+ document says `surfaces.mcp.auth: "own"`, `check_service.py` no longer calls MCP with the
28
+ descriptor's token as a caller. It checks that the surface refuses that token
29
+ (`interop.mcp-own-auth`) and leaves the caller rules NOT_RUN with the reason. Before this, two
30
+ services that separate the roles on purpose were reported as `interop.tools-match` FAIL
31
+ (401/403). `references/surfaces-and-auth.md` explains when to do it. The contract pin moves to
32
+ `23f9fda4c05f8a3852246ee98d4f2adf74ed0875`; `9091d3d` joins the supported earlier revisions.
33
+
34
+ ### Fixed
35
+
36
+ - **MCP 2026-07-28 standard headers.** The probe's MCP calls now send `Mcp-Method`, and `Mcp-Name`
37
+ for `tools/call`. Without them a server on the official 2026-07-28 SDK answers HTTP 400 (seen live
38
+ against a local agent on 2026-10-04: `tools/list failed: HTTP 400 from /mcp`).
39
+ `fabric_interop.McpToolServer.handle(message, headers)` checks them the way the specification
40
+ requires: a mismatch is refused with `-32020` whatever the revision, and a request that declares
41
+ 2026-07-28 must carry them. Callers that pass no headers keep the old behaviour.
42
+ `mcp_header_problem` / `mcp_request_headers` are in both kits (Node: `mcpHeaderProblem`,
43
+ `mcpRequestHeaders`). The sample service answers a mismatch with HTTP 400.
44
+
45
+ ### Changed
46
+
47
+ - Contract pin `df55c8c` → `9091d3d` (lock, scaffolder default, metadata). The supported earlier
48
+ revisions are now a reviewed list of two, `test/validate.py` checks it, and
49
+ `test_contract_revisions` runs against real checkouts of `2ce3922` and `9091d3d`.
50
+
51
+ ## 0.7.0 - 2026-10-04
52
+
53
+ The service kit meets the organization's
54
+ [product lifecycle contract](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/lifecycle.md)
55
+ (LC-01…LC-15). Findings F7, F8, F9, F16 of the 2026-10-03 lifecycle audit.
56
+
57
+ ### Fixed
58
+
59
+ - **An upgrade no longer switches back on a service the operator switched off (LC-14, F7).**
60
+ `launchd_install` called `launchctl enable` on every run. It now reads `launchctl
61
+ print-disabled` and enables only on a first install (no override recorded); a disabled label
62
+ stays disabled and unloaded, its plist is still rewritten, and the answer is
63
+ `{"disabled": true, ...}`. `force_enable=True` is the operator's explicit switch-on.
64
+ Tests: `test_fabric_service_lifecycle.LaunchdInstallTests` (fake `launchctl`, throwaway labels).
65
+ - **A duplicate copy under `KeepAlive` no longer respawns every 10 s (LC-03, F8).** A
66
+ launchd-supervised copy (`FABRIC_SERVICE_SUPERVISOR=launchd`, now written by `launchd_plist`)
67
+ backs off in-process on a held lock — 0.5 s doubling to 30 s, 300 s in all — takes over if the
68
+ holder leaves, and exits 75 only then. Started by hand it still exits 75 at once (the contract's
69
+ one-copy rule). Python and Node. Tests: `SupervisedLockTests`, `fabric-service-lifecycle.test.mjs`.
70
+ - **Uninstall is symmetric with install (LC-14, F9).** `launchd_uninstall` waits until launchd
71
+ reports the job gone (raises, removing nothing, at its timeout), removes the plist, resets the
72
+ override to `enabled`, and with `purge=True` removes the service's data, logs and cache. It now
73
+ returns a summary dict instead of `None`. Tests: `LaunchdUninstallTests`.
74
+ - **The installers leave launcher-managed hub links alone (F16).** `install.sh` and
75
+ `npx @passioncode-ai/fabric-agent-adapter` (even with `--force`) skip a `~/.agents/skills/<name>`
76
+ that is a symlink, naming `npx @passioncode-ai/passioncode@latest update` when it points into
77
+ `~/.passioncode`. Tests: `test_installer.InstallerTests.test_launcher_managed_hub_links_survive_force`,
78
+ `test_install_sh_leaves_launcher_links_alone`.
79
+
80
+ ### Added
81
+
82
+ - **`Drain` (Python and Node, LC-01, F9):** SIGTERM/SIGINT stops new work (`work()` raises
83
+ `Stopping`), drains in-flight work for 8 s, hands over, and hard-exits (`EXIT_HARD_STOP`, 70)
84
+ 2 s later if the hand-over hangs. `sample_service.py` uses it and answers `503` while stopping.
85
+ - **`RotatingLog` and `cap_stdout_log` / `capStdoutLog` (LC-12, F9):** JSON-lines log rotated by
86
+ size, 5 × 5 MB, files 0600 in a 0700 directory; the launchd stdout file is capped at start.
87
+ - **`prune_releases` / `pruneReleases` (LC-11, LC-15):** keeps the running release and the one
88
+ before it, removes older ones, never removes `current` after a rollback.
89
+
90
+ ### Changed
91
+
92
+ - **CO-193 contract adoption (source, pending release):** new bundles use the shared
93
+ capability-name schema and accept underscore names. Complete previously issued bundles
94
+ remain checkable at their explicitly supported legacy revision. The checker rejects
95
+ forged locks, revision mismatches and modified checkouts; unavailable dependencies remain
96
+ `NOT_RUN`. This adds no COM runtime, grant or admission authority.
97
+ - Align validation triggers with the operator's 2026-09-25 CI policy: replace automatic
98
+ push/PR runs with manual dispatch, retaining the reusable release-validation workflow.
99
+ Existing test jobs and commands are unchanged; no new nightly schedule is asserted.
100
+
101
+ - `launchd_plist`'s default `ExitTimeOut` is 15 s (was 40): above the drain and its hard exit, so
102
+ launchd's SIGKILL never comes first, and inside LC-01's 10 s quit bound for the service itself.
103
+ - `references/lifecycle.md` links the org contract and maps each LC rule to the kit.
104
+ - Three scenario evals: `operator-off-survives-upgrade`, `duplicate-under-keepalive`,
105
+ `stop-drains-and-uninstall-is-symmetric`.
106
+
3
107
  ## 0.6.3 - 2026-10-04
4
108
 
5
109
  ### Changed
@@ -348,4 +452,3 @@ Fabric Agent Contract DEC-0017. **Contract pin** moves from `a22dea3` to `9cd778
348
452
  - Add a standard-library project inspector, non-destructive scaffolder, and checker.
349
453
  - Add contract pinning and independent conformance gate reporting.
350
454
  - Add trigger/scenario evals, unit tests, repository validator, and plugin packaging.
351
-
package/README.md CHANGED
@@ -188,15 +188,38 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
188
188
 
189
189
  ## Contract pin
190
190
 
191
- The one pin is [`fabric-contract.lock.json`](fabric-contract.lock.json):
191
+ The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
192
192
 
193
193
  - version: `0.1.0`
194
- - commit: `2ce392291c6668598d12cd38327e24696b5ca15c`
194
+ - commit: `23f9fda4c05f8a3852246ee98d4f2adf74ed0875`
195
195
 
196
- Every other mention of the contract revision — this section, the skills' metadata, the
196
+ Every other mention of the default contract revision — this section, the skills' metadata, the
197
197
  skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
198
- where one does not. The contract repository is normative. Updating the pin requires a
199
- new adapter release, fixture review, and a complete validation run.
198
+ where one does not. The sole live legacy exception is the immutable
199
+ `SUPPORTED_CONTRACT_COMMITS` declaration in the scaffolder: previously issued bundles
200
+ keep their selected legacy lock and require that exact clean contract checkout. The
201
+ checker validates the lock's contract, repository, version and supported full SHA before
202
+ running its selected schema. Unknown locks, modified checkouts and revision mismatches fail.
203
+ Missing checkouts or dependencies are `NOT_RUN`, never admission.
204
+
205
+ There is no automatic upgrade or downgrade. An intentional migration requires review of
206
+ the whole bundle and validation against the new selected schema; editing a lock alone
207
+ does not prove runtime compatibility. Underscore names such as `receive_project_message`
208
+ are declarations only: they confer no COM grant or authority. Legacy revisions cannot
209
+ validate those names. The contract repository is normative. Updating the default pin
210
+ requires fixture review, complete validation and a new adapter release before propagation.
211
+
212
+ To run both compiled-schema integration arms, install the frozen dependencies in separate
213
+ clean checkouts at the supported revisions, then run:
214
+
215
+ ```bash
216
+ FABRIC_CONTRACT_OLD=/path/to/legacy-contract FABRIC_CONTRACT_NEW=/path/to/current-contract \
217
+ python3 -m unittest discover -s test -p test_contract_revisions.py -v
218
+ ```
219
+
220
+ Without both environment variables, those tests explicitly skip as `NOT_RUN`.
221
+ This source checkout is 0.8.0 pending release (0.7.0 was never released); the verified published GitHub/npm version
222
+ on 2026-10-04 is 0.6.3. Source delivery does not update installed skills or live sessions.
200
223
 
201
224
  ## License
202
225
 
@@ -48,6 +48,15 @@ function copyDir(src, dest) {
48
48
  }
49
49
  }
50
50
 
51
+ // A link in the hub belongs to whoever made it: 'launcher' for the PassionCode launcher's
52
+ // ~/.passioncode/... links, 'other' for any other link, null for a directory or nothing.
53
+ function linkOwner(dest) {
54
+ let info;
55
+ try { info = fs.lstatSync(dest); } catch { return null; }
56
+ if (!info.isSymbolicLink()) return null;
57
+ return fs.readlinkSync(dest).split(path.sep).includes('.passioncode') ? 'launcher' : 'other';
58
+ }
59
+
51
60
  function pluginInstalled(home) {
52
61
  try {
53
62
  const data = JSON.parse(fs.readFileSync(path.join(home, '.claude/plugins/installed_plugins.json'), 'utf8'));
@@ -129,6 +138,15 @@ function main(argv) {
129
138
  const destRoot = target === 'claude' ? path.join(home, '.claude', 'skills') : path.join(home, '.agents', 'skills');
130
139
  for (const name of names) {
131
140
  const dest = path.join(destRoot, name);
141
+ const owner = linkOwner(dest);
142
+ if (owner) {
143
+ // One owner per artefact on disk: a plain copy over someone else's link freezes the skill
144
+ // at this version, and its owner stops updating it. --force does not override this.
145
+ console.log(owner === 'launcher'
146
+ ? `skip: ${name} at ${dest} is managed by the PassionCode launcher — update it with: npx @passioncode-ai/passioncode@latest update`
147
+ : `skip: ${name} at ${dest} is a link managed elsewhere — remove the link first to install a copy here`);
148
+ continue;
149
+ }
132
150
  if (fs.existsSync(dest) && !force) {
133
151
  console.log(`skip: ${name} already installed at ${dest} (rerun with --force to overwrite)`);
134
152
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.6.3",
3
+ "version": "0.8.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.",
6
- "version": "0.6.3",
6
+ "version": "0.8.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,9 +5,9 @@ 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.6.3"
8
+ version: "0.8.0"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "2ce392291c6668598d12cd38327e24696b5ca15c"
10
+ contract-commit: "23f9fda4c05f8a3852246ee98d4f2adf74ed0875"
11
11
  ---
12
12
 
13
13
  # Adapting projects to Fabric
@@ -70,7 +70,13 @@ Use exactly:
70
70
 
71
71
  - contract version `0.1.0`;
72
72
  - repository `https://github.com/passioncode-ai/fabric-agent-contract`;
73
- - commit `2ce392291c6668598d12cd38327e24696b5ca15c`.
73
+ - commit `23f9fda4c05f8a3852246ee98d4f2adf74ed0875`.
74
+
75
+ This is the default for new bundles. When checking an issued legacy bundle, preserve its
76
+ explicit supported lock and use that exact clean checkout. Never silently upgrade or
77
+ downgrade it. Unknown repository/version/SHA locks and mismatched checkouts fail; missing
78
+ dependencies remain `NOT_RUN`. Capability names may contain underscores at this default
79
+ revision; declaring a COM name does not confer a grant, admission or wider authority.
74
80
 
75
81
  Read the pinned contract's guide
76
82
  `docs/guides/connecting-compatible-agents.md`, the selected profile specification, and
@@ -16,7 +16,11 @@ from urllib.parse import urlparse
16
16
 
17
17
  CONTRACT_VERSION = "0.1.0"
18
18
  CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
19
- CONTRACT_COMMIT = "2ce392291c6668598d12cd38327e24696b5ca15c"
19
+ CONTRACT_COMMIT = "23f9fda4c05f8a3852246ee98d4f2adf74ed0875"
20
+ # #region supported-contract-revisions — docs: README.md#contract-pin
21
+ # Only this declaration may name a legacy revision in live files.
22
+ SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
23
+ # #endregion supported-contract-revisions
20
24
  INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
21
25
  MCP_REVISION = "2026-07-28"
22
26
  A2A_VERSION = "1.0"
@@ -357,7 +361,9 @@ def _walk_keys(value: Any, prefix: str = "") -> Iterable[Tuple[str, Any]]:
357
361
  yield from _walk_keys(item, "%s[%d]" % (prefix, index))
358
362
 
359
363
 
360
- def _contract_shape(manifest: Path, contract: Path) -> Tuple[str, List[str]]:
364
+ def _contract_shape(manifest: Path, contract: Path, selected_commit: str) -> Tuple[str, List[str]]:
365
+ if selected_commit not in SUPPORTED_CONTRACT_COMMITS:
366
+ return "FAIL", ["unsupported selected contract revision"]
361
367
  if not contract.is_dir():
362
368
  return "NOT_RUN", ["contract checkout is not a directory: %s" % contract]
363
369
  try:
@@ -367,8 +373,19 @@ def _contract_shape(manifest: Path, contract: Path) -> Tuple[str, List[str]]:
367
373
  ).stdout.strip()
368
374
  except (OSError, subprocess.CalledProcessError) as exc:
369
375
  return "NOT_RUN", ["cannot read contract commit: %s" % exc]
370
- if head != CONTRACT_COMMIT:
371
- return "NOT_RUN", ["contract checkout is %s; expected %s" % (head, CONTRACT_COMMIT)]
376
+ if head != selected_commit:
377
+ return "FAIL", ["contract checkout is %s; selected lock requires %s" % (head, selected_commit)]
378
+ try:
379
+ dirty = subprocess.run(
380
+ ["git", "status", "--porcelain", "--untracked-files=normal"], cwd=str(contract), check=True,
381
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
382
+ ).stdout.strip()
383
+ except (OSError, subprocess.CalledProcessError) as exc:
384
+ return "NOT_RUN", ["cannot verify clean contract checkout: %s" % exc]
385
+ if dirty:
386
+ return "FAIL", ["contract checkout has modifications; exact selected schema is required"]
387
+ if not (contract / "node_modules/.bin/tsx").is_file():
388
+ return "NOT_RUN", ["pinned contract dependencies are missing; run pnpm install --frozen-lockfile"]
372
389
 
373
390
  code = """import {readFile} from 'node:fs/promises';
374
391
  import {createValidator,validateDocument} from './src/validator.ts';
@@ -391,8 +408,10 @@ import {SCHEMA_PREFIX} from './src/contract.ts';
391
408
  return "NOT_RUN", ["contract shape validation timed out"]
392
409
  if run.returncode != 0:
393
410
  detail = (run.stdout + "\n" + run.stderr).strip()
411
+ if run.returncode != 3:
412
+ return "NOT_RUN", [detail[-4000:] or "contract validator could not run"]
394
413
  return "FAIL", [detail[-4000:] or "contract validator failed"]
395
- return "PASS", ["manifest validated by pinned contract schema"]
414
+ return "PASS", ["manifest validated by selected contract schema at %s" % selected_commit]
396
415
 
397
416
 
398
417
  def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
@@ -406,11 +425,19 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
406
425
  manifest = _load_json(manifest_path, errors)
407
426
  lock = _load_json(root / "fabric-contract.lock.json", errors)
408
427
  profile_kind = None
428
+ lock_errors: List[str] = []
409
429
  if isinstance(lock, dict):
430
+ if lock.get("contract") != "fabric-agent-contract":
431
+ lock_errors.append("contract lock contract must be fabric-agent-contract")
432
+ if lock.get("repository") != CONTRACT_REPOSITORY:
433
+ lock_errors.append("contract lock repository must be %s" % CONTRACT_REPOSITORY)
410
434
  if lock.get("version") != CONTRACT_VERSION:
411
- errors.append("contract lock version must be %s" % CONTRACT_VERSION)
412
- if lock.get("commit") != CONTRACT_COMMIT:
413
- errors.append("contract lock commit must be %s" % CONTRACT_COMMIT)
435
+ lock_errors.append("contract lock version must be %s" % CONTRACT_VERSION)
436
+ if lock.get("commit") not in SUPPORTED_CONTRACT_COMMITS:
437
+ lock_errors.append("contract lock commit must be an explicitly supported immutable revision")
438
+ else:
439
+ lock_errors.append("contract lock must be an object")
440
+ errors.extend(lock_errors)
414
441
 
415
442
  if isinstance(manifest, dict):
416
443
  if manifest.get("contractVersion") != CONTRACT_VERSION:
@@ -462,8 +489,10 @@ def check_project(root: Path, contract: Optional[Path]) -> Dict[str, Any]:
462
489
 
463
490
  shape_status = "NOT_RUN"
464
491
  shape_receipts = ["pass --contract PATH pointing at the pinned checkout"]
465
- if contract is not None and manifest_path.is_file():
466
- shape_status, shape_receipts = _contract_shape(manifest_path, contract)
492
+ if lock_errors:
493
+ shape_status, shape_receipts = "FAIL", lock_errors
494
+ elif contract is not None and manifest_path.is_file():
495
+ shape_status, shape_receipts = _contract_shape(manifest_path, contract, lock["commit"])
467
496
 
468
497
  local_status = "PASS" if not errors else "FAIL"
469
498
  ready = local_status == "PASS" and shape_status == "PASS" and not warnings
@@ -5,9 +5,10 @@ description: >-
5
5
  macOS machine, or making an ONLINE agent or dashboard (https origin, the remote placement) a
6
6
  Fabric service — «сделай агенту дашборд», «локальный сервис агента», «онлайн-дашборд в Fabric»,
7
7
  «подключи агента к Fabric Dashboards», "make this agent a local service", "make an online
8
- dashboard a Fabric service", "fabric-service protocol", "add the well-known endpoint". Covers
8
+ dashboard a Fabric service", "fabric-service protocol", "add the well-known endpoint", «расходы агента
9
+ на модели». Covers
9
10
  fabric-service/0.1: surfaces, token and one-time login, state, one copy, launchd, descriptor,
10
- well-known document, events; online: the https guard, the token-gated well-known document, the
11
+ well-known document, events, the usage report (spend per model); online: the https guard, the token-gated well-known document, the
11
12
  __Host- cookie, registering it on the operator's computer; ships Python and Node kits, a TLS
12
13
  sample and a live probe. NOT for a one-off script or cron job, a hosted product with no agent
13
14
  behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric Dashboards.
@@ -15,10 +16,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
15
16
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only (Linux services use lifecycle manager none until a systemd adapter exists). Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
16
17
  metadata:
17
18
  author: PassionCode.ai
18
- version: "0.6.3"
19
+ version: "0.8.0"
19
20
  contract-version: "0.1.0"
20
21
  extension: "fabric-service/0.1"
21
- extension-commit: "2ce392291c6668598d12cd38327e24696b5ca15c"
22
+ extension-commit: "23f9fda4c05f8a3852246ee98d4f2adf74ed0875"
22
23
  ---
23
24
 
24
25
  # Building Fabric services
@@ -90,7 +91,9 @@ are in [`scripts/fabric_interop.py`](scripts/fabric_interop.py) and
90
91
  2. **One copy, locked before any side effect.** Take the exclusive lock on
91
92
  `<data>/service.lock` first — before resuming jobs, starting a scheduler, migrating
92
93
  a store or even creating a token. Held → print one sentence naming the holder's pid
93
- and exit **75**. Binding the port is not a lock. *(A second copy re-queued the first
94
+ and exit **75**. Binding the port is not a lock. Under launchd the kit backs off
95
+ in-process first (up to 300 s), so a duplicate is a few starts an hour, not a
96
+ `KeepAlive` respawn every 10 s. *(A second copy re-queued the first
94
97
  copy's running jobs in its startup hook, before its bind failed.)*
95
98
  3. **launchd is the only supervisor.** `RunAtLoad` true, `KeepAlive` true,
96
99
  `ThrottleInterval` 10, `ExitTimeOut` above your drain time. Never start yourself
@@ -133,7 +136,7 @@ Startup order is the part that goes wrong; keep it exactly:
133
136
  import fabric_service as fs
134
137
 
135
138
  dirs = fs.service_dirs("example-agent")
136
- lock = fs.hold_single_instance(dirs["data"]) # 1. lock — exits 75 if held
139
+ lock = fs.hold_single_instance(dirs["data"]) # 1. lock — exits 75 if held (backs off first under launchd)
137
140
  token = fs.ensure_token(dirs["data"] / "service.token") # 2. only now touch state
138
141
  log = fs.JsonlEventLog(dirs["data"] / "events.jsonl") # (or a view over your own log)
139
142
  codes = fs.LoginCodes(dirs["data"] / "auth")
@@ -153,8 +156,10 @@ On every request call `check_request(port, host, origin, sec_fetch_site)` and an
153
156
  | `POST /fabric/v1/login-code` | service token | `LoginCodes.issue()` |
154
157
  | `GET /fabric/v1/login?code=` | the code | `LoginCodes.redeem(code)` → `Set-Cookie: session_cookie_header(...)`, 302 to the dashboard |
155
158
 
156
- `SIGTERM` drains in-flight work within `ExitTimeOut`, then exits; interrupted work
157
- resumes on the next start.
159
+ `SIGTERM` drains in-flight work, then exits — 10 s at most, inside `ExitTimeOut`
160
+ (`fs.Drain` / `new Drain()`: `with drain.work():` refuses new work once a stop began);
161
+ interrupted work resumes on the next start. Log through `RotatingLog` (5 × 5 MB, 0600)
162
+ and call `cap_stdout_log` once at start.
158
163
 
159
164
  The MCP surface (`POST /mcp`, service token) is `fabric_interop.McpToolServer.handle`
160
165
  in Python; jobs live in `fabric_interop.JobStore(dirs["data"] / "jobs")`, created after
@@ -175,8 +180,11 @@ The installer, not the service, owns the plist and the descriptor. Sequence:
175
180
  2. `launchd_plist(...)` then `launchd_install(...)` — writes and lints the plist,
176
181
  `bootout` and waits for the unload, `bootstrap` with retries on the transient I/O
177
182
  error, then polls the well-known document until it answers with **this** identity.
178
- 3. Uninstall: `launchd_uninstall`, `remove_descriptor`; keep data unless the operator
179
- asks to purge.
183
+ A label the operator disabled stays disabled: only a first install enables.
184
+ 3. Uninstall: `launchd_uninstall` (waits for the unload, removes the plist, resets the
185
+ override; `purge=True` removes data only when the operator asks), `remove_descriptor`.
186
+ 4. After an upgrade, `prune_releases(releases_dir, current=…)` keeps the running release
187
+ and the one before it.
180
188
 
181
189
  Code runs from an immutable release directory; an upgrade writes a new release,
182
190
  rewrites the plist and restarts. Read [the lifecycle reference](references/lifecycle.md)
@@ -199,6 +207,11 @@ raises no banners of its own: the host is the one channel. Read
199
207
  [the events reference](references/events-and-notifications.md) for mapping an existing
200
208
  log, retention and notification policy.
201
209
 
210
+ An agent that calls paid models reports **what it spent**: one receipt per call from the
211
+ provider's own usage numbers, served behind the token at `surfaces.usage` as the DEC-0021 usage
212
+ report. An unknown cost is null, never 0. Read [the usage reference](references/usage.md)
213
+ before wiring it.
214
+
202
215
  ## Step 6 — verify
203
216
 
204
217
  Run the probe against the live service — it reads the descriptor, then checks every
@@ -1,5 +1,31 @@
1
1
  # Lifecycle — supervisor, install, release layout, state
2
2
 
3
+ This page implements the organization's
4
+ [product lifecycle contract](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/lifecycle.md)
5
+ (rules LC-01…LC-15) for `fabric-service/0.1` services. Where the two differ, the contract
6
+ wins and this page is the defect. The rule each section serves:
7
+
8
+ | Contract rule | Here | Kit (Python / Node) |
9
+ |---|---|---|
10
+ | LC-01 quit reaches exit within a deadline | [Shutdown](#shutdown) | `Drain` / `Drain` |
11
+ | LC-03 a job is bounded and exclusive | [one copy under launchd](#one-copy-under-launchd) | `hold_single_instance` / `holdSingleInstance` |
12
+ | LC-09 the footprint is declared | the descriptor and the plist; the service's own `AGENTS.md` names its label and port | `write_descriptor`, `launchd_plist` |
13
+ | LC-11 previous release kept, older pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
14
+ | LC-12 every file is bounded | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
15
+ | LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `launchd_install`, `launchd_uninstall` (Python) |
16
+ | LC-15 builds clean up after themselves | release pruning runs inside the installer, not later | `prune_releases` / `pruneReleases` |
17
+
18
+ ## Contents
19
+
20
+ - [launchd plist](#launchd-plist-generate-it-with-launchd_plist)
21
+ - [Install, upgrade, uninstall](#install-upgrade-uninstall)
22
+ - [One copy under launchd](#one-copy-under-launchd)
23
+ - [Directories](#directories)
24
+ - [Logs](#logs)
25
+ - [Shutdown](#shutdown)
26
+ - [Being managed by a host lifecycle broker](#being-managed-by-a-host-lifecycle-broker)
27
+ - [Linux](#linux)
28
+
3
29
  ## launchd plist (generate it with `launchd_plist`)
4
30
 
5
31
  | Key | Value | Why |
@@ -9,9 +35,9 @@
9
35
  | `RunAtLoad` | `true` | back at every login |
10
36
  | `KeepAlive` | `true` | `{SuccessfulExit:false}` leaves a cleanly exited service down |
11
37
  | `ThrottleInterval` | `10` | bounds a crash loop |
12
- | `ExitTimeOut` | drain time + margin (40 s default) | SIGKILL arrives after it |
38
+ | `ExitTimeOut` | above the drain and its hard exit (15 s default; `Drain` ends by 10 s) | SIGKILL arrives after it, so it must never come first |
13
39
  | `ProcessType` | `Standard` — never `Background`, and no `Nice` > 0 or `LowPriorityIO` | a host probes the service and agents call it while the Mac is busy; a background job is starved for tens of seconds under load and every host reports an outage that never happened (the probe's `lifecycle.priority` rule fails it) |
14
- | `EnvironmentVariables` | `PATH` set explicitly; `*_FILE` paths to secrets | never a secret value — the plist is world-readable in backups |
40
+ | `EnvironmentVariables` | `PATH` set explicitly; `*_FILE` paths to secrets; `FABRIC_SERVICE_SUPERVISOR=launchd` | never a secret value — the plist is world-readable in backups; the supervisor marker is how the lock knows to back off |
15
41
  | `StandardOutPath`/`StandardErrorPath` | `~/Library/Logs/<id>/service.log` | one place the host tails |
16
42
 
17
43
  ## Install, upgrade, uninstall
@@ -19,17 +45,41 @@
19
45
  1. Build or unpack the release into `~/.local/share/<id>/releases/<version>-<sha12>/`
20
46
  (a venv or `node_modules` inside it). Never edit a release after it is written.
21
47
  2. `write_descriptor` (port and id claims are checked here).
22
- 3. `launchd_install`: write + `plutil -lint`, `enable`, `bootout` and wait until
23
- `launchctl print gui/<uid>/<label>` fails, `bootstrap` with up to five retries,
48
+ 3. `launchd_install`: write + `plutil -lint`, read `launchctl print-disabled gui/<uid>`,
49
+ `enable` **only when no override is recorded** (a first install), `bootout` and wait
50
+ until `launchctl print gui/<uid>/<label>` fails, `bootstrap` with up to five retries,
24
51
  then poll the well-known document until it answers with this `id.instance`.
25
52
  4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
26
- again. Keep the previous release for rollback; prune older ones by count.
27
- 5. Uninstall = `launchd_uninstall` + `remove_descriptor`. Data stays.
28
-
29
- A host stops a service with `bootout` **and** `disable` (off survives a login) and
30
- starts it with `enable` + `bootstrap`; restart is `kickstart -k`. The service never
53
+ again, then `prune_releases(releases_dir, current=<new release>)`: the running release
54
+ and the one before it stay for rollback, older ones go (LC-11, LC-15).
55
+ 5. Uninstall = `launchd_uninstall` + `remove_descriptor`. Data stays; `purge=True`
56
+ (with `service_id`) removes data, logs and cache when the operator asks.
57
+
58
+ **The operator's intent wins (LC-14).** A host stops a service with `bootout` **and**
59
+ `disable` (off survives a login) and starts it with `enable` + `bootstrap`; restart is
60
+ `kickstart -k`. An install or upgrade never re-enables a label the operator disabled: it
61
+ rewrites the plist, so the new release is what starts once they turn it back on, and
62
+ answers `{"disabled": true, ...}` instead of the well-known document. `force_enable=True`
63
+ is for an operator who asked for the service to be switched on. The service never
31
64
  implements these verbs itself — its CLI calls launchctl the same way.
32
65
 
66
+ **Uninstall is symmetric.** `launchd_uninstall` boots the job out and **waits** until
67
+ launchd reports it gone (it raises, removing nothing, if the job is still loaded at the
68
+ timeout), removes the plist, and resets the override to `enabled`. launchctl has no verb
69
+ that deletes an override, and a leftover `disabled` would make a later fresh install stay
70
+ off. Tests use a throwaway label or a fake `launchctl` first on `PATH` — never the
71
+ operator's real jobs.
72
+
73
+ ## One copy under launchd
74
+
75
+ Started by hand, a copy that finds `service.lock` held prints one sentence naming the
76
+ holder and exits 75 at once. Under launchd that exit is a respawn every `ThrottleInterval`
77
+ (10 s), forever. So a copy whose plist carries `FABRIC_SERVICE_SUPERVISOR=launchd` backs
78
+ off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over if the holder leaves,
79
+ and exits 75 only when the wait runs out: a few starts an hour instead of 360. It touches
80
+ nothing while it waits. Two installs sharing one data directory are still the defect to
81
+ remove; the back-off only stops the loop from costing the machine.
82
+
33
83
  ## Directories
34
84
 
35
85
  | What | macOS | Linux |
@@ -46,15 +96,28 @@ anything the operator would miss belongs in data.
46
96
 
47
97
  ## Logs
48
98
 
49
- One structured log (JSON lines) rotated by size (5 × 5 MB is a sane default) plus the
50
- launchd stdout file, truncated at start. Configure logging once, in the entry point.
51
- Never log a token, a cookie, a login code or a request body that may carry one.
99
+ One structured log (JSON lines) rotated by size — `RotatingLog(dirs["logs"] / "service.jsonl")`,
100
+ 5 × 5 MB by default, files 0600 in a 0700 directory — plus the launchd stdout file, which
101
+ `cap_stdout_log` copies to `.1` and truncates at start once it passes 5 MB (launchd holds it
102
+ open for append, so it is truncated in place, never renamed). Configure logging once, in the
103
+ entry point. Never log a token, a cookie, a login code or a request body that may carry one.
52
104
 
53
105
  ## Shutdown
54
106
 
55
107
  On `SIGTERM`: stop accepting new work, append a `service.stopping` event, drain
56
- in-flight work until `ExitTimeOut` minus a margin, persist what was interrupted so the
57
- next start resumes it, release the lock, exit 0.
108
+ in-flight work, persist what was interrupted so the next start resumes it, release the
109
+ lock, exit 0 — within 10 s with work in flight (LC-01). `Drain` does the timing:
110
+
111
+ ```python
112
+ drain = fs.Drain() # drain 8 s, hard exit 2 s later (EXIT_HARD_STOP, 70)
113
+ drain.install(lambda drained: server.shutdown(),
114
+ on_stopping=lambda: log.append("service.stopping", "info", "Stopping."))
115
+ with drain.work(): # raises fs.Stopping once a stop has begun -> answer 503
116
+ handle(request)
117
+ ```
118
+
119
+ Work longer than the drain is journalled before it starts and resumes on the next start;
120
+ it is never a reason to raise the deadline.
58
121
 
59
122
  ## Being managed by a host lifecycle broker
60
123
 
@@ -1,7 +1,7 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `2ce392291c6668598d12cd38327e24696b5ca15c`
4
- (`docs/specification/service.md`, DEC-0015). The contract's schemas are normative;
3
+ Pinned to `fabric-agent-contract` commit `23f9fda4c05f8a3852246ee98d4f2adf74ed0875`
4
+ (`docs/specification/service.md`, DEC-0015; the usage report DEC-0021). The contract's schemas are normative;
5
5
  this page is the working summary. Extension key:
6
6
  `https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
7
7
 
@@ -47,7 +47,8 @@ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
47
47
  - `status`: `starting | ready | degraded | stopping`. No answer means `down`.
48
48
  - `build` needs `commit` or `digest` (`sha256:<64 hex>`).
49
49
  - `summary`: at most six tiles; `attention: true` counts toward the host's badge.
50
- - `surfaces.events` is required; `dashboard` and `mcp` when the service has them.
50
+ - `surfaces.events` is required; `dashboard` and `mcp` when the service has them; `usage`
51
+ (DEC-0021) when the service reports its spend — see [the usage reference](usage.md).
51
52
 
52
53
  ## Events page — `GET /fabric/v1/events?after=<cursor>&limit=<n>`
53
54
 
@@ -81,3 +82,4 @@ single use, at most 120 s, recorded as used before it is honoured. `GET` that UR
81
82
  | `FAC-SEM-010` | no two descriptors claim one port or one `id.instance` |
82
83
  | `FAC-SEM-011` | `ready` carries no degraded source |
83
84
  | `FAC-SEM-012` | commands start with an absolute or `~/` executable |
85
+ | `FAC-SEM-025` | a usage report adds up; an all-unpriced row costs `null`, not `0` (DEC-0021) |
@@ -6,6 +6,12 @@
6
6
  origin, stateless JSON responses unless the tools stream.
7
7
  - Authenticate with the service token in the declared header. OAuth is for remote
8
8
  multi-user servers, not for a loopback service.
9
+ - **Or keep the two roles apart (DEC-0024).** The descriptor's token is the host's: it reads
10
+ events and the usage report, and it mints operator login codes. An MCP token sits in every
11
+ client's `~/.claude.json`. When that matters, give agents their own token (`service.token`
12
+ for agents, `host.token` named by the descriptor), declare `surfaces.mcp.auth: "own"`, and
13
+ make `/mcp` refuse the host's token. `check_service.py` then checks only that refusal
14
+ (`interop.mcp-own-auth`) and leaves the caller rules NOT_RUN.
9
15
  - Register it into a client config with the service's own command
10
16
  (`<tool> mcp-register`), which writes `{"type":"http","url":"http://127.0.0.1:<port>/mcp","headers":{...}}`
11
17
  atomically, aborts if the file changed meanwhile, and keeps one 0600 backup. The token