@passioncode-ai/fabric-agent-adapter 0.3.1 → 0.4.2

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 +52 -0
  2. package/README.md +41 -5
  3. package/SECURITY.md +14 -0
  4. package/bin/fabric-agent-adapter.js +87 -43
  5. package/package.json +10 -5
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +2 -3
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +240 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +32 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +44 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +63 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +32 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +83 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +58 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +373 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +380 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +663 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +226 -0
  19. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +8 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.2 - 2026-09-29
4
+
5
+ - Kit: `LoopbackHTTPServer`, a `ThreadingHTTPServer` whose bind asks no resolver.
6
+ `http.server` calls `socket.getfqdn()` between `bind()` and `listen()`; on a macOS
7
+ CI runner that held a service's port bound but silent for over 20 seconds. The
8
+ sample service and the skill's startup order use it, and the skill names the trap.
9
+ - Kit: a garbled pid file (for example a superscript digit) now reads as "no pid"
10
+ instead of raising `ValueError` while naming the holder of a lock.
11
+
12
+ ## 0.4.1 - 2026-09-29
13
+
14
+ ### Fixed
15
+
16
+ - `building-fabric-services` could not be loaded by a strict YAML reader: its
17
+ front-matter `description` was an unquoted plain scalar holding `: `
18
+ ("fabric-service/0.1 extension: which surface", line 3, column 493), which YAML
19
+ rejects with "mapping values are not allowed here". Claude Code read it
20
+ leniently, so the plugin worked, but an agent reading the skill from
21
+ `~/.agents/skills` could drop it. The description is now a folded block
22
+ scalar (`>-`) with the exact same text (927 characters).
23
+ - The validator could not see that class of defect: `test/validate.py` now
24
+ parses every SKILL.md front matter strictly (standard library only) and
25
+ rejects what a YAML reader rejects in an unquoted value — `: ` or a trailing
26
+ `:`, ` #` (a comment that silently truncates the value), a leading indicator
27
+ character, a duplicate key, an unclosed quote, an undeclared multi-line
28
+ value — with the line and column. `test/test_validate.py` plants each case.
29
+ `validate.py --frontmatter <SKILL.md ...>` checks installed copies anywhere.
30
+ - The release smoke test looked for `~/.claude/skills`, where the installer has
31
+ not written since 0.4.0, so it would fail on the first release that ran it.
32
+ It now checks all three skills in `~/.agents/skills`, that `~/.claude/skills`
33
+ stays untouched, and that the installed front matter parses strictly.
34
+ - `SKILL-CARD.md` versions and the release smoke paths joined the validator's
35
+ sync checks.
36
+
37
+ ## 0.4.0 - 2026-09-28
38
+
39
+ - Add the `building-fabric-services` skill for the `fabric-service/0.1` local
40
+ service extension (Fabric Agent Contract DEC-0015): surface choice (MCP
41
+ streamable HTTP, CLI, A2A), token and one-time login, state and cache
42
+ directories, one copy per machine, launchd, descriptor, well-known document,
43
+ events feed and notifications.
44
+ - Ship reference kits in Python (`fabric_service.py`) and Node
45
+ (`fabric-service.mjs`) whose instance locks exclude each other, a complete
46
+ `sample_service.py`, and `check_service.py`, a live conformance probe that
47
+ reports PASS, FAIL or NOT_RUN per rule.
48
+ - `creating-fabric-agents` now routes an agent that runs as a service through
49
+ the new skill.
50
+ - Fix the installer shadowing its own plugin: `npx @passioncode-ai/fabric-agent-adapter`
51
+ and `install.sh` now install into the agents hub `~/.agents/skills`; a plain
52
+ copy into `~/.claude/skills` is refused while the plugin is installed, and
53
+ `--prune-shadow` moves existing shadowing copies aside.
54
+
3
55
  ## 0.3.1 - 2026-08-27
4
56
 
5
57
  - Fix the executable bit on `bin/fabric-agent-adapter.js` and `install.sh`: the
package/README.md CHANGED
@@ -1,13 +1,38 @@
1
+ <p align="center">
2
+ <a href="https://passioncode.ai/">
3
+ <img src="assets/passioncode-icon-256.png" width="104" height="104" alt="PassionCode.ai passion fruit mark">
4
+ </a>
5
+ </p>
6
+
1
7
  # Fabric Agent Adapter
2
8
 
