@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 +1 -1
- package/src/cli/cli.test.ts +23 -1
- package/src/cli/index.ts +42 -6
- package/src/daemon/daemon.test.ts +2 -0
- package/src/daemon/index.ts +24 -6
- package/src/daemon/main.test.ts +19 -0
- package/src/daemon/main.ts +98 -4
- package/src/launch/adapters/claude.ts +1 -0
- package/src/launch/adapters/codex.ts +1 -0
- package/src/launch/launch.test.ts +2 -0
- package/src/launch/types.ts +8 -0
- package/src/transport/compactDone.test.ts +60 -0
- package/src/transport/composerQueue.test.ts +174 -0
- package/src/transport/index.ts +600 -1
- package/src/transport/routesend-wake.test.ts +52 -0
package/src/transport/index.ts
CHANGED
|
@@ -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)
|