@agfpd/iapeer 0.4.87 → 0.4.88

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.
@@ -43,6 +43,8 @@ These are long-lived router processes, not AI. They run continuously under launc
43
43
 
44
44
  Infrastructure peers' plists, unlike the daemon's plist, are **loaded automatically** on `iapeer create --runtime telegram|notifier` and on `iapeer onboard` (unless `--no-bootstrap` is passed). So no separate `launchctl bootstrap` is needed for them.
45
45
 
46
+ **Plist scheme (multi-infra).** One personality can carry several always-on channels (e.g. a human peer with both a Telegram bridge and a web console). Each (personality, runtime) pair resolves to its own launchd label: a **legacy base plist** `com.iapeer.<personality>` stays the channel of the runtime it already holds (no forced migration — existing single-channel hosts keep their plists untouched), while every new install gets the **per-runtime plist** `com.iapeer.<personality>.<runtime>`. Both channels are held by launchd KeepAlive simultaneously and independently; `stop`/`start <peer> <runtime>` bootout/bootstrap exactly that runtime's label, `remove` tears down **all** the personality's plists. The suffixed label can't collide with anything: peer names can't contain dots.
47
+
46
48
  ## Several runtimes on a peer
47
49
 
48
50
  A peer profile has two runtime fields: `runtimes` (all the peer can be present on) and `default_runtime` (the primary — where messages route and which comes up first). Extending the options and switching the primary runtime are different commands.
@@ -43,6 +43,8 @@ iapeer codex # в Codex
43
43
 
44
44
  Плисты инфраструктурных пиров, в отличие от плиста демона, **загружаются автоматически** при `iapeer create --runtime telegram|notifier` и при `iapeer onboard` (если не передан `--no-bootstrap`). Поэтому отдельный `launchctl bootstrap` для них не нужен.
45
45
 
46
+ **Схема плистов (мульти-инфра).** Одна personality может нести несколько always-on каналов (например, человек с Telegram-мостом и веб-консолью одновременно). Каждая пара (personality, runtime) резолвится в собственный launchd-label: **легаси базовый плист** `com.iapeer.<personality>` остаётся каналом того рантайма, который он уже держит (без принудительной миграции — существующие одноканальные хосты не трогаются), а каждая новая установка получает **per-runtime плист** `com.iapeer.<personality>.<runtime>`. Оба канала держатся launchd KeepAlive одновременно и независимо; `stop`/`start <peer> <runtime>` делают bootout/bootstrap ровно label этого рантайма, `remove` сносит **все** плисты personality. Суффиксный label ни с чем не коллидирует: имена пиров не могут содержать точек.
47
+
46
48
  ## Несколько рантаймов у пира
47
49
 
48
50
  В профиле пира два поля про рантаймы: `runtimes` (все, на которых пир может присутствовать) и `default_runtime` (основной — куда маршрутизируются сообщения и какой поднимается первым). Расширить возможности и переключить основной рантайм — разные команды.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.87",
