@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 +19 -1
- package/package.json +1 -1
- package/src/launch/bootdeliver.test.ts +6 -1
- package/src/launch/index.ts +11 -10
- package/src/launch/ptyHost.test.ts +15 -10
- package/src/launch/ptyHost.ts +10 -5
- package/src/launch/sockdir.test.ts +6 -1
- package/src/shadow/enumerate-skip.test.ts +16 -14
- package/src/shadow/index.ts +5 -3
- package/src/supervisor/daemon.ts +24 -1
- package/src/supervisor/spinner-strip.test.ts +52 -0
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.
|
|
29
|
+
Версия — **0.2.73**. Платформа — macOS/launchd.
|
package/package.json
CHANGED
|
@@ -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')
|
package/src/launch/index.ts
CHANGED
|
@@ -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
|
-
// ──
|
|
253
|
-
//
|
|
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
|
|
259
|
-
// this whole block is skipped and the tmux path is byte-identical (golden)
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
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
|
|
9
|
-
test('
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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)
|
|
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
|
}
|
package/src/launch/ptyHost.ts
CHANGED
|
@@ -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
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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.
|
|
4
|
-
// compare against
|
|
5
|
-
// This proves the
|
|
6
|
-
// present): the
|
|
7
|
-
// CI runner has no tmux; the release machine has it —
|
|
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' //
|
|
21
|
-
const SKIP = 'skipobs' //
|
|
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
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
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
|
|
58
|
-
test('a
|
|
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) //
|
|
62
|
-
expect(models.has(addrOf(SKIP))).toBe(false) //
|
|
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
|
})
|
package/src/shadow/index.ts
CHANGED
|
@@ -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
|
|
65
|
-
//
|
|
66
|
-
// (
|
|
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
|
package/src/supervisor/daemon.ts
CHANGED
|
@@ -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
|
+
})
|