SLOPSHOPPER

lean-ctx

Wake on background job completion, remove redundant lean-ctx hook context, shape native Bash output, keep core tools in front, and show per-session usage.

newguardcommandprompttimer
★ 3,871v0.2.0Apache-2.0updated 2026-10-07yvgude/lean-ctx/integrations/claude-code-mod
A shopper browsing a rack in a slop shop
README

lean-ctx for Claude Code

A Claude Code mod (Community tier) that makes lean-ctx part of Claude Code itself instead of something the model has to be talked into using. Requires Claude Code 2.1.287+; tested with 2.1.287.

Install

lean-ctx claude-mod install     # or answer "y" in `lean-ctx setup`
lean-ctx claude-mod status
lean-ctx claude-mod uninstall

The lean-ctx binary carries the mod and installs it from a local marketplace in its data dir — nothing is downloaded, and the mod's version is the engine's version, so every lean-ctx update rolls it forward (lean-ctx setup/update refresh an existing install; they never install it on their own). It becomes active in new Claude Code sessions, or after /reload-plugins.

Why

Measured on 569 Claude Code sessions (30 days): ~19 % of all model requests were waiting — 7,551 status polls for 1,030 background jobs plus 1,770 sleep calls — and every extra request re-reads the whole context (median 149k tokens). The mod removes those requests instead of compressing around them.

