← back to Kickbacks Ai Upstream

src/reassert.ts

89 lines

/** Whether the injected webview patch should be (re)asserted on this timer
 *  tick. The extension reapplies the block on an interval so a Claude Code
 *  self-update / relaunch that silently overwrites index.js is healed without
 *  the user doing a manual reload — but ONLY while the extension is healthy:
 *  an ad in hand and not kill-switched. Pure; the single source of truth for
 *  the reassert health gate. applyPatch itself is idempotent (writes only when
 *  the file actually drifted), so a steady state is a cheap no-op and this
 *  never fights the kill-switch (gated on `killed`).
 *
 *  Sign-in is intentionally NOT part of the gate: a signed-out user holding a
 *  DEMO ad must reassert too, so the preview self-heals like the real product.
 *  `haveAd` already implies an ad is in hand (real or demo) — when signed out
 *  with no demo ad it is false, so the prior signed-out behaviour is preserved.
 */
export function shouldReassert(s: {
  haveAd: boolean;
  killed: boolean;
}): boolean {
  return s.haveAd && !s.killed;
}

/** Tiered self-heal policy for the "patched file on disk but the webview
 *  cached the pre-patch module" desync (block.desync) — the failure mode
 *  where ads silently stop rendering even though `isPatched()` is true, so
 *  the cheap drift-only reasserts (reassertWebview / reassertTick) can't see
 *  it. THIS is the case that forced a manual "Re-apply patch now".
 *
 *  Escalation ladder, cheapest + least disruptive first, and ONLY when there
 *  is positive evidence the user is actively using Claude Code (recent
 *  transcript writes) yet our overlay telemetry has gone silent. When the
 *  user is simply idle there is NO block.start to expect — that is not a
 *  desync, so we must not disrupt. Pure + the single source of truth so the
 *  cadence stays testable and deliberately non-aggressive. */
export const DESYNC_DEFAULTS = {
  // CC transcript written within this window => the user is actively using
  // Claude Code right now (independent of our overlay).
  ccActiveMs: 120_000,
  // Overlay silent at least this long (while CC is active) => treat as a
  // real desync worth healing. Patience knob: high enough that a briefly
  // unfocused panel or a normal lull never trips it.
  silenceMs: 300_000,
  toastCooldownMs: 30 * 60_000,
} as const;

export type DesyncAction = "none" | "cycle" | "reload" | "toast";

export function desyncDecision(i: {
  now: number;
  startedAt: number;            // detector start (activation) — the silence floor
  lastApplyAt: number;
  lastBlockStartAt: number;     // last overlay render = our telemetry heartbeat
  ccActivityAgeMs: number | null; // age of last CC transcript write; null = unknown
  healthy: boolean;             // shouldReassert(): signed-in + have-ad + not-killed
  cyclePatchTried: boolean;
  reloadTried: boolean;
  toastShownAt: number;
  /** True while CC is in an active tool_use turn (e.g. a long-running
   *  sub-agent the orchestrator is waiting on). Disruptive escalation
   *  (reload, toast) is deferred until the turn completes so we never
   *  interrupt an ongoing task. cycle (file-identity nudge only) is still
   *  allowed. null/undefined = unknown → treated as not active. */
  ccTurnActive?: boolean | null;
}, k: { ccActiveMs: number; silenceMs: number; toastCooldownMs: number } = DESYNC_DEFAULTS):
  { action: DesyncAction; reason: string } {
  if (!i.healthy) return { action: "none", reason: "unhealthy" };
  if (i.lastApplyAt === 0) return { action: "none", reason: "no-apply" };
  // Overlay rendered since the last apply => in sync, nothing to heal.
  if (i.lastBlockStartAt >= i.lastApplyAt) return { action: "none", reason: "in-sync" };
  // Idle gate (the key non-aggression rule): act ONLY when CC is actively in
  // use. No/unknown activity => a missing block.start is EXPECTED, not a desync.
  if (i.ccActivityAgeMs == null || i.ccActivityAgeMs > k.ccActiveMs)
    return { action: "none", reason: "cc-idle" };
  // Patience: require sustained overlay silence before any disruption.
  const silentMs = i.now - Math.max(i.lastBlockStartAt, i.startedAt);
  if (silentMs < k.silenceMs) return { action: "none", reason: "within-grace" };
  // Escalation ladder: cheap file-identity nudge, then webview reload, then
  // (last resort, user-consented) a window reload.
  if (!i.cyclePatchTried) return { action: "cycle", reason: "escalate" };
  // Defer disruptive actions while a CC turn is actively running (sub-agent).
  // reloadWebviewContent would interrupt the task; the toast would mislead
  // the user into thinking a reload is safe right now. cycle (already fired)
  // only touches the file identity — non-disruptive. When the turn finishes,
  // the next tick re-evaluates and escalates to reload if still needed.
  if (i.ccTurnActive === true) return { action: "none", reason: "cc-turn-active" };
  if (!i.reloadTried) return { action: "reload", reason: "escalate" };
  if (i.now - i.toastShownAt >= k.toastCooldownMs) return { action: "toast", reason: "escalate" };
  return { action: "none", reason: "cooldown" };
}