@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.
@@ -13,8 +13,9 @@
13
13
  // runtime's adapter.deliveryMarkers. The poll/re-press logic is untouched; only the
14
14
  // marker SOURCE moved, so the timing behaviour is identical.
15
15
 
16
- import { readdirSync } from 'fs'
16
+ import { readFileSync, readdirSync, realpathSync, statSync } from 'fs'
17
17
  import { join } from 'path'
18
+ import { homedir } from 'os'
18
19
  import { spawnSync } from 'child_process'
19
20
  import {
20
21
  MAX_ATTACHMENTS,
@@ -98,6 +99,168 @@ function sessionAlive(sock: string, address: string): boolean {
98
99
  return listSessions(sock).includes(address)
99
100
  }
100
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
+
101
264
  /** Is the (runtime,personality) endpoint live right now? Public liveness predicate. */
102
265
  export function isPeerLive(runtime: string, personality: string, sockDir = resolveSockDir()): boolean {
103
266
  const socketPath = buildSocketPath(runtime, personality, sockDir)
@@ -309,6 +472,229 @@ export function deliverViaTmux(target: DeliveryTarget, envelope: string, cwd?: s
309
472
  return ok(undefined)
310
473
  }
311
474
 
475
+
476
+ // ─── Compact done gate (dialogue control must report FACT, not keystroke delivery) ──
477
+
478
+ export interface CompactDoneBaseline {
479
+ runtime: string
480
+ cwd: string
481
+ capturedAtMs: number
482
+ files: { path: string; size: number }[]
483
+ }
484
+
485
+ export interface CompactDoneWaitResult {
486
+ ok: true
487
+ ms: number
488
+ signal: 'transcript+ready'
489
+ }
490
+
491
+ function envHome(env: NodeJS.ProcessEnv | undefined): string {
492
+ return env?.HOME?.trim() || homedir()
493
+ }
494
+
495
+ function canonicalPath(p: string): string {
496
+ try {
497
+ return realpathSync(p)
498
+ } catch {
499
+ return p
500
+ }
501
+ }
502
+
503
+ function claudeTranscriptDirForCwd(cwd: string, env?: NodeJS.ProcessEnv): string {
504
+ const slug = canonicalPath(cwd).replace(/[^a-zA-Z0-9]/g, '-')
505
+ return join(envHome(env), '.claude', 'projects', slug)
506
+ }
507
+
508
+ function codexSessionCwd(file: string): string | null {
509
+ try {
510
+ const firstLine = readFileSync(file, 'utf8').split(/\r?\n/, 1)[0]
511
+ if (!firstLine) return null
512
+ const entry = JSON.parse(firstLine) as { type?: unknown; payload?: { cwd?: unknown } }
513
+ return entry.type === 'session_meta' && typeof entry.payload?.cwd === 'string' ? entry.payload.cwd : null
514
+ } catch {
515
+ return null
516
+ }
517
+ }
518
+
519
+ function compactCandidateFiles(runtime: string, cwd: string, env?: NodeJS.ProcessEnv): string[] {
520
+ const target = canonicalPath(cwd)
521
+ const out: string[] = []
522
+ if (runtime === 'claude') {
523
+ const dir = claudeTranscriptDirForCwd(cwd, env)
524
+ let entries
525
+ try {
526
+ entries = readdirSync(dir, { withFileTypes: true })
527
+ } catch {
528
+ return []
529
+ }
530
+ for (const entry of entries) {
531
+ if (entry.isFile() && entry.name.endsWith('.jsonl')) out.push(join(dir, entry.name))
532
+ }
533
+ } else if (runtime === 'codex') {
534
+ const root = join(envHome(env), '.codex', 'sessions')
535
+ function visit(dir: string): void {
536
+ let entries
537
+ try {
538
+ entries = readdirSync(dir, { withFileTypes: true })
539
+ } catch {
540
+ return
541
+ }
542
+ for (const entry of entries) {
543
+ const path = join(dir, entry.name)
544
+ if (entry.isDirectory()) {
545
+ visit(path)
546
+ continue
547
+ }
548
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue
549
+ if (canonicalPath(codexSessionCwd(path) ?? '') === target) out.push(path)
550
+ }
551
+ }
552
+ visit(root)
553
+ }
554
+ return out.sort()
555
+ }
556
+
557
+ function compactDoneFileSize(path: string): number {
558
+ try {
559
+ return statSync(path).size
560
+ } catch {
561
+ return 0
562
+ }
563
+ }
564
+
565
+ export function compactDoneBaseline(runtime: string, cwd: string, opts: { env?: NodeJS.ProcessEnv } = {}): CompactDoneBaseline {
566
+ return {
567
+ runtime,
568
+ cwd,
569
+ capturedAtMs: monotonicMs(),
570
+ files: compactCandidateFiles(runtime, cwd, opts.env).map(path => ({ path, size: compactDoneFileSize(path) })),
571
+ }
572
+ }
573
+
574
+ function isStructuredCompactDone(runtime: string, obj: unknown): boolean {
575
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false
576
+ const rec = obj as Record<string, unknown>
577
+ const payload = rec.payload && typeof rec.payload === 'object' && !Array.isArray(rec.payload)
578
+ ? rec.payload as Record<string, unknown>
579
+ : undefined
580
+
581
+ // Codex writes an explicit structured completion event after slash-/compact.
582
+ // The adjacent top-level `compacted` record is accepted too: older/newer Codex
583
+ // builds can emit it immediately before/without the event_msg wrapper.
584
+ if (runtime === 'codex') {
585
+ if (rec.type === 'event_msg' && payload?.type === 'context_compacted') return true
586
+ if (rec.type === 'compacted') return true
587
+ }
588
+
589
+ // Claude Code records the finished compaction as a system compact boundary with
590
+ // compactMetadata (duration/pre/post tokens). Raw text mentions are ignored.
591
+ if (runtime === 'claude') {
592
+ if (rec.type === 'system' && rec.subtype === 'compact_boundary') return true
593
+ if (rec.type === 'system' && rec.compactMetadata && typeof rec.compactMetadata === 'object') return true
594
+ }
595
+
596
+ return false
597
+ }
598
+
599
+ export function compactTranscriptHasDone(
600
+ baseline: CompactDoneBaseline,
601
+ opts: { env?: NodeJS.ProcessEnv } = {},
602
+ ): boolean {
603
+ const offsets = new Map(baseline.files.map(f => [f.path, f.size]))
604
+ // Include a transcript/session file that appears after the baseline (rare, but a
605
+ // resume/rotate race should not make the done gate blind). Existing files are
606
+ // read only from their baseline byte offset, so old compact markers cannot pass.
607
+ for (const path of compactCandidateFiles(baseline.runtime, baseline.cwd, opts.env)) {
608
+ if (!offsets.has(path)) offsets.set(path, 0)
609
+ }
610
+
611
+ for (const [path, offset] of offsets) {
612
+ let buf: Buffer
613
+ try {
614
+ buf = readFileSync(path)
615
+ } catch {
616
+ continue
617
+ }
618
+ const start = Math.min(Math.max(0, offset), buf.length)
619
+ const tail = buf.subarray(start).toString('utf8')
620
+ for (const line of tail.split(/\r?\n/)) {
621
+ if (!line.trim()) continue
622
+ let obj: unknown
623
+ try {
624
+ obj = JSON.parse(line)
625
+ } catch {
626
+ continue
627
+ }
628
+ if (isStructuredCompactDone(baseline.runtime, obj)) return true
629
+ }
630
+ }
631
+ return false
632
+ }
633
+
634
+ function compactDoneTimeoutMs(env: NodeJS.ProcessEnv | undefined): number {
635
+ const raw = env?.IAPEER_COMPACT_DONE_TIMEOUT_MS
636
+ const n = raw === undefined ? NaN : Number(raw)
637
+ // Keep default below telegram-runtime's 300 s command timeout so iapeer returns
638
+ // the truthful failure itself instead of letting the channel kill the process.
639
+ return Number.isFinite(n) && n >= 0 ? n : 290_000
640
+ }
641
+
642
+ function compactDonePollMs(env: NodeJS.ProcessEnv | undefined): number {
643
+ const raw = env?.IAPEER_COMPACT_DONE_POLL_MS
644
+ const n = raw === undefined ? NaN : Number(raw)
645
+ return Number.isFinite(n) && n > 0 ? n : 1000
646
+ }
647
+
648
+ /**
649
+ * Wait until a slash-/compact command has actually FINISHED, not merely until its
650
+ * keystrokes were accepted. The primary signal is scroll-proof structured
651
+ * transcript state written by the runtimes: Codex `context_compacted`, Claude
652
+ * `compact_boundary`. The final input-ready check prevents a marker-only early
653
+ * return while the TUI is still transitioning back to the composer/prompt.
654
+ */
655
+ export function waitForCompactDone(
656
+ target: DeliveryTarget,
657
+ cwd: string,
658
+ baseline: CompactDoneBaseline,
659
+ opts: { env?: NodeJS.ProcessEnv; timeoutMs?: number; pollMs?: number } = {},
660
+ ): Result<CompactDoneWaitResult> {
661
+ const env = opts.env ?? process.env
662
+ const timeoutMs = opts.timeoutMs ?? compactDoneTimeoutMs(env)
663
+ const pollMs = opts.pollMs ?? compactDonePollMs(env)
664
+ const adapter = getAdapter(target.runtime)
665
+ const started = monotonicMs()
666
+ const deadline = started + timeoutMs
667
+ let sawTranscriptDone = false
668
+ let lastPane = ''
669
+
670
+ do {
671
+ if (!sessionAlive(target.socketPath, target.address)) {
672
+ return err(`compact did not complete: tmux session vanished`)
673
+ }
674
+ sawTranscriptDone ||= compactTranscriptHasDone(baseline, { env })
675
+ const pane = tmux(target.socketPath, 'capture-pane', '-p', '-t', target.address)
676
+ if (pane.ok) lastPane = pane.out
677
+ if (sawTranscriptDone && pane.ok && adapter.isInputReady(pane.out)) {
678
+ return ok({ ok: true, ms: monotonicMs() - started, signal: 'transcript+ready' })
679
+ }
680
+ sleepSync(pollMs)
681
+ } while (monotonicMs() < deadline)
682
+
683
+ const tail = lastPane
684
+ .split(/\r?\n/)
685
+ .map(line => line.trim())
686
+ .filter(Boolean)
687
+ .slice(-2)
688
+ .join(' ⏎ ')
689
+ .replace(/"/g, "'")
690
+ .slice(0, 240)
691
+ return err(
692
+ `compact did not complete within ${timeoutMs}ms (` +
693
+ `${sawTranscriptDone ? 'transcript marker seen, input not ready' : 'no transcript compact marker'}` +
694
+ `${tail ? `; pane tail: ${tail}` : ''})`,
695
+ )
696
+ }
697
+
312
698
  // ─── Control channel (Ф-E) — in-session control via the adapter, UNCONDITIONAL ──
313
699
 
314
700
  /**
@@ -384,6 +770,11 @@ export interface RouteResult {
384
770
  * substantive result arrives as the worker's own reply (FaaS semantics);
385
771
  * `delivered_to` names the ACCEPTING queue identity, not a live session. */
386
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'
387
778
  /** Queue depth right after the enqueue (observability; only with queued). */
388
779
  queueDepth?: number
389
780
  }
@@ -435,11 +826,44 @@ export interface EphemeralRouteDeps {
435
826
  }) => Promise<Result<RouteResult>>
436
827
  }
