@agfpd/iapeer 0.4.38 → 0.4.40

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.4.38",
3
+ "version": "0.4.40",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -2,9 +2,9 @@
2
2
  // Block 2, ported from PoC pts.mjs). Detach key Ctrl-] (session keeps running). The two reattach
3
3
  // port-deps live here + in protocol.ts: #1 TERM_RESET on EVERY exit path (incl. SIGHUP window-close,
4
4
  // via writeSync which works inside a signal handler), and #2 backpressure is the daemon's side.
5
- import { existsSync, readFileSync, writeSync } from 'node:fs'
5
+ import { writeSync } from 'node:fs'
6
6
  import { FRAME_DATA, FRAME_RESIZE, TERM_RESET, frame, makeFramer, sizePayload } from './protocol.ts'
7
- import { pidPath, sockPath } from './paths.ts'
7
+ import { pidStartToken, readPidFile, sockPath } from './paths.ts'
8
8
 
9
9
  const DETACH = 0x1d // Ctrl-]
10
10
 
@@ -16,9 +16,26 @@ const pidAlive = (p: number): boolean => {
16
16
  return false
17
17
  }
18
18
  }
19
+
20
+ // В22 — OWNER-validated liveness. kill(pid,0) is the fast fail (dead pid → not alive). When the pid is
21
+ // alive AND the pidfile carries a start-token, verify the token matches the pid's CURRENT start time so a
22
+ // REUSED pid (a different process that inherited the number) never reads as our live session. `ps` is
23
+ // bounded by a short per-session cache — repeated liveness reads within a tick do not re-spawn it.
24
+ const OWNER_TTL_MS = 3000
25
+ const ownerCache = new Map<string, { pid: number; token: string; verdict: boolean; until: number }>()
26
+
19
27
  export function sessionAlive(runDir: string, session: string): boolean {
20
- const pf = pidPath(runDir, session)
21
- return existsSync(pf) && pidAlive(Number(readFileSync(pf, 'utf8')))
28
+ const rec = readPidFile(runDir, session)
29
+ if (!rec || !pidAlive(rec.pid)) return false
30
+ if (!rec.token) return true // legacy tokenless pidfile → kill-0 only (no regression during rollout)
31
+ const key = `${runDir}\0${session}`
32
+ const now = Date.now()
33
+ const cached = ownerCache.get(key)
34
+ if (cached && cached.pid === rec.pid && cached.token === rec.token && now < cached.until) return cached.verdict
35
+ const live = pidStartToken(rec.pid)
36
+ const verdict = live !== null && live === rec.token // same process instance, not a reused pid
37
+ ownerCache.set(key, { pid: rec.pid, token: rec.token, verdict, until: now + OWNER_TTL_MS })
38
+ return verdict
22
39
  }
23
40
 
24
41
  /** Attach an interactive terminal client to a live daemon session. Resolves when the client
@@ -22,9 +22,10 @@ import {
22
22
  frame,
23
23
  makeFramer,
24
24
  parseSize,
25
+ splitDanglingEscape,
25
26
  stripQueries,
26
27
  } from './protocol.ts'
27
- import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
28
+ import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry, writePidFile } from './paths.ts'
28
29
  import { nextBootAction, nextNagAction, type BootPredicates, type NagPredicate } from './boot.ts'
29
30
  import { modelToPlainText } from './render.ts'
30
31
  import { codexAdapter } from '../launch/adapters/codex.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 || paneLogFd < 0) return
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
- if (ino !== paneLogIno) openPaneLog()
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
- const painted = new WeakSet<object>()
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
- if (clients.size > 0) writeFileSync(attachedMarker, '')
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) clients.delete(s)
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 (paneLogFd >= 0) {
273
- healPaneLog() // reopen if the path was unlinked/replaced out from under us (Defect 2)
274
- try {
275
- writeSync(paneLogFd, buf)
276
- } catch {
277
- /* best-effort */
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
- for (const r of capabilityResponses(s)) child.terminal.write(r) // answer capability queries
284
- const fwd = Buffer.from(stripQueries(s), 'latin1') // forward to clients sans queries (daemon is authoritative)
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,23 +497,25 @@ 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()
474
515
  },
475
516
  },
476
517
  })
