@agfpd/iapeer 0.4.26 → 0.4.28

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.28",
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)
@@ -239,6 +240,38 @@ export const claudeAdapter: RuntimeAdapter = {
239
240
  return null
240
241
  },
241
242
 
243
+ /**
244
+ * MID-SESSION nag/upsell auto-dismiss (livability — distinct from bootDialogKeys,
245
+ * which the supervisor drives only until ready). claude pops a ONE-TIME interactive
246
+ * upsell modal AFTER the session is live that BLOCKS a headless peer (no human to
247
+ * answer):
248
+ *
249
+ * Try the new fullscreen renderer?
250
+ * · Flicker-free output …
251
+ * ❯ 1. Yes, try it
252
+ * 2. Not now
253
+ * Enter to confirm · Esc to cancel
254
+ *
255
+ * The default cursor sits on "1. Yes, try it", so a BARE Enter would ENABLE the
256
+ * fullscreen renderer — which switches claude to the alt-screen TUI and changes the
257
+ * surface the pane-log model + composer-occupancy + ready-gate all read off. We must
258
+ * DECLINE: type the literal '2' then Enter ('2','Enter' → "2\r"; '2' is not a named/
259
+ * cursor key, so it is emitted verbatim without a `-l` literal switch — and `-l`
260
+ * would wrongly make the following 'Enter' literal too). VERIFIED-SAFE on the live
261
+ * fleet: '2'+Enter cleared the modal on boris and doc and returned them to the
262
+ * composer; an arrow-step+Enter MIS-FIRED into "tui":"fullscreen" on scriber, and Esc/
263
+ * 'n' are unverified — so this is the ONLY accepted sequence. Match the FULL signature
264
+ * (title AND the "2. Not now" decline row) so a transcript merely DISCUSSING the
265
+ * fullscreen renderer can never trigger a stray keystroke. Future one-time CC upsells
266
+ * of this class extend the match here (each with its own verified-safe decline key).
267
+ */
268
+ nagDismissKeys(pane: string): string[] | null {
269
+ if (pane.includes('Try the new fullscreen renderer?') && pane.includes('2. Not now')) {
270
+ return ['2', 'Enter']
271
+ }
272
+ return null
273
+ },
274
+
242
275
  /**
243
276
  * Ready for the first message iff the input surface is rendered AND no startup
244
277
  * dialog is still up (lifecycle.claudeInputReady, index.ts:272-279;
@@ -280,6 +313,33 @@ export const claudeAdapter: RuntimeAdapter = {
280
313
  return newest > 0 ? newest : null
281
314
  },
282
315
 
316
+ /**
317
+ * IDLE-accounting signal: the content-timestamp of the last meaningful entry in the ACTIVE transcript
318
+ * (newest .jsonl by file mtime), read from its tail. Immune to the file re-save (file-mtime is fresh
319
+ * but the last ENTRY is not) and to the statusline pane-log tick (the transcript is untouched by a
320
+ * status redraw). null when the dir is absent/empty or no timestamped entry sits in the tail.
321
+ */
322
+ lastTurnMtime(cwd: string): number | null {
323
+ let entries: string[]
324
+ try {
325
+ entries = readdirSync(transcriptDir(cwd))
326
+ } catch {
327
+ return null
328
+ }
329
+ let best = { path: '', mt: 0 }
330
+ for (const name of entries) {
331
+ if (!name.endsWith('.jsonl')) continue
332
+ try {
333
+ const path = join(transcriptDir(cwd), name)
334
+ const mt = statSync(path).mtimeMs
335
+ if (mt > best.mt) best = { path, mt }
336
+ } catch {
337
+ /* race */
338
+ }
339
+ }
340
+ return best.path ? lastTimestampedEntryMs(best.path) : null
341
+ },
342
+
283
343
  /**
284
344
  * Resume preflight (fail-loud — never a silent fresh fallback): resolve the
285
345
  * 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 } {
@@ -259,14 +259,35 @@ export interface RuntimeAdapter {
259
259
  */
260
260
  bootDialogKeys(pane: string): string[] | null
261
261
 
