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.

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.
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.
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.
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).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.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.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.lean-ctx skill with what is true in this session (wake, front-loaded tools, shaping), so it never contradicts the mod./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.
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.
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)hooks/register.ts 850 lines1// 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