@agfpd/iapeer 0.4.2 → 0.4.3

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.
@@ -74,6 +74,8 @@ Runtimes and memory are **best-effort**: a component failure is reported and the
74
74
 
75
75
  `iapeer update --foundation-only` does just step 1 (the narrow case). A pinned `iapeer update <version>` is **core-only** by design — a version pin is core-specific (downgrade, or pin a proven version, deeper than one rollback step).
76
76
 
77
+ > **Upgrading from before the cascade (< 0.4.2).** The cascade is driven by the *installed* binary, so the very first `iapeer update` from a pre-cascade version runs the OLD (foundation-only) logic: it upgrades the core to the cascade-capable binary but does not yet cascade runtimes + memory. Run `iapeer update` once more — now the new binary cascades the rest. This is a one-time bootstrap; from 0.4.2 onward it is a single command. (A fresh install lands on a cascade-capable binary directly, so it never needs the double run.)
78
+
77
79
  ## Rollback
78
80
 
79
81
  ```bash
@@ -74,6 +74,8 @@ iapeer update --force # переустановить, даже есл
74
74
 
75
75
  `iapeer update --foundation-only` делает только шаг 1 (узкий случай). Запиненный `iapeer update <версия>` — **только ядро** by design: пин версии специфичен для ядра (откат глубже одного шага или фиксация проверенной версии).
76
76
 
77
+ > **Обновление с версии до каскада (< 0.4.2).** Каскад исполняет *установленный* бинарь, поэтому самый первый `iapeer update` со старой версии работает по СТАРОЙ (foundation-only) логике: он поднимает ядро до каскад-способного бинаря, но рантаймы + память пока не каскадит. Запусти `iapeer update` ещё раз — теперь новый бинарь докаскадит остальное. Это разовый bootstrap; начиная с 0.4.2 — одна команда. (Свежая установка сразу ставит каскад-способный бинарь, поэтому двойной прогон ей не нужен.)
78
+
77
79
  ## Откат
78
80
 
79
81
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
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
@@ -1253,9 +1253,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1253
1253
  // and the memory provider's install-time sweep (`--all`). NOT slot-gated here —
1254
1254
  // an explicit operator/provider action; only the BIRTH-time hook is slot-gated.
1255
1255
  const state = positionals[0]
1256
- if (state !== 'off' && state !== 'on') return usage(errOut)
1256
+ if (state !== 'off' && state !== 'on') return argErr(errOut, 'native-memory needs a state — usage: iapeer native-memory <off|on> (--peer <p> | --all)')
1257
1257
  const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
1258
- if (flags.all !== true && !peerName) return usage(errOut)
1258
+ if (flags.all !== true && !peerName) return argErr(errOut, 'native-memory needs a target — pass --peer <p> or --all')
1259
1259
  const { applyNativeMemory } = await import('../launch/nativeMemory.ts')
1260
1260
  const index = readPeersIndex({ env })
1261
1261
  const targets = flags.all === true ? index.peers : index.peers.filter(p => p.personality === peerName)
@@ -1288,7 +1288,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1288
1288
  // the read-only drift detector for verify pipelines — per-hook
1289
1289
  // trusted/missing/drift, exit 1 on anything but full trust.
1290
1290
  const target = positionals[0]
1291
- if (!target) return usage(errOut)
1291
+ if (!target) return argErr(errOut, 'trust-hooks needs a hooks file — usage: iapeer trust-hooks <hooks.json> [--check]')
1292
1292
  const { preSeedCodexHooksTrust, checkCodexHooksTrust } = await import('../launch/codexHooksTrust.ts')
1293
1293
  if (flags.check === true) {
1294
1294
  let checks
@@ -1331,7 +1331,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1331
1331
  // manifest), THEN deploy its declared peer-set (each → provision + per-peer
1332
1332
  // self-config + auto-bootstrap). A runtime whose manifest declares no peers
1333
1333
  // (telegram) is operator-add — use `iapeer create <human> --runtime telegram`.
1334
- if (!positionals[0]) return usage(errOut)
1334
+ if (!positionals[0]) return argErr(errOut, 'install-runtime needs a runtime — usage: iapeer install-runtime <runtime> (e.g. telegram | notifier)')
1335
1335
  const { onboardRuntime } = await import('../runtime/deploy.ts')
1336
1336
  const r = await onboardRuntime({
1337
1337
  runtime: positionals[0] as Runtime,
@@ -1359,7 +1359,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1359
1359
  // restart the runtime's peers via the regular stop/start. The core's own
1360
1360
  // `update` stays foundation-only — this is the runtimes' counterpart.
1361
1361
  const all = flags.all === true
1362
- if (!all && !positionals[0]) return usage(errOut)
1362
+ if (!all && !positionals[0]) return argErr(errOut, 'update-runtime needs a runtime or --all — usage: iapeer update-runtime <runtime> | --all')
1363
1363
  const { updateRuntime, updateAllRuntimes } = await import('../runtime/update.ts')
1364
1364
  const results = all
1365
1365
  ? await updateAllRuntimes({ force: flags.force === true, env, warn: m => errOut(`warn: ${m}\n`) })
@@ -1406,7 +1406,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1406
1406
  // cwd-INDEPENDENT: resolve a location (default ~/.iapeer/peers/<p> or --path),
1407
1407
  // scaffold the folder (no-clobber), then init it. Operator-add for an infra
1408
1408
  // human (telegram) or any agentic peer; provisions + auto-bootstraps infra.
1409
- if (!positionals[0]) return usage(errOut)
1409
+ if (!positionals[0]) return argErr(errOut, 'create needs a peer name — usage: iapeer create <personality> [--runtime r] [--path dir]')
1410
1410
  const explicitRuntime = typeof flags.runtime === 'string' ? (flags.runtime as Runtime) : undefined
1411
1411
  // FU5 — never SILENTLY default to claude when both agentic runtimes are
1412
1412
  // installed. `--runtime` selects the DEFAULT (not the sole runtime); the
@@ -1570,7 +1570,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1570
1570
  : positionals[0]
1571
1571
  ? [positionals[0]]
1572
1572
  : null
1573
- if (!peers) return usage(errOut)
1573
+ if (!peers) return argErr(errOut, 'stop needs a peer or --all — usage: iapeer stop <peer> [runtime] | --all')
1574
1574
  const outcomes = peers.flatMap(p => stopPeer(p, flags.all === true ? undefined : positionals[1], { env }))
1575
1575
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
1576
1576
  return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
@@ -1579,9 +1579,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1579
1579
  // Fleet-switch enabler (codex-parity audit): add an agentic runtime to
1580
1580
  // existing peer(s) — full codex birth chain per target, idempotent.
1581
1581
  const rt = positionals[0]
1582
- if (!rt) return usage(errOut)
1582
+ if (!rt) return argErr(errOut, 'add-runtime needs a runtime — usage: iapeer add-runtime <runtime> (--peer <p> | --all)')
1583
1583
  const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
1584
- if (flags.all !== true && !peerName) return usage(errOut)
1584
+ if (flags.all !== true && !peerName) return argErr(errOut, 'add-runtime needs a target — pass --peer <p> or --all')
1585
1585
  const outcomes = await addRuntime(rt, { peer: peerName, all: flags.all === true, env })
1586
1586
  for (const o of outcomes) out(`${o.personality}: ${o.action}${o.detail ? ` — ${o.detail}` : ''}\n`)
1587
1587
  return outcomes.some(o => o.action === 'failed') ? 1 : 0
@@ -1589,9 +1589,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1589
1589
  case 'default-runtime': {
1590
1590
  // The PRIMARY flip (routing/wake default) — the fleet-switch moment itself.
1591
1591
  const rt = positionals[0]
1592
- if (!rt) return usage(errOut)
1592
+ if (!rt) return argErr(errOut, 'default-runtime needs a runtime — usage: iapeer default-runtime <runtime> (--peer <p> | --all)')
1593
1593
  const peerName = typeof flags.peer === 'string' ? flags.peer : undefined
1594
- if (flags.all !== true && !peerName) return usage(errOut)
1594
+ if (flags.all !== true && !peerName) return argErr(errOut, 'default-runtime needs a target — pass --peer <p> or --all')
1595
1595
  const outcomes = await defaultRuntime(rt, { peer: peerName, all: flags.all === true, env })
1596
1596
  for (const o of outcomes) out(`${o.personality}: ${o.action}${o.detail ? ` — ${o.detail}` : ''}\n`)
1597
1597
  return outcomes.some(o => o.action === 'failed') ? 1 : 0
@@ -1601,7 +1601,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1601
1601
  // §new): the emergency lever for a hung/dead session, bypasses the peer.
1602
1602
  // Source: operator CLI, or telegram-runtime's clean-/new detect (their bot
1603
1603
  // shells `iapeer new <peer> <runtime>` — exit 0 ⟺ fresh session up+ready).
1604
- if (!positionals[0]) return usage(errOut)
1604
+ if (!positionals[0]) return argErr(errOut, 'new needs a peer name — usage: iapeer new <peer> [runtime]')
1605
1605
  const o = await newPeer(positionals[0], positionals[1], { env })
1606
1606
  if (o.action === 'fresh') {
1607
1607
  out(`new: ${o.runtime}-${o.personality} fresh session up\n`)
@@ -1622,7 +1622,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1622
1622
  : positionals[0]
1623
1623
  ? [positionals[0]]
1624
1624
  : null
1625
- if (!peers) return usage(errOut)
1625
+ if (!peers) return argErr(errOut, 'start needs a peer or --all — usage: iapeer start <peer> [runtime] | --all')
1626
1626
  const outcomes = peers.flatMap(p => startPeer(p, flags.all === true ? undefined : positionals[1], { env }))
1627
1627
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}${o.reason ? ` — ${o.reason}` : ''}\n`)
1628
1628
  return outcomes.some(o => o.action === 'refused-foreign-launchd') ? 1 : 0
