@agfpd/iapeer 0.4.26 → 0.4.27

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.26",
3
+ "version": "0.4.27",
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",
@@ -24,6 +24,7 @@ import { homedir } from 'os'
24
24
  import { join } from 'path'
25
25
  import { readdirSync, realpathSync, statSync } from 'fs'
26
26
  import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
27
+ import { lastTimestampedEntryMs } from './transcriptTail.ts'
27
28
 
28
29
  // ─────────────────────────────────────────────────────────────────────────────
29
30
  // Boot dialog + ready markers (lifecycle claudeBootDialog / claudeInputReady)
@@ -280,6 +281,33 @@ export const claudeAdapter: RuntimeAdapter = {
280
281
  return newest > 0 ? newest : null
281
282
  },
282
283
 
284
+ /**
285
+ * IDLE-accounting signal: the content-timestamp of the last meaningful entry in the ACTIVE transcript
286
+ * (newest .jsonl by file mtime), read from its tail. Immune to the file re-save (file-mtime is fresh
287
+ * but the last ENTRY is not) and to the statusline pane-log tick (the transcript is untouched by a
288
+ * status redraw). null when the dir is absent/empty or no timestamped entry sits in the tail.
289
+ */
290
+ lastTurnMtime(cwd: string): number | null {
291
+ let entries: string[]
292
+ try {
293
+ entries = readdirSync(transcriptDir(cwd))
294
+ } catch {
295
+ return null
296
+ }
297
+ let best = { path: '', mt: 0 }
298
+ for (const name of entries) {
299
+ if (!name.endsWith('.jsonl')) continue
300
+ try {
301
+ const path = join(transcriptDir(cwd), name)
302
+ const mt = statSync(path).mtimeMs
303
+ if (mt > best.mt) best = { path, mt }
304
+ } catch {
305
+ /* race */
306
+ }
307
+ }
308
+ return best.path ? lastTimestampedEntryMs(best.path) : null
309
+ },
310
+
283
311
  /**
284
312
  * Resume preflight (fail-loud — never a silent fresh fallback): resolve the
285
313
  * newest transcript uuid for cwd via the claude-slug dir scan (lifecycle.
@@ -38,6 +38,7 @@
38
38
  import { homedir } from 'os'
39
39
  import { join } from 'path'
40
40
  import { readFileSync, readdirSync, realpathSync, statSync } from 'fs'
41
+ import { lastTimestampedEntryMs } from './transcriptTail.ts'
41
42
  import type { ControlCommand, ControlPlan, LaunchAdapterConfig, LaunchSpec, RuntimeAdapter } from '../types.ts'
42
43
 
43
44
  // ─────────────────────────────────────────────────────────────────────────────
@@ -114,9 +115,16 @@ function canonicalPath(p: string): string {
114
115
  * (the model produced its first turn); idle accounting reads the same proxy.
115
116
  */