3
+ "version": "0.4.88",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
package/src/cli/index.ts CHANGED
@@ -52,7 +52,12 @@ import {
52
52
  import { getAdapter } from '../launch/index.ts'
53
53
  import { hostRunDir } from '../launch/ptyHost.ts' // pty-only: attach via the supervisor client
54
54
  import { runSupervisorClient } from '../supervisor/client.ts' // @xterm-free attach client (both reattach port-deps baked in)
55
- import { cycleDaemon, isFoundationOwnedPlist, launchctlBootstrap, launchdLabel, launchdPlistPath } from '../launch/launchd.ts'
55
+ import {
56
+ cycleDaemon,
57
+ existingAlwaysOnPlists,
58
+ launchctlBootstrap,
59
+ resolveAlwaysOnTarget,
60
+ } from '../launch/launchd.ts'
56
61
  import { readPeerProfile, renamePeer, resolveCallerIdentity, resolveIdentity, writePeerProfileAtomic } from '../identity/index.ts'
57
62
  import { claudeProjectsRoot, transcriptSlug } from '../launch/adapters/claude.ts'
58
63
  import { peerProfilePath } from '../storage/index.ts'
@@ -206,9 +211,13 @@ function uid(): string {
206
211
  }
207
212
 
208
213
  /** FLEET GUARD: a peer launchd-managed by a NON-foundation plist (persistent-peer)
209
- * is off-limits to stop/start — the foundation is read-only for it (H4). */
214
+ * is off-limits to stop/start — the foundation is read-only for it (H4). Multi-infra
215
+ * (0.4.88): the peer is foreign iff ANY of its existing template plists (legacy base
216
+ * OR per-runtime suffixed) lacks the ownership sentinel. A personality whose only
217
+ * plists are our suffixed ones must NOT read as foreign (the pre-multi-infra check
218
+ * keyed on the base path alone, which is absent in that layout). */
210
219
  function isForeignLaunchd(personality: string, env: NodeJS.ProcessEnv): boolean {
211
- return isLaunchdManaged(personality, env) && !isFoundationOwnedPlist(launchdPlistPath(personality, env))
220
+ return existingAlwaysOnPlists(personality, env).some(p => !p.foundationOwned)
212
221
  }
213
222
 
214
223
  function targetRuntimes(peer: PeerRecord, runtime: string | undefined): Runtime[] {
@@ -249,7 +258,13 @@ export function stopPeer(personality: string, runtime: string | undefined, opts:
249
258
  // stop: the desired state ("not running") already holds, so it carries NO reason
250
259
  // and must not fail the verb. Any other non-zero is a REAL error and sets reason
251
260
  // (the CLI maps a reasoned bootout to exit 1).
252
- const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
261
+ // Multi-infra: bootout the label that ACTUALLY holds this (personality, runtime)
262
+ // channel — the legacy base or the per-runtime suffixed one (resolveAlwaysOnTarget,
263
+ // the same authority install uses). A runtime with no plist at all (manually-run
264
+ // session) resolves to its suffixed label → bootout exit 3 (benign) → killSession
265
+ // below still tears the session down.
266
+ const target = resolveAlwaysOnTarget(personality, rt, env)
267
+ const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${target.label}`], { encoding: 'utf8' })
253
268
  killSession(sock, identity, env)
254
269
  const stderrText = (r.stderr ?? '').trim()
255
270
  const benign = r.status === 0 || r.status === 3 || /No such process/i.test(stderrText)
@@ -295,7 +310,9 @@ export function startPeer(personality: string, runtime: string | undefined, opts
295
310
  for (const rt of targetRuntimes(peer, runtime)) {
296
311
  const identity = buildProcessAddress(rt, personality)
297
312
  if (isInfraRuntime(rt)) {
298
- const plist = launchdPlistPath(personality, env)
313
+ // Multi-infra: load the plist that holds THIS (personality, runtime) channel
314
+ // (legacy base or per-runtime suffixed — the same resolution install/stop use).
315
+ const plist = resolveAlwaysOnTarget(personality, rt, env).path
299
316
  // UNDEAD-JOB-SAFE start: a bootstrap right after a bootout used to hit the
300
317
  // still-dismantling job (exit 5 I/O
301
318
  // error) and leave the router DOWN. launchctlBootstrap now waits for the
@@ -552,7 +569,7 @@ export async function newPeer(
552
569
  personality,
553
570
  runtime: rt,
554
571
  action: 'refused-infra',
555
- reason: `infra runtime "${rt}" is launchd-held — restart it with: launchctl kickstart -k gui/$(id -u)/${launchdLabel(personality)}`,
572
+ reason: `infra runtime "${rt}" is launchd-held — restart it with: launchctl kickstart -k gui/$(id -u)/${resolveAlwaysOnTarget(personality, rt, env).label}`,
556
573
  }
557
574
  }
558
575
  const identity = buildProcessAddress(rt, personality)
@@ -785,25 +802,33 @@ export async function removePeerCli(
785
802
  }
786
803
  plistTeardown = 'skipped-foreign (H4 — foreign plist left intact; --force dropped only the registry record)'
787
804
  } else {
788
- const plistPath = launchdPlistPath(personality, env)
789
- // Sandbox/test: never invoke real launchctl (a test peer's label isn't a real job);
790
- // still remove the (temp-dir) plist file so the teardown is observable.
791
- let bootoutNote: string
792
- if (env.IAPEER_TEST_SANDBOX === '1') {
793
- bootoutNote = 'skipped-sandbox'
794
- } else {
795
- const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
796
- // bootout exits non-zero when the job was already unloaded — benign for a teardown.
797
- bootoutNote = r.status === 0 ? 'bootout' : `bootout (exit ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''})`
798
- }
799
- let rmNote: string
800
- try {
801
- rmSync(plistPath, { force: true })
802
- rmNote = ' + plist removed'
803
- } catch (e) {
804
- rmNote = ` (plist rm FAILED: ${e instanceof Error ? e.message : String(e)} — remove ${plistPath} manually)`
805
+ // Multi-infra (0.4.88): tear down EVERY foundation plist of the personality —
806
+ // the legacy base AND each per-runtime suffixed one (a two-channel peer like
807
+ // arthur telegram+web holds two). A forgotten plist would leave launchd
808
+ // KeepAlive crash-looping run-infra against the deleted record (the exact
809
+ // "remove must dismantle ALL artifacts" class).
810
+ const notes: string[] = []
811
+ for (const entry of existingAlwaysOnPlists(personality, env)) {
812
+ // Sandbox/test: never invoke real launchctl (a test peer's label isn't a real
813
+ // job); still remove the (temp-dir) plist file so the teardown is observable.
814
+ let bootoutNote: string
815
+ if (env.IAPEER_TEST_SANDBOX === '1') {
816
+ bootoutNote = 'skipped-sandbox'
817
+ } else {
818
+ const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${entry.label}`], { encoding: 'utf8' })
819
+ // bootout exits non-zero when the job was already unloaded — benign for a teardown.
820
+ bootoutNote = r.status === 0 ? 'bootout' : `bootout (exit ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''})`
821
+ }
822
+ let rmNote: string
823
+ try {
824
+ rmSync(entry.path, { force: true })
825
+ rmNote = ' + plist removed'
826
+ } catch (e) {
827
+ rmNote = ` (plist rm FAILED: ${e instanceof Error ? e.message : String(e)} — remove ${entry.path} manually)`
828
+ }
829
+ notes.push(`${entry.label}: ${bootoutNote}${rmNote}`)
805
830
  }
