@agfpd/iapeer 0.2.73 → 0.2.74

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
@@ -2,10 +2,28 @@
2
2
 
3
3
  Ядро мультиагентной системы на одном хосте: реестр пиров, единый демон-роутер для обмена сообщениями между ними и команды управления — создание, запуск, усыпление, перезапуск, подключение к внешним каналам. Пиром может быть AI-агент (Claude, Codex), человек (через Telegram) или служебный процесс; все адресуются одинаково через один протокол.
4
4
 
5
+ ## Ключевые возможности
6
+
7
+ iapeer держит AI-агентов как **живые интерактивные сессии настоящего CLI** (Claude Code, Codex CLI) — со всем поведением реального CLI, а не headless-режим одноразовых вызовов (`claude -p`, `codex exec`). Поскольку это реальный интерактивный CLI, пиры работают в рамках обычной подписки на Claude Code (изменение тарификации headless-режима от 15 июня 2026 интерактивный CLI не затрагивает).
8
+
9
+ Отдельные свойства ниже встречаются и в других инструментах; сильная сторона iapeer — их **комбинация целиком**:
10
+
11
+ 1. **Реальный интерактивный CLI в фоне.** Каждый AI-пир — полноценная сессия Claude Code или Codex CLI, поднятая в фоне и доступная по сообщению.
12
+
13
+ 2. **Постоянная сессия (persistent).** На входящее демон поднимает пира через возобновление (`--continue` / `--resume`) — диалог **продолжается**, а не начинается заново; контекст и история сохраняются между обращениями.
14
+
15
+ 3. **Событийный warm-on-demand жизненный цикл.** Сессия «тёплая» → быстро обрабатывает входящие; после ~часа простоя демон закрывает её (idle-reap); новое входящее → демон поднимает заново **автоматически**, с возобновлением контекста. Жизненным циклом правит поток сообщений, а не присутствие человека: это не привязанный к разработчику TUI и не одноразовый воркер на задачу. Ресурсы заняты, только пока пир работает.
16
+
17
+ 4. **Переносимость пиров между рантаймами.** Личность пира (`personality`) отделена от рантайма: один пир присутствует и в Claude, и в Codex, а основной рантайм переключается командой (`claude ↔ codex`) без потери идентичности и адресации.
18
+
19
+ 5. **Подключение человека к живой сессии.** `iapeer attach` открывает **живую** сессию пира прямо в вашем терминале через pty-супервизор — мышь, прокрутка и цвета работают нативно; отсоединение (`Ctrl-]`) сессию не закрывает.
20
+
21
+ Целиком эту связку — событийный warm-on-demand жизненный цикл (не attached-разработчиком TUI и не воркер-на-задачу), переносимость пиров между рантаймами и единую ткань обмена (протокол IAP, память-vault с поиском, Telegram-мост) — даёт iapeer.
22
+
5
23
  ## Документация
6
24
 
7
25
  Документация проекта — в [`docs/`](docs/README.md): продуктовая часть (что это и как пользоваться) и механика изнутри ([`docs/internals/`](docs/internals/README.md)). Этот репозиторий — кодовая реализация.
8
26
 
9
27
  ## Статус
10
28
 
