@agfpd/iapeer 0.2.52 → 0.2.53

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.2.52",
3
+ "version": "0.2.53",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -838,6 +838,7 @@ export async function sendMessage(
838
838
  via: result.ok ? `${result.value.delivered_to.runtime}-${result.value.delivered_to.personality}` : undefined,
839
839
  woke: result.ok ? String(result.value.woke) : undefined,
840
840
  queued: result.ok && result.value.queued ? 'true' : undefined,
841
+ qkind: result.ok ? result.value.queuedBy : undefined,
841
842
  qd: result.ok ? result.value.queueDepth : undefined,
842
843
  ms: Date.now() - t0,
843
844
  len: opts.message.length,
@@ -197,6 +197,7 @@ describe('ephemeral serial-queue seam (M3)', () => {
197
197
  delivered_to: { personality: peer.personality, runtime: 'claude' },
198
198
  woke: false,
199
199
  queued: true,
200
+ queuedBy: 'ephemeral',
200
201
  queueDepth: 2,
201
202
  ts: 'now',
202
203
  },
@@ -209,6 +210,7 @@ describe('ephemeral serial-queue seam (M3)', () => {
209
210
  expect(delivered).toEqual(['checked:/tmp/offlinepeer', 'deliver:offlinepeer:job-1'])
210
211
  const line = readFileSync(join(logDir, 'delivery.log'), 'utf8').trim()
211
212
  expect(line).toContain('queued=true')
213
+ expect(line).toContain('qkind=ephemeral')
212
214
  expect(line).toContain('qd=2')
213
215
  expect(line).toContain('ok=true')
214
216
  })
@@ -43,7 +43,7 @@ import { IapError } from '../core/errors.ts'
43
43
  import { pluginStateDir, writeFileAtomic, type StorageOptions } from '../storage/index.ts'
44
44
  import { publicPeerSummary, readPeersIndex, type PeersIndex } from '../registry/index.ts'
45
45
  import { resolveCallerIdentity, type CallerIdentity, type ResolvedCaller } from '../identity/index.ts'
46
- import { routeSend, type EphemeralRouteDeps, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
46
+ import { routeSend, type ComposerQueueRouteDeps, type EphemeralRouteDeps, type SendToPeerInput, type WakeFn } from '../transport/index.ts'
47
47
  import { appendDeliveryEvent } from './deliverylog.ts'
48
48
 
49
49
  export const CALLER_HEADER = 'x-iapeer-identity'
@@ -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
+ /** Busy-human-composer queue — see transport.createComposerDeliveryQueue. */
196
+ composerQueue?: ComposerQueueRouteDeps
195
197
  }
196
198
 
197
199
  export async function callTool(
@@ -209,7 +211,12 @@ export async function callTool(
209
211
  attachments: Array.isArray(args.attachments) ? (args.attachments as string[]) : undefined,
210
212
  }
211
213
  const t0 = Date.now()
212
- const sent = await routeSend(caller, input, { wake: deps.wake, ephemeral: deps.ephemeral, noteLiveTopic: deps.noteLiveTopic })
214
+ const sent = await routeSend(caller, input, {
215
+ wake: deps.wake,
216
+ ephemeral: deps.ephemeral,
217
+ noteLiveTopic: deps.noteLiveTopic,
218
+ composerQueue: deps.composerQueue,
219
+ })
213
220
  // Ф-#8a: ONE durable outcome line per delivery attempt (delivery.log, sibling
214
221
  // to lifecycle.log) — metadata only, never the body. Both branches of the
215
222
  // routeSend result are recorded, so a suspected loss is reconstructable from
@@ -226,6 +233,7 @@ export async function callTool(
226
233
  // M3 queue observability (boris acceptance (a)): a queued accept is marked
227
234
  // and carries the queue depth at accept time — a stuck queue is visible.
228
235
  queued: sent.ok && sent.value.queued ? 'true' : undefined,
236
+ qkind: sent.ok ? sent.value.queuedBy : undefined,
229
237
  qd: sent.ok ? sent.value.queueDepth : undefined,
230
238
  ms: Date.now() - t0,
231
239
  len: input.message.length,
@@ -237,7 +245,7 @@ export async function callTool(
237
245
  // reply is NOT out, and arming then could quiet-reap a worker mid-retry (the
238
246
  // ordinary idle bound covers that case instead). Fail-safe: hook errors are
239
247
  // swallowed, the delivery result stands.
240
- if (sent.ok) {
248
+ if (sent.ok && sent.value.queuedBy !== 'composer') {
241
249
  try {
242
250
  deps.onDelivered?.(caller)
243
251
  } catch {
@@ -377,6 +385,13 @@ export interface StartDaemonOptions extends StorageOptions {
377
385
  * main wires the lifecycle marker write (makeNoteLiveTopic).
378
386
  */
379
387
  noteLiveTopic?: (identity: string, topic: string) => void
388
+ /**
389
+ * Busy-human-composer queue — async acceptance for live local TUI delivery when
390
+ * an attached operator has non-dim text in the target composer. OFF by default
391
+ * for library/tests; production main wires an in-memory queue that fail-notifies
392
+ * senders on shutdown/restart.
393
+ */
394
+ composerQueue?: ComposerQueueRouteDeps
380
395
  /**
381
396
  * Write the discovery file (router.json) at <root>/state/iapeer/router.json with
382
397
  * the active addresses `{sock, tcp}` — atomically on listen, removed on close.
@@ -410,6 +425,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
410
425
  onDelivered: opts.onDelivered,
411
426
  ephemeral: opts.ephemeral,
412
427
  noteLiveTopic: opts.noteLiveTopic,
428
+ composerQueue: opts.composerQueue,
413
429
  })
414
430
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
415
431
  res.on('close', () => {
@@ -507,8 +523,9 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
507
523
  host: tcpEnabled ? host : undefined,
508
524
  port,
509
525
  url,
510
- close: () =>
511
- new Promise<void>(resolve => {
526
+ close: async () => {
527
+ await opts.composerQueue?.failAll?.('daemon shutting down/restarting before queued delivery completed')
528
+ await new Promise<void>(resolve => {
512
529
  if (supervisor) clearInterval(supervisor)
513
530
  for (const s of servers) s.closeAllConnections?.() // drop lingering keep-alive/SSE conns
514
531
  let pending = servers.length
@@ -525,6 +542,7 @@ export async function startDaemon(opts: StartDaemonOptions = {}): Promise<Daemon
525
542
  }
526
543
  })
527
544
  }
528
- }),
545
+ })
546
+ },
529
547
  }
530
548
  }
@@ -232,6 +232,24 @@ describe('dual-listen + router.json discovery', () => {
232
232
  })
233
233
  })
234
234
 
235
+ describe('startDaemon composerQueue shutdown seam', () => {
236
+ test('close() fails pending busy-composer queue before tearing listeners down', async () => {
237
+ const reasons: string[] = []
238
+ const h = await startDaemon({
239
+ port: 0,
240
+ host: '127.0.0.1',
241
+ composerQueue: {
242
+ tryEnqueue: async () => null,
243
+ failAll: async reason => {
244
+ reasons.push(reason)
245
+ },
246
+ },
247
+ })
248
+ await h.close()
249
+ expect(reasons).toEqual(['daemon shutting down/restarting before queued delivery completed'])
250
+ })
251
+ })
252
+
235
253
  // ─────────────────────────────────────────────────────────────────────────────
236
254
  // wake_policy:ephemeral M2 — makeArmEphemeralOnDelivered (the arm-on-outbound
237
255
  // composition: ephemeral caller's ok send ⇒ .ephemeral-armed for its identity)
@@ -307,6 +325,7 @@ describe('makeEphemeralRouteDeps (M3 serial-queue composition)', () => {
307
325
  const r2 = await deps.deliver({ peer: workerPeer, envelope: '<iap>task two</iap>' })
308
326
 
309
327
  expect(r1.ok && r1.value.queued).toBe(true)
328
+ expect(r1.ok && r1.value.queuedBy).toBe('ephemeral')
310
329
  expect(r1.ok && r1.value.queueDepth).toBe(1)
311
330
  expect(r1.ok && r1.value.woke).toBe(false)
312
331
  expect(r2.ok && r2.value.queueDepth).toBe(2)
@@ -46,9 +46,21 @@ import {
46
46
  import { appendLifecycleEvent } from '../lifecycle/eventlog.ts'
47
47
  import { buildProcessAddress } from '../core/socket.ts'
48
48
  import { err, ok } from '../core/errors.ts'
49
+ import { resolveCallerIdentity } from '../identity/index.ts'
49
50
  import type { ResolvedCaller } from '../identity/index.ts'
50
- import type { EphemeralRouteDeps, WakeFn, WakeOutcome, WakeRequest } from '../transport/index.ts'
51
+ import { findPeer, readPeersIndex } from '../registry/index.ts'
52
+ import {
53
+ createComposerDeliveryQueue,
54
+ routeSend,
55
+ type ComposerQueueRouteDeps,
56
+ type ComposerQueuedDelivery,
57
+ type EphemeralRouteDeps,
58
+ type WakeFn,
59
+ type WakeOutcome,
60
+ type WakeRequest,
61
+ } from '../transport/index.ts'
51
62
  import { iapeerBinPath } from '../install/index.ts'
63
+ import { appendDeliveryEvent } from './deliverylog.ts'
52
64
  import { defaultDaemonSocketPath, startDaemon, type DaemonHandle } from './index.ts'
53
65
 
54
66
  /** Default TCP loopback port for the always-on router. Real http MCP clients
@@ -168,6 +180,7 @@ export function makeEphemeralRouteDeps(
168
180
  delivered_to: { personality: peer.personality, runtime: rt.value },
169
181
  woke: false,
170
182
  queued: true,
183
+ queuedBy: 'ephemeral',
171
184
  queueDepth: depth,
172
185
  ts: new Date().toISOString(),
173
186
  })
@@ -175,6 +188,80 @@ export function makeEphemeralRouteDeps(
175
188
  }
176
189
  }
177
190
 
191
+ function queuedFailureMessage(job: ComposerQueuedDelivery, reason: string): string {
192
+ const topic = job.topic ? ` topic="${job.topic}"` : ''
193
+ return (
194
+ `delivery failed: queued message to ${job.target.personality} (${job.target.runtime})${topic} was not delivered.\n` +
195
+ `reason: ${reason}\n` +
196
+ 'The original send was accepted as queued because a human operator was typing in the target composer; retry if still needed.'
197
+ )
198
+ }
199
+
200
+ /**
201
+ * Busy-human-composer queue composition. This is deliberately daemon-owned and
202
+ * in-memory: it exists only to bridge a short operator-editing window. Because a
203
+ * queued ack is not a delivery fact, the queue has two extra responsibilities:
204
+ * 1) after actual drain success, run the same post-delivery hooks as the
205
+ * synchronous path (topic marker + ephemeral caller arm);
206
+ * 2) before daemon shutdown/restart, fail every pending queued envelope back
207
+ * to its sender, so `queued` never becomes silent loss.
208
+ */
209
+ export function makeComposerQueueRouteDeps(cfg: LifecycleConfig, env: NodeJS.ProcessEnv): ComposerQueueRouteDeps {
210
+ const noteLiveTopic = makeNoteLiveTopic(cfg, env)
211
+ const onDelivered = makeArmEphemeralOnDelivered(cfg)
212
+ const wake = makeWakeFn(cfg, env)
213
+ const ephemeral = makeEphemeralRouteDeps(cfg, env)
214
+
215
+ return createComposerDeliveryQueue({
216
+ env,
217
+ noteLiveTopic,
218
+ onDelivered,
219
+ notifyFailed: async (job, reason) => {
220
+ const index = readPeersIndex({ env })
221
+ const self = findPeer(index, 'iapeer')
222
+ if (!self) {
223
+ appendDeliveryEvent(cfg.eventLogDir, {
224
+ ev: 'composer-queue-failed-notify',
225
+ caller: 'iapeer',
226
+ to: job.caller.personality,
227
+ rt: job.caller.runtime,
228
+ ok: 'false',
229
+ err: 'foundation peer "iapeer" is not registered; cannot notify queued sender',
230
+ })
231
+ return
232
+ }
233
+ const sender = resolveCallerIdentity({ personality: 'iapeer', runtime: self.runtime }, index)
234
+ const result = await routeSend(
235
+ sender,
236
+ {
237
+ personality: job.caller.personality,
238
+ runtime: job.caller.runtime,
239
+ message: queuedFailureMessage(job, reason),
240
+ topic: job.topic ?? 'delivery-failed',
241
+ },
242
+ // Do not pass composerQueue here: this path is the queue's failure outlet
243
+ // (including SIGTERM/update). Recursively queueing a failure notification
244
+ // could turn shutdown re-fail into another queued item.
245
+ { wake, ephemeral, noteLiveTopic },
246
+ )
247
+ appendDeliveryEvent(cfg.eventLogDir, {
248
+ ev: 'composer-queue-failed-notify',
249
+ caller: sender.address,
250
+ to: job.caller.personality,
251
+ rt: job.caller.runtime,
252
+ ok: String(result.ok),
253
+ via: result.ok ? `${result.value.delivered_to.runtime}-${result.value.delivered_to.personality}` : undefined,
254
+ woke: result.ok ? String(result.value.woke) : undefined,
255
+ queued: result.ok && result.value.queued ? 'true' : undefined,
256
+ qkind: result.ok ? result.value.queuedBy : undefined,
257
+ qd: result.ok ? result.value.queueDepth : undefined,
258
+ topic: job.topic ?? 'delivery-failed',
259
+ err: result.ok ? undefined : result.error.message,
260
+ })
261
+ },
262
+ })
263
+ }
264
+
178
265
  export interface ConfiguredDaemonOptions {
179
266
  /** TCP loopback port (default DEFAULT_DAEMON_PORT). */
180
267
  port?: number
@@ -202,18 +289,25 @@ export async function startConfiguredDaemon(opts: ConfiguredDaemonOptions = {}):
202
289
  const env = opts.env ?? process.env
203
290
  const cfg = loadLifecycleConfig(env)
204
291
  const bearerToken = opts.bearerToken ?? (env.IAPEER_BEARER_TOKEN?.trim() || undefined)
292
+ const noteLiveTopic = makeNoteLiveTopic(cfg, env)
293
+ const armEphemeralOnDelivered = makeArmEphemeralOnDelivered(cfg)
294
+ const ephemeral = makeEphemeralRouteDeps(cfg, env)
295
+ const composerQueue = makeComposerQueueRouteDeps(cfg, env)
205
296
  return startDaemon({
206
297
  wake: makeWakeFn(cfg, env),
207
298
  // M2 arm-on-outbound (see makeArmEphemeralOnDelivered): ephemeral caller's ok
208
299
  // send ⇒ armed for the supervise quiet-reap. No-op for every other caller.
209
- onDelivered: makeArmEphemeralOnDelivered(cfg),
300
+ onDelivered: armEphemeralOnDelivered,
210
301
  // M3 serial queue (see makeEphemeralRouteDeps): ephemeral TARGET ⇒ enqueue +
211
302
  // async drain. No-op for every other target.
212
- ephemeral: makeEphemeralRouteDeps(cfg, env),
303
+ ephemeral,
213
304
  // Live-delivered topic → the target's .topic marker (see makeNoteLiveTopic):
214
305
  // the wake resolver's executor discriminator then compares against the topic
215
306
  // the session last WORKED ON, not the one it woke with (defect 11.06).
216
- noteLiveTopic: makeNoteLiveTopic(cfg, env),
307
+ noteLiveTopic,
308
+ // Busy-human-composer queue: fast queued ack, async drain, fail all pending
309
+ // on daemon close/update so a queued envelope is never silently lost.
310
+ composerQueue,
217
311
  supervise: {
218
312
  intervalMs: opts.superviseIntervalMs ?? DEFAULT_SUPERVISE_INTERVAL_MS,
219
313
  // idle-reap / zombie-sweep, THEN the eager fresh re-launch for any peer whose
@@ -103,6 +103,7 @@ export const claudeAdapter: RuntimeAdapter = {
103
103
  deliveryMarkers: {
104
104
  promptGlyphs: ['❯'],
105
105
  pastePatterns: [/\[Pasted text/, /\[Image #/],
106
+ ghostTextSgr: ['2', '38;5;246'],
106
107
  },
107
108
 
108
109
  /**
@@ -167,6 +167,7 @@ export const codexAdapter: RuntimeAdapter = {
167
167
  deliveryMarkers: {
168
168
  promptGlyphs: ['›'],
169
169
  pastePatterns: [/\[Pasted text/, /\[Image #/],
170
+ ghostTextSgr: ['2', '38;5;246'],
170
171
  },
171
172
 
172
173
  /**
@@ -304,10 +304,12 @@ describe('deliveryMarkers (adapter-owned, was transport PROMPT_GLYPHS)', () => {
304
304
  test('claude: ❯ glyph + paste patterns', () => {
305
305
  expect(claudeAdapter.deliveryMarkers.promptGlyphs).toEqual(['❯'])
306
306
  expect(claudeAdapter.deliveryMarkers.pastePatterns?.some(re => re.test('[Pasted text +5 lines]'))).toBe(true)
307
+ expect(claudeAdapter.deliveryMarkers.ghostTextSgr).toEqual(['2', '38;5;246'])
307
308
  })
308
309
  test('codex: › glyph (not ❯) — per-runtime, no false cross-match', () => {
309
310
  expect(codexAdapter.deliveryMarkers.promptGlyphs).toEqual(['›'])
310
311
  expect(codexAdapter.deliveryMarkers.promptGlyphs).not.toContain('❯')
312
+ expect(codexAdapter.deliveryMarkers.ghostTextSgr).toEqual(['2', '38;5;246'])
311
313
  })
312
314
  test('routers have no submit surface → empty glyphs', () => {
313
315
  expect(telegramAdapter.deliveryMarkers.promptGlyphs).toEqual([])
@@ -180,6 +180,14 @@ export interface DeliveryMarkers {
180
180
  * (claude '[Pasted text' / '[Image #'). Optional — when absent the tail-marker is
181
181
  * the sole landed-signal. */
182
182
  pastePatterns?: RegExp[]
183
+ /** SGR parameter sequences that the runtime uses for composer ghost/placeholder
184
+ * text (non-human input). The busy-composer delivery queue captures the pane
185
+ * with ANSI escapes and treats visible text after the prompt glyph as HUMAN only
186
+ * when at least one non-whitespace character is NOT under these dim/grey SGR
187
+ * attributes. Runtime-owned for the same reason as promptGlyphs: color is TUI
188
+ * surface, not transport policy. Examples observed on live panes:
189
+ * `2` (faint/dim) and `38;5;246` (xterm 256-color grey). */
190
+ ghostTextSgr?: string[]
183
191
  }
184
192
 
185
193
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,174 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ok } from '../core/errors.ts'
3
+ import { getAdapter } from '../launch/index.ts'
4
+ import type { PeerRecord } from '../registry/index.ts'
5
+ import type { ResolvedCaller } from '../identity/index.ts'
6
+ import {
7
+ composerCaptureHasHumanInput,
8
+ createComposerDeliveryQueue,
9
+ type ComposerQueueTryEnqueueArgs,
10
+ type DeliveryTarget,
11
+ } from './index.ts'
12
+
13
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
14
+
15
+ describe('composerCaptureHasHumanInput (SGR color gate)', () => {
16
+ test('codex: default-fg text after › is human input; faint/grey ghost text is not', () => {
17
+ const markers = getAdapter('codex').deliveryMarkers
18
+ expect(composerCaptureHasHumanInput('\x1b[32m›\x1b[0m hello', markers)).toBe(true)
19
+ expect(composerCaptureHasHumanInput('\x1b[32m›\x1b[0m \x1b[2mghost suggestion\x1b[0m', markers)).toBe(false)
20
+ expect(composerCaptureHasHumanInput('\x1b[32m›\x1b[0m \x1b[38;5;246mplaceholder\x1b[0m', markers)).toBe(false)
21
+ // A suggestion accepted into the composer is no longer grey/faint; it must be held.
22
+ expect(composerCaptureHasHumanInput('\x1b[32m›\x1b[0m accepted suggestion', markers)).toBe(true)
23
+ })
24
+
25
+ test('claude: same color rule is runtime-owned behind the ❯ prompt glyph', () => {
26
+ const markers = getAdapter('claude').deliveryMarkers
27
+ expect(composerCaptureHasHumanInput('\x1b[35m❯\x1b[0m human', markers)).toBe(true)
28
+ expect(composerCaptureHasHumanInput('\x1b[35m❯\x1b[0m \x1b[2mghost\x1b[0m', markers)).toBe(false)
29
+ expect(composerCaptureHasHumanInput('\x1b[35m❯\x1b[0m \x1b[38;5;246mplaceholder\x1b[0m', markers)).toBe(false)
30
+ })
31
+ })
32
+
33
+ const callerRecord = {
34
+ personality: 'sender',
35
+ runtime: 'codex',
36
+ runtimes: ['codex'],
37
+ description: '',
38
+ intelligence: 'artificial',
39
+ cwd: '/tmp/sender',
40
+ } as unknown as PeerRecord
41
+ const caller: ResolvedCaller = {
42
+ personality: 'sender',
43
+ runtime: 'codex',
44
+ address: 'codex-sender',
45
+ description: '',
46
+ intelligence: 'artificial',
47
+ cwd: '/tmp/sender',
48
+ record: callerRecord,
49
+ }
50
+ const peer = {
51
+ personality: 'target',
52
+ runtime: 'codex',
53
+ runtimes: ['codex'],
54
+ description: '',
55
+ intelligence: 'artificial',
56
+ cwd: '/tmp/target',
57
+ } as unknown as PeerRecord
58
+ const target: DeliveryTarget = {
59
+ personality: 'target',
60
+ runtime: 'codex',
61
+ address: 'codex-target',
62
+ socketPath: '/tmp/no-such-sock',
63
+ }
64
+ function args(envelope = '<iap>hello</iap>'): ComposerQueueTryEnqueueArgs {
65
+ return { caller, peer, target, envelope, topic: 'q-test' }
66
+ }
67
+
68
+ describe('createComposerDeliveryQueue', () => {
69
+ test('queued ack is immediate; drain waits until composer clears, then delivers silently', async () => {
70
+ let held = true
71
+ const delivered: string[] = []
72
+ const failed: string[] = []
73
+ const q = createComposerDeliveryQueue({
74
+ pollMs: 5,
75
+ forceTimeoutMs: 1000,
76
+ shouldQueue: () => true,
77
+ hasHumanInput: () => held,
78
+ sessionToken: () => 'session-1:pane-1',
79
+ sessionAlive: () => true,
80
+ deliver: (_target, envelope) => {
81
+ delivered.push(envelope)
82
+ return ok(undefined)
83
+ },
84
+ notifyFailed: (_job, reason) => { failed.push(reason) },
85
+ })
86
+
87
+ const accepted = await q.tryEnqueue(args('first'))
88
+ expect(accepted?.ok).toBe(true)
89
+ if (!accepted?.ok) return
90
+ expect(accepted.value).toMatchObject({ queued: true, queuedBy: 'composer', queueDepth: 1 })
91
+ await sleep(20)
92
+ expect(delivered).toEqual([])
93
+ held = false
94
+ await sleep(30)
95
+ expect(delivered).toEqual(['first'])
96
+ expect(failed).toEqual([])
97
+ })
98
+
99
+ test('120s ceiling (env-tunable in tests) force-delivers instead of waiting forever', async () => {
100
+ const delivered: string[] = []
101
+ const failed: string[] = []
102
+ const q = createComposerDeliveryQueue({
103
+ pollMs: 5,
104
+ forceTimeoutMs: 20,
105
+ shouldQueue: () => true,
106
+ hasHumanInput: () => true,
107
+ sessionToken: () => 'session-1:pane-1',
108
+ sessionAlive: () => true,
109
+ deliver: (_target, envelope) => {
110
+ delivered.push(envelope)
111
+ return ok(undefined)
112
+ },
113
+ notifyFailed: (_job, reason) => { failed.push(reason) },
114
+ })
115
+
116
+ await q.tryEnqueue(args('force-me'))
117
+ await sleep(70)
118
+ expect(delivered).toEqual(['force-me'])
119
+ expect(failed).toEqual([])
120
+ })
121
+
122
+ test('target death/session replacement fails the queued sender instead of delivering to a corpse/new session', async () => {
123
+ let alive = true
124
+ const delivered: string[] = []
125
+ const failed: string[] = []
126
+ const q = createComposerDeliveryQueue({
127
+ pollMs: 5,
128
+ forceTimeoutMs: 1000,
129
+ shouldQueue: () => true,
130
+ hasHumanInput: () => true,
131
+ sessionToken: () => 'session-1:pane-1',
132
+ sessionAlive: () => alive,
133
+ deliver: (_target, envelope) => {
134
+ delivered.push(envelope)
135
+ return ok(undefined)
136
+ },
137
+ notifyFailed: (_job, reason) => { failed.push(reason) },
138
+ })
139
+
140
+ await q.tryEnqueue(args('must-fail'))
141
+ alive = false
142
+ await sleep(30)
143
+ expect(delivered).toEqual([])
144
+ expect(failed.join('\n')).toMatch(/vanished|replaced/)
145
+ })
146
+
147
+ test('daemon close/restart failAll re-fails every queued-but-undelivered envelope', async () => {
148
+ const delivered: string[] = []
149
+ const failed: string[] = []
150
+ const q = createComposerDeliveryQueue({
151
+ pollMs: 50,
152
+ forceTimeoutMs: 1000,
153
+ shouldQueue: () => true,
154
+ hasHumanInput: () => true,
155
+ sessionToken: () => 'session-1:pane-1',
156
+ sessionAlive: () => true,
157
+ deliver: (_target, envelope) => {
158
+ delivered.push(envelope)
159
+ return ok(undefined)
160
+ },
161
+ notifyFailed: (_job, reason) => { failed.push(reason) },
162
+ })
163
+
164
+ await q.tryEnqueue(args('queued-a'))
165
+ await q.tryEnqueue(args('queued-b'))
166
+ await q.failAll?.('daemon shutting down/restarting before queued delivery completed')
167
+ await sleep(10)
168
+ expect(delivered).toEqual([])
169
+ expect(failed).toEqual([
170
+ 'daemon shutting down/restarting before queued delivery completed',
171
+ 'daemon shutting down/restarting before queued delivery completed',
172
+ ])
173
+ })
174
+ })
@@ -99,6 +99,168 @@ function sessionAlive(sock: string, address: string): boolean {
99
99
  return listSessions(sock).includes(address)
100
100
  }
101
101
 
102
+ // ─── Busy-composer detection (human input vs runtime ghost text) ─────────────
103
+
104
+ interface SgrState {
105
+ faint: boolean
106
+ fg256: number | null
107
+ fgBasic: number | null
108
+ }
109
+
110
+ interface GhostSgrConfig {
111
+ faint: boolean
112
+ fg256: Set<number>
113
+ fgBasic: Set<number>
114
+ }
115
+
116
+ interface PaneToken {
117
+ ch: string
118
+ ghost: boolean
119
+ }
120
+
121
+ function parseSgrCodeList(raw: string): number[] | null {
122
+ if (raw.trim() === '') return [0]
123
+ const out = raw.split(';').map(part => (part === '' ? 0 : Number(part)))
124
+ return out.every(Number.isFinite) ? out : null
125
+ }
126
+
127
+ function ghostSgrConfig(markers: DeliveryMarkers): GhostSgrConfig {
128
+ const cfg: GhostSgrConfig = { faint: false, fg256: new Set(), fgBasic: new Set() }
129
+ for (const raw of markers.ghostTextSgr ?? []) {
130
+ const codes = parseSgrCodeList(raw)
131
+ if (!codes) continue
132
+ if (codes.length === 1 && codes[0] === 2) {
133
+ cfg.faint = true
134
+ continue
135
+ }
136
+ if (codes.length === 1 && ((codes[0] >= 30 && codes[0] <= 37) || (codes[0] >= 90 && codes[0] <= 97))) {
137
+ cfg.fgBasic.add(codes[0])
138
+ continue
139
+ }
140
+ if (codes.length === 3 && codes[0] === 38 && codes[1] === 5) cfg.fg256.add(codes[2])
141
+ }
142
+ return cfg
143
+ }
144
+
145
+ function applySgr(state: SgrState, codes: number[]): void {
146
+ let i = 0
147
+ while (i < codes.length) {
148
+ const code = codes[i]
149
+ if (code === 0) {
150
+ state.faint = false
151
+ state.fg256 = null
152
+ state.fgBasic = null
153
+ } else if (code === 2) {
154
+ state.faint = true
155
+ } else if (code === 22) {
156
+ state.faint = false
157
+ } else if (code === 39) {
158
+ state.fg256 = null
159
+ state.fgBasic = null
160
+ } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
161
+ state.fgBasic = code
162
+ state.fg256 = null
163
+ } else if (code === 38 && codes[i + 1] === 5 && Number.isFinite(codes[i + 2])) {
164
+ state.fg256 = codes[i + 2]
165
+ state.fgBasic = null
166
+ i += 2
167
+ } else if (code === 38 && codes[i + 1] === 2) {
168
+ // Truecolor foreground: not one of the calibrated grey-256 markers. Track
169
+ // it as "not ghost" by clearing the 256/basic foreground slots.
170
+ state.fg256 = null
171
+ state.fgBasic = null
172
+ i += 4
173
+ }
174
+ i += 1
175
+ }
176
+ }
177
+
178
+ function stateIsGhost(state: SgrState, cfg: GhostSgrConfig): boolean {
179
+ if (cfg.faint && state.faint) return true
180
+ if (state.fg256 !== null && cfg.fg256.has(state.fg256)) return true
181
+ if (state.fgBasic !== null && cfg.fgBasic.has(state.fgBasic)) return true
182
+ return false
183
+ }
184
+
185
+ function parseAnsiLine(line: string, cfg: GhostSgrConfig): PaneToken[] {
186
+ const tokens: PaneToken[] = []
187
+ const state: SgrState = { faint: false, fg256: null, fgBasic: null }
188
+ let i = 0
189
+ while (i < line.length) {
190
+ if (line.charCodeAt(i) === 0x1b && line[i + 1] === '[') {
191
+ let end = i + 2
192
+ while (end < line.length && !/[A-Za-z]/.test(line[end])) end += 1
193
+ if (end < line.length) {
194
+ if (line[end] === 'm') {
195
+ const codes = parseSgrCodeList(line.slice(i + 2, end))
196
+ if (codes) applySgr(state, codes)
197
+ }
198
+ i = end + 1
199
+ continue
200
+ }
201
+ }
202
+ const ch = Array.from(line.slice(i))[0] ?? ''
203
+ if (!ch) break
204
+ tokens.push({ ch, ghost: stateIsGhost(state, cfg) })
205
+ i += ch.length
206
+ }
207
+ return tokens
208
+ }
209
+
210
+ /** True iff a captured TUI pane shows non-dim human text in the composer row.
211
+ * Grey/faint ghost suggestions and placeholders are intentionally NOT human
212
+ * input: holding IAP delivery behind them would be a false busy state. */
213
+ export function composerCaptureHasHumanInput(pane: string, markers: DeliveryMarkers): boolean {
214
+ if (markers.promptGlyphs.length === 0) return false
215
+ const cfg = ghostSgrConfig(markers)
216
+ let promptTokens: PaneToken[] | null = null
217
+ let promptIndex = -1
218
+
219
+ for (const line of pane.split(/\r?\n/)) {
220
+ const tokens = parseAnsiLine(line, cfg)
221
+ const firstVisible = tokens.findIndex(tok => tok.ch.trim() !== '')
222
+ if (firstVisible < 0) continue
223
+ if (!markers.promptGlyphs.includes(tokens[firstVisible].ch)) continue
224
+ promptTokens = tokens
225
+ promptIndex = firstVisible
226
+ }
227
+
228
+ if (!promptTokens || promptIndex < 0) return false
229
+ for (const tok of promptTokens.slice(promptIndex + 1)) {
230
+ if (tok.ch.trim() === '') continue
231
+ if (!tok.ghost) return true
232
+ }
233
+ return false
234
+ }
235
+
236
+ export function hasAttachedTmuxClient(target: DeliveryTarget): boolean {
237
+ const r = tmux(target.socketPath, 'list-clients', '-F', '#{client_session}')
238
+ if (!r.ok) return false
239
+ return r.out.split(/\r?\n/).map(line => line.trim()).includes(target.address)
240
+ }
241
+
242
+ export function targetComposerHasHumanInput(target: DeliveryTarget): boolean {
243
+ if (!isSupportedLocalRuntime(target.runtime)) return false
244
+ const cap = tmux(target.socketPath, 'capture-pane', '-e', '-p', '-t', target.address)
245
+ if (!cap.ok) return false
246
+ return composerCaptureHasHumanInput(cap.out, getAdapter(target.runtime).deliveryMarkers)
247
+ }
248
+
249
+ function targetComposerBlocksDelivery(target: DeliveryTarget): boolean {
250
+ if (!isSupportedLocalRuntime(target.runtime)) return false
251
+ const cap = tmux(target.socketPath, 'capture-pane', '-e', '-p', '-t', target.address)
252
+ // With a live resolved target, a capture failure is an uncertainty in the
253
+ // dangerous direction: prefer a bounded false-busy queue/force over a false-free
254
+ // paste into a human composer. If the session is actually gone, the queue's
255
+ // session-token/identity check fails loudly.
256
+ if (!cap.ok) return true
257
+ return composerCaptureHasHumanInput(cap.out, getAdapter(target.runtime).deliveryMarkers)
258
+ }
259
+
260
+ export function shouldQueueForHumanComposer(target: DeliveryTarget): boolean {
261
+ return isSupportedLocalRuntime(target.runtime) && hasAttachedTmuxClient(target) && targetComposerBlocksDelivery(target)
262
+ }
263
+
102
264
  /** Is the (runtime,personality) endpoint live right now? Public liveness predicate. */
103
265
  export function isPeerLive(runtime: string, personality: string, sockDir = resolveSockDir()): boolean {
104
266
  const socketPath = buildSocketPath(runtime, personality, sockDir)
@@ -608,6 +770,11 @@ export interface RouteResult {
608
770
  * substantive result arrives as the worker's own reply (FaaS semantics);
609
771
  * `delivered_to` names the ACCEPTING queue identity, not a live session. */
610
772
  queued?: boolean
773
+ /** Which async queue accepted the delivery. `ephemeral` is the durable worker
774
+ * task queue; `composer` is the daemon's in-memory busy-human-composer queue.
775
+ * Both surface as `queued:true`, but only `composer` means "not yet delivered
776
+ * and may still fail with an explicit sender notification". */
777
+ queuedBy?: 'ephemeral' | 'composer'
611
778
  /** Queue depth right after the enqueue (observability; only with queued). */
612
779
  queueDepth?: number
613
780
  }
@@ -659,11 +826,44 @@ export interface EphemeralRouteDeps {
659
826
  }) => Promise<Result<RouteResult>>
660
827
  }
661
828
 
829
+ export interface ComposerQueuedDelivery {
830
+ id: number
831
+ caller: ResolvedCaller
832
+ peer: PeerRecord
833
+ target: DeliveryTarget
834
+ envelope: string
835
+ topic?: string
836
+ enqueuedAtMs: number
837
+ sessionToken: string
838
+ }
839
+
840
+ export interface ComposerQueueTryEnqueueArgs {
841
+ caller: ResolvedCaller
842
+ peer: PeerRecord
843
+ target: DeliveryTarget
844
+ envelope: string
845
+ topic?: string
846
+ }
847
+
848
+ export interface ComposerQueueRouteDeps {
849
+ /** Return null when the live delivery should proceed synchronously; return a
850
+ * RouteResult when the envelope was accepted by the composer queue. */
851
+ tryEnqueue: (args: ComposerQueueTryEnqueueArgs) => Promise<Result<RouteResult> | null>
852
+ /** Fail every queued-but-not-yet-delivered envelope, used by daemon shutdown /
853
+ * restart so `queued` never degrades into silent loss. */
854
+ failAll?: (reason: string) => Promise<void>
855
+ }
856
+
662
857
  export interface RouteDeps {
663
858
  /** On a miss, wake the dead peer instead of returning offline (Ф2). */
664
859
  wake?: WakeFn
665
860
  /** Ephemeral-target serial-queue delivery (M3); absent → normal routing. */
666
861
  ephemeral?: EphemeralRouteDeps
862
+ /** Busy-human-composer queue. When a LIVE local TUI target has an attached
863
+ * operator and non-dim text in its composer, routeSend returns a fast
864
+ * `{queued:true, queuedBy:"composer"}` ack and the daemon drains the message
865
+ * later instead of blindly paste+Enter-ing into the human's unfinished input. */
866
+ composerQueue?: ComposerQueueRouteDeps
667
867
  /** Fired after a successful LIVE delivery whose envelope carried a non-empty
668
868
  * topic — the hit path AND the post-wake fast-path redelivery, NOT the boot
669
869
  * first-message (the wake itself records its topic). The fresh-vs-resume seam:
@@ -689,6 +889,177 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
689
889
  }
690
890
  }
691
891
 
892
+ export interface ComposerDeliveryQueueOptions {
893
+ env?: NodeJS.ProcessEnv
894
+ pollMs?: number
895
+ forceTimeoutMs?: number
896
+ /** Test seam / alternate detector. Production default is the full gate:
897
+ * local TUI runtime + attached tmux client + non-dim composer text. */
898
+ shouldQueue?: (args: ComposerQueueTryEnqueueArgs) => boolean
899
+ /** Test seam for the drain phase. Production default re-reads the pane color
900
+ * state (WITHOUT the attached-client prerequisite): once queued, abandoned
901
+ * human text waits until cleared or until the 120s force ceiling. */
902
+ hasHumanInput?: (target: DeliveryTarget) => boolean
903
+ /** Test seam; production default compares tmux session/pane identity captured
904
+ * at enqueue time, so `/new`/death/restart fails the old queued envelope rather
905
+ * than delivering it into a fresh session with the same name. */
906
+ sessionToken?: (target: DeliveryTarget) => string | null
907
+ sessionAlive?: (job: ComposerQueuedDelivery) => boolean
908
+ deliver?: (target: DeliveryTarget, envelope: string, cwd?: string) => Result<void>
909
+ notifyFailed: (job: ComposerQueuedDelivery, reason: string) => void | Promise<void>
910
+ noteLiveTopic?: (identity: string, topic: string) => void
911
+ onDelivered?: (caller: ResolvedCaller) => void
912
+ }
913
+
914
+ function composerQueuePollMs(env: NodeJS.ProcessEnv | undefined): number {
915
+ const raw = env?.IAPEER_COMPOSER_QUEUE_POLL_MS
916
+ const n = raw === undefined ? NaN : Number(raw)
917
+ return Number.isFinite(n) && n > 0 ? n : 500
918
+ }
919
+
920
+ function composerQueueForceTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
921
+ const raw = env?.IAPEER_COMPOSER_QUEUE_TIMEOUT_MS
922
+ const n = raw === undefined ? NaN : Number(raw)
923
+ return Number.isFinite(n) && n >= 0 ? n : 120_000
924
+ }
925
+
926
+ function delay(ms: number): Promise<void> {
927
+ return new Promise(resolve => setTimeout(resolve, ms))
928
+ }
929
+
930
+ function targetSessionToken(target: DeliveryTarget): string | null {
931
+ const r = tmux(target.socketPath, 'display-message', '-p', '-t', target.address, '#{session_id}:#{pane_id}')
932
+ if (!r.ok) return null
933
+ return r.out.trim() || null
934
+ }
935
+
936
+ function queuedTargetStillSameSession(job: ComposerQueuedDelivery): boolean {
937
+ return targetSessionToken(job.target) === job.sessionToken
938
+ }
939
+
940
+ let nextComposerQueueId = 1
941
+
942
+ /**
943
+ * In-memory async delivery queue for the narrow "operator is typing in the target
944
+ * TUI composer" case. It prevents the daemon's paste+Enter from concatenating an
945
+ * IAP envelope with human text already present in the shared composer stdin.
946
+ *
947
+ * Contract:
948
+ * - enqueue only on live local TUI + attached tmux client + non-dim composer text;
949
+ * - sender gets fast `{queued:true, queuedBy:"composer"}`;
950
+ * - FIFO drain delivers with the same deliverViaTmux Ф-B confirmation once the
951
+ * composer clears;
952
+ * - after 120s (env-tunable) force-deliver rather than hold forever;
953
+ * - target death/session replacement or daemon shutdown emits `failed` to sender
954
+ * via the injected notifier.
955
+ */
956
+ export function createComposerDeliveryQueue(opts: ComposerDeliveryQueueOptions): ComposerQueueRouteDeps {
957
+ const env = opts.env ?? process.env
958
+ const pollMs = opts.pollMs ?? composerQueuePollMs(env)
959
+ const forceTimeoutMs = opts.forceTimeoutMs ?? composerQueueForceTimeoutMs(env)
960
+ const shouldQueue = opts.shouldQueue ?? ((args: ComposerQueueTryEnqueueArgs) => shouldQueueForHumanComposer(args.target))
961
+ const hasHumanInput = opts.hasHumanInput ?? targetComposerBlocksDelivery
962
+ const sessionToken = opts.sessionToken ?? targetSessionToken
963
+ const sessionAliveFn = opts.sessionAlive ?? queuedTargetStillSameSession
964
+ const deliver = opts.deliver ?? deliverViaTmux
965
+ const queues = new Map<string, ComposerQueuedDelivery[]>()
966
+ const draining = new Set<string>()
967
+ let closed = false
968
+
969
+ async function notifyFailed(job: ComposerQueuedDelivery, reason: string): Promise<void> {
970
+ try {
971
+ await opts.notifyFailed(job, reason)
972
+ } catch {
973
+ /* best-effort notification; delivery.log still has the queued accept */
974
+ }
975
+ }
976
+
977
+ function kick(identity: string): void {
978
+ if (draining.has(identity)) return
979
+ draining.add(identity)
980
+ void drain(identity).finally(() => draining.delete(identity))
981
+ }
982
+
983
+ async function drain(identity: string): Promise<void> {
984
+ for (;;) {
985
+ const q = queues.get(identity)
986
+ const job = q?.[0]
987
+ if (!q || !job) {
988
+ queues.delete(identity)
989
+ return
990
+ }
991
+
992
+ if (!sessionAliveFn(job)) {
993
+ q.shift()
994
+ await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: target session vanished or was replaced before delivery`)
995
+ continue
996
+ }
997
+
998
+ const ageMs = monotonicMs() - job.enqueuedAtMs
999
+ const force = ageMs >= forceTimeoutMs
1000
+ if (!force && hasHumanInput(job.target)) {
1001
+ await delay(pollMs)
1002
+ continue
1003
+ }
1004
+
1005
+ const delivered = deliver(job.target, job.envelope, job.peer.cwd)
1006
+ q.shift()
1007
+ if (!delivered.ok) {
1008
+ await notifyFailed(job, `queued delivery to ${job.target.personality} (${job.target.runtime}) failed: ${delivered.error.message}`)
1009
+ } else {
1010
+ noteTopic({ noteLiveTopic: opts.noteLiveTopic }, job.target.address, job.topic)
1011
+ try {
1012
+ opts.onDelivered?.(job.caller)
1013
+ } catch {
1014
+ /* a post-delivery hook must never fail a delivery that already landed */
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ return {
1021
+ tryEnqueue: async args => {
1022
+ if (closed) return err('composer delivery queue is closing; message NOT queued')
1023
+ if (!shouldQueue(args)) return null
1024
+ const token = sessionToken(args.target)
1025
+ if (!token) {
1026
+ return err(
1027
+ `composer queue could not identify the live tmux session for "${args.target.personality}" (${args.target.runtime}); message NOT queued`,
1028
+ )
1029
+ }
1030
+ const job: ComposerQueuedDelivery = {
1031
+ id: nextComposerQueueId++,
1032
+ caller: args.caller,
1033
+ peer: args.peer,
1034
+ target: args.target,
1035
+ envelope: args.envelope,
1036
+ topic: args.topic,
1037
+ enqueuedAtMs: monotonicMs(),
1038
+ sessionToken: token,
1039
+ }
1040
+ const q = queues.get(args.target.address) ?? []
1041
+ q.push(job)
1042
+ queues.set(args.target.address, q)
1043
+ kick(args.target.address)
1044
+ return ok({
1045
+ ok: true as const,
1046
+ delivered_to: { personality: args.target.personality, runtime: args.target.runtime },
1047
+ woke: false,
1048
+ queued: true,
1049
+ queuedBy: 'composer',
1050
+ queueDepth: q.length,
1051
+ ts: new Date().toISOString(),
1052
+ })
1053
+ },
1054
+ failAll: async reason => {
1055
+ closed = true
1056
+ const jobs = Array.from(queues.values()).flat()
1057
+ queues.clear()
1058
+ await Promise.all(jobs.map(job => notifyFailed(job, reason)))
1059
+ },
1060
+ }
1061
+ }
1062
+
692
1063
  function truncateTopic(raw: string | undefined): string | undefined {
693
1064
  if (!raw) return undefined
694
1065
  return raw.slice(0, MAX_TOPIC_LEN)
@@ -828,6 +1199,8 @@ export async function routeSend(
828
1199
  // land on telegram without an override and without telegram being the default).
829
1200
  const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
830
1201
  if (!liveGuard.ok) return liveGuard
1202
+ const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: target.value, envelope, topic })
1203
+ if (queued) return queued
831
1204
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
832
1205
  const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
833
1206
  if (!delivered.ok) return delivered
@@ -868,6 +1241,8 @@ export async function routeSend(
868
1241
  // a false ok:true — the class the contract forbids. A delivery failure here is
869
1242
  // surfaced loudly (false-FAIL over false-OK).
870
1243
  if (woke.taskDelivered === false) {
1244
+ const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic })
1245
+ if (queued) return queued
871
1246
  const delivered = deliverViaTmux(live.value, envelope, peer.cwd)
872
1247
  if (!delivered.ok) return delivered
873
1248
  noteTopic(deps, live.value.address, topic)
@@ -40,6 +40,9 @@ const tmuxAvailable = spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status ===
40
40
  const TARGET = 'fpw'
41
41
  const SESSION = `telegram-${TARGET}`
42
42
  const sockPath = () => join(sockDir, `tmux-iap-telegram-${TARGET}.sock`)
43
+ const TUI_TARGET = 'tq'
44
+ const TUI_SESSION = `codex-${TUI_TARGET}`
45
+ const tuiSockPath = () => join(sockDir, `tmux-iap-codex-${TUI_TARGET}.sock`)
43
46
 
44
47
  const callerRecord = {
45
48
  personality: 'boris',
@@ -79,6 +82,17 @@ function capturePane(): string {
79
82
  return r.stdout ?? ''
80
83
  }
81
84
 
85
+ function startTuiSession(): void {
86
+ const r = spawnSync('tmux', ['-S', tuiSockPath(), 'new-session', '-d', '-x', '200', '-y', '50', '-s', TUI_SESSION, 'cat'], {
87
+ encoding: 'utf8',
88
+ })
89
+ if (r.status !== 0) throw new Error(`tmux new-session failed: ${r.stderr}`)
90
+ }
91
+
92
+ function killTuiSession(): void {
93
+ spawnSync('tmux', ['-S', tuiSockPath(), 'kill-server'], { encoding: 'utf8' })
94
+ }
95
+
82
96
  beforeAll(() => {
83
97
  root = mkdtempSync(join(tmpdir(), 'fpw-'))
84
98
  sockDir = join(root, 'socks')
@@ -90,6 +104,7 @@ beforeAll(() => {
90
104
  peers: [
91
105
  { personality: 'boris', runtime: 'claude', runtimes: ['claude'], description: '', intelligence: 'artificial', cwd: '/tmp/boris' },
92
106
  { personality: TARGET, runtime: 'telegram', runtimes: ['telegram'], description: '', intelligence: 'artificial', cwd: root },
107
+ { personality: TUI_TARGET, runtime: 'codex', runtimes: ['codex'], description: '', intelligence: 'artificial', cwd: root },
93
108
  ],
94
109
  }),
95
110
  )
@@ -97,6 +112,7 @@ beforeAll(() => {
97
112
  })
98
113
  afterAll(() => {
99
114
  killTargetSession()
115
+ killTuiSession()
100
116
  if (prevRoot === undefined) delete process.env.IAPEER_ROOT
101
117
  else process.env.IAPEER_ROOT = prevRoot
102
118
  rmSync(root, { recursive: true, force: true })
@@ -218,6 +234,42 @@ describe.if(tmuxAvailable)('routeSend noteLiveTopic seam (live-delivered topic)'
218
234
  })
219
235
  })
220
236
 
237
+ describe.if(tmuxAvailable)('routeSend busy-composer queue seam', () => {
238
+ test('live local TUI hit can return fast queued ack instead of delivering synchronously', async () => {
239
+ startTuiSession()
240
+ const seen: string[] = []
241
+ const r = await routeSend(
242
+ caller,
243
+ { personality: TUI_TARGET, message: 'hold until operator submits', topic: 'composer-q' },
244
+ {
245
+ composerQueue: {
246
+ tryEnqueue: async ({ target, envelope, topic }) => {
247
+ seen.push(`${target.runtime}-${target.personality}:${topic}:${envelope.includes('hold until operator submits')}`)
248
+ return {
249
+ ok: true,
250
+ value: {
251
+ ok: true as const,
252
+ delivered_to: { personality: target.personality, runtime: target.runtime },
253
+ woke: false,
254
+ queued: true,
255
+ queuedBy: 'composer',
256
+ queueDepth: 1,
257
+ ts: 'now',
258
+ },
259
+ }
260
+ },
261
+ },
262
+ },
263
+ )
264
+ expect(r.ok).toBe(true)
265
+ if (!r.ok) return
266
+ expect(r.value.queued).toBe(true)
267
+ expect(r.value.queuedBy).toBe('composer')
268
+ expect(seen).toEqual([`codex-${TUI_TARGET}:composer-q:true`])
269
+ killTuiSession()
270
+ })
271
+ })
272
+
221
273
  // ─── Telegram sender policy (defect track 11.06) ─────────────────────────────
222
274
  // A sender with NO declared telegram presence (interfaces.telegram.bot|user_id)
223
275
  // must get a SYNCHRONOUS refusal when the delivery channel is telegram — the