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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https://

## [Unreleased]

## [0.1.92] - 2026-09-02

### Fixed

- **Cursor native adapter hardening** (`src/adapters/cursor/`, `src/runtime/handle.ts`, `src/runtime/lifecycle/{rules-root,aipilot/dispatch-aipilot,failure-lesson}.ts`) — plugin-root discovery now follows an explicit precedence (`CURSOR_PLUGIN_ROOT` → `CLAUDE_PLUGIN_ROOT` → cwd marker) with a stderr diagnostic on miss, and project cwd resolution honors `CURSOR_PROJECT_DIR`/`CLAUDE_PROJECT_DIR`. Scope dispatchers now receive a canonical payload projection (`tool_name`, `cwd`, `tool_input` coerced string→object), and `MCP:<tool>` names are canonicalized via a closed server table. The doc-cache gate is now reachable on `beforeMCPExecution` under a cursor-only guard. Added per-event native response schemas with an exhaustive renderer, plus an `additional_context` 10,000-char cap backed by a session-level budget ledger (keyed `session|event|generation|tool_use_id`, fail-open, idempotent truncation). Covered by 23 provenance-labelled fixtures and byte-level stdout tests; test hygiene improved (tmp cwd/HOME, env restore, hex-free nonce for a pre-existing flake).

## [0.1.91] - 2026-09-01

### Fixed
Expand Down
107 changes: 107 additions & 0 deletions MEMORY/LESSON.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fusengine/harness",
"version": "0.1.91",
"version": "0.1.92",
"description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.",
"type": "module",
"module": "src/index.ts",
Expand Down
144 changes: 144 additions & 0 deletions src/adapters/cursor/context-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* @module context-budget
* Cursor-only shared `additional_context` budget registry. Cursor 3.18.25
* runs every hook plugin configured on an event in its OWN process, then
* merges their `additional_context` outputs with a 9-char `"\n\n---\n\n"`
* separator and drops the WHOLE merge past 10,000 UTF-16 units — so no
* single process can know the total by itself. This module gives every
* plugin's process a shared, best-effort view of that total via a small
* JSON registry file under the project's state dir (see `../../runtime/paths.ts`),
* keyed by `${sessionId}|${event}|${generationId ?? ""}|${toolUseId ?? ""}`
* (one key per merge group — Cursor merges preToolUse/postToolUse/
* postToolUseFailure PER TOOL CALL, so `toolUseId` joins the key on those
* three events), with entries older than 10s ignored (concurrent hooks on one
* event fire within the same second). Best-effort, fail-open throughout: any
* I/O or JSON error degrades to "no shared budget", i.e. the flat
* per-response cap in `./context-limit.ts` alone — never a thrown error, and
* never a Cursor-side regression.
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { atomicWrite } from "../../util/json-io";
import {
ADDITIONAL_CONTEXT_LIMIT, TRUNCATION_MARKER, additionalContextLength, capAdditionalContext, omitAdditionalContext,
} from "./context-limit";
import type { CursorBudgetContext } from "./interfaces/context-budget";

const REGISTRY_FILE = "cursor-context-budget.json";
/** Matches Cursor 3.18.25's observed `"\n\n---\n\n"` merge separator length. */
const SEPARATOR_LENGTH = 9;
const ENTRY_WINDOW_MS = 10_000;
/** Below this, a truncated value would carry more marker than budget — omit the field instead. */
const OMIT_THRESHOLD = TRUNCATION_MARKER.length + 100;

interface BudgetEntry {
at: number;
length: number;
}
type BudgetRegistry = Record<string, BudgetEntry[]>;

