@agfpd/iapeer 0.4.14 → 0.4.15

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,14 +10,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.4.15] - 2026-06-21
14
+
15
+ ### Fixed
16
+
17
+ - Ephemeral idle-reap no longer kills a **working** session mid-turn. An armed ephemeral
18
+ worker (one that has sent its reply) was reaped after 20s of *transcript* silence, but the
19
+ transcript goes quiet during a long model generation — so a still-working worker was killed
20
+ mid-turn. The reap now folds in the pane-log (TUI render-stream) mtime, so "quiet" means the
21
+ turn truly ended: a working session is never reaped, while an idle one still reaps in ~20s
22
+ (keeping the ephemeral serial conveyor draining promptly).
23
+
24
+ ## [0.4.14] - 2026-06-21
25
+
26
+ ### Fixed
27
+
28
+ - **Completed the bot_username cutover.** The telegram sender-guard (`hasTelegramPresence`)
29
+ now keys on `interfaces.telegram.bot_username` (the cutover's binding key) instead of the
30
+ legacy `bot`; the host registry was regenerated from the per-cwd source of truth, dropping
31
+ the stale `interfaces.telegram.bot` field across the fleet. telegram-runtime already
32
+ resolved on `bot_username`, so the foundation guard was the last consumer of `bot`.
33
+ - `recordDrift` (verify / reconcile) now compares the `interfaces` passport with an
34
+ order-insensitive canonical compare, so a source↔derived divergence is detected and healed
35
+ by `verify --fix` — the class that had let the derived registry silently keep a field
36
+ removed from the source of truth.
37
+
13
38
  ### Changed
14
39
 
15
40
  - Fact-actualized the repo docs against the current code: pty-only (tmux references removed),
16
41
  no canary/forensics in postmortem diagnostics, and the legacy `runtime`-mirror drop.
42
+ - Unified the README to the fleet documentation style.
17
43
 
18
44
  ### Added
19
45
 
20
- - This changelog.
46
+ - This changelog (Keep a Changelog), shipped in the npm package.
47
+ - HD hero GIF in the README (1440×726 — sharper terminal text).
21
48
 
22
49
  ## [0.4.13] - 2026-06-20
23
50
 
@@ -206,7 +233,9 @@ Initial public release — the foundation core of the iapeer multi-agent ecosyst
206
233
  - **Heterogeneous peers** — AI agents (Claude, Codex), humans (Telegram), and services
207
234
  (timer = cron, watcher = event) are all first-class and addressed the same way.
208
235
 
209
- [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.13...HEAD
236
+ [Unreleased]: https://github.com/agfpd/iapeer/compare/v0.4.15...HEAD
237
+ [0.4.15]: https://github.com/agfpd/iapeer/compare/v0.4.14...v0.4.15
238
+ [0.4.14]: https://github.com/agfpd/iapeer/compare/v0.4.13...v0.4.14
210
239
  [0.4.13]: https://github.com/agfpd/iapeer/compare/v0.4.12...v0.4.13
211
240
  [0.4.12]: https://github.com/agfpd/iapeer/compare/v0.4.11...v0.4.12
212
241
  [0.4.11]: https://github.com/agfpd/iapeer/compare/v0.4.10...v0.4.11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
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",
@@ -12,7 +12,7 @@
12
12
  // before any wake or reap. wakeOrSpawn refuses a launchd peer; superviseTick and
13
13
  // sweepZombies skip it. Only daemon-owned (no-plist) peers are managed here.
14
14
 
15
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
15
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'
16
16
  import { homedir } from 'os'
17
17
  import { join } from 'path'
18
18
  import { spawnSync } from 'child_process'
@@ -74,20 +74,21 @@ export interface LifecycleConfig {
74
74
  crashLoopWindowSecs: number
75
75
  /** wake_policy:ephemeral M2 — the quiet window (seconds of activity-proxy silence)
76
76
  * after which an ARMED ephemeral session (it has sent its outbound reply) is
77
- * reaped. Long enough for the post-reply housekeeping writes (operative notes)
78
- * to keep resetting it; far below idleSecs. ASSUMPTION (by design): the
79
- * transcript mtime is a LIVENESS proxy — "no longer writing" — not a semantic
80
- * "done" signal. */
77
+ * reaped. Kept SHORT so the M3 serial conveyor drains the peer's next queued task
78
+ * promptly once its session is truly idle. "Quiet" here is BOTH the transcript proxy
79
+ * AND the pane-log (TUI render-stream) silent — the pane-log advances ~1s while the
80
+ * TUI renders a turn (generation OR a tool), so a still-working session is NEVER
81
+ * reaped mid-turn even though the transcript is momentarily quiet (the live incident
82
+ * this fixes: an armed worker generating its next step >20s was reaped at age=29s). */
81
83
  ephemeralQuietSecs: number
82
84
  /** wake_policy:ephemeral — the UNARMED idle bound (seconds): an ephemeral session
83
85
  * that never armed (finished silently / lost its arm to a daemon-restart window)
84
- * is reaped after this much activity-proxy silence. Live case: a worker that
85
- * ended silently without its final outbound stalled its M3 FIFO for the FULL
86
- * generic idleSecs (1 h) — with serial drain that blocks the whole conveyor per
87
- * silent worker. Bound chosen ≫ the legitimate silent-tool case
88
- * (sleep-180) and ≪ idleSecs. DOCUMENTED RISK: an ephemeral worker whose tool
89
- * stays silent longer than this MID-TASK is reaped and its consumed queue item
90
- * is lost — ephemeral workers must emit activity (or their reply) within it. */
86
+ * is reaped after this much TRUE-idle silence (transcript AND pane-log both quiet).
87
+ * Live case: a worker that ended silently without its final outbound stalled its M3
88
+ * FIFO for the FULL generic idleSecs (1 h) — with serial drain that blocks the whole
89
+ * conveyor per silent worker. Bound chosen ≫ a brief no-output gap and ≪ idleSecs.
90
+ * A still-WORKING silent-tool worker keeps the pane-log fresh via the TUI render, so it
91
+ * is no longer reaped mid-task — only a genuinely idle/wedged session crosses this. */
91
92
  ephemeralUnarmedIdleSecs: number
92
93
  }
93
94
 
@@ -343,6 +344,21 @@ export function clearEphemeralArmed(cfg: LifecycleConfig, identity: string): voi
343
344
  }
344
345
  }
