@agfpd/iapeer 0.2.72 → 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.72",
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": {
@@ -12,7 +12,7 @@
12
12
  // key-by-key retype), not the tty discipline.
13
13
 
14
14
  import { afterEach, describe, expect, test } from 'bun:test'
15
- import { mkdtempSync, readFileSync, rmSync, statSync } from 'fs'
15
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'
16
16
  import { tmpdir } from 'os'
17
17
  import { join } from 'path'
18
18
  import { spawnSync } from 'child_process'
@@ -89,6 +89,11 @@ describe('boot first-message delivery (load-buffer + bracketed paste)', () => {
89
89
  readyGateSecs: 8,
90
90
  logDir: join(root, 'logs'),
91
91
  }
92
+ // pty-supervisor hosting is the DEFAULT now; this fixture exercises the legacy TMUX boot/delivery
93
+ // path (load-buffer + bracketed paste) explicitly, so opt this peer OUT of pty with a .no-pty-host
94
+ // marker (otherwise launch hosts it under the supervisor and the tmux-path assertions never apply).
95
+ mkdirSync(cfg.logDir, { recursive: true })
96
+ writeFileSync(join(cfg.logDir, `${spec.identity}.no-pty-host`), '')
92
97
  try {
93
98
  const r = await launch(spec, catAdapter(recv), firstMessage, cfg)
94
99
  expect(r.status).toBe('READY')
@@ -249,20 +249,21 @@ export const launch: LaunchFn = async (
249
249
  // at a not-yet-created dir. Symmetric to the cfg.logDir mkdir before pipe-pane.
250
250
  mkdirSync(dirname(sock), { recursive: true })
251
251
 
252
- // ── SPAWN-FLIP (cutover Block 2, Ф0a) — per-peer `.pty-host` flag, DARK by default ────────────────
253
- // A flag-on tui peer is HOSTED by the supervisor (it owns the pty + boot-driver + pane-log) instead
252
+ // ── PTY-SUPERVISOR HOSTING — the DEFAULT (opt-out per-peer via `.no-pty-host`) ─────────────────────
253
+ // By default a tui peer is HOSTED by the supervisor (it owns the pty + boot-driver + pane-log) instead
254
254
  // of `tmux new-session`. The supervisor serves the SAME launch-composed argv+env (buildLaunchInvocation,
255
255
  // the single source the tmux path also uses), so the hosted session is parity-identical. Readiness is
256
256
  // read off the pane-log model (the supervisor's boot-driver answers startup dialogs internally);
257
257
  // delivery goes over the supervisor socket. UNCONDITIONAL SPAWN-FALLBACK: any host-bringup failure
258
- // falls through to the tmux path below — a flipped peer is NEVER worse than tmux ON SPAWN. Flag-off →
259
- // this whole block is skipped and the tmux path is byte-identical (golden). Routers (telegram/
260
- // notifier) are ALSO hostable flag-on (cutover infra-track): launchd STILL owns their lifecycle via
261
- // run-infra (H4) — the supervisor only provides the pty + delivery socket + pane-log; the router
262
- // branch below skips every TUI phase (no boot dialogs — BOOT_ADAPTERS has no router entry so the
263
- // supervisor's boot-driver is skipped; no transcript ready-gate — newestActivityMtime=null). @xterm
264
- // stays out of THIS process (ptyHost: @xterm-free start + deliver + sessionAlive; readiness's @xterm
265
- // import is dynamic and only loads here, flag-on — and the router branch never reaches it).
258
+ // falls through to the tmux path below — a hosted peer is NEVER worse than tmux ON SPAWN. Opt-OUT
259
+ // (`.no-pty-host` marker) → this whole block is skipped and the tmux path is byte-identical (golden);
260
+ // it is the dev/debug escape valid only while tmux still exists. Routers (telegram/notifier) are ALSO
261
+ // hosted by default (cutover infra-track): launchd STILL owns their lifecycle via run-infra (H4) — the
262
+ // supervisor only provides the pty + delivery socket + pane-log; the router branch below skips every
263
+ // TUI phase (no boot dialogs — BOOT_ADAPTERS has no router entry so the supervisor's boot-driver is
264
+ // skipped; no transcript ready-gate — newestActivityMtime=null). @xterm stays out of THIS process
265
+ // (ptyHost: @xterm-free start + deliver + sessionAlive; readiness's @xterm import is dynamic and only
266
+ // loads here, default-on — and the router branch never reaches it).
266
267
  if ((adapter.kind === 'tui' || adapter.kind === 'router') && ptyHostEnabled(cfg.logDir, identity)) {
267
268
  const invocation = buildLaunchInvocation(spec, adapter, cfg)
268
269
  mkdirSync(cfg.logDir, { recursive: true, mode: 0o700 }) // the supervisor writes the pane-log here
@@ -5,27 +5,32 @@ import { join } from 'node:path'
5
5
  import { ptyHostEnabled } from './ptyHost.ts'
6
6
  import { readyGatePtyFlipEnabled } from './readyGateModel.ts'
7
7
 
8
- describe('ptyHostEnabled — the spawn-flip per-peer flag', () => {
9
- test('off by default; on when the .pty-host marker exists', () => {
8
+ describe('ptyHostEnabled — pty-hosting is the DEFAULT, .no-pty-host opts out', () => {
9
+ test('ON by default (no marker needed); OFF when the .no-pty-host opt-out marker exists; per-peer', () => {
10
10
  const dir = mkdtempSync(join(tmpdir(), 'ptyhost-flag-'))
11
11
  try {
12
+ expect(ptyHostEnabled(dir, 'codex-x')).toBe(true) // DEFAULT: hosted out of the box, no marker
13
+ writeFileSync(join(dir, 'codex-x.no-pty-host'), '') // explicit opt-OUT → tmux
12
14
  expect(ptyHostEnabled(dir, 'codex-x')).toBe(false)
13
- writeFileSync(join(dir, 'codex-x.pty-host'), '')
14
- expect(ptyHostEnabled(dir, 'codex-x')).toBe(true)
15
- expect(ptyHostEnabled(dir, 'codex-y')).toBe(false) // per-peer, not global
15
+ expect(ptyHostEnabled(dir, 'codex-y')).toBe(true) // opt-out is per-peer, not global — y stays default-on
16
16
  } finally {
17
17
  rmSync(dir, { recursive: true, force: true })
18
18
  }
19
19
  })
20
20
 
21
- test('DISTINCT from the ready-gate flip marker — a readygate-flipped peer is NOT pty-hosted', () => {
21
+ test('legacy .pty-host opt-in marker is REDUNDANT (ignored); .pty-readygate is orthogonal; only .no-pty-host flips to tmux', () => {
22
22
  const dir = mkdtempSync(join(tmpdir(), 'ptyhost-distinct-'))
23
23
  try {
24
- writeFileSync(join(dir, 'codex-perplex.pty-readygate'), '') // ready-gate flipped, still tmux-hosted
25
- expect(readyGatePtyFlipEnabled(dir, 'codex-perplex')).toBe(true)
26
- expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(false) // the .pty-host marker is separate
24
+ // legacy opt-in marker no longer decides anything — pty is the default with or without it
27
25
  writeFileSync(join(dir, 'codex-perplex.pty-host'), '')
28
- expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(true) // now supervisor-hosted too
26
+ expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(true)
27
+ // the ready-gate viewport-source marker is a SEPARATE concern — it does not decide pty-hosting
28
+ writeFileSync(join(dir, 'codex-perplex.pty-readygate'), '')
29
+ expect(readyGatePtyFlipEnabled(dir, 'codex-perplex')).toBe(true)
30
+ expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(true) // still hosted (default)
31
+ // only the explicit opt-out marker flips it back to tmux
32
+ writeFileSync(join(dir, 'codex-perplex.no-pty-host'), '')
33
+ expect(ptyHostEnabled(dir, 'codex-perplex')).toBe(false)
29
34
  } finally {
30
35
  rmSync(dir, { recursive: true, force: true })
31
36
  }
@@ -26,12 +26,17 @@ import type { RuntimeAdapter } from './types.ts'
26
26
  export const HOST_COLS = 220
27
27
  export const HOST_ROWS = 50
28
28
 
29
- /** Per-peer spawn-flip flag: marker `<logDir>/<identity>.pty-host`. Present → launch hosts the session
30
- * under the supervisor instead of `tmux new-session`. DISTINCT from `.pty-readygate` (the ready-gate
31
- * viewport-source flip): a readygate-flipped peer is still tmux-HOSTED and stays under observer
32
- * cross-check; only a `.pty-host` peer is supervisor-hosted (cross-check retires for it). */
29
+ /** Per-peer pty-hosting decision. pty-supervisor hosting is the DEFAULT — a fresh peer is hosted under
30
+ * the supervisor (not `tmux new-session`) WITHOUT any marker (Артур: «pty из коробки»). A peer opts OUT
31
+ * to the legacy tmux path with a `<logDir>/<identity>.no-pty-host` marker (the dev/debug escape, valid
32
+ * only while tmux still exists). Read in TWO places, both of which want "is this peer pty-hosted": the
33
+ * spawn-gate (launch — host vs tmux) and the shadow observer (skip pty-hosted peers, they have no tmux
34
+ * to compare; an opt-OUT peer is tmux → compared). Legacy `.pty-host` opt-in markers are now REDUNDANT
35
+ * (pty is the default) and harmless — ignored here, so the 29 migrated peers keep hosting unchanged.
36
+ * The golden spawn-fallback (startPtyHost fail → tmux, in launch) is SEPARATE and unaffected: a
37
+ * pty-default peer whose host bring-up fails still falls through to tmux — never worse on spawn. */
33
38
  export function ptyHostEnabled(logDir: string, identity: string): boolean {
34
- return existsSync(`${logDir}/${identity}.pty-host`)
39
+ return !existsSync(`${logDir}/${identity}.no-pty-host`)
35
40
  }
36
41
 
37
42
  /** The supervisor's run dir (session sockets/pids/serve-specs). Env-isolatable via IAPEER_ROOT. */
@@ -4,7 +4,7 @@
4
4
  // not-yet-created dir (prod sock=/tmp always exists; this bit a sandbox pilot).
5
5
 
6
6
  import { afterEach, describe, expect, test } from 'bun:test'
7
- import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
7
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
8
8
  import { tmpdir } from 'os'
9
9
  import { dirname, join } from 'path'
10
10
  import { spawnSync } from 'child_process'
@@ -51,6 +51,11 @@ describe('launch creates a missing socket dir', () => {
51
51
  alwaysOn: true,
52
52
  }
53
53
 
54
+ // pty-supervisor hosting is the DEFAULT; this regression tests the legacy TMUX new-session sock-dir
55
+ // mkdir, so opt this peer OUT of pty with a .no-pty-host marker (else it is pty-hosted and the tmux
56
+ // mkdir never runs).
57
+ mkdirSync(cfg.logDir, { recursive: true })
58
+ writeFileSync(join(cfg.logDir, `${spec.identity}.no-pty-host`), '')
54
59
  try {
55
60
  // The regression assertion: before the fix the dir was NOT created and
56
61
  // `tmux new-session -S <sock>` failed silently; now launch mkdir's it. (We assert
@@ -1,10 +1,11 @@
1
1
  // Spawn-flip cutover Block 2, Ф0b-3 slice 3b — the fidelity observer SKIPS supervisor-hosted peers.
2
2
  //
3
- // The observer compares the pty-model vs LIVE TMUX. A `.pty-host`-designated peer has no tmux to
4
- // compare against, and during a flip a lingering tmux socket for it would surface FALSE divergences.
5
- // This proves the explicit skip fires IN enumerate even in that race (a live tmux session + a pane-log
6
- // present): the marked peer is NOT enrolled, while an unmarked sibling IS. Real-tmux, guarded (a clean
7
- // CI runner has no tmux; the release machine has it — same pattern as routesend-wake).
3
+ // The observer compares the pty-model vs LIVE TMUX. pty-hosting is now the DEFAULT, so a normal
4
+ // (unmarked) peer has no tmux to compare against — and during a flip a lingering tmux socket for it
5
+ // would surface FALSE divergences. This proves the skip fires IN enumerate even in that race (a live
6
+ // tmux session + a pane-log present): the default peer is NOT enrolled, while a `.no-pty-host` opt-out
7
+ // (tmux) sibling IS. Real-tmux, guarded (a clean CI runner has no tmux; the release machine has it —
8
+ // same pattern as routesend-wake).
8
9
  import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
9
10
  import { spawnSync } from 'node:child_process'
10
11
  import { closeSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
@@ -17,8 +18,8 @@ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status ===
17
18
 
18
19
  let sockDir: string
19
20
  let logDir: string
20
- const KEEP = 'keepobs' // unmarked → observed
21
- const SKIP = 'skipobs' // .pty-host marker → skipped
21
+ const KEEP = 'keepobs' // .no-pty-host opt-out (tmux) → observed
22
+ const SKIP = 'skipobs' // default (no marker) → pty-hosted → skipped
22
23
  const addrOf = (p: string) => `codex-${p}`
23
24
  const sockOf = (p: string) => buildSocketPath('codex', p, sockDir)
24
25
 
@@ -43,9 +44,10 @@ beforeAll(() => {
43
44
  startTmux(p)
44
45
  writeFileSync(join(logDir, `${addrOf(p)}.log`), '')
45
46
  }
46
- // SKIP additionally carries the `.pty-host` designation — even WITH a live tmux session, the observer
47
- // must not enroll it (the flip-transition race the skip guards against).
48
- writeFileSync(join(logDir, `${addrOf(SKIP)}.pty-host`), '')
47
+ // KEEP carries the `.no-pty-host` opt-OUT → it is on tmux, so the observer MUST compare it. SKIP is a
48
+ // DEFAULT (unmarked) peer → pty-hosted → skipped even WITH a lingering live tmux session (the
49
+ // flip-transition race the skip guards against).
50
+ writeFileSync(join(logDir, `${addrOf(KEEP)}.no-pty-host`), '')
49
51
  })
50
52
 
51
53
  afterAll(() => {
@@ -54,12 +56,12 @@ afterAll(() => {
54
56
  rmSync(logDir, { recursive: true, force: true })
55
57
  })
56
58
 
57
- describe.if(tmuxAvailable)('observer enumerate skips supervisor-hosted (.pty-host) peers', () => {
58
- test('a .pty-host peer is NOT enrolled even with a live tmux session + pane-log; an unmarked one IS', () => {
59
+ describe.if(tmuxAvailable)('observer enumerate skips pty-default peers, compares .no-pty-host (tmux) peers', () => {
60
+ test('a default (unmarked, pty-hosted) peer is NOT enrolled despite a live tmux session; a .no-pty-host opt-out one IS', () => {
59
61
  const models = new Map<string, any>()
60
62
  enumerate(models, { sockDir, logDir, eventLogDir: logDir })
61
- expect(models.has(addrOf(KEEP))).toBe(true) // unmarked → observed (live tmux + log present)
62
- expect(models.has(addrOf(SKIP))).toBe(false) // .pty-host designation → skipped despite live tmux
63
+ expect(models.has(addrOf(KEEP))).toBe(true) // .no-pty-host opt-out (tmux) → observed (live tmux + log)
64
+ expect(models.has(addrOf(SKIP))).toBe(false) // default pty-hosted → skipped despite lingering live tmux
63
65
  for (const o of models.values()) { try { if (o.fd != null) closeSync(o.fd) } catch { /* */ } }
64
66
  })
65
67
  })
@@ -61,9 +61,11 @@ export function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker
61
61
  for (const p of peers) {
62
62
  const addr = buildProcessAddress(p.runtime, p.personality)
63
63
  // SPAWN-FLIP Ф0b-3: NEVER measure a supervisor-HOSTED peer. The observer's premise is pty-model vs
64
- // LIVE TMUX; a `.pty-host`-designated peer has no tmux to compare against, and during a flip a
65
- // lingering tmux socket for it would surface false divergences. Skip by the migration DESIGNATION
66
- // (marker), BEFORE `live` — a peer flipped while enrolled then falls out of `live` and is reaped.
64
+ // LIVE TMUX, so it only compares peers that are actually on tmux. pty-hosting is now the DEFAULT, so
65
+ // ptyHostEnabled is true for a normal peer (hosted → skip) and false only for an explicit `.no-pty-host`
66
+ // opt-OUT peer (tmux → compared). Skip BEFORE `live` — a peer that flips falls out of `live` and is
67
+ // reaped. (Post-migration the whole fleet is pty-default, so this skips everything and the observer is
68
+ // effectively retired until/unless a peer opts back to tmux.)
67
69
  if (ptyHostEnabled(opts.logDir, addr)) continue
68
70
  live.add(addr)
69
71
  if (models.has(addr)) continue
@@ -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
+ })