262
+ /**
263
+ * MID-SESSION nag/upsell auto-dismiss (livability). Distinct from bootDialogKeys:
264
+ * the boot-driver stops at ready, but claude/codex can pop a ONE-TIME interactive
265
+ * upsell modal AFTER the session is live (e.g. "Try the new fullscreen renderer?")
266
+ * that BLOCKS the pty waiting for a keypress no headless peer answers — it froze the
267
+ * live fleet until a human cleared it. A persistent supervisor watcher (daemon.ts)
268
+ * runs THIS off the authoritative model for the whole session and writes the keys.
269
+ * Return the send-keys args that dismiss with the VERIFIED-SAFE default (decline),
270
+ * else null. Match the FULL modal signature so ordinary content mentioning the
271
+ * feature can never trigger a stray keystroke into the composer. Optional — a runtime
272
+ * with no known mid-session nags (codex/router) omits it. tui only.
273
+ */
274
+ nagDismissKeys?(pane: string): string[] | null
275
+
262
276
  /** Is the input surface ready for the first message? (tui: ready marker present
263
277
  * AND startup dialogs gone). Router runtimes return true (no input surface). */
264
278
  isInputReady(pane: string): boolean
265
279
 
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). */
280
+ /** Newest activity-proxy FILE mtime — used by the READY-GATE (it waits for ANY transcript write past
281
+ * baseline = "the model produced its first turn"). NOT for idle accounting: a live session re-saves
282
+ * this file without a new entry, so it is falsely fresh at idle. null for a router (no proxy). */
268
283
  newestActivityMtime(cwd: string): number | null
269
284
 
285
+ /** Content-timestamp (epoch ms) of the LAST meaningful transcript ENTRY — the reliable IDLE-accounting
286
+ * signal. Unlike newestActivityMtime (file mtime, re-touched at idle) and the pane-log mtime (a
287
+ * statusline re-render ticks it at idle), the entry stream only advances on real turn activity.
288
+ * superviseTick uses THIS for idle/quiet age. null for a router (no transcript). */
289
+ lastTurnMtime(cwd: string): number | null
290
+
270
291
  /** Resume preflight — validate a resume request fail-loud (never silent fresh).
271
292
  * Returns the resolved ref (claude uuid) or ok:false with a reason. */
272
293
  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
@@ -32,3 +32,25 @@ export function nextBootAction(adapter: BootPredicates, viewport: string, enc: K
32
32
  if (adapter.isInputReady(viewport)) return { kind: 'ready' }
33
33
  return { kind: 'wait' }
34
34
  }
35
+
36
+ /** The slice of a RuntimeAdapter the MID-SESSION nag-watcher consumes — the optional
37
+ * nagDismissKeys (claudeAdapter satisfies it; codex/router omit it → no nags). */
38
+ export interface NagPredicate {
39
+ nagDismissKeys?(pane: string): string[] | null
40
+ }
41
+
42
+ export type NagAction = { kind: 'dismiss'; keys: string[]; bytes: Buffer } | { kind: 'none' }
43
+
44
+ /**
45
+ * MID-SESSION nag/upsell step (livability) — the persistent sibling of nextBootAction. Unlike the
46
+ * boot-driver, which STOPS at ready, the daemon loops this for the WHOLE session: a one-time CC upsell
47
+ * modal (e.g. "Try the new fullscreen renderer?") can pop AFTER the session is live and BLOCK the pty on
48
+ * a keypress no headless peer answers. If the adapter recognizes the (FULL-signature) modal it returns
49
+ * the verified-safe DECLINE keys; the caller writes `action.bytes` to the pty (cooldown-guarded so a
50
+ * cleared modal is never double-answered). An adapter with no nagDismissKeys → always 'none'.
51
+ */
52
+ export function nextNagAction(adapter: NagPredicate, viewport: string, enc: KeyEncoding = {}): NagAction {
53
+ const keys = adapter.nagDismissKeys?.(viewport)
54
+ if (keys && keys.length) return { kind: 'dismiss', keys, bytes: keysToBytes(keys, enc) }
55
+ return { kind: 'none' }
56
+ }
@@ -25,7 +25,7 @@ import {
25
25
  stripQueries,
26
26
  } from './protocol.ts'