806
- plistTeardown = `${bootoutNote}${rmNote}`
831
+ plistTeardown = notes.join('; ')
807
832
  }
808
833
  }
809
834
  await removePeer(personality, { env })
@@ -96,6 +96,12 @@ export function isIntelligenceAllowedForRuntime(runtime: string, intelligence: I
96
96
  // This set gates always-on launchd plist generation (src/launch/launchd.ts).
97
97
  const INFRA_RUNTIMES = new Set(['notifier', 'telegram', 'voicetalk', 'web'])
98
98
 
99
+ /** The infra runtimes as a list — for callers that must ENUMERATE the always-on
100
+ * plist candidates of a personality (H4 detector, remove teardown, update
101
+ * re-cycle walk the per-runtime plist scheme `com.iapeer.<p>.<rt>.plist`).
102
+ * Same membership as isInfraRuntime — single source, they cannot drift. */
103
+ export const INFRA_RUNTIME_LIST: readonly string[] = [...INFRA_RUNTIMES]
104
+
99
105
  export function isInfraRuntime(runtime: string): boolean {
100
106
  return INFRA_RUNTIMES.has(runtime)
101
107
  }
@@ -26,7 +26,7 @@ import {
26
26
  // KeepAlive, so creating one installs its always-on plist. Imported from the
27
27
  // launchd module directly (not the launch barrel) to keep the dependency surface
28
28
  // minimal — launchd.ts pulls only core/*, so identity → launch introduces no cycle.
29
- import { installAlwaysOnPlist, installedPlistRuntime } from '../launch/launchd.ts'
29
+ import { installAlwaysOnPlist } from '../launch/launchd.ts'
30
30
  import { buildProcessAddress } from '../core/socket.ts'
31
31
  import { IapError } from '../core/errors.ts'
32
32
  import {
@@ -623,30 +623,19 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
623
623
  // and leaves no half-updated profile. Idempotent for our own (sentinel) plist.
624
624
  let intelligence = existing.intelligence
625
625
  if (isInfraRuntime(options.runtime)) {
626
- // MULTI-INFRA BRIDGE: the always-on plist scheme is ONE per personality, so when the
627
- // personality's plist is already OCCUPIED by ANOTHER infra runtime (arthur: telegram,
628
- // now declaring web), installing here would clobber the live channel — and the
629
- // installAlwaysOnPlist collision guard would fail the whole declaration. Instead:
630
- // DECLARE the runtime (runtimes += <rt> below — identity `<rt>-<p>` resolves, delivery
631
- // to a LIVE hosted session works) but leave the first channel's plist untouched. The
632
- // new runtime's session is NOT launchd-owned until per-runtime plists (multi-infra)
633
- // land — bring it up manually via `iapeer run-infra <p> <rt>` meanwhile.
634
- const occupied = installedPlistRuntime(existing.personality, options.env)
635
- if (occupied && occupied !== options.runtime) {
636
- options.warn?.(
637
- `always-on plist for "${existing.personality}" already holds runtime "${occupied}" — ` +
638
- `"${options.runtime}" is DECLARED but gets no launchd plist (one plist per personality); ` +
639
- `until per-runtime plists land, bring the session up manually: iapeer run-infra ${existing.personality} ${options.runtime}`,
640
- )
641
- } else {
642
- installAlwaysOnPlist({
643
- personality: existing.personality,
644
- runtime: options.runtime,
645
- cwd,
646
- runtimeBin: options.runtimeBin,
647
- env: options.env,
648
- })
649
- }
626
+ // MULTI-INFRA (0.4.88): installAlwaysOnPlist resolves the per-(personality,
627
+ // runtime) target itself — the legacy base plist when it already carries this
628
+ // runtime, else the suffixed `com.iapeer.<p>.<rt>`. A second infra channel
629
+ // (arthur: telegram, now declaring web) therefore gets its OWN plist and the
630
+ // first channel's plist is untouched — the 0.4.87 declare-without-plist bridge
631
+ // (manual `run-infra` bring-up) is retired.
632
+ installAlwaysOnPlist({
633
+ personality: existing.personality,
634
+ runtime: options.runtime,
635
+ cwd,
636
+ runtimeBin: options.runtimeBin,
637
+ env: options.env,
638
+ })
650
639
  // Re-provision honors an EXPLICIT nature (a manifest-declared absent service bot survives
651
640
  // update-runtime re-provision), else the runtime default (the legacy vocab-flip this fix
652
641
  // preserved). resolvedIntelligence validates the explicit against the runtime's allowed set.
package/src/init/index.ts CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  } from '../storage/index.ts'
31
31
  import { provisionPeer, type ProvisionResult } from '../provision/index.ts'
32
32
  import { claudeSettingsPath } from '../launch/nativeMemory.ts'
33
- import { launchctlBootstrap, launchdPlistPath, type BootstrapResult } from '../launch/launchd.ts'
33
+ import { launchctlBootstrap, resolveAlwaysOnTarget, type BootstrapResult } from '../launch/launchd.ts'
34
34
  import { runtimeSelfConfig, type SelfConfigResult } from '../runtime/index.ts'
35
35
  import type { Intelligence } from '../core/constants.ts'
36
36
 
@@ -568,7 +568,14 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
568
568
  } else if (opts.bootstrap !== false) {
569
569
  // (2) AUTO-bootstrap (contract Фаза §5): load the plist NOW instead of
570
570
  // write-and-wait. Fleet-safe / idempotent / sandbox-skipped inside.
571
- bootstrapped = launchctlBootstrap(provisioned.personality, launchdPlistPath(provisioned.personality, env), env)
571
+ // Multi-infra: load the plist provision actually installed for THIS runtime
572
+ // (legacy base or per-runtime suffixed — resolveAlwaysOnTarget is stable
573
+ // post-install, so this is the same file installAlwaysOnPlist wrote).
574
+ bootstrapped = launchctlBootstrap(
575
+ provisioned.personality,
576
+ resolveAlwaysOnTarget(provisioned.personality, provisioned.runtime, env).path,
577
+ env,
578
+ )
572
579
  if (bootstrapped.state === 'failed' || bootstrapped.state === 'refused-foreign') {
573
580
  opts.warn?.(`bootstrap ${bootstrapped.state}: ${bootstrapped.detail ?? ''}`)
574
581
  }
@@ -55,6 +55,8 @@ export {
55
55
  launchdLabel,
56
56
  launchAgentsDir,
57
57
  launchdPlistPath,
58
+ resolveAlwaysOnTarget,
59
+ existingAlwaysOnPlists,
58
60
  renderLaunchdPlist,
59
61
  installAlwaysOnPlist,
60
62
  installedPlistRuntime,
@@ -69,6 +71,8 @@ export {
69
71
  IAPEER_PLIST_OWNER_KEY,
70
72
  } from './launchd.ts'
71
73
  export type {
74
+ AlwaysOnTarget,
75
+ PeerPlistEntry,
72
76
  LaunchdPlistSpec,
73
77
  InstallAlwaysOnPlistOptions,
74
78
  BootstrapResult,
@@ -23,6 +23,7 @@ import {
23
23
  IAPEER_DIR,
24
24
  INFRA_RUNTIME_BIN_ENV,
25
25
  INFRA_RUNTIME_DEFAULT_BIN,
26
+ INFRA_RUNTIME_LIST,
26
27
  LAUNCHD_LABEL_PREFIX,
27
28
  isInfraRuntime,
28
29
  type Runtime,
@@ -82,12 +83,12 @@ function readPlistEnvVar(path: string, key: string): string | undefined {
82
83
  }
83
84
 
84
85
  /**
85
- * The PEER_RUNTIME baked into the personality's EXISTING foundation-owned always-on
86
- * plist, or undefined when there is no plist / it is foreign-owned / it carries no
87
- * runtime. The plist scheme is ONE per personality (`com.iapeer.<p>.plist` — the
88
- * runtime rides only in ProgramArguments/env), so this is how a caller detects that
89
- * the personality's always-on slot is already OCCUPIED by another infra runtime
90
- * (e.g. arthur's telegram) before asking for a second one (multi-infra collision).
86
+ * The PEER_RUNTIME baked into the personality's EXISTING foundation-owned BASE
87
+ * always-on plist (`com.iapeer.<p>.plist` — the legacy default channel), or
88
+ * undefined when there is no base plist / it is foreign-owned / it carries no
89
+ * runtime. This is the "who occupies the base label" probe resolveAlwaysOnTarget
90
+ * builds on: the base stays that runtime's channel (no forced migration), every
91
+ * other runtime resolves to its per-runtime suffixed plist.
91
92
  */
92
93
  export function installedPlistRuntime(personality: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
93
94
  const path = launchdPlistPath(personality, env)
@@ -136,6 +137,93 @@ export function launchdPlistPath(personality: string, env: NodeJS.ProcessEnv = p
136
137
  return join(launchAgentsDir(env), `${launchdLabel(personality)}.plist`)
137
138
  }
138
139
 
140
+ // ─────────────────────────────────────────────────────────────────────────────
141
+ // MULTI-INFRA plist scheme — per-(personality, runtime) resolution
142
+ // ─────────────────────────────────────────────────────────────────────────────
143
+
144
+ export interface AlwaysOnTarget {
145
+ /** The launchd Label — also the plist basename stem. */
146
+ label: string
147
+ /** The plist path under launchAgentsDir. */
148
+ path: string
149
+ /** Which scheme resolved: the legacy one-per-personality base plist
150
+ * (`com.iapeer.<p>`) or the per-runtime suffixed one (`com.iapeer.<p>.<rt>`). */
151
+ scheme: 'legacy-base' | 'per-runtime'
152
+ }
153
+
154
+ /**
155
+ * Resolve the always-on plist TARGET for one (personality, runtime) pair — the
156
+ * single scheme authority every side shares (install / stop / start / remove /
157
+ * update re-cycle), so the writer and the operators can never disagree on which
158
+ * label holds which channel.
159
+ *
160
+ * Resolution: the LEGACY base plist (`com.iapeer.<p>`) is the personality's
161
+ * default channel — it is the target iff it EXISTS, is foundation-owned and
162
+ * carries THIS runtime (its baked PEER_RUNTIME matches). Every other case —
163
+ * base absent, base occupied by another infra runtime, base foreign — resolves
164
+ * to the per-runtime label `com.iapeer.<p>.<rt>`. So a legacy single-infra host
165
+ * keeps its base plists untouched (no forced migration), while every NEW install
166
+ * (and every second channel of a personality) gets its own plist and the two
167
+ * channels can be KeepAlive simultaneously.
168
+ *
169
+ * The suffixed label is structurally collision-free: NAME_RE forbids '.' in
170
+ * personalities and RUNTIME_RE forbids it in runtimes, so `com.iapeer.<p>.<rt>`
171
+ * can never spell another personality's base label (and the persistent-peer
172
+ * fleet only ever uses base-shaped labels).
173
+ *
174
+ * Resolution is STABLE across install→bootstrap→stop/start: installing a
175
+ * suffixed plist never flips a later resolve (the base check looks at the base
176
+ * file only), and re-resolving after an install sees the same target.
177
+ */
178
+ export function resolveAlwaysOnTarget(
179
+ personality: string,
180
+ runtime: string,
181
+ env: NodeJS.ProcessEnv = process.env,
182
+ ): AlwaysOnTarget {
183
+ if (installedPlistRuntime(personality, env) === runtime) {
184
+ return { label: launchdLabel(personality), path: launchdPlistPath(personality, env), scheme: 'legacy-base' }
185
+ }
186
+ const label = `${launchdLabel(personality)}.${runtime}`
187
+ return { label, path: join(launchAgentsDir(env), `${label}.plist`), scheme: 'per-runtime' }
188
+ }
189
+
190
+ export interface PeerPlistEntry {
191
+ label: string
192
+ path: string
193
+ /** The runtime the plist holds: the base plist's baked PEER_RUNTIME (undefined
194
+ * when unreadable/foreign), or the suffix of a per-runtime plist. */
195
+ runtime?: string
196
+ foundationOwned: boolean
197
+ }
198
+
199
+ /**
200
+ * Every EXISTING always-on plist of a personality under the foundation TEMPLATE
201
+ * scheme: the base `com.iapeer.<p>.plist` (legacy default channel — also the shape
202
+ * a foreign persistent-peer plist takes) plus the suffixed `com.iapeer.<p>.<rt>.plist`
203
+ * for each known infra runtime. The enumerator for personality-wide operations:
204
+ * the H4 detector mirror, remove's full teardown, and the update re-cycle.
205
+ */
206
+ export function existingAlwaysOnPlists(personality: string, env: NodeJS.ProcessEnv = process.env): PeerPlistEntry[] {
207
+ const out: PeerPlistEntry[] = []
208
+ const basePath = launchdPlistPath(personality, env)
209
+ if (existsSync(basePath)) {
210
+ out.push({
211
+ label: launchdLabel(personality),
212
+ path: basePath,
213
+ runtime: readPlistEnvVar(basePath, 'PEER_RUNTIME'),
214
+ foundationOwned: isFoundationOwnedPlist(basePath),
215
+ })
216
+ }
217
+ for (const rt of INFRA_RUNTIME_LIST) {
218
+ const label = `${launchdLabel(personality)}.${rt}`
219
+ const path = join(launchAgentsDir(env), `${label}.plist`)
220
+ if (existsSync(path)) {
221
+ out.push({ label, path, runtime: rt, foundationOwned: isFoundationOwnedPlist(path) })
222
+ }
223
+ }
224
+ return out
225
+ }
226
+
139
227
  // XML-escape a plist <string> text node. cwd / personality / PATH can carry '&',
140
228
  // '<', '>' (or unicode) — a literal '&' or '<' would corrupt the XML, so escape the
141
229
  // three significant characters. Also DROP XML-1.0-illegal control characters (NUL +
@@ -538,12 +626,17 @@ export function launchctlBootstrap(
538
626
  plistPath: string,
539
627
  env: NodeJS.ProcessEnv = process.env,
540
628
  ): BootstrapResult {
541
- const label = launchdLabel(personality)
629
+ // The label is the plist BASENAME stem — true for BOTH schemes (legacy base
630
+ // `com.iapeer.<p>.plist` and per-runtime `com.iapeer.<p>.<rt>.plist`), and the
631
+ // single derivation that cannot drift from the file actually being loaded.
632
+ // (Deriving from `personality` alone would mis-label a per-runtime plist.)
633
+ const base = plistPath.slice(plistPath.lastIndexOf('/') + 1)
634
+ const label = base.endsWith('.plist') ? base.slice(0, -'.plist'.length) : launchdLabel(personality)
542
635
  if (!isFoundationOwnedPlist(plistPath)) {
543
636
  return {
544
637
  state: 'refused-foreign',
545
638
  label,
546
- detail: `${plistPath} is not foundation-owned (no ${IAPEER_PLIST_OWNER_KEY} sentinel) — refusing to launchctl bootstrap a foreign plist`,
639
+ detail: `${plistPath} (peer "${personality}") is not foundation-owned (no ${IAPEER_PLIST_OWNER_KEY} sentinel) — refusing to launchctl bootstrap a foreign plist`,
547
640
  }
548
641
  }
549
642
  // SANDBOX FAIL-CLOSED: `launchctl bootstrap gui/<uid>` is HOST-GLOBAL — it loads a
@@ -599,18 +692,24 @@ export interface InstallAlwaysOnPlistOptions {
599
692
  * entrypoint with PEER_* env + WorkingDirectory=cwd; logs land under
600
693
  * <cwd>/.iapeer/logs/<runtime>/.
601
694
  *
602
- * COLLISION GUARD (H4 — shared label namespace): the launchd Label is
603
- * com.iapeer.<personality> — keyed on PERSONALITY, not identity, and SHARED with the
695
+ * LABEL SCHEME (multi-infra, 0.4.88): the target label/path come from
696
+ * resolveAlwaysOnTarget — the legacy base `com.iapeer.<personality>` when that plist
697
+ * already carries this runtime (default channel, no forced migration), else the
698
+ * per-runtime `com.iapeer.<personality>.<runtime>`. Two infra channels of one
699
+ * personality (e.g. arthur: telegram + web) therefore hold two separate KeepAlive
700
+ * plists and never clobber each other.
701
+ *
702
+ * COLLISION GUARD (H4 — shared label namespace): the BASE label is SHARED with the
604
703
  * already-deployed persistent-peer fleet (~/Library/LaunchAgents/
605
704
  * com.iapeer.<persistent-peer>.plist run by start.sh). A personality collision (a
606
- * notifier peer named like a live PP peer) must NOT silently overwrite that foreign
607
- * plist — doing so would tear a live PP peer off launchd. So before writing we
608
- * REFUSE when a plist already sits at the target and is not foundation-owned
609
- * (isFoundationOwnedPlist: it lacks the sentinel renderLaunchdPlist embeds). The
610
- * label prefix alone cannot tell ours from theirs (PP is com.iapeer.* too); the
611
- * sentinel can. Re-installing our OWN plist (sentinel present) is allowed
612
- * (idempotent re-provision). The guard is checked FIRST, before any mkdir/write, so
613
- * a refusal leaves the filesystem untouched.
705
+ * notifier peer named like a live PP peer) must NOT put a second manager on that
706
+ * name — so we REFUSE when the base plist exists and is not foundation-owned
707
+ * (isFoundationOwnedPlist: it lacks the sentinel renderLaunchdPlist embeds), and
708
+ * equally refuse a foreign file squatting the resolved per-runtime path. The label
709
+ * prefix alone cannot tell ours from theirs (PP is com.iapeer.* too); the sentinel
710
+ * can. Re-installing our OWN plist (sentinel present) is allowed (idempotent
711
+ * re-provision). The guards run FIRST, before any mkdir/write, so a refusal leaves
712
+ * the filesystem untouched.
614
713
  */
615
714
  export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string {
616
715
  if (!isInfraRuntime(opts.runtime)) {
@@ -619,29 +718,33 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
619
718
  )
620
719
  }
621
720
  const env = opts.env ?? process.env
622
- // Collision guard FIRST — never clobber a plist the foundation does not own.
623
- const path = launchdPlistPath(opts.personality, env)
624
- if (existsSync(path) && !isFoundationOwnedPlist(path)) {
721
+ // PERSONALITY COLLISION GUARD FIRST — never touch a personality whose BASE label is
722
+ // owned by a foreign manager (the live persistent-peer fleet writes base-shaped
723
+ // com.iapeer.<p> plists). Even though a second channel would land on the suffixed
724
+ // label, installing ANY foundation plist for a foreign-managed personality would
725
+ // put two managers on one peer name — refuse before any write.
726
+ const basePath = launchdPlistPath(opts.personality, env)
727
+ if (existsSync(basePath) && !isFoundationOwnedPlist(basePath)) {
625
728
  throw new IapError(
626
- `refusing to overwrite launchd plist ${path}: label ${launchdLabel(opts.personality)} ` +
627
- `is not foundation-managed (no ${IAPEER_PLIST_OWNER_KEY} sentinel) — a persistent-peer ` +
628
- `or other manager owns it; rename the peer to avoid the com.iapeer.<personality> collision`,
729
+ `refusing to install an always-on plist for "${opts.personality}": label ` +
730
+ `${launchdLabel(opts.personality)} is not foundation-managed (no ${IAPEER_PLIST_OWNER_KEY} ` +
731
+ `sentinel) — a persistent-peer or other manager owns it; rename the peer to avoid ` +
732
+ `the com.iapeer.<personality> collision`,
629
733
  )
630
734
  }
631
- // MULTI-INFRA COLLISION GUARD (fail-loud, never a silent clobber): the plist scheme
632
- // is ONE per personality, so installing a SECOND infra runtime for the same
633
- // personality would OVERWRITE the first channel's plist (e.g. web over arthur's
634
- // telegram) and kill that channel on the next bootout/reboot. Refuse loudly until
635
- // per-runtime plists (multi-infra) land. A deliberate runtime SWITCH is an operator
636
- // act: `iapeer stop <p>` + delete the plist, then re-provision.
637
- const bakedRuntime = existsSync(path) ? readPlistEnvVar(path, 'PEER_RUNTIME') : undefined
638
- if (bakedRuntime && bakedRuntime !== opts.runtime) {
735
+ // MULTI-INFRA (per-runtime plists): resolve the (personality, runtime) target —
736
+ // the legacy base plist iff it already carries THIS runtime (default channel, no
737
+ // forced migration), else the suffixed com.iapeer.<p>.<rt>. A second infra runtime
738
+ // therefore gets its OWN plist and can be KeepAlive alongside the first channel —
739
+ // the pre-0.4.88 fail-loud "one plist per personality" refusal is retired.
740
+ const target = resolveAlwaysOnTarget(opts.personality, opts.runtime, env)
741
+ const path = target.path
742
+ // Belt for the suffixed path too: a hand-planted foreign file at our per-runtime
743
+ // label is never clobbered (same sentinel rule as the base guard above).
744
+ if (existsSync(path) && !isFoundationOwnedPlist(path)) {
639
745
  throw new IapError(
640
- `refusing to overwrite launchd plist ${path}: it already holds the always-on ` +
641
- `"${bakedRuntime}" session for "${opts.personality}" (the plist scheme is one per ` +
642
- `personality) — installing "${opts.runtime}" here would silently kill that channel. ` +
643
- `Multi-infra per personality needs per-runtime plists (not yet supported); for a ` +
644
- `deliberate runtime switch: iapeer stop ${opts.personality}, delete the plist, re-provision.`,
746
+ `refusing to overwrite launchd plist ${path}: it exists but is not foundation-managed ` +
747
+ `(no ${IAPEER_PLIST_OWNER_KEY} sentinel) — remove or rename the foreign file first`,
645
748
  )
646
749
  }
647
750
  // GLOBAL infra logs (Фаза §8): ~/.iapeer/logs/<personality>/, NOT per-peer
@@ -692,7 +795,7 @@ export function installAlwaysOnPlist(opts: InstallAlwaysOnPlistOptions): string
692
795
  if (fallback) environment.NOTIFIER_FALLBACK_TARGET = fallback
693
796
  }
694
797
  const spec: LaunchdPlistSpec = {
695
- label: launchdLabel(opts.personality),
798
+ label: target.label,
696
799
  programArguments: [...(opts.entrypointArgv ?? defaultEntrypointArgv(env)), opts.personality, opts.runtime],
697
800
  workingDirectory: opts.cwd,
698
801
  environment,
@@ -18,6 +18,7 @@ import { join } from 'path'
18
18
  import { spawnSync } from 'child_process'
19
19
  import * as lockfile from 'proper-lockfile'
20
20
  import {
21
+ INFRA_RUNTIME_LIST,
21
22
  STATE_DIR,
22
23
  LOGS_DIR,
23
24
  isRuntime,
@@ -131,18 +132,26 @@ export function loadLifecycleConfig(env: NodeJS.ProcessEnv = process.env): Lifec
131
132
  // ─────────────────────────────────────────────────────────────────────────────
132
133
 
133
134
  /**
134
- * True iff the peer is launchd-managed: `~/Library/LaunchAgents/
135
- * com.iapeer.<personality>.plist` exists. A launchd peer is in the launchd
136
- * domain — KeepAlive owns its lifecycle — and the daemon must be READ-ONLY for
137
- * it (deliver to it if live, but never wake / reap / respawn / sweep it). This
138
- * is the hard guard against fighting launchd on the live fleet.
135
+ * True iff the peer is launchd-managed: ANY foundation-template always-on plist
136
+ * of the personality exists under ~/Library/LaunchAgents — the legacy base
137
+ * `com.iapeer.<personality>.plist` (also the shape a foreign persistent-peer
138
+ * plist takes) OR a per-runtime `com.iapeer.<personality>.<runtime>.plist`
139
+ * (multi-infra, 0.4.88). A launchd peer is in the launchd domain — KeepAlive
140
+ * owns its lifecycle — and the daemon must be READ-ONLY for it (deliver to it
141
+ * if live, but never wake / reap / respawn / sweep it). This is the hard guard
142
+ * against fighting launchd on the live fleet. H4 stays PERSONALITY-scoped: one
143
+ * launchd-held channel marks the whole peer launchd-owned, same as before.
139
144
  */
140
145
  export function isLaunchdManaged(personality: string, env: NodeJS.ProcessEnv = process.env): boolean {
141
146
  // Label + LaunchAgents dir come from the SAME helpers the plist generator uses
142
147
  // (launch/launchd.ts), so this H4 detector and installAlwaysOnPlist can never
143
- // disagree on `com.iapeer.<personality>.plist`. IAPEER_LAUNCHAGENTS_DIR overrides
144
- // the dir for tests (so an H4-guard test never touches ~/Library/LaunchAgents).
145
- return existsSync(join(launchAgentsDir(env), `${launchdLabel(personality)}.plist`))
148
+ // disagree on the scheme. The per-runtime candidates are enumerated from
149
+ // INFRA_RUNTIME_LIST (the same set that gates plist generation) — cheap
150
+ // existsSync probes, no file reads, since this runs on every route/sweep.
151
+ // IAPEER_LAUNCHAGENTS_DIR overrides the dir for tests.
152
+ const dir = launchAgentsDir(env)
153
+ if (existsSync(join(dir, `${launchdLabel(personality)}.plist`))) return true
154
+ return INFRA_RUNTIME_LIST.some(rt => existsSync(join(dir, `${launchdLabel(personality)}.${rt}.plist`)))
146
155
  }
147
156
 
148
157
  // ─────────────────────────────────────────────────────────────────────────────
@@ -24,7 +24,7 @@ import { IapError } from '../core/errors.ts'
24
24
  import { peerProfilePath } from '../storage/index.ts'
25
25
  import { ensurePeerProfile } from '../identity/index.ts'
26
26
  import { readPeersIndex, upsertPeer } from '../registry/index.ts'
27
- import { launchdPlistPath, resolveExecutable } from '../launch/launchd.ts'
27
+ import { resolveAlwaysOnTarget, resolveExecutable } from '../launch/launchd.ts'
28
28
 
29
29
  export interface ProvisionPeerOptions {
30
30
  /** The peer's working directory. personality defaults to normalized basename. */
@@ -222,7 +222,10 @@ export async function provisionPeer(opts: ProvisionPeerOptions): Promise<Provisi
222
222
  cwd,
223
223
  profilePath: peerProfilePath(cwd),
224
224
  intelligence: opts.intelligence ?? profile.intelligence,
225
- plistPath: isInfraRuntime(opts.runtime) ? launchdPlistPath(profile.personality, env) : undefined,
225
+ // Multi-infra: the plist ensurePeerProfile actually installed for THIS runtime
226
+ // (legacy base or per-runtime suffixed) — resolveAlwaysOnTarget re-resolves to
227
+ // the same target the install used (resolution is stable once the file exists).
228
+ plistPath: isInfraRuntime(opts.runtime) ? resolveAlwaysOnTarget(profile.personality, opts.runtime, env).path : undefined,
226
229
  runtimeBin,
227
230
  }
228
231
  }
@@ -42,8 +42,7 @@ import {
42
42
  cycleDaemon,
43
43
  cycleLaunchdJob,
44
44
  isFoundationOwnedPlist,
45
- launchdLabel,
46
- launchdPlistPath,
45
+ resolveAlwaysOnTarget,
47
46
  type DaemonRestartResult,
48
47
  type LaunchdJobCycleResult,
49
48
  } from '../launch/launchd.ts'
@@ -261,13 +260,18 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
261
260
  const out: InfraJobRecycleResult[] = []
262
261
  const index = readPeersIndex({ env })
263
262
  for (const peer of index.peers) {
264
- const runtime = (isInfraRuntime(peer.runtime) ? peer.runtime : peer.runtimes.find(isInfraRuntime)) ?? ''
265
- if (!runtime) continue
266
- const plist = launchdPlistPath(peer.personality, env)
267
- if (!isFoundationOwnedPlist(plist)) continue
268
- const label = launchdLabel(peer.personality)
269
- const r = cycleLaunchdJob(label, plist, env)
270
- out.push({ personality: peer.personality, runtime, label, state: r.state, detail: r.detail })
263
+ // Multi-infra (0.4.88): a personality can hold SEVERAL always-on channels
264
+ // (e.g. arthur: telegram on the legacy base plist + web on the per-runtime
265
+ // one) — re-cycle EVERY declared infra runtime's job, not just the first.
266
+ // resolveAlwaysOnTarget is the same scheme authority install/stop/start use;
267
+ // an absent plist reads as not-foundation-owned → skipped.
268
+ const infraRuntimes = [...new Set([peer.runtime, ...peer.runtimes])].filter(isInfraRuntime)
269
+ for (const runtime of infraRuntimes) {
270
+ const target = resolveAlwaysOnTarget(peer.personality, runtime, env)
271
+ if (!isFoundationOwnedPlist(target.path)) continue
272
+ const r = cycleLaunchdJob(target.label, target.path, env)
273
+ out.push({ personality: peer.personality, runtime, label: target.label, state: r.state, detail: r.detail })
274
+ }
271
275
  }
272
276
  return out
273
277
  }