@@ -1636,7 +1636,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1636
1636
  : positionals[0]
1637
1637
  ? [positionals[0]]
1638
1638
  : null
1639
- if (!peers) return usage(errOut)
1639
+ if (!peers) return argErr(errOut, 'refresh needs a peer or --all — usage: iapeer refresh <peer> [runtime] | --all')
1640
1640
  const outcomes = peers.flatMap(p => refreshPeer(p, flags.all === true ? undefined : positionals[1], { env }))
1641
1641
  for (const o of outcomes) out(`${o.personality} (${o.runtime}): ${o.action}\n`)
1642
1642
  return 0
@@ -1645,7 +1645,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1645
1645
  // Reap a registry record through the locked writer (the operator path over
1646
1646
  // registry.removePeer). Idempotent on an absent peer (exit 0). Refuses a LIVE
1647
1647
  // peer unless --force (orphaning a running session from routing is the risk).
1648
- if (!positionals[0]) return usage(errOut)
1648
+ if (!positionals[0]) return argErr(errOut, 'remove needs a peer name — usage: iapeer remove <peer> [--force]')
1649
1649
  const o = await removePeerCli(positionals[0], { force: flags.force === true, env })
1650
1650
  if (o.action === 'removed') {
1651
1651
  out(`removed "${o.personality}" from the registry\n`)
@@ -1687,7 +1687,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1687
1687
  const mf = flags['message-file']
1688
1688
  message = mf === '-' ? readFileSync(0, 'utf8') : readFileSync(mf, 'utf8')
1689
1689
  }