9
+ > **PassionCode.ai — The agent-agnostic operating system for AI-native teams.**
10
+
11
+ **From vibe coding to passion coding.** PassionCode.ai moves the control point from
12
+ managing agents one by one to operating Projects. Fabric Agent Adapter is the portable
13
+ on-ramp: it helps an existing repository become a compatible, replaceable Provider
14
+ without moving the Project onto one proprietary agent runtime.
15
+
3
16
  [![npm](https://img.shields.io/npm/v/%40passioncode-ai%2Ffabric-agent-adapter)](https://www.npmjs.com/package/@passioncode-ai/fabric-agent-adapter)
4
17
  [![validate](https://github.com/passioncode-ai/fabric-agent-adapter/actions/workflows/validate.yml/badge.svg)](https://github.com/passioncode-ai/fabric-agent-adapter/actions/workflows/validate.yml)
5
18
  [![license](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
6
19
 
7
- Private, portable Agent Skill for adapting an existing agent project to the
8
- [Fabric Agent Contract](https://github.com/passioncode-ai/fabric-agent-contract).
20
+ Private, portable Agent Skills for the
21
+ [Fabric Agent Contract](https://github.com/passioncode-ai/fabric-agent-contract):
22
+ `adapting-projects-to-fabric`, `creating-fabric-agents` and
23
+ `building-fabric-services`.
24
+
25
+ `building-fabric-services` makes an agent a long-lived local service with a dashboard
26
+ that is always alive, runs as one copy, keeps its state through a reinstall and appears
27
+ in Fabric Dashboards by itself — the `fabric-service/0.1` extension. It ships Python and
28
+ Node reference kits (`scripts/fabric_service.py`, `scripts/fabric-service.mjs`), a
29
+ complete `scripts/sample_service.py`, and `scripts/check_service.py`, a live probe:
30
+
31
+ ```bash
32
+ python3 plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py example-agent
33
+ ```
9
34
 
10
- It helps an agent author:
35
+ For provider bundles the adapting skill helps an agent author:
11
36
 
12
37
  - inspect the project's stable integration surfaces without executing it;
13
38
  - choose A2A `1.0`, MCP `2026-07-28`, or `fabric-local-runner/0.1` per capability;
@@ -26,7 +51,8 @@ The repository is private; the npm package is public. The npm path needs no GitH
26
51
  npm — no GitHub access required:
27
52
 
28
53
  ```bash
29
- npx @passioncode-ai/fabric-agent-adapter # installs both skills into ~/.claude/skills
54
+ npx @passioncode-ai/fabric-agent-adapter # installs every skill into the agents hub ~/.agents/skills
55
+ npx @passioncode-ai/fabric-agent-adapter --prune-shadow # remove ~/.claude/skills copies that shadow the plugin
30
56
  ```
31
57
 
32
58
  Generic Agent Skills clients:
@@ -34,6 +60,7 @@ Generic Agent Skills clients:
34
60
  ```bash
35
61
  npx skills add passioncode-ai/fabric-agent-adapter --skill adapting-projects-to-fabric
36
62
  npx skills add passioncode-ai/fabric-agent-adapter --skill creating-fabric-agents
63
+ npx skills add passioncode-ai/fabric-agent-adapter --skill building-fabric-services
37
64
  ```
38
65
 
39
66
  Claude Code plugin marketplace:
@@ -52,6 +79,12 @@ Adapt this project for Fabric compatibility. Inspect it, choose the profile per
52
79
  capability, scaffold the provider bundle, and report every conformance gate.
53
80
  ```
54
81
 
82
+ Or, for an agent that must keep running with a dashboard:
83
+
84
+ ```text
85
+ Make this agent a local service with an always-on dashboard, following fabric-service/0.1.
86
+ ```
87
+
55
88
  Or, for an agent that does not exist yet:
56
89
 
57
90
  ```text
@@ -86,6 +119,10 @@ claude plugin validate ./plugins/fabric-agent-adapter --strict
86
119
  claude plugin validate . --strict
87
120
  ```
88
121
 
122
+ `test/validate.py` reads every SKILL.md front matter as strictly as a YAML parser does; an
123
+ unquoted value holding `: ` fails it. `python3 test/validate.py --frontmatter <SKILL.md ...>`
124
+ checks copies installed elsewhere, such as `~/.agents/skills/*/SKILL.md`.
125
+
89
126
  ## Contract pin
90
127
 
91
128
  - version: `0.1.0`
@@ -93,4 +130,3 @@ claude plugin validate . --strict
93
130
 
94
131
  The contract repository is normative. Updating the pin requires a new adapter release,
95
132
  fixture review, and a complete validation run.
96
-
package/SECURITY.md CHANGED
@@ -8,3 +8,17 @@ The adapter must not write secret values to manifests, fixtures, probes, logs, o
8
8
  arguments. Generated and external provider outputs are untrusted. Live probes must be
9
9
  bounded and non-publishing until explicit scoped grants exist in a project binding.
10
10
 
11
+
12
+ ## What the installers and scripts touch
13
+
14
+ - `npx @passioncode-ai/fabric-agent-adapter` and `install.sh` write only
15
+ `~/.agents/skills/<skill>`; `--target claude` writes `~/.claude/skills/<skill>` and is
16
+ refused while the plugin is installed; `--prune-shadow` moves shadowing copies into
17
+ `~/.claude/skills-shadow-backup/`.
18
+ - `building-fabric-services/scripts/check_service.py` reads a descriptor and the token
19
+ file it names, talks only to `127.0.0.1:<port>`, and redeems one login code it asked
20
+ for itself (creating one dashboard session). It never prints the token.
21
+ - The service kits create the service token (0600) and the instance lock in the
22
+ service's own data directory, and write a launchd plist and a descriptor only when an
23
+ installer calls them. The token is readable by any process of the same user; the
24
+ protocol defends against web pages and mistakes, not against hostile local code.
@@ -2,13 +2,12 @@
2
2
  /*
3
3
  * fabric-agent-adapter installer CLI.
4
4
  *
5
- * Installs every skill this plugin ships into ~/.claude/skills/<name>
6
- * (same layout as install.sh). Idempotent: an existing install is skipped unless
7
- * --force. Zero dependencies.
8
- *
9
- * For other agents (Cursor, Codex, 70+) use:
10
- * npx skills add passioncode-ai/fabric-agent-adapter (needs GitHub access —
11
- * the repository is private; the npm package needs none)
5
+ * Installs every skill this plugin ships into the shared agents hub
6
+ * ~/.agents/skills/<name> (read by Codex, Cursor, Gemini, OpenCode and others).
7
+ * Claude Code gets the skills through the plugin, never through a plain copy:
8
+ * a copy in ~/.claude/skills shadows the plugin and serves its frozen version
9
+ * forever. Idempotent; an existing install is skipped unless --force.
10
+ * Zero dependencies.
12
11
  */
13
12
  'use strict';
14
13
 
@@ -18,20 +17,25 @@ const os = require('os');
18
17
 
19
18
  const ROOT = path.resolve(__dirname, '..');
20
19
  const REPO = 'passioncode-ai/fabric-agent-adapter';
20
+ const PLUGIN_ID = 'fabric-agent-adapter@fabric-agent-adapter';
21
21
 
22
22
  function usage() {
23
23
  console.log(`fabric-agent-adapter installer
24
24
 
25
25
  Usage:
26
26
  npx @passioncode-ai/fabric-agent-adapter [--force]
27
- install every skill this plugin ships into ~/.claude
28
- (skip existing unless --force)
27
+ install every skill into the agents hub ~/.agents/skills
28
+ npx @passioncode-ai/fabric-agent-adapter --target claude [--force]
29
+ plain copy into ~/.claude/skills — refused while the plugin is installed
30
+ npx @passioncode-ai/fabric-agent-adapter --prune-shadow
31
+ remove plain ~/.claude/skills copies that shadow the installed plugin
29
32
  npx @passioncode-ai/fabric-agent-adapter --help
30
33
 
31
- Other install paths:
32
- Claude Code plugin: /plugin marketplace add ${REPO}
33
- /plugin install fabric-agent-adapter@fabric-agent-adapter
34
- Any agent (70+): npx skills add ${REPO} (private repo — GitHub auth required)`);
34
+ Claude Code (recommended):
35
+ claude plugin marketplace add ${REPO}
36
+ claude plugin install ${PLUGIN_ID}
37
+ Every PassionCode.ai skill for every agent at once:
38
+ npx passioncode@latest update`);
35
39
  }
36
40
 
37
41
  function copyDir(src, dest) {
@@ -44,15 +48,38 @@ function copyDir(src, dest) {
44
48
  }
45
49
  }
46
50
 
47
- function installOne(label, src, dest, force) {
48
- if (fs.existsSync(dest) && !force) {
49
- console.log(`skip: ${label} already installed at ${dest} (rerun with --force to overwrite)`);
50
- return;
51
+ function pluginInstalled(home) {
52
+ try {
53
+ const data = JSON.parse(fs.readFileSync(path.join(home, '.claude/plugins/installed_plugins.json'), 'utf8'));
54
+ const records = (data.plugins || {})[PLUGIN_ID] || [];
55
+ return records.some((r) => r.installPath && fs.existsSync(path.join(r.installPath, 'skills')));
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function skillNames() {
62
+ const skillRoot = path.join(ROOT, 'plugins/fabric-agent-adapter/skills');
63
+ if (!fs.existsSync(skillRoot)) return { skillRoot, names: [] };
64
+ // Iterate rather than name one skill: a skill added to the plugin must not
65
+ // require an installer change to reach anybody.
66
+ const names = fs.readdirSync(skillRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort();
67
+ return { skillRoot, names };
68
+ }
69
+
70
+ function pruneShadows(home, names) {
71
+ let removed = 0;
72
+ for (const name of names) {
73
+ const shadow = path.join(home, '.claude', 'skills', name);
74
+ let info;
75
+ try { info = fs.lstatSync(shadow); } catch { continue; }
76
+ const backup = path.join(home, '.claude', 'skills-shadow-backup', `${name}-${Date.now()}`);
77
+ fs.mkdirSync(path.dirname(backup), { recursive: true });
78
+ fs.renameSync(shadow, backup);
79
+ console.log(`Removed shadowing ${info.isSymbolicLink() ? 'link' : 'copy'} ${shadow} (kept at ${backup})`);
80
+ removed += 1;
51
81
  }
52
- fs.rmSync(dest, { recursive: true, force: true });
53
- fs.mkdirSync(path.dirname(dest), { recursive: true });
54
- copyDir(src, dest);
55
- console.log(`Installed ${label} -> ${dest}`);
82
+ if (!removed) console.log('No shadowing copies found.');
56
83
  }
57
84
 
58
85
  function main(argv) {
@@ -62,39 +89,56 @@ function main(argv) {
62
89
  return 0;
63
90
  }
64
91
  const force = args.includes('--force');
65
- const unknown = args.filter((a) => a !== '--force');
66
- if (unknown.length) {
67
- console.error(`unknown argument(s): ${unknown.join(' ')}`);
92
+ const prune = args.includes('--prune-shadow');
93
+ let target = 'agents';
94
+ const known = new Set(['--force', '--prune-shadow']);
95
+ const unknown = [];
96
+ for (let i = 0; i < args.length; i += 1) {
97
+ if (args[i] === '--target') {
98
+ target = args[i + 1];
99
+ i += 1;
100
+ } else if (!known.has(args[i])) unknown.push(args[i]);
101
+ }
102
+ if (unknown.length || !['agents', 'claude'].includes(target)) {
103
+ console.error(`unknown argument(s): ${unknown.concat(['agents', 'claude'].includes(target) ? [] : [`--target ${target}`]).join(' ')}`);
68
104
  usage();
69
105
  return 2;
70
106
  }
71
107
 
72
- const skillRoot = path.join(ROOT, 'plugins/fabric-agent-adapter/skills');
73
- if (!fs.existsSync(skillRoot)) {
74
- console.error(`error: skill sources missing at ${skillRoot} — corrupted package?`);
108
+ const { skillRoot, names } = skillNames();
109
+ if (!names.length) {
110
+ console.error(`error: no skills found under ${skillRoot} — corrupted package?`);
75
111
  return 1;
76
112
  }
113
+ const home = os.homedir();
114
+ const hasPlugin = pluginInstalled(home);
77
115
 
78
- // Iterate rather than name one skill: a skill added to the plugin must not
79
- // require an installer change to reach anybody.
80
- const names = fs
81
- .readdirSync(skillRoot, { withFileTypes: true })
82
- .filter((e) => e.isDirectory())
83
- .map((e) => e.name)
84
- .sort();
85
- if (!names.length) {
86
- console.error(`error: no skills found under ${skillRoot} — corrupted package?`);
116
+ if (prune) {
117
+ if (!hasPlugin) {
118
+ console.error(`error: the ${PLUGIN_ID} plugin is not installed, so a copy in ~/.claude/skills is not a shadow — nothing removed.`);
119
+ return 1;
120
+ }
121
+ pruneShadows(home, names);
122
+ return 0;
123
+ }
124
+ if (target === 'claude' && hasPlugin) {
125
+ console.error(`error: the ${PLUGIN_ID} plugin is installed; a plain copy in ~/.claude/skills would shadow it. Update the plugin instead:\n claude plugin marketplace update fabric-agent-adapter && claude plugin update ${PLUGIN_ID}`);
87
126
  return 1;
88
127
  }
89
128
 
90
- const home = os.homedir();
129
+ const destRoot = target === 'claude' ? path.join(home, '.claude', 'skills') : path.join(home, '.agents', 'skills');
91
130
  for (const name of names) {
92
- installOne(
93
- `${name} skill`,
94
- path.join(skillRoot, name),
95
- path.join(home, '.claude', 'skills', name),
96
- force
97
- );
131
+ const dest = path.join(destRoot, name);
132
+ if (fs.existsSync(dest) && !force) {
133
+ console.log(`skip: ${name} already installed at ${dest} (rerun with --force to overwrite)`);
134
+ continue;
135
+ }
136
+ fs.rmSync(dest, { recursive: true, force: true });
137
+ copyDir(path.join(skillRoot, name), dest);
138
+ console.log(`Installed ${name} -> ${dest}`);
139
+ }
140
+ if (target === 'agents' && !hasPlugin) {
141
+ console.log(`Claude Code reads plugins, not the hub:\n claude plugin marketplace add ${REPO} && claude plugin install ${PLUGIN_ID}`);
98
142
  }
99
143
  console.log('Restart your agent — skills load at session start.');
100
144
  return 0;
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.3.1",
4
- "description": "Create new Fabric-compatible agents and adapt existing projects to the Fabric Agent Contract — evidence-backed MCP, A2A, or local-runner provider bundles. This package is the installer CLI.",
3
+ "version": "0.4.2",
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
- "test": "python3 test/validate.py && python3 -m unittest discover -s test -q"
6
+ "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test test/"
7
7
  },
8
8
  "bin": {
9
9
  "fabric-agent-adapter": "bin/fabric-agent-adapter.js"
@@ -17,7 +17,9 @@
17
17
  "README.md",
18
18
  "LICENSE",
19
19
  "CHANGELOG.md",
20
- "SECURITY.md"
20
+ "SECURITY.md",
21
+ "!**/__pycache__/**",
22
+ "!**/*.pyc"
21
23
  ],
22
24
  "repository": "github:passioncode-ai/fabric-agent-adapter",
23
25
  "homepage": "https://github.com/passioncode-ai/fabric-agent-adapter",
@@ -39,6 +41,9 @@
39
41
  "provider",
40
42
  "conformance",
41
43
  "claude-code",
42
- "skill"
44
+ "skill",
45
+ "local-service",
46
+ "dashboard",
47
+ "launchd"
43
48
  ]
44
49
  }
@@ -2,8 +2,8 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
- "description": "Create new Fabric-compatible agents and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
6
- "version": "0.3.1",
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.4.2",
7
7
  "author": {
8
8
  "name": "passioncode-ai",
9
9
  "url": "https://github.com/passioncode-ai"
@@ -22,4 +22,3 @@
22
22
  "conformance"
23
23
  ]
24
24
  }
25
-
@@ -5,7 +5,7 @@ license: MIT
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned private fabric-agent-contract checkout. Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: passioncode-ai
8
- version: "0.3.1"
8
+ version: "0.4.2"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "20a818e648a4c09a60df0126d11626922e8b9094"
11
11
  ---
@@ -0,0 +1,240 @@
1
+ ---
2
+ name: building-fabric-services
3
+ description: >-
4
+ Use when building or running an agent as a long-lived local service with a dashboard on the
5
+ operator's Mac — «сделай агенту дашборд», «локальный сервис агента», «дашборд должен всегда
6
+ работать и не плодить копии», «где агенту хранить настройки», «подключи агента к Fabric
7
+ Dashboards», "make this agent a local service", "always-on dashboard", "fabric-service
8
+ protocol", "add the well-known endpoint", "migrate a service to fabric-service". Covers the
9
+ fabric-service/0.1 extension: which surface to expose (MCP streamable HTTP, CLI, A2A), the token
10
+ and one-time login, where state, config, logs and cache live, one copy per machine, launchd, the
11
+ descriptor, the well-known document, the events feed and notifications; ships Python and Node
12
+ reference kits and a live conformance probe. NOT for a one-off script or cron job, a hosted
13
+ SaaS, the provider manifest itself (adapting-projects-to-fabric), or building Fabric Dashboards.
14
+ license: MIT
15
+ 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). No network or package install; the contract checkout is optional.
16
+ metadata:
17
+ author: passioncode-ai
18
+ version: "0.4.2"
19
+ contract-version: "0.1.0"
20
+ extension: "fabric-service/0.1"
21
+ extension-commit: "a5a27092ba0dcc5facfbeae8b359146dfb403e9a"
22
+ ---
23
+
24
+ # Building Fabric services
25
+
26
+ A **service** is an agent that keeps running on the operator's computer: it holds
27
+ state, answers other agents, and shows a dashboard. This skill makes one that is
28
+ always alive, comes back after a restart, never runs twice, keeps its state through a
29
+ reinstall, and appears in Fabric Dashboards by itself — the `fabric-service/0.1`
30
+ extension of the Fabric Agent Contract (DEC-0015).
31
+
32
+ Every rule below exists because a real service on this estate broke it. Treat them as
33
+ non-negotiable; the kit implements them so you call a function instead of
34
+ re-deriving a rule.
35
+
36
+ ## Boundary
37
+
38
+ Use it to create a service, to bring an existing local server under the protocol, or
39
+ to review one. Do not use it for:
40
+
41
+ - a script, a cron job or a one-shot CLI — nothing stays running, so there is nothing
42
+ to supervise; a launchd `StartInterval` job needs no descriptor;
43
+ - a hosted SaaS — the protocol is loopback-only by design;
44
+ - the provider manifest and admission bundle — that is `adapting-projects-to-fabric`
45
+ (a service that is also a provider does both);
46
+ - the Fabric Dashboards app itself.
47
+
48
+ ## Step 0 — is it a service?
49
+
50
+ It is a service when at least one is true: other agents call it while the operator is
51
+ away; it runs jobs longer than one request; it owns a store other tools read; the
52
+ operator watches it on a page. Otherwise build a CLI and stop here.
53
+
54
+ Answer before writing code, and record the answers in the project README:
55
+
56
+ | Question | Answer shape |
57
+ |---|---|
58
+ | `id` | `^[a-z][a-z0-9-]{1,62}$`, stable forever (it names the data directory) |
59
+ | Port | one number; run the claim check below before choosing it |
60
+ | Who calls it | the named agents or people, and through which surface (Step 1) |
61
+ | What it stores | the store, where it lives (Step 2, rule 5), and what survives uninstall |
62
+ | What it tells the operator | the events worth a notification (Step 5) |
63
+
64
+ ## Step 1 — choose the surfaces
65
+
66
+ | Caller | Surface | Why |
67
+ |---|---|---|
68
+ | Another agent on this machine | MCP `2026-07-28`, `streamable-http`, at `/mcp` on the service origin, token in a header | one process serves every session; stdio-per-session once left twenty stray servers on this machine |
69
+ | The operator or a script | a CLI: `<tool> service status\|start\|stop\|restart`, `<tool> dashboard`, `<tool> doctor --json` | every verb delegates to launchd and the well-known document, never to its own process table |
70
+ | A remote agent | A2A `1.0` over HTTPS, or MCP behind an authenticated gateway | loopback reachability is not authorization |
71
+ | The dashboard page | same-origin REST under `/api`, session cookie plus a custom request header | the CSRF defence all four existing dashboards use |
72
+
73
+ Read [the surfaces and auth reference](references/surfaces-and-auth.md) when wiring
74
+ MCP registration into a client config, the login flow or the CSRF header.
75
+
76
+ ## Step 2 — the non-negotiables
77
+
78
+ 1. **Loopback only.** Bind `127.0.0.1`. Refuse any `Host` other than
79
+ `127.0.0.1:<port>`, `localhost:<port>`, `[::1]:<port>`; refuse a foreign `Origin` and
80
+ `Sec-Fetch-Site: cross-site` — on every path, the well-known one included.
81
+ *(A throwaway `python -m http.server` bound to every interface served a source tree to the local network.)*
82
+ 2. **One copy, locked before any side effect.** Take the exclusive lock on
83
+ `<data>/service.lock` first — before resuming jobs, starting a scheduler, migrating
84
+ a store or even creating a token. Held → print one sentence naming the holder's pid
85
+ and exit **75**. Binding the port is not a lock. *(A second copy re-queued the first
86
+ copy's running jobs in its startup hook, before its bind failed.)*
87
+ 3. **launchd is the only supervisor.** `RunAtLoad` true, `KeepAlive` true,
88
+ `ThrottleInterval` 10, `ExitTimeOut` above your drain time. Never start yourself
89
+ from a CLI with `nohup` or `&`; never let a host spawn you. *(`KeepAlive
90
+ {SuccessfulExit:false}` left a cleanly exited service down; hand-started copies
91
+ raced launchd's.)*
92
+ 4. **Build identity.** The well-known document carries the git commit or a package
93
+ digest and the process start time. *(A service ran on stale code after an edit and
94
+ burned budget; nothing showed which build was answering.)*
95
+ 5. **State outside code.** Data and config in `~/Library/Application Support/<id>/`,
96
+ logs in `~/Library/Logs/<id>/`, cache in `~/Library/Caches/<id>/`; secrets in a 0600
97
+ file. Never inside the service's own code checkout or a release directory; a
98
+ repository that versions the data itself is a store and is fine — declare
99
+ `source.repository` so the probe can tell them apart. *(Deleting a checkout left a
100
+ plist launchd retried every 10 s, and the data went with it.)*
101
+ 6. **Tokens stay in files and headers.** Mode 0600, owner-checked, never a symlink;
102
+ never in a query string, an argument vector or a plist. The dashboard gets a
103
+ one-time login code, never the token.
104
+ 7. **`degraded` is always present.** An empty list asserts full health; `ready` with a
105
+ non-empty list is a lie the kit corrects to `degraded`.
106
+ 8. **Atomic writes.** Temporary file, fsync, rename — for the store's side files, the
107
+ descriptor and the token.
108
+
109
+ ## Step 3 — build it with the kit
110
+
111
+ Copy the kit into the service (each is one self-contained file): Python
112
+ [`scripts/fabric_service.py`](scripts/fabric_service.py), Node
113
+ [`scripts/fabric-service.mjs`](scripts/fabric-service.mjs). Both share file formats, and
114
+ their locks exclude each other. The complete worked example is
115
+ [`scripts/sample_service.py`](scripts/sample_service.py) — read it before writing a
116
+ handler.
117
+
118
+ Startup order is the part that goes wrong; keep it exactly:
119
+
120
+ ```python
121
+ import fabric_service as fs
122
+
123
+ dirs = fs.service_dirs("example-agent")
124
+ lock = fs.hold_single_instance(dirs["data"]) # 1. lock — exits 75 if held
125
+ token = fs.ensure_token(dirs["data"] / "service.token") # 2. only now touch state
126
+ log = fs.JsonlEventLog(dirs["data"] / "events.jsonl") # (or a view over your own log)
127
+ codes = fs.LoginCodes(dirs["data"] / "auth")
128
+ resume_jobs() # 3. side effects after the lock
129
+ fs.LoopbackHTTPServer(("127.0.0.1", port), Handler) # 4. bind loopback, no resolver
130
+ ```
131
+
132
+ Node: `const lock = await holdSingleInstance(dirs.data)` first, then the same order.
133
+
134
+ On every request call `check_request(port, host, origin, sec_fetch_site)` and answer
135
+ 403 with its sentence when it returns one. Serve the four protocol routes:
136
+
137
+ | Route | Auth | Kit |
138
+ |---|---|---|
139
+ | `GET /.well-known/fabric-service` | none, guard still applies | `build_well_known(...)` — from memory, under 100 ms |
140
+ | `GET /fabric/v1/events?after=&limit=` | service token | `events_page(fetch, after, parse_limit(limit))` |
141
+ | `POST /fabric/v1/login-code` | service token | `LoginCodes.issue()` |
142
+ | `GET /fabric/v1/login?code=` | the code | `LoginCodes.redeem(code)` → `Set-Cookie: session_cookie_header(...)`, 302 to the dashboard |
143
+
144
+ `SIGTERM` drains in-flight work within `ExitTimeOut`, then exits; interrupted work
145
+ resumes on the next start.
146
+
147
+ ## Step 4 — install it
148
+
149
+ The installer, not the service, owns the plist and the descriptor. Sequence:
150
+
151
+ 1. `write_descriptor(descriptor)` — refuses a port or `id.instance` another descriptor
152
+ claims. A preview or branch copy is a second **instance**, never a second id.
153
+ 2. `launchd_plist(...)` then `launchd_install(...)` — writes and lints the plist,
154
+ `bootout` and waits for the unload, `bootstrap` with retries on the transient I/O
155
+ error, then polls the well-known document until it answers with **this** identity.
156
+ 3. Uninstall: `launchd_uninstall`, `remove_descriptor`; keep data unless the operator
157
+ asks to purge.
158
+
159
+ Code runs from an immutable release directory; an upgrade writes a new release,
160
+ rewrites the plist and restarts. Read [the lifecycle reference](references/lifecycle.md)
161
+ for the plist fields, the release layout, log rotation and the Linux case.
162
+
163
+ ## Step 5 — the dashboard and the events
164
+
165
+ The dashboard is a same-origin page: no inline script, strict CSP, polling that pauses
166
+ while the operator types or a dialog is open, one panel's failure never blanks the
167
+ others, and every error is a sentence with the next action. Read
168
+ [the dashboard reference](references/dashboard.md) before building the page.
169
+
170
+ The events feed is a **view over the log you already keep** — a jobs table, a JSONL
171
+ journal — not a second store. Each event is one sentence a person reads; set
172
+ `notify: true` only for what the operator must act on or would want to hear about
173
+ unprompted, with a `link` to the page that resolves it. Read
174
+ [the events reference](references/events-and-notifications.md) for mapping an existing
175
+ log, retention and notification policy.
176
+
177
+ ## Step 6 — verify
178
+
179
+ Run the probe against the live service — it reads the descriptor, then checks every
180
+ rule it can reach and says which it could not:
181
+
182
+ ```bash
183
+ python3 <skill-dir>/scripts/check_service.py <id>[.<instance>]
184
+ ```
185
+
186
+ It reports `PASS`, `FAIL` or `NOT_RUN` per rule (descriptor, port claim, well-known
187
+ shape, identity, latency, Host/Origin/cross-site guards, loopback bind, token file,
188
+ events auth and shape, single-use login, state outside code, instance lock, plist,
189
+ launchd pid equals answering pid) and exits 1 on any `FAIL`. A `NOT_RUN` is not a pass —
190
+ name it in the report.
191
+
192
+ Then prove the lock: start a second copy by hand against the same data directory; it
193
+ must exit 75 while the first keeps serving. Prove recovery: `kill -9` the launchd pid;
194
+ the well-known document must answer again with a new pid within about 15 seconds.
195
+
196
+ ## Migrating an existing service
197
+
198
+ Read [the migration reference](references/migrating-a-service.md) — the order of
199
+ changes that keeps the service answering throughout, and what each existing service on
200
+ this estate needs.
201
+
202
+ ## When something is missing
203
+
204
+ - **Not macOS / no launchd:** declare `lifecycle.manager: "none"`; hosts show the
205
+ service read-only. Keep every other rule.
206
+ - **No Python:** use the Node kit; run the probe from any machine that has Python and
207
+ can reach the port, or mark the probe `NOT_RUN` with the reason.
208
+ - **Neither kit fits the framework:** implement the four routes and the lock by hand
209
+ against [the protocol reference](references/protocol.md); the probe is the judge.
210
+ - **No contract checkout:** the protocol reference is pinned to the extension commit
211
+ in this skill's metadata; do not reconstruct fields from memory.
212
+ - **Fabric Dashboards not installed:** nothing changes — the service is complete
213
+ without a host; the descriptor waits for one.
214
+
215
+ ## Gotchas
216
+
217
+ - `lsof` shows `*:<port>` or `0.0.0.0` → the bind is wrong even if the Host check works.
218
+ - A health probe that only checks HTTP 200 accepts any program on the port. Compare
219
+ `service.id` and `service.instance` — two services here once defaulted to ports
220
+ another service already held.
221
+ - `launchctl bootstrap` right after `bootout` fails with I/O error 5 until the unload
222
+ finishes; wait, then retry.
223
+ - Under launchd `PATH` is bare. Pin the interpreter by absolute path; set `PATH`
224
+ explicitly in the plist; never copy the whole shell environment into it.
225
+ - A plist pointing at a worktree or a checkout breaks the day it moves. Point it at a
226
+ release.
227
+ - `http.server.HTTPServer` asks the resolver for its FQDN between `bind()` and
228
+ `listen()`. With a slow resolver (a macOS CI runner, a Mac offline) the port is bound
229
+ but silent: connects time out instead of being refused. Use `fs.LoopbackHTTPServer`.
230
+ - `logging.basicConfig` called twice: the second call is a no-op, so a server's own
231
+ log file stays empty. Configure logging once, in the entry point.
232
+ - Hand-made `.plist.bak` files in `~/Library/LaunchAgents` are never cleaned up; keep
233
+ backups elsewhere.
234
+
235
+ ## Completion format
236
+
237
+ Report: the `id`, port and surfaces with the Step 0 answers; files created or changed;
238
+ the probe table verbatim with every `NOT_RUN` explained; the lock and kill-9 results
239
+ with pids; and the one next command. Never call a service conformant while the probe
240
+ reports a `FAIL`.