function isRegistry(value: unknown): value is BudgetRegistry {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

function registryPath(stateDir: string): string {
return join(stateDir, REGISTRY_FILE);
}

function budgetKey(ctx: Pick<CursorBudgetContext, "sessionId" | "event" | "generationId" | "toolUseId">): string {
return `${ctx.sessionId}|${ctx.event}|${ctx.generationId ?? ""}|${ctx.toolUseId ?? ""}`;
}

function loadRegistry(path: string): BudgetRegistry {
try {
if (!existsSync(path)) return {};
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
return isRegistry(parsed) ? parsed : {};
} catch {
return {};
}
}

function freshEntries(entries: BudgetEntry[] | undefined, now: number): BudgetEntry[] {
return (entries ?? []).filter((entry) => now - entry.at <= ENTRY_WINDOW_MS);
}

/** Sum of a key's fresh entry lengths plus the separators already joining them. */
function consumed(entries: BudgetEntry[]): number {
return entries.reduce((total, entry) => total + entry.length, 0) + SEPARATOR_LENGTH * Math.max(0, entries.length - 1);
}

/** {@link reserveAdditionalContext} input: budget context plus the length the caller wants to emit. */
export type ReserveInput = CursorBudgetContext & { wanted: number };
/** {@link recordAdditionalContext} input: budget context plus the length actually emitted. */
export type RecordInput = CursorBudgetContext & { emitted: number };

/**
* Reserve room in the shared budget for one hook's `additional_context`
* contribution to one (session, event, generation) merge group. `wanted` is
* accepted for a symmetric call shape with {@link recordAdditionalContext}
* but does not shrink `allowed` itself — the ceiling only depends on what
* OTHER entries already hold; a smaller `wanted` simply means the caller
* won't need all of it. Best-effort, fail-open: any I/O/JSON error returns
* the full flat ceiling, as if no other plugin had run.
* @param input - Registry location, reservation key, and the wanted length.
*/
export function reserveAdditionalContext(input: ReserveInput): { allowed: number } {
try {
const now = input.now ?? Date.now();
const registry = loadRegistry(registryPath(input.stateDir));
const fresh = freshEntries(registry[budgetKey(input)], now);
const separator = fresh.length > 0 ? SEPARATOR_LENGTH : 0;
return { allowed: Math.max(0, ADDITIONAL_CONTEXT_LIMIT - consumed(fresh) - separator) };
} catch {
return { allowed: ADDITIONAL_CONTEXT_LIMIT };
}
}

/**
* Record the length actually emitted for one reservation, best-effort.
* Prunes every key's stale entries while it holds the write so the registry
* file stays bounded. Silently no-ops on any I/O error (fail-open).
* @param input - Registry location, reservation key, and the emitted length.
*/
export function recordAdditionalContext(input: RecordInput): void {
try {
const now = input.now ?? Date.now();
const path = registryPath(input.stateDir);
const registry = loadRegistry(path);
const pruned: BudgetRegistry = {};
for (const [key, entries] of Object.entries(registry)) {
const fresh = freshEntries(entries, now);
if (fresh.length > 0) pruned[key] = fresh;
}
const key = budgetKey(input);
pruned[key] = [...(pruned[key] ?? []), { at: now, length: input.emitted }];
atomicWrite(path, JSON.stringify(pruned));
} catch {
// Best-effort: a lost entry only makes the NEXT reservation over-generous
// (never under), which is the safe direction to fail in.
}
}

/**
* Cap a Cursor stdout JSON's `additional_context` against the shared budget
* instead of the flat per-response ceiling alone. Falls back to the plain
* cap (`./context-limit.ts`), unbudgeted, when `budget` is `undefined` or
* the stdout carries no `additional_context` at all.
* @param stdout - A native Cursor JSON stdout candidate.
* @param budget - Shared budget context, or `undefined` to skip it.
*/
export function capAdditionalContextWithBudget(stdout: string, budget: CursorBudgetContext | undefined): string {
if (!budget) return capAdditionalContext(stdout);
const wanted = additionalContextLength(stdout);
if (wanted === 0) return stdout;
const { allowed } = reserveAdditionalContext({ ...budget, wanted });
if (allowed < OMIT_THRESHOLD) {
process.stderr.write(`[fuse-harness] cursor: additional_context budget exhausted for ${budget.event} (allowed=${allowed})\n`);
return omitAdditionalContext(stdout);
}
const limit = Math.min(ADDITIONAL_CONTEXT_LIMIT, allowed);
const capped = capAdditionalContext(stdout, limit);
recordAdditionalContext({ ...budget, emitted: additionalContextLength(capped) });
return capped;
}
115 changes: 115 additions & 0 deletions src/adapters/cursor/context-limit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @module context-limit
* Cursor 3.18.25's `hooks-carriers` drops an `additional_context` carrier
* once `o.length>1e4` — but `o` is the MERGED text of every hook's
* `additional_context` for that event (concatenated with `"\n\n---\n\n"`
* before the 10,000-char check), not this harness's response in isolation.
* Capping our own contribution at {@link ADDITIONAL_CONTEXT_LIMIT} is
* therefore the LAST-RESORT guard, not the real protection: on its own it
* only proves OUR piece stays under 10,000, while the total across every
* hook plugin configured on the same event can still exceed it and get
* dropped wholesale — measured at ~8,400 chars on `sessionStart` from core
* plugins alone, close enough to the ceiling that one more plugin tips it
* over. The actual protection is the cross-process shared budget registry
* in `./context-budget.ts` (Cursor id only), which reserves a slice of the
* 10,000 ceiling per (session, event, generation) key BEFORE calling
* {@link truncateAdditionalContext} here with the reserved amount instead of
* the flat {@link ADDITIONAL_CONTEXT_LIMIT} — this module stays a pure,
* budget-agnostic primitive so it keeps working unbudgeted (its historical,
* still-correct behavior) wherever no budget context is available. The
* limit unit is UTF-16 code units (`String.prototype.length`), matching
* `value.length` here exactly. Only 5 events carry `additional_context`
* through this carrier — sessionStart, beforeSubmitPrompt, preToolUse,
* postToolUse, postToolUseFailure — subagentStart/subagentStop use a
* different, unlimited channel. "Drops silently" also only holds when no
* `failClosed: true` hook is declared on that step/tool: with one declared,
* an oversized carrier REJECTS the tool call instead of being dropped quiet.
*/

/** Cursor's hard `additional_context` character ceiling. */
export const ADDITIONAL_CONTEXT_LIMIT = 10_000;

/** Suffix appended by {@link truncateAdditionalContext} once a value is cut. */
export const TRUNCATION_MARKER = "\n[fuse-harness] additional_context truncated to Cursor's 10000-char limit";

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

/**
* Truncate a string to end with {@link TRUNCATION_MARKER} once its length
* exceeds `limit`. Leaves shorter values untouched. Idempotent under a
* SHRINKING `limit` across repeated calls (e.g. an unbudgeted flat-cap pass
* followed by a budgeted re-cap of the same stdout — see `./respond.ts`'s
* `toCursorLifecycleResponse` doc): when `value` already ends with
* {@link TRUNCATION_MARKER}, that marker is stripped BEFORE re-slicing so the
* result carries exactly one marker instead of risking a duplicated/cut one.
* @param value - Candidate `additional_context` body.
* @param limit - Effective ceiling for this call (defaults to the flat
* {@link ADDITIONAL_CONTEXT_LIMIT}; a shared-budget caller passes a smaller,
* per-reservation value instead).
*/
export function truncateAdditionalContext(value: string, limit: number = ADDITIONAL_CONTEXT_LIMIT): string {
const alreadyMarked = value.endsWith(TRUNCATION_MARKER);
if (!alreadyMarked && value.length <= limit) return value;
if (limit <= TRUNCATION_MARKER.length) return TRUNCATION_MARKER.slice(0, Math.max(0, limit));
const base = alreadyMarked ? value.slice(0, value.length - TRUNCATION_MARKER.length) : value;
return base.slice(0, limit - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
}

/**
* Length of a Cursor stdout JSON's `additional_context` string field, or 0
* when the stdout is not JSON, has no such field, or that field isn't a
* string.
* @param stdout - A native Cursor JSON stdout candidate.
*/
export function additionalContextLength(stdout: string): number {
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
return 0;
}
return isPlainObject(parsed) && typeof parsed.additional_context === "string" ? parsed.additional_context.length : 0;
}

/**
* Re-serialize a Cursor stdout string with its `additional_context` field
* dropped entirely — used once the shared budget has no room left even for
* a truncated marker. Returns the input byte-for-byte unchanged when it is
* not JSON or has no string `additional_context` field.
* @param stdout - A native Cursor JSON stdout candidate.
*/
export function omitAdditionalContext(stdout: string): string {
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
return stdout;
}
if (!isPlainObject(parsed) || typeof parsed.additional_context !== "string") return stdout;
const { additional_context: _omitted, ...rest } = parsed;
return JSON.stringify(rest);
}

