@passioncode-ai/fabric-agent-adapter 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +26 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +182 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.30.0 - 2026-10-13
|
|
4
|
+
|
|
5
|
+
The Node service kit installs and uninstalls a launchd job, as the Python kit does (FAA-04). The probe fails a plist
|
|
6
|
+
whose copy would not know launchd supervises it (FAA-05).
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **launchd in the Node kit** (`fabric-service.mjs`, FAA-04): `launchdPlist`, `launchdLoaded`, `launchdOverride`,
|
|
11
|
+
`launchdInstall` and `launchdUninstall`, the twins of the Python `launchd_*` helpers.
|
|
12
|
+
- `launchdPlist` writes the same bytes as Python's `plistlib`.
|
|
13
|
+
- It writes `FABRIC_SERVICE_SUPERVISOR=launchd` itself and refuses a marker argument.
|
|
14
|
+
- It refuses a secret-named variable (a `*_FILE` path is fine), and any variable value or argument shaped like a
|
|
15
|
+
credential, a control character or an empty program.
|
|
16
|
+
- `launchdInstall` follows the LC-14 order: lint, then enable only on a first install. A label the operator
|
|
17
|
+
disabled stays off, with the plist rewritten. The bootout is waited out, the bootstrap retried five times, and
|
|
18
|
+
the install resolves once the well-known document answers with this `id.instance`.
|
|
19
|
+
- `launchdUninstall` waits for the unload, removes the plist and resets the override to `enabled`. `purge` (with
|
|
20
|
+
the service id) removes data, logs and cache; a symlinked folder loses only its link.
|
|
21
|
+
- Off macOS both are refused, with the name of that platform's installer. `run`, `fetchWellKnown` and `sleep` are
|
|
22
|
+
injectable, so tests never reach the real launchctl.
|
|
23
|
+
- **Probe rule `lifecycle.supervisor-marker`** (`check_service.py`, FAA-05). It reads the plist's
|
|
24
|
+
`EnvironmentVariables` and `ProgramArguments` the way the started copy reads them (`supervisor_marker`,
|
|
25
|
+
DEC-0057). It fails these cases:
|
|
26
|
+
- no marker;
|
|
27
|
+
- a marker naming another supervisor, or naming none;
|
|
28
|
+
- markers that disagree.
|
|
29
|
+
|
|
30
|
+
Without the marker, a copy that finds the lock held exits 75 at once, and KeepAlive restarts it every
|
|
31
|
+
`ThrottleInterval`. Plists written by `launchd_plist` since 0.8.0 (FAA-06) carry the marker; an older one fails
|
|
32
|
+
until it is regenerated and reinstalled.
|
|
33
|
+
|
|
34
|
+
### Evidence
|
|
35
|
+
|
|
36
|
+
- `test/fabric-service-launchd.test.mjs` (20 cases) runs against a launchd stand-in with the Python fake's
|
|
37
|
+
semantics. Its byte-identity case compares `launchdPlist` with `plistlib` output.
|
|
38
|
+
- Two Python cases in `test/test_fabric_service.py` check `marker_problem` and the rule run on a written plist.
|
|
39
|
+
- 16 planted defects were all caught, after replacing two equivalent mutants with real ones. Among them: enable on
|
|
40
|
+
every install, a skipped unload wait, a kept override, unsorted keys, an early bootstrap give-up, an ignored
|
|
41
|
+
instance, a dropped marker, a skipped lint, the legacy override format, an endless uninstall wait, and a probe
|
|
42
|
+
that accepts any supervisor or none.
|
|
43
|
+
|
|
3
44
|
## 0.29.0 - 2026-10-13
|
|
4
45
|
|
|
5
46
|
`working-with-fabric` carries the limit-stop rule (fabric-workspace principle 10, item 6, operator 2026-10-13).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "fabric-agent-adapter",
|
|
4
4
|
"displayName": "Fabric Agent Adapter",
|
|
5
5
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.30.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.30.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
|
|
11
11
|
---
|
|
@@ -17,7 +17,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
17
17
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
18
18
|
metadata:
|
|
19
19
|
author: PassionCode.ai
|
|
20
|
-
version: "0.
|
|
20
|
+
version: "0.30.0"
|
|
21
21
|
contract-version: "0.1.0"
|
|
22
22
|
extension: "fabric-service/0.1"
|
|
23
23
|
extension-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
|
|
@@ -203,7 +203,8 @@ The installer, not the service, owns the plist and the descriptor. Sequence:
|
|
|
203
203
|
|
|
204
204
|
On Linux the supervisor is a systemd user unit (`systemd_unit` + `systemd_install`), on
|
|
205
205
|
Windows a per-user Scheduled Task (`task_xml` + `task_install`); the Node kit has the same
|
|
206
|
-
writers (`systemdUnit`, `taskXml`, …) and
|
|
206
|
+
writers and installers (`launchdPlist` + `launchdInstall`, `systemdUnit`, `taskXml`, …) and
|
|
207
|
+
both kits write identical files.
|
|
207
208
|
|
|
208
209
|
Code runs from an immutable release directory; an upgrade writes a new release,
|
|
209
210
|
rewrites the plist and restarts. Read [the lifecycle reference](references/lifecycle.md)
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -12,7 +12,7 @@ wins and this page is the defect. The rule each section serves:
|
|
|
12
12
|
| LC-09 the footprint is declared | the descriptor and the plist, unit or task; the service's own `AGENTS.md` names its label (unit, task) and port | `write_descriptor`, `launchd_plist`, `systemd_unit` / `systemdUnit`, `task_xml` / `taskXml` |
|
|
13
13
|
| LC-11 previous release kept, older pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
|
|
14
14
|
| LC-12 every file is bounded | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
|
|
15
|
-
| LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install`, `launchd_uninstall`
|
|
15
|
+
| LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install` / `launchdInstall`, `launchd_uninstall` / `launchdUninstall`; `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
|
|
16
16
|
| LC-15 builds clean up after themselves | release pruning runs inside the installer, not later | `prune_releases` / `pruneReleases` |
|
|
17
17
|
|
|
18
18
|
## Contents
|
|
@@ -26,7 +26,7 @@ wins and this page is the defect. The rule each section serves:
|
|
|
26
26
|
- [Being managed by a host lifecycle broker](#being-managed-by-a-host-lifecycle-broker)
|
|
27
27
|
- [Linux and Windows](#linux-and-windows)
|
|
28
28
|
|
|
29
|
-
## launchd plist (generate it with `launchd_plist`)
|
|
29
|
+
## launchd plist (generate it with `launchd_plist` or `launchdPlist`; both kits write the same bytes)
|
|
30
30
|
|
|
31
31
|
| Key | Value | Why |
|
|
32
32
|
|---|---|---|
|
|
@@ -37,7 +37,7 @@ wins and this page is the defect. The rule each section serves:
|
|
|
37
37
|
| `ThrottleInterval` | `10` | bounds a crash loop |
|
|
38
38
|
| `ExitTimeOut` | above the drain and its hard exit (15 s default; `Drain` ends by 10 s) | SIGKILL arrives after it, so it must never come first |
|
|
39
39
|
| `ProcessType` | `Standard` — never `Background`, and no `Nice` > 0 or `LowPriorityIO` | a host probes the service and agents call it while the Mac is busy; a background job is starved for tens of seconds under load and every host reports an outage that never happened (the probe's `lifecycle.priority` rule fails it) |
|
|
40
|
-
| `EnvironmentVariables` | `PATH` set explicitly; `*_FILE` paths to secrets; `FABRIC_SERVICE_SUPERVISOR=launchd` | never a secret value — the plist is world-readable in backups; the supervisor marker is how the lock knows to back off |
|
|
40
|
+
| `EnvironmentVariables` | `PATH` set explicitly; `*_FILE` paths to secrets; `FABRIC_SERVICE_SUPERVISOR=launchd` | never a secret value — the plist is world-readable in backups; the supervisor marker is how the lock knows to back off (the probe's `lifecycle.supervisor-marker` rule fails a plist without it: a copy that finds the lock held would exit 75 at once into a respawn every `ThrottleInterval`) |
|
|
41
41
|
| `StandardOutPath`/`StandardErrorPath` | `~/Library/Logs/<id>/service.log` | one place the host tails |
|
|
42
42
|
|
|
43
43
|
## Install, upgrade, uninstall
|
|
@@ -45,14 +45,14 @@ wins and this page is the defect. The rule each section serves:
|
|
|
45
45
|
1. Build or unpack the release into `~/.local/share/<id>/releases/<version>-<sha12>/`
|
|
46
46
|
(a venv or `node_modules` inside it). Never edit a release after it is written.
|
|
47
47
|
2. `write_descriptor` (port and id claims are checked here).
|
|
48
|
-
3. `launchd_install
|
|
48
|
+
3. `launchd_install` (Node: `await launchdInstall(...)`, the same steps): write + `plutil -lint`, read `launchctl print-disabled gui/<uid>`,
|
|
49
49
|
`enable` **only when no override is recorded** (a first install), `bootout` and wait
|
|
50
50
|
until `launchctl print gui/<uid>/<label>` fails, `bootstrap` with up to five retries,
|
|
51
51
|
then poll the well-known document until it answers with this `id.instance`.
|
|
52
52
|
4. Upgrade = new release, rewrite the plist with the new path, `launchd_install`
|
|
53
53
|
again, then `prune_releases(releases_dir, current=<new release>)`: the running release
|
|
54
54
|
and the one before it stay for rollback, older ones go (LC-11, LC-15).
|
|
55
|
-
5. Uninstall = `launchd_uninstall` + `remove_descriptor`. Data stays; `purge=True`
|
|
55
|
+
5. Uninstall = `launchd_uninstall` (Node: `await launchdUninstall(...)`) + `remove_descriptor`. Data stays; `purge=True`
|
|
56
56
|
(with `service_id`) removes data, logs and cache when the operator asks.
|
|
57
57
|
|
|
58
58
|
**The operator's intent wins (LC-14).** A host stops a service with `bootout` **and**
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -75,6 +75,29 @@ def plist_problems(plist: dict, label: object, token: object = None) -> list:
|
|
|
75
75
|
return problems
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
def marker_problem(plist: dict) -> Optional[str]:
|
|
79
|
+
"""What is wrong with the job's supervisor marker (DEC-0057; FAA-05), read the way the copy it starts reads it.
|
|
80
|
+
|
|
81
|
+
Without ``FABRIC_SERVICE_SUPERVISOR=launchd`` (or ``--fabric-supervisor launchd``) a copy that finds the
|
|
82
|
+
lock held exits 75 at once, and KeepAlive starts it again every ThrottleInterval."""
|
|
83
|
+
env = plist.get("EnvironmentVariables")
|
|
84
|
+
env = {str(k): str(v) for k, v in env.items()} if isinstance(env, dict) else {}
|
|
85
|
+
argv = plist.get("ProgramArguments")
|
|
86
|
+
argv = [str(a) for a in argv] if isinstance(argv, list) else []
|
|
87
|
+
marker = fs.supervisor_marker(env, argv)
|
|
88
|
+
if marker["kind"] == "supervised" and marker["manager"] == "launchd":
|
|
89
|
+
return None
|
|
90
|
+
fix = ("a copy that finds the lock held exits 75 at once and KeepAlive respawns it every ThrottleInterval; "
|
|
91
|
+
"regenerate the plist with launchd_plist / launchdPlist and reinstall")
|
|
92
|
+
if marker["kind"] == "conflict":
|
|
93
|
+
return "%s — %s" % (fs.marker_conflict_message(marker), fix)
|
|
94
|
+
if marker["kind"] == "supervised":
|
|
95
|
+
return "the marker names %s, not launchd — %s" % (marker["manager"], fix)
|
|
96
|
+
if marker.get("unreadable") is not None:
|
|
97
|
+
return "the marker %r names no supervisor — %s" % (marker["unreadable"], fix)
|
|
98
|
+
return "no %s=launchd — %s" % (fs.SUPERVISOR_ENV, fix)
|
|
99
|
+
|
|
100
|
+
|
|
78
101
|
def priority_problems(plist: dict) -> list:
|
|
79
102
|
"""A service that answers hosts and agents must not be scheduled as background work."""
|
|
80
103
|
out = []
|
|
@@ -701,6 +724,9 @@ class Probe:
|
|
|
701
724
|
return
|
|
702
725
|
problems = plist_problems(plist, life.get("label"), self.token)
|
|
703
726
|
self.add("lifecycle.plist", "FAIL" if problems else "PASS", "; ".join(problems) or "%s: RunAtLoad, KeepAlive, no secrets" % plist_path.name)
|
|
727
|
+
marker = marker_problem(plist)
|
|
728
|
+
self.add("lifecycle.supervisor-marker", "FAIL" if marker else "PASS",
|
|
729
|
+
marker or "%s: %s=launchd, so a copy that finds the lock held backs off" % (plist_path.name, fs.SUPERVISOR_ENV))
|
|
704
730
|
slow = priority_problems(plist)
|
|
705
731
|
self.add("lifecycle.priority", "FAIL" if slow else "PASS",
|
|
706
732
|
"; ".join(slow) + " — macOS may starve the service under load and hosts then see an outage; use ProcessType Standard"
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs
CHANGED
|
@@ -2328,3 +2328,185 @@ export function taskUninstall(task, { run = defaultRun, platform = process.platf
|
|
|
2328
2328
|
run(['schtasks', '/End', '/TN', task]);
|
|
2329
2329
|
check(run(['schtasks', '/Delete', '/TN', task, '/F']), 'schtasks /Delete');
|
|
2330
2330
|
}
|
|
2331
|
+
|
|
2332
|
+
// --- launchd (macOS; lifecycle.md "Install, upgrade, uninstall", LC-14; FAA-04) ------------------------------------
|
|
2333
|
+
// The twins of fabric_service.launchd_plist / launchd_loaded / launchd_override / launchd_install / launchd_uninstall.
|
|
2334
|
+
// `run(argv)` returns spawnSync's {status, stdout, stderr}; the tests pass a launchd stand-in, never the real launchctl.
|
|
2335
|
+
|
|
2336
|
+
const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,250}$/;
|
|
2337
|
+
const DEFAULT_PATH = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
|
|
2338
|
+
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2339
|
+
|
|
2340
|
+
function launchdLabel(label) {
|
|
2341
|
+
if (!LABEL.test(String(label))) throw new ServiceError(`label must match ${LABEL.source}`);
|
|
2342
|
+
return String(label);
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
function plistValue(value, depth) {
|
|
2346
|
+
// plistlib.dumps's layout, byte for byte: one tab per level, keys sorted, & < > escaped, empty containers self-closed.
|
|
2347
|
+
const pad = '\t'.repeat(depth);
|
|
2348
|
+
if (value === true || value === false) return `${pad}<${value}/>\n`;
|
|
2349
|
+
if (Number.isInteger(value)) return `${pad}<integer>${value}</integer>\n`;
|
|
2350
|
+
if (typeof value === 'string') return `${pad}<string>${plain(value, 'A plist string').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</string>\n`;
|
|
2351
|
+
if (Array.isArray(value)) return value.length ? `${pad}<array>\n${value.map((v) => plistValue(v, depth + 1)).join('')}${pad}</array>\n` : `${pad}<array/>\n`;
|
|
2352
|
+
const keys = Object.keys(value).sort();
|
|
2353
|
+
if (!keys.length) return `${pad}<dict/>\n`;
|
|
2354
|
+
const inner = keys.map((k) => `${pad}\t<key>${plain(k, 'A plist key').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</key>\n${plistValue(value[k], depth + 1)}`).join('');
|
|
2355
|
+
return `${pad}<dict>\n${inner}${pad}</dict>\n`;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
/**
|
|
2359
|
+
* The launchd job: RunAtLoad + KeepAlive true + ThrottleInterval 10, ProcessType Standard, ExitTimeOut above the drain.
|
|
2360
|
+
* FABRIC_SERVICE_SUPERVISOR=launchd tells the lock to back off instead of exiting into a respawn loop (DEC-0057).
|
|
2361
|
+
* No secret is written: an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or an
|
|
2362
|
+
* argument shaped like a credential is refused. Returns the XML text, the same bytes Python's plistlib writes.
|
|
2363
|
+
*/
|
|
2364
|
+
export function launchdPlist(label, programArguments, { workingDirectory, stdoutPath, environment = {}, exitTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
|
|
2365
|
+
launchdLabel(label);
|
|
2366
|
+
const argv = programArguments.map(String);
|
|
2367
|
+
if (!argv.length) throw new ServiceError('A launchd job starts a program; ProgramArguments is empty.');
|
|
2368
|
+
if (!(exitTimeout > DRAIN_SECONDS)) throw new ServiceError(`ExitTimeOut must outlast the ${DRAIN_SECONDS} s drain.`);
|
|
2369
|
+
if (workingDirectory === undefined || stdoutPath === undefined) throw new ServiceError('A launchd job names its working directory and its log file.');
|
|
2370
|
+
const env = { ...environment };
|
|
2371
|
+
for (const [name, value] of Object.entries(env)) {
|
|
2372
|
+
if (/(TOKEN|SECRET|PASSWORD|KEY)$/.test(name) && !name.endsWith('_FILE')) throw new ServiceError(`Environment variable ${name} looks like a secret; pass a *_FILE path instead.`);
|
|
2373
|
+
if (secretValue(String(value))) throw new ServiceError(`Environment ${name} holds a value shaped like a credential; a plist never carries one.`);
|
|
2374
|
+
}
|
|
2375
|
+
for (const arg of argv) {
|
|
2376
|
+
if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a plist never does (read it from the token file or a secret reference).');
|
|
2377
|
+
}
|
|
2378
|
+
noMarkerArgument(argv);
|
|
2379
|
+
if (env.PATH === undefined) env.PATH = DEFAULT_PATH;
|
|
2380
|
+
env[SUPERVISOR_ENV] = 'launchd'; // the plist's writer writes the marker, whatever the caller passed
|
|
2381
|
+
const job = {
|
|
2382
|
+
Label: label,
|
|
2383
|
+
ProgramArguments: argv,
|
|
2384
|
+
WorkingDirectory: String(workingDirectory),
|
|
2385
|
+
RunAtLoad: true,
|
|
2386
|
+
KeepAlive: true,
|
|
2387
|
+
ThrottleInterval: 10,
|
|
2388
|
+
ExitTimeOut: Math.trunc(exitTimeout),
|
|
2389
|
+
// Standard, never Background: macOS starves a Background job under load and every host then reports an outage.
|
|
2390
|
+
ProcessType: 'Standard',
|
|
2391
|
+
StandardOutPath: String(stdoutPath),
|
|
2392
|
+
StandardErrorPath: String(stdoutPath),
|
|
2393
|
+
EnvironmentVariables: env,
|
|
2394
|
+
};
|
|
2395
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
2396
|
+
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
2397
|
+
+ `<plist version="1.0">\n${plistValue(job, 0)}</plist>\n`;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
function launchdDomain(platform, uid) {
|
|
2401
|
+
// DEC-0032: launchd is macOS's supervisor; Linux has systemd and Windows Task Scheduler.
|
|
2402
|
+
if (platform !== 'darwin') throw new ServiceError('launchd is the supervisor on macOS only; use systemdInstall on Linux and taskInstall on Windows.');
|
|
2403
|
+
return `gui/${uid ?? process.getuid()}`;
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/** True when launchd has the job loaded (`launchctl print gui/<uid>/<label>` answers). */
|
|
2407
|
+
export function launchdLoaded(label, { run = defaultRun, platform = process.platform, uid } = {}) {
|
|
2408
|
+
return run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`])?.status === 0;
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
const OVERRIDE_ROW = /^\s*"([^"]+)"\s*=>\s*(\S+)\s*$/;
|
|
2412
|
+
|
|
2413
|
+
/**
|
|
2414
|
+
* The operator's launchd override for `label`: 'disabled', 'enabled', null when none is recorded (a first install), or
|
|
2415
|
+
* 'unknown' when the table cannot be read. macOS 14+ prints `=> disabled|enabled`, older releases `=> true|false`.
|
|
2416
|
+
*/
|
|
2417
|
+
export function launchdOverride(label, { run = defaultRun, platform = process.platform, uid } = {}) {
|
|
2418
|
+
const result = run(['launchctl', 'print-disabled', launchdDomain(platform, uid)]);
|
|
2419
|
+
if (result?.status !== 0) return 'unknown';
|
|
2420
|
+
for (const line of String(result.stdout ?? '').split('\n')) {
|
|
2421
|
+
const match = OVERRIDE_ROW.exec(line);
|
|
2422
|
+
if (match && match[1] === label) return ['disabled', 'true'].includes(match[2]) ? 'disabled' : 'enabled';
|
|
2423
|
+
}
|
|
2424
|
+
return null;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
async function wellKnownOf(origin) {
|
|
2428
|
+
try {
|
|
2429
|
+
const response = await fetch(`${origin}/.well-known/fabric-service`, { signal: AbortSignal.timeout(2000) });
|
|
2430
|
+
return response.ok ? await response.json() : null;
|
|
2431
|
+
} catch {
|
|
2432
|
+
return null;
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/**
|
|
2437
|
+
* Write and lint the plist, bootout and WAIT for the unload, bootstrap (retrying launchd's EIO 5 times), then poll the
|
|
2438
|
+
* well-known document until it answers with this identity; resolves to that document.
|
|
2439
|
+
*
|
|
2440
|
+
* The operator's intent wins (LC-14): only a first install (no override recorded) enables the label. A label the
|
|
2441
|
+
* operator disabled stays disabled and unloaded — the plist is still rewritten, so the new release is what starts once
|
|
2442
|
+
* they turn it back on — and the answer is {disabled: true, label, plist, loaded}. `forceEnable` is for an operator
|
|
2443
|
+
* who asked for the service to be switched on.
|
|
2444
|
+
*/
|
|
2445
|
+
export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
|
|
2446
|
+
run = defaultRun, platform = process.platform, uid, fetchWellKnown = wellKnownOf, sleep = pause } = {}) {
|
|
2447
|
+
const domain = launchdDomain(platform, uid);
|
|
2448
|
+
launchdLabel(label);
|
|
2449
|
+
if (!origin || !serviceId) throw new ServiceError('launchdInstall needs the origin and the service id it waits to see answer.');
|
|
2450
|
+
const opts = { run, platform, uid };
|
|
2451
|
+
atomicWrite(plistPath, plistText, 0o644);
|
|
2452
|
+
const lint = run(['plutil', '-lint', String(plistPath)]);
|
|
2453
|
+
if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
|
|
2454
|
+
const target = `${domain}/${label}`;
|
|
2455
|
+
const override = launchdOverride(label, opts);
|
|
2456
|
+
if (forceEnable || override === null) run(['launchctl', 'enable', target]);
|
|
2457
|
+
else if (override === 'disabled') return { disabled: true, label, plist: String(plistPath), loaded: launchdLoaded(label, opts) };
|
|
2458
|
+
run(['launchctl', 'bootout', target]);
|
|
2459
|
+
const unloaded = Date.now() + 15_000;
|
|
2460
|
+
while (launchdLoaded(label, opts) && Date.now() < unloaded) await sleep(250);
|
|
2461
|
+
let last = '';
|
|
2462
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
2463
|
+
const result = run(['launchctl', 'bootstrap', domain, String(plistPath)]);
|
|
2464
|
+
if (result?.status === 0) break;
|
|
2465
|
+
last = String(result?.stderr || result?.stdout || '').trim();
|
|
2466
|
+
if (attempt === 4) throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
|
|
2467
|
+
await sleep(1000);
|
|
2468
|
+
}
|
|
2469
|
+
const deadline = Date.now() + timeout * 1000;
|
|
2470
|
+
while (Date.now() < deadline) {
|
|
2471
|
+
const doc = await fetchWellKnown(origin);
|
|
2472
|
+
if (doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
|
|
2473
|
+
await sleep(500);
|
|
2474
|
+
}
|
|
2475
|
+
throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
/**
|
|
2479
|
+
* Symmetric with launchdInstall (LC-14): bootout and WAIT until the job is gone, remove the plist, and reset the
|
|
2480
|
+
* override to enabled — launchctl has no verb that deletes an override, and a leftover `disabled` would keep a later
|
|
2481
|
+
* fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go). A job still
|
|
2482
|
+
* loaded at `timeout` seconds throws, leaving the plist and the data in place.
|
|
2483
|
+
*/
|
|
2484
|
+
export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
|
|
2485
|
+
const domain = launchdDomain(platform, uid);
|
|
2486
|
+
launchdLabel(label);
|
|
2487
|
+
if (purge && !serviceId) throw new ServiceError('purge needs the service id whose data, logs and cache to remove.');
|
|
2488
|
+
const dirs = purge ? serviceDirs(serviceId, platform) : {};
|
|
2489
|
+
const opts = { run, platform, uid };
|
|
2490
|
+
const target = `${domain}/${label}`;
|
|
2491
|
+
run(['launchctl', 'bootout', target]);
|
|
2492
|
+
const deadline = Date.now() + timeout * 1000;
|
|
2493
|
+
while (launchdLoaded(label, opts)) {
|
|
2494
|
+
if (Date.now() >= deadline) throw new ServiceError(`launchd job ${label} is still loaded ${timeout} s after bootout; nothing was removed.`);
|
|
2495
|
+
await sleep(100);
|
|
2496
|
+
}
|
|
2497
|
+
let plistRemoved = true;
|
|
2498
|
+
try {
|
|
2499
|
+
fs.unlinkSync(plistPath);
|
|
2500
|
+
} catch (error) {
|
|
2501
|
+
if (error.code !== 'ENOENT') throw error;
|
|
2502
|
+
plistRemoved = false;
|
|
2503
|
+
}
|
|
2504
|
+
run(['launchctl', 'enable', target]);
|
|
2505
|
+
const purged = [];
|
|
2506
|
+
for (const dir of Object.values(dirs)) {
|
|
2507
|
+
try { fs.lstatSync(dir); } catch { continue; }
|
|
2508
|
+
fs.rmSync(dir, { recursive: true, force: true }); // a symlinked folder: the link goes, never what it points at
|
|
2509
|
+
purged.push(dir);
|
|
2510
|
+
}
|
|
2511
|
+
return { unloaded: true, plistRemoved, override: 'enabled', purged };
|
|
2512
|
+
}
|
|
@@ -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.
|
|
8
|
+
version: "0.30.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
|
|
11
11
|
---
|
|
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
|
|
|
60
60
|
SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
|
|
61
61
|
LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
|
|
62
62
|
LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
|
|
63
|
-
VERSION = "0.
|
|
63
|
+
VERSION = "0.30.0"
|
|
64
64
|
HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
|
|
65
65
|
# A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
|
|
66
66
|
# what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value
|