@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 +62 -14
- package/package.json +1 -1
- package/src/cli/index.ts +17 -4
- package/src/daemon/main.ts +38 -2
- package/src/identity/index.ts +13 -0
- package/src/init/index.ts +57 -4
- package/src/launch/launchd.ts +27 -0
package/README.md
CHANGED
|
@@ -1,29 +1,77 @@
|
|
|
1
1
|
# iapeer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Host-level core for a team of AI agents on one machine.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@agfpd/iapeer)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
[](#quick-start)
|
|
6
8
|
|
|
7
|
-
iapeer
|
|
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
|
-
|
|
11
|
+
## How it works
|
|
10
12
|
|
|
11
|
-
|
|
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
|
-
|
|
23
|
+
## Quick start
|
|
14
24
|
|
|
15
|
-
|
|
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
|
-
|
|
27
|
+
```sh
|
|
28
|
+
# 1. install — the iapeer binary and the ~/.iapeer layout
|
|
29
|
+
npx @agfpd/iapeer
|
|
18
30
|
|
|
19
|
-
|
|
31
|
+
# 2. set up the host — starts the router daemon, then adds scheduler peers, Telegram, and shared memory
|
|
32
|
+
iapeer onboard
|
|
20
33
|
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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] [--
|
|
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,
|
package/src/daemon/main.ts
CHANGED
|
@@ -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.
|
|
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 {
|
package/src/identity/index.ts
CHANGED
|
@@ -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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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,
|
package/src/launch/launchd.ts
CHANGED
|
@@ -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
|