@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 +1 -1
- package/package.json +1 -1
- package/src/cli/index.ts +61 -35
- package/src/daemon/main.ts +38 -2
- package/src/launch/launchd.ts +27 -0
- package/src/supervisor/daemon.ts +6 -5
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
|
|
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
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
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/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
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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'
|