@passioncode-ai/passioncode 0.1.4

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 (42) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +22 -0
  3. package/README.md +69 -0
  4. package/SECURITY.md +42 -0
  5. package/bin/passioncode.js +65 -0
  6. package/family.json +45 -0
  7. package/lib/launcher.js +361 -0
  8. package/package.json +48 -0
  9. package/payload/.claude-plugin/marketplace.json +47 -0
  10. package/payload/manifest.json +77 -0
  11. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +24 -0
  12. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +175 -0
  13. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/profile-selection.md +71 -0
  14. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-bundle.md +62 -0
  15. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +55 -0
  16. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +537 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +240 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard.md +32 -0
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +44 -0
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +63 -0
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +32 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +83 -0
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +58 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +373 -0
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +380 -0
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +663 -0
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +226 -0
  28. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +126 -0
  29. package/payload/plugins/observatory-log/.claude-plugin/plugin.json +19 -0
  30. package/payload/plugins/observatory-log/hooks/ask-why.py +98 -0
  31. package/payload/plugins/observatory-log/hooks/hooks.json +31 -0
  32. package/payload/plugins/observatory-log/hooks/record-turn.sh +81 -0
  33. package/payload/plugins/observatory-log/hooks/session-start.sh +27 -0
  34. package/payload/plugins/observatory-log/skills/explaining-changes/SKILL.md +111 -0
  35. package/payload/plugins/observatory-log/skills/handling-secrets/SKILL.md +115 -0
  36. package/payload/plugins/observatory-log/skills/tracking-resources/SKILL.md +87 -0
  37. package/payload/plugins/passioncode/.claude-plugin/plugin.json +13 -0
  38. package/payload/plugins/passioncode/hooks/hooks.json +10 -0
  39. package/payload/plugins/passioncode/hooks/probe.js +23 -0
  40. package/payload/plugins/passioncode/hooks/session-start.js +45 -0
  41. package/payload/plugins/passioncode/hooks/update-check.js +126 -0
  42. package/payload/plugins/passioncode/trust.json +6 -0