477
- writeFileSync(pid, String(process.pid))
518
+ writePidFile(runDir, session, process.pid) // В22 — `<pid> <startToken>` so a reused pid can't impersonate this session
478
519
  writeGeometry(runDir, session, serveCols, serveRows) // initial geometry sidecar (= serve); updated on every real child resize
479
520
 
480
521
  let down = false
@@ -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' || Date.now() > driveDeadline) clearInterval(driver)
595
+ else if (action.kind === 'ready') clearInterval(driver)
547
596
  }, BOOT_POLL_MS)
548
597
  ;(driver as { unref?: () => void }).unref?.()
549
598
  }
@@ -8,7 +8,7 @@ import { spawn as nodeSpawn } from 'node:child_process'
8
8
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'node:fs'
9
9
  import { fileURLToPath } from 'node:url'
10
10
  import { runSupervisorClient, sessionAlive } from './client.ts'
11
- import { defaultRunDir, logPath, pidPath, servePath, sockPath } from './paths.ts'
11
+ import { defaultRunDir, logPath, pidPath, pidStartToken, readPidFile, servePath, sockPath } from './paths.ts'
12
12
 
13
13
  // daemon.ts loads @xterm. It is dynamic-imported ONLY in the 'daemon' CLI case below (the detached
14
14
  // daemon process, where @xterm belongs), so importing THIS module — e.g. launch resolving
@@ -125,17 +125,35 @@ export async function startSupervisorDaemon(opts: StartDaemonOptions): Promise<S
125
125
  const deadline = Date.now() + (opts.timeoutMs ?? 5000)
126
126
  while (Date.now() < deadline) {
127
127
  if (existsSync(sockPath(runDir, session)) && sessionAlive(runDir, session)) {
128
- return { state: 'started', pid: Number(readFileSync(pidPath(runDir, session), 'utf8')) }
128
+ return { state: 'started', pid: readPidFile(runDir, session)?.pid }
129
129
  }
130
130
  await Bun.sleep(80) // yield the loop — never a sync block in the daemon's wake path
131
131
  }
132
- // Failed to come up — drop the serve-spec we wrote so it can't mislead a later read.
132
+ // Failed to come up in time. В20 — KILL the spawned child FIRST. A merely-slow child that becomes the
133
+ // daemon AFTER we unlink the serve-spec would readServeSpec → undefined → come up as a BARE CONDUIT
134
+ // ([bin] runtime with NO PEER_IDENTITY / composed argv / doctrine) that captures the peer's identity:
135
+ // liveness then reads it "online", messages route into an identity-less session, and if it slipped
136
+ // past enroll-on-FAILED it is invisible to idle-reap (an unbounded orphan). Better a dead peer (the
137
+ // next wake respawns clean) than an identity-less conduit. Kill, THEN drop the spec + any stale
138
+ // sock/pid the dying child created (SIGKILL skips the child's own cleanup).
139
+ try {
140
+ child.kill('SIGKILL')
141
+ } catch {
142
+ /* already gone */
143
+ }
133
144
  if (opts.serve)
134
145
  try {
135
146
  unlinkSync(servePath(runDir, session))
136
147
  } catch {
137
148
  /* */
138
149
  }
150
+ for (const p of [sockPath(runDir, session), pidPath(runDir, session)]) {
151
+ try {
152
+ if (existsSync(p)) unlinkSync(p)
153
+ } catch {
154
+ /* best-effort */
155
+ }
156
+ }
139
157
  return { state: 'failed', detail: `daemon did not come up; see ${logPath(runDir, session)}` }
140
158
  }
141
159
 
@@ -146,16 +164,22 @@ export function listSessions(runDir: string): Array<{ session: string; alive: bo
146
164
  .map(f => f.replace(/\.sock$/, ''))
147
165
  .map(session => {
148
166
  const alive = sessionAlive(runDir, session)
149
- return { session, alive, pid: alive ? Number(readFileSync(pidPath(runDir, session), 'utf8')) : undefined }
167
+ return { session, alive, pid: alive ? readPidFile(runDir, session)?.pid : undefined }
150
168
  })
151
169
  }
152
170
 
