You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
Added
Session-health nudge — the semi-automatic resolution of #190. The original issue wanted the plugin to inject /compact//clear into the Claude Code main loop at idle boundaries; the feasibility verification (see #190 discussion) established that no programmatic trigger exists (hooks observe only — PreCompact cannot initiate; the Skill tool excludes built-ins; the Agent SDK has no compaction API; parallel session resume is unsafe) and that the session is shared with the operator's own terminal work, vetoing autonomous clears outright. This release ships the issue's state machine with the actuator swapped to the operator:
Measurement (Stop hook): hooks/enforce-lark-reply.mjs now records each session's exact current context size — the transcript's last main-loop assistant usage (input + cache_read + cache_creation; sidechain/subagent entries skipped) — into a sidecar session-stats.json, keyed per session_id (several sessions share the hook: bot megasession + dev sessions). Pruned at 48 h / 32 entries; atomic tmp+rename; wrapped in its own try/catch so stats failures can never influence the hook's verdict (measured ~0.9 M tokens on the reference deployment — exact, no estimation).
Nudge (plugin): new SessionHealthMonitor (src/session-health.ts, 60 s unref'd tick) sends the OWNER a Feishu DM when ALL of: heaviest recent session > LARK_SESSION_NUDGE_TOKEN_THRESHOLD (default 400 k), channel inbound-idle > LARK_SESSION_NUDGE_IDLE_MS (default 30 min, reset by every forwarded message), channel quiet (best-effort: queue depth 0 AND no pending IM ack reactions), and the reminder ladder is due. Reminders back off exponentially: LARK_SESSION_NUDGE_COOLDOWN_MS (default 2 h) is the BASE — after the n-th unanswered nudge the next is due base × 2^(n-1) later (0 / +2 h / +6 h / +14 h cumulative undelayed; each rung anchors on the ACTUAL previous send, so a busy-channel delay shifts the rest instead of catch-up double-firing), hard-capped at 4 nudges per episode (with the 2 h base the 4th rung is also the last that can still see fresh stats inside the 24 h stats window). Episode semantics: closes when the next Stop-event measurement drops ≥30% vs the last-nudged value or below threshold (operator compacted — a re-arm floor of 1.25× the post-drop level prevents an instant re-nudge when the compacted level still sits above threshold); an exhausted episode re-arms on ≥25% NEW accumulation or re-engages after 24 h of ladder silence (the time-based reset is scoped to the EXHAUSTED state only — wiping a live re-arm floor would re-nudge with zero regrowth, and wiping mid-ladder state under a long-busy channel would turn the 4-per-episode cap into 4-per-day); episode state (ladder, floor, token comparisons) is bound to the session it was recorded for, and a heaviest-session identity flip starts a fresh evaluation instead of firing a spurious cross-session close. DM failure applies a 15 min retry backoff without consuming a rung; ticks are single-flight (an overlapping interval fire reports busy instead of interleaving state writes around the await). Off by default (LARK_SESSION_NUDGE_ENABLED=true + LARK_OWNER_OPEN_ID required). The DM carries a [n/4] rung counter + next-backoff hint and suggests typing /compact (or /clear) now, at the idle boundary — instead of fullness-driven auto-compaction firing mid-burst.
Prevention (mcpServerInstructions): the host session is now told to delegate heavy multi-step channel tasks to subagents — subagent transcripts stay out of main-loop history, attacking the dominant share of accumulation at the source.
hooks/enforce-lark-reply.mjs: writeSessionStats called right after transcript load so every early-exit path still records; hooks/test-enforce-lark-reply.mjs gains a default tmp LARK_SESSION_STATS_PATH for ALL spawns (keeps the suite out of the real sidecar) + 10 new cases (S1–S10: exact sum, no-usage / sidechain-only no-file, corruption recovery, sidechain skip, unwritable-path verdict isolation, prune semantics incl. future-ts rejection, 32-entry count cap, loop-break skip, zeroed-usage fallback, day-old orphan-tmp sweep with live-writer protection).
src/queue.ts: get pending() — in-flight chain count for the quiet gate. src/channel.ts: getQueueDepth(). src/index.ts: monitor wiring; noteInbound() piggybacks on the existing message handler (covers IM / doc-comment / reaction / cronjob injection with zero channel changes); nudge DM intentionally NOT registered in BotMessageTracker (reactions on operator-facing plumbing should not reach Claude).
scripts/session-nudge-smoke.ts (new, wired into scripts/test.sh): 24 cases — disable switch, stats gating, threshold, startup idle guard, noteInbound reset, busy gate, happy path + not-yet-due, due-rung re-nudge, stale-stats rejection, send-failure backoff cycle, heaviest-selection robustness (stale/future/garbage entries), start/stop idempotence, full 0/+2h/+6h/+14h ladder schedule with [n/4] counters + exhaustion, delayed-rung shift (incl. a post-backoff probe pinning anchor-on-actual-send), drop-close + re-arm floor with the 1.25× boundary pinned exactly (740k blocks / 750k arms), below-threshold close, exhausted-episode regrowth re-arm, exhausted 24 h re-engage, zero-base constructor floor, heaviest-identity switch (no spurious cross-session close), floor surviving 24 h (no zero-regrowth re-nudge), mid-ladder surviving 24 h (position preserved), single-flight overlap, and floor down-ratchet after a second deeper compact (24 cases total).
Review-round hardening (2 agents: correctness/concurrency + security): time-based reset scoped to exhausted state; per-session episode identity; single-flight tick guard; optionalPositiveNumber for the three numeric knobs + 60 s constructor floor on the ladder base; prompt-cronjob injections now count as activity via SchedulerOptions.onActivity (they bypass the message handler); isQuiet extended with pending IM ack reactions and re-documented as best-effort; nudge DM routed through sanitizeOutboundText (repo convention for all outbound text); hook hardening — zeroed-usage entries fall back to the previous valid measurement, far-future timestamps pruned, day-old orphaned tmp files swept, LARK_SESSION_STATS_PATH override accepted only for .json targets, and the loop-break staleness trade-off documented in place. Round-2 verification added the re-arm-floor down-ratchet (a second deeper compact lowers the floor to 25% above the lowest observed level — a stale 1M floor must not suppress nudges across the whole band on regrowth) and documented one accepted limitation: episode state is single-slot, so two concurrently-heavy sessions whose heaviest-rank alternates can launder exhaustion into extra reminders — bounded by the 15-min attempt backoff and idle/quiet gates, not worth a per-session map for a one-megasession deployment.
Migration
Fully opt-in: default behavior is unchanged (LARK_SESSION_NUDGE_ENABLED defaults to false; the Stop hook's stats write is passive and fail-quiet).
No new Feishu scopes (owner DM uses the existing im:message send permission, same as scheduler owner notices).
Deferred to upstream: a programmatic compact trigger (feature request to Claude Code — hook return value or SDK API) would allow replacing the DM with direct action; #190's snapshot/restore design stays parked until then.