116
117
  function newestCodexSessionMtime(cwd: string): number | null {
118
+ return newestCodexSession(cwd)?.mt ?? null
119
+ }
120
+
121
+ /** The newest ~/.codex/sessions/**\/*.jsonl whose recorded session cwd realpath-matches `cwd`, as
122
+ * {path, mt}, or null when none. Shared by newestActivityMtime (file-mtime) and lastTurnMtime (needs
123
+ * the PATH to read the tail). Codex nests its session logs in a date tree, so the scan recurses. */
124
+ function newestCodexSession(cwd: string): { path: string; mt: number } | null {
117
125
  const root = join(homedir(), '.codex', 'sessions')
118
126
  const target = canonicalPath(cwd)
119
- let newest = 0
127
+ let best = { path: '', mt: 0 }
120
128
  function visit(dir: string): void {
121
129
  let entries
122
130
  try {
@@ -134,14 +142,14 @@ function newestCodexSessionMtime(cwd: string): number | null {
134
142
  if (canonicalPath(codexSessionCwd(path) ?? '') !== target) continue
135
143
  try {
136
144
  const mt = statSync(path).mtimeMs
137
- if (mt > newest) newest = mt
145
+ if (mt > best.mt) best = { path, mt }
138
146
  } catch {
139
147
  /* race — entry vanished between readdir and stat */
140
148
  }
141
149
  }
142
150
  }
143
151
  visit(root)
144
- return newest > 0 ? newest : null
152
+ return best.path ? best : null
145
153
  }
146
154
 
147
155
  // ─────────────────────────────────────────────────────────────────────────────
@@ -259,6 +267,17 @@ export const codexAdapter: RuntimeAdapter = {
259
267
  return newestCodexSessionMtime(cwd)
260
268
  },
261
269
 
270
+ /**
271
+ * IDLE-accounting signal: the content-timestamp of the last meaningful entry in the active codex
272
+ * session jsonl (newest cwd-matching file), read from its tail. codex entries (event_msg /
273
+ * response_item) carry a top-level `timestamp`, so this reflects real turn activity — immune to a
274
+ * file re-save and to a statusline pane-log tick. null when no cwd-matching session / no timestamp.
275
+ */
276
+ lastTurnMtime(cwd: string): number | null {
277
+ const s = newestCodexSession(cwd)
278
+ return s ? lastTimestampedEntryMs(s.path) : null
279
+ },
280
+
262
281
  /**
263
282
  * Resume preflight (fail-loud — never a silent fresh fallback). Codex resumes
264
283
  * via 'resume --last' with NO ref: it matches the newest session for cwd
@@ -68,6 +68,10 @@ export const notifierAdapter: RuntimeAdapter = {
68
68
  return null
69
69
  },
70
70
 
71
+ lastTurnMtime(_cwd: string): number | null {
72
+ return null // router — no transcript
73
+ },
74
+
71
75
  /** Nothing to resume: a router does not replay a transcript. */
72
76
  resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
73
77
  return { ok: true }
@@ -98,6 +98,10 @@ export const telegramAdapter: RuntimeAdapter = {
98
98
  return null
99
99
  },
100
100
 
101
+ lastTurnMtime(_cwd: string): number | null {
102
+ return null // router — no transcript
103
+ },
104
+
101
105
  /**
102
106
  * Nothing to resume: a router does not replay a transcript, so resume cannot
103
107
  * fail-loud the way claude/codex do — there is no prior session ref to resolve
@@ -0,0 +1,64 @@
1
+ // Shared transcript-tail reader for the idle-accounting signal.
2
+ //
3
+ // The RELIABLE "last real turn" time is the content-timestamp of the LAST meaningful transcript entry —
4
+ // NOT the file mtime (a live claude session RE-SAVES its .jsonl without a new entry, so file-mtime is
5
+ // falsely fresh at idle) and NOT the pane-log mtime (a statusline / footer re-render writes pty bytes at
6
+ // idle, so the pane-log is falsely fresh too). The transcript ENTRY stream only advances on actual turn
7
+ // activity (user/assistant/tool/turn_duration for claude; event_msg/response_item for codex), so the
8
+ // last entry that carries a `timestamp` is the true idle signal — immune to both noise sources.
9
+
10
+ import { openSync, readSync, closeSync, statSync } from 'fs'
11
+
12
+ /**
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.
20
+ */
21
+ export function lastTimestampedEntryMs(path: string, tailBytes = 65536): number | null {
22
+ let fd: number
23
+ try {
24
+ fd = openSync(path, 'r')
25
+ } catch {
26
+ return null
27
+ }
28
+ try {
29
+ 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
49
+ }
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
56
+ }
57
+ }
58
+ return null
59
+ } catch {
60
+ return null
61
+ } finally {
62
+ closeSync(fd)
63
+ }
64
+ }
@@ -81,6 +81,10 @@ export const voicetalkAdapter: RuntimeAdapter = {
81
81
  return null
82
82
  },
83
83
 
84
+ lastTurnMtime(_cwd: string): number | null {
85
+ return null // router — no transcript
86
+ },
87
+
84
88
  /** Nothing to resume: a router does not replay a transcript, so resume cannot
85
89
  * fail-loud the way claude/codex do. Always {ok:true}. */
