Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/claude-session-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Fixed
- Claude context uses the session's captured limit, including 1M windows below 200k tokens.
18 changes: 16 additions & 2 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,10 +375,24 @@ Only providers with a sign-in are shown; a GitHub account without Copilot is lef
adds only `herdr-statusline/` in Claude's config directory (`CLAUDE_CONFIG_DIR`, else `~/.claude`)
and points `statusLine` in that directory's `settings.json` at a wrapper there (creating the file
when Claude has none yet). The wrapper runs your status line command unchanged (none where none was
set) and keeps only the two windows and the account in `~/.herdr/statusline-last.json`, readable only
set). It also stores each session’s model and context limit under `~/.herdr/statusline-context/`,
using a profile hash prefix so Claude config directories do not share session files. Context capture
requires a real directory owned by your uid without group/other permissions; it never changes that
directory’s permissions. Chat requires the installer’s mark, matching profile, session and model,
and a capture no more than six hours old, read through a bounded, no-follow file descriptor.
This needs no subscription usage data. New session captures inspect at most 128 directory entries
for expired captures of their own profile; cleanup is best-effort, not a total file-count bound.
Context replacement and removal first quarantine an entry, then verify it through a descriptor.
Unrecognized entries are restored without overwriting a newcomer, or kept as visible
`statusline-context-recovery-*` entries if restoration is blocked. Publication uses a hard link
and never overwrites an existing target. These pathname-only POSIX operations cannot prevent a
concurrent process running as your uid from replacing a parent directory between operations.
A concurrent same-user process replacing the quarantine name or directory itself during the few microseconds of an operation is also outside what pathname APIs can exclude.
The wrapper keeps only the two windows and the account in `~/.herdr/statusline-last.json`, readable only
by you. A reading counts only for the account that drew it and only until its window resets, and an
answer from the endpoint newer than it wins. Turning it off puts `settings.json` back as it was and
deletes what was added; a file there that it did not write is never replaced or deleted. If you have
deletes its marked files; context removal checks the profile too and removes the context directory
only when empty. Unmarked files are left alone under normal, nonconcurrent use. If you have
since changed the status line yourself and it still runs the wrapper, turning it off changes nothing
and says so: set the status line yourself first. A watching device sees the switch and cannot turn it. It is not available on
Windows.
Expand Down
74 changes: 74 additions & 0 deletions server/claude-context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { expect, it, spyOn } from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SessionSnapshot } from "../shared/protocol.ts";
import { contextLeftPercent } from "../src/lib/compose.ts";
import { claudeProjectDir } from "./claude-store.ts";
import { forgetPaneTranscriptState, forgetTranscriptState, transcriptPage } from "./conversation.ts";
import * as herdr from "./herdr/client.ts";
import { handlePaneRequest } from "./pane-routes.ts";
import { CONTEXT_GENERATED, contextFilename, contextProfile } from "./statusline-context.ts";

it("uses the matching Claude window in HTTP responses and invalidates a 304 without a transcript append", async () => {
const home = mkdtempSync(join(tmpdir(), "ovis-claude-context-"));
const previous = { HOME: process.env["HOME"], CLAUDE_CONFIG_DIR: process.env["CLAUDE_CONFIG_DIR"] };
const session = "0b9d6a52-7a5e-4f8e-9a51-2f3c4d5e6f70";
const configDir = join(home, ".claude");
const cwd = join(home, "project");
const project = join(configDir, "projects", claudeProjectDir(cwd));
mkdirSync(project, { recursive: true });
const transcript = join(project, session + ".jsonl");
writeFileSync(transcript, [
{ type: "user", uuid: "user", timestamp: "2026-10-29T08:00:00Z", message: { role: "user", content: "Hello" } },
{ type: "assistant", uuid: "assistant", parentUuid: "user", timestamp: "2026-10-29T08:00:01Z",
message: { role: "assistant", model: "claude-test", content: [{ type: "text", text: "Hi" }], usage: { input_tokens: 702, cache_read_input_tokens: 40_000 } } },
].map(row => JSON.stringify(row)).join("\n") + "\n");
const captureDir = join(home, ".herdr", "statusline-context");
mkdirSync(captureDir, { recursive: true, mode: 0o700 });
const profile = contextProfile(configDir);
const capture = join(captureDir, contextFilename(profile, session));
const pane = { pane_id: "claude-context-fixture", cwd, agent: "claude" as const };
const snapshot = spyOn(herdr, "sessionSnapshot").mockResolvedValue({ panes: [pane], agents: [] } as unknown as SessionSnapshot);
const rpc = spyOn(herdr, "herdrRpc").mockImplementation(async (method: string) => (method === "agent.get"
? { agent: { agent_session: { value: session } } }
: { process_info: { foreground_processes: [] } }) as never);
process.env["HOME"] = home;
process.env["CLAUDE_CONFIG_DIR"] = configDir;
const url = new URL("http://x/api/pane/conversation?pane_id=" + pane.pane_id);
const ask = async (etag?: string) => (await handlePaneRequest(new Request(url, { headers: etag ? { "if-none-match": etag } : {} }), url, "/api/pane/conversation", {}))!;
const save = (window: number, model = "claude-test", session_id = session) =>
writeFileSync(capture, JSON.stringify({ generatedBy: CONTEXT_GENERATED, session_id, model, window, _herdr: { profile } }));
try {
const initial = await ask();
expect(initial.status).toBe(200);
expect((await initial.json()).metadata.context).toEqual({ used: 40_702, window: null });
const stamp = statSync(transcript).mtimeMs;
save(1_000_000);
const changed = await ask(initial.headers.get("etag")!);
expect(changed.status).toBe(200);
const context = (await changed.json()).metadata.context;
expect(context).toEqual({ used: 40_702, window: 1_000_000 });
expect(contextLeftPercent(context)).toBe(96);
expect((await ask(changed.headers.get("etag")!)).status).toBe(304);
save(200_000);
const smaller = await ask(changed.headers.get("etag")!);
expect(smaller.status).toBe(200);
expect((await smaller.json()).metadata.context.window).toBe(200_000);
expect(statSync(transcript).mtimeMs).toBe(stamp);
expect(transcriptPage("claude-transcript", transcript).metadata.context?.window).toBeNull();
save(1_000_000, "another-model");
expect((await (await ask()).json()).metadata.context.window).toBeNull();
save(1_000_000, "claude-test", "another-session");
expect((await (await ask()).json()).metadata.context.window).toBeNull();
rmSync(capture);
expect((await (await ask()).json()).metadata.context.window).toBeNull();
} finally {
snapshot.mockRestore(); rpc.mockRestore();
forgetPaneTranscriptState(pane.pane_id); forgetTranscriptState();
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key]; else process.env[key] = value;
}
rmSync(home, { recursive: true, force: true });
}
});
10 changes: 10 additions & 0 deletions server/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@