345
346
 
347
+ /** mtime (ms) of a hosted session's pane-log (`<logDir>/<identity>.log`) — the RAW TUI
348
+ * render byte-stream the supervisor appends for the session's whole life. It advances
349
+ * ~1s while the TUI is rendering a turn (the "esc to interrupt" elapsed counter ticks
350
+ * during BOTH model generation AND a tool run) and goes quiet at the prompt. So it is
351
+ * the truest active-turn liveness signal — the SAME mtime telegram-runtime reads for its
352
+ * typing/activity indicator — and unlike the transcript proxy it does NOT go quiet during
353
+ * a long model generation (no JSONL write until the message completes). null if absent. */
354
+ function paneLogMtimeMs(cfg: LifecycleConfig, identity: string): number | null {
355
+ try {
356
+ return statSync(join(cfg.logDir, `${identity}.log`)).mtimeMs
357
+ } catch {
358
+ return null
359
+ }
360
+ }
361
+
346
362
  function deathsPath(cfg: LifecycleConfig, identity: string): string {
347
363
  return join(cfg.stateDir, `${identity}.deaths`)
348
364
  }
@@ -1147,6 +1163,9 @@ export interface SuperviseDeps {
1147
1163
  /** Liveness seam (default: the real `sessionAlive` = pty-host OR tmux-fallback). Tests inject a
1148
1164
  * fixed verdict so the idle path is exercised hermetically — no live tmux/pty session needed. */
1149
1165
  sessionAlive?: (sock: string, identity: string) => boolean
1166
+ /** Pane-log mtime seam (default: the real pane-log file mtime). Tests inject a controlled value to
1167
+ * exercise the ephemeral active-turn vs idle distinction without a real pane-log on disk. */
1168
+ paneLogMtime?: (identity: string) => number | null
1150
1169
  }
1151
1170
 
1152
1171
  export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
@@ -1154,6 +1173,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1154
1173
  const nowMs = deps.nowMs ?? Date.now()
1155
1174
  const activityMtime = deps.newestActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).newestActivityMtime(c))
1156
1175
  const isAlive = deps.sessionAlive ?? sessionAlive
1176
+ const paneLogMt = deps.paneLogMtime ?? ((identity: string) => paneLogMtimeMs(cfg, identity))
1157
1177
  const verbose = superviseLogVerbose(env)
