@agfpd/iapeer 0.4.23 → 0.4.25

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 CHANGED
@@ -10,6 +10,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.25] - 2026-06-22
14
+
15
+ ### Fixed
16
+
17
+ - **`connect telegram` now eager-reindexes the registry** (#1). `interface bot` merges
18
+ interfaces.telegram into the per-cwd profile (source of truth), but the derived registry
19
+ (peers-profiles.json) is a projection that only refreshed on specific verbs — so until
20
+ then the peer's telegram FACE was invisible to the sender-guard (`hasTelegramPresence`
21
+ reads the REGISTRY passport), **blocking the peer's outbound telegram** (observed live:
22
+ impact-finder "no telegram face"). connect now calls `reindexFromLocals` after a
23
+ successful `interface bot` (best-effort), so the face lands in the registry immediately —
24
+ no manual `iapeer verify --fix`.
25
+ - **Launchd-revive delivery retry** (#2): a MISS on a launchd-managed target — which the
26
+ daemon can't wake (H4) but launchd KeepAlive revives in ~1s — no longer fails terminally
27
+ in ~16ms. During a router restart (on `connect` / `iapeer update`) an in-flight delivery
28
+ was lost (observed: `natalya→arthur ok=false ms=16` mid-restart — a message to a HUMAN).
29
+ routeSend now retries-resolve for a bounded window (`IAP_LAUNCHD_REVIVE_GRACE_MS`, ~4s)
30
+ and delivers the instant the target revives; it re-resolves each poll (verify-before-act,
31
+ so the ok reflects a CONFIRMED delivery — no false-OK), and a genuinely-down peer still
32
+ fails LOUD and retryable (never silent). `isLaunchdManaged` injected via deps (no
33
+ transport→lifecycle coupling).
34
+
35
+ ## [0.4.24] - 2026-06-22
36
+
37
+ ### Fixed
38
+
39
+ - **`iapeer connect telegram <peer>` (and the `enable telegram` alias) no longer fails for a
40
+ peer whose personality isn't a valid Telegram @username** (e.g. a hyphen: "impact-finder").
41
+ connect passed the PERSONALITY as the bot @username to telegram-runtime (which keys bots by
42
+ `<bot-username>`), so a hyphenated personality was rejected ("must be a telegram @username").
43
+ The bot @username is TOKEN-derived (set in @BotFather), never the personality. connect now
44
+ resolves it from the token via Telegram **getMe** and uses that for `bot add` / `interface
45
+ bot` / storage; the human still owes only the token. An invalid/unreachable token fails
46
+ early with a getMe-rejected message. (The getMe lives in the telegram-glue module
47
+ `connect/index.ts` — the foundation core stays Telegram-API-free.)
48
+
13
49
  ## [0.4.23] - 2026-06-22
14
50
 
15
51
  ### Added
@@ -352,7 +388,9 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
352
388
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
353
389
  (timer = cron, watcher = event) are all first-class and addressed the same way.
354
390
 
355
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.23...HEAD
391
+ [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.25...HEAD
392
+ [0.4.25]: https://github.com/agfpd/iapeer/compare/v0.4.24...v0.4.25
393
+ [0.4.24]: https://github.com/agfpd/iapeer/compare/v0.4.23...v0.4.24
356
394
  [0.4.23]: https://github.com/agfpd/iapeer/compare/v0.4.22...v0.4.23
357
395
  [0.4.22]: https://github.com/agfpd/iapeer/compare/v0.4.21...v0.4.22
358
396
  [0.4.21]: https://github.com/agfpd/iapeer/compare/v0.4.20...v0.4.21
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
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",
@@ -5,9 +5,11 @@
5
5
  //
6
6
  // The human owes EXACTLY ONE external fact: the bot token (prompt walks them
7
7
  // through @BotFather → /newbot). Everything else the system resolves: the bot
8
- // alias (= the peer's personality), `telegram-runtime bot add` (owner adds getMe
9
- // validation — an invalid token fails THERE, early), `telegram-runtime interface
10
- // bot` (profile merge; precondition: the peer is registered), then the MANDATORY
8
+ // @username via Telegram getMe(token) — the bot key is TOKEN-derived, NOT the peer
9
+ // personality (a personality may contain a hyphen, structurally invalid as a
10
+ // @username; telegram-runtime keys bots by <bot-username>), `telegram-runtime bot add`
11
+ // (also validates the token), `telegram-runtime interface bot` (profile merge;
12
+ // precondition: the peer is registered), then the MANDATORY
11
13
  // router-session restart (the live poller reads bots/ ONCE at start, no fs-watch —
12
14
  // without the restart the channel is dead both ways), and the activation hint:
13
15
  // the FIRST message from the human to the bot opens the chat (a Telegram platform
@@ -23,6 +25,7 @@ import { spawnSync } from 'child_process'
23
25
  import { normalizeIntelligenceValue } from '../core/constants.ts'
24
26
  import { runtimeRoot } from '../storage/index.ts'
25
27
  import { findPeer, readPeersIndex } from '../registry/index.ts'
28
+ import { reindexFromLocals } from '../identity/profileStandard.ts'
26
29
  import { readRuntimeManifest } from '../runtime/index.ts'
27
30
 
28
31
  export interface TgRunResult {
@@ -51,6 +54,9 @@ export interface ConnectTelegramOptions {
51
54
  runTg?: TgRunner
52
55
  /** Injectable router restart (tests). Default: stopPeer→startPeer strictly in order. */
53
56
  restart?: RestartFn
57
+ /** Injectable bot-@username resolver (tests). Default: Telegram getMe(token). Returns the
58
+ * bare @username (no leading @) or null on a rejected/unreachable token. */
59
+ resolveUsername?: (token: string) => Promise<string | null>
54
60
  }
55
61
 
56
62
  export interface ConnectTelegramResult {
@@ -60,6 +66,7 @@ export interface ConnectTelegramResult {
60
66
  | 'refused-no-token' // non-tty and no --token (or an empty tty answer)
61
67
  | 'unregistered-peer' // precondition: the peer must be in the registry
62
68
  | 'runtime-missing' // no telegram runtime manifest — install it first
69
+ | 'bad-token' // Telegram getMe rejected the token (invalid/revoked) or was unreachable
63
70
  | 'bot-add-failed' // telegram-runtime bot add exited non-zero (incl. getMe refusal)
64
71
  | 'interface-failed' // telegram-runtime interface bot exited non-zero
65
72
  peer: string
@@ -93,6 +100,26 @@ function readBotEnv(alias: string, env: NodeJS.ProcessEnv): string | null {
93
100
  }
94
101
  }
95
102
 
103
+ /**
104
+ * Resolve the bot's REAL @username from the token via Telegram getMe — the bot identity
105
+ * is TOKEN-derived (BotFather sets it), NOT the peer personality. telegram-runtime keys
106
+ * bots by `<bot-username>` (`bot add <bot-username>`, `interface bot <bot-username>`), and a
107
+ * personality can contain a hyphen which is structurally invalid as a @username — so the
108
+ * personality can NEVER be the bot-username. Returns the bare username (no leading @) or
109
+ * null on a rejected/unreachable token. The token goes literally in the URL path
110
+ * (Telegram tokens are `<digits>:<urlsafe>`; no encoding).
111
+ */
112
+ async function defaultResolveBotUsername(token: string): Promise<string | null> {
113
+ try {
114
+ const r = await fetch(`https://api.telegram.org/bot${token}/getMe`)
115
+ const j = (await r.json()) as { ok?: boolean; result?: { username?: unknown } }
116
+ const u = j?.ok === true && typeof j.result?.username === 'string' ? j.result.username.trim().replace(/^@/, '') : ''
117
+ return u || null
118
+ } catch {
119
+ return null // network error / non-JSON → treat as unresolvable (bad-token)
120
+ }
121
+ }
122
+
96
123
  /** Resolve the telegram-runtime CLI bin from the manifest's selfConfig command (the
97
124
  * package's own absolute bin — PATH-independent, launchd-safe). */
98
125
  function resolveTgBin(env: NodeJS.ProcessEnv): string | null {
@@ -173,8 +200,17 @@ export async function connectTelegram(opts: ConnectTelegramOptions): Promise<Con
173
200
  if (!token) return { state: 'refused-no-token', peer, detail: 'no answer — nothing connected' }
174
201
  }
175
202
 
203
+ // Resolve the bot's REAL @username from the token (getMe) — NOT the personality. The
204
+ // bot-username is token-derived; a personality with a hyphen (e.g. "impact-finder") is
205
+ // structurally invalid as a @username and telegram-runtime (which keys bots by
206
+ // <bot-username>) rejects it. getMe also validates the token early (bad token → fail here).
207
+ const resolveUsername = opts.resolveUsername ?? defaultResolveBotUsername
208
+ const botUsername = await resolveUsername(token)
209
+ if (!botUsername) {
210
+ return { state: 'bad-token', peer, detail: 'Telegram getMe rejected the token (invalid/revoked) or was unreachable — recheck the token with @BotFather' }
211
+ }
176
212
  const runTg = opts.runTg ?? defaultRunTg(tgBin)
177
- const alias = peer // bot alias = the peer's personality (design §(в); passes the owner's NAME_RE)
213
+ const alias = botUsername // bot key = the REAL @username from getMe (telegram-runtime keys bots by <bot-username>)
178
214
  const before = readBotEnv(alias, env)
179
215
 
180
216
  // (1) bot add — token → bots/<alias>/.env. Owner adds getMe validation here: an
@@ -183,17 +219,10 @@ export async function connectTelegram(opts: ConnectTelegramOptions): Promise<Con
183
219
  if (add.status !== 0) {
184
220
  return { state: 'bot-add-failed', peer, detail: (add.stderr || add.stdout || `exit ${add.status}`).trim() }
185
221
  }
186
- // @username: the bots/<alias>/.env TELEGRAM_BOT_USERNAME field is the RELIABLE
187
- // source (present on the live host; survives a quiet bot-add stdout — acceptance
188
- // testing saw the activation line degrade to the BotFather hint). stdout
189
- // match stays as the fallback.
190
- const envAfterAdd = readBotEnv(alias, env)
191
- const envUser = envAfterAdd?.match(/^TELEGRAM_BOT_USERNAME=(.+)$/m)?.[1]?.trim()
192
- const username = envUser
193
- ? envUser.startsWith('@')
194
- ? envUser
195
- : `@${envUser}`
196
- : add.stdout.match(/@[A-Za-z0-9_]{3,}/)?.[0]
222
+ // @username for display / the activation hint: the bot key (alias) IS the
223
+ // getMe-resolved username, so the display form is simply `@<alias>` — no need to
224
+ // re-parse bots/<alias>/.env (telegram-runtime writes the same value there).
225
+ const username = `@${alias}`
197
226
 
198
227
  // (2) interface bot — merge the channel binding into the peer's profile.
199
228
  const iface = runTg(['interface', 'bot', alias, '--peer', peer], env)
@@ -201,6 +230,22 @@ export async function connectTelegram(opts: ConnectTelegramOptions): Promise<Con
201
230
  return { state: 'interface-failed', peer, username, detail: (iface.stderr || iface.stdout || `exit ${iface.status}`).trim() }
202
231
  }
203
232
 
233
+ // (2b) EAGER reindex — `interface bot` merged interfaces.telegram into the per-cwd
234
+ // profile (source of truth), but the derived registry (peers-profiles.json) is a
235
+ // PROJECTION that only refreshes on specific verbs (verify --fix, default-runtime…).
236
+ // Until then the peer's telegram FACE is INVISIBLE to the sender-guard
237
+ // (hasTelegramPresence reads the REGISTRY passport) — so the peer's OUTBOUND telegram
238
+ // is blocked ("no telegram face") + list/attach are stale. Project it now (same
239
+ // reindexFromLocals the default-runtime verb uses). Best-effort: a reindex hiccup must
240
+ // not fail a connect whose profile + bot are already in place (registry catches up on
241
+ // the next reindex verb). Runs on EVERY connect, incl. the idempotent no-op below — so
242
+ // a registry that drifted on a prior (pre-fix) connect self-heals here too.
243
+ try {
244
+ await reindexFromLocals({ env })
245
+ } catch {
246
+ /* registry lazily catches up on the next reindex verb; profile + bot are correct */
247
+ }
248
+
204
249
  // (3) Idempotency gate: the SAME token leaves bots/<alias>/.env byte-stable →
205
250
  // the live poller already loaded these credentials → NO restart, clean no-op.
206
251
  const after = readBotEnv(alias, env)
@@ -194,6 +194,10 @@ export interface CallToolDeps {
194
194
  noteLiveTopic?: (identity: string, topic: string) => void
195
195
  /** Busy-human-composer queue — see transport.createComposerDeliveryQueue. */
196
196
  composerQueue?: ComposerQueueRouteDeps
197
+ /** H4 launchd-managed detector — see transport.RouteDeps.isLaunchdManaged (the
198
+ * launchd-revive delivery retry). Injected so the daemon never imports lifecycle;
199
+ * the production main wires the real isLaunchdManaged. */
200
+ isLaunchdManaged?: (personality: string) => boolean
197
201
  }
198
202
 
199
203
  export async function callTool(
@@ -216,6 +220,7 @@ export async function callTool(
216
220
  ephemeral: deps.ephemeral,
217
221
  noteLiveTopic: deps.noteLiveTopic,
218
222
  composerQueue: deps.composerQueue,
223
+ isLaunchdManaged: deps.isLaunchdManaged,
219
224
  })
220
225
  // Ф-#8a: ONE durable outcome line per delivery attempt (delivery.log, sibling
221
226
  // to lifecycle.log) — metadata only, never the body. Both branches of the
@@ -392,6 +397,9 @@ export interface StartDaemonOptions extends StorageOptions {
392
397
  * senders on shutdown/restart.
393
398
  */
394
399
  composerQueue?: ComposerQueueRouteDeps
400
+ /** H4 launchd-managed detector — see CallToolDeps.isLaunchdManaged / the launchd-revive
401
+ * delivery retry. OFF by default (library/tests); production main wires the real one. */
402
+ isLaunchdManaged?: (personality: string) => boolean
395
403
  /**
396
404
  * Write the discovery file (router.json) at <root>/state/iapeer/router.json with
397
405
  * the active addresses `{sock, tcp}` — atomically on listen, removed on close.
@@ -426,6 +434,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
426
434
  ephemeral: opts.ephemeral,
427
435
  noteLiveTopic: opts.noteLiveTopic,
428
436
  composerQueue: opts.composerQueue,
437
+ isLaunchdManaged: opts.isLaunchdManaged,
429
438
  })
430
439
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
431
440
  res.on('close', () => {
@@ -35,6 +35,7 @@ import {
35
35
  drainEphemeralQueue,
36
36
  enqueueEphemeralTask,
37
37
  isEphemeralPeer,
38
+ isLaunchdManaged,
38
39
  addTopic,
39
40
  loadLifecycleConfig,
40
41
  processEagerRelaunches,
@@ -332,6 +333,10 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
332
333
  // Busy-human-composer queue: fast queued ack, async drain, fail all pending
333
334
  // on daemon close/update so a queued envelope is never silently lost.
334
335
  composerQueue,
336
+ // H4 launchd-revive delivery retry (see RouteDeps.isLaunchdManaged): a MISS on a
337
+ // launchd-managed target (the daemon can't wake it, but KeepAlive revives it) → retry-
338
+ // resolve for a bounded window instead of failing in ~16ms — bridges a router restart.
339
+ isLaunchdManaged: (personality: string) => isLaunchdManaged(personality, env),
335
340
  supervise: {
336
341
  intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
337
342
  // idle-reap / zombie-sweep, THEN the eager fresh re-launch for any peer whose
@@ -176,6 +176,17 @@ function hostLivenessGraceMs(): number {
176
176
  return Number.isFinite(n) && n >= 0 ? n : livenessGraceMs()
177
177
  }
178
178
 
179
+ // Launchd-revive retry — a MISS on a launchd-managed target the daemon can't wake (H4)
180
+ // but launchd KeepAlive WILL revive (router restart / crash-revive window). routeSend
181
+ // re-resolves for up to this window before failing, bridging the gap (silent-loss guard).
182
+ // Env-tunable; default ~4s (a bootout→bootstrap router restart settles well under it).
183
+ const LAUNCHD_REVIVE_POLL_MS = 300
184
+ function launchdReviveGraceMs(): number {
185
+ const raw = process.env.IAP_LAUNCHD_REVIVE_GRACE_MS
186
+ const n = raw === undefined ? NaN : Number(raw)
187
+ return Number.isFinite(n) && n >= 0 ? n : 4000
188
+ }
189
+
179
190
  // ─── Warm delivery (pty-only — supervisor socket) ────────────────────────────────────────────────
180
191
 
181
192
  /** Test seam for `deliverWarm`. Production defaults route on the real supervisor; tests inject a
@@ -793,6 +804,17 @@ export interface RouteDeps {
793
804
  * imports lifecycle (layering — main.ts is where the two meet); call sites
794
805
  * swallow hook errors (a hook must never fail a delivered message). */
795
806
  noteLiveTopic?: (identity: string, topic: string) => void
807
+ /** H4 launchd-managed detector (injected so transport never imports lifecycle —
808
+ * main.ts wires the real `isLaunchdManaged`). A MISS on a launchd-managed target is
809
+ * almost always a TRANSIENT restart window (router restart on connect / `iapeer
810
+ * update`, or a crash that KeepAlive is reviving), NOT a real outage: the daemon
811
+ * can't wake it (H4) but launchd WILL revive it in ~1s. So routeSend retries the
812
+ * RESOLVE for a bounded window before failing, bridging the gap (prevents the
813
+ * silent-loss class: an in-flight delivery — esp. to a HUMAN over a telegram router —
814
+ * dropped during a restart). Absent → no retry (legacy behaviour). */
815
+ isLaunchdManaged?: (personality: string) => boolean
816
+ /** Injectable async sleep for the launchd-revive retry poll (tests). Default: setTimeout. */
817
+ sleep?: (ms: number) => Promise<void>
796
818
  }
797
819
 
798
820
  /** Best-effort noteLiveTopic invocation — only on a non-empty topic, never throws. */
@@ -1159,6 +1181,42 @@ export async function routeSend(
1159
1181
 
1160
1182
  // MISS — peer offline.
1161
1183
  if (!deps.wake) return target // Ф1: explicit offline, no wake
1184
+ // LAUNCHD-REVIVE RETRY: a launchd-managed target the daemon can't wake (H4) but launchd
1185
+ // KeepAlive WILL revive — a MISS here is almost always a transient RESTART WINDOW (router
1186
+ // restart on connect / `iapeer update`, or a crash-revive), not a real outage. Wake would
1187
+ // just refuse (launchd-managed) and the delivery would fail in ~16ms — losing an in-flight
1188
+ // message (observed: natalya→arthur ok=false ms=16 during a connect router restart; a
1189
+ // message to a HUMAN, the silent-loss-adjacent class). Bridge it: re-resolve for a bounded
1190
+ // window and deliver the instant it revives. Each poll RE-RESOLVES the live target
1191
+ // (verify-before-act) → the ok reflects a CONFIRMED delivery (no false-OK); a genuinely
1192
+ // down peer still fails LOUD after the window (retryable, never silent).
1193
+ if (deps.isLaunchdManaged?.(personality)) {
1194
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
1195
+ const reviveDeadline = monotonicMs() + launchdReviveGraceMs()
1196
+ while (monotonicMs() < reviveDeadline) {
1197
+ await sleep(LAUNCHD_REVIVE_POLL_MS)
1198
+ const revived = resolvePeerDeliveryTarget(personality, runtime, peer)
1199
+ if (!revived.ok) continue // not back yet — keep polling until the deadline
1200
+ if (revived.value.address === caller.address) return err('cannot send to self')
1201
+ const reviveGuard = telegramSenderGuard(caller, revived.value.runtime, personality)
1202
+ if (!reviveGuard.ok) return reviveGuard
1203
+ const delivered = await deliverWarm(revived.value, envelope, peer.cwd)
1204
+ if (delivered.ok) {
1205
+ noteTopic(deps, revived.value.address, topic)
1206
+ return ok({
1207
+ ok: true,
1208
+ delivered_to: { personality: revived.value.personality, runtime: revived.value.runtime },
1209
+ woke: false,
1210
+ ts: new Date().toISOString(),
1211
+ })
1212
+ }
1213
+ // resolved but the deliver didn't land (session still settling) → keep polling
1214
+ }
1215
+ return err(
1216
+ `peer "${personality}" (launchd-managed) offline and did not revive within ${launchdReviveGraceMs()}ms ` +
1217
+ `(restart window outlasted the grace?) — message NOT delivered; retry`,
1218
+ )
1219
+ }
1162
1220
  const woke = await deps.wake({ personality, runtime, topic, task: envelope })
1163
1221
  if (woke.status === 'FAILED') {
1164
1222
  // C1 — a durably STOPPED peer is a deliberate halt, not a transient miss: