@agfpd/iapeer 0.4.37 → 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/daemon/index.ts +7 -0
- package/src/daemon/main.ts +5 -0
- package/src/lifecycle/index.ts +33 -1
- package/src/supervisor/daemon.ts +66 -17
- package/src/supervisor/protocol.ts +45 -0
- package/src/transport/index.ts +47 -1
package/package.json
CHANGED
package/src/daemon/index.ts
CHANGED
|
@@ -192,6 +192,8 @@ export interface CallToolDeps {
|
|
|
192
192
|
* see transport.RouteDeps.noteLiveTopic. Injected so the daemon never imports
|
|
193
193
|
* lifecycle; the production main wires makeNoteLiveTopic. */
|
|
194
194
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
195
|
+
/** В7 — confirmed-delivery stamp → the target's idle-proxy floor (see transport.RouteDeps.noteDelivered). */
|
|
196
|
+
noteDelivered?: (identity: string) => void
|
|
195
197
|
/** Busy-human-composer queue — see transport.createComposerDeliveryQueue. */
|
|
196
198
|
composerQueue?: ComposerQueueRouteDeps
|
|
197
199
|
/** H4 launchd-managed detector — see transport.RouteDeps.isLaunchdManaged (the
|
|
@@ -219,6 +221,7 @@ export async function callTool(
|
|
|
219
221
|
wake: deps.wake,
|
|
220
222
|
ephemeral: deps.ephemeral,
|
|
221
223
|
noteLiveTopic: deps.noteLiveTopic,
|
|
224
|
+
noteDelivered: deps.noteDelivered,
|
|
222
225
|
composerQueue: deps.composerQueue,
|
|
223
226
|
isLaunchdManaged: deps.isLaunchdManaged,
|
|
224
227
|
})
|
|
@@ -390,6 +393,9 @@ export interface StartDaemonOptions extends StorageOptions {
|
|
|
390
393
|
* main wires the lifecycle marker write (makeNoteLiveTopic).
|
|
391
394
|
*/
|
|
392
395
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
396
|
+
/** В7 — confirmed-delivery stamp → the target's idle-proxy floor (setLastDelivered). OFF by default;
|
|
397
|
+
* the production main wires it. */
|
|
398
|
+
noteDelivered?: (identity: string) => void
|
|
393
399
|
/**
|
|
394
400
|
* Busy-human-composer queue — async acceptance for live local TUI delivery when
|
|
395
401
|
* an attached operator has non-dim text in the target composer. OFF by default
|
|
@@ -433,6 +439,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
|
|
|
433
439
|
onDelivered: opts.onDelivered,
|
|
434
440
|
ephemeral: opts.ephemeral,
|
|
435
441
|
noteLiveTopic: opts.noteLiveTopic,
|
|
442
|
+
noteDelivered: opts.noteDelivered,
|
|
436
443
|
composerQueue: opts.composerQueue,
|
|
437
444
|
isLaunchdManaged: opts.isLaunchdManaged,
|
|
438
445
|
})
|
package/src/daemon/main.ts
CHANGED
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
readTopic,
|
|
43
43
|
resolveWakeRuntime,
|
|
44
44
|
setEphemeralArmed,
|
|
45
|
+
setLastDelivered,
|
|
45
46
|
superviseTick,
|
|
46
47
|
wakeOrSpawn,
|
|
47
48
|
type LifecycleConfig,
|
|
@@ -222,6 +223,7 @@ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.Pro
|
|
|
222
223
|
// flip + the shadow observer. tmux targets ignore it.
|
|
223
224
|
logDir: cfg.logDir,
|
|
224
225
|
noteLiveTopic,
|
|
226
|
+
noteDelivered: identity => setLastDelivered(cfg, identity),
|
|
225
227
|
onDelivered,
|
|
226
228
|
notifyFailed: async (job, reason) => {
|
|
227
229
|
const index = readPeersIndex({ env })
|
|
@@ -330,6 +332,9 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
|
|
|
330
332
|
// the wake resolver's executor discriminator then compares against the topic
|
|
331
333
|
// the session last WORKED ON, not the one it woke with.
|
|
332
334
|
noteLiveTopic,
|
|
335
|
+
// В7 — a confirmed live delivery floors the target's idle proxy (setLastDelivered), so a
|
|
336
|
+
// just-delivered message is never reaped away before its turn record lands.
|
|
337
|
+
noteDelivered: identity => setLastDelivered(cfg, identity),
|
|
333
338
|
// Busy-human-composer queue: fast queued ack, async drain, fail all pending
|
|
334
339
|
// on daemon close/update so a queued envelope is never silently lost.
|
|
335
340
|
composerQueue,
|
package/src/lifecycle/index.ts
CHANGED
|
@@ -292,6 +292,34 @@ export function clearIdleReaped(cfg: LifecycleConfig, identity: string): void {
|
|
|
292
292
|
}
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
function lastDeliveredPath(cfg: LifecycleConfig, identity: string): string {
|
|
296
|
+
return join(cfg.stateDir, `${identity}.last-delivered`)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Stamp the moment a live delivery to this session was CONFIRMED (В7). superviseTick folds this into
|
|
300
|
+
* the idle proxy so a message delivered just as the session crosses idleSecs cannot be reaped out from
|
|
301
|
+
* under the just-delivered message before the runtime writes its turn record — the receiver's own
|
|
302
|
+
* process may not have appended the user-turn yet (codex socket-ack lands ok BEFORE ingest), so the
|
|
303
|
+
* transcript proxy alone would miss it and the reap would silently drop the message behind ok:true. */
|
|
304
|
+
export function setLastDelivered(cfg: LifecycleConfig, identity: string, nowMs: number = Date.now()): void {
|
|
305
|
+
mkdirSync(cfg.stateDir, { recursive: true, mode: 0o700 })
|
|
306
|
+
try {
|
|
307
|
+
writeFileSync(lastDeliveredPath(cfg, identity), String(nowMs), { mode: 0o600 })
|
|
308
|
+
} catch {
|
|
309
|
+
/* best-effort — the transcript proxy still governs; this only ADDS a floor, never removes one */
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Epoch-ms of the last confirmed live delivery to this session, or 0 when none/unreadable. */
|
|
314
|
+
export function readLastDelivered(cfg: LifecycleConfig, identity: string): number {
|
|
315
|
+
try {
|
|
316
|
+
const n = parseInt(readFileSync(lastDeliveredPath(cfg, identity), 'utf8').trim(), 10)
|
|
317
|
+
return Number.isFinite(n) && n > 0 ? n : 0
|
|
318
|
+
} catch {
|
|
319
|
+
return 0
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
295
323
|
function newEagerPath(cfg: LifecycleConfig, identity: string): string {
|
|
296
324
|
return join(cfg.stateDir, `${identity}.new-eager`)
|
|
297
325
|
}
|
|
@@ -1328,7 +1356,11 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
|
|
|
1328
1356
|
const ephemeral = isEphemeralPeer(s.cwd)
|
|
1329
1357
|
let lt = 0
|
|
1330
1358
|
try { lt = lastTurnMt(s.runtime, s.cwd) ?? 0 } catch { /* unreadable → wokeAt floor */ }
|
|
1331
|
-
|
|
1359
|
+
// В7 — fold in the last CONFIRMED live delivery. A message delivered just as the session crosses
|
|
1360
|
+
// idleSecs (codex acks BEFORE it writes the turn record) would otherwise be reaped out from under the
|
|
1361
|
+
// just-delivered message before lt catches up — a silent loss behind ok:true. lastDelivered only ever
|
|
1362
|
+
// RAISES the floor (a delivery is activity), so it can never keep a genuinely idle session alive.
|
|
1363
|
+
const mt = Math.max(lt, s.wokeAt, readLastDelivered(cfg, s.identity))
|
|
1332
1364
|
const ageSecs = Math.floor((nowMs - mt) / 1000)
|
|
1333
1365
|
// wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
|
|
1334
1366
|
// daemon routed its outbound reply) is reaped after a QUIET window, checked
|
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
|
package/src/transport/index.ts
CHANGED
|
@@ -217,13 +217,37 @@ export interface WarmDeliverSeam {
|
|
|
217
217
|
* via launchd KeepAlive).
|
|
218
218
|
* See `deliverViaHost`.
|
|
219
219
|
*/
|
|
220
|
+
// В6 — per-target delivery serialization. deliverToHost is paste → 300ms settle → CR over the pty; two
|
|
221
|
+
// CONCURRENT deliveries to the SAME hosted session interleave their frames (paste2 lands inside paste1's
|
|
222
|
+
// settle window → CR1 submits the SPLICED "envelope1envelope2" as one turn, CR2 submits an empty
|
|
223
|
+
// composer; for a router target the splice breaks the mate's per-envelope framing). Chain deliveries per
|
|
224
|
+
// address so each paste+settle+CR (and its confirm) completes as a UNIT before the next to that address
|
|
225
|
+
// starts. Different addresses stay parallel. Covers the DOMINANT path — every agent send_to_peer routes
|
|
226
|
+
// through the SINGLE daemon process; a rare CLI in-process `iapeer send` is a separate process this chain
|
|
227
|
+
// does not span (fully hermetic serialization would live in the supervisor as a per-session transaction).
|
|
228
|
+
const deliverTails = new Map<string, Promise<unknown>>()
|
|
229
|
+
|
|
220
230
|
export async function deliverWarm(
|
|
221
231
|
target: DeliveryTarget,
|
|
222
232
|
envelope: string,
|
|
223
233
|
cwd?: string,
|
|
224
234
|
seam: WarmDeliverSeam = {},
|
|
225
235
|
): Promise<Result<void>> {
|
|
226
|
-
|
|
236
|
+
const address = target.address
|
|
237
|
+
const prev = deliverTails.get(address) ?? Promise.resolve()
|
|
238
|
+
// run AFTER prev settles (success or failure) — a prior delivery's error must not stall the chain
|
|
239
|
+
const run = prev.then(
|
|
240
|
+
() => deliverViaHost(target, envelope, cwd, seam),
|
|
241
|
+
() => deliverViaHost(target, envelope, cwd, seam),
|
|
242
|
+
)
|
|
243
|
+
deliverTails.set(
|
|
244
|
+
address,
|
|
245
|
+
run.then(
|
|
246
|
+
() => {},
|
|
247
|
+
() => {},
|
|
248
|
+
),
|
|
249
|
+
)
|
|
250
|
+
return run
|
|
227
251
|
}
|
|
228
252
|
|
|
229
253
|
async function deliverViaHost(
|
|
@@ -890,6 +914,10 @@ export interface RouteDeps {
|
|
|
890
914
|
* imports lifecycle (layering — main.ts is where the two meet); call sites
|
|
891
915
|
* swallow hook errors (a hook must never fail a delivered message). */
|
|
892
916
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
917
|
+
/** В7 — stamp a CONFIRMED live delivery (target identity) so superviseTick's idle proxy floors on it
|
|
918
|
+
* and cannot reap the session before the just-delivered message's turn record lands. Injected (same
|
|
919
|
+
* layering as noteLiveTopic — lifecycle.setLastDelivered wired in main.ts). */
|
|
920
|
+
noteDelivered?: (identity: string) => void
|
|
893
921
|
/** H4 launchd-managed detector (injected so transport never imports lifecycle —
|
|
894
922
|
* main.ts wires the real `isLaunchdManaged`). A MISS on a launchd-managed target is
|
|
895
923
|
* almost always a TRANSIENT restart window (router restart on connect / `iapeer
|
|
@@ -913,6 +941,16 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
|
|
|
913
941
|
}
|
|
914
942
|
}
|
|
915
943
|
|
|
944
|
+
/** Best-effort lastDelivered stamp (В7) — records a CONFIRMED live delivery so the idle-reap proxy
|
|
945
|
+
* cannot reap the session out from under a just-delivered message before its turn record lands. */
|
|
946
|
+
function noteDelivered(deps: RouteDeps, identity: string): void {
|
|
947
|
+
try {
|
|
948
|
+
deps.noteDelivered?.(identity)
|
|
949
|
+
} catch {
|
|
950
|
+
/* a post-delivery hook must never fail the delivery */
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
916
954
|
export interface ComposerDeliveryQueueOptions {
|
|
917
955
|
env?: NodeJS.ProcessEnv
|
|
918
956
|
pollMs?: number
|
|
@@ -938,6 +976,8 @@ export interface ComposerDeliveryQueueOptions {
|
|
|
938
976
|
deliver?: (target: DeliveryTarget, envelope: string, cwd?: string) => Result<void> | Promise<Result<void>>
|
|
939
977
|
notifyFailed: (job: ComposerQueuedDelivery, reason: string) => void | Promise<void>
|
|
940
978
|
noteLiveTopic?: (identity: string, topic: string) => void
|
|
979
|
+
/** В7 — stamp a confirmed queued delivery (same seam as RouteDeps.noteDelivered). */
|
|
980
|
+
noteDelivered?: (identity: string) => void
|
|
941
981
|
onDelivered?: (caller: ResolvedCaller) => void
|
|
942
982
|
}
|
|
943
983
|
|
|
@@ -1078,6 +1118,11 @@ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions):
|
|
|
1078
1118
|
await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: ${delivered.error.message}`)
|
|
1079
1119
|
} else {
|
|
1080
1120
|
noteTopic({ noteLiveTopic: opts.noteLiveTopic }, job.target.address, job.topic)
|
|
1121
|
+
try {
|
|
1122
|
+
opts.noteDelivered?.(job.target.address) // В7 — floor the idle proxy for the queued delivery too
|
|
1123
|
+
} catch {
|
|
1124
|
+
/* best-effort */
|
|
1125
|
+
}
|
|
1081
1126
|
try {
|
|
1082
1127
|
opts.onDelivered?.(job.caller)
|
|
1083
1128
|
} catch {
|
|
@@ -1279,6 +1324,7 @@ export async function routeSend(
|
|
|
1279
1324
|
const delivered = await deliverWarm(target.value, envelope, peer.cwd)
|
|
1280
1325
|
if (!delivered.ok) return delivered
|
|
1281
1326
|
noteTopic(deps, target.value.address, topic)
|
|
1327
|
+
noteDelivered(deps, target.value.address) // В7 — floor the idle proxy so this delivery isn't reaped away
|
|
1282
1328
|
return ok({
|
|
1283
1329
|
ok: true,
|
|
1284
1330
|
delivered_to: { personality: target.value.personality, runtime: target.value.runtime },
|