@agfpd/iapeer 0.4.38 → 0.4.39
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/package.json +1 -1
- package/src/supervisor/daemon.ts +66 -17
- package/src/supervisor/protocol.ts +45 -0
package/package.json
CHANGED
package/src/supervisor/daemon.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
frame,
|
|
23
23
|
makeFramer,
|
|
24
24
|
parseSize,
|
|
25
|
+
splitDanglingEscape,
|
|
25
26
|
stripQueries,
|
|
26
27
|
} from './protocol.ts'
|
|
27
28
|
import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
|
|
@@ -86,6 +87,10 @@ const BOOT_DRIVE_MS = 120_000
|
|
|
86
87
|
// survives the hold (our key did not take), the next tick re-fires, so retries stay bounded, not lost.
|
|
87
88
|
const NAG_POLL_MS = 2000
|
|
88
89
|
const NAG_COOLDOWN_MS = 4000
|
|
90
|
+
// В25 — cap the attach-snapshot stabilization wait: under saturating output the model never goes quiet,
|
|
91
|
+
// so without a budget firstAttach would never complete (frozen attach). On timeout we snapshot the
|
|
92
|
+
// current model; live-forward delivers the rest once the gate lifts.
|
|
93
|
+
const MODEL_STABLE_BUDGET_MS = 500
|
|
89
94
|
|
|
90
95
|
// Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
|
|
91
96
|
interface PtyChild {
|
|
@@ -187,7 +192,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
187
192
|
// (same inode) so a normal cap never triggers a spurious reopen. Tied to output (no idle wakeups) —
|
|
188
193
|
// exactly when the log matters.
|
|
189
194
|
const healPaneLog = (): void => {
|
|
190
|
-
if (!opts.paneLogPath
|
|
195
|
+
if (!opts.paneLogPath) return
|
|
191
196
|
const now = Date.now()
|
|
192
197
|
if (now - paneLogCheckedMs < PANELOG_HEAL_MS) return
|
|
193
198
|
paneLogCheckedMs = now
|
|
@@ -197,7 +202,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
197
202
|
} catch {
|
|
198
203
|
ino = -1 // path gone
|
|
199
204
|
}
|
|
200
|
-
|
|
205
|
+
// В19 — reopen when the fd is DEAD (initial open failed / a prior reopen failed) as well as when the
|
|
206
|
+
// path maps to a DIFFERENT inode (unlinked/replaced). The old `paneLogFd < 0` early-return above made
|
|
207
|
+
// a single failed open PERMANENT — the log stayed blind for the whole session (composer-busy detect,
|
|
208
|
+
// telegram typing, boot ready-gate all read this file). fd<0 with ino=-1 (path also gone) still needs
|
|
209
|
+
// a reopen attempt: openPaneLog recreates the file.
|
|
210
|
+
if (paneLogFd < 0 || ino !== paneLogIno) openPaneLog()
|
|
201
211
|
}
|
|
202
212
|
openPaneLog()
|
|
203
213
|
|
|
@@ -210,7 +220,11 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
210
220
|
// NO E3 capability) → smeared/overlapping scroll-up history. After the catch-up the supervisor does
|
|
211
221
|
// NOTHING on resize — the child redraws its viewport (SIGWINCH) and the terminal reflows its own
|
|
212
222
|
// scrollback, like a directly-launched claude.
|
|
213
|
-
|
|
223
|
+
// В23 — a SET (not a WeakSet) so `.size` gates the .attached marker on INTERACTIVE clients only. A
|
|
224
|
+
// client is painted iff it sent a FRAME_RESIZE (runClient always does on connect; a deliver-client
|
|
225
|
+
// NEVER resizes). Using clients.size counted deliver-clients too → a delivery briefly marked "human
|
|
226
|
+
// attached", falsely holding a concurrent delivery in the composer queue. Kept in sync with removals.
|
|
227
|
+
const painted = new Set<object>()
|
|
214
228
|
// INITIALIZING GATE (scrollback fix): a client is held OUT of live output-forwarding from connect until
|
|
215
229
|
// its one-time attach snapshot has been sent. Without this, live frames (claude's relative-cursor
|
|
216
230
|
// redraws) race ahead of the snapshot and corrupt the fresh terminal. The model still ingests those
|
|
@@ -223,6 +237,10 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
223
237
|
// write's parse-callback; `writeSeq` bumps on every write so the drain can detect new arrivals mid-await.
|
|
224
238
|
let writeChain: Promise<void> = Promise.resolve()
|
|
225
239
|
let writeSeq = 0
|
|
240
|
+
// В24 — a trailing INCOMPLETE escape sequence carried from one child-output chunk to the next, so a
|
|
241
|
+
// capability query split across a pty read boundary is detected/stripped as a whole (client-facing only;
|
|
242
|
+
// the model + pane-log always get the raw bytes immediately).
|
|
243
|
+
let queryTail = ''
|
|
226
244
|
// ATTACHED-CLIENT MARKER (spawn-flip Ф0b-3 slice 3c): keep `<runDir>/<session>.attached` in sync with
|
|
227
245
|
// clients.size — present iff ≥1 operator is attached. The warm-deliver path (a DIFFERENT process)
|
|
228
246
|
// reads it to gate the hosted busy-composer hold: hold ONLY when a human is attached (their unfinished
|
|
@@ -231,7 +249,9 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
231
249
|
const attachedMarker = attachedPath(runDir, session)
|
|
232
250
|
const syncAttached = (): void => {
|
|
233
251
|
try {
|
|
234
|
-
|
|
252
|
+
// В23 — gate on PAINTED (interactive) clients, not raw client count: a deliver-client connection
|
|
253
|
+
// must never read as "a human is attached".
|
|
254
|
+
if (painted.size > 0) writeFileSync(attachedMarker, '')
|
|
235
255
|
else if (existsSync(attachedMarker)) unlinkSync(attachedMarker)
|
|
236
256
|
} catch {
|
|
237
257
|
/* */
|
|
@@ -253,7 +273,11 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
253
273
|
/** Backpressure-safe send to one client; drop it (and report false) if its socket is dead. */
|
|
254
274
|
const sendClient = (s: { write(b: Buffer): number }, buf: Buffer): boolean => {
|
|
255
275
|
const ok = writerFor(s).send(buf)
|
|
256
|
-
if (!ok)
|
|
276
|
+
if (!ok) {
|
|
277
|
+
clients.delete(s)
|
|
278
|
+
painted.delete(s as object) // В23 — keep the painted set (marker gate) in sync on every drop
|
|
279
|
+
syncAttached()
|
|
280
|
+
}
|
|
257
281
|
return ok
|
|
258
282
|
}
|
|
259
283
|
|
|
@@ -269,19 +293,26 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
269
293
|
const buf = Buffer.isBuffer(d) ? d : typeof d === 'string' ? Buffer.from(d, 'utf8') : Buffer.from(d)
|
|
270
294
|
// PANE-LOG: the RAW child bytes, exactly as pipe-pane writes them (before any query-stripping
|
|
271
295
|
// for clients) — written as a Buffer, never a string. The `s` below is for query DETECTION only.
|
|
272
|
-
if (
|
|
273
|
-
healPaneLog() // reopen if
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
296
|
+
if (opts.paneLogPath) {
|
|
297
|
+
healPaneLog() // reopen if never-opened / unlinked / replaced out from under us (Defect 2 + В19)
|
|
298
|
+
if (paneLogFd >= 0) {
|
|
299
|
+
try {
|
|
300
|
+
writeSync(paneLogFd, buf)
|
|
301
|
+
} catch {
|
|
302
|
+
/* best-effort */
|
|
303
|
+
}
|
|
278
304
|
}
|
|
279
305
|
}
|
|
280
|
-
const s = buf.toString('latin1')
|
|
281
306
|
writeSeq++ // a new write arrived (lets the attach drain detect bytes that land mid-await)
|
|
282
|
-
writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier)
|
|
283
|
-
|
|
284
|
-
|
|
307
|
+
writeChain = new Promise<void>(resolve => xterm.write(buf, () => resolve())) // authoritative model (+ parse barrier) — RAW bytes
|
|
308
|
+
// В24 — run capability detect/strip over the chunk PLUS any carried tail, holding back a trailing
|
|
309
|
+
// INCOMPLETE escape for the next chunk so a query split across a pty read boundary is matched as a
|
|
310
|
+
// whole. The model + pane-log above get the RAW bytes immediately; only the client-facing detect/
|
|
311
|
+
// strip is tail-buffered (the held bytes reach the client next chunk, once complete).
|
|
312
|
+
const [complete, dangling] = splitDanglingEscape(queryTail + buf.toString('latin1'))
|
|
313
|
+
queryTail = dangling
|
|
314
|
+
for (const r of capabilityResponses(complete)) child.terminal.write(r) // answer capability queries
|
|
315
|
+
const fwd = Buffer.from(stripQueries(complete), 'latin1') // forward to clients sans queries (daemon is authoritative)
|
|
285
316
|
if (fwd.length) {
|
|
286
317
|
// Forward live output to every ATTACHED client — but NOT to one still INITIALIZING (its
|
|
287
318
|
// snapshot hasn't been sent; these bytes are already in the model and will be in that snapshot,
|
|
@@ -337,6 +368,12 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
337
368
|
// bytes still land in the model and bump writeSeq — loop until the sequence holds steady so the snapshot
|
|
338
369
|
// reflects every byte received so far (no stale screen, no dropped tail).
|
|
339
370
|
const awaitModelParsedStable = async (): Promise<void> => {
|
|
371
|
+
// В25 — BOUND the stabilization. Under a saturating output stream (a big file dump, a verbose build,
|
|
372
|
+
// a tailing log) every await window sees a new write → seq never holds steady → firstAttach never
|
|
373
|
+
// completes, so the attaching operator sees a FROZEN blank screen while their keystrokes go to the
|
|
374
|
+
// child blind. Cap the wait: on timeout, snapshot the CURRENT model — bytes that land after are
|
|
375
|
+
// delivered by normal live-forward once the gate lifts (the model already ingests them).
|
|
376
|
+
const deadline = Date.now() + MODEL_STABLE_BUDGET_MS
|
|
340
377
|
for (;;) {
|
|
341
378
|
const seq = writeSeq
|
|
342
379
|
try {
|
|
@@ -345,6 +382,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
345
382
|
/* a write parse-callback should not reject */
|
|
346
383
|
}
|
|
347
384
|
if (seq === writeSeq) return
|
|
385
|
+
if (Date.now() > deadline) return // saturating output — snapshot now, live-forward catches the rest
|
|
348
386
|
}
|
|
349
387
|
}
|
|
350
388
|
// Build + send the one-time attach SNAPSHOT (full resolved model: scrollback + screen + cursor + SGR colour
|
|
@@ -428,6 +466,7 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
428
466
|
const sz = parseSize(payload)
|
|
429
467
|
if (!painted.has(s as object)) {
|
|
430
468
|
painted.add(s as object)
|
|
469
|
+
syncAttached() // В23 — this client is now INTERACTIVE (it resized) → the .attached marker reflects it
|
|
431
470
|
void firstAttach(s, sz.cols, sz.rows) // split-resize: model → snapshot → gate-lift → child (clean frame0)
|
|
432
471
|
} else {
|
|
433
472
|
applyGeometry(sz.cols, sz.rows) // LATER resize: one real resize, no re-snapshot
|
|
@@ -458,16 +497,18 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
458
497
|
framers.get(s)?.(d)
|
|
459
498
|
},
|
|
460
499
|
drain(s: object) {
|
|
461
|
-
if (!writers.get(s)?.flush()) { clients.delete(s); syncAttached() } // writable again → flush queued tail
|
|
500
|
+
if (!writers.get(s)?.flush()) { clients.delete(s); painted.delete(s); syncAttached() } // writable again → flush queued tail
|
|
462
501
|
},
|
|
463
502
|
close(s: object) {
|
|
464
503
|
clients.delete(s)
|
|
504
|
+
painted.delete(s)
|
|
465
505
|
writers.delete(s)
|
|
466
506
|
syncAttached()
|
|
467
507
|
restoreServeGeometryIfIdle() // last client gone → un-stick the pty from its size
|
|
468
508
|
},
|
|
469
509
|
error(s: object) {
|
|
470
510
|
clients.delete(s)
|
|
511
|
+
painted.delete(s)
|
|
471
512
|
writers.delete(s)
|
|
472
513
|
syncAttached()
|
|
473
514
|
restoreServeGeometryIfIdle()
|
|
@@ -542,8 +583,16 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
|
|
|
542
583
|
} catch {
|
|
543
584
|
return // transient model read — retry next tick
|
|
544
585
|
}
|
|
586
|
+
// В21 — check the deadline FIRST, independent of the action. Previously the clearInterval only
|
|
587
|
+
// fired in the `else` branch, so a boot-dialog predicate that kept returning 'dialog' (a resume-
|
|
588
|
+
// picker whose layout changed after a claude update, a screen matching the dialog pattern) drove
|
|
589
|
+
// keystrokes into the pty EVERY tick for the whole session — the BOOT_DRIVE_MS backstop was dead.
|
|
590
|
+
if (Date.now() > driveDeadline) {
|
|
591
|
+
clearInterval(driver)
|
|
592
|
+
return
|
|
593
|
+
}
|
|
545
594
|
if (action.kind === 'dialog') child.terminal.write(action.bytes)
|
|
546
|
-
else if (action.kind === 'ready'
|
|
595
|
+
else if (action.kind === 'ready') clearInterval(driver)
|
|
547
596
|
}, BOOT_POLL_MS)
|
|
548
597
|
;(driver as { unref?: () => void }).unref?.()
|
|
549
598
|
}
|
|
@@ -70,6 +70,51 @@ export function stripQueries(s: string): string {
|
|
|
70
70
|
return s.replace(QUERY_RE, '')
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
// ── chunk-boundary query buffering (В24) ────────────────────────────────────────
|
|
74
|
+
// A capability query split across a pty read boundary (`\x1b[>` in one chunk, `q` in the next)
|
|
75
|
+
// matches NEITHER chunk → the daemon neither answers nor strips it, and the fragment LEAKS to an
|
|
76
|
+
// attached client whose real terminal answers it as keystrokes into the child. Before running the
|
|
77
|
+
// capability detect/strip on a chunk, carry a trailing INCOMPLETE escape into the next one.
|
|
78
|
+
|
|
79
|
+
const MAX_DANGLING = 16 // a real short query fits well within this — a longer trailing ESC is passed through
|
|
80
|
+
|
|
81
|
+
function isCompleteEscape(seq: string): boolean {
|
|
82
|
+
if (seq.length < 2) return false // a bare ESC — could begin any sequence
|
|
83
|
+
switch (seq[1]) {
|
|
84
|
+
case '[': {
|
|
85
|
+
// CSI — parameter/intermediate bytes then a FINAL byte 0x40–0x7e
|
|
86
|
+
for (let i = 2; i < seq.length; i++) {
|
|
87
|
+
const c = seq.charCodeAt(i)
|
|
88
|
+
if (c >= 0x40 && c <= 0x7e) return true
|
|
89
|
+
}
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
case ']':
|
|
93
|
+
case 'P':
|
|
94
|
+
case 'X':
|
|
95
|
+
case '^':
|
|
96
|
+
case '_':
|
|
97
|
+
// OSC / DCS / SOS / PM / APC — terminated by BEL or ST (\x1b\)
|
|
98
|
+
return seq.includes('\x07') || seq.includes('\x1b\\')
|
|
99
|
+
case 'O':
|
|
100
|
+
return seq.length >= 3 // SS3 (ESC O <byte>)
|
|
101
|
+
default:
|
|
102
|
+
return true // any other 2-byte escape (ESC + one final char) is complete once the 2nd byte arrives
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Split `s` into [complete, dangling]: `dangling` is a trailing incomplete escape sequence to prepend
|
|
107
|
+
* to the NEXT chunk before capability detect/strip. Bounded — a lone trailing ESC with no follow-up
|
|
108
|
+
* never stalls the forward forever (it is passed through once it exceeds MAX_DANGLING). Pure/testable. */
|
|
109
|
+
export function splitDanglingEscape(s: string): [string, string] {
|
|
110
|
+
const esc = s.lastIndexOf('\x1b')
|
|
111
|
+
if (esc < 0) return [s, '']
|
|
112
|
+
const tail = s.slice(esc)
|
|
113
|
+
if (tail.length > MAX_DANGLING) return [s, '']
|
|
114
|
+
if (isCompleteEscape(tail)) return [s, '']
|
|
115
|
+
return [s.slice(0, esc), tail]
|
|
116
|
+
}
|
|
117
|
+
|
|
73
118
|
// ── key-name → pty bytes (boot-driver, serving slice a) ─────────────────────────
|
|
74
119
|
// Translate a tmux send-keys-style key list into the raw bytes a pty expects, so the supervisor
|
|
75
120
|
// boot-driver can answer startup dialogs (codex trust/update/hooks, claude trust/resume-picker) off
|