@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 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.29.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.29.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.29.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.29.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 both kits write identical files.
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)
@@ -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` (Python); `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
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`: write + `plutil -lint`, read `launchctl print-disabled gui/<uid>`,
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**
@@ -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"
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</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.29.0"
8
+ version: "0.30.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
11
11
  ---
@@ -19,7 +19,7 @@ compatibility: >-
19
19
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
20
20
  metadata:
21
21
  author: PassionCode.ai
22
- version: "0.29.0"
22
+ version: "0.30.0"
23
23
  ---
24
24
 
25
25
  # Working with Fabric
@@ -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.29.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