@agfpd/iapeer 0.2.79 → 0.2.81

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/README.md CHANGED
@@ -1,29 +1,77 @@
1
1
  # iapeer
2
2
 
3
- Ядро мультиагентной системы на одном хосте: реестр пиров, единый демон-роутер для обмена сообщениями между ними и команды управления — создание, запуск, усыпление, перезапуск, подключение к внешним каналам. Пиром может быть AI-агент (Claude, Codex), человек (через Telegram) или служебный процесс; все адресуются одинаково через один протокол.
3
+ **Host-level core for a team of AI agents on one machine.**
4
4
 
5
- ## Ключевые возможности
5
+ [![npm](https://img.shields.io/npm/v/@agfpd/iapeer)](https://www.npmjs.com/package/@agfpd/iapeer)
6
+ [![license](https://img.shields.io/badge/license-Apache--2.0-blue)](./LICENSE)
7
+ [![platform](https://img.shields.io/badge/platform-macOS-lightgrey)](#quick-start)
6
8
 
7
- iapeer держит AI-агентов как **живые интерактивные сессии настоящего CLI** (Claude Code, Codex CLI) — со всем поведением реального CLI, а не headless-режим одноразовых вызовов (`claude -p`, `codex exec`). Поскольку это реальный интерактивный CLI, пиры работают в рамках обычной подписки на Claude Code (изменение тарификации headless-режима от 15 июня 2026 интерактивный CLI не затрагивает).
9
+ iapeer keeps a team of AI agents, humans, and services on one host **always reachable — every one of them a single message away.** Each participant is a **peer**: a Claude Code or Codex CLI agent, a person on Telegram, or a scheduler/watcher service. Any agent reaches any other with one call — `send_to_peer(<name>, <text>)` — and a peer that is asleep is still reachable: the daemon wakes it and delivers. Agents on Claude and on Codex — different AI runtimes from different vendors — talk as equal peers; humans take part over Telegram.
8
10
 
9
- Отдельные свойства ниже встречаются и в других инструментах; сильная сторона iapeer — их **комбинация целиком**:
11
+ ## How it works
10
12
 
11
- 1. **Реальный интерактивный CLI в фоне.** Каждый AI-пир — полноценная сессия Claude Code или Codex CLI, поднятая в фоне и доступная по сообщению.
13
+ ```text
14
+ send_to_peer(name, text) ← from any peer
15
+ │
16
+ ▼
17
+ router daemon — always on; finds the recipient, checks liveness, delivers
18
+ │
19
+ ▼
20
+ recipient peer — asleep? the daemon wakes it and delivers on wake
21
+ ```
12
22
 
13
- 2. **Постоянная сессия (persistent).** На входящее демон поднимает пира через возобновление (`--continue` / `--resume`) — диалог **продолжается**, а не начинается заново; контекст и история сохраняются между обращениями.
23
+ ## Quick start
14
24
 
15
- 3. **Событийный warm-on-demand жизненный цикл.** Сессия «тёплая» → быстро обрабатывает входящие; после ~часа простоя демон закрывает её (idle-reap); новое входящее → демон поднимает заново **автоматически**, с возобновлением контекста. Жизненным циклом правит поток сообщений, а не присутствие человека: это не привязанный к разработчику TUI и не одноразовый воркер на задачу. Ресурсы заняты, только пока пир работает.
25
+ Requirements: **macOS**, [bun](https://bun.sh) on `PATH`, `~/.local/bin` on `PATH`, and at least one agent runtime installed — Claude Code or Codex CLI (each AI peer is a live session of one).
16
26
 
17
- 4. **Переносимость пиров между рантаймами.** Личность пира (`personality`) отделена от рантайма: один пир присутствует и в Claude, и в Codex, а основной рантайм переключается командой (`claude ↔ codex`) без потери идентичности и адресации.
27
+ ```sh
28
+ # 1. install — the iapeer binary and the ~/.iapeer layout
29
+ npx @agfpd/iapeer
18
30
 
19
- 5. **Подключение человека к живой сессии.** `iapeer attach` открывает **живую** сессию пира прямо в вашем терминале через pty-супервизор — мышь, прокрутка и цвета работают нативно; отсоединение (`Ctrl-]`) сессию не закрывает.
31
+ # 2. set up the host — starts the router daemon, then adds scheduler peers, Telegram, and shared memory
32
+ iapeer onboard
20
33
 
21
- Целиком эту связку — событийный warm-on-demand жизненный цикл (не attached-разработчиком TUI и не воркер-на-задачу), переносимость пиров между рантаймами и единую ткань обмена (протокол IAP, память-vault с поиском, Telegram-мост) — даёт iapeer.
34
+ # 3. create your first agent
35
+ iapeer create assistant --runtime claude
22
36
 
23
- ## Документация
37
+ # 4. talk to it live in your terminal — detach with Ctrl-]
38
+ iapeer attach assistant
39
+ ```
24
40
 
25
- Документация проекта — в [`docs/`](https://github.com/agfpd/iapeer/tree/main/docs): продуктовая часть (что это и как пользоваться) и механика изнутри ([`docs/internals/`](https://github.com/agfpd/iapeer/tree/main/docs/internals)). Этот репозиторий — кодовая реализация.
41
+ **Already have a project folder?** In step 3, use `init` instead of `create`: `cd` into the project and register that folder itself as a peer.
26
42
 
27
- ## Статус
43
+ ```sh
44
+ cd ~/projects/myagent
45
+ iapeer init
46
+ ```
28
47
 
29
- Платформа — macOS/launchd. Актуальная версия — в `package.json` / `iapeer version`.
48
+ The peer's name is the folder name, normalized (`myagent`). `create` sets up a fresh folder under `~/.iapeer/peers/<name>`; `init` turns the folder you are in into a peer. The setup is identical either way.
49
+
50
+ Two ways to work with an agent locally in your terminal:
51
+
52
+ - **Join its live session** — `iapeer attach assistant`. The daemon wakes the peer first if it is asleep; detach with `Ctrl-]` and the session keeps running.
53
+ - **Open a fresh session in the peer's folder** — go to the peer's directory and launch its runtime:
54
+
55
+ ```sh
56
+ cd ~/.iapeer/peers/assistant
57
+ iapeer claude
58
+ ```
59
+
60
+ This starts a full session with the peer's identity and doctrine and drops you straight into it. In effect, `iapeer claude` is `claude` for that peer — the same interactive CLI, but the session has a stable identity, its own memory, and an address other peers can reach.
61
+
62
+ Agents reach each other with `send_to_peer`; you can also message any peer over Telegram.
63
+
64
+ ## What makes it different
65
+
66
+ - **Real interactive CLI sessions, not headless one-shots.** Each AI peer is a full Claude Code or Codex CLI session, not a disposable `claude -p` / `codex exec` call.
67
+ - **Warm-on-demand lifecycle.** A live session answers fast; after about an hour idle the daemon closes it; the next message brings it back with its context resumed. The message flow drives the lifecycle — not a human's presence.
68
+ - **One identity across runtimes, with one memory.** A peer's `personality` is decoupled from its runtime — the same peer runs on Claude or Codex, switched with a command. By default (set up by `iapeer onboard`) it carries one shared memory (iapeer-memory) keyed to the personality, not the runtime: one personality, one memory, whatever runtime it runs on.
69
+ - **Heterogeneous peers, one protocol.** AI agents, humans (Telegram), and services (timer = cron, watcher = event) are all first-class, addressed the same way over an always-on MCP daemon that exposes exactly one tool, `send_to_peer`.
70
+
71
+ ## Documentation
72
+
73
+ [`docs/`](https://github.com/agfpd/iapeer/tree/main/docs) — the product side (what it is and how to use it); [`docs/internals/`](https://github.com/agfpd/iapeer/tree/main/docs/internals) — the mechanics from the inside. This repository is the implementation.
74
+
75
+ ## License
76
+
77
+ Apache-2.0. Platform: macOS / launchd. iapeer is the foundation core of a larger ecosystem; runtime and capability plugins build on top of it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.79",
3
+ "version": "0.2.81",
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
@@ -61,7 +61,7 @@ import {
61
61
  validateProfileStandard,
62
62
  } from '../identity/profileStandard.ts'
63
63
  import { runAlwaysOn } from '../launch/launchdRun.ts'
64
- import { installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
64
+ import { ensureDaemonStarted, installDaemonPlist, startConfiguredDaemon } from '../daemon/main.ts'
65
65
  import { MARKETPLACE_NAME, onboardHost } from '../onboard/index.ts'
66
66
  import { readMemoryProvider } from '../status/index.ts'
67
67
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
@@ -915,7 +915,7 @@ const USAGE = `usage: iapeer <verb> [args]
915
915
  status host snapshot: version, daemon health, memory slot (<provider> | none)
916
916
  install-runtime <runtime> [--package pkg] [--npx] npx-install a runtime package + deploy its declared peer-set
917
917
  update-runtime <runtime> | --all [--force] version-gate → re-install + re-provision declared set → restart the runtime's peers
918
- init [cwd] [--personality p] [--runtime r] [--description d] onboard the CURRENT folder as a peer (identity + MCP + doctrine)
918
+ init [cwd] [--runtime r] [--description d] onboard the CURRENT folder as a peer (name = folder name; identity + MCP + doctrine)
919
919
  create <personality> [--runtime r] [--path dir] [--bin abs] create a peer anywhere (default ~/.iapeer/peers/<p>) + provision
920
920
  list [--json] registered peers + per-runtime liveness
921
921
  verify [--json] [--fix] profile-standard conformance + index↔local drift (--fix self-heals the index + migrates legacy runtime→default_runtime in local profiles)
@@ -969,6 +969,14 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
969
969
  // reports the would-be actions without touching anything. --infra <csv> ALSO
970
970
  // onboards infra runtimes (§6): npx-install each package (auto-resolved) + deploy
971
971
  // its declared set. notifier → timer+watcher auto; telegram → operator-add after.
972
+ // STEP 0 (non-disableable): ensure the router daemon is up. Onboard is the
973
+ // explicit "set up the host" action, so starting the always-on daemon belongs
974
+ // here — the operator never types raw `launchctl bootstrap`. Idempotent (no-op
975
+ // if already loaded). A failure here is a real, host-breaking problem → exit 1.
976
+ const dstart = await ensureDaemonStarted({ dryRun: flags['dry-run'] === true, env })
977
+ const dLabel = dstart.state === 'would-start' ? 'would-start (dry-run)' : dstart.state
978
+ out(`daemon: ${dLabel}${dstart.healthy === false ? ' — UNHEALTHY' : ''}${dstart.detail ? ` — ${dstart.detail}` : ''}\n`)
979
+ const daemonFailed = dstart.state === 'failed' || dstart.healthy === false
972
980
  const r = onboardHost({ dryRun: flags['dry-run'] === true, env })
973
981
  for (const m of r.marketplaces) {
974
982
  out(`marketplace ${MARKETPLACE_NAME} @ ${m.runtime}: ${m.state}${m.detail ? ` — ${m.detail}` : ''}\n`)
@@ -1038,7 +1046,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1038
1046
  })
1039
1047
  const memLabel = mem.provider ? `${mem.provider.provider} ${mem.provider.version}` : 'none'
1040
1048
  out(`memory: ${mem.state}${mem.detail ? ` — ${mem.detail}` : ''} (slot: ${memLabel})\n`)
1041
- return r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
1049
+ return daemonFailed || r.marketplaces.some(m => m.state === 'failed') || infraFailed ? 1 : 0
1042
1050
  }
1043
1051
  case 'native-memory': {
1044
1052
  // The canonized runtime-memory lever (контракт «Слот памяти» §Native-память):
@@ -1174,11 +1182,16 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1174
1182
  // cwd-DEPENDENT: onboard the CURRENT folder (or positional cwd) as a peer —
1175
1183
  // identity + MCP wiring + doctrine, runtime resolved from the cwd's markers
1176
1184
  // when not explicit. Auto-bootstraps an infra plist unless --no-bootstrap.
1185
+ // A peer's name IS its folder name (normalize(basename(cwd))) — personality ↔ folder
1186
+ // is 1:1, so `--personality` is gone: name the folder, don't pass a separate name.
1187
+ if (flags.personality !== undefined) {
1188
+ errOut("init: --personality was removed — a peer's name is its folder name; rename the folder to set the name\n")
1189
+ return 2
1190
+ }
1177
1191
  const { initPeer } = await import('../init/index.ts')
1178
1192
  const r = await initPeer({
1179
1193
  cwd: positionals[0] ?? process.cwd(),
1180
1194
  runtime: typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined,
1181
- personality: typeof flags.personality === 'string' ? flags.personality : undefined,
1182
1195
  description: typeof flags.description === 'string' ? flags.description : undefined,
1183
1196
  runtimeBin: typeof flags.bin === 'string' ? flags.bin : undefined,
1184
1197
  bootstrap: flags['no-bootstrap'] === true ? false : undefined,
@@ -23,11 +23,13 @@ import { DAEMON_PLIST_LABEL } from '../core/constants.ts'
23
23
  import { IapError } from '../core/errors.ts'
24
24
  import { pluginLogsDir } from '../storage/index.ts'
25
25
  import {
26
+ bootstrapDaemon,
26
27
  isFoundationOwnedPlist,
27
28
  launchAgentsDir,
28
29
  renderLaunchdPlist,
29
30
  } from '../launch/launchd.ts'
30
- import type { LaunchdPlistSpec } from '../launch/launchd.ts'
31
+ import type { BootstrapState, LaunchdPlistSpec } from '../launch/launchd.ts'
32
+ import { waitForDaemonHealthy } from '../update/index.ts'
31
33
  import {
32
34
  drainAllEphemeralQueues,
33
35
  drainEphemeralQueue,
@@ -450,6 +452,40 @@ export function installDaemonPlist(opts: InstallDaemonPlistOptions = {}): Instal
450
452
  return { path, changed: true }
451
453
  }
452
454
 
455
+ export interface DaemonStartResult {
456
+ /** 'would-start' = dryRun report; otherwise the bootstrap outcome. */
457
+ state: BootstrapState | 'would-start'
458
+ /** Socket health after a (non-sandbox) bootstrap; undefined when not probed. */
459
+ healthy?: boolean
460
+ detail?: string
461
+ }
462
+
463
+ /**
464
+ * `iapeer onboard`'s first, non-disableable step: ensure the foundation daemon is up.
465
+ * Writes the plist if missing (idempotent write-if-changed — defensive when onboard
466
+ * runs on a host where the plist is absent), idempotently bootstraps it (no-op when
467
+ * already loaded — NOT a restart), then health-checks the socket. dryRun reports the
468
+ * intent without touching launchctl. Sandbox-safe: bootstrapDaemon + waitForDaemonHealthy
469
+ * both short-circuit under IAPEER_TEST_SANDBOX, so this never loads a real job in tests.
470
+ */
471
+ export async function ensureDaemonStarted(
472
+ opts: { env?: NodeJS.ProcessEnv; dryRun?: boolean } = {},
473
+ ): Promise<DaemonStartResult> {
474
+ const env = opts.env ?? process.env
475
+ if (opts.dryRun) {
476
+ return {
477
+ state: 'would-start',
478
+ detail: 'install plist if missing + launchctl bootstrap com.agfpd.iapeer (idempotent) + health-check',
479
+ }
480
+ }
481
+ installDaemonPlist({ env })
482
+ const b = bootstrapDaemon(env)
483
+ if (b.state === 'failed') return { state: 'failed', detail: b.detail }
484
+ if (b.state === 'skipped-sandbox') return { state: 'skipped-sandbox', detail: b.detail }
485
+ const h = await waitForDaemonHealthy({ env })
486
+ return { state: b.state, healthy: h.healthy, detail: h.healthy ? undefined : h.detail }
487
+ }
488
+
453
489
  // ─────────────────────────────────────────────────────────────────────────────
454
490
  // CLI — launchd-held process / foreground acceptance entrypoint
455
491
  // ─────────────────────────────────────────────────────────────────────────────
@@ -463,7 +499,7 @@ if (import.meta.main) {
463
499
  const { path: p, changed } = installDaemonPlist()
464
500
  process.stdout.write(
465
501
  `daemon plist ${changed ? 'written' : 'unchanged'}: ${p}\n` +
466
- `NOT loaded. To start it live: launchctl bootstrap gui/$(id -u) ${p}\n`,
502
+ `NOT loaded. \`iapeer onboard\` starts it (idempotent); or manually: launchctl bootstrap gui/$(id -u) ${p}\n`,
467
503
  )
468
504
  process.exit(0)
469
505
  } else {
@@ -415,6 +415,19 @@ export function ensurePeerProfile(options: EnsurePeerProfileOptions): PeerProfil
415
415
  let personality: string
416
416
  if (options.personality !== undefined) {
417
417
  personality = validatePersonality(options.personality, 'personality')
418
+ // INVARIANT (personality ↔ folder is 1:1): an explicitly-supplied personality MUST
419
+ // equal the normalized cwd basename. Otherwise the registry record (personality) and
420
+ // a manual `cd <cwd>; <runtime>` session (which self-derives identity from the cwd
421
+ // basename) would disagree. The only ways to reach a mismatch are a runtime manifest
422
+ // `peers[].path` or `create --path <dir>` whose basename ≠ personality — both are
423
+ // misconfigurations, surfaced loudly here rather than written as a latent drift.
424
+ const expected = normalizeNameCandidate(basename(cwd))
425
+ if (personality !== expected) {
426
+ throw new IapError(
427
+ `personality "${personality}" must equal the normalized cwd basename "${expected}" ` +
428
+ `(personality ↔ folder is 1:1) — name the folder to match, or drop the explicit name. cwd: ${cwd}`,
429
+ )
430
+ }
418
431
  const collision = peerCollision(personality, cwd, peers)
419
432
  if (collision) {
420
433
  throw new IapError(
package/src/init/index.ts CHANGED
@@ -17,6 +17,7 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
17
17
  import { homedir } from 'os'
18
18
  import { join } from 'path'
19
19
  import { CODEX_BEARER_ENV_VAR, CODEX_DUMMY_BEARER, IAPEER_DIR, isInfraRuntime, type Runtime } from '../core/constants.ts'
20
+ import { IapError } from '../core/errors.ts'
20
21
  import {
21
22
  ensureLocalIapScaffold,
22
23
  pluginStateDir,
@@ -257,11 +258,63 @@ export function ensureDoctrineTemplate(cwd: string): { path: string; created: bo
257
258
  * the old inconsistency where a `.codex`-only folder defaulted to claude as primary
258
259
  * yet got a codex config — now the primary matches the markers, deterministically.
259
260
  */
260
- export function resolvePrimaryRuntime(cwd: string, explicit?: Runtime): Runtime {
261
- if (explicit) return explicit
261
+ /**
262
+ * Is an AGENTIC runtime installed on this host? Checks (any hit ⇒ installed): env
263
+ * override (IAPEER_<RT>_BIN), the native installer location (~/.local/bin/claude), the
264
+ * bare name on PATH, and the runtime's global config dir (~/.claude / ~/.codex — a
265
+ * runtime that has ever run leaves one). SANDBOX: under IAPEER_TEST_SANDBOX it reports
266
+ * `true` (never probes the real host) — mirror of the install-side guards; a hermetic
267
+ * test that wants the real branches injects its own predicate into resolvePrimaryRuntime.
268
+ */
269
+ export function isRuntimeInstalled(runtime: Runtime, env: NodeJS.ProcessEnv = process.env): boolean {
270
+ if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') return true
271
+ const override = runtime === 'claude' ? env.IAPEER_CLAUDE_BIN : runtime === 'codex' ? env.IAPEER_CODEX_BIN : undefined
272
+ if (override?.trim()) return true
273
+ const home = env.HOME?.trim() || homedir()
274
+ if (runtime === 'claude' && existsSync(join(home, '.local', 'bin', 'claude'))) return true
275
+ if (Bun.which(runtime)) return true
276
+ if (runtime === 'claude' && existsSync(join(home, '.claude'))) return true
277
+ if (runtime === 'codex' && existsSync(join(home, '.codex'))) return true
278
+ return false
279
+ }
280
+
281
+ const AGENTIC_RUNTIMES: Runtime[] = ['claude', 'codex']
282
+
283
+ /**
284
+ * Resolve a peer's primary AGENTIC runtime — install-aware:
285
+ * 1. explicit (`--runtime`) → use it, but VALIDATE it is installed (clear error instead
286
+ * of a silent launch failure later on an uninstalled runtime).
287
+ * 2. else a cwd runtime MARKER (.claude / .codex) → that runtime. The folder's already-
288
+ * established config takes PRECEDENCE over install-presence (a folder configured for
289
+ * X stays X even if Y is also installed; markers are not re-validated).
290
+ * 3. else from what is INSTALLED: exactly one → it; none → clear error (don't silently
291
+ * default to claude on a host without it); both → claude (deterministic default —
292
+ * resolution runs in non-interactive paths, so no prompt; `--runtime` picks codex).
293
+ * `isInstalled` is injectable for hermetic tests.
294
+ */
295
+ export function resolvePrimaryRuntime(
296
+ cwd: string,
297
+ explicit?: Runtime,
298
+ isInstalled: (rt: Runtime) => boolean = rt => isRuntimeInstalled(rt),
299
+ ): Runtime {
300
+ if (explicit) {
301
+ if (!isInstalled(explicit)) {
302
+ throw new IapError(
303
+ `runtime "${explicit}" is not installed on this host — install it (Claude Code / Codex CLI) or choose an installed runtime`,
304
+ )
305
+ }
306
+ return explicit
307
+ }
262
308
  if (existsSync(join(cwd, '.claude'))) return 'claude'
263
309
  if (existsSync(join(cwd, '.codex'))) return 'codex'
264
- return 'claude'
310
+ const installed = AGENTIC_RUNTIMES.filter(isInstalled)
311
+ if (installed.length === 1) return installed[0]!
312
+ if (installed.length === 0) {
313
+ throw new IapError(
314
+ `no agentic runtime installed (Claude Code or Codex CLI) — install one, then create/init the peer`,
315
+ )
316
+ }
317
+ return 'claude' // both installed → deterministic default; pass --runtime codex to pick codex
265
318
  }
266
319
 
267
320
  export interface InitPeerOptions {
@@ -319,7 +372,7 @@ export async function initPeer(opts: InitPeerOptions): Promise<InitPeerResult> {
319
372
  // Runtime-aware + CONSISTENT: an explicit runtime wins; else the cwd's markers
320
373
  // decide the primary (resolvePrimaryRuntime), so the primary always matches the
321
374
  // config that gets written (no `.codex`-only-but-primary-claude mismatch).
322
- const runtime = resolvePrimaryRuntime(opts.cwd, opts.runtime)
375
+ const runtime = resolvePrimaryRuntime(opts.cwd, opts.runtime, rt => isRuntimeInstalled(rt, env))
323
376
  const provisioned = await provisionPeer({
324
377
  cwd: opts.cwd,
325
378
  runtime,
@@ -382,6 +382,33 @@ export function cycleDaemon(env: NodeJS.ProcessEnv = process.env): DaemonRestart
382
382
  return cycleLaunchdJob(DAEMON_PLIST_LABEL, join(launchAgentsDir(env), `${DAEMON_PLIST_LABEL}.plist`), env)
383
383
  }
384
384
 
385
+ /**
386
+ * Ensure the foundation daemon (`com.agfpd.iapeer`) is LOADED in the gui domain — an
387
+ * IDEMPOTENT `launchctl bootstrap` (no-op when already loaded), NOT a restart. This is
388
+ * the "start it now" step `iapeer onboard` runs, so an operator never types raw
389
+ * launchctl. Composed on bootstrapJobCore (undead-job-safe wait-for-gone + backoff).
390
+ * SANDBOX FAIL-SAFE: under IAPEER_TEST_SANDBOX it never calls launchctl (`bootstrap
391
+ * gui/<uid>` is host-global regardless of IAPEER_ROOT), consulting BOTH the passed env
392
+ * and the process env — mirror of launchctlBootstrap's guard.
393
+ */
394
+ export function bootstrapDaemon(env: NodeJS.ProcessEnv = process.env): BootstrapResult {
395
+ if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
396
+ return { state: 'skipped-sandbox', label: DAEMON_PLIST_LABEL, detail: 'IAPEER_TEST_SANDBOX=1 — not loading a real launchd job' }
397
+ }
398
+ const uid = currentUid()
399
+ const plistPath = join(launchAgentsDir(env), `${DAEMON_PLIST_LABEL}.plist`)
400
+ const core = bootstrapJobCore(uid, DAEMON_PLIST_LABEL, plistPath, {
401
+ run: args => {
402
+ const r = spawnSync('launchctl', args, { encoding: 'utf8' })
403
+ return { status: r.status, stderr: r.stderr ?? '' }
404
+ },
405
+ sleepMs: ms => spawnSync('sleep', [String(ms / 1000)]),
406
+ })
407
+ return core.state === 'failed'
408
+ ? { state: 'failed', label: DAEMON_PLIST_LABEL, detail: core.detail }
409
+ : { state: core.state, label: DAEMON_PLIST_LABEL }
410
+ }
411
+
385
412
  /**
386
413
  * Restart any launchd job onto the executable(s) currently referenced by its plist:
387
414
  * bootout → bootstrap, never kickstart. Used by the daemon update path AND by