86
90
  resolveResume(_cwd: string): { ok: boolean; ref?: string; reason?: string } {
@@ -263,10 +263,17 @@ export interface RuntimeAdapter {
263
263
  * AND startup dialogs gone). Router runtimes return true (no input surface). */
264
264
  isInputReady(pane: string): boolean
265
265
 
266
- /** Newest activity-proxy mtime for the ready-gate AND idle accounting:
267
- * claude transcript / codex session jsonl mtime; null for a router (no proxy). */
266
+ /** Newest activity-proxy FILE mtime — used by the READY-GATE (it waits for ANY transcript write past
267
+ * baseline = "the model produced its first turn"). NOT for idle accounting: a live session re-saves
268
+ * this file without a new entry, so it is falsely fresh at idle. null for a router (no proxy). */
268
269
  newestActivityMtime(cwd: string): number | null
269
270
 
271
+ /** Content-timestamp (epoch ms) of the LAST meaningful transcript ENTRY — the reliable IDLE-accounting
272
+ * signal. Unlike newestActivityMtime (file mtime, re-touched at idle) and the pane-log mtime (a
273
+ * statusline re-render ticks it at idle), the entry stream only advances on real turn activity.
274
+ * superviseTick uses THIS for idle/quiet age. null for a router (no transcript). */
275
+ lastTurnMtime(cwd: string): number | null
276
+
270
277
  /** Resume preflight — validate a resume request fail-loud (never silent fresh).
271
278
  * Returns the resolved ref (claude uuid) or ok:false with a reason. */
272
279
  resolveResume(cwd: string): { ok: boolean; ref?: string; reason?: string }
@@ -344,21 +344,6 @@ export function clearEphemeralArmed(cfg: LifecycleConfig, identity: string): voi
344
344
  }
345
345
  }
346
346
 
347
- /** mtime (ms) of a hosted session's pane-log (`<logDir>/<identity>.log`) — the RAW TUI
348
- * render byte-stream the supervisor appends for the session's whole life. It advances
349
- * ~1s while the TUI is rendering a turn (the "esc to interrupt" elapsed counter ticks
350
- * during BOTH model generation AND a tool run) and goes quiet at the prompt. So it is
351
- * the truest active-turn liveness signal — the SAME mtime telegram-runtime reads for its
352
- * typing/activity indicator — and unlike the transcript proxy it does NOT go quiet during
353
- * a long model generation (no JSONL write until the message completes). null if absent. */
354
- function paneLogMtimeMs(cfg: LifecycleConfig, identity: string): number | null {
355
- try {
356
- return statSync(join(cfg.logDir, `${identity}.log`)).mtimeMs
357
- } catch {
358
- return null
359
- }
360
- }
361
-
362
347
  function deathsPath(cfg: LifecycleConfig, identity: string): string {
363
348
  return join(cfg.stateDir, `${identity}.deaths`)
364
349
  }