/**
* Re-serialize a Cursor stdout string with its `additional_context` field
* capped at `limit` characters. Returns the input byte-for-byte unchanged
* when it is not JSON, has no string `additional_context` field, or that
* field is already within the limit — so callers can wrap every return path
* unconditionally.
* @param stdout - A native Cursor JSON stdout candidate.
* @param limit - Effective ceiling for this call (see {@link truncateAdditionalContext}).
*/
export function capAdditionalContext(stdout: string, limit: number = ADDITIONAL_CONTEXT_LIMIT): string {
let parsed: unknown;
try {
parsed = JSON.parse(stdout);
} catch {
return stdout;
}
if (!isPlainObject(parsed) || typeof parsed.additional_context !== "string") return stdout;
const truncated = truncateAdditionalContext(parsed.additional_context, limit);
if (truncated === parsed.additional_context) return stdout;
return JSON.stringify({ ...parsed, additional_context: truncated });
}
23 changes: 21 additions & 2 deletions src/adapters/cursor/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,36 @@ function contains(root: string, filePath: string): boolean {
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}

/** Select Cursor's project scope without replacing a valid payload cwd. */
/**
* Select Cursor's project scope without replacing a valid payload cwd. Order:
* payload `cwd` -> longest workspace root containing `filePath` ->
* `workspaceRoots[0]` -> `CURSOR_PROJECT_DIR` env -> `CLAUDE_PROJECT_DIR` env
* -> `fallback`. Both env vars are validated the same way as any other Cursor
* path (`cursorAbsolutePath`: absolute, NUL-free, realpath-resolved), so an
* unset or malformed value is silently skipped rather than trusted.
* @param cwd - Cursor payload `cwd`, when present.
* @param workspaceRoots - Validated, deduped Cursor `workspace_roots`.
* @param filePath - The file the current event targets, when present.
* @param fallback - Caller-supplied last resort (never `process.cwd()`).
* @param env - Environment (defaults to `process.env`).
* @returns The resolved project root.
*/
export function cursorProjectCwd(
cwd: string | undefined,
workspaceRoots: readonly string[],
filePath: string | undefined,
fallback: string,
env: Record<string, string | undefined> = process.env,
): string {
if (cwd) return cwd;
if (filePath) {
const matches = workspaceRoots.filter((root) => contains(root, filePath));
if (matches.length > 0) return matches.sort((a, b) => b.length - a.length)[0]!;
}
return workspaceRoots[0] ?? fallback;
if (workspaceRoots[0]) return workspaceRoots[0];
const fromCursorEnv = cursorAbsolutePath(env.CURSOR_PROJECT_DIR);
if (fromCursorEnv) return fromCursorEnv;
const fromClaudeEnv = cursorAbsolutePath(env.CLAUDE_PROJECT_DIR);
if (fromClaudeEnv) return fromClaudeEnv;
return fallback;
}
25 changes: 25 additions & 0 deletions src/adapters/cursor/interfaces/context-budget.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Reservation key + registry location for one hook invocation's slice of
* Cursor's shared, cross-process `additional_context` budget (see
* `../context-budget.ts`). `undefined` at a call site means "no shared
* budget available" — callers then fall back to the flat per-response cap.
*/
export interface CursorBudgetContext {
/** Project state directory the registry file lives under (see `defaultStateDir`). */
stateDir: string;
/** Cursor `session_id` (its `conversation_id`). */
sessionId: string;
/** Raw Cursor `hook_event_name` (e.g. `"sessionStart"`). */
event: string;
/** Cursor `generation_id`; absent on `sessionStart`/`workspaceOpen`. */
generationId?: string;
/**
* Cursor `tool_use_id`; present on preToolUse/postToolUse/postToolUseFailure
* — Cursor merges `additional_context` PER TOOL CALL for these events, not
* once per (session, event, generation), so this must join the key or
* concurrent tool calls in the same generation would wrongly share one slice.
*/
toolUseId?: string;
/** Test seam: injectable clock (defaults to `Date.now()`). */
now?: number;
}
Loading