import { createHash, randomUUID } from "node:crypto";
import { closeSync, fstatSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs";
import { readStatuslineContext } from "./statusline.ts";
import nodePath, { basename, dirname, relative, sep, type PlatformPath } from "node:path";
import { authorizedTranscript } from "./transcript-store.ts";

Expand Down Expand Up @@ -1664,6 +1665,15 @@ export async function paneConversation(paneId: string, codexHome?: string, page:
}
if (resolved.agy && !sameAgyStore(resolved.agy, await resolveAgyStore(snapshot.panes.find((pane) => pane.pane_id === paneId)!))) throw new ConversationUnavailable("no_session_path");
if (identity !== null) { writtenSessions.add(`${resolved.source}\0${identity}`); rememberPaneSession(paneId, `${resolved.source}\0${identity}`); }
if (resolved.source === "claude-transcript" && identity !== null && answer.metadata.context) {
const window = readStatuslineContext(userHome(), identity, answer.metadata.model, resolved.path);
if (window !== null) return {
...answer,
metadata: { ...answer.metadata, context: { ...answer.metadata.context, window } },
// A statusline capture can change without a transcript append.
version: answerVersion(answer.version, String(window)),
};
}
if (resolved.source === "cmd-transcript") {
// The pane's current model can change without a transcript append. Enrich a response,
// never the shared transcript page, and include the live model in its ETag.
Expand Down
24 changes: 22 additions & 2 deletions server/statusline-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { randomUUID } from "node:crypto";
import { chmodSync, closeSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { contextProfile, publishContext } from "./statusline-context.ts";

/**
* Keeps only what usage reads from the payload Claude Code hands its status line: the five-hour and
* weekly windows, and the account of the CLI that drew it (from its config, not whichever account
* is signed in at read time). The rest of the payload (cwd, cost, session, transcript path) is
* is signed in at read time). Separate session files keep the model and context window.
* The rest of the payload (cwd, cost, transcript path) is
* never written. The file starts with the installer's mark (MARK in server/statusline-install.ts),
* and one at that path without it is not this capture's, so it is never replaced.
*/
Expand Down Expand Up @@ -37,6 +39,22 @@ function window(value: unknown): { used_percentage: number; resets_at?: number }
const used = number(found["used_percentage"]);
return used === undefined ? undefined : { used_percentage: used, resets_at: number(found["resets_at"]) };
}
/** Context is session-scoped, not account-wide like quota; another pane must not overwrite it. */
function captureContext(body: Record<string, unknown>): void {
const session = body["session_id"];
const model = record(body["model"])["id"];
const capacity = record(body["context_window"])["context_window_size"];
if (typeof session !== "string" || !/^[a-zA-Z0-9-]{1,128}$/.test(session)
|| typeof model !== "string" || model.trim() === "" || model.length > 256
|| typeof capacity !== "number" || !Number.isSafeInteger(capacity) || capacity <= 0) return;
const contexts = join(dir, "statusline-context");
try {
mkdirSync(contexts, { recursive: true, mode: 0o700 });
if (!startsWith(import.meta.path, `// ${GENERATED}\n`)) return;
const profile = contextProfile(process.env["CLAUDE_CONFIG_DIR"] || join(home, ".claude"));
publishContext(contexts, profile, { session_id: session, model, window: capacity });
} catch { /* A missing context must not prevent quota capture or the original status line. */ }
}
/** Claude's payload is a few kilobytes; one past this is not read whole, and the last reading stays. */
const MAX_PAYLOAD_BYTES = 256 * 1024;
async function payload(): Promise<string | null> {
Expand All @@ -52,7 +70,9 @@ async function payload(): Promise<string | null> {
try {
const text = await payload();
if (text === null) process.exit(0);
const limits = record(record(JSON.parse(text))["rate_limits"]);
const body = record(JSON.parse(text));
captureContext(body);
const limits = record(body["rate_limits"]);
const fiveHour = window(limits["five_hour"]);
const sevenDay = window(limits["seven_day"]);
// a render before Claude's first answer names no limits; the last reading stays
Expand Down
Loading
Loading