@agfpd/iapeer 0.4.104 → 0.4.106

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.
@@ -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.
@@ -63,6 +63,8 @@ The daemon's supervisory tick is fixed — once a minute, not configurable.
63
63
  | `IAPEER_DELIVERY_LOG_KEEP` | `5` | How many `delivery.log` rotations to keep. |
64
64
  | `IAPEER_PANELOG_MAX_BYTES` | `16 MiB` | The per-peer pane-log (`logs/lifecycle/<runtime>-<peer>.log`, the raw terminal byte stream) size at which rotation happens. |
65
65
  | `IAPEER_PANELOG_KEEP` | `2` | How many rotated pane-log copies to keep (`<log>.1` newest … `.N`). |
66
+ | `IAPEER_PANELOG_DIR_BUDGET` | `512 MiB` | The retention ceiling on the WHOLE `logs/lifecycle/` directory — the bound that does not grow with the fleet. |
67
+ | `IAPEER_PANELOG_STALE_DAYS` | `14` | Age after which a pane-log artifact (base or copy) is reclaimed — this is what frees the logs of dead/removed peers. |
66
68
  | `IAPEER_SUPERVISE_LOG_VERBOSE` | off | A verbose log of supervisory ticks (by default only decisions are written, not routine "doing nothing"). |
67
69
  | `IAPEER_DAEMON_LOG` | off | An extended daemon log. |
68
70
 
@@ -84,6 +86,14 @@ The daemon keeps three durable logs in `~/.iapeer/logs/iapeer/`:
84
86
 
85
87
  Per-peer **pane-logs** live separately under `~/.iapeer/logs/lifecycle/<runtime>-<peer>.log` — the raw terminal (pty) byte stream the supervisor appends for a session's whole life (occupancy/typing detection, the boot ready-gate, and `tail`-based monitors read them). They are rotated on the supervise tick by **copytruncate** (`IAPEER_PANELOG_MAX_BYTES` × `IAPEER_PANELOG_KEEP`, default 16 MiB × 2): the base file keeps its path and inode (its last 8 MiB tail is retained in place, so the live writer and every reader are undisturbed) while the preceding window is promoted to `<log>.1` … `.N`. Total per peer is bounded by roughly `MAX_BYTES × (KEEP + 1)`.
86
88
 
89
+ That per-file bound alone is a *per-peer* one, so the directory total was still a product of the fleet size (peers × runtimes) and nothing ever reclaimed the artifacts of a peer that had died or been removed. The same supervise tick therefore also runs a **retention** pass over the directory, reclaiming in cheapest-loss-first order:
90
+
91
+ 1. **Age** — every artifact (base or copy) untouched for longer than `IAPEER_PANELOG_STALE_DAYS` (14 d) is deleted. Nothing live reads a base that cold — idle-reap ends a warm session in minutes, and only a *current* session's base feeds the ready-gate seed and the mtime-occupancy signal — so this is what finally frees dead, removed, and renamed peers. The supervisor recreates the file on the next wake.
92
+ 2. **Budget / copies** — while the directory exceeds `IAPEER_PANELOG_DIR_BUDGET` (512 MiB), rotated copies are deleted oldest-first. They are history nobody reads live, so they are the correct first casualty.
93
+ 3. **Budget / backstop** — if the copies run out and the directory is still over budget, the largest bases are shrunk in place to the 8 MiB reader-floor, inode *and mtime* preserved (a janitor write must never masquerade as peer activity in the occupancy contract).
94
+
95
+ The floor of step 3 is `live-identities × 8 MiB`, so the budget is authoritative up to roughly 64 concurrently warm identities at the defaults; past that the reader-floor deliberately outranks the budget (truncating below the seed window would starve occupancy/ready-gate detection) and the pass records `outcome=over-budget-after-gc` in `lifecycle.log`, where raising `IAPEER_PANELOG_DIR_BUDGET` is the remedy. Every pass that reclaims anything leaves an `action=panelog-gc` line with the before/after megabytes. The whole pass is idempotent and best-effort by construction: it runs on each tick and never fails one.
96
+
87
97
  ### Postmortem diagnostics
