@agfpd/iapeer 0.4.20 → 0.4.22
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/CHANGELOG.md +31 -1
- package/package.json +1 -1
- package/src/cli/index.ts +71 -3
- package/src/transport/index.ts +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.4.22] - 2026-06-22
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`iapeer live-runtime <peer>`** — prints the peer's CURRENT live runtime: the
|
|
18
|
+
freshest-pane-log among its **pid-alive** supervisor sessions (a peer can be alive on
|
|
19
|
+
more than one runtime at once — a `/codex` flip can leave the old session running
|
|
20
|
+
alongside the new). This is the authoritative, real-time signal for "what runtime is
|
|
21
|
+
this peer running right now" — NOT `default_runtime` (only the wake-default) and NOT a
|
|
22
|
+
`.session` wake-record (alive-filtered only on a supervise-tick latency). Exit 0 + the
|
|
23
|
+
runtime on stdout when one is live; exit 1 + no output when none is alive. For external
|
|
24
|
+
consumers (e.g. telegram-runtime's typing/tool-use indicator) that must resolve the live
|
|
25
|
+
runtime of a peer running on a non-default runtime, instead of inferring it. Exposed as
|
|
26
|
+
`resolveLiveRuntime` (transport).
|
|
27
|
+
|
|
28
|
+
## [0.4.21] - 2026-06-22
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **`iapeer remove` of an always-on peer no longer leaves an orphan launchd plist.** An
|
|
33
|
+
infra peer (notifier/telegram/voicetalk) has a `com.iapeer.<p>` plist with KeepAlive;
|
|
34
|
+
`remove` dropped only the registry record, so launchd kept relaunching `iapeer run-infra
|
|
35
|
+
<p> <rt>` against the now-deleted record → crash-loop (the SRE peer alerted
|
|
36
|
+
"gone-without-disable"). `remove` now boots out + removes the **foundation-owned** plist
|
|
37
|
+
BEFORE the registry remove (closing the crash-loop window), and the **H4 fleet-guard**
|
|
38
|
+
refuses a FOREIGN persistent-peer plist (`--force` drops only the registry record and
|
|
39
|
+
leaves the foreign plist intact). A warm peer (no plist) is unaffected.
|
|
40
|
+
|
|
13
41
|
## [0.4.20] - 2026-06-22
|
|
14
42
|
|
|
15
43
|
### Fixed
|
|
@@ -312,7 +340,9 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
|
|
|
312
340
|
- **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
|
|
313
341
|
(timer = cron, watcher = event) are all first-class and addressed the same way.
|
|
314
342
|
|
|
315
|
-
[Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.
|
|
343
|
+
[Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.22...HEAD
|
|
344
|
+
[0.4.22]: https://github.com/agfpd/iapeer/compare/v0.4.21...v0.4.22
|
|
345
|
+
[0.4.21]: https://github.com/agfpd/iapeer/compare/v0.4.20...v0.4.21
|
|
316
346
|
[0.4.20]: https://github.com/agfpd/iapeer/compare/v0.4.19...v0.4.20
|
|
317
347
|
[0.4.19]: https://github.com/agfpd/iapeer/compare/v0.4.18...v0.4.19
|
|
318
348
|
[0.4.18]: https://github.com/agfpd/iapeer/compare/v0.4.17...v0.4.18
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// sentinel-marked always-on plist) are stop/start-able.
|
|
12
12
|
|
|
13
13
|
import { spawnSync } from 'child_process'
|
|
14
|
-
import { existsSync, readFileSync, renameSync } from 'fs'
|
|
14
|
+
import { existsSync, readFileSync, renameSync, rmSync } from 'fs'
|
|
15
15
|
import { fileURLToPath } from 'url'
|
|
16
16
|
import { dirname, join } from 'path'
|
|
17
17
|
import {
|
|
@@ -693,8 +693,13 @@ export async function compactPeer(
|
|
|
693
693
|
|
|
694
694
|
export interface RemoveOutcome {
|
|
695
695
|
personality: string
|
|
696
|
-
action: 'removed' | 'absent' | 'refused-live'
|
|
696
|
+
action: 'removed' | 'absent' | 'refused-live' | 'refused-foreign-launchd'
|
|
697
697
|
reason?: string
|
|
698
|
+
/** Always-on plist teardown outcome (`bootout + plist removed` / `skipped-sandbox` /
|
|
699
|
+
* `skipped-foreign …`). Present only when the peer had a com.iapeer.<p> plist — an
|
|
700
|
+
* always-on (infra) peer. Without this, remove orphaned the loaded plist → launchd
|
|
701
|
+
* KeepAlive crash-looped `run-infra` against the deleted record. */
|
|
702
|
+
plistTeardown?: string
|
|
698
703
|
/** The removed peer's cwd (registry fact, captured BEFORE the removal). remove
|
|
699
704
|
* deliberately keeps the folder — user data is never deleted by a registry reap
|
|
700
705
|
* (say so in the output instead of leaving silent orphans). */
|
|
@@ -744,6 +749,47 @@ export async function removePeerCli(
|
|
|
744
749
|
}
|
|
745
750
|
}
|
|
746
751
|
}
|
|
752
|
+
// PLIST TEARDOWN (the always-on / launchd-managed peer). An infra peer
|
|
753
|
+
// (notifier/telegram/voicetalk) has a com.iapeer.<p> plist with KeepAlive. Removing
|
|
754
|
+
// ONLY the registry record leaves the plist LOADED → launchd keeps relaunching
|
|
755
|
+
// `iapeer run-infra <p> <rt>` against the now-deleted record → CRASH-LOOP (doc alerts
|
|
756
|
+
// "gone-without-disable"). So bootout the job BEFORE the registry remove (stop KeepAlive
|
|
757
|
+
// before its target vanishes — no crash-loop window), then rm the plist file.
|
|
758
|
+
// FLEET GUARD (H4): a FOREIGN persistent-peer plist is off-limits — refuse (unless
|
|
759
|
+
// --force, which then drops ONLY the registry record and leaves the foreign plist intact).
|
|
760
|
+
let plistTeardown: string | undefined
|
|
761
|
+
if (isLaunchdManaged(personality, env)) {
|
|
762
|
+
if (isForeignLaunchd(personality, env)) {
|
|
763
|
+
if (!opts.force) {
|
|
764
|
+
return {
|
|
765
|
+
personality,
|
|
766
|
+
action: 'refused-foreign-launchd',
|
|
767
|
+
reason: `"${personality}" is managed by persistent-peer (foreign launchd plist) — the foundation will not remove it (H4). Use persistent-peer tooling, or --force to drop ONLY the registry record (the foreign plist is left intact).`,
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
plistTeardown = 'skipped-foreign (H4 — foreign plist left intact; --force dropped only the registry record)'
|
|
771
|
+
} else {
|
|
772
|
+
const plistPath = launchdPlistPath(personality, env)
|
|
773
|
+
// Sandbox/test: never invoke real launchctl (a test peer's label isn't a real job);
|
|
774
|
+
// still remove the (temp-dir) plist file so the teardown is observable.
|
|
775
|
+
let bootoutNote: string
|
|
776
|
+
if (env.IAPEER_TEST_SANDBOX === '1') {
|
|
777
|
+
bootoutNote = 'skipped-sandbox'
|
|
778
|
+
} else {
|
|
779
|
+
const r = spawnSync('launchctl', ['bootout', `gui/${uid()}/${launchdLabel(personality)}`], { encoding: 'utf8' })
|
|
780
|
+
// bootout exits non-zero when the job was already unloaded — benign for a teardown.
|
|
781
|
+
bootoutNote = r.status === 0 ? 'bootout' : `bootout (exit ${r.status}${(r.stderr ?? '').trim() ? `: ${(r.stderr ?? '').trim()}` : ''})`
|
|
782
|
+
}
|
|
783
|
+
let rmNote: string
|
|
784
|
+
try {
|
|
785
|
+
rmSync(plistPath, { force: true })
|
|
786
|
+
rmNote = ' + plist removed'
|
|
787
|
+
} catch (e) {
|
|
788
|
+
rmNote = ` (plist rm FAILED: ${e instanceof Error ? e.message : String(e)} — remove ${plistPath} manually)`
|
|
789
|
+
}
|
|
790
|
+
plistTeardown = `${bootoutNote}${rmNote}`
|
|
791
|
+
}
|
|
792
|
+
}
|
|
747
793
|
await removePeer(personality, { env })
|
|
748
794
|
// v1.2 UNPROVISION joint (контракт §Provision провайдера): a provision-declaring
|
|
749
795
|
// slot gets its unprovision command per agentic runtime with occasion=remove —
|
|
@@ -822,6 +868,7 @@ export async function removePeerCli(
|
|
|
822
868
|
action: 'removed',
|
|
823
869
|
cwd: peer.cwd,
|
|
824
870
|
purgedState,
|
|
871
|
+
...(plistTeardown ? { plistTeardown } : {}),
|
|
825
872
|
...(unprovisionOutcomes.length ? { unprovision: unprovisionOutcomes } : {}),
|
|
826
873
|
...(trustCleaned ? { codexTrust: trustCleaned } : {}),
|
|
827
874
|
...(hooksTrustCleaned ? { codexHooksTrust: hooksTrustCleaned } : {}),
|
|
@@ -1092,6 +1139,7 @@ const VERBS: ReadonlyArray<{ sig: string; desc: string }> = [
|
|
|
1092
1139
|
desc: 'backbone host-phase: marketplace → notifier → telegram (human peer) → memory → voice (all default YES). --accept-risk (or IAPEER_ACCEPT_RISK=1) accepts the security warning non-interactively',
|
|
1093
1140
|
},
|
|
1094
1141
|
{ sig: 'status', desc: 'host snapshot: version, daemon health, memory + voice slots (<provider> | none)' },
|
|
1142
|
+
{ sig: 'live-runtime <peer>', desc: 'print the peer\'s CURRENT live runtime (freshest pane-log among pid-alive sessions; NOT default_runtime). Exit 1 + no output if none alive' },
|
|
1095
1143
|
{ sig: 'install-runtime <runtime> [--package pkg] [--npx]', desc: 'npx-install a runtime package + deploy its declared peer-set' },
|
|
1096
1144
|
{ sig: 'update-runtime <runtime> | --all [--force]', desc: "version-gate → re-install + re-provision declared set → restart the runtime's peers" },
|
|
1097
1145
|
{ sig: 'init [cwd] [--runtime r] [--description d]', desc: 'onboard the CURRENT folder as a peer (name = folder name; identity + MCP + doctrine)' },
|
|
@@ -1454,6 +1502,21 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1454
1502
|
out(formatHostStatus(s))
|
|
1455
1503
|
return s.daemon.healthy ? 0 : 1
|
|
1456
1504
|
}
|
|
1505
|
+
case 'live-runtime': {
|
|
1506
|
+
// The AUTHORITATIVE current live runtime of a peer (machine-readable, for external
|
|
1507
|
+
// consumers like telegram-runtime's typing indicator). Prints the runtime the peer
|
|
1508
|
+
// is RUNNING right now — the freshest-pane-log among its PID-alive supervisor
|
|
1509
|
+
// sessions — NOT default_runtime (the wake-default) and NOT a .session wake-record.
|
|
1510
|
+
// A peer can be alive on >1 runtime (a /codex flip leaves both); this picks the
|
|
1511
|
+
// currently-active. Exit 0 + the runtime on stdout when one is live; exit 1 + no
|
|
1512
|
+
// output when none is alive (queryable: `rt=$(iapeer live-runtime <p>) || handle-down`).
|
|
1513
|
+
if (!positionals[0]) return argErr(errOut, 'live-runtime needs a peer name — usage: iapeer live-runtime <peer>')
|
|
1514
|
+
const { resolveLiveRuntime } = await import('../transport/index.ts')
|
|
1515
|
+
const rt = resolveLiveRuntime(positionals[0])
|
|
1516
|
+
if (!rt) return 1 // no live session — no output, non-zero
|
|
1517
|
+
out(`${rt}\n`)
|
|
1518
|
+
return 0
|
|
1519
|
+
}
|
|
1457
1520
|
case 'install-runtime': {
|
|
1458
1521
|
// §6 onboard a runtime END-TO-END: npx-install the package (auto-resolved from
|
|
1459
1522
|
// the built-in runtime→package registry, or --package; self-deploys bin +
|
|
@@ -1806,6 +1869,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1806
1869
|
const o = await removePeerCli(positionals[0], { force: flags.force === true, env })
|
|
1807
1870
|
if (o.action === 'removed') {
|
|
1808
1871
|
out(`removed "${o.personality}" from the registry\n`)
|
|
1872
|
+
// Always-on plist teardown (bootout + rm) — an orphan loaded plist would
|
|
1873
|
+
// KeepAlive-crash-loop run-infra against the deleted record.
|
|
1874
|
+
if (o.plistTeardown) {
|
|
1875
|
+
out(`launchd plist: ${o.plistTeardown}\n`)
|
|
1876
|
+
}
|
|
1809
1877
|
// v1.2: the provider unwound its surfaces (occasion=remove) — say how it went.
|
|
1810
1878
|
if (o.unprovision?.length) {
|
|
1811
1879
|
out(`memory unprovision: ${o.unprovision.join(', ')}\n`)
|
|
@@ -1830,7 +1898,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1830
1898
|
}
|
|
1831
1899
|
} else if (o.action === 'absent') out(`"${o.personality}" not registered — no-op\n`)
|
|
1832
1900
|
else errOut(`remove: ${o.reason}\n`)
|
|
1833
|
-
return o.action === 'refused-live' ? 1 : 0
|
|
1901
|
+
return o.action === 'refused-live' || o.action === 'refused-foreign-launchd' ? 1 : 0
|
|
1834
1902
|
}
|
|
1835
1903
|
case 'rename': {
|
|
1836
1904
|
// FULL folder rename (Arthur's invariant: personality == basename(cwd)): mv the
|
package/src/transport/index.ts
CHANGED
|
@@ -279,6 +279,33 @@ function defaultPaneLogMtime(address: string): number | null {
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/**
|
|
283
|
+
* The CURRENT live runtime of a peer — the AUTHORITATIVE, real-time signal for "what
|
|
284
|
+
* runtime is this peer running right now" (NOT default_runtime, which is only the wake
|
|
285
|
+
* DEFAULT, and NOT a `.session` file, a wake-record cleaned on a supervise-tick latency).
|
|
286
|
+
*
|
|
287
|
+
* Resolution: among the peer's PID-ALIVE supervisor sessions (listHostedPeers — the same
|
|
288
|
+
* source delivery uses; `sessionAlive` = pidfile exists AND kill-0), pick the one with the
|
|
289
|
+
* FRESHEST pane-log. A peer can be alive on >1 runtime at once (a `/codex` flip can leave
|
|
290
|
+
* the old session running alongside the new), so the freshest-pane-log among the alive set
|
|
291
|
+
* is the currently-active surface. The alive-filter is what fixes the flip-race: a
|
|
292
|
+
* just-died runtime's pane-log / .sock / .session all LINGER, but its pid is dead → it is
|
|
293
|
+
* excluded here. Returns null when no session is alive.
|
|
294
|
+
*
|
|
295
|
+
* Deps are injectable for hermetic tests (the defaults read the real run-dir + pane-logs).
|
|
296
|
+
*/
|
|
297
|
+
export function resolveLiveRuntime(
|
|
298
|
+
personality: string,
|
|
299
|
+
deps: { aliveRuntimes?: (p: string) => string[]; paneLogMtime?: (address: string) => number | null } = {},
|
|
300
|
+
): string | null {
|
|
301
|
+
const aliveOf = deps.aliveRuntimes ?? ((p: string) => listHostedPeers().filter(h => h.personality === p).map(h => h.runtime))
|
|
302
|
+
const paneMt = deps.paneLogMtime ?? defaultPaneLogMtime
|
|
303
|
+
const alive = aliveOf(personality)
|
|
304
|
+
if (alive.length <= 1) return alive[0] ?? null
|
|
305
|
+
// >1 alive → the freshest pane-log mtime (the currently-rendering turn surface).
|
|
306
|
+
return alive.reduce((best, rt) => ((paneMt(`${rt}-${personality}`) ?? 0) > (paneMt(`${best}-${personality}`) ?? 0) ? rt : best))
|
|
307
|
+
}
|
|
308
|
+
|
|
282
309
|
|
|
283
310
|
// ─── Compact done gate (dialogue control must report FACT, not keystroke delivery) ──
|
|
284
311
|
|