@agfpd/iapeer 0.4.8 → 0.4.9

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.
@@ -16,7 +16,6 @@
16
16
  import { readFileSync, readdirSync, realpathSync, statSync } from 'fs'
17
17
  import { join } from 'path'
18
18
  import { homedir } from 'os'
19
- import { spawnSync } from 'child_process'
20
19
  import {
21
20
  MAX_ATTACHMENTS,
22
21
  MAX_MESSAGE_LEN,
@@ -31,8 +30,6 @@ import { err, ok, type Result } from '../core/errors.ts'
31
30
  import {
32
31
  buildProcessAddress,
33
32
  buildSocketPath,
34
- parseSessionName,
35
- parseSocketPath,
36
33
  type ProcessAddress,
37
34
  } from '../core/socket.ts'
38
35
  import { buildEnvelope } from '../codec/index.ts'
@@ -42,7 +39,7 @@ import type { ResolvedCaller } from '../identity/index.ts'
42
39
  // reads them from getAdapter(target.runtime) for the tui submit path. One-way
43
40
  // dependency: launch does NOT import transport, so no cycle.
44
41
  import { getAdapter } from '../launch/index.ts'
45
- import type { ControlCommand, DeliveryMarkers } from '../launch/types.ts'
42
+ import type { ControlCommand } from '../launch/types.ts'
46
43
  // Spawn-flip Ф0b-2: warm-deliver is HOST-AWARE. hostSessionAlive (runtime-state host detect, an
47
44
  // @xterm-free pid-file check) + deliverHosted (the pure-protocol socket leaf, Ф0a) come from
48
45
  // launch/ptyHost — already in transport's module graph via getAdapter above and @xterm-free by
@@ -70,32 +67,6 @@ export interface DeliveryTarget extends ProcessAddress {
70
67
  socketPath: string
71
68
  }
72
69
 
73
- export interface TmuxResult {
74
- ok: boolean
75
- out: string
76
- err: string
77
- }
78
-
79
- export function tmux(sock: string, ...args: string[]): TmuxResult {
80
- const r = spawnSync('tmux', ['-S', sock, ...args], { encoding: 'utf8' })
81
- return { ok: r.status === 0, out: r.stdout ?? '', err: r.stderr ?? '' }
82
- }
83
-
84
- // Settle delay between bracketed paste and the submit Enter — non-TUI path only.
85
- const PASTE_SETTLE_MS = (() => {
86
- const raw = process.env.IAP_TMUX_PASTE_SETTLE_MS
87
- const n = raw === undefined ? NaN : Number(raw)
88
- return Number.isFinite(n) && n >= 0 ? n : 250
89
- })()
90
-
91
- const SUBMIT_POLL_MS = 40
92
- const SUBMIT_LANDED_TIMEOUT_MS = 1500
93
- const SUBMIT_CONFIRM_WINDOW_MS = 500
94
- function submitTotalTimeoutMs(): number {
95
- const raw = process.env.IAP_TMUX_SUBMIT_TIMEOUT_MS
96
- const n = raw === undefined ? NaN : Number(raw)
97
- return Number.isFinite(n) && n >= 0 ? n : 4000
98
- }
99
70
 
100
71
  function sleepSync(ms: number): void {
101
72
  if (ms <= 0) return
@@ -106,174 +77,10 @@ function monotonicMs(): number {
106
77
  return Number(process.hrtime.bigint() / 1_000_000n)
107
78
  }
108
79
 
109
- function listSessions(sock: string): string[] {
110
- const r = tmux(sock, 'list-sessions', '-F', '#{session_name}')
111
- if (!r.ok) return []
112
- return r.out.split(/\r?\n/).map(line => line.trim()).filter(Boolean)
113
- }
114
-
115
- function sessionAlive(sock: string, address: string): boolean {
116
- // SPAWN-FLIP Ф0b-3: a supervisor-HOSTED session has no tmux entry — its liveness is the supervisor
117
- // daemon (runtime-state), checked FIRST (a cheap pid-file). Non-hosted → the tmux session list, as
118
- // before (byte-identical). Without this, resolveDeliveryTarget/isPeerLive see a live hosted peer as
119
- // OFFLINE → routeSend mis-routes a warm hit to a wake and the post-wake verify-before-act false-fails
120
- // (the canary symptom: "woke but not live" though delivery actually landed). `address` IS the identity.
121
- return hostSessionAlive(address) || listSessions(sock).includes(address)
122
- }
123
-
124
- // ─── Busy-composer detection (human input vs runtime ghost text) ─────────────
125
-
126
- interface SgrState {
127
- faint: boolean
128
- fg256: number | null
129
- fgBasic: number | null
130
- }
131
-
132
- interface GhostSgrConfig {
133
- faint: boolean
134
- fg256: Set<number>
135
- fgBasic: Set<number>
136
- }
137
-
138
- interface PaneToken {
139
- ch: string
140
- ghost: boolean
141
- }
142
-
143
- function parseSgrCodeList(raw: string): number[] | null {
144
- if (raw.trim() === '') return [0]
145
- const out = raw.split(';').map(part => (part === '' ? 0 : Number(part)))
146
- return out.every(Number.isFinite) ? out : null
147
- }
148
-
149
- function ghostSgrConfig(markers: DeliveryMarkers): GhostSgrConfig {
150
- const cfg: GhostSgrConfig = { faint: false, fg256: new Set(), fgBasic: new Set() }
151
- for (const raw of markers.ghostTextSgr ?? []) {
152
- const codes = parseSgrCodeList(raw)
153
- if (!codes) continue
154
- if (codes.length === 1 && codes[0] === 2) {
155
- cfg.faint = true
156
- continue
157
- }
158
- if (codes.length === 1 && ((codes[0] >= 30 && codes[0] <= 37) || (codes[0] >= 90 && codes[0] <= 97))) {
159
- cfg.fgBasic.add(codes[0])
160
- continue
161
- }
162
- if (codes.length === 3 && codes[0] === 38 && codes[1] === 5) cfg.fg256.add(codes[2])
163
- }
164
- return cfg
165
- }
166
-
167
- function applySgr(state: SgrState, codes: number[]): void {
168
- let i = 0
169
- while (i < codes.length) {
170
- const code = codes[i]
171
- if (code === 0) {
172
- state.faint = false
173
- state.fg256 = null
174
- state.fgBasic = null
175
- } else if (code === 2) {
176
- state.faint = true
177
- } else if (code === 22) {
178
- state.faint = false
179
- } else if (code === 39) {
180
- state.fg256 = null
181
- state.fgBasic = null
182
- } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
183
- state.fgBasic = code
184
- state.fg256 = null
185
- } else if (code === 38 && codes[i + 1] === 5 && Number.isFinite(codes[i + 2])) {
186
- state.fg256 = codes[i + 2]
187
- state.fgBasic = null
188
- i += 2
189
- } else if (code === 38 && codes[i + 1] === 2) {
190
- // Truecolor foreground: not one of the calibrated grey-256 markers. Track
191
- // it as "not ghost" by clearing the 256/basic foreground slots.
192
- state.fg256 = null
193
- state.fgBasic = null
194
- i += 4
195
- }
196
- i += 1
197
- }
198
- }
199
-
200
- function stateIsGhost(state: SgrState, cfg: GhostSgrConfig): boolean {
201
- if (cfg.faint && state.faint) return true
202
- if (state.fg256 !== null && cfg.fg256.has(state.fg256)) return true
203
- if (state.fgBasic !== null && cfg.fgBasic.has(state.fgBasic)) return true
204
- return false
205
- }
206
-
207
- function parseAnsiLine(line: string, cfg: GhostSgrConfig): PaneToken[] {
208
- const tokens: PaneToken[] = []
209
- const state: SgrState = { faint: false, fg256: null, fgBasic: null }
210
- let i = 0
211
- while (i < line.length) {
212
- if (line.charCodeAt(i) === 0x1b && line[i + 1] === '[') {
213
- let end = i + 2
214
- while (end < line.length && !/[A-Za-z]/.test(line[end])) end += 1
215
- if (end < line.length) {
216
- if (line[end] === 'm') {
217
- const codes = parseSgrCodeList(line.slice(i + 2, end))
218
- if (codes) applySgr(state, codes)
219
- }
220
- i = end + 1
221
- continue
222
- }
223
- }
224
- const ch = Array.from(line.slice(i))[0] ?? ''
225
- if (!ch) break
226
- tokens.push({ ch, ghost: stateIsGhost(state, cfg) })
227
- i += ch.length
228
- }
229
- return tokens
230
- }
231
-
232
- /** True iff a captured TUI pane shows non-dim human text in the composer row.
233
- * Grey/faint ghost suggestions and placeholders are intentionally NOT human
234
- * input: holding IAP delivery behind them would be a false busy state. */
235
- export function composerCaptureHasHumanInput(pane: string, markers: DeliveryMarkers): boolean {
236
- if (markers.promptGlyphs.length === 0) return false
237
- const cfg = ghostSgrConfig(markers)
238
- let promptTokens: PaneToken[] | null = null
239
- let promptIndex = -1
240
-
241
- for (const line of pane.split(/\r?\n/)) {
242
- const tokens = parseAnsiLine(line, cfg)
243
- const firstVisible = tokens.findIndex(tok => tok.ch.trim() !== '')
244
- if (firstVisible < 0) continue
245
- if (!markers.promptGlyphs.includes(tokens[firstVisible].ch)) continue
246
- promptTokens = tokens
247
- promptIndex = firstVisible
248
- }
249
-
250
- if (!promptTokens || promptIndex < 0) return false
251
- for (const tok of promptTokens.slice(promptIndex + 1)) {
252
- if (tok.ch.trim() === '') continue
253
- if (!tok.ghost) return true
254
- }
255
- return false
256
- }
257
-
258
- export function hasAttachedTmuxClient(target: DeliveryTarget): boolean {
259
- const r = tmux(target.socketPath, 'list-clients', '-F', '#{client_session}')
260
- if (!r.ok) return false
261
- return r.out.split(/\r?\n/).map(line => line.trim()).includes(target.address)
262
- }
263
-
264
- function targetComposerBlocksDelivery(target: DeliveryTarget): boolean {
265
- if (!isSupportedLocalRuntime(target.runtime)) return false
266
- const cap = tmux(target.socketPath, 'capture-pane', '-e', '-p', '-t', target.address)
267
- // With a live resolved target, a capture failure is an uncertainty in the
268
- // dangerous direction: prefer a bounded false-busy queue/force over a false-free
269
- // paste into a human composer. If the session is actually gone, the queue's
270
- // session-token/identity check fails loudly.
271
- if (!cap.ok) return true
272
- return composerCaptureHasHumanInput(cap.out, getAdapter(target.runtime).deliveryMarkers)
273
- }
274
-
275
- export function shouldQueueForHumanComposer(target: DeliveryTarget): boolean {
276
- return isSupportedLocalRuntime(target.runtime) && hasAttachedTmuxClient(target) && targetComposerBlocksDelivery(target)
80
+ function sessionAlive(_sock: string, address: string): boolean {
81
+ // pty-only: a peer's liveness is its supervisor daemon (a cheap pid-file check). `address` IS the
82
+ // identity. (`_sock` is retained for call-site compatibility; tmux sockets no longer exist.)
83
+ return hostSessionAlive(address)
277
84
  }
278
85
 
279
86
  /** Is the (runtime,personality) endpoint live right now? Public liveness predicate. */
@@ -282,35 +89,14 @@ export function isPeerLive(runtime: string, personality: string, sockDir = resol
282
89
  return sessionAlive(socketPath, buildProcessAddress(runtime, personality))
283
90
  }
284
91
 
285
- export function listOnlinePeers(sockDir = resolveSockDir()): OnlinePeer[] {
92
+ export function listOnlinePeers(_sockDir = resolveSockDir()): OnlinePeer[] {
93
+ // pty-only: every live peer is a supervisor-hosted session (no tmux sockets to scan).
286
94
  const out = new Map<string, OnlinePeer>()
287
- let entries: string[] = []
288
- try {
289
- entries = readdirSync(sockDir)
290
- } catch {
291
- /* no tmux sock dir → only supervisor-hosted peers, merged below (never early-return past them) */
292
- }
293
- for (const entry of entries) {
294
- const parsedSocket = parseSocketPath(join(sockDir, entry))
295
- if (!parsedSocket) continue
296
- for (const sessionName of listSessions(join(sockDir, entry))) {
297
- const parsedSession = parseSessionName(sessionName)
298
- if (!parsedSession) continue
299
- if (parsedSession.address !== parsedSocket.address) continue
300
- out.set(parsedSession.address, {
301
- personality: parsedSession.personality,
302
- runtime: parsedSession.runtime,
303
- })
304
- }
305
- }
306
- // SPAWN-FLIP Ф0b-3: supervisor-HOSTED peers have no tmux socket, so the scan above misses them —
307
- // merge in the live hosted sessions so the online scan + the no-runtime resolve see them too.
308
95
  for (const h of listHostedPeers()) {
309
96
  out.set(buildProcessAddress(h.runtime, h.personality), { personality: h.personality, runtime: h.runtime })
310
97
  }
311
98
  return Array.from(out.values()).sort(
312
- (a, b) =>
313
- a.personality.localeCompare(b.personality) || a.runtime.localeCompare(b.runtime),
99
+ (a, b) => a.personality.localeCompare(b.personality) || a.runtime.localeCompare(b.runtime),
314
100
  )
315
101
  }
316
102
 
@@ -365,56 +151,6 @@ export function resolvePeerDeliveryTarget(
365
151
  return resolveDeliveryTarget({ personality })
366
152
  }
367
153
 
368
- // ─── TUI submit (ported byte-for-byte from transport.ts) ────────────────────
369
-
370
- function inputHoldsPaste(
371
- sock: string,
372
- address: string,
373
- tailMarker: string,
374
- markers: DeliveryMarkers,
375
- ): boolean {
376
- const cap = tmux(sock, 'capture-pane', '-p', '-t', address)
377
- if (!cap.ok) return true
378
- const lines = cap.out.split('\n')
379
- let promptRow = -1
380
- for (let i = 0; i < lines.length; i++) {
381
- const ch = lines[i]?.[0] ?? ''
382
- if (markers.promptGlyphs.includes(ch)) promptRow = i
383
- }
384
- if (promptRow < 0) return true
385
- const band = lines.slice(promptRow).join('\n')
386
- if (band.includes(tailMarker)) return true
387
- return markers.pastePatterns?.some(re => re.test(band)) ?? false
388
- }
389
-
390
- // Returns TRUE iff the input cleared after Enter within the budget — i.e. an
391
- // (idle, responsive) TUI accepted the submit. FALSE on timeout (the input never
392
- // cleared): either a DEAD/hung session, OR a BUSY session mid-turn whose input row
393
- // is not even rendered (then liveness is confirmed by the transcript-mtime advance
394
- // in deliverViaTmux, not here). The poll/re-press TIMING is the byte-for-byte 0.7.6
395
- // port; only the return value is new (Ф-B: feed the delivery-liveness decision).
396
- function submitIntoTui(sock: string, address: string, envelope: string, markers: DeliveryMarkers): boolean {
397
- const tailMarker =
398
- envelope.split('\n').map(line => line.trim()).filter(Boolean).pop() ?? envelope.trim()
399
- const start = monotonicMs()
400
- while (monotonicMs() - start < SUBMIT_LANDED_TIMEOUT_MS) {
401
- if (inputHoldsPaste(sock, address, tailMarker, markers)) break
402
- sleepSync(SUBMIT_POLL_MS)
403
- }
404
- while (monotonicMs() - start < submitTotalTimeoutMs()) {
405
- tmux(sock, 'send-keys', '-t', address, 'Enter')
406
- const windowStart = monotonicMs()
407
- while (
408
- monotonicMs() - windowStart < SUBMIT_CONFIRM_WINDOW_MS &&
409
- monotonicMs() - start < submitTotalTimeoutMs()
410
- ) {
411
- sleepSync(SUBMIT_POLL_MS)
412
- if (!inputHoldsPaste(sock, address, tailMarker, markers)) return true
413
- }
414
- }
415
- return false
416
- }
417
-
418
154
  // Ф-B liveness: how long to keep polling the activity proxy for a transcript
419
155
  // advance when the submit did NOT clear the input (a busy session whose input row
420
156
  // is not rendered). Env-tunable for LIVE calibration (busy/idle/cold). Conservative
@@ -439,78 +175,12 @@ function hostLivenessGraceMs(): number {
439
175
  return Number.isFinite(n) && n >= 0 ? n : livenessGraceMs()
440
176
  }
441
177
 
442
- /**
443
- * Deliver `envelope` into the target's tmux session and CONFIRM it landed in a LIVE
444
- * session (Ф-B delivery guarantee, contract Демон §Гарантия доставки). `cwd` (the
445
- * target peer's working dir) enables the transcript-mtime liveness probe; omit it and
446
- * the tui path falls back to submit-confirmation only (existing direct callers/tests).
447
- *
448
- * tui liveness — ok ⟺ message landed in a live session, by EITHER strong signal:
449
- * (a) submit confirmed — the input cleared after Enter (an idle session accepted it);
450
- * (b) transcript-mtime advanced past the pre-submit baseline (a BUSY session is
451
- * mid-turn — our message is queued and surfaces on its next tool call).
452
- * Neither within the grace budget → the session is listed live but UNRESPONSIVE
453
- * (dead/hung) → fail LOUDLY. Silent loss is forbidden; when in doubt prefer a false-
454
- * FAIL (sender retries) over a false-OK. router (telegram/notifier) is always-on by
455
- * launchd → its liveness is structural, the C-j path keeps its prior semantics.
456
- */
457
- export function deliverViaTmux(target: DeliveryTarget, envelope: string, cwd?: string): Result<void> {
458
- const bufferName = `iap-${process.pid}-${Date.now()}`
459
- const load = spawnSync(
460
- 'tmux',
461
- ['-S', target.socketPath, 'load-buffer', '-b', bufferName, '-'],
462
- { input: envelope, encoding: 'utf8' },
463
- )
464
- if (load.status !== 0) {
465
- return err(`tmux load-buffer failed: ${(load.stderr ?? '').trim() || `exit ${load.status}`}`)
466
- }
467
- const isTui = isSupportedLocalRuntime(target.runtime)
468
- const pasteArgs = isTui
469
- ? ['paste-buffer', '-p', '-b', bufferName, '-t', target.address]
470
- : ['paste-buffer', '-p', '-r', '-b', bufferName, '-t', target.address]
471
- const paste = tmux(target.socketPath, ...pasteArgs)
472
- if (!paste.ok) {
473
- tmux(target.socketPath, 'delete-buffer', '-b', bufferName)
474
- return err(`tmux paste-buffer failed: ${paste.err.trim() || 'unknown error'}`)
475
- }
476
- if (isTui) {
477
- const adapter = getAdapter(target.runtime)
478
- // Baseline the activity proxy BEFORE the submit (a paste does not move the
479
- // transcript — only a model turn does), so an advance afterwards proves a busy
480
- // session is alive. null proxy / no cwd → 0 (the advance check is skipped below).
481
- const baselineMtime = cwd ? adapter.newestActivityMtime(cwd) ?? 0 : 0
482
- // Submit markers come from the target runtime's adapter.
483
- const confirmed = submitIntoTui(target.socketPath, target.address, envelope, adapter.deliveryMarkers)
484
- tmux(target.socketPath, 'delete-buffer', '-b', bufferName)
485
- if (confirmed) return ok(undefined) // idle session accepted the submit
486
- if (!cwd) return ok(undefined) // no activity proxy available → confirmed-only (legacy callers)
487
- // Busy session: the input never cleared, so confirm liveness by a transcript
488
- // advance. Grace-poll the proxy (the model may take a beat to write its turn).
489
- const graceDeadline = monotonicMs() + livenessGraceMs()
490
- do {
491
- if ((adapter.newestActivityMtime(cwd) ?? 0) > baselineMtime) return ok(undefined)
492
- sleepSync(LIVENESS_POLL_MS)
493
- } while (monotonicMs() < graceDeadline)
494
- return err(
495
- `peer "${target.personality}" (${target.runtime}) is listed live but did not accept the message ` +
496
- `(no input-clear, no transcript advance within ${livenessGraceMs()}ms) — live but unresponsive to this delivery (not dead); message NOT delivered`,
497
- )
498
- }
499
- sleepSync(PASTE_SETTLE_MS)
500
- const enter = tmux(target.socketPath, 'send-keys', '-t', target.address, 'C-j')
501
- tmux(target.socketPath, 'delete-buffer', '-b', bufferName)
502
- if (!enter.ok) return err(`tmux send-keys failed: ${enter.err.trim() || 'unknown error'}`)
503
- return ok(undefined)
504
- }
505
-
506
- // ─── Host-aware warm delivery (spawn-flip cutover Block 2, Ф0b-2) ────────────────────────────────
178
+ // ─── Warm delivery (pty-only — supervisor socket) ────────────────────────────────────────────────
507
179
 
508
180
  /** Test seam for `deliverWarm`. Production defaults route on the real supervisor; tests inject a
509
- * fake host-detect + host-deliver + a temp transcript proxy, so the host branch is covered with no
510
- * live daemon. Omitting all fields = pure production behaviour. */
181
+ * fake host-deliver + a temp transcript proxy, so the deliver path is covered with no live daemon.
182
+ * Omitting all fields = pure production behaviour. */
511
183
  export interface WarmDeliverSeam {
512
- /** Runtime-state host detector (NOT the `.pty-host` marker). Default: the real `hostSessionAlive`. */
513
- hostAlive?: (identity: string) => boolean
514
184
  /** Deliver to the hosted session over its socket. Default: the real `deliverHosted` (Ф0a leaf). */
515
185
  deliverHosted?: (identity: string, envelope: string) => Promise<DeliverResult>
516
186
  /** Activity proxy for the landed-confirm. Default: the target runtime adapter's transcript mtime. */
@@ -518,13 +188,11 @@ export interface WarmDeliverSeam {
518
188
  }
519
189
 
520
190
  /**
521
- * Warm-deliver `envelope` to a LIVE local target, HOST-AWARE. A supervisor-HOSTED target (spawn-flip
522
- * Ф0b: detected by live runtime state, NOT the `.pty-host` marker) is delivered over its socket; any
523
- * other target takes the unchanged `deliverViaTmux` path. The host branch CONFIRMS landing by the
524
- * SAME final arbiter as the tmux busy path — a transcript-mtime advance past the pre-deliver baseline,
525
- * NOT the socket-ack (a flushed CR proves the bytes left us, NOT that the session accepted them;
526
- * trusting the ack would re-open the silent-loss class the contract forbids). Flag-off (no live
527
- * supervisor session) → `hostAlive` is a cheap false → `deliverViaTmux` verbatim (byte-identical).
191
+ * Warm-deliver `envelope` to a LIVE local target over its supervisor socket (pty-only). Landing is
192
+ * CONFIRMED by a transcript-mtime advance past the pre-deliver baseline, NOT the socket-ack (a flushed
193
+ * CR proves the bytes left us, NOT that the session accepted them; trusting the ack would re-open the
194
+ * silent-loss class the contract forbids). A router (telegram/notifier) has no transcript proxy → it is
195
+ * confirmed by the socket-ack (its liveness is structural via launchd KeepAlive). See `deliverViaHost`.
528
196
  */
529
197
  export async function deliverWarm(
530
198
  target: DeliveryTarget,
@@ -532,8 +200,6 @@ export async function deliverWarm(
532
200
  cwd?: string,
533
201
  seam: WarmDeliverSeam = {},
534
202
  ): Promise<Result<void>> {
535
- const hostAlive = seam.hostAlive ?? hostSessionAlive
536
- if (!hostAlive(target.address)) return deliverViaTmux(target, envelope, cwd)
537
203
  return deliverViaHost(target, envelope, cwd, seam)
538
204
  }
539
205
 
@@ -819,12 +485,10 @@ export function waitForCompactDone(
819
485
  const pollMs = opts.pollMs ?? compactDonePollMs(env)
820
486
  const graceMs = opts.graceMs ?? compactReadyGraceMs(env)
821
487
  const aliveFn = opts.seam?.sessionAlive ?? ((t: DeliveryTarget) => sessionAlive(t.socketPath, t.address))
822
- const captureFn =
823
- opts.seam?.capturePane ??
824
- ((t: DeliveryTarget) => {
825
- const cap = tmux(t.socketPath, 'capture-pane', '-p', '-t', t.address)
826
- return cap.ok ? cap.out : null
827
- })
488
+ // pty-only: the AUTHORITATIVE compact-done signal is the transcript marker (compactTranscriptHasDone).
489
+ // The pane capture was a tmux fast-path (idle-composer detect) + error context; with tmux gone the
490
+ // default is no-capture (the transcript marker remains the contract). Tests inject seam.capturePane.
491
+ const captureFn = opts.seam?.capturePane ?? ((_t: DeliveryTarget): string | null => null)
828
492
  const adapter = getAdapter(target.runtime)
829
493
  const started = monotonicMs()
830
494
  const deadline = started + timeoutMs
@@ -880,16 +544,14 @@ export function waitForCompactDone(
880
544
 
881
545
  /**
882
546
  * Perform an in-session control command on a LIVE target (Ф-E, docs/Control-команды).
883
- * The target's adapter maps the abstract command to a tmux send-keys sequence
884
- * (ControlPlan); each step is sent IN ORDER, UNCONDITIONALLY — NO ready-gate, NO
885
- * submit-confirm (the point of `interrupt` is to break a stuck/raving turn exactly
886
- * when normal delivery would not land). An unsupported command (router runtimes,
887
- * unknown name) → explicit refusal, not a silent no-op.
547
+ * The target's adapter maps the abstract command to a control sequence (ControlPlan); each step is
548
+ * sent IN ORDER, UNCONDITIONALLY — NO ready-gate, NO submit-confirm (the point of `interrupt` is to
549
+ * break a stuck/raving turn exactly when normal delivery would not land). An unsupported command
550
+ * (router runtimes, unknown name) → explicit refusal, not a silent no-op.
888
551
  */
889
- /** Test seam for the hosted control branch (spawn-flip Ф0b-2 slice 3): inject host-detect + the
890
- * socket control-send so the host path is covered without a live supervisor. Omitting all = prod. */
552
+ /** Test seam for the control path: inject the socket control-send so it is covered without a live
553
+ * supervisor. Omitting all = prod. */
891
554
  export interface ControlHostSeam {
892
- hostAlive?: (identity: string) => boolean
893
555
  sendControl?: (runDir: string, session: string, chunks: Buffer[], opts: { stepDelayMs?: number }) => Promise<DeliverResult>
894
556
  }
895
557
 
@@ -902,24 +564,14 @@ export async function executeControlOnTarget(
902
564
  if (!plan) {
903
565
  return err(`runtime "${target.runtime}" does not support control command "${command.name}"`)
904
566
  }
905
- // SPAWN-FLIP Ф0b-2 slice 3: a supervisor-HOSTED target has no tmux send-keys — translate each plan
906
- // step to pty bytes with keysToBytes (REUSED from the boot-driver, NOT reimplemented) and send them
907
- // over the supervisor socket. Same UNCONDITIONAL semantics (no ready-gate, no submit-confirm) — the
908
- // point of interrupt is to break a stuck turn. Flag-off (no live host) → the unchanged tmux path.
909
- const hostAlive = seam.hostAlive ?? hostSessionAlive
910
- if (hostAlive(target.address)) {
911
- const chunks = plan.sequence.map(keys => keysToBytes(keys))
912
- const send = seam.sendControl ?? sendControlToHost
913
- const sent = await send(hostRunDir(), target.address, chunks, { stepDelayMs: plan.stepDelayMs })
914
- if (!sent.ok) {
915
- return err(`hosted control "${command.name}" failed for "${target.personality}" (${target.runtime}): ${sent.error ?? 'unknown error'}`)
916
- }
917
- return ok(undefined)
918
- }
919
- for (const keys of plan.sequence) {
920
- const r = tmux(target.socketPath, 'send-keys', '-t', target.address, ...keys)
921
- if (!r.ok) return err(`tmux send-keys failed for control "${command.name}": ${r.err.trim() || 'unknown error'}`)
922
- if (plan.stepDelayMs) sleepSync(plan.stepDelayMs)
567
+ // pty-only: translate each plan step to pty bytes with keysToBytes (REUSED from the boot-driver) and
568
+ // send them over the supervisor socket. UNCONDITIONAL semantics (no ready-gate, no submit-confirm) —
569
+ // the point of interrupt is to break a stuck turn.
570
+ const chunks = plan.sequence.map(keys => keysToBytes(keys))
571
+ const send = seam.sendControl ?? sendControlToHost
572
+ const sent = await send(hostRunDir(), target.address, chunks, { stepDelayMs: plan.stepDelayMs })
573
+ if (!sent.ok) {
574
+ return err(`hosted control "${command.name}" failed for "${target.personality}" (${target.runtime}): ${sent.error ?? 'unknown error'}`)
923
575
  }
924
576
  return ok(undefined)
925
577
  }
@@ -1139,38 +791,27 @@ function delay(ms: number): Promise<void> {
1139
791
  return new Promise(resolve => setTimeout(resolve, ms))
1140
792
  }
1141
793
 
1142
- function targetSessionToken(target: DeliveryTarget): string | null {
1143
- const r = tmux(target.socketPath, 'display-message', '-p', '-t', target.address, '#{session_id}:#{pane_id}')
1144
- if (!r.ok) return null
1145
- return r.out.trim() || null
1146
- }
1147
-
1148
- // ── Host-aware composer-queue predicates (spawn-flip Ф0b-3 slice 3c) ─────────────────────────────────
1149
- // Each dispatches on hostSessionAlive(address): a HOSTED target uses the supervisor signals, any other
1150
- // target falls through to the UNCHANGED tmux predicate (so the tmux fleet's observable queue behavior
1151
- // is byte-identical — only the call becomes awaitable).
794
+ // ── Composer-queue predicates (pty-only) ─────────────────────────────────────────────────────────
1152
795
 
1153
- /** Enqueue gate. HOSTED: hold ONLY when a HUMAN is attached (supervisor client) AND the composer model
1154
- * is occupied — the slice-2 anti-bug: with no operator attached the composer text is the AI's own
1155
- * output, never held. tmux: the unchanged shouldQueueForHumanComposer. */
796
+ /** Enqueue gate. Hold ONLY when a HUMAN is attached (supervisor client) AND the composer model is
797
+ * occupied — the anti-bug: with no operator attached the composer text is the AI's own output, never
798
+ * held. No pane-log dir / unreadable model → don't hold (deliver, never stall). */
1156
799
  async function shouldQueueForComposer(target: DeliveryTarget, logDir: string | undefined): Promise<boolean> {
1157
- if (!hostSessionAlive(target.address)) return shouldQueueForHumanComposer(target)
1158
800
  if (!isSupportedLocalRuntime(target.runtime) || !hasAttachedSupervisorClient(target.address)) return false
1159
801
  if (!logDir) return false // no pane-log dir → cannot read occupancy → don't hold (deliver, never stall)
1160
802
  return paneLogComposerOccupied(`${logDir}/${target.address}.log`, hostGeometry(target.address).cols, hostGeometry(target.address).rows, target.runtime)
1161
803
  }
1162
804
 
1163
805
  /** Drain re-check (no attached-client prereq — once queued, abandoned input waits until cleared/ceiling).
1164
- * HOSTED: pane-log model occupancy. tmux: the unchanged targetComposerBlocksDelivery. */
806
+ * Pane-log model occupancy. */
1165
807
  async function composerStillBusy(target: DeliveryTarget, logDir: string | undefined): Promise<boolean> {
1166
- if (!hostSessionAlive(target.address)) return targetComposerBlocksDelivery(target)
1167
808
  if (!logDir) return false
1168
809
  return paneLogComposerOccupied(`${logDir}/${target.address}.log`, hostGeometry(target.address).cols, hostGeometry(target.address).rows, target.runtime)
1169
810
  }
1170
811
 
1171
- /** Session-replacement token. HOSTED: supervisor daemon pid; tmux: session_id:pane_id. */
812
+ /** Session-replacement token: the supervisor daemon pid. */
1172
813
  function composerSessionToken(target: DeliveryTarget): string | null {
1173
- return hostSessionAlive(target.address) ? hostSessionToken(target.address) : targetSessionToken(target)
814
+ return hostSessionToken(target.address)
1174
815
  }
1175
816
 
1176
817
  function queuedTargetStillSameSession(job: ComposerQueuedDelivery): boolean {
@@ -46,7 +46,6 @@ import {
46
46
  type DaemonRestartResult,
47
47
  type LaunchdJobCycleResult,
48
48
  } from '../launch/launchd.ts'
49
- import { cycleShadowJob, SHADOW_PERSONALITY, SHADOW_PLIST_LABEL } from '../shadow/install.ts'
50
49
  import { defaultDaemonSocketPath } from '../daemon/index.ts'
51
50
  // Type-only (erased at runtime → no import cycle): the cascade tail renders these
52
51
  // shapes but receives the component-updaters as injected deps.
@@ -252,14 +251,6 @@ export function recycleFoundationOwnedInfraJobs(env: NodeJS.ProcessEnv = process
252
251
  const r = cycleLaunchdJob(label, plist, env)
253
252
  out.push({ personality: peer.personality, runtime, label, state: r.state, detail: r.detail })
254
253
  }
255
- // The tmux→pty fidelity BURN-IN job (com.iapeer.shadow-fidelity) is foundation-owned and
256
- // runs the just-replaced `iapeer shadow` off the same binary, so it carries the same LWCR
257
- // constraint — but it is NOT a registry peer, so the scan above cannot reach it. Cycle it
258
- // by label too (no-op when absent / not-loaded / foreign).
259
- const shadow = cycleShadowJob(env)
260
- if (shadow) {
261
- out.push({ personality: SHADOW_PERSONALITY, runtime: 'foundation', label: SHADOW_PLIST_LABEL, state: shadow.state, detail: shadow.detail })
262
- }
263
254
  return out
264
255
  }
265
256