88
98
 
89
99
  The cause of a session's death (exit code / signal) the supervisor records at the moment of death to `exits.log` — what an ordinary "is the process alive" check misses. It's the material for answering "why did the agent die".
@@ -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 секунд, потом досылает принудительно.
@@ -63,6 +63,8 @@
63
63
  | `IAPEER_DELIVERY_LOG_KEEP` | `5` | Сколько ротаций `delivery.log` хранить. |
64
64
  | `IAPEER_PANELOG_MAX_BYTES` | `16 МиБ` | Размер пер-пирового pane-лога (`logs/lifecycle/<runtime>-<peer>.log`, сырой байтовый поток терминала), при котором происходит ротация. |
65
65
  | `IAPEER_PANELOG_KEEP` | `2` | Сколько ротированных копий pane-лога хранить (`<log>.1` — свежайшая … `.N`). |
66
+ | `IAPEER_PANELOG_DIR_BUDGET` | `512 МиБ` | Потолок хранения для ВСЕГО каталога `logs/lifecycle/` — граница, которая не растёт вместе с флотом. |
67
+ | `IAPEER_PANELOG_STALE_DAYS` | `14` | Возраст, после которого артефакт pane-лога (база или копия) освобождается; именно это убирает логи мёртвых и удалённых пиров. |
66
68
  | `IAPEER_SUPERVISE_LOG_VERBOSE` | выкл. | Подробный лог надзорных тиков (по умолчанию пишутся только решения, не рутинные «ничего не делаю»). |
67
69
  | `IAPEER_DAEMON_LOG` | выкл. | Расширенный лог демона. |
68
70
 
@@ -84,6 +86,14 @@
84
86
 
85
87
  Пер-пировые **pane-логи** лежат отдельно в `~/.iapeer/logs/lifecycle/<runtime>-<peer>.log` — сырой байтовый поток терминала (pty), который супервизор дописывает всю жизнь сессии (его читают детект занятости/typing, boot ready-gate и `tail`-мониторы). Они ротируются на надзорном тике методом **copytruncate** (`IAPEER_PANELOG_MAX_BYTES` × `IAPEER_PANELOG_KEEP`, по умолчанию 16 МиБ × 2): базовый файл сохраняет путь и inode (его последние 8 МиБ остаются на месте, поэтому живой писатель и все читатели не задеты), а предшествующее окно повышается в `<log>.1` … `.N`. Суммарный объём на пира ограничен примерно `MAX_BYTES × (KEEP + 1)`.
86
88
 
89
+ Эта пер-файловая граница действует лишь *на одного пира*, поэтому суммарный вес каталога оставался произведением размера флота (пиры × рантаймы), а артефакты умершего или удалённого пира не освобождались никогда. Поэтому тот же надзорный тик выполняет над каталогом проход **хранения (retention)**, освобождая место в порядке «сначала наименее ценное»:
90
+
91
+ 1. **Возраст** — любой артефакт (база или копия), не тронутый дольше `IAPEER_PANELOG_STALE_DAYS` (14 сут.), удаляется. Настолько холодную базу не читает ничто живое: idle-reap завершает тёплую сессию за минуты, а seed boot-гейта и сигнал занятости по mtime смотрят только на базу *текущей* сессии. Именно этот ярус освобождает мёртвых, удалённых и переименованных пиров; при следующем пробуждении супервизор создаёт файл заново.
92
+ 2. **Бюджет / копии** — пока каталог превышает `IAPEER_PANELOG_DIR_BUDGET` (512 МиБ), ротированные копии удаляются начиная с самых старых. Это история, которую никто не читает живьём, — верная первая жертва.
93
+ 3. **Бюджет / страховка** — если копии кончились, а каталог всё ещё сверх бюджета, крупнейшие базы усекаются на месте до читательского пола в 8 МиБ с сохранением inode *и mtime* (запись уборщика не должна выдавать себя за активность пира в контракте занятости).
94
+
95
+ Пол яруса 3 равен `живые_идентичности × 8 МиБ`, поэтому на умолчаниях бюджет авторитетен примерно до 64 одновременно тёплых идентичностей; дальше читательский пол намеренно перевешивает бюджет (усечение ниже seed-окна лишило бы детект занятости и boot-гейт данных), проход пишет в `lifecycle.log` строку с `outcome=over-budget-after-gc`, и лечится это повышением `IAPEER_PANELOG_DIR_BUDGET`. Каждый проход, что-либо освободивший, оставляет строку `action=panelog-gc` с мегабайтами до и после. Проход идемпотентен и best-effort по построению: выполняется на каждом тике и никогда его не роняет.
96
+
87
97
  ### Посмертная диагностика
