@agfpd/iapeer 0.2.78 → 0.2.80

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/LICENSE CHANGED
@@ -187,7 +187,7 @@
187
187
  same "printed page" as the copyright notice for easier
188
188
  identification within third-party archives.
189
189
 
190
- Copyright [yyyy] [name of copyright owner]
190
+ Copyright 2026 Artur Agafapudov
191
191
 
192
192
  Licensed under the Apache License, Version 2.0 (the "License");
193
193
  you may not use this file except in compliance with the License.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.78",
3
+ "version": "0.2.80",
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'
@@ -925,7 +925,7 @@ const USAGE = `usage: iapeer <verb> [args]
925
925
  it up on the peer's first message); always-on: launchctl bootstrap. --all = every registered peer
926
926
  remove <peer> [--force] delete a peer's registry record (locked writer); refuses a LIVE peer unless --force
927
927
  send <target> (--message <text> | --message-file <f|->) [--from <id>] [--attachment <p>]… [--topic <t>] manual IAP send (fallback)
928
- <runtime> launch the cwd's peer (ALWAYS fresh)
928
+ <runtime> launch the cwd's peer FRESH; on a TTY drop into it (like attach), else detached
929
929
  connect telegram <peer> [--token <t>] attach a telegram bot to a peer (bot add → interface → router restart; asks only the token)
930
930
  enable <plugin> [peer] [--no-setup] install + enable an agfpd capability for a peer
931
931
  new <peer> [runtime] UNCONDITIONAL fresh restart: canary-clean kill + fresh wake (emergency lever for a hung/dead session — bypasses the peer; exit 0 = fresh session up+ready)
@@ -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-память):
@@ -1735,36 +1743,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1735
1743
  return 1
1736
1744
  }
1737
1745
  out(`${r.woke ? 'woke + ' : ''}attaching ${r.identity}…\n`)
1738
- // Attach-window observability (whether a death happened under attach was
1739
- // answerable only INDIRECTLY via wake cause=attach — i.e. only when the
1740
- // session was dead at attach time). Bracket the operator's client
1741
- // window in lifecycle.log: ev=attach at entry, ev=attach-end (with the tmux
1742
- // client's exit code) when the operator detaches or the server dies under
1743
- // the client. A death timestamp inside an attach…attach-end window now
1744
- // reads directly off the log.
1745
1746
  const attachCfg = loadLifecycleConfig(env)
1746
- appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach', identity: r.identity, woke: String(r.woke) }, { env })
1747
- // SPAWN-FLIP Ф0b-3: a supervisor-HOSTED session has no tmux to attach — hand the operator to
1748
- // the supervisor client (runSupervisorClient: raw passthrough + repaint-on-attach + Ctrl-]
1749
- // detach + both reattach port-deps). It calls process.exit on every terminal path, so bracket
1750
- // attach-end via an exit hook (appendFileSync is signal/exit-safe). Flag-off → tmux attach.
1751
- if (hostSessionAlive(r.identity)) {
1752
- process.on('exit', () =>
1753
- appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach-end', identity: r.identity, rc: process.exitCode ?? 0 }, { env }),
1754
- )
1755
- await runSupervisorClient(hostRunDir(), r.identity)
1756
- return 0 // unreachable — runSupervisorClient exits on detach / session end
1757
- }
1758
- // Drop the operator into the session. `env -u TMUX` so a nested attach from
1759
- // inside tmux does not error ("sessions should be nested with care").
1760
- const attachEnv = { ...env }
1761
- delete attachEnv.TMUX
1762
- const a = spawnSync('tmux', ['-S', r.socketPath, 'attach', '-t', r.identity], {
1763
- stdio: 'inherit',
1764
- env: attachEnv as Record<string, string>,
1765
- })
1766
- appendLifecycleEvent(attachCfg.eventLogDir, { ev: 'attach-end', identity: r.identity, rc: a.status ?? -1 }, { env })
1767
- return a.status ?? 0
1747
+ return await attachIntoSession(r.identity, r.socketPath, env, attachCfg.eventLogDir, r.woke)
1768
1748
  }
1769
1749
  case 'shadow': {
1770
1750
  // Read-only fidelity OBSERVER for the tmux→pty migration burn-in (track «b»). Compares
@@ -1823,14 +1803,25 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1823
1803
  return await runSupervisorCli(rest)
1824
1804
  }
1825
1805
  default: {
1826
- // `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh.
1806
+ // `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh. On a TTY this is
1807
+ // the human "start a fresh session and work in it" verb (parallel to `attach`, which resumes):
1808
+ // after a successful fresh bring-up it drops the operator straight into the new session.
1809
+ // Non-TTY (scripted / piped) keeps the fire-and-forget behavior — report the launch and exit.
1827
1810
  if (verb && isRuntime(verb)) {
1828
- const r = await folderLaunch({ cwd: process.cwd(), runtime: verb, env })
1811
+ const cfg = loadLifecycleConfig(env)
1812
+ const id = resolveIdentity({ env })
1813
+ const r = await folderLaunch({ cwd: process.cwd(), runtime: verb, env, cfg })
1829
1814
  if (r.status === 'FAILED') {
1830
1815
  errOut(`launch: ${r.reason}\n`)
1831
1816
  return 1
1832
1817
  }
1833
- out(`launched ${r.process_address} (fresh)\n`)
1818
+ const identity = r.process_address ?? buildProcessAddress(verb, id.personality)
1819
+ if (process.stdin.isTTY && process.stdout.isTTY) {
1820
+ const socketPath = buildSocketPath(verb, id.personality, cfg.sockDir)
1821
+ out(`launched ${identity} (fresh) — attaching…\n`)
1822
+ return await attachIntoSession(identity, socketPath, env, cfg.eventLogDir, true)
1823
+ }
1824
+ out(`launched ${identity} (fresh)\n`)
1834
1825
  return 0
1835
1826
  }
