@passioncode-ai/passioncode 0.1.16 → 0.1.18

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 (21) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +13 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +92 -0
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +86 -0
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +23 -6
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +92 -0
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +26 -5
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/surfaces-and-auth.md +3 -2
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +86 -0
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +35 -12
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -1
  18. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +13 -1
  19. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +92 -0
  20. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +86 -0
  21. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.18 - 2026-10-01
4
+
5
+ ### Changed
6
+
7
+ - Pin Fabric Agent Adapter v0.5.7: services are scheduled as standard processes (`ProcessType
8
+ Standard`; the probe's `lifecycle.priority` rule fails `Background`), `notify: true` is reserved
9
+ for a decision, a failure or a blocking warning once per episode, a service raises no banners of
10
+ its own, and a preview instance is uninstalled once checked
11
+ ([adapter changelog](https://github.com/passioncode-ai/fabric-agent-adapter/blob/v0.5.7/CHANGELOG.md)).
12
+
13
+ ## 0.1.17 - 2026-10-01
14
+
15
+ ### Changed
16
+
17
+ - Pin Fabric Agent Adapter v0.5.6 so the installed set carries its dashboard deep-link procedure and portable handoff checker. The Observatory Log and working-in-passioncode members keep their existing behavior.
18
+ - Dashboard routing evidence belongs to the [adapter handoff](https://github.com/passioncode-ai/fabric-agent-adapter/blob/v0.5.6/docs/handoffs/2026-10-01-dashboard-links.md); the launcher installs these bytes through its existing plugin and agents-hub channels.
19
+
3
20
  ## 0.1.16 - 2026-10-01
4
21
 
5
22
  ### Fixed
package/family.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "name": "fabric-agent-adapter",
9
9
  "displayName": "Fabric Agent Adapter",
10
10
  "repo": "passioncode-ai/fabric-agent-adapter",
11
- "ref": "v0.5.5",
11
+ "ref": "v0.5.7",
12
12
  "kind": "plugin",
13
13
  "path": "plugins/fabric-agent-adapter",
14
14
  "legacyPluginIds": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/passioncode",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "The PassionCode.ai agent skill set \u2014 Fabric Agent Adapter, Observatory Log and the organisation's working rules \u2014 for Claude Code and every other agent on the machine, updated as one set.",
5
5
  "bin": {
6
6
  "passioncode": "bin/passioncode.js"
@@ -11,7 +11,7 @@
11
11
  "name": "fabric-agent-adapter",
12
12
  "displayName": "Fabric Agent Adapter",
13
13
  "source": "./plugins/fabric-agent-adapter",
14
- "version": "0.5.5",
14
+ "version": "0.5.7",
15
15
  "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.",
16
16
  "author": {
17
17
  "name": "PassionCode.ai",
@@ -35,7 +35,7 @@
35
35
  "name": "passioncode",
36
36
  "displayName": "PassionCode.ai",
37
37
  "source": "./plugins/passioncode",
38
- "version": "0.1.16",
38
+ "version": "0.1.18",
39
39
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
40
40
  "author": {
41
41
  "name": "PassionCode.ai",
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "family": "passioncode",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "release": true,
5
5
  "members": [
6
6
  {
7
7
  "name": "fabric-agent-adapter",
8
8
  "displayName": "Fabric Agent Adapter",
9
9
  "repo": "passioncode-ai/fabric-agent-adapter",
10
- "ref": "v0.5.5",
11
- "commit": "f302123b97b0c4aaf2b447f6054b583b62b3caf3",
12
- "version": "0.5.5",
10
+ "ref": "v0.5.7",
11
+ "commit": "de21d9548bc836fe09ca6049afb647b11450dda8",
12
+ "version": "0.5.7",
13
13
  "via": "clone of https://***@github.com/passioncode-ai/fabric-agent-adapter.git",
14
14
  "skills": [
15
15
  "adapting-projects-to-fabric",
@@ -22,7 +22,7 @@
22
22
  "legacyMarketplaces": [
23
23
  "fabric-agent-adapter"
24
24
  ],
25
- "contentHash": "sha256:6e4ba6f36e9d2f62a621a1dee0d0feb5532c7e959acdf4bfa5c8edfa82304711",
25
+ "contentHash": "sha256:bebf6fe75b96b059558f0ad4f771db6784b07f429f88db2b5e285f3420284d5a",
26
26
  "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.",
27
27
  "author": {
28
28
  "name": "PassionCode.ai",
@@ -61,16 +61,16 @@
61
61
  "name": "passioncode",
62
62
  "displayName": "PassionCode.ai",
63
63
  "repo": "passioncode-ai/passioncode",
64
- "ref": "v0.1.16",
64
+ "ref": "v0.1.18",
65
65
  "commit": null,
66
- "version": "0.1.16",
66
+ "version": "0.1.18",
67
67
  "via": "this repository",
68
68
  "skills": [
69
69
  "working-in-passioncode"
70
70
  ],
71
71
  "legacyPluginIds": [],
72
72
  "legacyMarketplaces": [],
73
- "contentHash": "sha256:15d7a746ecf165e574f87a28af46bd0ea04255b86cc3351ee0d96778bb741447",
73
+ "contentHash": "sha256:26d450262adad29363047bc7a6df496d8a0ecd6fd82d8c1e771944248db8eb8e",
74
74
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check that keeps the PassionCode.ai set current.",
75
75
  "author": {
76
76
  "name": "PassionCode.ai",
@@ -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.5.5",
6
+ "version": "0.5.7",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.5.5"
8
+ version: "0.5.7"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "74d3852f122f5ca5cbc4138a201483531dfa5006"
11
11
  ---
@@ -213,3 +213,15 @@ Report:
213
213
 
214
214
  Never summarize the outcome as “Fabric-compatible” unless all required admission gates
215
215
  passed against a real provider and the project binding is valid.
216
+
217
+ ## Dashboard handoff — also for consumers
218
+
219
+ When handing a registered service dashboard to a person, or writing the generated
220
+ agent's completion/notification instructions, read and apply
221
+ [dashboard links](references/dashboard-links.md): use the host's `open_link`,
222
+ check the target device and host, and run the bundled output gate before delivery.
223
+ An installed host failure never means browser fallback. This applies in Claude
224
+ Code, Codex and other providers; it needs no host-specific hook. MCP unavailable:
225
+ report the unresolved capability or use an approved project resolver; no automatic
226
+ client configuration. Python unavailable: perform the reference's manual checks
227
+ and report the executable gate NOT_RUN. Headless agents need no dashboard.
@@ -0,0 +1,92 @@
1
+ # Dashboard links — producer and consumer rule
2
+
3
+ Read when an agent creates a dashboard, implements `<tool> dashboard`, or hands
4
+ an existing Fabric service dashboard to a person. This also applies to generated
5
+ agent instructions and completion messages. Headless agents need no dashboard;
6
+ API origins, external docs and OAuth links are not dashboard actions.
7
+
8
+ ## Resolve, then hand over
9
+
10
+ 1. Discover the target computer and exact registered `service.id.instance`.
11
+ Never guess a key from a project name or port. `default` and `preview` differ.
12
+ 2. Discover Fabric Dashboards tools by their descriptions; provider tool prefixes
13
+ differ. Use `link` with service + path (including the page's query/fragment),
14
+ or pass a known local service URL to it. For a root, `list_services.open_link`
15
+ is also authoritative. Hand back **the returned `open_link` as the primary
16
+ dashboard action**. Do not rebuild its URI or double-encode `path`.
17
+ 3. Use `host_status`, where offered, to distinguish available, not_installed,
18
+ unknown, incompatible and handler_mismatch. It is a read, not a GUI launch.
19
+ With an older server lacking that tool, host readiness is unverified: use a
20
+ compatible host resolver or report the limitation, not presumed absence.
21
+ 4. A request for a link opens nothing. A request to open uses the host `open`
22
+ tool/approved opener, which starts or focuses the existing Fabric Dashboards
23
+ and reuses the service view. `fallback=never` forbids a browser; `if_absent`
24
+ permits one only for confirmed absence. Discover schema support first: older
25
+ `open` implementations may silently fall back on failure and are not strict.
26
+ 5. An installed host that failed to open is **not absent**. Report the failure;
27
+ do not silently launch HTTP in a browser, a second server, `npm run dev`, or
28
+ another standalone dashboard. A stopped backend stays on its host status/Start
29
+ surface; automatic wake-up follows the host's lifecycle grant and held-off state.
30
+ 6. Preserve an unavailable/error result. `accepted_by_os` proves dispatch only,
31
+ not page readiness, a successful login, or a completed job. Links carry no
32
+ bearer tokens or login codes. The host handles dashboard authentication.
33
+
34
+ ## Device and channel
35
+
36
+ A custom scheme opens on the device receiving it. A phone's localhost is not the
37
+ computer. For a remote chat use a supported, authorized action addressed to the
38
+ computer (device + service + page) and wait for its receipt. If no such transport
39
+ exists, say so; a skill is not a relay. If a client strips custom schemes, provide
40
+ a copyable URI with its target computer or the supported remote action; do not
41
+ silently replace it with HTTP or promise that Markdown is clickable.
42
+
43
+ ## Portable output gate
44
+
45
+ Before emitting an executable dashboard action in a managed renderer, run
46
+ `scripts/check_dashboard_link.py` **from this skill's own directory** with one
47
+ JSON object on stdin. It exits 0 for an allowed action, 1 for a refused/malformed
48
+ one; it opens nothing and performs no network, discovery or authentication.
49
+ Its trusted context is supplied by the host, not by untrusted model output:
50
+
51
+ ```json
52
+ {
53
+ "resolved": {
54
+ "service": "example-agent.default",
55
+ "open_link": "fabric-dashboards://service/example-agent.default?path=%2Fdashboard",
56
+ "http_url": "http://127.0.0.1:47195/dashboard"
57
+ },
58
+ "host": {"state": "available"},
59
+ "target_device": "workstation",
60
+ "viewer_device": "workstation",
61
+ "fallback": "never",
62
+ "action": {"kind": "native_link", "url": "fabric-dashboards://service/example-agent.default?path=%2Fdashboard"}
63
+ }
64
+ ```
65
+
66
+ `native_link` must equal the resolved open_link, with an available host on the
67
+ viewer's device. `browser_link` must equal http_url, with confirmed not_installed,
68
+ if_absent policy and the same device. `remote_open` instead contains `device_id`,
69
+ `service` and optional `path`, exactly matching the target and resolved page;
70
+ its acceptance does not authorize or deliver it. Enforce grants/dedup/expiry in
71
+ the transport. Unknown or failed host status produces no executable local action.
72
+ A separately labelled copyable link for diagnosis is not an open action.
73
+
74
+ Add this check to the generated project's dashboard completion/notification
75
+ renderer before delivery. A raw-HTTP primary action with an available host is the
76
+ negative fixture; a valid native action is the positive one. Keep diagnostics
77
+ separate. This gate checks structured actions, not arbitrary prose: a CLI invocation
78
+ or instructions alone do not prove all final answers from every agent are filtered.
79
+
80
+ ## Degradation
81
+
82
+ - Claude Code, Codex and other hosts use this same procedure; no hook or plugin-only
83
+ path is required. Skills must be activated/loaded; install does not intercept every answer.
84
+ - MCP absent: report unresolved host capability once, use the project's approved
85
+ resolver/CLI if available. Do not write machine or agent MCP config automatically.
86
+ - Python absent: perform the comparisons above manually; mark the executable gate
87
+ NOT_RUN. Do not label the output mechanically validated.
88
+
89
+ Syntax is owned by Fabric Dashboards and `@passioncode-ai/fabric-service-host`:
90
+ [ADR-0005](https://github.com/passioncode-ai/fabric-dashboards/blob/51a7a80acf42781d7bec304cc87c655d48cc309b/docs/adr/0005-service-links.md).
91
+ The gate accepts canonical links and the legacy `open?service=` form returned by
92
+ older hosts; it does not generate either dialect or redefine the Fabric wire contract.
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """Check one structured dashboard handoff from stdin. No I/O effects; exit 0/1.
3
+
4
+ The caller supplies a trusted host resolver result and observed device/host context.
5
+ This is an output correctness gate, not authentication, discovery, or a relay.
6
+ """
7
+ import json
8
+ import re
9
+ import sys
10
+ from urllib.parse import parse_qs, urlsplit
11
+
12
+
13
+ def require(condition, reason):
14
+ if not condition:
15
+ raise ValueError(reason)
16
+
17
+
18
+ def safe_path(path):
19
+ return (isinstance(path, str) and path.startswith('/') and not path.startswith('//')
20
+ and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path))
21
+
22
+
23
+ def check(data):
24
+ require(isinstance(data, dict), 'invalid_input')
25
+ resolved, host, action = (data.get(k) for k in ('resolved', 'host', 'action'))
26
+ require(all(isinstance(v, dict) for v in (resolved, host, action)), 'missing_context')
27
+ service, native, http = (resolved.get(k) for k in ('service', 'open_link', 'http_url'))
28
+ require(isinstance(service, str) and re.fullmatch(r'[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*', service), 'invalid_service')
29
+ require(isinstance(native, str) and isinstance(http, str), 'missing_resolved_links')
30
+ require(not re.search(r'[\\\x00-\x20\x7f]', native + http), 'unsafe_url')
31
+ deep, plain = urlsplit(native), urlsplit(http)
32
+ require(deep.scheme == 'fabric-dashboards' and not deep.username and not deep.password and not deep.port and not deep.fragment, 'invalid_deep_link')
33
+ query = parse_qs(deep.query, keep_blank_values=True, strict_parsing=True)
34
+ if deep.netloc == 'service':
35
+ require(deep.path in ('/' + service, '/' + service + '/') and set(query) <= {'path'}, 'wrong_service_or_parameter')
36
+ else:
37
+ require(deep.netloc == 'open' and deep.path in ('', '/') and query.get('service') == [service] and set(query) <= {'service', 'path'}, 'invalid_legacy_link')
38
+ require('path' not in query or len(query['path']) == 1, 'duplicate_path')
39
+ page = query.get('path', [None])[0]
40
+ require(page is None or safe_path(page), 'unsafe_path')
41
+ require(plain.scheme == 'http' and plain.hostname in ('127.0.0.1', 'localhost', '::1') and plain.port is not None
42
+ and not plain.username and not plain.password, 'invalid_http_fallback')
43
+ http_page = plain.path + ('?' + plain.query if plain.query else '') + ('#' + plain.fragment if plain.fragment else '')
44
+ require(safe_path(http_page) and http_page == (page or '/'), 'mismatched_page')
45
+ sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code'}
46
+ page_query = parse_qs(urlsplit(http_page).query)
47
+ require(not sensitive.intersection(k.lower() for k in page_query), 'credential_query')
48
+ require(urlsplit(http_page).path != '/fabric/v1/login', 'login_link_is_not_dashboard')
49
+ state = host.get('state')
50
+ require(state in ('available', 'not_installed', 'unknown', 'incompatible', 'handler_mismatch', 'unsupported'), 'unknown_host_state')
51
+ target, viewer = data.get('target_device'), data.get('viewer_device')
52
+ require(all(isinstance(v, str) and 0 < len(v) <= 200 for v in (target, viewer)), 'missing_device')
53
+ fallback = data.get('fallback', 'if_absent')
54
+ require(fallback in ('if_absent', 'never'), 'invalid_fallback')
55
+ kind = action.get('kind')
56
+ if kind == 'remote_open':
57
+ require(set(action) <= {'kind', 'device_id', 'service', 'path'}, 'unexpected_action_field')
58
+ require(action.get('device_id') == target and action.get('service') == service and action.get('path') == page, 'wrong_remote_target')
59
+ require(viewer != target, 'remote_action_on_local_device')
60
+ elif kind == 'native_link':
61
+ require(set(action) == {'kind', 'url'} and action.get('url') == native, 'not_host_provided_link')
62
+ require(viewer == target, 'wrong_viewer_device')
63
+ require(state == 'available', 'host_unavailable')
64
+ elif kind == 'browser_link':
65
+ require(set(action) == {'kind', 'url'} and action.get('url') == http, 'not_resolved_http_url')
66
+ require(viewer == target, 'wrong_viewer_device')
67
+ require(state == 'not_installed' and fallback == 'if_absent', 'browser_fallback_forbidden')
68
+ else:
69
+ raise ValueError('unsupported_dashboard_action')
70
+ return {'ok': True, 'kind': kind}
71
+
72
+
73
+ def main():
74
+ try:
75
+ raw = sys.stdin.read(65537)
76
+ require(len(raw) <= 65536, 'input_too_large')
77
+ result = check(json.loads(raw))
78
+ except (ValueError, TypeError, KeyError, AttributeError):
79
+ # Fixed reasons only: never echo a submitted URL, token or JSON blob.
80
+ result = {'ok': False, 'reason': 'dashboard_handoff_refused'}
81
+ print(json.dumps(result))
82
+ return 0 if result['ok'] else 1
83
+
84
+
85
+ if __name__ == '__main__':
86
+ sys.exit(main())
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: building-fabric-services
3
3
  description: >-
4
- Use when building or running an agent as a long-lived local service with a dashboard on
4
+ Use when handing out or opening a Fabric service dashboard, or building a local agent service on
5
5
  a macOS machine — «сделай агенту дашборд», «локальный сервис агента», «дашборд должен всегда
6
6
  работать и не плодить копии», «где агенту хранить настройки», «подключи агента к Fabric
7
7
  Dashboards», "make this agent a local service", "always-on dashboard", "fabric-service
@@ -12,10 +12,10 @@ description: >-
12
12
  reference kits and a live conformance probe. NOT for a one-off script or cron job, a hosted
13
13
  SaaS, the provider manifest itself (adapting-projects-to-fabric), or building Fabric Dashboards.
14
14
  license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
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.
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). Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
16
16
  metadata:
17
17
  author: PassionCode.ai
18
- version: "0.5.5"
18
+ version: "0.5.7"
19
19
  contract-version: "0.1.0"
20
20
  extension: "fabric-service/0.1"
21
21
  extension-commit: "74d3852f122f5ca5cbc4138a201483531dfa5006"
@@ -163,7 +163,10 @@ descriptor's `fabricManifest` at a manifest whose service key names this
163
163
  The installer, not the service, owns the plist and the descriptor. Sequence:
164
164
 
165
165
  1. `write_descriptor(descriptor)` — refuses a port or `id.instance` another descriptor
166
- claims. A preview or branch copy is a second **instance**, never a second id.
166
+ claims. A preview or branch copy is a second **instance**, never a second id — and it is
167
+ one more service the operator sees, probes and gets notified by. Verify a release with its
168
+ own doctor or self-check before it replaces `default`; a temporary instance is uninstalled
169
+ (`launchd_uninstall`, `remove_descriptor`) as soon as its check is done, not left running.
167
170
  2. `launchd_plist(...)` then `launchd_install(...)` — writes and lints the plist,
168
171
  `bootout` and waits for the unload, `bootstrap` with retries on the transient I/O
169
172
  error, then polls the well-known document until it answers with **this** identity.
@@ -183,8 +186,10 @@ others, and every error is a sentence with the next action. Read
183
186
 
184
187
  The events feed is a **view over the log you already keep** — a jobs table, a JSONL
185
188
  journal — not a second store. Each event is one sentence a person reads; set
186
- `notify: true` only for what the operator must act on or would want to hear about
187
- unprompted, with a `link` to the page that resolves it. Read
189
+ `notify: true` only when the operator must decide, or something failed or degrades their
190
+ work — once per episode, with a `subject` and a `link` to the page that resolves it. A
191
+ question's kind says so (`*.awaiting_*`, `*.approval_*`, `human_step.opened`). The service
192
+ raises no banners of its own: the host is the one channel. Read
188
193
  [the events reference](references/events-and-notifications.md) for mapping an existing
189
194
  log, retention and notification policy.
190
195
 
@@ -275,3 +280,15 @@ Report: the `id`, port and surfaces with the Step 0 answers; files created or ch
275
280
  the probe table verbatim with every `NOT_RUN` explained; the lock and kill-9 results
276
281
  with pids; and the one next command. Never call a service conformant while the probe
277
282
  reports a `FAIL`.
283
+
284
+ ## Dashboard handoff — also for consumers
285
+
286
+ When handing a registered service dashboard to a person, or writing the generated
287
+ agent's completion/notification instructions, read and apply
288
+ [dashboard links](references/dashboard-links.md): use the host's `open_link`,
289
+ check the target device and host, and run the bundled output gate before delivery.
290
+ An installed host failure never means browser fallback. This applies in Claude
291
+ Code, Codex and other providers; it needs no host-specific hook. MCP unavailable:
292
+ report the unresolved capability or use an approved project resolver; no automatic
293
+ client configuration. Python unavailable: perform the reference's manual checks
294
+ and report the executable gate NOT_RUN. Headless agents need no dashboard.
@@ -0,0 +1,92 @@
1
+ # Dashboard links — producer and consumer rule
2
+
3
+ Read when an agent creates a dashboard, implements `<tool> dashboard`, or hands
4
+ an existing Fabric service dashboard to a person. This also applies to generated
5
+ agent instructions and completion messages. Headless agents need no dashboard;
6
+ API origins, external docs and OAuth links are not dashboard actions.
7
+
8
+ ## Resolve, then hand over
9
+
10
+ 1. Discover the target computer and exact registered `service.id.instance`.
11
+ Never guess a key from a project name or port. `default` and `preview` differ.
12
+ 2. Discover Fabric Dashboards tools by their descriptions; provider tool prefixes
13
+ differ. Use `link` with service + path (including the page's query/fragment),
14
+ or pass a known local service URL to it. For a root, `list_services.open_link`
15
+ is also authoritative. Hand back **the returned `open_link` as the primary
16
+ dashboard action**. Do not rebuild its URI or double-encode `path`.
17
+ 3. Use `host_status`, where offered, to distinguish available, not_installed,
18
+ unknown, incompatible and handler_mismatch. It is a read, not a GUI launch.
19
+ With an older server lacking that tool, host readiness is unverified: use a
20
+ compatible host resolver or report the limitation, not presumed absence.
21
+ 4. A request for a link opens nothing. A request to open uses the host `open`
22
+ tool/approved opener, which starts or focuses the existing Fabric Dashboards
23
+ and reuses the service view. `fallback=never` forbids a browser; `if_absent`
24
+ permits one only for confirmed absence. Discover schema support first: older
25
+ `open` implementations may silently fall back on failure and are not strict.
26
+ 5. An installed host that failed to open is **not absent**. Report the failure;
27
+ do not silently launch HTTP in a browser, a second server, `npm run dev`, or
28
+ another standalone dashboard. A stopped backend stays on its host status/Start
29
+ surface; automatic wake-up follows the host's lifecycle grant and held-off state.
30
+ 6. Preserve an unavailable/error result. `accepted_by_os` proves dispatch only,
31
+ not page readiness, a successful login, or a completed job. Links carry no
32
+ bearer tokens or login codes. The host handles dashboard authentication.
33
+
34
+ ## Device and channel
35
+
36
+ A custom scheme opens on the device receiving it. A phone's localhost is not the
37
+ computer. For a remote chat use a supported, authorized action addressed to the
38
+ computer (device + service + page) and wait for its receipt. If no such transport
39
+ exists, say so; a skill is not a relay. If a client strips custom schemes, provide
40
+ a copyable URI with its target computer or the supported remote action; do not
41
+ silently replace it with HTTP or promise that Markdown is clickable.
42
+
43
+ ## Portable output gate
44
+
45
+ Before emitting an executable dashboard action in a managed renderer, run
46
+ `scripts/check_dashboard_link.py` **from this skill's own directory** with one
47
+ JSON object on stdin. It exits 0 for an allowed action, 1 for a refused/malformed
48
+ one; it opens nothing and performs no network, discovery or authentication.
49
+ Its trusted context is supplied by the host, not by untrusted model output:
50
+
51
+ ```json
52
+ {
53
+ "resolved": {
54
+ "service": "example-agent.default",
55
+ "open_link": "fabric-dashboards://service/example-agent.default?path=%2Fdashboard",
56
+ "http_url": "http://127.0.0.1:47195/dashboard"
57
+ },
58
+ "host": {"state": "available"},
59
+ "target_device": "workstation",
60
+ "viewer_device": "workstation",
61
+ "fallback": "never",
62
+ "action": {"kind": "native_link", "url": "fabric-dashboards://service/example-agent.default?path=%2Fdashboard"}
63
+ }
64
+ ```
65
+
66
+ `native_link` must equal the resolved open_link, with an available host on the
67
+ viewer's device. `browser_link` must equal http_url, with confirmed not_installed,
68
+ if_absent policy and the same device. `remote_open` instead contains `device_id`,
69
+ `service` and optional `path`, exactly matching the target and resolved page;
70
+ its acceptance does not authorize or deliver it. Enforce grants/dedup/expiry in
71
+ the transport. Unknown or failed host status produces no executable local action.
72
+ A separately labelled copyable link for diagnosis is not an open action.
73
+
74
+ Add this check to the generated project's dashboard completion/notification
75
+ renderer before delivery. A raw-HTTP primary action with an available host is the
76
+ negative fixture; a valid native action is the positive one. Keep diagnostics
77
+ separate. This gate checks structured actions, not arbitrary prose: a CLI invocation
78
+ or instructions alone do not prove all final answers from every agent are filtered.
79
+
80
+ ## Degradation
81
+
82
+ - Claude Code, Codex and other hosts use this same procedure; no hook or plugin-only
83
+ path is required. Skills must be activated/loaded; install does not intercept every answer.
84
+ - MCP absent: report unresolved host capability once, use the project's approved
85
+ resolver/CLI if available. Do not write machine or agent MCP config automatically.
86
+ - Python absent: perform the comparisons above manually; mark the executable gate
87
+ NOT_RUN. Do not label the output mechanically validated.
88
+
89
+ Syntax is owned by Fabric Dashboards and `@passioncode-ai/fabric-service-host`:
90
+ [ADR-0005](https://github.com/passioncode-ai/fabric-dashboards/blob/51a7a80acf42781d7bec304cc87c655d48cc309b/docs/adr/0005-service-links.md).
91
+ The gate accepts canonical links and the legacy `open?service=` form returned by
92
+ older hosts; it does not generate either dialect or redefine the Fabric wire contract.
@@ -26,11 +26,32 @@ The feed is a view: never copy rows into a second store that can drift from the
26
26
 
27
27
  ## When to set `notify: true`
28
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.
29
+ `notify: true` asks the host to interrupt the operator. Hosts treat it as a request, not an
30
+ order: Fabric Dashboards shows a banner only for a **decision**, a **failure** or a **warning**,
31
+ once per episode, and puts everything else in Activity (its ADR-0010). Write events so that rule
32
+ lands on what matters:
33
+
34
+ | The operator… | Event | `notify` |
35
+ |---|---|---|
36
+ | must decide or act — a choice, an approval, a key to enter | kind says it: `job.awaiting_choice`, `plan.awaiting_approval`, `human_step.opened`; level `notice` | `true` |
37
+ | lost work — a job failed after its retries, a key expired | level `error`; kind `*.failed`, `*.expired` | `true` |
38
+ | will lose work soon — a degraded source that blocks orders | level `warning`, `service.degraded`, **once when it starts** | `true` |
39
+ | asked to be told when a long job finishes | `job.delivered`, level `notice` | `true` only if that person asked; a host may still keep it in Activity |
40
+ | only needs the record — progress, retries, recoveries, syncs | `job.started`, `service.recovered`, `*.cleared`, level `info` or `notice` | `false` |
41
+
42
+ - **One episode, one request.** Notify on the transition, never on each poll: a source that
43
+ flaps between degraded and healthy sends `service.degraded` with `notify: true` once, and later
44
+ repeats with `notify: false` until it has stayed healthy for a while. Asking the same question
45
+ about the same subject again is not news.
46
+ - **Say who wants what.** `subject` names the thing (`{type: "job", id: "…", label: "Q3 report"}`)
47
+ so a host can tell episodes apart and group; `text` names the object and what the operator
48
+ should do ("The storyboard of the Q3 launch video waits for your decision."); `link` opens the
49
+ page where they do it. The host adds the agent's name and instance.
50
+ - **One channel.** A service with a descriptor raises no banners of its own (AppleScript
51
+ `display notification`, a notifier binary, its own notification-centre entries): the operator
52
+ would get each one twice, from a sender that is not the host. Publish the event; the host
53
+ delivers it, honouring quiet hours and per-service settings.
54
+ - Anything that would fire more than a few times an hour is not a notification.
34
55
 
35
56
  ## Lifecycle events every service emits
36
57
 
@@ -10,7 +10,7 @@
10
10
  | `KeepAlive` | `true` | `{SuccessfulExit:false}` leaves a cleanly exited service down |
11
11
  | `ThrottleInterval` | `10` | bounds a crash loop |
12
12
  | `ExitTimeOut` | drain time + margin (40 s default) | SIGKILL arrives after it |
13
- | `ProcessType` | `Background` | scheduler hint |
13
+ | `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
14
  | `EnvironmentVariables` | `PATH` set explicitly; `*_FILE` paths to secrets | never a secret value — the plist is world-readable in backups |
15
15
  | `StandardOutPath`/`StandardErrorPath` | `~/Library/Logs/<id>/service.log` | one place the host tails |
16
16
 
@@ -17,8 +17,9 @@
17
17
  ## CLI for people and scripts
18
18
 
19
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`
20
+ well-known document), `<tool> dashboard` (resolve the exact service and use the Fabric Dashboards host
21
+ opener first; installed-but-failed never falls back to a browser; only confirmed
22
+ absence permits explicit browser fallback and its one-time login flow), `<tool> doctor --json`
22
23
  (the same checks the probe runs, plus provider keys), `--json` on every command. Print
23
24
  a sign-in link only when stdout is a terminal.
24
25
 
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """Check one structured dashboard handoff from stdin. No I/O effects; exit 0/1.
3
+
4
+ The caller supplies a trusted host resolver result and observed device/host context.
5
+ This is an output correctness gate, not authentication, discovery, or a relay.
6
+ """
7
+ import json
8
+ import re
9
+ import sys
10
+ from urllib.parse import parse_qs, urlsplit
11
+
12
+
13
+ def require(condition, reason):
14
+ if not condition:
15
+ raise ValueError(reason)
16
+
17
+
18
+ def safe_path(path):
19
+ return (isinstance(path, str) and path.startswith('/') and not path.startswith('//')
20
+ and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path))
21
+
22
+
23
+ def check(data):
24
+ require(isinstance(data, dict), 'invalid_input')
25
+ resolved, host, action = (data.get(k) for k in ('resolved', 'host', 'action'))
26
+ require(all(isinstance(v, dict) for v in (resolved, host, action)), 'missing_context')
27
+ service, native, http = (resolved.get(k) for k in ('service', 'open_link', 'http_url'))
28
+ require(isinstance(service, str) and re.fullmatch(r'[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*', service), 'invalid_service')
29
+ require(isinstance(native, str) and isinstance(http, str), 'missing_resolved_links')
30
+ require(not re.search(r'[\\\x00-\x20\x7f]', native + http), 'unsafe_url')
31
+ deep, plain = urlsplit(native), urlsplit(http)
32
+ require(deep.scheme == 'fabric-dashboards' and not deep.username and not deep.password and not deep.port and not deep.fragment, 'invalid_deep_link')
33
+ query = parse_qs(deep.query, keep_blank_values=True, strict_parsing=True)
34
+ if deep.netloc == 'service':
35
+ require(deep.path in ('/' + service, '/' + service + '/') and set(query) <= {'path'}, 'wrong_service_or_parameter')
36
+ else:
37
+ require(deep.netloc == 'open' and deep.path in ('', '/') and query.get('service') == [service] and set(query) <= {'service', 'path'}, 'invalid_legacy_link')
38
+ require('path' not in query or len(query['path']) == 1, 'duplicate_path')
39
+ page = query.get('path', [None])[0]
40
+ require(page is None or safe_path(page), 'unsafe_path')
41
+ require(plain.scheme == 'http' and plain.hostname in ('127.0.0.1', 'localhost', '::1') and plain.port is not None
42
+ and not plain.username and not plain.password, 'invalid_http_fallback')
43
+ http_page = plain.path + ('?' + plain.query if plain.query else '') + ('#' + plain.fragment if plain.fragment else '')
44
+ require(safe_path(http_page) and http_page == (page or '/'), 'mismatched_page')
45
+ sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code'}
46
+ page_query = parse_qs(urlsplit(http_page).query)
47
+ require(not sensitive.intersection(k.lower() for k in page_query), 'credential_query')
48
+ require(urlsplit(http_page).path != '/fabric/v1/login', 'login_link_is_not_dashboard')
49
+ state = host.get('state')
50
+ require(state in ('available', 'not_installed', 'unknown', 'incompatible', 'handler_mismatch', 'unsupported'), 'unknown_host_state')
51
+ target, viewer = data.get('target_device'), data.get('viewer_device')
52
+ require(all(isinstance(v, str) and 0 < len(v) <= 200 for v in (target, viewer)), 'missing_device')
53
+ fallback = data.get('fallback', 'if_absent')
54
+ require(fallback in ('if_absent', 'never'), 'invalid_fallback')
55
+ kind = action.get('kind')
56
+ if kind == 'remote_open':
57
+ require(set(action) <= {'kind', 'device_id', 'service', 'path'}, 'unexpected_action_field')
58
+ require(action.get('device_id') == target and action.get('service') == service and action.get('path') == page, 'wrong_remote_target')
59
+ require(viewer != target, 'remote_action_on_local_device')
60
+ elif kind == 'native_link':
61
+ require(set(action) == {'kind', 'url'} and action.get('url') == native, 'not_host_provided_link')
62
+ require(viewer == target, 'wrong_viewer_device')
63
+ require(state == 'available', 'host_unavailable')
64
+ elif kind == 'browser_link':
65
+ require(set(action) == {'kind', 'url'} and action.get('url') == http, 'not_resolved_http_url')
66
+ require(viewer == target, 'wrong_viewer_device')
67
+ require(state == 'not_installed' and fallback == 'if_absent', 'browser_fallback_forbidden')
68
+ else:
69
+ raise ValueError('unsupported_dashboard_action')
70
+ return {'ok': True, 'kind': kind}
71
+
72
+
73
+ def main():
74
+ try:
75
+ raw = sys.stdin.read(65537)
76
+ require(len(raw) <= 65536, 'input_too_large')
77
+ result = check(json.loads(raw))
78
+ except (ValueError, TypeError, KeyError, AttributeError):
79
+ # Fixed reasons only: never echo a submitted URL, token or JSON blob.
80
+ result = {'ok': False, 'reason': 'dashboard_handoff_refused'}
81
+ print(json.dumps(result))
82
+ return 0 if result['ok'] else 1
83
+
84
+
85
+ if __name__ == '__main__':
86
+ sys.exit(main())
@@ -39,6 +39,36 @@ import fabric_interop as fi # noqa: E402
39
39
  Result = Dict[str, str]
40
40
 
41
41
 
42
+
43
+ def plist_problems(plist: dict, label: object, token: object = None) -> list:
44
+ """What makes a service's launchd job unfit to stay up: identity, restart, secrets."""
45
+ problems = []
46
+ if plist.get("Label") != label:
47
+ problems.append("Label %r" % plist.get("Label"))
48
+ if plist.get("RunAtLoad") is not True:
49
+ problems.append("RunAtLoad is not true")
50
+ if plist.get("KeepAlive") is not True:
51
+ problems.append("KeepAlive is %r, not true" % plist.get("KeepAlive"))
52
+ for key, value in (plist.get("EnvironmentVariables") or {}).items():
53
+ if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
54
+ problems.append("secret-like variable %s in the plist" % key)
55
+ if token and token in str(value):
56
+ problems.append("the service token itself is in the plist")
57
+ return problems
58
+
59
+
60
+ def priority_problems(plist: dict) -> list:
61
+ """A service that answers hosts and agents must not be scheduled as background work."""
62
+ out = []
63
+ if plist.get("ProcessType", "Standard") in ("Background", "Adaptive"):
64
+ out.append("ProcessType is %s" % plist.get("ProcessType"))
65
+ if isinstance(plist.get("Nice"), int) and plist["Nice"] > 0:
66
+ out.append("Nice is %d" % plist["Nice"])
67
+ if plist.get("LowPriorityIO") is True or plist.get("LowPriorityBackgroundIO") is True:
68
+ out.append("low-priority I/O")
69
+ return out
70
+
71
+
42
72
  class Probe:
43
73
  def __init__(self, descriptor_path: Path, descriptor: Dict[str, Any], services_dir: Path, skip_login: bool):
44
74
  self.path = descriptor_path
@@ -416,19 +446,12 @@ class Probe:
416
446
  except (OSError, ValueError) as exc:
417
447
  self.add("lifecycle.plist", "FAIL", "cannot read %s: %s" % (plist_path, exc))
418
448
  return
419
- problems = []
420
- if plist.get("Label") != life.get("label"):
421
- problems.append("Label %r" % plist.get("Label"))
422
- if plist.get("RunAtLoad") is not True:
423
- problems.append("RunAtLoad is not true")
424
- if plist.get("KeepAlive") is not True:
425
- problems.append("KeepAlive is %r, not true" % plist.get("KeepAlive"))
426
- for key, value in (plist.get("EnvironmentVariables") or {}).items():
427
- if re.search(r"(TOKEN|SECRET|PASSWORD|KEY)$", key) and not key.endswith("_FILE"):
428
- problems.append("secret-like variable %s in the plist" % key)
429
- if self.token and self.token in str(value):
430
- problems.append("the service token itself is in the plist")
449
+ problems = plist_problems(plist, life.get("label"), self.token)
431
450
  self.add("lifecycle.plist", "FAIL" if problems else "PASS", "; ".join(problems) or "%s: RunAtLoad, KeepAlive, no secrets" % plist_path.name)
451
+ slow = priority_problems(plist)
452
+ self.add("lifecycle.priority", "FAIL" if slow else "PASS",
453
+ "; ".join(slow) + " — macOS may starve the service under load and hosts then see an outage; use ProcessType Standard"
454
+ if slow else "%s: scheduled as a standard process" % plist_path.name)
432
455
  if not shutil.which("launchctl"):
433
456
  self.add("lifecycle.one-copy", "NOT_RUN", "launchctl is not available")
434
457
  return
@@ -596,7 +596,10 @@ def launchd_plist(label: str, program_arguments: Sequence[str], *, working_direc
596
596
  "KeepAlive": True,
597
597
  "ThrottleInterval": 10,
598
598
  "ExitTimeOut": exit_timeout,
599
- "ProcessType": "Background",
599
+ # Standard, never Background: a host probes the service and agents call it while the Mac
600
+ # is busy; Background (and Nice/LowPriorityIO) lets macOS starve it for tens of seconds
601
+ # under load, and every host then reports an outage that never happened.
602
+ "ProcessType": "Standard",
600
603
  "StandardOutPath": str(stdout_path),
601
604
  "StandardErrorPath": str(stdout_path),
602
605
  "EnvironmentVariables": env,
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.5.5"
8
+ version: "0.5.7"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "74d3852f122f5ca5cbc4138a201483531dfa5006"
11
11
  ---
@@ -160,3 +160,15 @@ and which traps became fixtures; created files; contract version and commit; the
160
160
  table with `PASS`, `FAIL`, `NOT_RUN`, or `NOT_VERIFIED`; remaining placeholders; and the
161
161
  exact next command. Never summarize the outcome as "Fabric-compatible" unless every
162
162
  required admission gate passed against the real provider.
163
+
164
+ ## Dashboard handoff — also for consumers
165
+
166
+ When handing a registered service dashboard to a person, or writing the generated
167
+ agent's completion/notification instructions, read and apply
168
+ [dashboard links](references/dashboard-links.md): use the host's `open_link`,
169
+ check the target device and host, and run the bundled output gate before delivery.
170
+ An installed host failure never means browser fallback. This applies in Claude
171
+ Code, Codex and other providers; it needs no host-specific hook. MCP unavailable:
172
+ report the unresolved capability or use an approved project resolver; no automatic
173
+ client configuration. Python unavailable: perform the reference's manual checks
174
+ and report the executable gate NOT_RUN. Headless agents need no dashboard.
@@ -0,0 +1,92 @@
1
+ # Dashboard links — producer and consumer rule
2
+
3
+ Read when an agent creates a dashboard, implements `<tool> dashboard`, or hands
4
+ an existing Fabric service dashboard to a person. This also applies to generated
5
+ agent instructions and completion messages. Headless agents need no dashboard;
6
+ API origins, external docs and OAuth links are not dashboard actions.
7
+
8
+ ## Resolve, then hand over
9
+
10
+ 1. Discover the target computer and exact registered `service.id.instance`.
11
+ Never guess a key from a project name or port. `default` and `preview` differ.
12
+ 2. Discover Fabric Dashboards tools by their descriptions; provider tool prefixes
13
+ differ. Use `link` with service + path (including the page's query/fragment),
14
+ or pass a known local service URL to it. For a root, `list_services.open_link`
15
+ is also authoritative. Hand back **the returned `open_link` as the primary
16
+ dashboard action**. Do not rebuild its URI or double-encode `path`.
17
+ 3. Use `host_status`, where offered, to distinguish available, not_installed,
18
+ unknown, incompatible and handler_mismatch. It is a read, not a GUI launch.
19
+ With an older server lacking that tool, host readiness is unverified: use a
20
+ compatible host resolver or report the limitation, not presumed absence.
21
+ 4. A request for a link opens nothing. A request to open uses the host `open`
22
+ tool/approved opener, which starts or focuses the existing Fabric Dashboards
23
+ and reuses the service view. `fallback=never` forbids a browser; `if_absent`
24
+ permits one only for confirmed absence. Discover schema support first: older
25
+ `open` implementations may silently fall back on failure and are not strict.
26
+ 5. An installed host that failed to open is **not absent**. Report the failure;
27
+ do not silently launch HTTP in a browser, a second server, `npm run dev`, or
28
+ another standalone dashboard. A stopped backend stays on its host status/Start
29
+ surface; automatic wake-up follows the host's lifecycle grant and held-off state.
30
+ 6. Preserve an unavailable/error result. `accepted_by_os` proves dispatch only,
31
+ not page readiness, a successful login, or a completed job. Links carry no
32
+ bearer tokens or login codes. The host handles dashboard authentication.
33
+
34
+ ## Device and channel
35
+
36
+ A custom scheme opens on the device receiving it. A phone's localhost is not the
37
+ computer. For a remote chat use a supported, authorized action addressed to the
38
+ computer (device + service + page) and wait for its receipt. If no such transport
39
+ exists, say so; a skill is not a relay. If a client strips custom schemes, provide
40
+ a copyable URI with its target computer or the supported remote action; do not
41
+ silently replace it with HTTP or promise that Markdown is clickable.
42
+
43
+ ## Portable output gate
44
+
45
+ Before emitting an executable dashboard action in a managed renderer, run
46
+ `scripts/check_dashboard_link.py` **from this skill's own directory** with one
47
+ JSON object on stdin. It exits 0 for an allowed action, 1 for a refused/malformed
48
+ one; it opens nothing and performs no network, discovery or authentication.
49
+ Its trusted context is supplied by the host, not by untrusted model output:
50
+
51
+ ```json
52
+ {
53
+ "resolved": {
54
+ "service": "example-agent.default",
55
+ "open_link": "fabric-dashboards://service/example-agent.default?path=%2Fdashboard",
56
+ "http_url": "http://127.0.0.1:47195/dashboard"
57
+ },
58
+ "host": {"state": "available"},
59
+ "target_device": "workstation",
60
+ "viewer_device": "workstation",
61
+ "fallback": "never",
62
+ "action": {"kind": "native_link", "url": "fabric-dashboards://service/example-agent.default?path=%2Fdashboard"}
63
+ }
64
+ ```
65
+
66
+ `native_link` must equal the resolved open_link, with an available host on the
67
+ viewer's device. `browser_link` must equal http_url, with confirmed not_installed,
68
+ if_absent policy and the same device. `remote_open` instead contains `device_id`,
69
+ `service` and optional `path`, exactly matching the target and resolved page;
70
+ its acceptance does not authorize or deliver it. Enforce grants/dedup/expiry in
71
+ the transport. Unknown or failed host status produces no executable local action.
72
+ A separately labelled copyable link for diagnosis is not an open action.
73
+
74
+ Add this check to the generated project's dashboard completion/notification
75
+ renderer before delivery. A raw-HTTP primary action with an available host is the
76
+ negative fixture; a valid native action is the positive one. Keep diagnostics
77
+ separate. This gate checks structured actions, not arbitrary prose: a CLI invocation
78
+ or instructions alone do not prove all final answers from every agent are filtered.
79
+
80
+ ## Degradation
81
+
82
+ - Claude Code, Codex and other hosts use this same procedure; no hook or plugin-only
83
+ path is required. Skills must be activated/loaded; install does not intercept every answer.
84
+ - MCP absent: report unresolved host capability once, use the project's approved
85
+ resolver/CLI if available. Do not write machine or agent MCP config automatically.
86
+ - Python absent: perform the comparisons above manually; mark the executable gate
87
+ NOT_RUN. Do not label the output mechanically validated.
88
+
89
+ Syntax is owned by Fabric Dashboards and `@passioncode-ai/fabric-service-host`:
90
+ [ADR-0005](https://github.com/passioncode-ai/fabric-dashboards/blob/51a7a80acf42781d7bec304cc87c655d48cc309b/docs/adr/0005-service-links.md).
91
+ The gate accepts canonical links and the legacy `open?service=` form returned by
92
+ older hosts; it does not generate either dialect or redefine the Fabric wire contract.
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """Check one structured dashboard handoff from stdin. No I/O effects; exit 0/1.
3
+
4
+ The caller supplies a trusted host resolver result and observed device/host context.
5
+ This is an output correctness gate, not authentication, discovery, or a relay.
6
+ """
7
+ import json
8
+ import re
9
+ import sys
10
+ from urllib.parse import parse_qs, urlsplit
11
+
12
+
13
+ def require(condition, reason):
14
+ if not condition:
15
+ raise ValueError(reason)
16
+
17
+
18
+ def safe_path(path):
19
+ return (isinstance(path, str) and path.startswith('/') and not path.startswith('//')
20
+ and len(path) <= 2048 and not re.search(r'[\\\x00-\x1f\x7f]', path))
21
+
22
+
23
+ def check(data):
24
+ require(isinstance(data, dict), 'invalid_input')
25
+ resolved, host, action = (data.get(k) for k in ('resolved', 'host', 'action'))
26
+ require(all(isinstance(v, dict) for v in (resolved, host, action)), 'missing_context')
27
+ service, native, http = (resolved.get(k) for k in ('service', 'open_link', 'http_url'))
28
+ require(isinstance(service, str) and re.fullmatch(r'[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*', service), 'invalid_service')
29
+ require(isinstance(native, str) and isinstance(http, str), 'missing_resolved_links')
30
+ require(not re.search(r'[\\\x00-\x20\x7f]', native + http), 'unsafe_url')
31
+ deep, plain = urlsplit(native), urlsplit(http)
32
+ require(deep.scheme == 'fabric-dashboards' and not deep.username and not deep.password and not deep.port and not deep.fragment, 'invalid_deep_link')
33
+ query = parse_qs(deep.query, keep_blank_values=True, strict_parsing=True)
34
+ if deep.netloc == 'service':
35
+ require(deep.path in ('/' + service, '/' + service + '/') and set(query) <= {'path'}, 'wrong_service_or_parameter')
36
+ else:
37
+ require(deep.netloc == 'open' and deep.path in ('', '/') and query.get('service') == [service] and set(query) <= {'service', 'path'}, 'invalid_legacy_link')
38
+ require('path' not in query or len(query['path']) == 1, 'duplicate_path')
39
+ page = query.get('path', [None])[0]
40
+ require(page is None or safe_path(page), 'unsafe_path')
41
+ require(plain.scheme == 'http' and plain.hostname in ('127.0.0.1', 'localhost', '::1') and plain.port is not None
42
+ and not plain.username and not plain.password, 'invalid_http_fallback')
43
+ http_page = plain.path + ('?' + plain.query if plain.query else '') + ('#' + plain.fragment if plain.fragment else '')
44
+ require(safe_path(http_page) and http_page == (page or '/'), 'mismatched_page')
45
+ sensitive = {'token', 'access_token', 'refresh_token', 'password', 'secret', 'login_code'}
46
+ page_query = parse_qs(urlsplit(http_page).query)
47
+ require(not sensitive.intersection(k.lower() for k in page_query), 'credential_query')
48
+ require(urlsplit(http_page).path != '/fabric/v1/login', 'login_link_is_not_dashboard')
49
+ state = host.get('state')
50
+ require(state in ('available', 'not_installed', 'unknown', 'incompatible', 'handler_mismatch', 'unsupported'), 'unknown_host_state')
51
+ target, viewer = data.get('target_device'), data.get('viewer_device')
52
+ require(all(isinstance(v, str) and 0 < len(v) <= 200 for v in (target, viewer)), 'missing_device')
53
+ fallback = data.get('fallback', 'if_absent')
54
+ require(fallback in ('if_absent', 'never'), 'invalid_fallback')
55
+ kind = action.get('kind')
56
+ if kind == 'remote_open':
57
+ require(set(action) <= {'kind', 'device_id', 'service', 'path'}, 'unexpected_action_field')
58
+ require(action.get('device_id') == target and action.get('service') == service and action.get('path') == page, 'wrong_remote_target')
59
+ require(viewer != target, 'remote_action_on_local_device')
60
+ elif kind == 'native_link':
61
+ require(set(action) == {'kind', 'url'} and action.get('url') == native, 'not_host_provided_link')
62
+ require(viewer == target, 'wrong_viewer_device')
63
+ require(state == 'available', 'host_unavailable')
64
+ elif kind == 'browser_link':
65
+ require(set(action) == {'kind', 'url'} and action.get('url') == http, 'not_resolved_http_url')
66
+ require(viewer == target, 'wrong_viewer_device')
67
+ require(state == 'not_installed' and fallback == 'if_absent', 'browser_fallback_forbidden')
68
+ else:
69
+ raise ValueError('unsupported_dashboard_action')
70
+ return {'ok': True, 'kind': kind}
71
+
72
+
73
+ def main():
74
+ try:
75
+ raw = sys.stdin.read(65537)
76
+ require(len(raw) <= 65536, 'input_too_large')
77
+ result = check(json.loads(raw))
78
+ except (ValueError, TypeError, KeyError, AttributeError):
79
+ # Fixed reasons only: never echo a submitted URL, token or JSON blob.
80
+ result = {'ok': False, 'reason': 'dashboard_handoff_refused'}
81
+ print(json.dumps(result))
82
+ return 0 if result['ok'] else 1
83
+
84
+
85
+ if __name__ == '__main__':
86
+ sys.exit(main())
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "passioncode",
4
4
  "displayName": "PassionCode.ai",
5
- "version": "0.1.16",
5
+ "version": "0.1.18",
6
6
  "description": "The organisation's working rules for every contributor's agent (working-in-passioncode), and a once-a-day check at session start that keeps the PassionCode.ai skill set current.",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",