@agfpd/iapeer 0.4.27 → 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.27",
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",
@@ -240,6 +240,38 @@ export const claudeAdapter: RuntimeAdapter = {
240
240
  return null
241
241
  },
242
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
+
243
275
  /**
244
276
  * Ready for the first message iff the input surface is rendered AND no startup
245
277
  * dialog is still up (lifecycle.claudeInputReady, index.ts:272-279;
@@ -259,6 +259,20 @@ 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
@@ -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
  }