@@ -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`.
@@ -0,0 +1,32 @@
1
+ # Dashboard principles
2
+
3
+ The dashboard is where the operator decides; it must stay truthful when parts of the
4
+ service are not.
5
+
6
+ 1. **Same origin, static assets.** HTML shell plus hashed `app.js`/`app.css` from a
7
+ whitelist; no inline script; a strict CSP. Nothing from a CDN — the page must work
8
+ offline on a plane.
9
+ 2. **State first, then detail.** The top of every page answers "is it working, and
10
+ does it need me?" — the same `status`, `degraded` and attention tiles the
11
+ well-known document publishes, so the page and Fabric Dashboards never disagree.
12
+ 3. **Calm refresh.** Poll the page's own API every 5 s while visible, back off when
13
+ hidden, and pause while a field is focused, a form has unsaved input or a dialog is
14
+ open. Replace a region only when its data changed; keep focus, scroll and open
15
+ sections.
16
+ 4. **Partial failure stays partial.** Load panels independently (`Promise.allSettled`);
17
+ a failed panel shows its own one-sentence error and a retry, and the rest render.
18
+ 5. **Every error is a sentence and an action.** "Store listing push failed: App Store
19
+ Connect rejected the key. Settings → Keys" — never a status code, a stack trace or a
20
+ blank area. Show when data was last fresh.
21
+ 6. **Server down is a state, not a crash.** When the API stops answering, keep the
22
+ last data greyed with "Service not answering since 17:02", and recover by itself
23
+ when it returns.
24
+ 7. **Approvals show the diff and its hash.** Any write the operator approves shows
25
+ before → after and the hash the service will verify at apply time.
26
+ 8. **Theme.** Follow the operator's product design system (PassionCode.ai tokens for
27
+ Fabric-family services); respect `prefers-color-scheme` unless the product fixes a
28
+ theme; never encode meaning by colour alone.
29
+ 9. **Links into the page are stable.** Event `link`s point at hash routes that survive
30
+ a reload (`/dashboard#/approvals/77`) so a notification opens the exact item.
31
+ 10. **No second browser tab required.** With Fabric Dashboards installed, the page is
32
+ shown inside it; do not open windows or tabs on your own — use in-page dialogs.
@@ -0,0 +1,44 @@
1
+ # Events and notifications
2
+
3
+ ## Map the log you already keep
4
+
5
+ | Existing log | View |
6
+ |---|---|
7
+ | a SQL `events`/`jobs` table with an autoincrement id | `SELECT … WHERE id > ? ORDER BY id LIMIT ?`; `id` as string |
8
+ | per-job JSONL files | keep one append-only `activity.jsonl` index written beside them, or merge by `(ts, job, seq)` into a composite id `"<ts>-<job>-<seq>"` that still sorts |
9
+ | a journal with no id | add a monotonic counter when appending; backfill ids once on migration |
10
+ | nothing | `JsonlEventLog` from the kit |
11
+
12
+ The feed is a view: never copy rows into a second store that can drift from the first.
13
+
14
+ ## Writing an event
15
+
16
+ - `kind`: dotted, stable, past tense or state (`job.failed`, `job.awaiting_choice`,
17
+ `service.started`, `collector.stale`). Hosts filter on it.
18
+ - `level`: `info` (routine), `notice` (the operator may want to look), `warning`
19
+ (something is degrading), `error` (something failed and needs action).
20
+ - `text`: one sentence, in the operator's language, with the object named and the
21
+ number stated — "The Q3 report draft is ready for your approval (12 sections, 3
22
+ languages)." Never a machine id, never a stack trace.
23
+ - `subject`: what it is about (`{type: "report", id: "q3", label: "Q3 report"}`), so a host
24
+ can group.
25
+ - `link`: the page that resolves it.
26
+
27
+ ## When to set `notify: true`
28
+
29
+ Notify when the operator must act (an approval is waiting, a key expired, a job failed
30
+ after retries) or asked to be told (a long job finished). Do not notify for routine
31
+ progress, retries that will succeed, or anything that fires more than a few times an
32
+ hour. The host decides whether to show it — quiet hours, per-service settings — and
33
+ debounces; the service decides only what is notification-worthy.
34
+
35
+ ## Lifecycle events every service emits
36
+
37
+ `service.started` (with the build), `service.stopping`, `service.degraded` and
38
+ `service.recovered` when a degraded source appears or clears, `update.available` when
39
+ the service learns of a newer release.
40
+
41
+ ## Retention
42
+
43
+ At least seven days or 1000 events, whichever is more; trim oldest first; a cursor
44
+ older than retention returns the oldest retained page, not an error.
@@ -0,0 +1,63 @@
1
+ # Lifecycle — supervisor, install, release layout, state
2
+
3
+ ## launchd plist (generate it with `launchd_plist`)
4
+
5
+ | Key | Value | Why |
6
+ |---|---|---|
7
+ | `Label` | reverse-DNS, stable (`com.example.example-agent`) | the host controls the job by label |
8
+ | `ProgramArguments` | absolute interpreter + module/script inside a **release** directory | a worktree or checkout path breaks the day it moves |
9
+ | `RunAtLoad` | `true` | back at every login |
10
+ | `KeepAlive` | `true` | `{SuccessfulExit:false}` leaves a cleanly exited service down |
11
+ | `ThrottleInterval` | `10` | bounds a crash loop |
12
+ | `ExitTimeOut` | drain time + margin (40 s default) | SIGKILL arrives after it |
13
+ | `ProcessType` | `Background` | scheduler hint |
14
+ | `EnvironmentVariables` | `PATH` set explicitly; `*_FILE` paths to secrets | never a secret value — the plist is world-readable in backups |
15
+ | `StandardOutPath`/`StandardErrorPath` | `~/Library/Logs/<id>/service.log` | one place the host tails |
16
+
17
+ ## Install, upgrade, uninstall
18
+
19
+ 1. Build or unpack the release into `~/.local/share/<id>/releases/<version>-<sha12>/`
20
+ (a venv or `node_modules` inside it). Never edit a release after it is written.
21
+ 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,
24
+ then poll the well-known document until it answers with this `id.instance`.
25
+ 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
31
+ implements these verbs itself — its CLI calls launchctl the same way.
32
+
33
+ ## Directories
34
+
35
+ | What | macOS | Linux |
36
+ |---|---|---|
37
+ | data + config | `~/Library/Application Support/<id>/` | `${XDG_DATA_HOME:-~/.local/share}/<id>/` |
38
+ | logs | `~/Library/Logs/<id>/` | `${XDG_STATE_HOME:-~/.local/state}/<id>/logs/` |
39
+ | cache (deletable any time) | `~/Library/Caches/<id>/` | `${XDG_CACHE_HOME:-~/.cache}/<id>/` |
40
+ | code | `~/.local/share/<id>/releases/…` | same |
41
+
42
+ A repository that versions the data itself (a registry, a plan) may hold the data; the
43
+ service's own code checkout and its releases may not. Directories 0700, secret files 0600. A store carries a schema version and the service
44
+ refuses to open a store newer than its code. The cache holds only what can be rebuilt;
45
+ anything the operator would miss belongs in data.
46
+
47
+ ## Logs
48
+
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.
52
+
53
+ ## Shutdown
54
+
55
+ 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.
58
+
59
+ ## Linux
60
+
61
+ Until a systemd adapter exists, declare `lifecycle.manager: "none"`. A `systemd --user`
62
+ unit with `Restart=always` gives the same guarantees; the host will control it once the
63
+ adapter ships. Everything else in this skill applies unchanged.
@@ -0,0 +1,32 @@
1
+ # Migrating an existing service
2
+
3
+ Order matters: each step leaves the service answering.
4
+
5
+ 1. **Measure first.** Run `check_service.py --descriptor <draft>` against a descriptor
6
+ written by hand into a temporary `--services-dir`; keep the `FAIL` list as the
7
+ migration's checklist.
8
+ 2. **Add the well-known route and the build identity.** Read-only, no risk.
9
+ 3. **Add the events view** over the existing log, then the login-code routes. Keep the
10
+ service's old login path until the operator has switched.
11
+ 4. **Move the lock to the top of startup** — before the startup hook that resumes jobs.
12
+ Test: a second copy exits 75 and the first copy's running jobs are untouched.
13
+ 5. **Move state out of the code tree** if it lives there: copy to the OS data
14
+ directory, switch the paths, keep the old tree read-only for one release, then
15
+ delete it with the operator's consent.
16
+ 6. **Regenerate the plist** from `launchd_plist`, pointing at a release; delete stray
17
+ `.plist.bak` files from `~/Library/LaunchAgents` after moving them elsewhere.
18
+ 7. **Write the descriptor** from the installer; resolve any port claim it refuses by
19
+ moving the newcomer, never the established service.
20
+ 8. **Re-run the probe** until nothing `FAIL`s; record the table in the service's
21
+ verification document.
22
+
23
+ ## Gaps that existing services usually have
24
+
25
+ | Gap | What fixes it |
26
+ |---|---|
27
+ | A health route that answers `{"ok": true}` with no identity | the well-known document with `service.id`, `instance`, build and pid |
28
+ | The port bind treated as the single-instance guarantee, with a startup hook that runs first | the lock at the top of startup; a second copy exits 75 |
29
+ | Data, logs or caches inside the repository | the OS data, log and cache directories; the old tree read-only for one release |
30
+ | `KeepAlive {SuccessfulExit:false}`, a plist pointing at a checkout or a worktree | a plist generated by `launchd_plist`, pointing at a release |
31
+ | A port another service already uses by default | the port claim at install; move the newcomer |
32
+ | An event log in a private shape | the events view over it, with one sentence per event |
@@ -0,0 +1,83 @@
1
+ # fabric-service/0.1 — wire reference
2
+
3
+ Pinned to `fabric-agent-contract` commit `a5a27092ba0dcc5facfbeae8b359146dfb403e9a`
4
+ (`docs/specification/service.md`, DEC-0015). The contract's schemas are normative;
5
+ this page is the working summary. Extension key:
6
+ `https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
7
+
8
+ ## Descriptor — written by the installer
9
+
10
+ Directory: macOS `~/Library/Application Support/ai.passioncode.fabric/services/`,
11
+ Linux `${XDG_DATA_HOME:-~/.local/share}/passioncode-fabric/services/`, or
12
+ `FABRIC_SERVICES_DIR`. File `<id>.<instance>.json`, mode 0600, atomic.
13
+
14
+ | Field | Rule |
15
+ |---|---|
16
+ | `protocol` | `"fabric-service/0.1"` |
17
+ | `id`, `instance` | `^[a-z][a-z0-9-]{1,62}$`, `^[a-z][a-z0-9-]{0,31}$` (default `default`); the pair is unique per machine |
18
+ | `name`, `summary` | ≤ 80 and ≤ 200 characters |
19
+ | `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim |
20
+ | `auth` | `tokenFile` (0600); `header` default `Authorization` with `scheme` `Bearer`; any other header carries the raw token with `scheme: "none"` |
21
+ | `lifecycle` | `manager` `launchd` (then `label` and `plist`) or `none` |
22
+ | `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
23
+ | `commands` | only `doctor` and `update`, argument arrays whose first item is an absolute or `~/` path |
24
+ | `source.repository`, `fabricManifest` | optional |
25
+ | `installedAt`, `installedBy` | when and by which installer version |
26
+
27
+ ## Well-known document — `GET /.well-known/fabric-service`
28
+
29
+ No auth; the Host/Origin guard applies; answered from memory in under 100 ms.
30
+
31
+ ```json
32
+ {
33
+ "protocol": "fabric-service/0.1",
34
+ "service": { "id": "example-agent", "instance": "default", "name": "Example Agent",
35
+ "version": "0.2.0", "build": { "commit": "8b80be9", "dirty": false, "builtAt": "2026-09-28T17:40:00Z" } },
36
+ "process": { "pid": 58090, "startedAt": "2026-09-28T17:41:02Z" },
37
+ "status": "degraded",
38
+ "degraded": [{ "source": "llm", "reason": "No model key: drafting is paused." }],
39
+ "summary": [{ "label": "Jobs running", "value": 2 }, { "label": "Awaiting you", "value": 1, "attention": true }],
40
+ "surfaces": { "dashboard": { "path": "/dashboard", "login": true },
41
+ "mcp": { "path": "/mcp", "transport": "streamable-http" },
42
+ "events": { "path": "/fabric/v1/events" } },
43
+ "update": { "available": null }
44
+ }
45
+ ```
46
+
47
+ - `status`: `starting | ready | degraded | stopping`. No answer means `down`.
48
+ - `build` needs `commit` or `digest` (`sha256:<64 hex>`).
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.
51
+
52
+ ## Events page — `GET /fabric/v1/events?after=<cursor>&limit=<n>`
53
+
54
+ Token required. `limit` default 50, maximum 200. Without `after`: the newest `limit`,
55
+ ascending. `cursor` is the last id returned, or the `after` you sent when the page is
56
+ empty, or `null` for an empty log. Retain at least seven days or 1000 events.
57
+
58
+ ```json
59
+ { "events": [ { "id": "4213", "at": "2026-09-28T17:55:10Z", "kind": "job.awaiting_choice",
60
+ "level": "notice", "text": "The Q3 report draft is ready for your approval.",
61
+ "subject": { "type": "report", "id": "q3", "label": "Q3 report" },
62
+ "link": "/dashboard#/approvals/77", "notify": true } ],
63
+ "cursor": "4213" }
64
+ ```
65
+
66
+ `kind` is dotted lowercase; `level` is `info | notice | warning | error`; `link` is a
67
+ path on the service origin, never an absolute URL. Optional SSE stream at
68
+ `surfaces.events.stream` carrying the same objects.
69
+
70
+ ## Login code — `POST /fabric/v1/login-code`
71
+
72
+ Token required → `{ "url": "/fabric/v1/login?code=<16–256 url-safe chars>", "expiresAt": "…" }`,
73
+ single use, at most 120 s, recorded as used before it is honoured. `GET` that URL →
74
+ `Set-Cookie` `HttpOnly; SameSite=Strict`, `302` to `surfaces.dashboard.path`.
75
+
76
+ ## Semantic rules a host applies
77
+
78
+ | Code | Rule |
79
+ |---|---|
80
+ | `FAC-SEM-009` | well-known `service.id.instance` equals the descriptor's, else the port holder is `foreign` |
81
+ | `FAC-SEM-010` | no two descriptors claim one port or one `id.instance` |
82
+ | `FAC-SEM-011` | `ready` carries no degraded source |
83
+ | `FAC-SEM-012` | commands start with an absolute or `~/` executable |
@@ -0,0 +1,58 @@
1
+ # Surfaces and auth
2
+
3
+ ## MCP for agents
4
+
5
+ - One server inside the service process, `streamable-http`, at `/mcp` on the service
6
+ origin, stateless JSON responses unless the tools stream.
7
+ - Authenticate with the service token in the declared header. OAuth is for remote
8
+ multi-user servers, not for a loopback service.
9
+ - Register it into a client config with the service's own command
10
+ (`<tool> mcp-register`), which writes `{"type":"http","url":"http://127.0.0.1:<port>/mcp","headers":{...}}`
11
+ atomically, aborts if the file changed meanwhile, and keeps one 0600 backup. The token
12
+ goes in `headers`, never in the URL — every log that records a URL prints its query
13
+ string.
14
+ - Ship server `instructions` that tell a caller where to start and that every answer
15
+ carries `degraded`.
16
+
17
+ ## CLI for people and scripts
18
+
19
+ `<tool> service status|start|stop|restart` (launchctl on the label, then the
20
+ well-known document), `<tool> dashboard` (asks for a login code and opens it; with
21
+ Fabric Dashboards installed it opens the service there instead), `<tool> doctor --json`
22
+ (the same checks the probe runs, plus provider keys), `--json` on every command. Print
23
+ a sign-in link only when stdout is a terminal.
24
+
25
+ ## A2A for remote agents
26
+
27
+ Remote reach goes through A2A `1.0` over HTTPS or an authenticated gateway in front of
28
+ MCP — never by binding a LAN address. A tunnel (Tailscale Serve and the like) is an
29
+ outward surface with its own token lifetime; watch it like a service, because nothing
30
+ else will notice it expire.
31
+
32
+ ## Token
33
+
34
+ - Created once with `ensure_token` at first start, after the instance lock.
35
+ - Stored at the descriptor's `auth.tokenFile`, 0600, owner-checked, never a symlink.
36
+ - Compared in constant time (`token_matches`).
37
+ - Rotation is an operator act: write a new file, restart, re-run `mcp-register`.
38
+ - Same-user processes can read it. The protocol defends against web pages and
39
+ mistakes, not against hostile code running as the operator — say so in SECURITY.md.
40
+
41
+ ## Dashboard session
42
+
43
+ - `POST /fabric/v1/login-code` (token) → single-use code, at most 120 s, persisted as
44
+ used before it is honoured, burned even when expired.
45
+ - `GET /fabric/v1/login?code=` → `HttpOnly; SameSite=Strict` cookie, 302 to the
46
+ dashboard. Sessions are HMAC-signed; `revoke_all` rotates the key and ends every
47
+ session at once.
48
+ - Browser writes need the cookie **and** a custom header such as `X-<Tool>-Request: 1`,
49
+ which forces a preflight the service never answers.
50
+ - A dashboard that is deliberately read-open to local pages declares
51
+ `"login": false`; its writes still need the header.
52
+
53
+ ## Headers every response carries
54
+
55
+ `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`, a CSP with no inline
56
+ script, and `frame-ancestors 'self'` unless the page must never be embedded. Fabric
57
+ Dashboards shows pages in a native view, not a frame, so `X-Frame-Options: DENY` does
58
+ not stop it.