27
27
  import { attachedPath, geometryPath, pidPath, servePath, sockPath, writeGeometry } from './paths.ts'
28
- import { nextBootAction, type BootPredicates } from './boot.ts'
28
+ import { nextBootAction, nextNagAction, type BootPredicates, type NagPredicate } from './boot.ts'
29
29
  import { modelToPlainText } from './render.ts'
30
30
  import { codexAdapter } from '../launch/adapters/codex.ts'
31
31
  import { claudeAdapter } from '../launch/adapters/claude.ts'
@@ -80,6 +80,12 @@ const BOOT_POLL_MS = 2000
80
80
  // Stop driving after this long even if readiness is never observed (a hung/unknown startup); the
81
81
  // driver is best-effort and never blocks the conduit, which keeps serving regardless.
82
82
  const BOOT_DRIVE_MS = 120_000
83
+ // MID-SESSION nag-watcher cadence: same 2 s model poll as the boot-driver. After answering a nag, hold
84
+ // this long before the next check so a just-dismissed modal (claude repaints it away within a frame) is
85
+ // never re-answered — a stray keystroke would land in the now-visible composer. If the modal genuinely
86
+ // survives the hold (our key did not take), the next tick re-fires, so retries stay bounded, not lost.
87
+ const NAG_POLL_MS = 2000
88
+ const NAG_COOLDOWN_MS = 4000
83
89
 
84
90
  // Bun-native PTY (Bun ≥1.3.5) is not yet in @types/bun — type the slice we use.
85
91
  interface PtyChild {
@@ -541,4 +547,38 @@ export function runSupervisorDaemon(opts: SupervisorDaemonOptions): void {
541
547
  }, BOOT_POLL_MS)
542
548
  ;(driver as { unref?: () => void }).unref?.()
543
549
  }
550
+
551
+ // ── mid-session nag-watcher (livability) ────────────────────────────────────────
552
+ // The boot-driver above STOPS at ready. But claude/codex can pop a ONE-TIME upsell modal
553
+ // MID-SESSION (e.g. "Try the new fullscreen renderer?", which appears on the return-to-prompt
554
+ // boundary after a tool result) that BLOCKS the pty on a keypress no headless peer answers — it
555
+ // froze live fleet peers (boris, doc) until a human cleared it by hand. A PERSISTENT watcher answers
556
+ // the curated, verified-safe DECLINE for that class off the SAME authoritative @xterm model, for the
557
+ // WHOLE session lifetime, exactly like the boot-driver answers startup dialogs — model-read
558
+ // (modelToPlainText) + raw pty-byte-write (keysToBytes). Cooldown-guarded: after firing, hold
559
+ // NAG_COOLDOWN_MS so a just-dismissed modal is never double-answered (a stray keystroke would land in
560
+ // the now-visible composer). Only runs for a runtime whose adapter declares nagDismissKeys.
561
+ const nagAdapter = BOOT_ADAPTERS[runtime] as NagPredicate | undefined
562
+ if (nagAdapter?.nagDismissKeys) {
563
+ let nagFiredMs = 0
564
+ const nagWatch = setInterval(() => {
565
+ if (down) {
566
+ clearInterval(nagWatch)
567
+ return
568
+ }
569
+ if (Date.now() - nagFiredMs < NAG_COOLDOWN_MS) return // let a just-answered modal repaint away first
570
+ let action
571
+ try {
572
+ const enc = { appCursorKeys: xterm.modes.applicationCursorKeysMode }
573
+ action = nextNagAction(nagAdapter, modelToPlainText(xterm, cols, rows), enc)
574
+ } catch {
575
+ return // transient model read — retry next tick
576
+ }
577
+ if (action.kind === 'dismiss') {
578
+ child.terminal.write(action.bytes)
579
+ nagFiredMs = Date.now()
580
+ }
581
+ }, NAG_POLL_MS)
582
+ ;(nagWatch as { unref?: () => void }).unref?.()
583
+ }
544
584
  }