@agfpd/iapeer 0.4.5 → 0.4.7

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/README.md CHANGED
@@ -9,6 +9,12 @@
9
9
 
10
10
  iapeer keeps a team of AI agents, humans, and services on one host **always reachable — every one of them a single message away.** Each participant is a **peer**: a Claude Code or Codex CLI agent, a person on Telegram, or a scheduler/watcher service. Any agent reaches any other with one call — `send_to_peer(<name>, <text>)` — and a peer that is asleep is still reachable: the daemon wakes it and delivers. Agents on Claude and on Codex — different AI runtimes from different vendors — talk as equal peers; humans take part over Telegram, or directly in the terminal — attached to a peer's live session with `iapeer attach`.
11
11
 
12
+ <p align="center">
13
+ <img src=".github/assets/iapeer-pingpong.gif" width="800" alt="A person messages one peer on Telegram; heterogeneous peers (Claude + Codex) relay a baton over one protocol and report back to Telegram.">
14
+ <br>
15
+ <em>One Telegram message in → Claude and Codex peers pass a baton over the same protocol → a report back in Telegram.</em>
16
+ </p>
17
+
12
18
  ## How it works
13
19
 
14
20
  ```text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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
@@ -37,10 +37,10 @@ import {
37
37
  isEphemeralPeer,
38
38
  isStopped,
39
39
  killSession,
40
- lastActiveRuntime,
41
40
  loadLifecycleConfig,
42
41
  purgeIdentityState,
43
42
  removeSessionState,
43
+ resolvePeerRuntime,
44
44
  setEphemeralArmed,
45
45
  setFreshNext,
46
46
  setIdleReaped,
@@ -518,7 +518,10 @@ export async function newPeer(
518
518
  }
519
519
  }
520
520
  if (runtime && !isRuntime(runtime)) throw new Error(`invalid runtime "${runtime}"`)
521
- const rt: Runtime = (runtime as Runtime | undefined) ?? peer.runtime
521
+ // Omitted runtime resolves IDENTICALLY to `attach` (resolvePeerRuntime): default_runtime
522
+ // anchored, sole-live refinement — so `iapeer new <peer>` and `iapeer attach <peer>` never
523
+ // target different runtimes (the "new had no effect, attach resurrected the other" footgun).
524
+ const rt: Runtime = (runtime as Runtime | undefined) ?? resolvePeerRuntime(peer, cfg)
522
525
  if (!peer.runtimes.includes(rt)) {
523
526
  return {
524
527
  personality,
@@ -638,7 +641,9 @@ export async function compactPeer(
638
641
  return { personality, runtime: r.value.controlled.runtime, action: 'compacted', woke: false }
639
642
  }
640
643
 
641
- const rt: Runtime = (runtime as Runtime | undefined) ?? lastActiveRuntime(peer, cfg) ?? peer.runtime
644
+ // Asleep branch: omitted runtime resolves like new/attach (resolvePeerRuntime) — consistent
645
+ // default_runtime anchoring instead of the old hidden last-active-by-mtime.
646
+ const rt: Runtime = (runtime as Runtime | undefined) ?? resolvePeerRuntime(peer, cfg)
642
647
  if (!peer.runtimes.includes(rt)) {
643
648
  return {
644
649
  personality,
@@ -134,9 +134,15 @@ export async function paneLogViewport(log: string, cols: number, rows: number, s
134
134
  * validated `composerOccupancyFromModel` (REUSED, not reimplemented). The hosted equivalent of
135
135
  * `targetComposerBlocksDelivery`'s capture-pane occupancy. @xterm loads dynamically here (flag-on
136
136
  * hosted only; the leaf `composerOccupancyFromModel` import is type-only at runtime, so the warm path
137
- * stays @xterm-free with the flip off). UNCERTAINTY → BUSY (true): a model-build failure mirrors the
138
- * tmux predicate's capture-fail-is-busy stance — prefer a false-busy hold over a false-free paste into
139
- * a human composer.
137
+ * stays @xterm-free with the flip off). UNREADABLE PANE-LOG → NOT busy (false): the hold exists ONLY to
138
+ * avoid clobbering VISIBLE human composer text; if the model can't be built there is NO observed text to
139
+ * protect. This deliberately does NOT mirror the tmux predicate's capture-fail-is-busy stance: tmux
140
+ * `capture-pane` fails ~only when the session is GONE (then the queue's session-token/alive check fails
141
+ * the job loudly), but a hosted pane-log can be absent/unreadable while the session is ALIVE (e.g. a
142
+ * long-lived session whose lifecycle pane-log was rotated/removed) — so "uncertainty → busy" would stall
143
+ * EVERY delivery to such a peer until the 120 s force-timeout, starving the core addressed-delivery
144
+ * feature. The narrow cost (no composer protection while the log is unreadable) is acceptable: a session
145
+ * actively being typed into is precisely one whose pane-log IS being written.
140
146
  *
141
147
  * NB (sequence): this is the DETECTION primitive. The end-to-end gate (attached-supervisor-
142
148
  * client + enqueue + host-aware drain) lands with the attach client in Ф0b-3 — until a human can
@@ -145,7 +151,7 @@ export async function paneLogViewport(log: string, cols: number, rows: number, s
145
151
  */
146
152
  export async function paneLogComposerOccupied(log: string, cols: number, rows: number, runtime: string): Promise<boolean> {
147
153
  const term = await loadPaneLogModel(log, cols, rows)
148
- if (!term) return true // uncertainty → busy: never paste into a maybe-occupied composer
154
+ if (!term) return false // unreadable pane-log → no observed human composer text → deliver, never stall (see JSDoc)
149
155
  const { composerOccupancyFromModel } = await import('../shadow/render.ts')
150
156
  return composerOccupancyFromModel(term, cols, rows, runtime)
151
157
  }
@@ -1552,9 +1552,11 @@ export async function folderLaunch(opts: FolderLaunchOptions): Promise<WakeResul
1552
1552
  }
1553
1553
 
1554
1554
  /**
1555
- * The runtime with the freshest transcript activity for a peer (contract: attach
1556
- * resolves the runtime by last-active transcript-mtime, NOT the profile default).
1557
- * undefined when no runtime has any activity (a never-run peer).
1555
+ * The runtime with the freshest transcript activity for a peer. undefined when no
1556
+ * runtime has any activity (a never-run peer). NOTE: this is NO LONGER the attach
1557
+ * default — operator runtime resolution moved to `resolvePeerRuntime` (default_runtime
1558
+ * anchored) because last-active-by-mtime is hidden state and unreliable for codex
1559
+ * (rollout flushed only on a real turn). Kept as a diagnostic / `list` helper.
1558
1560
  */
1559
1561
  export function lastActiveRuntime(peer: PeerRecord, cfg: LifecycleConfig): Runtime | undefined {
1560
1562
  let best: Runtime | undefined
@@ -1573,6 +1575,46 @@ export function lastActiveRuntime(peer: PeerRecord, cfg: LifecycleConfig): Runti
1573
1575
  return best
1574
1576
  }
1575
1577
 
1578
+ /** Runtimes of `peer` whose session is LIVE right now (lifecycle-local liveness,
1579
+ * same `sessionAlive` predicate the wake/attach paths use). */
1580
+ function liveRuntimesOf(peer: PeerRecord, cfg: LifecycleConfig): Runtime[] {
1581
+ return peer.runtimes.filter(rt =>
1582
+ sessionAlive(buildSocketPath(rt, peer.personality, cfg.sockDir), buildProcessAddress(rt, peer.personality)),
1583
+ )
1584
+ }
1585
+
1586
+ /**
1587
+ * The SINGLE runtime an operator verb (new / attach / compact) acts on when the
1588
+ * runtime arg is OMITTED, for a peer that may declare several. PREDICTABLE by
1589
+ * construction and IDENTICAL across those verbs — this is the fix for the
1590
+ * multi-runtime footgun where `new` defaulted to `default_runtime` while `attach`
1591
+ * defaulted to last-active-by-mtime, so `iapeer new <peer>` freshed one runtime
1592
+ * while `iapeer attach <peer>` resurrected the other ("new had no effect, attach
1593
+ * came up on the old session").
1594
+ *
1595
+ * Precedence (never refuses — the naive flow must self-resolve, no explicit arg):
1596
+ * 1. sole declared runtime → it.
1597
+ * 2. `default_runtime` is LIVE → `default_runtime` (the configured anchor AND the
1598
+ * obvious running target).
1599
+ * 3. exactly ONE other runtime is live (default dead) → that live one.
1600
+ * 4. otherwise (nothing live, or 2+ live without the default) → `default_runtime`.
1601
+ *
1602
+ * The peer's runtime is governed by its CONFIGURABLE `default_runtime` (the
1603
+ * documented `iapeer default-runtime` lever), NOT by which transcript was touched
1604
+ * last — last-active-by-mtime is hidden state and unreliable for codex (its rollout
1605
+ * file is flushed only on a real model turn, so a just-spawned fresh codex session
1606
+ * reads as "older" than a stale one). To make `iapeer new/attach <peer>` (no runtime
1607
+ * arg) resolve to codex, set the peer's default to codex once; per-command runtime
1608
+ * args remain the explicit override.
1609
+ */
1610
+ export function resolvePeerRuntime(peer: PeerRecord, cfg: LifecycleConfig): Runtime {
1611
+ if (peer.runtimes.length === 1) return peer.runtimes[0]
1612
+ const live = liveRuntimesOf(peer, cfg)
1613
+ if (live.includes(peer.runtime)) return peer.runtime
1614
+ if (live.length === 1) return live[0]
1615
+ return peer.runtime
1616
+ }
1617
+
1576
1618
  export interface AttachOptions {
1577
1619
  personality: string
1578
1620
  runtime?: string
@@ -1586,10 +1628,11 @@ export type AttachResult =
1586
1628
  /**
1587
1629
  * `iapeer attach <peer> [runtime]` — ensure the peer is live, then hand back the
1588
1630
  * socket/identity for the caller to `tmux attach`. ALWAYS RESUME (contract: attach
1589
- * never starts fresh). Runtime: explicit arg, else the LAST-ACTIVE runtime by
1590
- * transcript-mtime (not the profile default), else the profile default. A warm-live
1591
- * session is attached directly; a warm-asleep one is woken with --resume first
1592
- * (fail-loud if there is nothing to resume — a never-run peer must be folder-launched).
1631
+ * never starts fresh). Runtime: explicit arg, else `resolvePeerRuntime` (the SAME
1632
+ * resolver `new`/`compact` use — default_runtime anchored with a sole-live
1633
+ * refinement), so attach and new never disagree on which runtime "peer" means. A
1634
+ * warm-live session is attached directly; a warm-asleep one is woken with --resume
1635
+ * first (fail-loud if there is nothing to resume — a never-run peer must be folder-launched).
1593
1636
  */
1594
1637
  export async function attachPeer(opts: AttachOptions): Promise<AttachResult> {
1595
1638
  const env = opts.env ?? process.env
@@ -1598,8 +1641,10 @@ export async function attachPeer(opts: AttachOptions): Promise<AttachResult> {
1598
1641
  if (!peer) return { ok: false, reason: `peer "${opts.personality}" is not registered` }
1599
1642
  const runtimeResult = resolveWakeRuntime(opts.runtime, peer)
1600
1643
  if (opts.runtime && !runtimeResult.ok) return { ok: false, reason: runtimeResult.error.message }
1601
- // last-active (by mtime) wins over the profile default when no runtime is given.
1602
- const runtime = opts.runtime ?? lastActiveRuntime(peer, cfg) ?? peer.runtime
1644
+ // Omitted runtime resolves IDENTICALLY to `new`/`compact` (resolvePeerRuntime):
1645
+ // default_runtime anchored, sole-live refinement, never hidden last-active — so
1646
+ // `iapeer attach <peer>` and `iapeer new <peer>` never disagree on the runtime.
1647
+ const runtime = opts.runtime ?? resolvePeerRuntime(peer, cfg)
1603
1648
  const identity = buildProcessAddress(runtime, opts.personality)
1604
1649
  const sock = buildSocketPath(runtime, opts.personality, cfg.sockDir)
1605
1650
  if (sessionAlive(sock, identity)) return { ok: true, identity, socketPath: sock, woke: false, runtime }