@agfpd/iapeer 0.4.98 → 0.4.99

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.98",
3
+ "version": "0.4.99",
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",
@@ -45,10 +45,11 @@
45
45
  // the `cwd` field on every line; codex: session_meta.payload.cwd), and match that against the
46
46
  // registry. A file whose cwd is no peer's is ignored — a human's own session never notifies.
47
47
 
48
- import { existsSync, openSync, closeSync, fstatSync, readSync, readdirSync, statSync } from 'node:fs'
48
+ import { existsSync, readdirSync, statSync } from 'node:fs'
49
49
  import { join } from 'node:path'
50
50
  import type { LifecycleConfig } from '../lifecycle/index.ts'
51
51
  import type { NoticeBoard } from './notices.ts'
52
+ import { readSessionSlices } from './sessionfiles.ts'
52
53
 
53
54
  /** How much of a session file's tail is parsed. The evidence is always at the end. */
54
55
  const TAIL_BYTES = 256 * 1024
@@ -229,32 +230,6 @@ export function parseCodexRollout(text: string, sinceMs: number, head = ''): Raw
229
230
  // The sweep — find changed session files, attribute them, raise notices
230
231
  // ─────────────────────────────────────────────────────────────────────────────
231
232
 
232
- function readTail(path: string, maxBytes = TAIL_BYTES): { tail: string; head: string } | null {
233
- try {
234
- const fd = openSync(path, 'r')
235
- try {
236
- const size = fstatSync(fd).size
237
- if (size <= 0) return null
238
- const off = Math.max(0, size - maxBytes)
239
- const buf = Buffer.alloc(size - off)
240
- readSync(fd, buf, 0, buf.length, off)
241
- let head = ''
242
- if (off > 0) {
243
- // The tail cut away the file's start — read a HEAD slice too (codex session_meta
244
- // is line 1, and a long session pushes it far out of the tail).
245
- const hb = Buffer.alloc(Math.min(64 * 1024, off))
246
- readSync(fd, hb, 0, hb.length, 0)
247
- head = hb.toString('utf8')
248
- }
249
- return { tail: buf.toString('utf8'), head }
250
- } finally {
251
- closeSync(fd)
252
- }
253
- } catch {
254
- return null
255
- }
256
- }
257
-
258
233
  function safeReaddir(dir: string): string[] {
259
234
  try {
260
235
  return readdirSync(dir)
@@ -336,12 +311,12 @@ export function scanMuteEvents(deps: ScanDeps, sinceMs: number): MuteDetection[]
336
311
  }
337
312
  // claude: ~/.claude/projects/<slug>/<sessionId>.jsonl — one level of slug dirs.
338
313
  for (const file of changedJsonl(deps.claudeProjectsDir, floor, 1)) {
339
- const t = readTail(file)
314
+ const t = readSessionSlices(file, TAIL_BYTES)
340
315
  if (t) push(parseClaudeTranscript(t.tail, floor), 'claude')
341
316
  }
342
317
  // codex: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
343
318
  for (const file of changedJsonl(deps.codexSessionsDir, floor, CODEX_WALK_DEPTH)) {
344
- const t = readTail(file)
319
+ const t = readSessionSlices(file, TAIL_BYTES)
345
320
  if (t) push(parseCodexRollout(t.tail, floor, t.head), 'codex')
346
321
  }
347
322
  return out
@@ -0,0 +1,121 @@
1
+ // Session-file reading — the ONE place that knows how to get evidence out of a runtime's own
2
+ // session JSONL (claude transcript / codex rollout). Shared by every cwd-attributed detector:
3
+ // mutewatch (docs/19), turnwatch (docs/15 §Activity).
4
+ //
5
+ // WHY THIS MODULE EXISTS — a bug, measured on real bytes 16.07.2026.
6
+ //
7
+ // Both detectors need two slices of a session file: the TAIL (where the evidence is) and, for
8
+ // codex, the HEAD (where `session_meta` states the cwd that attributes the file to a peer). The
9
+ // head was read as a FIXED 64 KB slice. That is wrong, and it failed silently:
10
+ //
11
+ // `session_meta` is ONE line, and it carries the session's composed doctrine — so its size
12
+ // scales with the peer's own instructions. Measured on this host: 45 KB for one peer, 65 KB
13
+ // for another, and **79 KB** for another. A 64 KB slice cuts that line mid-JSON, the parse
14
+ // throws, no cwd is ever found, and the file is treated as unattributable — i.e. as a human's
15
+ // own session. The peer VANISHES from the detector entirely.
16
+ //
17
+ // The failure mode is the nastiest kind: no error, no log, no partial result — a real peer that
18
+ // simply never appears, and the bigger its doctrine the more certainly it disappears. It was
19
+ // invisible in tests because fixtures have small metas; it surfaced only against the live tree,
20
+ // where the peer with the largest doctrine was the one silently missing.
21
+ //
22
+ // THE RULE: never slice a line at a fixed size to parse it. Read the LINE. `readFirstLine`
23
+ // scans forward until the first newline (bounded), so it is correct at any doctrine size and
24
+ // costs one extra chunk read only for the files that need it.
25
+
26
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs'
27
+
28
+ /** Chunk size for the forward scan. One read covers the common case. */
29
+ const HEAD_CHUNK_BYTES = 64 * 1024
30
+
31
+ /**
32
+ * Upper bound on the first line. Generous on purpose — a doctrine can grow, and the cost of
33
+ * being wrong is a peer disappearing, while the cost of a larger bound is one read of a file
34
+ * we are already reading. A line longer than this yields '' (unattributable) rather than a
35
+ * truncated parse that would lie.
36
+ */
37
+ export const MAX_FIRST_LINE_BYTES = 4 * 1024 * 1024
38
+
39
+ /**
40
+ * The file's FIRST LINE (without the trailing newline), read forward in bounded chunks.
41
+ * Returns '' if the bound is hit before a newline — the honest "cannot read it" rather than a
42
+ * fragment. Never throws.
43
+ */
44
+ export function readFirstLine(path: string, maxBytes = MAX_FIRST_LINE_BYTES): string {
45
+ let fd: number | null = null
46
+ try {
47
+ fd = openSync(path, 'r')
48
+ const size = fstatSync(fd).size
49
+ if (size <= 0) return ''
50
+ const limit = Math.min(size, maxBytes)
51
+ let acc = ''
52
+ let off = 0
53
+ while (off < limit) {
54
+ const want = Math.min(HEAD_CHUNK_BYTES, limit - off)
55
+ const buf = Buffer.alloc(want)
56
+ const got = readSync(fd, buf, 0, want, off)
57
+ if (got <= 0) break
58
+ const chunk = buf.subarray(0, got).toString('utf8')
59
+ const nl = chunk.indexOf('\n')
60
+ if (nl >= 0) return acc + chunk.slice(0, nl)
61
+ acc += chunk
62
+ off += got
63
+ }
64
+ // Reached EOF within the bound with no newline: the whole file IS one line — return it.
65
+ // (`off < limit` above only exits early when we hit the bound BEFORE EOF; a file smaller
66
+ // than the bound falls through to here with its complete single line in `acc`.)
67
+ if (off >= size) return acc
68
+ return '' // hit the bound before EOF → refuse to hand back a fragment of a longer line
69
+ } catch {
70
+ return ''
71
+ } finally {
72
+ if (fd !== null) {
73
+ try {
74
+ closeSync(fd)
75
+ } catch {
76
+ /* best-effort */
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ export interface SessionSlices {
83
+ /** The last `tailBytes` of the file — where every detector's evidence lives. */
84
+ tail: string
85
+ /** The file's first line, when the tail did not already contain the file start. Empty
86
+ * otherwise (the tail already has it) or when it could not be read. */
87
+ head: string
88
+ }
89
+
90
+ /**
91
+ * Read a session file's tail plus, when the tail cut the start away, its first line.
92
+ * Returns null for an empty/unreadable file. Never throws — a detector sweep must survive any
93
+ * file on disk.
94
+ */
95
+ export function readSessionSlices(path: string, tailBytes: number): SessionSlices | null {
96
+ let fd: number | null = null
97
+ try {
98
+ fd = openSync(path, 'r')
99
+ const size = fstatSync(fd).size
100
+ if (size <= 0) return null
101
+ const off = Math.max(0, size - tailBytes)
102
+ const buf = Buffer.alloc(size - off)
103
+ readSync(fd, buf, 0, buf.length, off)
104
+ const tail = buf.toString('utf8')
105
+ closeSync(fd)
106
+ fd = null
107
+ // Only when the tail cut the start away does the first line need its own read.
108
+ const head = off > 0 ? readFirstLine(path) : ''
109
+ return { tail, head }
110
+ } catch {
111
+ return null
112
+ } finally {
113
+ if (fd !== null) {
114
+ try {
115
+ closeSync(fd)
116
+ } catch {
117
+ /* best-effort */
118
+ }
119
+ }
120
+ }
121
+ }
@@ -56,15 +56,14 @@
56
56
  // IDLE codex pane-log freezes (39s→46s). The signal means opposite things per runtime; one
57
57
  // clock that lies differently on each runtime is worse than no clock.
58
58
 
59
- import { existsSync, openSync, closeSync, fstatSync, readSync, readdirSync, statSync } from 'node:fs'
59
+ import { existsSync, readdirSync, statSync } from 'node:fs'
60
60
  import { join } from 'node:path'
61
61
  import type { LifecycleConfig } from '../lifecycle/index.ts'
62
62
  import { appendTurnEvent } from './turnslog.ts'
63
+ import { readSessionSlices } from './sessionfiles.ts'
63
64
 
64
65
  /** Tail parsed per session file. The turn marker is always at the very end. */
65
66
  const TAIL_BYTES = 128 * 1024
66
- /** Head slice for codex's `session_meta` (line 1 — a long rollout pushes it out of the tail). */
67
- const HEAD_BYTES = 64 * 1024
68
67
  /** Walk depth for the codex YYYY/MM/DD tree. */
69
68
  const CODEX_WALK_DEPTH = 3
70
69
  /** How far back the FIRST sweep looks, to seed state for sessions already running when the
@@ -238,30 +237,6 @@ export class ActivityBoard {
238
237
  // The sweep
239
238
  // ─────────────────────────────────────────────────────────────────────────────
240
239
 
241
- function readTail(path: string): { tail: string; head: string } | null {
242
- try {
243
- const fd = openSync(path, 'r')
244
- try {
245
- const size = fstatSync(fd).size
246
- if (size <= 0) return null
247
- const off = Math.max(0, size - TAIL_BYTES)
248
- const buf = Buffer.alloc(size - off)
249
- readSync(fd, buf, 0, buf.length, off)
250
- let head = ''
251
- if (off > 0) {
252
- const hb = Buffer.alloc(Math.min(HEAD_BYTES, off))
253
- readSync(fd, hb, 0, hb.length, 0)
254
- head = hb.toString('utf8')
255
- }
256
- return { tail: buf.toString('utf8'), head }
257
- } finally {
258
- closeSync(fd)
259
- }
260
- } catch {
261
- return null
262
- }
263
- }
264
-
265
240
  function safeReaddir(dir: string): string[] {
266
241
  try {
267
242
  return readdirSync(dir)
@@ -335,11 +310,11 @@ export function scanTurnStates(deps: TurnScanDeps, sinceMs: number): TurnObserva
335
310
  out.push({ ...st, personality, runtime })
336
311
  }
337
312
  for (const file of changedJsonl(deps.claudeProjectsDir, floor, 1)) {
338
- const t = readTail(file)
313
+ const t = readSessionSlices(file, TAIL_BYTES)
339
314
  if (t) push(parseClaudeTurn(t.tail), 'claude')
340
315
  }
341
316
  for (const file of changedJsonl(deps.codexSessionsDir, floor, CODEX_WALK_DEPTH)) {
342
- const t = readTail(file)
317
+ const t = readSessionSlices(file, TAIL_BYTES)
343
318
  if (t) push(parseCodexTurn(t.tail, t.head), 'codex')
344
319
  }
345
320
  return out