@agfpd/iapeer 0.2.73 → 0.2.75
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/supervisor/daemon.ts +24 -1
- package/src/supervisor/spinner-strip.test.ts +52 -0
- package/src/transport/compactDone.test.ts +85 -1
- package/src/transport/index.ts +85 -14
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
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
|
+
})
|
|
@@ -2,7 +2,14 @@ import { describe, expect, test } from 'bun:test'
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, appendFileSync } from 'fs'
|
|
3
3
|
import { tmpdir } from 'os'
|
|
4
4
|
import { join } from 'path'
|
|
5
|
-
import { compactDoneBaseline, compactTranscriptHasDone } from './index.ts'
|
|
5
|
+
import { compactDoneBaseline, compactTranscriptHasDone, waitForCompactDone, type DeliveryTarget } from './index.ts'
|
|
6
|
+
|
|
7
|
+
function claudeTarget(): DeliveryTarget {
|
|
8
|
+
return { runtime: 'claude', personality: 'tester', address: 'claude-tester', socketPath: '/tmp/iapeer-test-noop.sock' }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const CLAUDE_IDLE_PANE = '❯ \n bypass permissions on'
|
|
12
|
+
const CLAUDE_BUSY_PANE = '· Compacting conversation… (esc to interrupt)\n reloading context'
|
|
6
13
|
|
|
7
14
|
function withHome(fn: (home: string, env: NodeJS.ProcessEnv) => void): void {
|
|
8
15
|
const home = mkdtempSync(join(tmpdir(), 'iapeer-compact-gate-home-'))
|
|
@@ -58,3 +65,80 @@ describe('compact done transcript gate', () => {
|
|
|
58
65
|
}
|
|
59
66
|
}))
|
|
60
67
|
})
|
|
68
|
+
|
|
69
|
+
describe('waitForCompactDone — marker is authoritative, idle is a fast-path', () => {
|
|
70
|
+
function withClaudeMarker(
|
|
71
|
+
fn: (args: { cwd: string; env: NodeJS.ProcessEnv; writeMarker: () => void; baseline: ReturnType<typeof compactDoneBaseline> }) => void,
|
|
72
|
+
): void {
|
|
73
|
+
withHome((home, env) => {
|
|
74
|
+
const cwd = mkdtempSync(join(tmpdir(), 'iapeer-compact-wait-cwd-'))
|
|
75
|
+
try {
|
|
76
|
+
const dir = join(home, '.claude', 'projects', claudeSlug(cwd))
|
|
77
|
+
mkdirSync(dir, { recursive: true })
|
|
78
|
+
const file = join(dir, 'session.jsonl')
|
|
79
|
+
writeFileSync(file, JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } }) + '\n')
|
|
80
|
+
const baseline = compactDoneBaseline('claude', cwd, { env })
|
|
81
|
+
const writeMarker = () =>
|
|
82
|
+
appendFileSync(file, JSON.stringify({ type: 'system', subtype: 'compact_boundary', compactMetadata: { durationMs: 140708, preTokens: 535706, postTokens: 12524 } }) + '\n')
|
|
83
|
+
fn({ cwd, env, writeMarker, baseline })
|
|
84
|
+
} finally {
|
|
85
|
+
rmSync(cwd, { recursive: true, force: true })
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// THE FIX: a large-context session that auto-continues into a working turn after compact
|
|
91
|
+
// keeps the composer busy (never idle). The marker proves completion → SUCCESS, not failure.
|
|
92
|
+
test('marker seen + composer never idle → success on the marker (signal: transcript)', () => withClaudeMarker(({ cwd, env, writeMarker, baseline }) => {
|
|
93
|
+
writeMarker()
|
|
94
|
+
const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
|
|
95
|
+
env,
|
|
96
|
+
timeoutMs: 5000,
|
|
97
|
+
pollMs: 5,
|
|
98
|
+
graceMs: 20,
|
|
99
|
+
seam: { sessionAlive: () => true, capturePane: () => CLAUDE_BUSY_PANE },
|
|
100
|
+
})
|
|
101
|
+
expect(r.ok).toBe(true)
|
|
102
|
+
if (r.ok) expect(r.value.signal).toBe('transcript')
|
|
103
|
+
}))
|
|
104
|
+
|
|
105
|
+
// Clean common case unchanged: marker + idle composer → the historical 'transcript+ready'.
|
|
106
|
+
test('marker seen + composer idle → fast-path success (signal: transcript+ready)', () => withClaudeMarker(({ cwd, env, writeMarker, baseline }) => {
|
|
107
|
+
writeMarker()
|
|
108
|
+
const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
|
|
109
|
+
env,
|
|
110
|
+
timeoutMs: 5000,
|
|
111
|
+
pollMs: 5,
|
|
112
|
+
graceMs: 5000,
|
|
113
|
+
seam: { sessionAlive: () => true, capturePane: () => CLAUDE_IDLE_PANE },
|
|
114
|
+
})
|
|
115
|
+
expect(r.ok).toBe(true)
|
|
116
|
+
if (r.ok) expect(r.value.signal).toBe('transcript+ready')
|
|
117
|
+
}))
|
|
118
|
+
|
|
119
|
+
// BOUNDARY preserved: no structured marker ever → honest failure (hung / never completed).
|
|
120
|
+
test('no marker → honest failure', () => withClaudeMarker(({ cwd, env, baseline }) => {
|
|
121
|
+
const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
|
|
122
|
+
env,
|
|
123
|
+
timeoutMs: 40,
|
|
124
|
+
pollMs: 5,
|
|
125
|
+
graceMs: 20,
|
|
126
|
+
seam: { sessionAlive: () => true, capturePane: () => CLAUDE_BUSY_PANE },
|
|
127
|
+
})
|
|
128
|
+
expect(r.ok).toBe(false)
|
|
129
|
+
if (!r.ok) expect(r.error.message).toContain('no transcript compact marker')
|
|
130
|
+
}))
|
|
131
|
+
|
|
132
|
+
// Session death during the wait still fails (liveness boundary).
|
|
133
|
+
test('session vanished → failure', () => withClaudeMarker(({ cwd, env, baseline }) => {
|
|
134
|
+
const r = waitForCompactDone(claudeTarget(), cwd, baseline, {
|
|
135
|
+
env,
|
|
136
|
+
timeoutMs: 5000,
|
|
137
|
+
pollMs: 5,
|
|
138
|
+
graceMs: 20,
|
|
139
|
+
seam: { sessionAlive: () => false, capturePane: () => CLAUDE_BUSY_PANE },
|
|
140
|
+
})
|
|
141
|
+
expect(r.ok).toBe(false)
|
|
142
|
+
if (!r.ok) expect(r.error.message).toContain('session vanished')
|
|
143
|
+
}))
|
|
144
|
+
})
|
package/src/transport/index.ts
CHANGED
|
@@ -603,7 +603,23 @@ export interface CompactDoneBaseline {
|
|
|
603
603
|
export interface CompactDoneWaitResult {
|
|
604
604
|
ok: true
|
|
605
605
|
ms: number
|
|
606
|
-
|
|
606
|
+
/** How completion was confirmed:
|
|
607
|
+
* 'transcript+ready' — marker seen AND the composer returned to idle (the clean,
|
|
608
|
+
* common case: a small/medium compact that settles back to the prompt).
|
|
609
|
+
* 'transcript' — marker seen but the composer is still BUSY past the ready-grace
|
|
610
|
+
* (a large-context session that auto-continues into a working turn right after
|
|
611
|
+
* compact). The structured marker is the AUTHORITATIVE completion signal, so this
|
|
612
|
+
* is SUCCESS, not a timeout. */
|
|
613
|
+
signal: 'transcript+ready' | 'transcript'
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Test seam for waitForCompactDone — inject the tmux liveness + pane capture so the
|
|
617
|
+
* marker/grace logic is covered hermetically (no live session). Omitting all = prod. */
|
|
618
|
+
export interface CompactDoneWaitSeam {
|
|
619
|
+
/** Is the target session still alive? Default: the real tmux/host sessionAlive. */
|
|
620
|
+
sessionAlive?: (target: DeliveryTarget) => boolean
|
|
621
|
+
/** Capture the target pane; null = capture failed. Default: real tmux capture-pane. */
|
|
622
|
+
capturePane?: (target: DeliveryTarget) => string | null
|
|
607
623
|
}
|
|
608
624
|
|
|
609
625
|
function envHome(env: NodeJS.ProcessEnv | undefined): string {
|
|
@@ -763,41 +779,97 @@ function compactDonePollMs(env: NodeJS.ProcessEnv | undefined): number {
|
|
|
763
779
|
return Number.isFinite(n) && n > 0 ? n : 1000
|
|
764
780
|
}
|
|
765
781
|
|
|
782
|
+
// Once the completion marker is seen, how long to keep PREFERRING the idle-composer
|
|
783
|
+
// confirmation (the clean 'transcript+ready' signal) before reporting success on the
|
|
784
|
+
// marker alone. A large-context session that auto-continues into a working turn never
|
|
785
|
+
// goes idle — this bounds how long we wait for an idle that may never come, so the
|
|
786
|
+
// busy case succeeds promptly instead of stalling to the overall deadline. Short by
|
|
787
|
+
// design; the marker is already authoritative.
|
|
788
|
+
function compactReadyGraceMs(env: NodeJS.ProcessEnv | undefined): number {
|
|
789
|
+
const raw = env?.IAPEER_COMPACT_READY_GRACE_MS
|
|
790
|
+
const n = raw === undefined ? NaN : Number(raw)
|
|
791
|
+
return Number.isFinite(n) && n >= 0 ? n : 5000
|
|
792
|
+
}
|
|
793
|
+
|
|
766
794
|
/**
|
|
767
795
|
* Wait until a slash-/compact command has actually FINISHED, not merely until its
|
|
768
|
-
* keystrokes were accepted. The
|
|
769
|
-
* transcript state written by the runtimes
|
|
770
|
-
*
|
|
771
|
-
*
|
|
796
|
+
* keystrokes were accepted. The AUTHORITATIVE signal is scroll-proof structured
|
|
797
|
+
* transcript state written by the runtimes at the END of the operation (with post-
|
|
798
|
+
* compact token metadata): Codex `context_compacted`, Claude `compact_boundary`.
|
|
799
|
+
* Once that marker is present the compact objectively completed.
|
|
800
|
+
*
|
|
801
|
+
* Idle-composer (`isInputReady`) is treated as a FAST-PATH confirmation, NOT a
|
|
802
|
+
* requirement. A healthy large-context session auto-continues into a working turn
|
|
803
|
+
* right after compact — its composer is legitimately busy (not idle) for a while, so
|
|
804
|
+
* requiring idle here false-failed a finished compact (live incident 15.06, boris:
|
|
805
|
+
* marker at 12:14:58, first post-compact idle only ~232s later, past the ~290s gate).
|
|
806
|
+
* Therefore: marker seen + composer idle → 'transcript+ready' (clean); marker seen +
|
|
807
|
+
* composer still busy past a short grace → 'transcript' (busy-but-done, still SUCCESS).
|
|
808
|
+
*
|
|
809
|
+
* BOUNDARY (preserved): a compact whose structured marker NEVER appears still fails
|
|
810
|
+
* honestly — that is the genuine "hung / never completed" case. Cross-runtime: the
|
|
811
|
+
* marker set + isInputReady are both per-adapter, so claude and codex share this path.
|
|
772
812
|
*/
|
|
773
813
|
export function waitForCompactDone(
|
|
774
814
|
target: DeliveryTarget,
|
|
775
815
|
cwd: string,
|
|
776
816
|
baseline: CompactDoneBaseline,
|
|
777
|
-
opts: {
|
|
817
|
+
opts: {
|
|
818
|
+
env?: NodeJS.ProcessEnv
|
|
819
|
+
timeoutMs?: number
|
|
820
|
+
pollMs?: number
|
|
821
|
+
graceMs?: number
|
|
822
|
+
seam?: CompactDoneWaitSeam
|
|
823
|
+
} = {},
|
|
778
824
|
): Result<CompactDoneWaitResult> {
|
|
779
825
|
const env = opts.env ?? process.env
|
|
780
826
|
const timeoutMs = opts.timeoutMs ?? compactDoneTimeoutMs(env)
|
|
781
827
|
const pollMs = opts.pollMs ?? compactDonePollMs(env)
|
|
828
|
+
const graceMs = opts.graceMs ?? compactReadyGraceMs(env)
|
|
829
|
+
const aliveFn = opts.seam?.sessionAlive ?? ((t: DeliveryTarget) => sessionAlive(t.socketPath, t.address))
|
|
830
|
+
const captureFn =
|
|
831
|
+
opts.seam?.capturePane ??
|
|
832
|
+
((t: DeliveryTarget) => {
|
|
833
|
+
const cap = tmux(t.socketPath, 'capture-pane', '-p', '-t', t.address)
|
|
834
|
+
return cap.ok ? cap.out : null
|
|
835
|
+
})
|
|
782
836
|
const adapter = getAdapter(target.runtime)
|
|
783
837
|
const started = monotonicMs()
|
|
784
838
|
const deadline = started + timeoutMs
|
|
785
839
|
let sawTranscriptDone = false
|
|
840
|
+
let markerSeenAtMs = -1
|
|
786
841
|
let lastPane = ''
|
|
787
842
|
|
|
788
843
|
do {
|
|
789
|
-
if (!
|
|
844
|
+
if (!aliveFn(target)) {
|
|
790
845
|
return err(`compact did not complete: tmux session vanished`)
|
|
791
846
|
}
|
|
792
|
-
sawTranscriptDone
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
847
|
+
if (!sawTranscriptDone && compactTranscriptHasDone(baseline, { env })) {
|
|
848
|
+
sawTranscriptDone = true
|
|
849
|
+
markerSeenAtMs = monotonicMs()
|
|
850
|
+
}
|
|
851
|
+
const pane = captureFn(target)
|
|
852
|
+
if (pane !== null) lastPane = pane
|
|
853
|
+
if (sawTranscriptDone) {
|
|
854
|
+
// Fast clean path: the composer returned to idle (the historical signal).
|
|
855
|
+
if (pane !== null && adapter.isInputReady(pane)) {
|
|
856
|
+
return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript+ready' })
|
|
857
|
+
}
|
|
858
|
+
// Marker is authoritative: a busy composer after a held marker is a healthy
|
|
859
|
+
// auto-continue, not a failure. Succeed on the marker once the grace elapses.
|
|
860
|
+
if (monotonicMs() - markerSeenAtMs >= graceMs) {
|
|
861
|
+
return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript' })
|
|
862
|
+
}
|
|
797
863
|
}
|
|
798
864
|
sleepSync(pollMs)
|
|
799
865
|
} while (monotonicMs() < deadline)
|
|
800
866
|
|
|
867
|
+
// Deadline hit. The marker is authoritative: if it was EVER seen, the compact did
|
|
868
|
+
// finish — a still-busy composer at the deadline is not a failure (succeed on the
|
|
869
|
+
// marker). Only a MISSING marker is an honest failure (hung / never-completed compact).
|
|
870
|
+
if (sawTranscriptDone) {
|
|
871
|
+
return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript' })
|
|
872
|
+
}
|
|
801
873
|
const tail = lastPane
|
|
802
874
|
.split(/\r?\n/)
|
|
803
875
|
.map(line => line.trim())
|
|
@@ -807,8 +879,7 @@ export function waitForCompactDone(
|
|
|
807
879
|
.replace(/"/g, "'")
|
|
808
880
|
.slice(0, 240)
|
|
809
881
|
return err(
|
|
810
|
-
`compact did not complete within ${timeoutMs}ms (` +
|
|
811
|
-
`${sawTranscriptDone ? 'transcript marker seen, input not ready' : 'no transcript compact marker'}` +
|
|
882
|
+
`compact did not complete within ${timeoutMs}ms (no transcript compact marker` +
|
|
812
883
|
`${tail ? `; pane tail: ${tail}` : ''})`,
|
|
813
884
|
)
|
|
814
885
|
}
|