@@ -1157,15 +1142,14 @@ export interface SuperviseOutcome {
1157
1142
  export interface SuperviseDeps {
1158
1143
  env?: NodeJS.ProcessEnv
1159
1144
  nowMs?: number
1160
- /** Activity proxy seam (default: the runtime adapter's transcript/session mtime). Tests inject a
1161
- * controlled value to exercise the idle-age floor without a real transcript on disk. */
1162
- newestActivityMtime?: (runtime: string, cwd: string) => number | null
1163
- /** Liveness seam (default: the real `sessionAlive` = pty-host OR tmux-fallback). Tests inject a
1164
- * fixed verdict so the idle path is exercised hermetically — no live tmux/pty session needed. */
1145
+ /** IDLE-proxy seam (default: the runtime adapter's `lastTurnMtime` = content-ts of the last meaningful
1146
+ * transcript entry). This is the RELIABLE idle signal — NOT the transcript FILE mtime (re-saved at
1147
+ * idle) and NOT the pane-log mtime (a statusline re-render ticks it at idle); both falsely keep an
1148
+ * idle session young. Tests inject a controlled value to exercise the idle/quiet age hermetically. */
1149
+ lastTurnMtime?: (runtime: string, cwd: string) => number | null
1150
+ /** Liveness seam (default: the real `sessionAlive` = pty-host pid-file check). Tests inject a fixed
1151
+ * verdict so the idle path is exercised hermetically — no live pty session needed. */
1165
1152
  sessionAlive?: (sock: string, identity: string) => boolean
1166
- /** Pane-log mtime seam (default: the real pane-log file mtime). Tests inject a controlled value to
1167
- * exercise the ephemeral active-turn vs idle distinction without a real pane-log on disk. */
1168
- paneLogMtime?: (identity: string) => number | null
1169
1153
  }
1170
1154
 
1171
1155
  /** Resolved per-tick context threaded into superviseOnePeer (deps already defaulted by superviseTick). */
@@ -1173,9 +1157,8 @@ interface SuperviseCtx {
1173
1157
  cfg: LifecycleConfig
1174
1158
  env: NodeJS.ProcessEnv
1175
1159
  nowMs: number
1176
- activityMtime: (runtime: string, cwd: string) => number | null
1160
+ lastTurnMt: (runtime: string, cwd: string) => number | null
1177
1161
  isAlive: (sock: string, identity: string) => boolean
1178
- paneLogMt: (identity: string) => number | null
1179
1162
  verbose: boolean
1180
1163
  trace: (fields: Record<string, string | number | undefined>) => void
1181
1164
  }
@@ -1188,7 +1171,7 @@ interface SuperviseCtx {
1188
1171
  * returns exactly one outcome; the trace side-effects mirror the prior inline behaviour.
1189
1172
  */
1190
1173
  function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome {
1191
- const { cfg, env, nowMs, activityMtime, isAlive, paneLogMt, verbose, trace } = ctx
1174
+ const { cfg, env, nowMs, lastTurnMt, isAlive, verbose, trace } = ctx
1192
1175
  // H4 — FIRST, before any reap. A launchd-managed peer is read-only.
1193
1176
  if (isLaunchdManaged(s.personality, env)) {
1194
1177
  if (verbose) trace({ identity: s.identity, action: 'skipped-launchd', outcome: 'read-only-h4' })
@@ -1240,34 +1223,24 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1240
1223
  trace({ identity: s.identity, action: 'reaped-gone', death: gone.death, reason: gone.reason, outcome: 'fresh-next-msg' })
1241
1224
  return { identity: s.identity, action: 'reaped-gone', reason: gone.reason }
1242
1225
  }
1243
- // Idle accounting via the PANE-LOG (TUI render-stream) mtime — the RELIABLE idle
1244
- // signal: it advances ~1s while the TUI renders ANY turn (model generation OR a tool
1245
- // run — the "esc to interrupt" elapsed counter ticks) and goes quiet ONLY at the
1246
- // prompt. NOT the transcript file mtime: a live claude session RE-TOUCHES its session
1247
- // .jsonl ~every 30-45min WITHOUT a real turn (a system/bridge re-save), so the
1248
- // transcript proxy is perpetually < idleSecs and a claude peer NEVER idle-reaps — the
1249
- // fleet-reap incident: a warm claude pile, last real turn hours ago, never reaped;
1250
- // codex's session file is NOT re-touched, so codex reaps (the claude-vs-codex split).
1251
- // The transcript stays the launch ready-gate's signal (it needs "a real turn produced
1252
- // output"); only this idle proxy moves to the pane-log. FLOORED at wokeAt: a session
1253
- // cannot be idle longer than it has been awake (a wake that produced no turn →
1254
- // mt=wokeAt → reaped idleSecs after the wake, never out from under an attaching op).
1226
+ // Idle accounting via the LAST MEANINGFUL TRANSCRIPT ENTRY's content-timestamp (adapter.lastTurnMtime)
1227
+ // — the only RELIABLE idle signal. The two raw mtimes both report FALSE freshness for an idle session:
1228
+ // • pane-log mtime — a statusline / footer re-render writes pty bytes at idle (the clock/usage/ctx%
1229
+ // redraw + Claude Code's own update-check/token-counter), so the pane-log ticks at the prompt. The
1230
+ // 0.4.16 invariant "the pane-log goes quiet ONLY at the prompt" was broken by the statusline.
1231
+ // • transcript FILE mtime — a live claude session RE-SAVES its .jsonl without appending a new entry,
1232
+ // so the file is fresh while the last real turn was hours ago (the 0.4.16 re-touch).
1233
+ // Both made real peers live idle 3-5h UNREAPED (incident 23.06: perplex/mrwriter/doc). The transcript
1234
+ // ENTRY stream only advances on actual turn activity, so the last timestamped entry is the true last
1235
+ // turn. The launch ready-gate keeps newestActivityMtime (file-mtime advance = "a turn produced
1236
+ // output"). FLOORED at wokeAt: a freshly-woken session that produced no turn yet (lastTurn from a PRIOR
1237
+ // session is old) must not be reaped out from under an attaching op — max(lastTurn, wokeAt) reaps it
1238
+ // idleSecs after the wake, never before. null lastTurn (unreadable / churned out of the tail) → wokeAt
1239
+ // governs (an old wokeAt = woken-long-ago = idle → reaped; a recent wokeAt = just-woken → protected).
1255
1240
  const ephemeral = isEphemeralPeer(s.cwd)
1256
- const plMt = paneLogMt(s.identity)
1257
- let mt: number
1258
- if (plMt !== null) {
1259
- mt = Math.max(plMt, s.wokeAt)
1260
- } else {
1261
- // Pane-log MISSING (a legacy pre-pane-log supervisor) → fall back to the transcript
1262
- // proxy, NEVER to wokeAt-as-activity: a wokeAt-only age would read an ACTIVE legacy
1263
- // session's idle from its hours-old wokeAt and reap it MID-WORK. The transcript is
1264
- // false-fresh at idle (so an IDLE legacy peer lingers warm until its next respawn
1265
- // earns a pane-log — bounded, self-heals), but an ACTIVE legacy peer keeps a
1266
- // genuinely-fresh transcript and is correctly protected. Safe direction preserved.
1267
- let tx = 0
1268
- try { tx = activityMtime(s.runtime, s.cwd) ?? 0 } catch { /* no adapter → wokeAt */ }
1269
- mt = Math.max(tx, s.wokeAt)
1270
- }
1241
+ let lt = 0
1242
+ try { lt = lastTurnMt(s.runtime, s.cwd) ?? 0 } catch { /* unreadable → wokeAt floor */ }
1243
+ const mt = Math.max(lt, s.wokeAt)
1271
1244
  const ageSecs = Math.floor((nowMs - mt) / 1000)
1272
1245
  // wake_policy:ephemeral M2 — die-after-reply: an ARMED ephemeral session (the
1273
1246
  // daemon routed its outbound reply) is reaped after a QUIET window, checked
@@ -1337,9 +1310,8 @@ function superviseOnePeer(s: SessionState, ctx: SuperviseCtx): SuperviseOutcome
1337
1310
  export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): SuperviseOutcome[] {
1338
1311
  const env = deps.env ?? process.env
1339
1312
  const nowMs = deps.nowMs ?? Date.now()
1340
- const activityMtime = deps.newestActivityMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).newestActivityMtime(c))
1313
+ const lastTurnMt = deps.lastTurnMtime ?? ((rt: string, c: string) => getAdapter(rt as Runtime).lastTurnMtime(c))
1341
1314
  const isAlive = deps.sessionAlive ?? sessionAlive
1342
- const paneLogMt = deps.paneLogMtime ?? ((identity: string) => paneLogMtimeMs(cfg, identity))
1343
1315
  const verbose = superviseLogVerbose(env)
1344
1316
  // Pane-log volume cap (launch/cmdlog.ts capPaneLogs): the per-identity pane-log
1345
1317
  // (<logDir>/<identity>.log) is the RAW TUI byte stream pipe-pane/the supervisor
@@ -1356,7 +1328,7 @@ export function superviseTick(cfg: LifecycleConfig, deps: SuperviseDeps = {}): S
1356
1328
  const trace = (fields: Record<string, string | number | undefined>): void =>
1357
1329
  appendLifecycleEvent(cfg.eventLogDir, { ev: 'supervise', ...fields }, { env, nowMs })
1358
1330
  const out: SuperviseOutcome[] = []
1359
- const ctx: SuperviseCtx = { cfg, env, nowMs, activityMtime, isAlive, paneLogMt, verbose, trace }
1331
+ const ctx: SuperviseCtx = { cfg, env, nowMs, lastTurnMt, isAlive, verbose, trace }
1360
1332
  for (const s of readSessionStates(cfg)) {
1361
1333
  // Per-peer ISOLATION: a throw while evaluating ONE peer must NOT abort the whole sweep — that is
1362
1334
  // the silent fleet-wide reap-outage class (one malformed peer-state and NO peer gets reaped, the