@agfpd/iapeer 0.2.51 → 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.51",
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": {
@@ -11,7 +11,7 @@ import { addRuntime, compactPeer, defaultRuntime, formatListTable, listPeers, ne
11
11
  import { findPeer, readPeersIndex, upsertPeer } from '../registry/index.ts'
12
12
  import { hasIdleReaped, isStopped, loadLifecycleConfig, setIdleReaped, setStopped } from '../lifecycle/index.ts'
13
13
  import { launchdPlistPath } from '../launch/launchd.ts'
14
- import { ok } from '../core/errors.ts'
14
+ import { err, ok } from '../core/errors.ts'
15
15
 
16
16
  let root: string
17
17
  let laDir: string
@@ -463,6 +463,7 @@ describe('compact (resume sleeping dialogue)', () => {
463
463
  setIdleReaped(cfg, 'codex-sleepy')
464
464
  const wakeCalls: unknown[] = []
465
465
  const controlCalls: unknown[] = []
466
+ const doneCalls: unknown[] = []
466
467
  const o = await compactPeer('sleepy', 'codex', {
467
468
  env: e,
468
469
  wakeFn: async args => {
@@ -473,13 +474,34 @@ describe('compact (resume sleeping dialogue)', () => {
473
474
  controlCalls.push({ personality, runtime, command })
474
475
  return ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' })
475
476
  },
477
+ compactDoneFn: (target, cwd, baseline) => {
478
+ doneCalls.push({ target, cwd, runtime: baseline.runtime })
479
+ return ok({ ok: true, ms: 42, signal: 'transcript+ready' })
480
+ },
476
481
  })
477
482
  expect(o).toMatchObject({ action: 'compacted', runtime: 'codex', woke: true })
478
483
  expect(wakeCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', task: '', resume: true }])
479
484
  expect(controlCalls).toEqual([{ personality: 'sleepy', runtime: 'codex', command: { name: 'compact' } }])
485
+ expect(doneCalls).toHaveLength(1)
486
+ expect(doneCalls[0]).toMatchObject({ cwd: '/tmp/sleepy', runtime: 'codex', target: { runtime: 'codex', personality: 'sleepy' } })
480
487
  expect(hasIdleReaped(cfg, 'codex-sleepy')).toBe(false)
481
488
  })
482
489
 
490
+ test('compact done gate failure surfaces as failed (no false success)', async () => {
491
+ await register('slow', 'codex')
492
+ const e = env()
493
+ const cfg = loadLifecycleConfig(e)
494
+ setIdleReaped(cfg, 'codex-slow')
495
+ const o = await compactPeer('slow', 'codex', {
496
+ env: e,
497
+ wakeFn: async args => ({ status: 'READY', woke: true, runtime: args.runtime, process_address: `${args.runtime}-${args.personality}`, taskDelivered: true }),
498
+ controlFn: (personality, runtime, command) => ok({ ok: true, controlled: { personality, runtime: runtime! }, command: command.name, ts: 't' }),
499
+ compactDoneFn: () => err('compact did not complete within test'),
500
+ })
501
+ expect(o.action).toBe('failed')
502
+ expect(o.reason).toContain('did not complete')
503
+ })
504
+
483
505
  test('crashed / first-wake asleep peer (no clean idle marker) → nothing-to-compact, no fresh wake', async () => {
484
506
  await register('freshy', 'codex')
485
507
  let woke = false
package/src/cli/index.ts CHANGED
@@ -24,7 +24,7 @@ import { recycleFoundationOwnedInfraJobs, updateIapeer, waitForDaemonHealthy } f
24
24
  import { buildProcessAddress, buildSocketPath, parseSessionName } from '../core/socket.ts'
25
25
  import { ensureGlobalIapScaffold } from '../storage/index.ts'
26
26
  import { findPeer, readPeersIndex, removePeer, type PeerRecord } from '../registry/index.ts'
27
- import { isPeerLive, routeControl, routeSend, type WakeFn } from '../transport/index.ts'
27
+ import { compactDoneBaseline, isPeerLive, routeControl, routeSend, waitForCompactDone, type WakeFn } from '../transport/index.ts'
28
28
  import {
29
29
  attachPeer,
30
30
  clearIdleReaped,
@@ -515,9 +515,11 @@ export interface CompactPeerOutcome {
515
515
 
516
516
  /**
517
517
  * compact <peer> [runtime] — compact the DIALOGUE, not merely the currently-live
518
- * session. A live target receives the in-session `/compact` control as before. A
519
- * cleanly idle-reaped target is first resumed into the same dialogue, then compacted.
520
- * A crashed / never-run / non-resumable target returns an honest "nothing to compact"
518
+ * session. A live target receives the in-session `/compact` control; a cleanly
519
+ * idle-reaped target is first resumed into the same dialogue, then controlled.
520
+ * SUCCESS is gated on actual compaction completion (structured transcript marker
521
+ * after the command + input surface ready), not on keystrokes being accepted. A
522
+ * crashed / never-run / non-resumable target returns an honest "nothing to compact"
521
523
  * instead of starting a fresh empty session and compacting that.
522
524
  */
523
525
  export async function compactPeer(
@@ -526,6 +528,7 @@ export async function compactPeer(
526
528
  opts: CliEnvOptions & {
527
529
  wakeFn?: (args: { personality: string; runtime: Runtime; task: string; resume: true }) => Promise<WakeResult>
528
530
  controlFn?: typeof routeControl
531
+ compactDoneFn?: typeof waitForCompactDone
529
532
  } = {},
530
533
  ): Promise<CompactPeerOutcome> {
531
534
  const env = opts.env ?? process.env
@@ -551,10 +554,39 @@ export async function compactPeer(
551
554
  }
552
555
 
553
556
  const control = opts.controlFn ?? routeControl
557
+ const compactDone = opts.compactDoneFn ?? waitForCompactDone
558
+ const waitDone = (rt: Runtime, baseline: ReturnType<typeof compactDoneBaseline>, woke: boolean): CompactPeerOutcome | null => {
559
+ const done = compactDone(
560
+ {
561
+ personality,
562
+ runtime: rt,
563
+ address: buildProcessAddress(rt, personality),
564
+ socketPath: buildSocketPath(rt, personality, cfg.sockDir),
565
+ },
566
+ peer.cwd,
567
+ baseline,
568
+ { env },
569
+ )
570
+ if (!done.ok) return { personality, runtime: rt, action: 'failed', woke, reason: done.error.message }
571
+ return null
572
+ }
554
573
  const liveRuntimes = peer.runtimes.filter(rt => isPeerLive(rt, personality, cfg.sockDir))
555
574
  if (runtime ? liveRuntimes.includes(runtime as Runtime) : liveRuntimes.length > 0) {
556
- const r = control(personality, runtime, { name: 'compact' })
557
- if (!r.ok) return { personality, runtime: runtime ?? peer.runtime, action: 'failed', reason: r.error.message }
575
+ const rt = (runtime as Runtime | undefined)
576
+ ?? (liveRuntimes.includes(peer.runtime) ? peer.runtime : liveRuntimes.length === 1 ? liveRuntimes[0] : undefined)
577
+ if (!rt) {
578
+ return {
579
+ personality,
580
+ runtime: peer.runtime,
581
+ action: 'failed',
582
+ reason: `${personality} is online in multiple runtimes (${liveRuntimes.join(', ')}) — specify runtime`,
583
+ }
584
+ }
585
+ const baseline = compactDoneBaseline(rt, peer.cwd, { env })
586
+ const r = control(personality, rt, { name: 'compact' })
587
+ if (!r.ok) return { personality, runtime: rt, action: 'failed', reason: r.error.message }
588
+ const waited = waitDone(r.value.controlled.runtime as Runtime, baseline, false)
589
+ if (waited) return waited
558
590
  return { personality, runtime: r.value.controlled.runtime, action: 'compacted', woke: false }
559
591
  }
560
592
 
@@ -589,8 +621,11 @@ export async function compactPeer(
589
621
  return { personality, runtime: rt, action: 'failed', reason }
590
622
  }
591
623
  clearIdleReaped(cfg, identity)
624
+ const baseline = compactDoneBaseline(rt, peer.cwd, { env })
592
625
  const r = control(personality, rt, { name: 'compact' })
593
626
  if (!r.ok) return { personality, runtime: rt, action: 'failed', woke: true, reason: r.error.message }
627
+ const waited = waitDone(rt, baseline, true)
628
+ if (waited) return waited
594
629
  return { personality, runtime: rt, action: 'compacted', woke: true }
595
630
  }
596
631
 
@@ -803,6 +838,7 @@ export async function sendMessage(
803
838
  via: result.ok ? `${result.value.delivered_to.runtime}-${result.value.delivered_to.personality}` : undefined,
804
839
  woke: result.ok ? String(result.value.woke) : undefined,
805
840
  queued: result.ok && result.value.queued ? 'true' : undefined,
841
+ qkind: result.ok ? result.value.queuedBy : undefined,
806
842
  qd: result.ok ? result.value.queueDepth : undefined,
807
843
  ms: Date.now() - t0,
808
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,60 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, appendFileSync } from 'fs'
3
+ import { tmpdir } from 'os'
4
+ import { join } from 'path'
5
+ import { compactDoneBaseline, compactTranscriptHasDone } from './index.ts'
6
+
7
+ function withHome(fn: (home: string, env: NodeJS.ProcessEnv) => void): void {
8
+ const home = mkdtempSync(join(tmpdir(), 'iapeer-compact-gate-home-'))
9
+ try {
10
+ fn(home, { ...process.env, HOME: home })
11
+ } finally {
12
+ rmSync(home, { recursive: true, force: true })
13
+ }
14
+ }
15
+
16
+ function claudeSlug(cwd: string): string {
17
+ return realpathSync(cwd).replace(/[^a-zA-Z0-9]/g, '-')
18
+ }
19
+
20
+ describe('compact done transcript gate', () => {
21
+ test('codex: ignores old markers and detects a new context_compacted event', () => withHome((home, env) => {
22
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-compact-codex-cwd-'))
23
+ try {
24
+ const dir = join(home, '.codex', 'sessions', '2026', '06', '13')
25
+ mkdirSync(dir, { recursive: true })
26
+ const file = join(dir, 'rollout-test.jsonl')
27
+ writeFileSync(file,
28
+ JSON.stringify({ type: 'session_meta', payload: { cwd } }) + '\n' +
29
+ JSON.stringify({ type: 'event_msg', payload: { type: 'context_compacted' } }) + '\n',
30
+ )
31
+ const baseline = compactDoneBaseline('codex', cwd, { env })
32
+ expect(compactTranscriptHasDone(baseline, { env })).toBe(false)
33
+ appendFileSync(file, JSON.stringify({ type: 'event_msg', payload: { type: 'context_compacted' } }) + '\n')
34
+ expect(compactTranscriptHasDone(baseline, { env })).toBe(true)
35
+ } finally {
36
+ rmSync(cwd, { recursive: true, force: true })
37
+ }
38
+ }))
39
+
40
+ test('claude: detects a new compact_boundary and ignores raw text mentions', () => withHome((home, env) => {
41
+ const cwd = mkdtempSync(join(tmpdir(), 'iapeer-compact-claude-cwd-'))
42
+ try {
43
+ const dir = join(home, '.claude', 'projects', claudeSlug(cwd))
44
+ mkdirSync(dir, { recursive: true })
45
+ const file = join(dir, 'session.jsonl')
46
+ writeFileSync(file,
47
+ JSON.stringify({ type: 'user', message: { role: 'user', content: 'context_compacted is only text' } }) + '\n' +
48
+ JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted' }) + '\n',
49
+ )
50
+ const baseline = compactDoneBaseline('claude', cwd, { env })
51
+ expect(compactTranscriptHasDone(baseline, { env })).toBe(false)
52
+ appendFileSync(file, JSON.stringify({ type: 'user', message: { role: 'user', content: 'Conversation compacted text only' } }) + '\n')
53
+ expect(compactTranscriptHasDone(baseline, { env })).toBe(false)
54
+ appendFileSync(file, JSON.stringify({ type: 'system', subtype: 'compact_boundary', content: 'Conversation compacted', compactMetadata: { durationMs: 12 } }) + '\n')
55
+ expect(compactTranscriptHasDone(baseline, { env })).toBe(true)
56
+ } finally {
57
+ rmSync(cwd, { recursive: true, force: true })
58
+ }
59
+ }))
60
+ })