@agfpd/iapeer 0.4.103 → 0.4.105

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.
@@ -55,6 +55,8 @@ iapeer verify --fix # repair
55
55
 
56
56
  `verify` checks two things: profiles' conformance to the standard, and divergences between the registry and the local profiles. With `--fix` the command self-heals the registry from the profiles (and migrates profiles from the old runtime-field format to the current one along the way). It's the standard way to put the registry in order if it's gone out of sync.
57
57
 
58
+ **The daemon reconciles continuously (0.4.104).** The local `peer-profile.json` is the source of truth and the registry only its projection — but until 0.4.104 a direct edit of the source (the owner's legitimate move) propagated only when some reindexing verb happened to run afterwards; the router reads the INDEX per-request, so a stale projection mis-routed deliveries (measured live 17.07.2026: a peer's local profile said `default_runtime: claude` for days while the index kept `codex`, and deliveries went to a mute codex session). The daemon now runs a profile-sync sweep (`daemon/profilesync.ts`, 60 s, mtime/set-gated — steady state is stats only, zero writes): any change to a registered peer's local profile triggers the same locked `reindexFromLocals` the verbs use, and a boot sweep heals drift accumulated while the daemon was down. Heals are logged to `lifecycle.log` as `profile-reindex` events. `verify` remains the read-only report and the manual lever; the daemon closes the propagation gap.
59
+
58
60
  ## Stop and start: stop, start
59
61
 
60
62
  ```bash
@@ -67,6 +67,8 @@ iapeer default-runtime codex --all # all peers
67
67
 
68
68
  Switches the primary runtime — where routing goes and which runtime comes up first. A bulk `--all` switch is exactly the "transplant" of the whole team from one agent runtime to another; it's symmetric back (`default-runtime claude --all`). The command refuses if the peer doesn't declare that runtime (run `add-runtime` first), and skips infrastructure peers. The registry is fixed in the same command, so routing switches at once.
69
69
 
70
+ The routing choice is strict: a send without an explicit `runtime` always targets `default_runtime`. A live session on another declared runtime does not capture that send; if the default session is asleep, the daemon wakes it. Passing `runtime` explicitly remains the way to target a non-default surface.
71
+
70
72
  ## Connecting Telegram: connect
71
73
 
72
74
  ```bash
@@ -25,7 +25,7 @@ The `--from` flag sets the sender (`<runtime>-<name>`); by default it's the iden
25
25
 
26
26
  ## The delivery path
27
27
 
28
- The daemon finds the recipient in the registry and looks at its state:
28
+ The daemon finds the recipient in the registry and selects its endpoint first. With no explicit `runtime`, the endpoint is always the recipient's `default_runtime`; liveness of other declared runtimes does not change that choice. An explicit `runtime` selects that exact surface. The daemon then looks at the selected endpoint's state:
29
29
 
30
30
  - **alive** — the daemon writes the message into the peer's session through the supervisor socket onto the session's input. The daemon waits for confirmation that the input was accepted (the input field cleared); the answer is `delivered`.
