@agfpd/iapeer 0.4.29 → 0.4.31

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.4.29",
3
+ "version": "0.4.31",
4
4
  "description": "Run Claude Code and Codex CLI as local background agents that message each other, wake on demand, and share one memory.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "agfpd",
@@ -136,6 +136,11 @@ export const claudeAdapter: RuntimeAdapter = {
136
136
  ghostTextSgr: ['2', '38;5;246'],
137
137
  },
138
138
 
139
+ // claude logs an accepted paste PROMPTLY (sub-second: a queue-operation when busy,
140
+ // the user-turn when idle), so the message-specific transcript confirm is both cheap
141
+ // and a swallow-guard (the false-OK class). Keep it. (See RuntimeAdapter.deliveryConfirm.)
142
+ deliveryConfirm: 'transcript',
143
+
139
144
  /**
140
145
  * argv = claudeBin + headless flags + (system-prompt-file when set) +
141
146
  * (--continue when resuming) + extraArgs.
@@ -177,6 +177,16 @@ export const codexAdapter: RuntimeAdapter = {
177
177
  ghostTextSgr: ['2', '38;5;246'],
178
178
  },
179
179
 
180
+ // codex writes NO prompt-acceptance record — its session-jsonl user-input appears only
181
+ // when the model TURN ingests the message (during a long turn, tens of seconds away;
182
+ // measured ~80s live 2026-06-25). But its input queue is DURABLE: a mid-turn submit is
183
+ // held and processed at the next turn boundary, never lost (verified live — an 80s turn
184
+ // still ingested + replied with the exact probe token, despite the send false-FAILing at
185
+ // the 8s grace). So the flushed socket-ack IS the delivery confirm; a transcript grace
186
+ // only false-FAILs a message that WILL be processed → wrong fallback escalation. A
187
+ // genuinely dead session still fails at the socket-ack. (See RuntimeAdapter.deliveryConfirm.)
188
+ deliveryConfirm: 'socket-ack',
189
+
180
190
  /**
181
191
  * argv = codexBin + (resume --last when spec.resume) + the TUI args +
182
192
  * (doctrine via -c when systemPromptFile set) + bypass + extraArgs. Order is
@@ -11,12 +11,24 @@ import { openSync, readSync, closeSync, statSync } from 'fs'
11
11
 
12
12
  /**
13
13
  * Content-timestamp (epoch ms) of the LAST jsonl entry carrying a `timestamp` field, read from the
14
- * file's TAIL (cheap — only the last `tailBytes` are read). Metadata records without a `timestamp`
15
- * (claude `mode` / `ai-title` / `bridge-session`) are skipped. Returns null when the file is
16
- * unreadable or no timestamped entry sits in the tail (the caller then floors at wokeAt). A partial
17
- * FIRST line in the tail buffer is harmless: we scan from the END and return on the first complete
18
- * timestamped entry, so a truncated leading line is only ever reached (and skipped) when nothing
19
- * matched.
14
+ * file's TAIL. Metadata records without a `timestamp` (claude `mode` / `ai-title` / `bridge-session`)
15
+ * are skipped. Returns null only when the file is unreadable / empty, or genuinely carries NO
16
+ * timestamped entry anywhere (the caller then floors at wokeAt).
17
+ *
18
+ * ADAPTIVE WINDOW (the giant-tail fix). The reader starts at the cheap `tailBytes` window and GROWS it
19
+ * (doubling, capped at file size) until a complete timestamped entry is captured. A single transcript
20
+ * record can dwarf the default window — a browser peer appends base64 screenshots, observed up to
21
+ * 368 KB (5.6× the 64 KB default). When such a record is the tail, a FIXED window holds only a fragment
22
+ * of one line: no complete JSON → the old code returned null, the caller floored at wokeAt, and a LIVE
23
+ * working session was idle-reaped (incident: mrbrowser killed mid-task, last turn 7 s before the reap).
24
+ * Growing guarantees we eventually read the full last record (worst case: the entire file), so a present
25
+ * timestamp is NEVER missed. The default window already covers the common case in one read; growth only
26
+ * kicks in for oversized tail records, and even then is bounded (≈log2(fileSize/tailBytes) reads).
27
+ *
28
+ * A partial LEADING line is never trusted: once the window starts past byte 0, the first split segment
29
+ * may be the truncated tail of an earlier record (which could mis-parse as valid JSON), so we scan only
30
+ * the newline-bounded segments after it. Reaching byte 0 makes every segment complete, including the
31
+ * first — and terminates the growth.
20
32
  */
