@agfpd/iapeer 0.4.30 → 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.30",
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",
@@ -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 {