@fusengine/harness 0.1.91 → 0.1.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -189,14 +189,32 @@ function contains(root, filePath) {
189
189
  const rel = relative(root, filePath);
190
190
  return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
191
191
  }
192
- /** Select Cursor's project scope without replacing a valid payload cwd. */
193
- function cursorProjectCwd(cwd, workspaceRoots, filePath, fallback) {
192
+ /**
193
+ * Select Cursor's project scope without replacing a valid payload cwd. Order:
194
+ * payload `cwd` -> longest workspace root containing `filePath` ->
195
+ * `workspaceRoots[0]` -> `CURSOR_PROJECT_DIR` env -> `CLAUDE_PROJECT_DIR` env
196
+ * -> `fallback`. Both env vars are validated the same way as any other Cursor
197
+ * path (`cursorAbsolutePath`: absolute, NUL-free, realpath-resolved), so an
198
+ * unset or malformed value is silently skipped rather than trusted.
199
+ * @param cwd - Cursor payload `cwd`, when present.
200
+ * @param workspaceRoots - Validated, deduped Cursor `workspace_roots`.
201
+ * @param filePath - The file the current event targets, when present.
202
+ * @param fallback - Caller-supplied last resort (never `process.cwd()`).
203
+ * @param env - Environment (defaults to `process.env`).
204
+ * @returns The resolved project root.
205
+ */
206
+ function cursorProjectCwd(cwd, workspaceRoots, filePath, fallback, env = process.env) {
194
207
  if (cwd) return cwd;
195
208
  if (filePath) {
196
209
  const matches = workspaceRoots.filter((root) => contains(root, filePath));
197
210
  if (matches.length > 0) return matches.sort((a, b) => b.length - a.length)[0];
198
211
  }
199
- return workspaceRoots[0] ?? fallback;
212
+ if (workspaceRoots[0]) return workspaceRoots[0];
213
+ const fromCursorEnv = cursorAbsolutePath(env.CURSOR_PROJECT_DIR);
214
+ if (fromCursorEnv) return fromCursorEnv;
215
+ const fromClaudeEnv = cursorAbsolutePath(env.CLAUDE_PROJECT_DIR);
216
+ if (fromClaudeEnv) return fromClaudeEnv;
217
+ return fallback;
200
218
  }
201
219
  //#endregion
202
220
  //#region src/adapters/cursor/normalize.ts
@@ -240,10 +258,72 @@ function sanitizedCursorInput(input) {
240
258
  else delete safe.workspace_roots;
241
259
  return safe;
242
260
  }
261
+ /**
262
+ * Closed table: Cursor's `MCP:<tool>` tool_name form on preToolUse/
263
+ * postToolUse/postToolUseFailure LOSES the MCP server name (ground truth:
264
+ * Cursor CLI 3.18.25 + official docs — only beforeMCPExecution/
265
+ * afterMCPExecution carry `mcp_server_name`). This reconstructs the real
266
+ * server for the closed set of tool names this repo's gates actually depend
267
+ * on (GATED_TOOLS in doc-cache-gate.ts, CONTEXT7_SOURCE, RESEARCH_TOOLS,
268
+ * SHOT_TOOLS, gemini-mcp-gate, shadcn-skill-gate) — same closed-table
269
+ * philosophy as `mcp-tool-name.ts`'s Codex aliasing, never a blanket
270
+ * reversal. Coordinator decision: a tool name OUTSIDE this table (server
271
+ * genuinely unrecoverable, and no safe placeholder) is left as Cursor's raw
272
+ * `MCP:<tool>` string — `test/cursor-followup-normalize.test.ts` pins this
273
+ * as the committed contract ("commandless MCP tools keep their name"), so a
274
+ * fabricated `mcp__cursor__<tool>` placeholder is never introduced for the
275
+ * unknown case.
276
+ */
277
+ const CURSOR_MCP_TOOL_SERVERS = Object.assign(Object.create(null), {
278
+ "query-docs": "context7",
279
+ "resolve-library-id": "context7",
280
+ web_search_exa: "exa",
281
+ get_code_context_exa: "exa",
282
+ deep_researcher_start: "exa",
283
+ deep_researcher_check: "exa",
284
+ create_frontend: "gemini-design",
285
+ modify_frontend: "gemini-design",
286
+ snippet_frontend: "gemini-design",
287
+ search_items_in_registries: "shadcn",
288
+ view_items_in_registries: "shadcn",
289
+ get_item_examples_from_registries: "shadcn",
290
+ get_add_command_for_items: "shadcn",
291
+ get_audit_checklist: "shadcn"
292
+ });
293
+ /**
294
+ * The real MCP server for a bare Cursor tool name (the part after `MCP:`),
295
+ * or `undefined` when it isn't in the closed table. fuse-browser is inferred
296
+ * from the `browser_*` prefix — every fuse-browser tool is named that way
297
+ * and no other server in this ecosystem uses it — the remaining,
298
+ * non-distinctive tool names go through {@link CURSOR_MCP_TOOL_SERVERS}.
299
+ * NO placeholder fallback (coordinator decision, see {@link CURSOR_MCP_TOOL_SERVERS}):
300
+ * an unknown tool name means the server is genuinely unrecoverable, so the
301
+ * caller leaves the raw `MCP:<tool>` string untouched instead of fabricating one.
302
+ */
303
+ function cursorMcpServer(bareTool) {
304
+ if (bareTool.startsWith("browser_")) return "fuse-browser";
305
+ return CURSOR_MCP_TOOL_SERVERS[bareTool];
306
+ }
307
+ /**
308
+ * Canonicalize Cursor's `MCP:<tool>` tool_name (preToolUse/postToolUse/
309
+ * postToolUseFailure) into the shared `mcp__<server>__<tool>` shape every
310
+ * other harness/gate expects. Returns `undefined` — meaning "leave the raw
311
+ * `MCP:<tool>` string as-is" — both when `tool` isn't the `MCP:` form and
312
+ * when the bare tool name is outside the closed {@link CURSOR_MCP_TOOL_SERVERS}
313
+ * table (server unrecoverable, no placeholder fabricated).
314
+ */
315
+ function cursorBareMcpToolName(tool) {
316
+ if (!tool || !tool.startsWith("MCP:")) return void 0;
317
+ const bare = tool.slice(4);
318
+ const server = cursorMcpServer(bare);
319
+ return server ? `mcp__${server}__${bare}` : void 0;
320
+ }
243
321
  function cursorToolName(raw, event, tool, hasCommand) {
244
322
  if (hasCommand) return "Bash";
245
323
  const server = str(raw.mcp_server_name)?.trim().replace(/[^A-Za-z0-9_-]+/g, "_");
246
324
  if (/^(before|after)MCPExecution$/i.test(event) && server && tool && !tool.startsWith("mcp__")) return `mcp__${server}__${tool}`;
325
+ const bareMcp = cursorBareMcpToolName(tool);
326
+ if (bareMcp) return bareMcp;
247
327
  if (tool === "Write") return "Edit";
248
328
  return tool ?? "";
249
329
  }
@@ -906,6 +906,16 @@ interface HandleOutcome {
906
906
  /**
907
907
  * Run one hook and adapt every Cursor scope outcome at the common runtime exit.
908
908
  * Other harnesses retain the core handler's stdout and exit status unchanged.
909
+ * Cursor's shared `additional_context` budget context (see
910
+ * `../adapters/cursor/context-budget.ts`) is assembled here too — this is
911
+ * the single point every Cursor stdout passes through exactly once, so it's
912
+ * also the single point that reserves from and records into the registry.
913
+ * With no `session_id`/`conversation_id` at all, `sessionId` is `""` — the
914
+ * registry key would degenerate to one bucket shared by every session-less
915
+ * call on the same (cwd, event) pair, so `budget` stays `undefined` instead
916
+ * (falls back to the flat per-response cap in `toCursorLifecycleResponse`,
917
+ * with zero registry I/O). `stateDir` honors `opts.home` (test-only OS home
918
+ * override, see `HandleOptions`) so tests never need the real `os.homedir()`.
909
919
  */
910
920
  declare function handleHook(id: string, payload: Record<string, unknown>, opts: HandleOptions): Promise<HandleOutcome>;
911
921
  //#endregion
@@ -1,6 +1,6 @@
1
1
  import { r as projectLayout } from "../layout-KWoE_Mqn.mjs";
2
2
  import { a as sanitizeSessionId, c as sessionsDir, i as loadSessionState, n as fuseHarnessHome, o as saveSessionState, r as fusengineCache, s as sessionStatePath, t as claudeHome } from "../home-state-oUGFB4ds.mjs";
3
- import { A as trackWatchResearch, At as taskContext, B as writePluginMap, Bt as todayUtc, C as postEditContext, Ct as removeOldFiles, D as securityAdvisory, Dt as projectContext, E as dispatchMemory, Et as gitContext, F as aipilotPostToolUse, Ft as isoUtc, G as mergeLines, H as isProject, I as dispatchAipilot, It as loadSecurityState, J as listChildren, K as countFiles, L as dispatchLessons, Lt as saveSecurityState, M as trackSkillRead, Mt as projectHash, N as trackEnrichment, Nt as trackFile, O as securityAdvisoryForPatch, Ot as claudeMdKey, P as dispatchLifecycle, Pt as normalizeEvent, R as cartoSessionStart, Rt as securityStateDir, S as lifecycleStdout, St as purgeTtlTree, T as seoPostToolUseResponse, Tt as devContext, U as writeTree, V as generateProjectMap, W as loadEnriched, X as lessonsFileFor, Y as lessonsArchiveFileFor, Z as lessonsStateFileFor, _ as gateCommandCandidates, _t as injectRules, a as recordActivity, at as cleanupSession, b as dryGate, bt as sessionStartCore, c as MCP_TTL_MS, ct as validateTeammateOutput, d as isMcpTool, dt as validateTailwind, f as queryOf, ft as validateSolidGate, g as gate, gt as solidDetectStart, h as TRIVIAL_BUDGET, ht as detectSolidProfile, i as respond, it as validateRulesLoaded, j as trackMcpResearch, jt as defaultStateDir, k as postTrackingSideEffects, kt as promptSubmitContext, l as WEBFETCH_TTL_MS, lt as trackAgentMemory, m as REQUIRED_AGENTS, mt as countLoc, n as activityFor, nt as postEditTypescript, o as mcpPostStore, ot as saveApexState, p as DEFAULT_WINDOW_MS, pt as checkFileSize, q as getFileDesc, r as handlePre, rt as trackSessionChanges, s as mcpPreIntercept, st as logToolFailure, t as handleHook, u as cacheQueryOf, ut as subagentCacheContext, v as preCommitGate, vt as readRules, w as seoPostToolUse, wt as trimLogFile, x as extractSymbols, xt as pruneEmptyDirs, y as detectDuplication, yt as runSessionStartCleanups, z as generateEcosystemMap, zt as securityStatePath } from "../handle-C43gA-Pr.mjs";
3
+ import { A as trackWatchResearch, At as taskContext, B as writePluginMap, Bt as todayUtc, C as postEditContext, Ct as removeOldFiles, D as securityAdvisory, Dt as projectContext, E as dispatchMemory, Et as gitContext, F as aipilotPostToolUse, Ft as isoUtc, G as mergeLines, H as isProject, I as dispatchAipilot, It as loadSecurityState, J as listChildren, K as countFiles, L as dispatchLessons, Lt as saveSecurityState, M as trackSkillRead, Mt as projectHash, N as trackEnrichment, Nt as trackFile, O as securityAdvisoryForPatch, Ot as claudeMdKey, P as dispatchLifecycle, Pt as normalizeEvent, R as cartoSessionStart, Rt as securityStateDir, S as lifecycleStdout, St as purgeTtlTree, T as seoPostToolUseResponse, Tt as devContext, U as writeTree, V as generateProjectMap, W as loadEnriched, X as lessonsFileFor, Y as lessonsArchiveFileFor, Z as lessonsStateFileFor, _ as gateCommandCandidates, _t as injectRules, a as recordActivity, at as cleanupSession, b as dryGate, bt as sessionStartCore, c as MCP_TTL_MS, ct as validateTeammateOutput, d as isMcpTool, dt as validateTailwind, f as queryOf, ft as validateSolidGate, g as gate, gt as solidDetectStart, h as TRIVIAL_BUDGET, ht as detectSolidProfile, i as respond, it as validateRulesLoaded, j as trackMcpResearch, jt as defaultStateDir, k as postTrackingSideEffects, kt as promptSubmitContext, l as WEBFETCH_TTL_MS, lt as trackAgentMemory, m as REQUIRED_AGENTS, mt as countLoc, n as activityFor, nt as postEditTypescript, o as mcpPostStore, ot as saveApexState, p as DEFAULT_WINDOW_MS, pt as checkFileSize, q as getFileDesc, r as handlePre, rt as trackSessionChanges, s as mcpPreIntercept, st as logToolFailure, t as handleHook, u as cacheQueryOf, ut as subagentCacheContext, v as preCommitGate, vt as readRules, w as seoPostToolUse, wt as trimLogFile, x as extractSymbols, xt as pruneEmptyDirs, y as detectDuplication, yt as runSessionStartCleanups, z as generateEcosystemMap, zt as securityStatePath } from "../handle-BF1dZFjY.mjs";
4
4
  //#region src/runtime/storage.ts
5
5
  /**
6
6
  * The project's single state dir (`<root>/.harness`) — neutral + harness-agnostic,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusengine/harness",
3
- "version": "0.1.91",
3
+ "version": "0.1.92",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "module": "src/index.ts",
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @module context-budget
3
+ * Cursor-only shared `additional_context` budget registry. Cursor 3.18.25
4
+ * runs every hook plugin configured on an event in its OWN process, then
5
+ * merges their `additional_context` outputs with a 9-char `"\n\n---\n\n"`
6
+ * separator and drops the WHOLE merge past 10,000 UTF-16 units — so no
7
+ * single process can know the total by itself. This module gives every
8
+ * plugin's process a shared, best-effort view of that total via a small
9
+ * JSON registry file under the project's state dir (see `../../runtime/paths.ts`),
10
+ * keyed by `${sessionId}|${event}|${generationId ?? ""}|${toolUseId ?? ""}`
11
+ * (one key per merge group — Cursor merges preToolUse/postToolUse/
12
+ * postToolUseFailure PER TOOL CALL, so `toolUseId` joins the key on those
13
+ * three events), with entries older than 10s ignored (concurrent hooks on one
14
+ * event fire within the same second). Best-effort, fail-open throughout: any
15
+ * I/O or JSON error degrades to "no shared budget", i.e. the flat
16
+ * per-response cap in `./context-limit.ts` alone — never a thrown error, and
17
+ * never a Cursor-side regression.
18
+ */
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { atomicWrite } from "../../util/json-io";
22
+ import {
23
+ ADDITIONAL_CONTEXT_LIMIT, TRUNCATION_MARKER, additionalContextLength, capAdditionalContext, omitAdditionalContext,
24
+ } from "./context-limit";
25
+ import type { CursorBudgetContext } from "./interfaces/context-budget";
26
+
27
+ const REGISTRY_FILE = "cursor-context-budget.json";
28
+ /** Matches Cursor 3.18.25's observed `"\n\n---\n\n"` merge separator length. */
29
+ const SEPARATOR_LENGTH = 9;
30
+ const ENTRY_WINDOW_MS = 10_000;
31
+ /** Below this, a truncated value would carry more marker than budget — omit the field instead. */
32
+ const OMIT_THRESHOLD = TRUNCATION_MARKER.length + 100;
33
+
34
+ interface BudgetEntry {
35
+ at: number;
36
+ length: number;
37
+ }
38
+ type BudgetRegistry = Record<string, BudgetEntry[]>;
39
+
40
+ function isRegistry(value: unknown): value is BudgetRegistry {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
43
+
44
+ function registryPath(stateDir: string): string {
45
+ return join(stateDir, REGISTRY_FILE);
46
+ }
47
+
48
+ function budgetKey(ctx: Pick<CursorBudgetContext, "sessionId" | "event" | "generationId" | "toolUseId">): string {
49
+ return `${ctx.sessionId}|${ctx.event}|${ctx.generationId ?? ""}|${ctx.toolUseId ?? ""}`;
50
+ }
51
+
52
+ function loadRegistry(path: string): BudgetRegistry {
53
+ try {
54
+ if (!existsSync(path)) return {};
55
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
56
+ return isRegistry(parsed) ? parsed : {};
57
+ } catch {
58
+ return {};
59
+ }
60
+ }
61
+
62
+ function freshEntries(entries: BudgetEntry[] | undefined, now: number): BudgetEntry[] {
63
+ return (entries ?? []).filter((entry) => now - entry.at <= ENTRY_WINDOW_MS);
64
+ }
65
+
66
+ /** Sum of a key's fresh entry lengths plus the separators already joining them. */
67
+ function consumed(entries: BudgetEntry[]): number {
68
+ return entries.reduce((total, entry) => total + entry.length, 0) + SEPARATOR_LENGTH * Math.max(0, entries.length - 1);
69
+ }
70
+
71
+ /** {@link reserveAdditionalContext} input: budget context plus the length the caller wants to emit. */
72
+ export type ReserveInput = CursorBudgetContext & { wanted: number };
73
+ /** {@link recordAdditionalContext} input: budget context plus the length actually emitted. */
74
+ export type RecordInput = CursorBudgetContext & { emitted: number };
75
+
76
+ /**
77
+ * Reserve room in the shared budget for one hook's `additional_context`
78
+ * contribution to one (session, event, generation) merge group. `wanted` is
79
+ * accepted for a symmetric call shape with {@link recordAdditionalContext}
80
+ * but does not shrink `allowed` itself — the ceiling only depends on what
81
+ * OTHER entries already hold; a smaller `wanted` simply means the caller
82
+ * won't need all of it. Best-effort, fail-open: any I/O/JSON error returns
83
+ * the full flat ceiling, as if no other plugin had run.
84
+ * @param input - Registry location, reservation key, and the wanted length.
85
+ */
86
+ export function reserveAdditionalContext(input: ReserveInput): { allowed: number } {
87
+ try {
88
+ const now = input.now ?? Date.now();
89
+ const registry = loadRegistry(registryPath(input.stateDir));
90
+ const fresh = freshEntries(registry[budgetKey(input)], now);
91
+ const separator = fresh.length > 0 ? SEPARATOR_LENGTH : 0;
92
+ return { allowed: Math.max(0, ADDITIONAL_CONTEXT_LIMIT - consumed(fresh) - separator) };
93
+ } catch {
94
+ return { allowed: ADDITIONAL_CONTEXT_LIMIT };
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Record the length actually emitted for one reservation, best-effort.
100
+ * Prunes every key's stale entries while it holds the write so the registry
101
+ * file stays bounded. Silently no-ops on any I/O error (fail-open).
102
+ * @param input - Registry location, reservation key, and the emitted length.
103
+ */
104
+ export function recordAdditionalContext(input: RecordInput): void {
105
+ try {
106
+ const now = input.now ?? Date.now();
107
+ const path = registryPath(input.stateDir);
108
+ const registry = loadRegistry(path);
109
+ const pruned: BudgetRegistry = {};
110
+ for (const [key, entries] of Object.entries(registry)) {
111
+ const fresh = freshEntries(entries, now);
112
+ if (fresh.length > 0) pruned[key] = fresh;
113
+ }
114
+ const key = budgetKey(input);
115
+ pruned[key] = [...(pruned[key] ?? []), { at: now, length: input.emitted }];
116
+ atomicWrite(path, JSON.stringify(pruned));
117
+ } catch {
118
+ // Best-effort: a lost entry only makes the NEXT reservation over-generous
119
+ // (never under), which is the safe direction to fail in.
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Cap a Cursor stdout JSON's `additional_context` against the shared budget
125
+ * instead of the flat per-response ceiling alone. Falls back to the plain
126
+ * cap (`./context-limit.ts`), unbudgeted, when `budget` is `undefined` or
127
+ * the stdout carries no `additional_context` at all.
128
+ * @param stdout - A native Cursor JSON stdout candidate.
129
+ * @param budget - Shared budget context, or `undefined` to skip it.
130
+ */
131
+ export function capAdditionalContextWithBudget(stdout: string, budget: CursorBudgetContext | undefined): string {
132
+ if (!budget) return capAdditionalContext(stdout);
133
+ const wanted = additionalContextLength(stdout);
134
+ if (wanted === 0) return stdout;
135
+ const { allowed } = reserveAdditionalContext({ ...budget, wanted });
136
+ if (allowed < OMIT_THRESHOLD) {
137
+ process.stderr.write(`[fuse-harness] cursor: additional_context budget exhausted for ${budget.event} (allowed=${allowed})\n`);
138
+ return omitAdditionalContext(stdout);
139
+ }
140
+ const limit = Math.min(ADDITIONAL_CONTEXT_LIMIT, allowed);
141
+ const capped = capAdditionalContext(stdout, limit);
142
+ recordAdditionalContext({ ...budget, emitted: additionalContextLength(capped) });
143
+ return capped;
144
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @module context-limit
3
+ * Cursor 3.18.25's `hooks-carriers` drops an `additional_context` carrier
4
+ * once `o.length>1e4` — but `o` is the MERGED text of every hook's
5
+ * `additional_context` for that event (concatenated with `"\n\n---\n\n"`
6
+ * before the 10,000-char check), not this harness's response in isolation.
7
+ * Capping our own contribution at {@link ADDITIONAL_CONTEXT_LIMIT} is
8
+ * therefore the LAST-RESORT guard, not the real protection: on its own it
9
+ * only proves OUR piece stays under 10,000, while the total across every
10
+ * hook plugin configured on the same event can still exceed it and get
11
+ * dropped wholesale — measured at ~8,400 chars on `sessionStart` from core
12
+ * plugins alone, close enough to the ceiling that one more plugin tips it
13
+ * over. The actual protection is the cross-process shared budget registry
14
+ * in `./context-budget.ts` (Cursor id only), which reserves a slice of the
15
+ * 10,000 ceiling per (session, event, generation) key BEFORE calling
16
+ * {@link truncateAdditionalContext} here with the reserved amount instead of
17
+ * the flat {@link ADDITIONAL_CONTEXT_LIMIT} — this module stays a pure,
18
+ * budget-agnostic primitive so it keeps working unbudgeted (its historical,
19
+ * still-correct behavior) wherever no budget context is available. The
20
+ * limit unit is UTF-16 code units (`String.prototype.length`), matching
21
+ * `value.length` here exactly. Only 5 events carry `additional_context`
22
+ * through this carrier — sessionStart, beforeSubmitPrompt, preToolUse,
23
+ * postToolUse, postToolUseFailure — subagentStart/subagentStop use a
24
+ * different, unlimited channel. "Drops silently" also only holds when no
25
+ * `failClosed: true` hook is declared on that step/tool: with one declared,
26
+ * an oversized carrier REJECTS the tool call instead of being dropped quiet.
27
+ */
28
+
29
+ /** Cursor's hard `additional_context` character ceiling. */
30
+ export const ADDITIONAL_CONTEXT_LIMIT = 10_000;
31
+
32
+ /** Suffix appended by {@link truncateAdditionalContext} once a value is cut. */
33
+ export const TRUNCATION_MARKER = "\n[fuse-harness] additional_context truncated to Cursor's 10000-char limit";
34
+
35
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
36
+ return typeof value === "object" && value !== null && !Array.isArray(value);
37
+ }
38
+
39
+ /**
40
+ * Truncate a string to end with {@link TRUNCATION_MARKER} once its length
41
+ * exceeds `limit`. Leaves shorter values untouched. Idempotent under a
42
+ * SHRINKING `limit` across repeated calls (e.g. an unbudgeted flat-cap pass
43
+ * followed by a budgeted re-cap of the same stdout — see `./respond.ts`'s
44
+ * `toCursorLifecycleResponse` doc): when `value` already ends with
45
+ * {@link TRUNCATION_MARKER}, that marker is stripped BEFORE re-slicing so the
46
+ * result carries exactly one marker instead of risking a duplicated/cut one.
47
+ * @param value - Candidate `additional_context` body.
48
+ * @param limit - Effective ceiling for this call (defaults to the flat
49
+ * {@link ADDITIONAL_CONTEXT_LIMIT}; a shared-budget caller passes a smaller,
50
+ * per-reservation value instead).
51
+ */
52
+ export function truncateAdditionalContext(value: string, limit: number = ADDITIONAL_CONTEXT_LIMIT): string {
53
+ const alreadyMarked = value.endsWith(TRUNCATION_MARKER);
54
+ if (!alreadyMarked && value.length <= limit) return value;
55
+ if (limit <= TRUNCATION_MARKER.length) return TRUNCATION_MARKER.slice(0, Math.max(0, limit));
56
+ const base = alreadyMarked ? value.slice(0, value.length - TRUNCATION_MARKER.length) : value;
57
+ return base.slice(0, limit - TRUNCATION_MARKER.length) + TRUNCATION_MARKER;
58
+ }
59
+
60
+ /**
61
+ * Length of a Cursor stdout JSON's `additional_context` string field, or 0
62
+ * when the stdout is not JSON, has no such field, or that field isn't a
63
+ * string.
64
+ * @param stdout - A native Cursor JSON stdout candidate.
65
+ */
66
+ export function additionalContextLength(stdout: string): number {
67
+ let parsed: unknown;
68
+ try {
69
+ parsed = JSON.parse(stdout);
70
+ } catch {
71
+ return 0;
72
+ }
73
+ return isPlainObject(parsed) && typeof parsed.additional_context === "string" ? parsed.additional_context.length : 0;
74
+ }
75
+
76
+ /**
77
+ * Re-serialize a Cursor stdout string with its `additional_context` field
78
+ * dropped entirely — used once the shared budget has no room left even for
79
+ * a truncated marker. Returns the input byte-for-byte unchanged when it is
80
+ * not JSON or has no string `additional_context` field.
81
+ * @param stdout - A native Cursor JSON stdout candidate.
82
+ */
83
+ export function omitAdditionalContext(stdout: string): string {
84
+ let parsed: unknown;
85
+ try {
86
+ parsed = JSON.parse(stdout);
87
+ } catch {
88
+ return stdout;
89
+ }
90
+ if (!isPlainObject(parsed) || typeof parsed.additional_context !== "string") return stdout;
91
+ const { additional_context: _omitted, ...rest } = parsed;
92
+ return JSON.stringify(rest);
93
+ }
94
+
95
+ /**
96
+ * Re-serialize a Cursor stdout string with its `additional_context` field
97
+ * capped at `limit` characters. Returns the input byte-for-byte unchanged
98
+ * when it is not JSON, has no string `additional_context` field, or that
99
+ * field is already within the limit — so callers can wrap every return path
100
+ * unconditionally.
101
+ * @param stdout - A native Cursor JSON stdout candidate.
102
+ * @param limit - Effective ceiling for this call (see {@link truncateAdditionalContext}).
103
+ */
104
+ export function capAdditionalContext(stdout: string, limit: number = ADDITIONAL_CONTEXT_LIMIT): string {
105
+ let parsed: unknown;
106
+ try {
107
+ parsed = JSON.parse(stdout);
108
+ } catch {
109
+ return stdout;
110
+ }
111
+ if (!isPlainObject(parsed) || typeof parsed.additional_context !== "string") return stdout;
112
+ const truncated = truncateAdditionalContext(parsed.additional_context, limit);
113
+ if (truncated === parsed.additional_context) return stdout;
114
+ return JSON.stringify({ ...parsed, additional_context: truncated });
115
+ }
@@ -30,17 +30,36 @@ function contains(root: string, filePath: string): boolean {
30
30
  return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
31
31
  }
32
32
 
33
- /** Select Cursor's project scope without replacing a valid payload cwd. */
33
+ /**
34
+ * Select Cursor's project scope without replacing a valid payload cwd. Order:
35
+ * payload `cwd` -> longest workspace root containing `filePath` ->
36
+ * `workspaceRoots[0]` -> `CURSOR_PROJECT_DIR` env -> `CLAUDE_PROJECT_DIR` env
37
+ * -> `fallback`. Both env vars are validated the same way as any other Cursor
38
+ * path (`cursorAbsolutePath`: absolute, NUL-free, realpath-resolved), so an
39
+ * unset or malformed value is silently skipped rather than trusted.
40
+ * @param cwd - Cursor payload `cwd`, when present.
41
+ * @param workspaceRoots - Validated, deduped Cursor `workspace_roots`.
42
+ * @param filePath - The file the current event targets, when present.
43
+ * @param fallback - Caller-supplied last resort (never `process.cwd()`).
44
+ * @param env - Environment (defaults to `process.env`).
45
+ * @returns The resolved project root.
46
+ */
34
47
  export function cursorProjectCwd(
35
48
  cwd: string | undefined,
36
49
  workspaceRoots: readonly string[],
37
50
  filePath: string | undefined,
38
51
  fallback: string,
52
+ env: Record<string, string | undefined> = process.env,
39
53
  ): string {
40
54
  if (cwd) return cwd;
41
55
  if (filePath) {
42
56
  const matches = workspaceRoots.filter((root) => contains(root, filePath));
43
57
  if (matches.length > 0) return matches.sort((a, b) => b.length - a.length)[0]!;
44
58
  }
45
- return workspaceRoots[0] ?? fallback;
59
+ if (workspaceRoots[0]) return workspaceRoots[0];
60
+ const fromCursorEnv = cursorAbsolutePath(env.CURSOR_PROJECT_DIR);
61
+ if (fromCursorEnv) return fromCursorEnv;
62
+ const fromClaudeEnv = cursorAbsolutePath(env.CLAUDE_PROJECT_DIR);
63
+ if (fromClaudeEnv) return fromClaudeEnv;
64
+ return fallback;
46
65
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Reservation key + registry location for one hook invocation's slice of
3
+ * Cursor's shared, cross-process `additional_context` budget (see
4
+ * `../context-budget.ts`). `undefined` at a call site means "no shared
5
+ * budget available" — callers then fall back to the flat per-response cap.
6
+ */
7
+ export interface CursorBudgetContext {
8
+ /** Project state directory the registry file lives under (see `defaultStateDir`). */
9
+ stateDir: string;
10
+ /** Cursor `session_id` (its `conversation_id`). */
11
+ sessionId: string;
12
+ /** Raw Cursor `hook_event_name` (e.g. `"sessionStart"`). */
13
+ event: string;
14
+ /** Cursor `generation_id`; absent on `sessionStart`/`workspaceOpen`. */
15
+ generationId?: string;
16
+ /**
17
+ * Cursor `tool_use_id`; present on preToolUse/postToolUse/postToolUseFailure
18
+ * — Cursor merges `additional_context` PER TOOL CALL for these events, not
19
+ * once per (session, event, generation), so this must join the key or
20
+ * concurrent tool calls in the same generation would wrongly share one slice.
21
+ */
22
+ toolUseId?: string;
23
+ /** Test seam: injectable clock (defaults to `Date.now()`). */
24
+ now?: number;
25
+ }
@@ -1,132 +1,13 @@
1
- type FieldValidator = (value: unknown) => boolean;
2
-
3
- interface NativeSchema {
4
- fields: Readonly<Record<string, FieldValidator>>;
5
- required?: readonly string[];
6
- }
7
-
8
- const stringValue: FieldValidator = (value) => typeof value === "string";
9
- const booleanValue: FieldValidator = (value) => typeof value === "boolean";
10
- const plainRecord = (value: unknown): value is Record<string, unknown> => {
11
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
12
- try {
13
- const prototype = Object.getPrototypeOf(value);
14
- return prototype === Object.prototype || prototype === null;
15
- } catch {
16
- return false;
17
- }
18
- };
19
-
20
- type JsonFrame = { value: unknown; leave?: false } | { value: object; leave: true };
21
-
22
- function jsonChildren(value: object): unknown[] | null {
23
- const keys = Reflect.ownKeys(value);
24
- const descriptors = Object.getOwnPropertyDescriptors(value);
25
- if (Array.isArray(value)) {
26
- if (Object.getPrototypeOf(value) !== Array.prototype) return null;
27
- const length = descriptors.length;
28
- if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0) return null;
29
- if (keys.length !== length.value + 1 || keys.some((key) => typeof key === "symbol")) return null;
30
- const children: unknown[] = [];
31
- for (let index = 0; index < length.value; index += 1) {
32
- const descriptor = descriptors[String(index)];
33
- if (!descriptor?.enumerable || !("value" in descriptor)) return null;
34
- children.push(descriptor.value);
35
- }
36
- return children;
37
- }
38
- if (!plainRecord(value) || keys.some((key) => typeof key === "symbol")) return null;
39
- const children: unknown[] = [];
40
- for (const key of keys) {
41
- const descriptor = descriptors[key as string];
42
- if (!descriptor?.enumerable || !("value" in descriptor)) return null;
43
- children.push(descriptor.value);
44
- }
45
- return children;
46
- }
47
-
48
- function jsonValue(root: unknown): boolean {
49
- const active = new WeakSet<object>();
50
- const stack: JsonFrame[] = [{ value: root }];
51
- while (stack.length > 0) {
52
- const frame = stack.pop()!;
53
- if (frame.leave) {
54
- active.delete(frame.value);
55
- continue;
56
- }
57
- const { value } = frame;
58
- if (value === null || typeof value === "string" || typeof value === "boolean") continue;
59
- if (typeof value === "number") {
60
- if (!Number.isFinite(value)) return false;
61
- continue;
62
- }
63
- if (typeof value !== "object" || active.has(value)) return false;
64
- let children: unknown[] | null;
65
- try {
66
- children = jsonChildren(value);
67
- } catch {
68
- return false;
69
- }
70
- if (!children) return false;
71
- active.add(value);
72
- stack.push({ value, leave: true });
73
- for (let index = children.length - 1; index >= 0; index -= 1) stack.push({ value: children[index] });
74
- }
75
- return true;
76
- }
77
-
78
- const recordValue: FieldValidator = (value) => plainRecord(value) && jsonValue(value);
79
- const stringRecord: FieldValidator = (value) => {
80
- if (!recordValue(value)) return false;
81
- try {
82
- return Object.values(Object.getOwnPropertyDescriptors(value as object))
83
- .every((descriptor) => "value" in descriptor && typeof descriptor.value === "string");
84
- } catch {
85
- return false;
86
- }
87
- };
88
- const stringArray: FieldValidator = (value) => Array.isArray(value) && value.every(stringValue);
89
- const permission = (...values: string[]): FieldValidator => (value) => typeof value === "string" && values.includes(value);
90
-
91
- const EMPTY: NativeSchema = { fields: {} };
92
- const FOLLOWUP: NativeSchema = { fields: { followup_message: stringValue } };
93
- const PERMISSION_ASK: NativeSchema = {
94
- fields: { permission: permission("allow", "deny", "ask"), user_message: stringValue, agent_message: stringValue },
95
- required: ["permission"],
96
- };
97
-
98
- const NATIVE_SCHEMAS = {
99
- sessionStart: {
100
- fields: { env: stringRecord, additional_context: stringValue, continue: booleanValue, user_message: stringValue },
101
- },
102
- sessionEnd: EMPTY,
103
- beforeSubmitPrompt: { fields: { continue: booleanValue, user_message: stringValue }, required: ["continue"] },
104
- preCompact: { fields: { user_message: stringValue } },
105
- subagentStart: {
106
- fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"],
107
- },
108
- subagentStop: FOLLOWUP,
109
- preToolUse: {
110
- fields: { ...PERMISSION_ASK.fields, updated_input: recordValue }, required: ["permission"],
111
- },
112
- postToolUse: { fields: { updated_mcp_tool_output: recordValue, additional_context: stringValue } },
113
- postToolUseFailure: EMPTY,
114
- beforeShellExecution: PERMISSION_ASK,
115
- afterShellExecution: EMPTY,
116
- beforeMCPExecution: PERMISSION_ASK,
117
- afterMCPExecution: EMPTY,
118
- beforeReadFile: {
119
- fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"],
120
- },
121
- afterFileEdit: EMPTY,
122
- beforeTabFileRead: { fields: { permission: permission("allow", "deny") }, required: ["permission"] },
123
- afterTabFileEdit: EMPTY,
124
- afterAgentResponse: EMPTY,
125
- afterAgentThought: EMPTY,
126
- stop: FOLLOWUP,
127
- workspaceOpen: { fields: { pluginPaths: stringArray } },
128
- } as const satisfies Record<string, NativeSchema>;
129
-
1
+ import { NATIVE_SCHEMAS, recordValue, type NativeSchema } from "./native-schemas";
2
+
3
+ /**
4
+ * Check that every enumerable own key of `value` is a documented field for
5
+ * `eventName` and passes its validator, and that every required field is
6
+ * present. Rejects prototype-polluted or exotic-shaped candidates via
7
+ * {@link recordValue}.
8
+ * @param value - Parsed JSON candidate.
9
+ * @param eventName - The Cursor hook event the candidate would answer.
10
+ */
130
11
  function isNativeCursorResponse(value: unknown, eventName: string): boolean {
131
12
  try {
132
13
  if (!recordValue(value)) return false;