@agfpd/iapeer 0.4.20 → 0.4.21

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
@@ -10,6 +10,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.21] - 2026-06-22
14
+
15
+ ### Fixed
16
+
17
+ - **`iapeer remove` of an always-on peer no longer leaves an orphan launchd plist.** An
18
+ infra peer (notifier/telegram/voicetalk) has a `com.iapeer.<p>` plist with KeepAlive;
19
+ `remove` dropped only the registry record, so launchd kept relaunching `iapeer run-infra
20
+ <p> <rt>` against the now-deleted record → crash-loop (the SRE peer alerted
21
+ "gone-without-disable"). `remove` now boots out + removes the **foundation-owned** plist
22
+ BEFORE the registry remove (closing the crash-loop window), and the **H4 fleet-guard**
23
+ refuses a FOREIGN persistent-peer plist (`--force` drops only the registry record and
24
+ leaves the foreign plist intact). A warm peer (no plist) is unaffected.
25
+
13
26
  ## [0.4.20] - 2026-06-22
14
27
 
15
28
  ### Fixed
@@ -312,7 +325,8 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
312
325
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
313
326
  (timer = cron, watcher = event) are all first-class and addressed the same way.
314
327
 
315
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.20...HEAD
328
+ [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.21...HEAD
329
+ [0.4.21]: https://github.com/agfpd/iapeer/compare/v0.4.20...v0.4.21
316
330
  [0.4.20]: https://github.com/agfpd/iapeer/compare/v0.4.19...v0.4.20
317
331
  [0.4.19]: https://github.com/agfpd/iapeer/compare/v0.4.18...v0.4.19
318
332
  [0.4.18]: https://github.com/agfpd/iapeer/compare/v0.4.17...v0.4.18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.20",
3
+ "version": "0.4.21",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // sentinel-marked always-on plist) are stop/start-able.
12
12
 
13
13
  import { spawnSync } from 'child_process'
14
- import { existsSync, readFileSync, renameSync } from 'fs'
14
+ import { existsSync, readFileSync, renameSync, rmSync } from 'fs'
15
15
  import { fileURLToPath } from 'url'
16
16
  import { dirname, join } from 'path'
17
17
  import {
@@ -693,8 +693,13 @@ export async function compactPeer(
693
693
 
694
694
  export interface RemoveOutcome {
695
695
  personality: string
696
- action: 'removed' | 'absent' | 'refused-live'
696
+ action: 'removed' | 'absent' | 'refused-live' | 'refused-foreign-launchd'
697
697
  reason?: string
698
+ /** Always-on plist teardown outcome (`bootout + plist removed` / `skipped-sandbox` /
699
+ * `skipped-foreign …`). Present only when the peer had a com.iapeer.<p> plist — an
700
+ * always-on (infra) peer. Without this, remove orphaned the loaded plist → launchd
701
+ * KeepAlive crash-looped `run-infra` against the deleted record. */
702
+ plistTeardown?: string
698
703
  /** The removed peer's cwd (registry fact, captured BEFORE the removal). remove
699
704
  * deliberately keeps the folder — user data is never deleted by a registry reap
700
705
  * (say so in the output instead of leaving silent orphans). */
@@ -744,6 +749,47 @@ export async function removePeerCli(
744
749
  }
745
750
  }
746
751
  }
752
+ // PLIST TEARDOWN (the always-on / launchd-managed peer). An infra peer
753
+ // (notifier/telegram/voicetalk) has a com.iapeer.<p> plist with KeepAlive. Removing
754
+ // ONLY the registry record leaves the plist LOADED → launchd keeps relaunching
755
+ // `iapeer run-infra <p> <rt>` against the now-deleted record → CRASH-LOOP (doc alerts
756
+ // "gone-without-disable"). So bootout the job BEFORE the registry remove (stop KeepAlive
757
+ // before its target vanishes — no crash-loop window), then rm the plist file.
758
+ // FLEET GUARD (H4): a FOREIGN persistent-peer plist is off-limits — refuse (unless
759
+ // --force, which then drops ONLY the registry record and leaves the foreign plist intact).
760
+ let plistTeardown: string | undefined
761
+ if (isLaunchdManaged(personality, env)) {
762
+ if (isForeignLaunchd(personality, env)) {
763
+ if (!opts.force) {
764
+ return {
765
+ personality,
766
+ action: 'refused-foreign-launchd',
767
+ reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation will not remove it (H4). Use persistent-peer tooling, or --force to drop ONLY the registry record (the foreign plist is left intact).`,
768
+ }
769
+ }
770
+ plistTeardown = 'skipped-foreign (H4 — foreign plist left intact; --force dropped only the registry record)'
771
+ } else {
772
+ const plistPath = launchdPlistPath(personality, env)
773
+ // Sandbox/test: never invoke real launchctl (a test peer's label isn't a real job);
774
+ // still remove the (temp-dir) plist file so the teardown is observable.
775
+ let bootoutNote: string
776
+ if (env.IAPEER_TEST_SANDBOX === '1') {
777
+ bootoutNote = 'skipped-sandbox'
778
+ } else {
779
+ const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
780
+ // bootout exits non-zero when the job was already unloaded — benign for a teardown.
781
+ bootoutNote = r.status === 0 ? 'bootout' : `bootout (exit ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''})`
782
+ }
783
+ let rmNote: string
784
+ try {
785
+ rmSync(plistPath, { force: true })
786
+ rmNote = ' + plist removed'
787
+ } catch (e) {
788
+ rmNote = ` (plist rm FAILED: ${e instanceof Error ? e.message : String(e)} — remove ${plistPath} manually)`
789
+ }
790
+ plistTeardown = `${bootoutNote}${rmNote}`
791
+ }
792
+ }
747
793
  await removePeer(personality, { env })
748
794
  // v1.2 UNPROVISION joint (контракт §Provision провайдера): a provision-declaring
749
795
  // slot gets its unprovision command per agentic runtime with occasion=remove —
@@ -822,6 +868,7 @@ export async function removePeerCli(
822
868
  action: 'removed',
823
869
  cwd: peer.cwd,
824
870
  purgedState,
871
+ ...(plistTeardown ? { plistTeardown } : {}),
825
872
  ...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
826
873
  ...(trustCleaned ? { codexTrust: trustCleaned } : {}),
827
874
  ...(hooksTrustCleaned ? { codexHooksTrust: hooksTrustCleaned } : {}),
@@ -1806,6 +1853,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1806
1853
  const o = await removePeerCli(positionals[0], { force: flags.force === true, env })
1807
1854
  if (o.action === 'removed') {
1808
1855
  out(`removed "${o.personality}" from the registry\n`)
1856
+ // Always-on plist teardown (bootout + rm) — an orphan loaded plist would
1857
+ // KeepAlive-crash-loop run-infra against the deleted record.
1858
+ if (o.plistTeardown) {
1859
+ out(`launchd plist: ${o.plistTeardown}\n`)
1860
+ }
1809
1861
  // v1.2: the provider unwound its surfaces (occasion=remove) — say how it went.
1810
1862
  if (o.unprovision?.length) {
1811
1863
  out(`memory unprovision: ${o.unprovision.join(', ')}\n`)
@@ -1830,7 +1882,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1830
1882
  }
1831
1883
  } else if (o.action === 'absent') out(`"${o.personality}" not registered — no-op\n`)
1832
1884
  else errOut(`remove: ${o.reason}\n`)
1833
- return o.action === 'refused-live' ? 1 : 0
1885
+ return o.action === 'refused-live' || o.action === 'refused-foreign-launchd' ? 1 : 0
1834
1886
  }
1835
1887
  case 'rename': {
1836
1888
  // FULL folder rename (Arthur's invariant: personality == basename(cwd)): mv the