1690
- if (!positionals[0] || message === null) return usage(errOut)
1690
+ if (!positionals[0] || message === null) return argErr(errOut, 'send needs a target and a message — usage: iapeer send <target> --message <text> (or --message-file <f|->)')
1691
1691
  // --attachment is REPEATABLE; parseArgs collapses repeats (last-wins), so
1692
1692
  // re-scan the raw rest argv to collect every attachment path (else files
1693
1693
  // silently drop — a text-only smoke test would not catch it).
@@ -1935,7 +1935,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1935
1935
  // Ф-F: the always-on infra entrypoint (telegram/notifier), held by launchd.
1936
1936
  // The infra plist runs `iapeer run-infra <personality> <runtime>` (installed
1937
1937
  // binary) instead of `bun launchdRun.ts`. cwd = the launchd WorkingDirectory.
1938
- if (!positionals[0] || !positionals[1]) return usage(errOut)
1938
+ if (!positionals[0] || !positionals[1]) return argErr(errOut, 'run-infra needs a runtime and a personality — usage: iapeer run-infra <runtime> <personality> (internal launchd entrypoint)')
1939
1939
  return await runAlwaysOn(positionals[0], positionals[1], process.cwd())
1940
1940
  }
1941
1941
  case 'self-done': {
@@ -2009,7 +2009,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2009
2009
  case 'interrupt': {
2010
2010
  // In-session control (Ф-E, clean-slash namespace): interrupt a stuck/raving
2011
2011
  // turn (Escape). UNCONDITIONAL — acts on the live session.
2012
- if (!positionals[0]) return usage(errOut)
2012
+ if (!positionals[0]) return argErr(errOut, 'interrupt needs a peer name — usage: iapeer interrupt <peer> [runtime]')
2013
2013
  const r = await routeControl(positionals[0], positionals[1], { name: verb })
2014
2014
  if (!r.ok) {
2015
2015
  errOut(`${verb}: ${r.error.message}\n`)
@@ -2022,7 +2022,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2022
2022
  // Dialogue control: if the session is merely asleep
2023
2023
  // after a clean idle-reap, resume the same dialogue and compact it; if there
2024
2024
  // is no resumable dialogue, fail honestly instead of compacting a fresh one.
2025
- if (!positionals[0]) return usage(errOut)
2025
+ if (!positionals[0]) return argErr(errOut, 'compact needs a peer name — usage: iapeer compact <peer> [runtime]')
2026
2026
  const o = await compactPeer(positionals[0], positionals[1], { env })
2027
2027
  if (o.action === 'compacted') {
2028
2028
  out(`compact → ${o.personality} (${o.runtime})${o.woke ? ' after resume' : ''}\n`)
@@ -2036,7 +2036,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2036
2036
  // `connect telegram <peer> [--token <t>]`. The human owes only the token;
2037
2037
  // alias/bot-add/interface/router-restart are resolved by the system. The
2038
2038
  // FIRST message from the human to the bot activates the chat (platform rule).
2039
- if (positionals[0] !== 'telegram' || !positionals[1]) return usage(errOut)
2039
+ if (positionals[0] !== 'telegram' || !positionals[1]) return argErr(errOut, 'connect needs "telegram <peer>" — usage: iapeer connect telegram <peer> [--token <t>] (e.g. iapeer connect telegram boris)')
2040
2040
  const { connectTelegram } = await import('../connect/index.ts')
2041
2041
  const r = await connectTelegram({
2042
2042
  peer: positionals[1],
@@ -2066,7 +2066,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2066
2066
  // per-runtime (claude project-scope IN the peer cwd / codex global) + enable +
2067
2067
  // call the plugin's `setup` ONLY if its iapeer.json declares it. Idempotent and
2068
2068
  // fleet-safe — claude is keyed by the peer's projectPath. `enable <plugin> [peer]`.
2069
- if (!positionals[0]) return usage(errOut)
2069
+ if (!positionals[0]) return argErr(errOut, 'enable needs a capability/plugin — usage: iapeer enable <plugin> [peer]')
2070
2070
  const { enableCapability } = await import('../enable/index.ts')
2071
2071
  const r = enableCapability({
2072
2072
  plugin: positionals[0],
@@ -2081,7 +2081,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2081
2081
  return r.runtimes.some(rt => rt.state === 'failed') || r.setup === 'failed' ? 1 : 0
2082
2082
  }
2083
2083
  case 'attach': {
2084
- if (!positionals[0]) return usage(errOut)
2084
+ if (!positionals[0]) return argErr(errOut, 'attach needs a peer name — usage: iapeer attach <peer> [runtime]')
2085
2085
  const r = await attachPeer({ personality: positionals[0], runtime: positionals[1], env })
2086
2086
  if (!r.ok) {
2087
2087
  errOut(`attach: ${r.reason}\n`)
@@ -2157,7 +2157,14 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2157
2157
  const id = resolveIdentity({ env })
2158
2158
  const r = await folderLaunch({ cwd: process.cwd(), runtime: verb, env, cfg })
2159
2159
  if (r.status === 'FAILED') {
2160
- errOut(`launch: ${r.reason}\n`)
2160
+ // A bare token format-matches a runtime, so it dispatched here as `iapeer
2161
+ // <runtime>` (launch the cwd peer on that runtime). When that fails, the user
2162
+ // most often just MISTYPED a command — surface BOTH readings so the message is
2163
+ // actionable instead of a bare "runtime not declared" (FU13).
2164
+ errOut(
2165
+ `iapeer: "${verb}" — ${r.reason}\n` +
2166
+ ' If you meant a command, run `iapeer help`. `iapeer <runtime>` launches a runtime declared for the current peer.\n',
2167
+ )
2161
2168
  return 1
2162
2169
  }
2163
2170
  const identity = r.process_address ?? buildProcessAddress(verb, id.personality)
@@ -2169,6 +2176,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
2169
2176
  out(`launched ${identity} (fresh)\n`)
2170
2177
  return 0
2171
2178
  }
2179
+ errOut(`iapeer: unknown verb "${verb ?? ''}"\n`)
2172
2180
  return usage(errOut)
2173
2181
  }
2174
2182
  }
@@ -2183,6 +2191,15 @@ function usage(errOut: (s: string) => void): number {
2183
2191
  return 2
2184
2192
  }
2185
2193
 
2194
+ /** A TARGETED argument error (FU13): a one-line, addressed, actionable reason for THIS
2195
+ * verb — never the generic top-level help wall (which reads as "nothing happened" to a
2196
+ * user who just mistyped one verb). `message` should name what's missing + the verb's
2197
+ * own form, e.g. "connect telegram needs a peer — usage: iapeer connect telegram <peer>". */
2198
+ function argErr(errOut: (s: string) => void, message: string): number {
2199
+ errOut(`iapeer: ${message}\n`)
2200
+ return 2
2201
+ }
2202
+
2186
2203
  /** Interactive default-runtime picker (FU5) — only called on a real TTY when more
2187
2204
  * than one agentic runtime is installed and no `--runtime` was given. Accepts a
2188
2205
  * number or the runtime name; empty input takes the first (the listed default). */
@@ -46,6 +46,14 @@ export interface OnboardOptions {
46
46
  /** Restrict to these runtimes (default both). */
47
47
  runtimes?: OnboardRuntime[]
48
48
  env?: NodeJS.ProcessEnv
49
+ /** Injectable (tests). Default registerMarketplace. */
50
+ register?: (runtime: OnboardRuntime, env: NodeJS.ProcessEnv) => { ok: boolean; detail?: string }
51
+ /** Injectable (tests). Default isMarketplaceRegistered. */
52
+ isRegistered?: (runtime: OnboardRuntime, env: NodeJS.ProcessEnv) => boolean
53
+ /** Injectable (tests). Default Bun.sleepSync — the pause before the one transient retry. */
54
+ sleep?: (ms: number) => void
55
+ /** Delay (ms) before the single non-timeout retry. Default 2000. */
56
+ retryDelayMs?: number
49
57
  }
50
58
 
51
59
  // ─────────────────────────────────────────────────────────────────────────────
@@ -192,13 +200,17 @@ export function refreshMarketplace(
192
200
  export function onboardHost(opts: OnboardOptions = {}): OnboardResult {
193
201
  const env = opts.env ?? process.env
194
202
  const runtimes = opts.runtimes ?? (['claude', 'codex'] as OnboardRuntime[])
203
+ const isRegistered = opts.isRegistered ?? isMarketplaceRegistered
204
+ const register = opts.register ?? registerMarketplace
205
+ const sleep = opts.sleep ?? ((ms: number) => Bun.sleepSync(ms))
206
+ const retryDelayMs = opts.retryDelayMs ?? 2000
195
207
  const marketplaces: OnboardRuntimeResult[] = []
196
208
  for (const runtime of runtimes) {
197
209
  if (!isExecutable(runtimeBin(runtime, env), env)) {
198
210
  marketplaces.push({ runtime, state: 'runtime-missing' })
199
211
  continue
200
212
  }
201
- if (isMarketplaceRegistered(runtime, env)) {
213
+ if (isRegistered(runtime, env)) {
202
214
  marketplaces.push({ runtime, state: 'already-registered' })
203
215
  continue
204
216
  }
@@ -206,13 +218,59 @@ export function onboardHost(opts: OnboardOptions = {}): OnboardResult {
206
218
  marketplaces.push({ runtime, state: 'would-register' })
207
219
  continue
208
220
  }
209
- const r = registerMarketplace(runtime, env)
210
- marketplaces.push({ runtime, state: r.ok ? 'registered' : 'failed', detail: r.detail })
221
+ marketplaces.push(resolveMarketplaceAdd(runtime, env, register, isRegistered, sleep, retryDelayMs))
211
222
  }
212
223
  const noop = marketplaces.every(m => m.state !== 'registered')
213
224
  return { marketplaces, noop }
214
225
  }
215
226
 
227
+ /**
228
+ * One `marketplace add` with first-run-transient resilience (Arthur's fresh-run found a
229
+ * scary red "failed" on a step that actually self-heals — the failure is transient, the
230
+ * marketplace ends up registered). Layered:
231
+ * (a) SELF-HEAL: if the add reports failure but a re-check shows the marketplace IS
232
+ * present (it landed despite a non-zero tail / a race), report `registered`.
233
+ * (c) ONE retry after a short delay — only for a NON-timeout transient (a network/git
234
+ * blip on the GitHub clone). NEVER on a timeout: a wedged / macOS-launch-approval-
235
+ * parked binary would just re-wedge, doubling the wait.
236
+ * (b) on a timeout signature, report `failed` with the macOS-approval advisory; on any
237
+ * other persistent failure, a transient-retry hint. The marketplace is OPTIONAL for
238
+ * core function, so a clear actionable line beats a bare "failed".
239
+ */
240
+ function resolveMarketplaceAdd(
241
+ runtime: OnboardRuntime,
242
+ env: NodeJS.ProcessEnv,
243
+ register: (r: OnboardRuntime, e: NodeJS.ProcessEnv) => { ok: boolean; detail?: string },
244
+ isRegistered: (r: OnboardRuntime, e: NodeJS.ProcessEnv) => boolean,
245
+ sleep: (ms: number) => void,
246
+ retryDelayMs: number,
247
+ ): OnboardRuntimeResult {
248
+ const first = register(runtime, env)
249
+ if (first.ok) return { runtime, state: 'registered' }
250
+ // (a) self-heal — present despite the reported error?
251
+ if (isRegistered(runtime, env)) {
252
+ return { runtime, state: 'registered', detail: 'add reported an error, but the marketplace is present' }
253
+ }
254
+ const timedOut = /timed out/i.test(first.detail ?? '')
255
+ if (!timedOut) {
256
+ // (c) one retry for a non-timeout transient.
257
+ sleep(retryDelayMs)
258
+ const second = register(runtime, env)
259
+ if (second.ok || isRegistered(runtime, env)) return { runtime, state: 'registered', detail: 'succeeded on retry' }
260
+ return {
261
+ runtime,
262
+ state: 'failed',
263
+ detail: `${second.detail ?? first.detail} — transient (network/git)? re-run \`iapeer onboard\`; the marketplace is optional for core function`,
264
+ }
265
+ }
266
+ // (b) timeout signature → the known macOS launch-approval park.
267
+ return {
268
+ runtime,
269
+ state: 'failed',
270
+ detail: `${first.detail} — macOS may be verifying the runtime binary; approve it in System Settings → Privacy & Security, then re-run \`iapeer onboard\``,
271
+ }
272
+ }
273
+
216
274
  /**
217
275
  * macOS Full Disk Access (TCC) advisory printed at the END of onboard. The runtime permission layer
218
276
  * (`--add-dir` / `additionalDirectories` for claude, the sandbox flags for codex) governs what the