@agfpd/iapeer 0.4.19 → 0.4.21
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 +34 -1
- package/package.json +1 -1
- package/src/cli/index.ts +55 -3
- package/src/transport/index.ts +38 -7
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.4.21] - 2026-06-22
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`iapeer remove` of an always-on peer no longer leaves an orphan launchd plist.** An
|
|
18
|
+
infra peer (notifier/telegram/voicetalk) has a `com.iapeer.<p>` plist with KeepAlive;
|
|
19
|
+
`remove` dropped only the registry record, so launchd kept relaunching `iapeer run-infra
|
|
20
|
+
<p> <rt>` against the now-deleted record → crash-loop (the SRE peer alerted
|
|
21
|
+
"gone-without-disable"). `remove` now boots out + removes the **foundation-owned** plist
|
|
22
|
+
BEFORE the registry remove (closing the crash-loop window), and the **H4 fleet-guard**
|
|
23
|
+
refuses a FOREIGN persistent-peer plist (`--force` drops only the registry record and
|
|
24
|
+
leaves the foreign plist intact). A warm peer (no plist) is unaffected.
|
|
25
|
+
|
|
26
|
+
## [0.4.20] - 2026-06-22
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Codex hosted-delivery no longer false-fails.** The warm-delivery landing-confirm
|
|
31
|
+
(`deliverViaHost`) confirmed a message "landed" only by a transcript/session-jsonl mtime
|
|
32
|
+
advance within a 3000ms grace. Claude writes the user turn to its transcript promptly, but
|
|
33
|
+
**codex writes its session jsonl only at model-turn-start** (gated by time-to-first-token,
|
|
34
|
+
~4-6s after submit) — so every codex delivery structurally false-failed with `"no transcript
|
|
35
|
+
advance within 3000ms"` even though the message landed and codex was processing it (proven in
|
|
36
|
+
delivery.log: an `ok=false` immediately followed by codex recording the input and replying).
|
|
37
|
+
The confirm now also accepts a **pane-log (TUI render-stream) mtime advance** — the same true
|
|
38
|
+
active-turn signal the 0.4.16 idle-reap fix uses; it ticks ~1s as the session renders the
|
|
39
|
+
working state on submit, giving codex delivery parity with claude without lengthening the
|
|
40
|
+
(synchronous) grace. An advance proves the session is alive and reacted to the just-flushed
|
|
41
|
+
bytes (a dead/wedged pty renders nothing → still fails correctly). The typing/tool-use
|
|
42
|
+
indicator for codex is a separate telegram-runtime concern (its pane-log parsing).
|
|
43
|
+
|
|
13
44
|
## [0.4.19] - 2026-06-22
|
|
14
45
|
|
|
15
46
|
### Added
|
|
@@ -294,7 +325,9 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
|
|
|
294
325
|
- **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
|
|
295
326
|
(timer = cron, watcher = event) are all first-class and addressed the same way.
|
|
296
327
|
|
|
297
|
-
[Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.
|
|
328
|
+
[Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.21...HEAD
|
|
329
|
+
[0.4.21]: https://github.com/agfpd/iapeer/compare/v0.4.20...v0.4.21
|
|
330
|
+
[0.4.20]: https://github.com/agfpd/iapeer/compare/v0.4.19...v0.4.20
|
|
298
331
|
[0.4.19]: https://github.com/agfpd/iapeer/compare/v0.4.18...v0.4.19
|
|
299
332
|
[0.4.18]: https://github.com/agfpd/iapeer/compare/v0.4.17...v0.4.18
|
|
300
333
|
[0.4.17]: https://github.com/agfpd/iapeer/compare/v0.4.16...v0.4.17
|
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 } : {}),
|
|
@@ -1806,6 +1853,11 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1806
1853
|
const o = await removePeerCli(positionals[0], { force: flags.force === true, env })
|
|
1807
1854
|
if (o.action === 'removed') {
|
|
1808
1855
|
out(`removed "${o.personality}" from the registry\n`)
|
|
1856
|
+
// Always-on plist teardown (bootout + rm) — an orphan loaded plist would
|
|
1857
|
+
// KeepAlive-crash-loop run-infra against the deleted record.
|
|
1858
|
+
if (o.plistTeardown) {
|
|
1859
|
+
out(`launchd plist: ${o.plistTeardown}\n`)
|
|
1860
|
+
}
|
|
1809
1861
|
// v1.2: the provider unwound its surfaces (occasion=remove) — say how it went.
|
|
1810
1862
|
if (o.unprovision?.length) {
|
|
1811
1863
|
out(`memory unprovision: ${o.unprovision.join(', ')}\n`)
|
|
@@ -1830,7 +1882,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
1830
1882
|
}
|
|
1831
1883
|
} else if (o.action === 'absent') out(`"${o.personality}" not registered — no-op\n`)
|
|
1832
1884
|
else errOut(`remove: ${o.reason}\n`)
|
|
1833
|
-
return o.action === 'refused-live' ? 1 : 0
|
|
1885
|
+
return o.action === 'refused-live' || o.action === 'refused-foreign-launchd' ? 1 : 0
|
|
1834
1886
|
}
|
|
1835
1887
|
case 'rename': {
|
|
1836
1888
|
// FULL folder rename (Arthur's invariant: personality == basename(cwd)): mv the
|
package/src/transport/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type ProcessAddress,
|
|
34
34
|
} from '../core/socket.ts'
|
|
35
35
|
import { buildEnvelope } from '../codec/index.ts'
|
|
36
|
+
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
36
37
|
import { findPeer, readPeersIndex, type PeerRecord } from '../registry/index.ts'
|
|
37
38
|
import type { ResolvedCaller } from '../identity/index.ts'
|
|
38
39
|
// Delivery markers are OWNED by the runtime adapter. transport
|
|
@@ -185,6 +186,12 @@ export interface WarmDeliverSeam {
|
|
|
185
186
|
deliverHosted?: (identity: string, envelope: string) => Promise<DeliverResult>
|
|
186
187
|
/** Activity proxy for the landed-confirm. Default: the target runtime adapter's transcript mtime. */
|
|
187
188
|
newestActivityMtime?: (cwd: string) => number | null
|
|
189
|
+
/** SECOND landed-confirm proxy: the pane-log (TUI render-stream) mtime, keyed by process
|
|
190
|
+
* ADDRESS. Default: the real `<root>/logs/lifecycle/<address>.log` mtime. The pane-log ticks
|
|
191
|
+
* ~1s as the session renders its working state on submit — for codex it advances ~1s while the
|
|
192
|
+
* session-jsonl only advances at model-turn-start (~4-6s, past the grace), so this is the signal
|
|
193
|
+
* that gives codex delivery parity. See `deliverViaHost`. */
|
|
194
|
+
paneLogMtime?: (address: string) => number | null
|
|
188
195
|
}
|
|
189
196
|
|
|
190
197
|
/**
|
|
@@ -211,9 +218,12 @@ async function deliverViaHost(
|
|
|
211
218
|
): Promise<Result<void>> {
|
|
212
219
|
const adapter = getAdapter(target.runtime)
|
|
213
220
|
const mtimeOf = seam.newestActivityMtime ?? ((c: string) => adapter.newestActivityMtime(c))
|
|
214
|
-
|
|
215
|
-
//
|
|
221
|
+
const paneMtimeOf = seam.paneLogMtime ?? ((addr: string) => defaultPaneLogMtime(addr))
|
|
222
|
+
// Baseline BOTH landed-proxies BEFORE delivery (a paste/CR does not move the transcript — only a
|
|
223
|
+
// model turn does; the pane-log moves as the session renders), so an advance in EITHER afterwards
|
|
224
|
+
// proves the session is alive and took our message.
|
|
216
225
|
const baseline = cwd ? mtimeOf(cwd) ?? 0 : 0
|
|
226
|
+
const paneBaseline = paneMtimeOf(target.address) ?? 0
|
|
217
227
|
const send = seam.deliverHosted ?? deliverHosted
|
|
218
228
|
const sent = await send(target.address, envelope)
|
|
219
229
|
if (!sent.ok) {
|
|
@@ -232,22 +242,43 @@ async function deliverViaHost(
|
|
|
232
242
|
// socket-ack ≠ landed. With no activity proxy (direct callers/tests without a cwd) we can only
|
|
233
243
|
// trust the flushed submit — confirmed-only, mirroring deliverViaTmux's legacy-caller path.
|
|
234
244
|
if (!cwd) return ok(undefined)
|
|
235
|
-
// Confirm landing by
|
|
236
|
-
//
|
|
237
|
-
//
|
|
238
|
-
// (
|
|
245
|
+
// Confirm landing by EITHER of two proxies within the grace:
|
|
246
|
+
// (a) transcript/session-jsonl mtime advance — the strong "model wrote a turn" signal. claude
|
|
247
|
+
// writes the user turn to its transcript promptly (sub-second); CODEX writes its session
|
|
248
|
+
// jsonl only at model-turn-START (gated by TTFT, measured ~4-6s after submit), so on the
|
|
249
|
+
// transcript-ONLY confirm every codex delivery STRUCTURALLY false-FAILED (the 3000ms grace
|
|
250
|
+
// expires before the first session write — proven in delivery.log).
|
|
251
|
+
// (b) PANE-LOG (TUI render-stream) mtime advance — the SAME true active-turn signal the 0.4.16
|
|
252
|
+
// idle-reap fix uses: it ticks ~1s as the session renders the working state on submit, so it
|
|
253
|
+
// catches codex in ~1s, giving codex delivery parity with claude WITHOUT lengthening the
|
|
254
|
+
// (synchronous) grace. NOT a false-OK: an advance proves the session is alive and rendered
|
|
255
|
+
// output in response to our just-flushed bytes (a wedged/dead pty renders nothing → no
|
|
256
|
+
// advance → correct fail); the bytes are then in the session's input (codex queues input
|
|
257
|
+
// during a turn). Prefer a false-FAIL (sender retries) over a false-OK (silent loss).
|
|
239
258
|
const graceMs = hostLivenessGraceMs()
|
|
240
259
|
const graceDeadline = monotonicMs() + graceMs
|
|
241
260
|
do {
|
|
242
261
|
if ((mtimeOf(cwd) ?? 0) > baseline) return ok(undefined)
|
|
262
|
+
if ((paneMtimeOf(target.address) ?? 0) > paneBaseline) return ok(undefined)
|
|
243
263
|
sleepSync(LIVENESS_POLL_MS)
|
|
244
264
|
} while (monotonicMs() < graceDeadline)
|
|
245
265
|
return err(
|
|
246
266
|
`hosted peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
|
|
247
|
-
`(no transcript advance within ${graceMs}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
|
|
267
|
+
`(no transcript or pane-log advance within ${graceMs}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
|
|
248
268
|
)
|
|
249
269
|
}
|
|
250
270
|
|
|
271
|
+
/** Pane-log (TUI render-stream) mtime for a process address, or null when absent/unreadable.
|
|
272
|
+
* The pane-log is the canonical `<root>/logs/lifecycle/<address>.log` the supervisor appends raw
|
|
273
|
+
* pty bytes to (§1 contract). Used as the SECOND landed-confirm proxy in deliverViaHost. */
|
|
274
|
+
function defaultPaneLogMtime(address: string): number | null {
|
|
275
|
+
try {
|
|
276
|
+
return statSync(join(resolveGlobalRoot(), 'logs', 'lifecycle', `${address}.log`)).mtimeMs
|
|
277
|
+
} catch {
|
|
278
|
+
return null
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
251
282
|
|
|
252
283
|
// ─── Compact done gate (dialogue control must report FACT, not keystroke delivery) ──
|
|
253
284
|
|