88
98
 
89
99
  Причину смерти сессии (код выхода / сигнал) супервизор фиксирует в момент смерти в `exits.log` — это то, что обычная проверка «жив ли процесс» не ловит. Это материал для ответа на вопрос «почему агент умер».
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.104",
3
+ "version": "0.4.106",
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",
@@ -21,7 +21,24 @@
21
21
  // swallows errors. The dropped head of an already-oversized legacy file is redraw noise; the
22
22
  // last maxBytes is snapshotted, older bytes are discarded (never a giant copy of the bloat).
23
23
 
24
- import { openSync, closeSync, readSync, readdirSync, statSync, unlinkSync, writeFileSync, fstatSync, renameSync } from 'fs'
24
+ // VOLUME RETENTION (gcPaneLogs, below) is the SECOND half of the bound. Per-file rotation caps one
25
+ // identity (16 MiB base + N×16 MiB copies), but the DIRECTORY total is then a product of the fleet
26
+ // size — it only ever grows with peers × runtimes, and nothing ever reclaimed the artifacts of a peer
27
+ // that died or was removed months ago. The janitor therefore also enforces an age retention and a
28
+ // hard directory BUDGET each tick, so the ceiling is a fixed number instead of a per-peer multiple.
29
+
30
+ import {
31
+ openSync,
32
+ closeSync,
33
+ readSync,
34
+ readdirSync,
35
+ statSync,
36
+ unlinkSync,
37
+ writeFileSync,
38
+ fstatSync,
39
+ renameSync,
40
+ utimesSync,
41
+ } from 'fs'
25
42
  import { join } from 'path'
26
43
 
27
44
  /** Rotate threshold (env IAPEER_PANELOG_MAX_BYTES) — a base pane-log over this is rotated.
@@ -36,6 +53,17 @@ export const PANELOG_COPIES = 2
36
53
  * env-tunable: capping the base below the seed would starve occupancy/ready-gate detection. */
37
54
  export const PANELOG_TAIL_BYTES = 8 * 1024 * 1024
38
55
 
56
+ /** Directory BUDGET (env IAPEER_PANELOG_DIR_BUDGET) — the observable ceiling on the WHOLE
57
+ * pane-log dir, enforced every tick by gcPaneLogs. Default 512 MiB. Sized off the live shape of
58
+ * this host: the artifacts that are load-bearing are the bases of CONCURRENTLY warm sessions
59
+ * (~12 × ≤16 MiB here), everything else is history. See gcPaneLogs for the honest boundary. */
60
+ export const PANELOG_DIR_BUDGET_BYTES = 512 * 1024 * 1024
61
+ /** Age retention (env IAPEER_PANELOG_STALE_DAYS) — any pane-log artifact untouched for longer is
62
+ * reclaimed. Default 14 d: FAR beyond any live signal (idle-reap kills a warm session in minutes,
63
+ * the ready-gate seed and the mtime-occupancy contract only ever read a CURRENT session's base),
64
+ * so a base this cold belongs to a peer that is dead, removed, or renamed. A wake recreates it. */
65
+ export const PANELOG_STALE_MS = 14 * 24 * 60 * 60 * 1000
66
+
39
67
  /** Parse a positive integer env value, else the default (mirror of the daemon-log config helpers). */