437
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
+
438
857
  export interface RouteDeps {
439
858
  /** On a miss, wake the dead peer instead of returning offline (Ф2). */
440
859
  wake?: WakeFn
441
860
  /** Ephemeral-target serial-queue delivery (M3); absent → normal routing. */
442
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
443
867
  /** Fired after a successful LIVE delivery whose envelope carried a non-empty
444
868
  * topic — the hit path AND the post-wake fast-path redelivery, NOT the boot
445
869
  * first-message (the wake itself records its topic). The fresh-vs-resume seam:
@@ -465,6 +889,177 @@ function noteTopic(deps: RouteDeps, identity: string, topic: string | undefined)
465
889
  }
466
890
  }
467
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
+
468
1063
  function truncateTopic(raw: string | undefined): string | undefined {
469
1064
  if (!raw) return undefined
470
1065
  return raw.slice(0, MAX_TOPIC_LEN)
@@ -604,6 +1199,8 @@ export async function routeSend(
604
1199
  // land on telegram without an override and without telegram being the default).
605
1200
  const liveGuard = telegramSenderGuard(caller, target.value.runtime, personality)
606
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
607
1204
  // peer.cwd enables the Ф-B transcript-mtime liveness probe (busy-session case).
608
1205
  const delivered = deliverViaTmux(target.value, envelope, peer.cwd)
609
1206
  if (!delivered.ok) return delivered
@@ -644,6 +1241,8 @@ export async function routeSend(
644
1241
  // a false ok:true — the class the contract forbids. A delivery failure here is
645
1242
  // surfaced loudly (false-FAIL over false-OK).
646
1243
  if (woke.taskDelivered === false) {
1244
+ const queued = await deps.composerQueue?.tryEnqueue({ caller, peer, target: live.value, envelope, topic })
1245
+ if (queued) return queued
647
1246
  const delivered = deliverViaTmux(live.value, envelope, peer.cwd)
648
1247
  if (!delivered.ok) return delivered
649
1248
  noteTopic(deps, live.value.address, topic)