1158
1178
  // Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
1159
1179
  // (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
@@ -1241,17 +1261,31 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1241
1261
  } catch {
1242
1262
  /* no adapter for this runtime yet → wokeAt baseline */
1243
1263
  }
1264
+ const ephemeral = isEphemeralPeer(s.cwd)
1265
+ if (ephemeral) {
1266
+ // FOLD IN the pane-log (TUI render-stream) mtime for ephemeral peers. The transcript
1267
+ // proxy goes QUIET during a long model generation (no JSONL write until the message
1268
+ // completes), so the short ephemeral windows below mis-read mid-generation as "done"
1269
+ // and reap the worker MID-TURN (live incident 2026-06-20: Index reaped age=29s while
1270
+ // generating the next step after a tool_result). The pane-log advances ~1s while the
1271
+ // TUI renders a turn (model generation OR a tool run — the "esc to interrupt" elapsed
1272
+ // counter ticks) and goes quiet only at the prompt, so it is the truer active-turn
1273
+ // signal. max() means "quiet" iff BOTH are silent — it can only DELAY a reap, never
1274
+ // cause an earlier one, so the pipeline serial-drain (idle → quiet → reaped) is
1275
+ // preserved while a working session is never killed mid-turn.
1276
+ const plMt = paneLogMt(s.identity)
1277
+ if (plMt !== null) mt = Math.max(mt, plMt)
1278
+ }
1244
1279
  const ageSecs = Math.floor((nowMs - mt) / 1000)
1245
1280
  // wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
1246
1281
  // daemon routed its outbound reply) is reaped after a QUIET window, checked
1247
- // BEFORE the idle branch (quiet ≪ idle). Quiet = the activity proxy stayed
1248
- // silent for ephemeralQuietSecs — post-reply housekeeping (operative-note
1249
- // writes) keeps resetting it, so the worker finishes its bookkeeping first.
1250
- // NOT armed (still mid-task, e.g. a long silent tool run) → the ordinary
1251
- // idle bound below is its only reaper. Deliberate, policy-driven death:
1252
- // NO .idle-reaped (an ephemeral peer never resumes) and NO recordDeath
1253
- // (the crash-loop ring counts faults, not policy reaps).
1254
- if (isEphemeralPeer(s.cwd) && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
1282
+ // BEFORE the idle branch (quiet ≪ idle). Quiet = transcript AND pane-log both
1283
+ // silent for ephemeralQuietSecs — a still-rendering turn keeps the pane-log fresh,
1284
+ // so the worker finishes its turn first; post-reply housekeeping also resets it.
1285
+ // NOT armed (still mid-task) → the ordinary idle bound below is its only reaper.
1286
+ // Deliberate, policy-driven death: NO .idle-reaped (an ephemeral peer never resumes)
1287
+ // and NO recordDeath (the crash-loop ring counts faults, not policy reaps).
1288
+ if (ephemeral && hasEphemeralArmed(cfg, s.identity) && ageSecs > cfg.ephemeralQuietSecs) {
1255
1289
  killSession(sock, s.identity)
1256
1290
  clearEphemeralArmed(cfg, s.identity)
1257
1291
  removeSessionState(cfg, s.identity)
@@ -1274,8 +1308,11 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1274
1308
  // silent-finish path is `iapeer self-done` (arm without waking anyone —
1275
1309
  // the invariant «нет пустых пробуждений» stays intact); this branch only
1276
1310
  // bounds the damage when a worker does neither. Policy reap: NO .idle-reaped
1277
- // (ephemeral never resumes), NO recordDeath (the ring counts faults).
1278
- if (isEphemeralPeer(s.cwd) && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
1311
+ // (ephemeral never resumes), NO recordDeath (the ring counts faults). The pane-log
1312
+ // fold-in above also covers this branch: a STILL-WORKING unarmed worker (a silent
1313
+ // tool, no reply yet) keeps the pane-log fresh via the TUI render, so it is no longer
1314
+ // reaped mid-task — only a genuinely idle/wedged unarmed session crosses this bound.
1315
+ if (ephemeral && ageSecs > cfg.ephemeralUnarmedIdleSecs) {
1279
1316
  killSession(sock, s.identity)
1280
1317
  clearEphemeralArmed(cfg, s.identity)
1281
1318
  removeSessionState(cfg, s.identity)