40
68
  function envPosInt(raw: string | undefined, dflt: number): number {
41
69
  const n = raw !== undefined ? Number(raw) : NaN
@@ -135,3 +163,164 @@ export function capPaneLogs(
135
163
  }
136
164
  return rotated
137
165
  }
166
+
167
+ // ─────────────────────────── volume retention (dir budget) ───────────────────────────
168
+
169
+ /** Resolve the retention knobs from env (defaults above). */
170
+ export function paneLogGcConfig(env: NodeJS.ProcessEnv = process.env): { budgetBytes: number; staleMs: number } {
171
+ const days = env.IAPEER_PANELOG_STALE_DAYS
172
+ const staleDays = days !== undefined && Number.isFinite(Number(days)) && Number(days) > 0 ? Number(days) : 14
173
+ return {
174
+ budgetBytes: envPosInt(env.IAPEER_PANELOG_DIR_BUDGET, PANELOG_DIR_BUDGET_BYTES),
175
+ staleMs: Math.round(staleDays * 24 * 60 * 60 * 1000),
176
+ }
177
+ }
178
+
179
+ /** One pane-log artifact on disk. `base` = `<identity>.log` (live-readable), else a rotated copy. */
180
+ type PaneLogArtifact = { path: string; size: number; mtimeMs: number; base: boolean }
181
+
182
+ /** Snapshot the dir's artifacts (bases + rotated copies). Unreadable entries are skipped. */
183
+ function scanPaneLogs(logDir: string): PaneLogArtifact[] {
184
+ let names: string[]
185
+ try {
186
+ names = readdirSync(logDir)
187
+ } catch {
188
+ return []
189
+ }
190
+ const out: PaneLogArtifact[] = []
191
+ for (const name of names) {
192
+ const base = name.endsWith('.log')
193
+ if (!base && !/\.log\.\d+$/.test(name)) continue // only OUR artifacts — never a foreign file
194
+ const path = join(logDir, name)
195
+ try {
196
+ const st = statSync(path)
197
+ if (st.isFile()) out.push({ path, size: st.size, mtimeMs: st.mtimeMs, base })
198
+ } catch {
199
+ /* vanished mid-scan → fine */
200
+ }
201
+ }
202
+ return out
203
+ }
204
+
205
+ /** Truncate a base IN PLACE to its last tailBytes, PRESERVING its mtime. Same inode (writer's
206
+ * O_APPEND fd + В19 heal survive, exactly as rotateFile step 3), and the mtime restore keeps the
207
+ * pane-log OCCUPANCY contract honest: a janitor write must never masquerade as peer activity.
208
+ * Returns the bytes reclaimed (0 on no-op/error). */
209
+ function shrinkBase(path: string, tailBytes: number): number {
210
+ try {
211
+ const st = statSync(path)
212
+ if (st.size <= tailBytes) return 0
213
+ const tail = readTail(path, tailBytes)
214
+ if (tail === null) return 0
215
+ writeFileSync(path, tail, { mode: 0o600 })
216
+ try {
217
+ utimesSync(path, st.atime, st.mtime) // janitor ≠ activity
218
+ } catch {
219
+ /* best-effort */
220
+ }
221
+ return st.size - tail.length
222
+ } catch {
223
+ return 0
224
+ }
225
+ }
226
+
227
+ /** What one GC pass did — observability for the supervise-tick trace. */
228
+ export type PaneLogGcResult = {
229
+ bytesBefore: number
230
+ bytesAfter: number
231
+ /** Artifacts deleted by the AGE retention (dead/removed peers, ancient rotations). */
232
+ reapedStale: number
233
+ /** Rotated copies deleted by the BUDGET, oldest first. */
234
+ droppedCopies: number
235
+ /** Live bases shrunk to the reader-floor as the budget backstop. */
236
+ shrunkBases: number
237
+ /** True when even the backstop could not reach the budget — see the honest boundary below. */
238
+ overBudget: boolean
239
+ }
240
+
241
+ /**
242
+ * RETENTION pass over the pane-log dir — turns the per-file rotation bound into a DIRECTORY bound.
243
+ * Runs right after capPaneLogs on every supervise tick (idempotent, best-effort, no manual step),
244
+ * reclaiming in cheapest-loss-first order:
245
+ *
246
+ * 1. AGE — every artifact (base OR copy) untouched for > staleMs is deleted. This is what finally
247
+ * reclaims peers that died, were removed, or renamed: nothing live reads a base that cold, and
248
+ * the supervisor recreates it on the next wake.
249
+ * 2. BUDGET / copies — while the dir exceeds budgetBytes, delete rotated copies OLDEST FIRST.
250
+ * Copies are history nobody reads live, so they are the correct first casualty.
251
+ * 3. BUDGET / backstop — if copies ran out and the dir is STILL over, shrink the largest bases to
252
+ * the reader-floor (tailBytes, ≥ the ready-gate seed window) with mtime preserved.
253
+ *
254
+ * HONEST BOUNDARY: the floor of step 3 is `live-identities × tailBytes` (8 MiB each). The budget is
255
+ * therefore authoritative up to ~`budgetBytes / tailBytes` concurrently warm identities (~64 at the
256
+ * defaults); beyond that the reader-floor wins, `overBudget` comes back true, and the budget must be
257
+ * raised via IAPEER_PANELOG_DIR_BUDGET. Truncating below the seed window would starve
258
+ * occupancy/ready-gate detection, so the floor deliberately outranks the budget.
259
+ */
260
+ export function gcPaneLogs(
261
+ logDir: string,
262
+ opts: { budgetBytes?: number; staleMs?: number; tailBytes?: number; nowMs?: number } = {},
263
+ ): PaneLogGcResult {
264
+ const budgetBytes = opts.budgetBytes ?? PANELOG_DIR_BUDGET_BYTES
265
+ const staleMs = opts.staleMs ?? PANELOG_STALE_MS
266
+ const tailBytes = opts.tailBytes ?? PANELOG_TAIL_BYTES
267
+ const nowMs = opts.nowMs ?? Date.now()
268
+
269
+ let live = scanPaneLogs(logDir)
270
+ const bytesBefore = live.reduce((s, a) => s + a.size, 0)
271
+ const res: PaneLogGcResult = {
272
+ bytesBefore,
273
+ bytesAfter: bytesBefore,
274
+ reapedStale: 0,
275
+ droppedCopies: 0,
276
+ shrunkBases: 0,
277
+ overBudget: false,
278
+ }
279
+ if (live.length === 0) return res
280
+
281
+ let total = bytesBefore
282
+ const drop = (a: PaneLogArtifact): boolean => {
283
+ try {
284
+ unlinkSync(a.path)
285
+ total -= a.size
286
+ return true
287
+ } catch {
288
+ return false // per-file best-effort — retention never fails a supervise tick
289
+ }
290
+ }
291
+
292
+ // 1 — AGE retention.
293
+ const staleCutoff = nowMs - staleMs
294
+ const kept: PaneLogArtifact[] = []
295
+ for (const a of live) {
296
+ if (a.mtimeMs < staleCutoff && drop(a)) res.reapedStale++
297
+ else kept.push(a)
298
+ }
299
+ live = kept
300
+
301
+ // 2 — BUDGET: rotated copies, oldest first.
302
+ if (total > budgetBytes) {
303
+ const copies = live.filter((a) => !a.base).sort((x, y) => x.mtimeMs - y.mtimeMs)
304
+ for (const a of copies) {
305
+ if (total <= budgetBytes) break
306
+ if (drop(a)) res.droppedCopies++
307
+ }
308
+ }
309
+
310
+ // 3 — BUDGET backstop: shrink the largest bases to the reader-floor.
311
+ if (total > budgetBytes) {
312
+ const bases = live.filter((a) => a.base && a.size > tailBytes).sort((x, y) => y.size - x.size)
313
+ for (const a of bases) {
314
+ if (total <= budgetBytes) break
315
+ const freed = shrinkBase(a.path, tailBytes)
316
+ if (freed > 0) {
317
+ total -= freed
318
+ res.shrunkBases++
319
+ }
320
+ }
321
+ }
322
+
323
+ res.bytesAfter = total
324
+ res.overBudget = total > budgetBytes
325
+ return res
326
+ }
@@ -28,7 +28,7 @@ import {
28
28
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
29
29
  import { renderEnvelopeForAgent } from '../codec/index.ts'
30
30
  import { err, ok, type Result } from '../core/errors.ts'
31
- import { capPaneLogs, paneLogRotateConfig } from '../launch/cmdlog.ts'
31
+ import { capPaneLogs, gcPaneLogs, paneLogGcConfig, paneLogRotateConfig } from '../launch/cmdlog.ts'
32
32
  import { resolveGlobalRoot } from '../storage/index.ts'
33
33
  import { resolveMemoryWritableRoots } from '../status/index.ts'
34
34
  import { approvalModeOf, readPeerProfile, resolveIdentity, type ApprovalMode } from '../identity/index.ts'
@@ -1524,12 +1524,31 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1524
1524
  // to lifecycle ownership, so launchd-managed routers' pane-logs are rotated too.
1525
1525
  const paneLogRc = paneLogRotateConfig(env)
1526
1526
  capPaneLogs(cfg.logDir, paneLogRc.maxBytes, paneLogRc.copies)
1527
+ // Pane-log volume RETENTION (gcPaneLogs): rotation bounds ONE identity, but the DIRECTORY total
1528
+ // is that bound × the fleet — it only grew with peers × runtimes, and the artifacts of dead /
1529
+ // removed peers were never reclaimed. Enforce the age retention + the directory BUDGET right
1530
+ // after the rotation, on the same tick and the same best-effort contract (knobs:
1531
+ // IAPEER_PANELOG_DIR_BUDGET / IAPEER_PANELOG_STALE_DAYS, defaults 512 MiB / 14 d).
1532
+ const paneLogGc = gcPaneLogs(cfg.logDir, { ...paneLogGcConfig(env), nowMs })
1527
1533
  // Durable decision trace (eventlog.ts): every reap/death/eager-fresh gets a line
1528
1534
  // so a postmortem can answer "when & how did peer X's prior session end" even
1529
1535
  // after the .idle-reaped / .deaths markers are consumed. alive / skipped-launchd
1530
1536
  // are steady-state non-decisions → logged only under IAPEER_SUPERVISE_LOG_VERBOSE.
1531
1537
  const trace = (fields: Record<string, string | number | undefined>): void =>
1532
1538
  appendLifecycleEvent(cfg.eventLogDir, { ev: 'supervise', ...fields }, { env, nowMs })
1539
+ // Retention is silent in steady state (nothing to reclaim); a pass that actually reclaimed —
1540
+ // or that could NOT reach the budget even after the backstop — leaves a durable line.
1541
+ if (paneLogGc.reapedStale || paneLogGc.droppedCopies || paneLogGc.shrunkBases || paneLogGc.overBudget) {
1542
+ trace({
1543
+ action: 'panelog-gc',
1544
+ stale: paneLogGc.reapedStale,
1545
+ copies: paneLogGc.droppedCopies,
1546
+ shrunk: paneLogGc.shrunkBases,
1547
+ mb_before: Math.round(paneLogGc.bytesBefore / (1024 * 1024)),
1548
+ mb_after: Math.round(paneLogGc.bytesAfter / (1024 * 1024)),
1549
+ outcome: paneLogGc.overBudget ? 'over-budget-after-gc' : 'within-budget',
1550
+ })
1551
+ }
1533
1552
  const out: SuperviseOutcome[] = []
1534
1553
  const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, childActivityMt, isAlive, kill, verbose, trace }
1535
1554
  for (const s of readSessionStates(cfg)) {
@@ -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 })