11
- Версия — **0.2.58**. Платформа — macOS/launchd.
29
+ Версия — **0.2.73**. Платформа — macOS/launchd.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.73",
3
+ "version": "0.2.74",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,6 +39,29 @@ const TICK_CMD = ['sh', '-c', 'i=0; while true; do printf "STREAMTICK_%d\\r\\n"
39
39
  // answer off its model. 'tick' (and any unknown runtime) has no entry → no driver → the hermetic
40
40
  // spawn/attach tests and the bare conduit are untouched.
41
41
  const BOOT_ADAPTERS: Record<string, BootPredicates> = { codex: codexAdapter, claude: claudeAdapter }
42
+
43
+ // SCROLLBACK SPINNER STRIP (serve-side, repaint snapshot only). claude's TUI redraws its bottom status
44
+ // block WITHOUT a scroll-region (DECSTBM), so as the conversation scrolls, stale spinner frames
45
+ // ("✶ Gerund… (Nm Ns · ↓ N tokens)" / "… (esc to interrupt)") get pushed into the @xterm scrollback ring
46
+ // and ACCUMULATE — surfacing as repeated/broken lines when a reattaching operator scrolls history UP
47
+ // (the live viewport is clean; only the served history is polluted). We drop those lines from the
48
+ // SCROLLBACK part of the serialized repaint snapshot, keeping the last `viewportRows` segments (the LIVE
49
+ // status block, incl. the current spinner) verbatim. Matched by STRUCTURE — a gerund + … + the
50
+ // elapsed/token or esc-to-interrupt tail — NOT a word-list (claude rotates 100+ spinner gerunds). The
51
+ // live byte stream, pane-log and transcript are untouched; only the human-attach repaint is filtered.
52
+ const SPINNER_STATUS_LINE = /…[^\n]{0,48}\((?:esc to interrupt|\d+m? ?\d*s ?·)/
53
+ const ANSI_SEQ = /\x1b\[[0-9;:?]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
54
+ export function stripSpinnerScrollback(serialized: string, viewportRows: number): string {
55
+ const segs = serialized.split('\r\n')
56
+ if (segs.length <= viewportRows) return serialized // no scrollback to clean
57
+ const scrollbackEnd = segs.length - viewportRows // keep the last viewportRows (live viewport) verbatim
58
+ const out: string[] = []
59
+ for (let i = 0; i < segs.length; i++) {
60
+ if (i < scrollbackEnd && SPINNER_STATUS_LINE.test(segs[i].replace(ANSI_SEQ, ''))) continue
61
+ out.push(segs[i])
62
+ }
63
+ return out.join('\r\n')
64
+ }
42
65
  // Poll cadence mirrors the launch primitive's proven 2 s boot loop (index.ts:377). The supervisor
43
66
  // model is strictly FRESHER than capture-pane (bytes land in @xterm synchronously as they arrive),
44
67
  // so 2 s leaves ample repaint time — the claude resume-picker never over-Downs, since bootDialogKeys
@@ -220,7 +243,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
220
243
  /** Repaint a (re)attaching client from the headless snapshot: clear+home, then the full grid.
221
244
  * Backpressure-safe — a ~90 KB heavy snapshot MUST NOT drop past the socket buffer (port-dep #2). */
222
245
  const repaint = (s: { write(b: Buffer): number }): void => {
223
- const snap = '\x1b[2J\x1b[3J\x1b[H' + ser.serialize()
246
+ const snap = '\x1b[2J\x1b[3J\x1b[H' + stripSpinnerScrollback(ser.serialize(), xterm.rows)
224
247
  sendClient(s, frame(FRAME_DATA, Buffer.from(snap, 'utf8')))
225
248
  }
226
249
 
@@ -0,0 +1,52 @@
1
+ // Scrollback spinner strip (serve-side repaint snapshot). claude's TUI redraws its status block without
2
+ // a scroll-region, so stale spinner frames accumulate in the @xterm scrollback ring and pollute a
3
+ // reattaching operator's scroll-up. stripSpinnerScrollback drops those frames from the SCROLLBACK part
4
+ // of the serialized snapshot while keeping the live viewport (incl. the current spinner) verbatim,
5
+ // matched by STRUCTURE (gerund + … + elapsed/token or esc-to-interrupt tail), not a word-list.
6
+ import { describe, expect, test } from 'bun:test'
7
+ import { stripSpinnerScrollback } from './daemon.ts'
8
+
9
+ describe('stripSpinnerScrollback — drop stale spinner frames from served scrollback', () => {
10
+ test('strips scrollback spinner-status lines (elapsed + tokens), keeps real history + the live viewport', () => {
11
+ const lines = [
12
+ 'real history line 1',
13
+ '\x1b[38;2;215;119;87m✶\x1b[39m Metamorphosing… (4m 24s · ↓ 18.4k tokens)', // scrollback spinner → drop
14
+ 'real history line 2',
15
+ '· Quantumizing… (6m 57s · ↓ 30.0k tokens)', // scrollback spinner → drop
16
+ // last 3 = viewport (the LIVE status block, kept verbatim incl. the current spinner)
17
+ 'real history line 3',
18
+ '────────────',
19
+ '✽ Brewing… (1m 59s · ↓ 6.5k tokens)',
20
+ ].join('\r\n')
21
+ const out = stripSpinnerScrollback(lines, 3).split('\r\n')
22
+ expect(out).toContain('real history line 1')
23
+ expect(out).toContain('real history line 2')
24
+ expect(out).toContain('real history line 3')
25
+ expect(out).toContain('✽ Brewing… (1m 59s · ↓ 6.5k tokens)') // viewport live spinner KEPT
26
+ expect(out.some(l => /Metamorphosing/.test(l))).toBe(false)
27
+ expect(out.some(l => /Quantumizing/.test(l))).toBe(false)
28
+ })
29
+
30
+ test('strips the (esc to interrupt) tail variant in scrollback', () => {
31
+ const lines = ['history', '· Running… (esc to interrupt · 1.2k tokens)', 'vp1', 'vp2', 'vp3'].join('\r\n')
32
+ const out = stripSpinnerScrollback(lines, 3)
33
+ expect(out.includes('Running…')).toBe(false)
34
+ expect(out.includes('history')).toBe(true)
35
+ })
36
+
37
+ test('does NOT over-strip — real content with … or (…) but no spinner tail is kept', () => {
38
+ const lines = [
39
+ 'мы обсудили тайминг… он занял время', // … but no elapsed/token tail
40
+ 'итого вышло (5 долларов)', // (…) but no elapsed/esc tail
41
+ 'vp1', 'vp2', 'vp3',
42
+ ].join('\r\n')
43
+ const out = stripSpinnerScrollback(lines, 3).split('\r\n')
44
+ expect(out).toContain('мы обсудили тайминг… он занял время')
45
+ expect(out).toContain('итого вышло (5 долларов)')
46
+ })
47
+
48
+ test('the LIVE spinner in the viewport is never stripped (no scrollback → unchanged)', () => {
49
+ const lines = ['✶ Vibing… (3s · ↓ 1 tokens)', 'vp2', 'vp3'].join('\r\n')
50
+ expect(stripSpinnerScrollback(lines, 3)).toBe(lines) // segs <= viewportRows → returned verbatim
51
+ })
52
+ })