153
171
  export function killSession(runDir: string, session: string): boolean {
154
- const pf = pidPath(runDir, session)
155
- if (!existsSync(pf)) return false
156
- const pid = Number(readFileSync(pf, 'utf8'))
172
+ const rec = readPidFile(runDir, session)
173
+ if (!rec) return false
174
+ // В22 — verify the pid is STILL our daemon before SIGTERM. After an abnormal death + pid reuse, a bare
175
+ // kill here would SIGTERM the INNOCENT process that inherited the pid. Skip the kill when the start-token
176
+ // no longer matches (a legacy tokenless pidfile keeps the old behavior — no regression during rollout).
177
+ if (rec.token) {
178
+ const live = pidStartToken(rec.pid)
179
+ if (live !== null && live !== rec.token) return false // reused pid — NOT our session; do not signal it
180
+ }
157
181
  try {
158
- process.kill(pid, 'SIGTERM')
182
+ process.kill(rec.pid, 'SIGTERM')
159
183
  } catch {
160
184
  /* already gone */
161
185
  }
@@ -3,6 +3,7 @@
3
3
  // socket/pid files never touch a real location.
4
4
  import { renameSync, writeFileSync } from 'node:fs'
5
5
  import { readFileSync } from 'node:fs'
6
+ import { spawnSync } from 'node:child_process'
6
7
  import { join } from 'node:path'
7
8
  import { pluginStateDir } from '../storage/index.ts'
8
9
 
@@ -13,6 +14,49 @@ export function defaultRunDir(env: NodeJS.ProcessEnv = process.env): string {
13
14
 
14
15
  export const sockPath = (runDir: string, session: string): string => join(runDir, `${session}.sock`)
15
16
  export const pidPath = (runDir: string, session: string): string => join(runDir, `${session}.pid`)
17
+
18
+ // ── pid-OWNERSHIP token (В22) ───────────────────────────────────────────────────
19
+ // kill(pid,0) proves SOME process holds the pid, not that it is STILL our daemon: after an abnormal
20
+ // death (SIGKILL/panic/reboot) the pidfile survives, and if the OS reuses that pid for another same-uid
21
+ // process, a bare kill(pid,0) reads the dead session as "alive" (peer undeliverable + unreapable) and a
22
+ // kill() would SIGTERM the innocent reuser. The pidfile therefore stores `<pid> <startToken>` where the
23
+ // token is the process's start time; a reused pid has a DIFFERENT start time, so ownership is verifiable.
24
+
25
+ /** The OS-reported start time of `pid` (a stable per-process-instance token), or null if it is not
26
+ * running / unreadable. `ps -o lstart=` is a single spawn — callers bound its frequency (see the
27
+ * liveness cache in client.ts). */
28
+ export function pidStartToken(pid: number): string | null {
29
+ if (!Number.isInteger(pid) || pid <= 0) return null
30
+ try {
31
+ const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' })
32
+ const t = (r.stdout ?? '').trim()
33
+ return t.length > 0 ? t : null
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ /** Write the pidfile as `<pid> <startToken>` (В22). Falls back to bare `<pid>` if the token is
40
+ * unavailable — a tokenless pidfile is treated as legacy (owner-check skipped, kill-0 only). */
41
+ export function writePidFile(runDir: string, session: string, pid: number): void {
42
+ const token = pidStartToken(pid)
43
+ writeFileSync(pidPath(runDir, session), token ? `${pid} ${token}` : String(pid))
44
+ }
45
+
46
+ /** Parse the pidfile → { pid, token } (token '' for a legacy bare-pid file). null when absent/invalid. */
47
+ export function readPidFile(runDir: string, session: string): { pid: number; token: string } | null {
48
+ let raw: string
49
+ try {
50
+ raw = readFileSync(pidPath(runDir, session), 'utf8').trim()
51
+ } catch {
52
+ return null
53
+ }
54
+ if (!raw) return null
55
+ const sp = raw.indexOf(' ')
56
+ const pid = Number(sp < 0 ? raw : raw.slice(0, sp))
57
+ if (!Number.isInteger(pid) || pid <= 0) return null
58
+ return { pid, token: sp < 0 ? '' : raw.slice(sp + 1).trim() }
59
+ }
16
60
  export const logPath = (runDir: string, session: string): string => join(runDir, `${session}.log`)
17
61
  /** Serving spec (cutover Block 2 slice b): the composed {argv, env, cwd} a detached daemon serves.
18
62
  * Written 0600 by startSupervisorDaemon BEFORE spawn (env can carry launch.env secrets), read by
@@ -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