21
33
  export function lastTimestampedEntryMs(path: string, tailBytes = 65536): number | null {
22
34
  let fd: number
@@ -27,35 +39,45 @@ export function lastTimestampedEntryMs(path: string, tailBytes = 65536): number
27
39
  }
28
40
  try {
29
41
  const size = statSync(path).size
30
- const start = Math.max(0, size - tailBytes)
31
- const len = size - start
32
- if (len <= 0) return null
33
- const buf = Buffer.allocUnsafe(len)
34
- let read = 0
35
- while (read < len) {
36
- const n = readSync(fd, buf, read, len - read, start + read)
37
- if (n <= 0) break
38
- read += n
39
- }
40
- const lines = buf.subarray(0, read).toString('utf8').split(/\r?\n/)
41
- for (let i = lines.length - 1; i >= 0; i--) {
42
- const line = lines[i].trim()
43
- if (!line) continue
44
- let obj: unknown
45
- try {
46
- obj = JSON.parse(line)
47
- } catch {
48
- continue // partial leading line / non-json → skip
42
+ if (size <= 0) return null
43
+ let window = Math.max(tailBytes, 1)
44
+ for (;;) {
45
+ const start = Math.max(0, size - window)
46
+ const len = size - start
47
+ const buf = Buffer.allocUnsafe(len)
48
+ let read = 0
49
+ while (read < len) {
50
+ const n = readSync(fd, buf, read, len - read, start + read)
51
+ if (n <= 0) break
52
+ read += n
49
53
  }
50
- const ts = (obj as { timestamp?: unknown })?.timestamp
51
- if (typeof ts === 'string') {
52
- const ms = Date.parse(ts)
53
- if (!Number.isNaN(ms)) return ms
54
- } else if (typeof ts === 'number' && Number.isFinite(ts)) {
55
- return ts
54
+ const atFileStart = start === 0
55
+ const lines = buf.subarray(0, read).toString('utf8').split(/\r?\n/)
56
+ // Segment 0 is a complete record ONLY when the window reaches byte 0; otherwise it is the possibly
57
+ // truncated tail of an earlier record and must not be parsed (`floor` skips it).
58
+ const floor = atFileStart ? 0 : 1
59
+ for (let i = lines.length - 1; i >= floor; i--) {
60
+ const line = lines[i].trim()
61
+ if (!line) continue
62
+ let obj: unknown
63
+ try {
64
+ obj = JSON.parse(line)
65
+ } catch {
66
+ continue // metadata fragment / non-json → skip
67
+ }
68
+ const ts = (obj as { timestamp?: unknown })?.timestamp
69
+ if (typeof ts === 'string') {
70
+ const ms = Date.parse(ts)
71
+ if (!Number.isNaN(ms)) return ms
72
+ } else if (typeof ts === 'number' && Number.isFinite(ts)) {
73
+ return ts
74
+ }
56
75
  }
76
+ // No complete timestamped entry in this window. If the whole file is read, there genuinely is none.
77
+ // Otherwise the tail record outgrows the window → grow and retry (terminates: window hits size → start 0).
78
+ if (atFileStart) return null
79
+ window *= 2
57
80
  }
58
- return null
59
81
  } catch {
60
82
  return null
61
83
  } finally {
@@ -235,6 +235,32 @@ export interface RuntimeAdapter {
235
235
  */
236
236
  deliveryMarkers: DeliveryMarkers
237
237
 
238
+ /**
239
+ * How a delivered envelope's LANDING is confirmed AFTER the socket-ack, in
240
+ * deliverViaHost (transport). Owned by the adapter because it is a property of
241
+ * HOW the runtime logs an accepted message, not transport policy. A router
242
+ * (kind:'router') is always socket-ack by structure and short-circuits before
243
+ * this field is read; it refines the TUI runtimes. Absent → 'transcript' (the
244
+ * strict default: a new/unknown runtime gets the swallow-guarding confirm, never
245
+ * a weaker one).
246
+ * - 'transcript' (claude): the runtime writes an acceptance record CARRYING the
247
+ * envelope PROMPTLY — sub-second: a queue-operation when busy, the user-turn
248
+ * when idle. So a message-specific transcript-carries-envelope check within a
249
+ * short grace BOTH proves landing AND catches a paste swallowed at a turn
250
+ * boundary (the false-OK class, incident 2026-06-23). Cheap and safe → keep it.
251
+ * - 'socket-ack' (codex): the runtime writes NO acceptance record — its
252
+ * session-jsonl user-input appears only when the model TURN INGESTS the
253
+ * message, which during a long turn is many tens of seconds away (measured ~80s
254
+ * live, 2026-06-25). But its input queue is DURABLE: a mid-turn submit is HELD
255
+ * and processed at the next turn boundary, never lost (verified live — an 80s
256
+ * turn still ingested + replied with the exact probe token). So the flushed
257
+ * socket-ack (bytes left us AND drained to the pty) IS the delivery confirm; a
258
+ * transcript grace here only ever FALSE-FAILs a message that WILL be processed,
259
+ * wrongly escalating to a fallback peer. A genuinely dead session still fails
260
+ * at the socket-ack (no socket / stalled flush) → really-dead still escalates.
261
+ */
262
+ deliveryConfirm?: 'transcript' | 'socket-ack'
263
+
238
264
  /**
239
265
  * If set, launch REFUSES unless the peer's intelligence equals this value
240
266
  * (fail-loud). telegram → 'natural' — it is a human channel; launching an
@@ -157,20 +157,19 @@ export function resolvePeerDeliveryTarget(
157
157
  const LIVENESS_POLL_MS = 100
158
158
 
159
159
  // Confirm grace — how long deliverViaHost polls for a transcript record CARRYING our envelope before
160
- // declaring a false-FAIL. Per-RUNTIME because the runtimes log an accepted injection at different
161
- // times: claude writes the queue-operation (busy) / user-turn (idle) record PROMPTLY (sub-second), so
162
- // a short grace suffices; codex writes its session-jsonl user-input only at model-turn-START (TTFT
163
- // measured ~4-6s after submit), so it needs a longer window or every codex delivery would false-FAIL
164
- // before the first write. `IAP_HOST_LIVENESS_GRACE_MS` overrides BOTH (operator calibration; tests set
165
- // 0 for an immediate deterministic fail). Prefer a false-FAIL (sender retries) over a false-OK (silent
166
- // loss, which the contract forbids).
167
- const CONFIRM_GRACE_CLAUDE_MS = 4000
168
- const CONFIRM_GRACE_CODEX_MS = 8000
169
- function confirmGraceMs(runtime: string): number {
160
+ // declaring a false-FAIL. This applies ONLY to the 'transcript'-confirm runtimes (claude): claude
161
+ // writes the queue-operation (busy) / user-turn (idle) record PROMPTLY (sub-second), so a short grace
162
+ // suffices and the confirm doubles as a swallow-guard. codex is 'socket-ack' (adapter.deliveryConfirm)
163
+ // and never reaches this loop — its input queue is durable but it logs the user-input only at ingest
164
+ // (tens of seconds into a long turn), so a grace would only false-FAIL a message that WILL be processed.
165
+ // `IAP_HOST_LIVENESS_GRACE_MS` overrides (operator calibration; tests set 0 for an immediate
166
+ // deterministic fail). Prefer a false-FAIL (sender retries) over a false-OK (silent loss, forbidden).
167
+ const CONFIRM_GRACE_MS = 4000
168
+ function confirmGraceMs(): number {
170
169
  const raw = process.env.IAP_HOST_LIVENESS_GRACE_MS
171
170
  const n = raw === undefined ? NaN : Number(raw)
172
171
  if (Number.isFinite(n) && n >= 0) return n
173
- return runtime === 'codex' ? CONFIRM_GRACE_CODEX_MS : CONFIRM_GRACE_CLAUDE_MS
172
+ return CONFIRM_GRACE_MS
174
173
  }
175
174
 
176
175
  // Launchd-revive retry — a MISS on a launchd-managed target the daemon can't wake (H4)
@@ -204,13 +203,17 @@ export interface WarmDeliverSeam {
204
203
  }
205
204
 
206
205
  /**
207
- * Warm-deliver `envelope` to a LIVE local target over its supervisor socket (pty-only). Landing is
208
- * CONFIRMED MESSAGE-SPECIFICALLY — by a NEW transcript record CARRYING this envelope appearing past the
209
- * pre-deliver baseline, NOT the socket-ack (a flushed CR proves the bytes left us, not that the session
210
- * accepted them) and NOT a bare mtime advance (the receiver's OWN concurrent turn bumps mtime even when
211
- * our paste was swallowed — the false-OK class the contract forbids). A router (telegram/notifier) has
212
- * no transcript → confirmed by the socket-ack (liveness is structural via launchd KeepAlive). See
213
- * `deliverViaHost`.
206
+ * Warm-deliver `envelope` to a LIVE local target over its supervisor socket (pty-only). Confirmation is
207
+ * adapter-driven (RuntimeAdapter.deliveryConfirm):
208
+ * - 'transcript' (claude): CONFIRMED MESSAGE-SPECIFICALLY — by a NEW transcript record CARRYING this
209
+ * envelope appearing past the pre-deliver baseline, NOT the socket-ack (a flushed CR proves the
210
+ * bytes left us, not that the session accepted them) and NOT a bare mtime advance (the receiver's
211
+ * OWN concurrent turn bumps mtime even when our paste was swallowed — the false-OK class, forbidden).
212
+ * - 'socket-ack' (codex): confirmed by the socket-ack — its input queue is durable (a mid-turn submit
213
+ * is never lost) and it logs no prompt-acceptance record, so a transcript grace would only false-FAIL.
214
+ * - router (telegram/notifier): no transcript → confirmed by the socket-ack (liveness is structural
215
+ * via launchd KeepAlive).
216
+ * See `deliverViaHost`.
214
217
  */
215
218
  export async function deliverWarm(
216
219
  target: DeliveryTarget,
@@ -244,21 +247,32 @@ async function deliverViaHost(
244
247
  // router's liveness is structural via launchd KeepAlive (not a model turn), so the ack IS the
245
248
  // delivery-level confirm (parity with the legacy tmux router C-j path).
246
249
  if (adapter.kind === 'router') return ok(undefined)
250
+ // 'socket-ack' TUI runtimes (codex): the input queue is DURABLE — a mid-turn submit is HELD and
251
+ // processed at the next turn boundary, however long that is (verified live 2026-06-25: an 80s turn
252
+ // still ingested + replied; the send false-FAILed at the 8s grace, the message was NOT lost). codex
253
+ // writes NO prompt-acceptance record (its user-input lands only at ingest), so a transcript grace
254
+ // can't tell "swallowed" from "queued-but-not-yet-ingested" within any useful window — it only
255
+ // false-FAILs a message that WILL be delivered, wrongly escalating to a fallback peer. The flushed
256
+ // socket-ack above IS the delivery confirm; a genuinely dead session already failed at deliverHosted
257
+ // (no socket / stalled flush) → really-dead still escalates. (claude stays on the strict
258
+ // message-specific confirm below — it logs acceptance sub-second, so that is cheap AND catches a
259
+ // swallowed paste, the false-OK class.) Driven by adapter.deliveryConfirm, not a hardcoded runtime.
260
+ if (adapter.deliveryConfirm === 'socket-ack') return ok(undefined)
247
261
  // socket-ack ≠ landed. With no transcript (direct callers/tests without a cwd) we can only trust the
248
262
  // flushed submit — confirmed-only.
249
263
  if (!cwd || !baseline) return ok(undefined)
250
- // MESSAGE-SPECIFIC landed-confirm — the false-OK killer. A bare transcript/pane-log mtime BUMP is
251
- // NOT proof the session took THIS message: a receiver in an active turn advances both mtimes with
252
- // its OWN rendering even when our paste was swallowed at the turn boundary (incident 2026-06-23:
253
- // ok=true, message gone). The ONLY proof is a NEW transcript record CARRYING our envelope —
254
- // claude: a queue-operation `content` (busy → enqueued) or the user-turn message (idle),
255
- // codex: a user-input response_item (written at model-turn-start, ~4-6s — hence the codex grace).
256
- // The receiver's own assistant/tool turn never reproduces the `<iap from-personality=…>` wrapper, so
257
- // it cannot forge a confirm. No such record within the grace → false-FAIL (the sender retries) — NOT
258
- // a false-OK (silent loss the contract forbids).
264
+ // MESSAGE-SPECIFIC landed-confirm — the false-OK killer (claude / any 'transcript'-confirm runtime;
265
+ // codex took the socket-ack short-circuit above). A bare transcript/pane-log mtime BUMP is NOT proof
266
+ // the session took THIS message: a receiver in an active turn advances both mtimes with its OWN
267
+ // rendering even when our paste was swallowed at the turn boundary (incident 2026-06-23: ok=true,
268
+ // message gone). The ONLY proof is a NEW transcript record CARRYING our envelope — for claude, a
269
+ // queue-operation `content` (busy → enqueued) or the user-turn message (idle), written sub-second so
270
+ // a short grace covers it. The receiver's own assistant/tool turn never reproduces the
271
+ // `<iap from-personality=…>` wrapper, so it cannot forge a confirm. No such record within the grace →
272
+ // false-FAIL (the sender retries) — NOT a false-OK (silent loss the contract forbids).
259
273
  const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload))
260
274
  const sleep = seam.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
261
- const graceMs = confirmGraceMs(target.runtime)
275
+ const graceMs = confirmGraceMs()
262
276
  const graceDeadline = monotonicMs() + graceMs
263
277
  while (true) {
264
278
  if (confirm(envelope)) return ok(undefined)