1836
1827
  return usage(errOut)
@@ -1853,6 +1844,41 @@ function defaultFromIdentity(env: NodeJS.ProcessEnv): string {
1853
1844
  return resolveIdentity({ env }).address
1854
1845
  }
1855
1846
 
1847
+ /** Drop the operator into a live peer session — the interactive open shared by `attach` (resume)
1848
+ * and a TTY `iapeer <runtime>` (fresh launch). A supervisor-HOSTED session has no tmux to attach,
1849
+ * so the operator is handed to the supervisor client (raw passthrough + repaint-on-attach + Ctrl-]
1850
+ * detach); otherwise `tmux attach`. The operator window is bracketed in lifecycle.log
1851
+ * (ev=attach … ev=attach-end) so a death timestamp inside the window reads directly off the log.
1852
+ * Returns the client exit code. */
1853
+ async function attachIntoSession(
1854
+ identity: string,
1855
+ socketPath: string,
1856
+ env: NodeJS.ProcessEnv,
1857
+ eventLogDir: string,
1858
+ woke: boolean,
1859
+ ): Promise<number> {
1860
+ appendLifecycleEvent(eventLogDir, { ev: 'attach', identity, woke: String(woke) }, { env })
1861
+ if (hostSessionAlive(identity)) {
1862
+ // runSupervisorClient calls process.exit on every terminal path → bracket attach-end via an
1863
+ // exit hook (appendFileSync is signal/exit-safe).
1864
+ process.on('exit', () =>
1865
+ appendLifecycleEvent(eventLogDir, { ev: 'attach-end', identity, rc: process.exitCode ?? 0 }, { env }),
1866
+ )
1867
+ await runSupervisorClient(hostRunDir(), identity)
1868
+ return 0 // unreachable — runSupervisorClient exits on detach / session end
1869
+ }
1870
+ // `env -u TMUX` so a nested attach from inside tmux does not error ("sessions should be nested
1871
+ // with care").
1872
+ const attachEnv = { ...env }
1873
+ delete attachEnv.TMUX
1874
+ const a = spawnSync('tmux', ['-S', socketPath, 'attach', '-t', identity], {
1875
+ stdio: 'inherit',
1876
+ env: attachEnv as Record<string, string>,
1877
+ })
1878
+ appendLifecycleEvent(eventLogDir, { ev: 'attach-end', identity, rc: a.status ?? -1 }, { env })
1879
+ return a.status ?? 0
1880
+ }
1881
+
1856
1882
  if (import.meta.main) {
1857
1883
  runCli(process.argv.slice(2)).then(code => process.exit(code))
1858
1884
  }
@@ -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 {
@@ -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
@@ -1,12 +1,13 @@
1
1
  // Supervisor DAEMON — holds the pty + child + headless-xterm model + capability responder
2
- // independent of any terminal; clients attach/detach over a unix socket (cutover Block 2, ported
3
- // from the validated PoC pts.mjs). DARK: reachable only via the `supervisor` CLI verb (dynamic
4
- // import), so @xterm never loads in the delivery hot path. NOT wired into launch/lifecycle — it
5
- // serves NOTHING on the live fleet yet; it is validated on throwaway sessions only.
2
+ // independent of any terminal; clients attach/detach over a unix socket. It HOSTS tui/router peers
3
+ // by default (the pty-host launch path; opt-out per-peer via a `.no-pty-host` marker), and is also
4
+ // reachable manually via the `supervisor` CLI verb for throwaway validation. It runs in its OWN
5
+ // detached process, dynamic-imported from the launch path (supervisor/index.ts → daemon.ts), so
6
+ // @xterm never loads in the central router's delivery path nor in the launch/lifecycle process.
6
7
  //
7
8
  // Loads @xterm/headless + @xterm/addon-serialize (the latter is the repaint source: a reattaching
8
9
  // client gets the full current screen even if the child is silent). Both bundle under
9
- // `bun build --compile` (pure JS; the Bun-native pty has no native addon — PoC-proven).
10
+ // `bun build --compile` (pure JS; the Bun-native pty has no native addon).
10
11
  import { Terminal } from '@xterm/headless'
11
12
  import { SerializeAddon } from '@xterm/addon-serialize'
12
13
  import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'