What it does

  • Wake, don't poll. Watches ctx_shell(run_in_background=true) jobs in Claude Code's process (state from ctx_shell's structuredContent, then its JSON text, then the [background:…] header) and starts one new turn when they finish — including failed jobs, which MCP reports as error results — with the job id, exit status and recovery handle. Bare sleep N waits are answered while a job is watched. Verified live: the model started a job, ended its turn, and was woken by the mod with Job … finished · exit 0 (no polls).
  • Shape, don't redirect. Large native Bash stdout (≥ 2,000 chars) is compressed by lean-ctx's command-aware engine via the internal ctx_shape tool — the same patterns, secret redaction and output filters as ctx_shell, ending lossy results with a recovery handle. stderr, small output, images, background launches and commands with LEAN_CTX_RAW=1 / lean-ctx raw stay byte-for-byte; any failure keeps the native result. Turn it off with the shape_native_output setting.
  • Focused tool surface. Front-loads ctx_read, ctx_search, ctx_shell, ctx_compose, ctx_callgraph, ctx_session (setting front_loaded_tools) and defers every other lean-ctx tool — including the shell alias — behind ToolSearch, with descriptions byte-identical. Verified by capturing the real /v1/messages request: exactly the configured six keep their schema.
  • One channel for session guidance. By default, removes lean-ctx-authored settings-hook context from SessionStart and UserPromptSubmit attachments, including matching lean-ctx blocks when Claude joins several hooks' text. Exact leading signatures keep engine and plugin attachments, other hooks' output, and unrelated security or policy reminders intact. The MCP server instructions and live lean-ctx skill remain the guidance channel. Set keep_hook_context to true to keep the hook context; its default is false. /leanctx reports how many attachments and characters the mod removed.
  • Live skill. Prefixes the lean-ctx skill with what is true in this session (wake, front-loaded tools, shaping), so it never contradicts the mod.
  • Compaction that keeps lean-ctx usable. Before Claude Code compacts the main conversation, lean-ctx saves its session state and the summarizer is told to keep what lean-ctx still depends on (recovery handles, ids of jobs that will still report) — your own /compact … instructions are kept. The first prompt afterwards carries the lean-ctx session state (task, decisions, findings) once, capped at ~500 tokens. Subagent compactions are untouched.
  • /leanctx. This session's requests, input/output/cache tokens, ToolSearch-only requests, lean-ctx calls, answered sleeps, wakes and shaped Bash outputs, and dropped hook attachments/characters — from Claude Code's own turn.step usage and hook text, not estimates.

It keeps nothing after the session, sends no telemetry, sets no gateway policy and stays inert when no lean-ctx MCP server is connected.

Security model

A mod runs inside Claude Code with your permissions. This one only calls the mods API methods the validator lists below; it reaches lean-ctx through Claude Code's own MCP connection ($.mcp.call), never through a shell or the network. ctx_shape is an internal host hook: callable, but never advertised to agents.

Develop

The sources are canonical in rust/src/templates/claude_mod/ (embedded in the binary); this directory is the development workspace, regenerated with cargo run --example gen_rules --features dev-tools and drift-checked in CI. Edit the templates, regenerate, then:

claude --plugin-dir ./integrations/claude-code-mod      # one session, hot reload
claude plugin validate --strict integrations/claude-code-mod
claude plugin validate --strict integrations/claude-code-mod/.claude-plugin/plugin.json
claude plugin test integrations/claude-code-mod
tsc -p integrations/claude-code-mod/tsconfig.json

tsc needs the version-matched declarations in .claude-plugin/types/ (git-ignored), which Claude Code writes the first time it loads the mod with --plugin-dir. The validator reports:

  ❯ ./register.ts hooks: tool.describe{tool=/"^mcp__lean[-_]ctx__[A-Za-z0-9_-]+$"/}, skill.prompt{skill=lean-ctx}, prompt.attachment, session.start, tool.call, session.compact, prompt.submit, turn.step, command.run{command=leanctx}
  ❯ ./register.ts calls: $.clock.every (via startWatcher), $.command.register (via ensureMeterCommand), $.mcp.call (via pollWatchedJobs, shapeBash), $.prompt.submit (via submitWake)
Source 1 files
hooks/register.ts 850 lines
1// SPDX-License-Identifier: Apache-2.0
2import type {
3  EngineInterface,
4  McpToolResult,
5  Register,
6  Timer,
7  ToolCallResult,
8  TurnStepResult,
9} from "claude-code";
10
11const DEFAULT_FRONT_LOADED_TOOLS = [
12  "ctx_read",
13  "ctx_search",
14  "ctx_shell",
15  "ctx_compose",
16  "ctx_callgraph",
17  "ctx_session",
18];
19// Every tool of the lean-ctx server, not only `ctx_*`: the `shell` alias of
20// ctx_shell would otherwise stay front-loaded under `alwaysLoad`.
21const LEAN_CTX_TOOL_PATTERN = /^mcp__lean[-_]ctx__[A-Za-z0-9_-]+$/;
22const LEAN_CTX_TOOL_NAME = /^mcp__(lean[-_]ctx)__([A-Za-z0-9_-]+)$/;
23const SHELL_TOOLS = new Set(["ctx_shell", "shell"]);
24const WATCH_CONTEXT =
25  "This background job is being watched; you will be woken automatically on completion, so do not poll or sleep.";
26const WATCH_INTERVAL_MS = 2_000;
27// Consecutive unreadable status checks before a job is handed back to the
28// model's own polling; one transient MCP hiccup must not drop the watch.
29const MAX_STATUS_MISSES = 5;
30const MAX_TAIL_LINES = 20;
31const MAX_TAIL_LINE_CHARS = 300;
32// Native Bash stdout below this is kept byte-for-byte: the shaping gain cannot
33// pay for the extra hop, and short output is usually exactly what was asked.
34const SHAPE_MIN_CHARS = 2_000;
35// Commands that explicitly ask for exact bytes are never shaped.
36const RAW_INTENT = /\bLEAN_CTX_(?:RAW|DISABLED)=1\b|\blean-ctx\s+raw\b/;
37// Upper bound for the session state injected after a compaction (~500 tokens).
38const MAX_RESUME_CHARS = 2_000;
39type LeanCtxHookTextEnd = { end: string; continues?: readonly string[] };
40type LeanCtxHookTextSignature = { start: string; ends: readonly LeanCtxHookTextEnd[] };
41// Exact leading signatures emitted by observe.rs. Keep these stable and update
42// the cross-language drift test there whenever an authored hook text changes.
43const LEAN_CTX_HOOK_TEXT_SIGNATURES: readonly LeanCtxHookTextSignature[] = [
44  {
45    start: "lean-ctx active: ALWAYS use ctx_* MCP tools instead of native equivalents.",
46    ends: [
47      { end: "Exclusive tools: ctx_compose, ctx_callgraph, ctx_knowledge, ctx_session." },
48    ],
49  },
50  {
51    start: "CRITICAL: ALWAYS use lean-ctx ctx_* tools as mapped below.",
52    ends: [
53      {
54        end: "Use native Read for out-of-root; `lean-ctx doctor` shows effective roots.",
55        continues: [
56          "Advanced tools not in your profile are available via ctx_call(tool=<name>) gateway.",
57          "Prefer stdlib and native platform alternatives before adding code or dependencies.",
58          "Solution efficiency ladder:",
59          "challenge every requirement, prefer deletion.",
60        ],
61      },
62      {
63        end: "Advanced tools not in your profile are available via ctx_call(tool=<name>) gateway.",
64        continues: [
65          "Prefer stdlib and native platform alternatives before adding code or dependencies.",
66          "Solution efficiency ladder:",
67          "challenge every requirement, prefer deletion.",
68        ],
69      },
70      { end: "Prefer stdlib and native platform alternatives before adding code or dependencies." },
71      { end: "Preserve validation, security, and error-handling." },
72    ],
73  },
74  {
75    start: "lean-ctx shadow mode: native read/search/shell calls auto-route to ctx_* — no tool-mapping needed.",
76    ends: [
77      {
78        end: "ctx_search(action=semantic) (by meaning).",
79        continues: [
80          "Prefer stdlib and native platform alternatives before adding code or dependencies.",
81          "Solution efficiency ladder:",
82          "challenge every requirement, prefer deletion.",
83        ],
84      },
85      {
86        end: "ctx_callgraph (callers).",
87        continues: [
88          "Prefer stdlib and native platform alternatives before adding code or dependencies.",
89          "Solution efficiency ladder:",
90          "challenge every requirement, prefer deletion.",
91        ],
92      },
93      {
94        end: "ctx_knowledge / ctx_session (memory).",
95        continues: [
96          "Prefer stdlib and native platform alternatives before adding code or dependencies.",
97          "Solution efficiency ladder:",
98          "challenge every requirement, prefer deletion.",
99        ],
100      },
101      { end: "Prefer stdlib and native platform alternatives before adding code or dependencies." },
102      { end: "Preserve validation, security, and error-handling." },
103    ],
104  },
105  {
106    start: "lean-ctx policy (mechanically enforced):",
107    ends: [
108      { end: "are overruled by this policy." },
109    ],
110  },
111] as const;
112
113const HOOK_CONTEXT_FRAME = /(?:^|\n)([A-Za-z]+ hook additional context: )$/;
114
115type JsonRecord = Record<string, unknown>;
116type WatchJob = { server: string; id: string; contextSent: boolean; misses: number };
117type JobStatus = { state: "running" | "terminal" | "unknown"; exitCode?: number; archiveId?: string; summary?: string };
118type FinishedJob = { job: WatchJob; status: JobStatus; response: McpToolResult };
119type JobCheck = {
120  key: string;
121  job: WatchJob;
122  status?: JobStatus;
123  response?: McpToolResult;
124};
125type Metrics = {
126  requests: number;
127  input: number;
128  output: number;
129  cacheRead: number;
130  cacheCreation: number;
131  toolSearchOnly: number;
132  leanCtxCalls: number;
133  sleepsAnswered: number;
134  wakesDelivered: number;
135  shapedCalls: number;
136  shapedCharsSaved: number;
137  droppedHookAttachments: number;
138  droppedHookChars: number;
139  compactions: number;
140};
141
142const watchedJobs = new Map<string, WatchJob>();
143const metrics: Metrics = {
144  requests: 0,
145  input: 0,
146  output: 0,
147  cacheRead: 0,
148  cacheCreation: 0,
149  toolSearchOnly: 0,
150  leanCtxCalls: 0,
151  sleepsAnswered: 0,
152  wakesDelivered: 0,
153  shapedCalls: 0,
154  shapedCharsSaved: 0,
155  droppedHookAttachments: 0,
156  droppedHookChars: 0,
157  compactions: 0,
158};
159let watcher: Timer | undefined;
160let watcherTickInProgress = false;
161let leanCtxSeen = false;
162// The lean-ctx MCP server name as this session spells it (`lean-ctx`/`lean_ctx`).
163let leanServer: string | undefined;
164let commandAttempted = false;
165let commandRegistered = false;
166// Set by a main-loop compaction; the next prompt carries the lean-ctx session state once.
167let resumePending = false;
168
169export const register: Register = (on, options) => {
170  const frontLoaded = getFrontLoadedTools(options);
171  const shapeNative = asRecord(options).shape_native_output !== false;
172  const keepHookContext = asRecord(options).keep_hook_context === true;
173
174  on("tool.describe", { tool: LEAN_CTX_TOOL_PATTERN }, async ($, event) => {
175    const match = getLeanCtxTool(event.tool);
176    if (!match) return { description: event.description, isDeferred: event.isDeferred };
177
178    leanCtxSeen = true;
179    leanServer = match.server;
180    await ensureMeterCommand($);
181    return {
182      description: event.description,
183      isDeferred: !frontLoaded.has(match.tool),
184    };
185  });
186
187  // Live skill (concept K5): the shipped SKILL.md is static; this prefixes it
188  // with what is true in *this* session, so the guidance never contradicts the
189  // mod (e.g. "no need to poll") or the configured tool surface. Byte-stable
190  // for a given configuration, so it never churns the prompt cache.
191  on("skill.prompt", { skill: "lean-ctx" }, async ($, event, next) => {
192    const base = await next(event);
193    return { text: `${liveSkillHeader(frontLoaded, shapeNative)}\n\n${base.text}` };
194  });
195
196  // The MCP instructions and live skill carry the durable guidance. Drop only
197  // exact lean-ctx SessionStart/UserPromptSubmit hook blocks; other authors and
198  // other hook events pass through unchanged. No agentId check keeps this
199  // channel diet active in both the main loop and subagents.
200  on("prompt.attachment", async ($, event, next) => {
201    if (
202      keepHookContext ||
203      event.origin.kind !== "hook" ||
204      (event.origin.event !== "SessionStart" && event.origin.event !== "UserPromptSubmit")
205    ) {
206      return next(event);
207    }
208    const stripped = stripLeanCtxHookText(event.text);
209    if (stripped.removedChars === 0) return next(event);
210    metrics.droppedHookAttachments += 1;
211    metrics.droppedHookChars += stripped.removedChars;
212    return { text: stripped.text || null };
213  });
214
215  on("session.start", async ($, event, next) => {
216    resetSessionState();
217    // Detect lean-ctx up front so `/leanctx` exists before the first model
218    // request (tool.describe only fires once a request renders the tools).
219    // A server still connecting is picked up later by the tool hooks.
220    if (!leanCtxSeen) {
221      try {
222        const match = (await $.tool.list())
223          .map((tool) => getLeanCtxTool(tool.name))
224          .find((found) => found !== undefined);
225        if (match) {
226          leanCtxSeen = true;
227          leanServer = match.server;
228        }
229      } catch {
230        // Listing is best-effort; the tool hooks still detect lean-ctx.
231      }
232    }
233    if (leanCtxSeen) await ensureMeterCommand($);
234    return next(event);
235  });
236
237  on("tool.call", async ($, event, next) => {
238    const fields = event as unknown as JsonRecord;
239    const tool = typeof fields.tool === "string" ? fields.tool : "";
240    const leanCtxTool = getLeanCtxTool(tool);
241
242    if (!leanCtxTool) {
243      if (tool === "Bash" && watchedJobs.size > 0 && isSleepWait(readString(fields, "command"))) {
244        metrics.sleepsAnswered += 1;
245        return { result: sleepAnswer() };
246      }
247      if (tool === "Bash" && shapeNative) {
248        return shapeBash($, readString(fields, "command"), await next(event));
249      }
250      return next(event);
251    }
252
253    leanCtxSeen = true;
254    leanServer = leanCtxTool.server;
255    metrics.leanCtxCalls += 1;
256    await ensureMeterCommand($);
257
258    if (
259      SHELL_TOOLS.has(leanCtxTool.tool) &&
260      watchedJobs.size > 0 &&
261      isSleepWait(readString(fields, "command"))
262    ) {
263      metrics.sleepsAnswered += 1;
264      return { result: sleepAnswer() };
265    }
266
267    if (SHELL_TOOLS.has(leanCtxTool.tool) && fields.run_in_background) {
268      const result = await next(event);
269      const jobId = extractJobId(result);
270      if (!jobId) return result;
271
272      const key = watchKey(leanCtxTool.server, jobId);
273      const job = watchedJobs.get(key) ?? { server: leanCtxTool.server, id: jobId, contextSent: false, misses: 0 };
274      watchedJobs.set(key, job);
275      if (!startWatcher($)) {
276        watchedJobs.delete(key);
277        stopWatcherWhenIdle();
278        return result;
279      }
280      return addWatchContext(result, job);
281    }
282
283    if (SHELL_TOOLS.has(leanCtxTool.tool) && fields.background_action === "status") {
284      const jobId = readString(fields, "job_id");
285      const job = jobId ? watchedJobs.get(watchKey(leanCtxTool.server, jobId)) : undefined;
286      const result = await next(event);
287      return job ? addWatchContext(result, job) : result;
288    }
289
290    return next(event);
291  });
292
293  // K8 compaction coordination: before the main conversation is compacted,
294  // lean-ctx persists its session state and the summarizer is told what lean-ctx
295  // still depends on; afterwards the next prompt carries that state once.
296  // Subagent compactions and every failure pass through untouched.
297  on("session.compact", async ($, event, next) => {
298    if (!leanServer || event.agentId) return next(event);
299    const server = leanServer;
300    try {
301      await $.mcp.call(server, "ctx_session", { action: "save" });
302    } catch {
303      // Saving is best-effort; compaction proceeds regardless.
304    }
305    const instructions = [event.instructions, compactionInstructions()].filter(Boolean).join("\n\n");
306    const result = await next({ ...event, instructions });
307    if (!result.skip) {
308      metrics.compactions += 1;
309      resumePending = true;
310    }
311    return result;
312  });
313
314  on("prompt.submit", async ($, event, next) => {
315    if (!resumePending || !leanServer) return next(event);
316    resumePending = false;
317    const state = await sessionState($, leanServer);
318    return state ? next({ ...event, context: [...(event.context ?? []), state] }) : next(event);
319  });
320
321  on("turn.step", async function* ($, event, next) {
322    const result = yield* next(event);
323    if (leanCtxSeen) recordRequest(result);
324    return result;
325  });
326
327  on("command.run", { command: "leanctx" }, async ($, event, next) => {
328    return commandRegistered ? { text: formatMetrics() } : next(event);
329  });
330};
331
332// Shape, don't redirect (concept K2): the native Bash call already ran; its
333// stdout goes through lean-ctx's command-aware compressor (`ctx_shape`, which
334// also applies lean-ctx's secret redaction and output filters) instead of
335// denying the call and forcing a round trip to ctx_shell. Lossy results end
336// with a recovery handle. stderr is kept verbatim. Fail-open everywhere.
337async function shapeBash(
338  $: EngineInterface,
339  command: string | undefined,
340  result: ToolCallResult,
341): Promise<ToolCallResult> {
342  if (!leanServer || !command || RAW_INTENT.test(command)) return result;
343  if ("deny" in result || result.isError) return result;
344  const record = asRecord(result.result);
345  const stdout = typeof record.stdout === "string" ? record.stdout : "";
346  if (
347    stdout.length < SHAPE_MIN_CHARS ||
348    record.isImage ||
349    record.backgroundTaskId ||
350    record.persistedOutputPath
351  ) {
352    return result;
353  }
354  try {
355    // A non-error result without a special-exit interpretation exited 0, so the
356    // engine takes its success path (folds build/test noise); otherwise it gets
357    // no exit code and keeps the unknown-outcome guard.
358    const exitCode = record.returnCodeInterpretation ? {} : { exit_code: 0 };
359    const shaped = await $.mcp.call(leanServer, "ctx_shape", {
360      tool: "Bash",
361      command,
362      output: stdout,
363      ...exitCode,
364    });
365    if (shaped.isError) return result;
366    const text = mcpText(shaped);
367    if (!text || text.length >= stdout.length) return result;
368    metrics.shapedCalls += 1;
369    metrics.shapedCharsSaved += stdout.length - text.length;
370    return { result: { ...record, stdout: text }, context: result.context } as ToolCallResult;
371  } catch {
372    return result;
373  }
374}
375
376// What the compaction summary must keep for lean-ctx to stay usable: ids of
377// jobs that will still wake the model, and recovery handles of compressed
378// output the ongoing work refers to. Deterministic for a given watch set.
379function compactionInstructions(): string {
380  const ids = [...watchedJobs.values()].map((job) => job.id).sort();
381  const lines = [
382    "lean-ctx: keep, verbatim, any lean-ctx recovery handles the ongoing work still relies on (ctx_expand ids and `full original at …` paths).",
383  ];
384  if (ids.length > 0) {
385    lines.push(
386      `lean-ctx: background job(s) ${ids.join(", ")} are still running and will report when done — keep their ids and do not plan to poll them.`,
387    );
388  }
389  return lines.join("\n");
390}
391
392// The lean-ctx session state (task, decisions, findings) for the first prompt
393// after a compaction, bounded so it can never crowd out the conversation.
394async function sessionState($: EngineInterface, server: string): Promise<string | undefined> {
395  try {
396    const response = await $.mcp.call(server, "ctx_session", { action: "status" });
397    if (response.isError) return undefined;
398    const text = mcpText(response).trim();
399    if (!text) return undefined;
400    const bounded = text.length <= MAX_RESUME_CHARS ? text : `${text.slice(0, MAX_RESUME_CHARS)}…`;
401    return `lean-ctx session state, restored after compaction:\n${bounded}`;
402  } catch {
403    return undefined;
404  }
405}
406
407function liveSkillHeader(frontLoaded: Set<string>, shapeNative: boolean): string {
408  const front = [...frontLoaded].sort().join(", ");
409  return [
410    "## Live in this session (lean-ctx Claude Code mod)",
411    "- Background jobs (`ctx_shell(run_in_background=true)` or Bash `run_in_background`) wake you when they finish: start them, then continue other work or end the turn. Never `sleep` or poll their status.",
412    `- In your tool list: ${front}. Other lean-ctx tools load with one ToolSearch \`select:\` or run via \`ctx_call\`.`,
413    shapeNative
414      ? "- Native Bash output is compressed automatically (a recovery handle ends any lossy result); there is no need to route commands through ctx_shell just for compression."
415      : "- Native Bash output is passed through unchanged in this session.",
416  ].join("\n");
417}
418
419function getFrontLoadedTools(options: unknown): Set<string> {
420  const configured = asRecord(options).front_loaded_tools;
421  if (!Array.isArray(configured)) return new Set(DEFAULT_FRONT_LOADED_TOOLS);
422  return new Set(configured.filter((value): value is string => typeof value === "string"));
423}
424
425function getLeanCtxTool(name: string): { server: string; tool: string } | undefined {
426  const match = LEAN_CTX_TOOL_NAME.exec(name);
427  if (!match?.[1] || !match[2]) return undefined;
428  return { server: match[1], tool: match[2] };
429}
430
431function readString(record: JsonRecord, key: string): string | undefined {
432  return typeof record[key] === "string" ? (record[key] as string) : undefined;
433}
434
435function isSleepWait(command: string | undefined): boolean {
436  return !!command &&
437    /^\s*sleep\s+\d+(?:\.\d+)?(?:\s*&&\s*(?=[^;\n]*(?:\bstatus\b|\btail\b))[^;\n]*)?\s*$/i.test(command);
438}
439
440function sleepAnswer(): string {
441  const ids = [...watchedJobs.values()].map((job) => job.id).sort();
442  return `No need to wait: background job(s) ${ids.join(", ")} are watched and you will be woken automatically; end the turn or continue other work.`;
443}
444
445function watchKey(server: string, id: string): string {
446  return `${server}\u0000${id}`;
447}
448
449function addWatchContext(result: ToolCallResult, job: WatchJob): ToolCallResult {
450  if (job.contextSent || "deny" in result) return result;
451  job.contextSent = true;
452  const context = [...(result.context ?? [])];
453  if (!context.includes(WATCH_CONTEXT)) context.push(WATCH_CONTEXT);
454  return { ...result, context };
455}
456
457function extractJobId(result: ToolCallResult): string | undefined {
458  if ("deny" in result) return undefined;
459  return readString(readShellFields(result.result, result.text), "jobId");
460}
461
462// ctx_shell reports background state as MCP `structuredContent`
463// (`{ jobId, state, exitCode?, archiveId?, summary }`, or `{ jobId, errorCode }`
464// for an expired id — rust/src/server/tool_trait.rs). Hosts often render
465// only that object as the text block, so a JSON text block is the second
466// source; the `[background:…]` text header is the last resort.
467function readShellFields(raw: unknown, extraText?: string): JsonRecord {
468  const body = asRecord(raw);
469  const structured = asRecord(body.structuredContent);
470  if (readString(structured, "jobId") || readString(structured, "job_id")) return normalize(structured);
471
472  const texts = [
473    ...(Array.isArray(body.content)
474      ? body.content.map((block) => asRecord(block).text).filter((t): t is string => typeof t === "string")
475      : []),
476    ...(extraText ? [extraText] : []),
477  ];
478  for (const text of texts) {
479    const trimmed = text.trim();
480    if (!trimmed.startsWith("{")) continue;
481    try {
482      const parsed = asRecord(JSON.parse(trimmed));
483      if (readString(parsed, "jobId") || readString(parsed, "job_id")) return normalize(parsed);
484    } catch {
485      // Not a JSON block; try the next source.
486    }
487  }
488
489  const joined = texts.join("\n");
490  const header = /\[(?:auto-)?background:([A-Za-z0-9_-]+)\s*(running|started|completed|failed|cancelled|canceled|not found)?(?:,\s*exit\s+(-?\d+))?/i.exec(joined);
491  if (!header) return {};
492  const word = header[2]?.toLowerCase();
493  return {
494    jobId: header[1],
495    state: word === "started" ? "running" : word === "not found" ? "expired" : word,
496    exitCode: header[3] === undefined ? undefined : Number(header[3]),
497    text: joined,
498  };
499}
500
501function normalize(fields: JsonRecord): JsonRecord {
502  return {
503    jobId: readString(fields, "jobId") ?? readString(fields, "job_id"),
504    state: readString(fields, "errorCode") ? "expired" : readString(fields, "state"),
505    exitCode: getNumber(fields, ["exitCode", "exit_code"]),
506    archiveId: readString(fields, "archiveId") ?? readString(fields, "archive_id"),
507    summary: readString(fields, "summary"),
508  };
509}
510
511function getNumber(record: JsonRecord, keys: string[]): number | undefined {
512  for (const key of keys) {
513    const value = record[key];
514    if (typeof value === "number" && Number.isFinite(value)) return value;
515  }
516  return undefined;
517}
518
519function startWatcher($: EngineInterface): boolean {
520  if (watcher) return true;
521  try {
522    watcher = $.clock.every(WATCH_INTERVAL_MS, () => {
523      void pollWatchedJobs($);
524    });
525    return true;
526  } catch {
527    watcher = undefined;
528    return false;
529  }
530}
531
532function stopWatcherWhenIdle(): void {
533  if (watchedJobs.size > 0 || !watcher) return;
534  try {
535    watcher.cancel();
536  } catch {
537    // Cancellation failure must not affect the model's tool flow.
538  }
539  watcher = undefined;
540}
541
542async function pollWatchedJobs($: EngineInterface): Promise<void> {
543  if (watcherTickInProgress || watchedJobs.size === 0) return;
544  watcherTickInProgress = true;
545  const snapshot = [...watchedJobs.entries()];
546
547  try {
548    const checks = await Promise.all(
549      snapshot.map(async ([key, job]): Promise<JobCheck> => {
550        try {
551          const response = await $.mcp.call(job.server, "ctx_shell", {
552            background_action: "status",
553            job_id: job.id,
554          });
555          // A finished job with a non-zero exit is an MCP error result too;
556          // only the parsed state decides, never `isError`.
557          return { key, job, response, status: inspectJobStatus(response, job.id) };
558        } catch {
559          return { key, job, status: { state: "unknown" } };
560        }
561      }),
562    );
563
564    const finished: FinishedJob[] = [];
565    const lost: WatchJob[] = [];
566    for (const check of checks) {
567      if (watchedJobs.get(check.key) !== check.job) continue;
568      if (!check.status || check.status.state === "running") {
569        check.job.misses = 0;
570        continue;
571      }
572      if (check.status.state === "unknown") {
573        check.job.misses += 1;
574        if (check.job.misses >= MAX_STATUS_MISSES) {
575          watchedJobs.delete(check.key);
576          lost.push(check.job);
577        }
578        continue;
579      }
580      watchedJobs.delete(check.key);
581      if (check.response) finished.push({ job: check.job, status: check.status, response: check.response });
582    }
583    stopWatcherWhenIdle();
584    const text = [
585      finished.length > 0 ? formatWakeSummary(finished) : "",
586      lost.length > 0 ? formatLostNotice(lost) : "",
587    ].filter(Boolean).join("\n\n");
588    if (text) submitWake($, text);
589  } catch {
590    // The watcher can no longer read job state: hand every watched job back
591    // to the model explicitly — it was told it would be woken, so a silent
592    // drop would leave it waiting forever.
593    const lost = [...watchedJobs.values()];
594    watchedJobs.clear();
595    stopWatcherWhenIdle();
596    if (lost.length > 0) submitWake($, formatLostNotice(lost));
597  } finally {
598    watcherTickInProgress = false;
599  }
600}
601
602function formatLostNotice(lost: WatchJob[]): string {
603  const ids = lost.map((job) => job.id).sort();
604  return `lean-ctx can no longer watch background job(s) ${ids.join(", ")}: check each once with ctx_shell(background_action="status", job_id=…) when you need its result.`;
605}
606
607function inspectJobStatus(response: McpToolResult, jobId: string): JobStatus {
608  const fields = readShellFields(response);
609  if (fields.jobId !== undefined && fields.jobId !== jobId) return { state: "unknown" };
610  const state = readString(fields, "state")?.toLowerCase();
611  if (state === "running") return { state: "running" };
612  if (state === "completed" || state === "failed" || state === "cancelled" || state === "canceled" || state === "expired") {
613    return {
614      state: "terminal",
615      exitCode: getNumber(fields, ["exitCode"]),
616      archiveId: readString(fields, "archiveId"),
617      summary: readString(fields, "summary"),
618    };
619  }
620  return { state: "unknown" };
621}
622
623function mcpText(response: McpToolResult): string {
624  return response.content
625    .map((block) => (block.type === "text" ? block.text : ""))
626    .filter(Boolean)
627    .join("\n");
628}
629
630type LeanCtxHookTextBlock = { start: number; end: number };
631
632function stripLeanCtxHookText(text: string): { text: string; removedChars: number } {
633  let remaining = text;
634  let removedChars = 0;
635  while (true) {
636    const block = LEAN_CTX_HOOK_TEXT_SIGNATURES
637      .map((signature) => findLeanCtxHookTextBlock(remaining, signature))
638      .find((candidate) => candidate !== undefined);
639    if (!block) break;
640    const next = removeJoinedTextBlock(remaining, block);
641    if (next === remaining) break;
642    removedChars += remaining.length - next.length;
643    remaining = next;
644  }
645  return { text: remaining, removedChars };
646}
647
648function findLeanCtxHookTextBlock(
649  text: string,
650  signature: LeanCtxHookTextSignature,
651): LeanCtxHookTextBlock | undefined {
652  let start = text.indexOf(signature.start);
653  while (start !== -1) {
654    // Claude Code frames hook context as "<Event> hook additional context: "
655    // on the same line; the frame goes with the block it introduces.
656    const frame = start === 0 || text[start - 1] === "\n" ? "" : HOOK_CONTEXT_FRAME.exec(text.slice(0, start))?.[1];
657    if (frame !== undefined) {
658      const end = findLeanCtxHookTextEnd(text, start, signature);
659      if (end !== undefined) return { start: start - frame.length, end };
660    }
661    start = text.indexOf(signature.start, start + 1);
662  }
663  return undefined;
664}
665
666function findLeanCtxHookTextEnd(
667  text: string,
668  start: number,
669  signature: LeanCtxHookTextSignature,
670): number | undefined {
671  let lineStart = start;
672  while (lineStart <= text.length) {
673    const end = lineEnd(text, lineStart);
674    const line = text.slice(lineStart, end);
675    const ending = signature.ends.find((marker) => line.endsWith(marker.end));
676    if (ending) {
677      const nextLine = nextNonEmptyLineStart(text, end);
678      const next = text.slice(nextLine, lineEnd(text, nextLine));
679      if (!ending.continues?.some((marker) => next.startsWith(marker))) return end;
680      lineStart = nextLine;
681      continue;
682    }
683    if (end === text.length) break;
684    lineStart = text.startsWith("\r\n", end) ? end + 2 : end + 1;
685  }
686}
687
688function nextNonEmptyLineStart(text: string, end: number): number {
689  let next = end;
690  while (next < text.length) {
691    if (text.startsWith("\r\n", next)) next += 2;
692    else if (text[next] === "\n") next += 1;
693    else break;
694    if (text.slice(next, lineEnd(text, next)) !== "") break;
695  }
696  return next;
697}
698
699function lineEnd(text: string, from: number): number {
700  const newline = text.indexOf("\n", from);
701  if (newline === -1) return text.length;
702  return newline > from && text[newline - 1] === "\r" ? newline - 1 : newline;
703}
704
705function removeJoinedTextBlock(text: string, block: LeanCtxHookTextBlock): string {
706  let before = text.slice(0, block.start);
707  let after = text.slice(block.end);
708  if (before.endsWith("\r\n")) before = before.slice(0, -2);
709  else if (before.endsWith("\n")) before = before.slice(0, -1);
710  else if (after.startsWith("\r\n")) after = after.slice(2);
711  else if (after.startsWith("\n")) after = after.slice(1);
712  return `${before}${after}`;
713}
714
715function submitWake($: EngineInterface, text: string): void {
716  try {
717    void $.prompt
718      .submit({ text })
719      .then(() => {
720        metrics.wakesDelivered += 1;
721      })
722      .catch(() => {
723        // A failed wake leaves native status polling available.
724      });
725  } catch {
726    // A failed wake leaves native status polling available.
727  }
728}
729
730function formatWakeSummary(finished: FinishedJob[]): string {
731  const ordered = [...finished].sort((a, b) =>
732    a.job.id < b.job.id ? -1 : a.job.id > b.job.id ? 1 : a.job.server < b.job.server ? -1 : 1,
733  );
734  const sections = ordered.map(({ job, status, response }) => {
735    const handle = recoveryHandle(response, status.archiveId);
736    const lines = tailLines(response, job.id);
737    const body = handle
738      ? `Full output: ${handle}`
739      : status.summary || lines.join("\n") || "No output captured.";
740    const exit = status.exitCode === undefined ? "unknown" : status.exitCode;
741    return `Job ${job.id} finished · exit ${exit}\n${body}`;
742  });
743  return `Watched background job(s) finished:\n${sections.join("\n\n")}`;
744}
745
746function recoveryHandle(response: McpToolResult, archiveId?: string): string | undefined {
747  if (archiveId) return `ctx_expand id=${archiveId}`;
748  const structured = asRecord(response.structuredContent);
749  const direct =
750    readString(structured, "recovery_handle") ??
751    readString(structured, "recoveryHandle") ??
752    readString(structured, "tee_path") ??
753    readString(structured, "teePath");
754  if (direct) return direct;
755
756  const match = /(?:full output at|full output:)\s+([^\]\n]*?)(?:\s+—|\]|$)/i.exec(mcpText(response));
757  return match?.[1]?.trim();
758}
759
760function tailLines(response: McpToolResult, jobId: string): string[] {
761  const escapedId = jobId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
762  const statusLine = new RegExp(`^\\[background:${escapedId}(?:\\s|\\])`, "i");
763  return mcpText(response)
764    .split(/\r?\n/)
765    // Status headers and the structured JSON echo are metadata, not output.
766    .filter((line) => {
767      const trimmed = line.trim();
768      return trimmed.length > 0 && !statusLine.test(trimmed) && !trimmed.startsWith("{");
769    })
770    .slice(-MAX_TAIL_LINES)
771    .map((line) => (line.length <= MAX_TAIL_LINE_CHARS ? line : `${line.slice(0, MAX_TAIL_LINE_CHARS)}…`));
772}
773
774function recordRequest(result: TurnStepResult): void {
775  metrics.requests += 1;
776  if (result.usage) {
777    metrics.input += nonNegative(result.usage.input_tokens);
778    metrics.output += nonNegative(result.usage.output_tokens);
779    metrics.cacheRead += nonNegative(result.usage.cache_read_input_tokens);
780    metrics.cacheCreation += nonNegative(result.usage.cache_creation_input_tokens);
781  }
782  if (
783    result.toolUses.length > 0 &&
784    result.toolUses.every((use) => /(?:^|[_-])tool[_-]?search$/i.test(use.name))
785  ) {
786    metrics.toolSearchOnly += 1;
787  }
788}
789
790function formatMetrics(): string {
791  return [
792    `Requests ${metrics.requests}`,
793    `input ${metrics.input}`,
794    `output ${metrics.output}`,
795    `cache read ${metrics.cacheRead}`,
796    `cache creation ${metrics.cacheCreation}`,
797    `ToolSearch-only ${metrics.toolSearchOnly}`,
798    `lean-ctx calls ${metrics.leanCtxCalls}`,
799    `sleeps answered ${metrics.sleepsAnswered}`,
800    `wakes delivered ${metrics.wakesDelivered}`,
801    `Bash outputs shaped ${metrics.shapedCalls} (−${metrics.shapedCharsSaved} chars)`,
802    `hook attachments dropped ${metrics.droppedHookAttachments} (−${metrics.droppedHookChars} chars)`,
803    `compactions ${metrics.compactions}`,
804  ].join(" · ");
805}
806
807function nonNegative(value: number): number {
808  return Number.isFinite(value) && value > 0 ? value : 0;
809}
810
811async function ensureMeterCommand($: EngineInterface): Promise<void> {
812  if (commandAttempted) return;
813  commandAttempted = true;
814  try {
815    const command = await $.command.register({
816      name: "leanctx",
817      description: "Show per-session Claude Code request and lean-ctx usage.",
818    });
819    commandRegistered = command.command === "leanctx";
820  } catch {
821    // The name may already be taken; keep all other mod behavior fail-open.
822  }
823}
824
825function resetSessionState(): void {
826  metrics.requests = 0;
827  metrics.input = 0;
828  metrics.output = 0;
829  metrics.cacheRead = 0;
830  metrics.cacheCreation = 0;
831  metrics.toolSearchOnly = 0;
832  metrics.leanCtxCalls = 0;
833  metrics.sleepsAnswered = 0;
834  metrics.wakesDelivered = 0;
835  metrics.shapedCalls = 0;
836  metrics.shapedCharsSaved = 0;
837  metrics.droppedHookAttachments = 0;
838  metrics.droppedHookChars = 0;
839  metrics.compactions = 0;
840  resumePending = false;
841  watchedJobs.clear();
842  stopWatcherWhenIdle();
843}
844
845function asRecord(value: unknown): JsonRecord {
846  return value !== null && typeof value === "object" && !Array.isArray(value)
847    ? (value as JsonRecord)
848    : {};
849}
850