31
31
  - **busy** (the agent is mid-turn, the input field isn't empty) — the message goes into the composer queue and is delivered as soon as the agent is free; the answer to the sender is `queued`. The queue waits up to 120 seconds, then force-delivers.
@@ -67,6 +67,8 @@ iapeer default-runtime codex --all # все пиры
67
67
 
68
68
  Переключает основной рантайм — куда идёт маршрутизация и какой рантайм поднимается первым. Массовое переключение `--all` — это и есть «пересадка» всей команды с одного агентского рантайма на другой; обратно симметрично (`default-runtime claude --all`). Команда откажет, если пир не объявляет этот рантайм (сначала `add-runtime`), и пропустит инфраструктурных. Реестр чинится в той же команде, поэтому маршрутизация переключается сразу.
69
69
 
70
+ Выбор для маршрутизации строгий: отправка без явного `runtime` всегда целится в `default_runtime`. Живая сессия на другом объявленном рантайме не перехватывает такую отправку; если дефолтная сессия спит, демон будит её. Явный `runtime` по-прежнему выбирает конкретную недефолтную поверхность.
71
+
70
72
  ## Подключение Telegram: connect
71
73
 
72
74
  ```bash
@@ -25,7 +25,7 @@ iapeer send assistant --message "..." --topic "релиз" --from claude-reviewe
25
25
 
26
26
  ## Путь доставки
27
27
 
28
- Демон находит получателя в реестре и смотрит на его состояние:
28
+ Демон находит получателя в реестре и сначала выбирает поверхность доставки. Без явного `runtime` это всегда `default_runtime` адресата; живость других объявленных рантаймов выбор не меняет. Явный `runtime` выбирает ровно указанную поверхность. Затем демон смотрит на состояние выбранной поверхности:
29
29
 
30
30
  - **живой** — демон пишет сообщение в сессию пира через сокет супервизора на ввод сессии. Демон ждёт подтверждения, что ввод принят (поле ввода очистилось); ответ — `доставлено`.
31
31
  - **занят** (агент в середине хода, поле ввода не пустое) — сообщение встаёт в очередь композера и доставляется, как только агент освободится; ответ отправителю — `в очереди`. Очередь ждёт до 120 секунд, потом досылает принудительно.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.103",
3
+ "version": "0.4.105",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -69,6 +69,7 @@ import { appendDeliveryEvent } from './deliverylog.ts'
69
69
  import { buildFleetHandler } from './fleet.ts'
70
70
  import { NoticeBoard } from './notices.ts'
71
71
  import { startMuteWatch } from './mutewatch.ts'
72
+ import { DEFAULT_PROFILE_SYNC_INTERVAL_MS, startProfileSync } from './profilesync.ts'
72
73
  import { startGoalWatch } from './goalwatch.ts'
73
74
  import { ActivityBoard, startTurnWatch } from './turnwatch.ts'
74
75
  import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
@@ -348,6 +349,8 @@ export interface ConfiguredDaemonOptions {
348
349
  goalWatchIntervalMs?: number
349
350
  /** Turn-watch sweep cadence (default DEFAULT_TURNWATCH_INTERVAL_MS). */
350
351
  turnWatchIntervalMs?: number
352
+ /** Profile-sync sweep cadence (default DEFAULT_PROFILE_SYNC_INTERVAL_MS). */
353
+ profileSyncIntervalMs?: number
351
354
  /** Write the router.json discovery file (default true for production). */
352
355
  discovery?: boolean
353
356
  rootDir?: string
@@ -417,6 +420,34 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
417
420
  } catch { /* best-effort */ }
418
421
  },
419
422
  })
423
+ // Profile-sync (identity self-heal, continuous half) — the local peer-profile.json is
424
+ // the SOURCE OF TRUTH and the registry only its projection; this sweep is what makes
425
+ // that invariant operationally true (an owner's hand-edit of the source propagates
426
+ // within a minute instead of waiting for someone to run a reindexing verb — the
427
+ // 17.07.2026 routing-desync incident class). mtime-gated: steady state is stats only.
428
+ const stopProfileSync = startProfileSync({
429
+ env,
430
+ intervalMs: opts.profileSyncIntervalMs ?? DEFAULT_PROFILE_SYNC_INTERVAL_MS,
431
+ onHealed: r => {
432
+ try {
433
+ appendLifecycleEvent(
434
+ cfg.eventLogDir,
435
+ {
436
+ ev: 'profile-reindex',
437
+ ...(r.healed.length > 0 ? { healed: r.healed.join('; ').slice(0, 600) } : {}),
438
+ ...(r.missing.length > 0 ? { missing: r.missing.join(', ').slice(0, 300) } : {}),
439
+ },
440
+ { env },
441
+ )
442
+ } catch { /* best-effort */ }
443
+ },
444
+ onError: (err: unknown) => {
445
+ try {
446
+ const detail = err instanceof Error ? (err.stack ?? err.message) : String(err)
447
+ appendLifecycleEvent(cfg.eventLogDir, { ev: 'profilesync-error', error: detail.replace(/\s+/g, ' ').slice(0, 600) }, { env })
448
+ } catch { /* best-effort */ }
449
+ },
450
+ })
420
451
  const handle = await startDaemon({
421
452
  wake: makeWakeFn(cfg, env),
422
453
  // M2 arm-on-outbound (see makeArmEphemeralOnDelivered): ephemeral caller's ok
@@ -491,7 +522,7 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
491
522
  // The mute-watch timer is OURS, not startDaemon's — so its teardown must hang off the
492
523
  // handle we return, or a closed daemon would leave a live timer sweeping the disk (and
493
524
  // every test that starts a daemon would hang on exit).
494
- return { ...handle, close: async () => { stopMuteWatch(); stopGoalWatch(); stopTurnWatch(); await handle.close() } }
525
+ return { ...handle, close: async () => { stopMuteWatch(); stopGoalWatch(); stopTurnWatch(); stopProfileSync(); await handle.close() } }
495
526
  }
496
527
 
497
528
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,112 @@
1
+ // Profile-sync — the daemon's CONTINUOUS half of the registry self-heal invariant.
2
+ //
3
+ // THE INVARIANT (identity/profileStandard.ts; contract zone «Идентичность пира — модель,
4
+ // профиль, реестр»): the per-cwd peer-profile.json is the SOURCE OF TRUTH and
5
+ // peers-profiles.json is a regenerable PROJECTION of it — "the index is never the source
6
+ // of truth". Before this module that sentence was true only lexically. reindexFromLocals
7
+ // ran on a handful of verbs (add-runtime / default-runtime / connect / verify --fix), so
8
+ // an edit of the source file ITSELF — the owner's legitimate edit, on the very file the
9
+ // contract names as the truth — propagated NEVER unless one of those verbs happened to
10
+ // run afterwards. Measured on the real fleet (17.07.2026): linus's local profile had said
11
+ // default_runtime=claude for days; the index still said codex; the router, which reads
12
+ // the INDEX per-request, kept delivering into a mute codex session until an operator ran
13
+ // the verb by hand. A projection invariant without a continuous reconciler is a promise
14
+ // the system does not keep.
15
+ //
16
+ // MECHANISM. Every tick (60 s default) stat each registered peer's peer-profile.json.
17
+ // Only when a profile mtime advanced past the previous sweep's maximum — or the set of
18
+ // profile paths changed — run reindexFromLocals: the SAME locked writer the verbs use,
19
+ // so the registry single-writer invariant is untouched. The steady state is N stats and
20
+ // ZERO writes; the index is rewritten only when a source actually changed. Reindex
21
+ // writes the index and never the profiles, so there is no feedback loop. A corrupt or
22
+ // missing profile is preserved-and-reported by reindexFromLocals itself (it treats both
23
+ // as "leave the record, surface in `missing`" — a half-saved editor buffer must never
24
+ // wipe a live record).
25
+ //
26
+ // The boot tick ALWAYS reconciles once (the gate starts empty): drift accumulated while
27
+ // the daemon was down — exactly the incident shape — heals on startup, not on the next
28
+ // hand-run verb.
29
+
30
+ import { statSync } from 'node:fs'
31
+ import { reindexFromLocals } from '../identity/profileStandard.ts'
32
+ import { readPeersIndex } from '../registry/index.ts'
33
+ import { peerProfilePath } from '../storage/index.ts'
34
+
35
+ export const DEFAULT_PROFILE_SYNC_INTERVAL_MS = 60_000
36
+
37
+ export interface ProfileSyncResult {
38
+ healed: string[]
39
+ missing: string[]
40
+ }
41
+
42
+ export interface ProfileSyncDeps {
43
+ /** Registered peers' profile paths. Default: the live registry (readPeersIndex). */
44
+ listProfilePaths?: () => string[]
45
+ /** The locked self-heal writer. Default: reindexFromLocals({ env }). */
46
+ reindex?: () => Promise<ProfileSyncResult>
47
+ /** Fired ONLY when a reindex actually changed/flagged something (healed or missing
48
+ * non-empty) — the caller's audit hook; quiet reconciles stay quiet. */
49
+ onHealed?: (r: ProfileSyncResult) => void
50
+ /** Reported, never thrown — a reconciler must never take the daemon down. */
51
+ onError?: (err: unknown) => void
52
+ env?: NodeJS.ProcessEnv
53
+ }
54
+
55
+ /** The sweep gate: the profile set + the newest profile mtime, folded into one string.
56
+ * A changed gate = some source changed (edit, new peer, dropped profile) → reindex. */
57
+ function computeGate(paths: string[]): string {
58
+ let maxMtime = 0
59
+ const present: string[] = []
60
+ for (const p of paths) {
61
+ try {
62
+ const m = statSync(p).mtimeMs
63
+ present.push(p)
64
+ if (m > maxMtime) maxMtime = m
65
+ } catch {
66
+ // Missing profile: participates via the PATH SET (its appearance/disappearance
67
+ // flips the gate once), not via mtime. reindexFromLocals handles the rest.
68
+ }
69
+ }
70
+ return `${maxMtime}|${present.sort().join(',')}`
71
+ }
72
+
73
+ /**
74
+ * One sweep: reindex IFF the gate moved since `lastGate`. Returns the new gate.
75
+ * Exported for tests; the timer below is just this on an interval.
76
+ */
77
+ export async function profileSyncTick(deps: ProfileSyncDeps, lastGate: string): Promise<string> {
78
+ try {
79
+ const env = deps.env ?? process.env
80
+ const paths = deps.listProfilePaths
81
+ ? deps.listProfilePaths()
82
+ : readPeersIndex({ env }).peers.map(rec => peerProfilePath(rec.cwd))
83
+ const gate = computeGate(paths)
84
+ if (gate === lastGate) return lastGate
85
+ const reindex = deps.reindex ?? (() => reindexFromLocals({ env }))
86
+ const result = await reindex()
87
+ if (result.healed.length > 0 || result.missing.length > 0) deps.onHealed?.(result)
88
+ return gate
89
+ } catch (e) {
90
+ deps.onError?.(e)
91
+ return lastGate // a failed sweep retries the same gate next tick — never silently skips
92
+ }
93
+ }
94
+
95
+ /** Start the profile-sync timer. Returns its stop function — the caller OWNS teardown. */
96
+ export function startProfileSync(opts: ProfileSyncDeps & { intervalMs?: number } = {}): () => void {
97
+ let gate = '' // empty ≠ any computed gate → the FIRST tick always reconciles (boot heal)
98
+ let running = false
99
+ const sweep = async (): Promise<void> => {
100
+ if (running) return // a slow reindex must never overlap itself
101
+ running = true
102
+ try {
103
+ gate = await profileSyncTick(opts, gate)
104
+ } finally {
105
+ running = false
106
+ }
107
+ }
108
+ void sweep() // boot heal — drift accumulated while the daemon was down heals NOW
109
+ const timer = setInterval(() => void sweep(), opts.intervalMs ?? DEFAULT_PROFILE_SYNC_INTERVAL_MS)
110
+ ;(timer as { unref?: () => void }).unref?.()
111
+ return () => clearInterval(timer)
112
+ }
@@ -162,12 +162,18 @@ export function resolvePeerDeliveryTarget(
162
162
  peer: PeerRecord,
163
163
  env: NodeJS.ProcessEnv = process.env,
164
164
  ): Result<DeliveryTarget> {
165
- if (runtime) return resolveDeliveryTarget({ personality, runtime, env })
166
- if (peer.runtime) {
167
- const exactDefault = resolveDeliveryTarget({ personality, runtime: peer.runtime, env })
168
- if (exactDefault.ok) return exactDefault
169
- }
170
- return resolveDeliveryTarget({ personality, env })
165
+ // Routing is CONFIG-ANCHORED, not liveness-selected. An omitted runtime means
166
+ // exactly the peer's default_runtime (`PeerRecord.runtime` is the normalized
167
+ // in-memory name), even when another declared runtime already has a live
168
+ // session. If that default endpoint is asleep, returning its exact offline
169
+ // miss is load-bearing: routeSend will wake THE DEFAULT instead of falling
170
+ // through to whichever non-default session happens to be alive.
171
+ //
172
+ // An explicit runtime remains an exact override. `resolveDeliveryTarget`
173
+ // without a runtime is deliberately NOT used here: its sole-live discovery is
174
+ // useful for observation/control callers, but would silently defeat the
175
+ // operator's `default-runtime` routing lever.
176
+ return resolveDeliveryTarget({ personality, runtime: runtime ?? peer.runtime, env })
171
177
  }
172
178
 
173
179
  // Confirm poll interval — how often deliverViaHost re-reads the transcript for a NEW record carrying
@@ -1407,8 +1413,7 @@ export function hasTelegramPresence(record: PeerRecord): boolean {
1407
1413
  * ok:true — a false-OK + silent loss, the class this contract forbids. Applied to
1408
1414
  * the INTENDED channel (override / target default — before any wake side-effect,
1409
1415
  * since a wake delivers the envelope as the boot first-message) AND to the RESOLVED
1410
- * channel at both delivery points (the only-live-session fallback can land on
1411
- * telegram without either). */
1416
+ * channel at both delivery points (defence in depth against registry/wake drift). */
1412
1417
  function telegramSenderGuard(
1413
1418
  caller: ResolvedCaller,
1414
1419
  targetRuntime: string,
@@ -1500,9 +1505,8 @@ async function routeSendInner(
1500
1505
  // a mismatch is HELD (persisted verbatim + instructive error), not delivered — the
1501
1506
  // agent then confirms cross-channel or redirects to the origin, zero regeneration.
1502
1507
  // Intended runtime = the explicit override or the human's default; exact for the
1503
- // natural-peer class (their channels are always-on launchd routers, so the
1504
- // only-live-session fallback cannot silently diverge outside a seconds-wide crash
1505
- // window). Initiative (answered / no inbound / ARM_TTL-stale) passes untouched.
1508
+ // natural-peer class (implicit routing is strictly default_runtime-anchored).
1509
+ // Initiative (answered / no inbound / ARM_TTL-stale) passes untouched.
1506
1510
  if (
1507
1511
  !deps.originGuardBypass &&
1508
1512
  originGuardEnabled(env) &&
@@ -1561,8 +1565,8 @@ async function routeSendInner(
1561
1565
  const target = resolvePeerDeliveryTarget(personality, runtime, peer, env)
1562
1566
  if (target.ok) {
1563
1567
  if (target.value.address === caller.address) return err('cannot send to self')
1564
- // Telegram sender policy — RESOLVED channel (the only-live-session fallback can
1565
- // land on telegram without an override and without telegram being the default).
1568
+ // Telegram sender policy — RESOLVED channel (defence in depth: the resolved
1569
+ // target must still obey the domain guard even though selection is exact).
1566
1570
  const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
1567
1571
  if (!liveGuard.ok) return liveGuard
1568
1572
  const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic, sentAt })