@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.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @module native-schemas
3
+ * Per-event field validators for Cursor's documented native stdout contract.
4
+ * Extracted from native-response.ts to keep that module focused on the
5
+ * passthrough decision logic (SOLID file-size split, not a plafond workaround).
6
+ *
7
+ * Field lists are binary-verified against Cursor 3.18.25 (agent-cli
8
+ * `190.index.js` / `workbench.desktop.main.js`, validators `R`/`Ded`) and
9
+ * match the published hooks documentation.
10
+ */
11
+
12
+ /** A single-field runtime type check used to build a {@link NativeSchema}. */
13
+ export type FieldValidator = (value: unknown) => boolean;
14
+
15
+ /** The exact field set (and per-field validator) Cursor reads for one event. */
16
+ export interface NativeSchema {
17
+ fields: Readonly<Record<string, FieldValidator>>;
18
+ required?: readonly string[];
19
+ }
20
+
21
+ const stringValue: FieldValidator = (value) => typeof value === "string";
22
+ const booleanValue: FieldValidator = (value) => typeof value === "boolean";
23
+
24
+ /** A plain `{}`-literal or `Object.create(null)` object — never a class instance or array. */
25
+ export const plainRecord = (value: unknown): value is Record<string, unknown> => {
26
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
27
+ try {
28
+ const prototype = Object.getPrototypeOf(value);
29
+ return prototype === Object.prototype || prototype === null;
30
+ } catch {
31
+ return false;
32
+ }
33
+ };
34
+
35
+ type JsonFrame = { value: unknown; leave?: false } | { value: object; leave: true };
36
+
37
+ function jsonChildren(value: object): unknown[] | null {
38
+ const keys = Reflect.ownKeys(value);
39
+ const descriptors = Object.getOwnPropertyDescriptors(value);
40
+ if (Array.isArray(value)) {
41
+ if (Object.getPrototypeOf(value) !== Array.prototype) return null;
42
+ const length = descriptors.length;
43
+ if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0) return null;
44
+ if (keys.length !== length.value + 1 || keys.some((key) => typeof key === "symbol")) return null;
45
+ const children: unknown[] = [];
46
+ for (let index = 0; index < length.value; index += 1) {
47
+ const descriptor = descriptors[String(index)];
48
+ if (!descriptor?.enumerable || !("value" in descriptor)) return null;
49
+ children.push(descriptor.value);
50
+ }
51
+ return children;
52
+ }
53
+ if (!plainRecord(value) || keys.some((key) => typeof key === "symbol")) return null;
54
+ const children: unknown[] = [];
55
+ for (const key of keys) {
56
+ const descriptor = descriptors[key as string];
57
+ if (!descriptor?.enumerable || !("value" in descriptor)) return null;
58
+ children.push(descriptor.value);
59
+ }
60
+ return children;
61
+ }
62
+
63
+ function jsonValue(root: unknown): boolean {
64
+ const active = new WeakSet<object>();
65
+ const stack: JsonFrame[] = [{ value: root }];
66
+ while (stack.length > 0) {
67
+ const frame = stack.pop()!;
68
+ if (frame.leave) {
69
+ active.delete(frame.value);
70
+ continue;
71
+ }
72
+ const { value } = frame;
73
+ if (value === null || typeof value === "string" || typeof value === "boolean") continue;
74
+ if (typeof value === "number") {
75
+ if (!Number.isFinite(value)) return false;
76
+ continue;
77
+ }
78
+ if (typeof value !== "object" || active.has(value)) return false;
79
+ let children: unknown[] | null;
80
+ try {
81
+ children = jsonChildren(value);
82
+ } catch {
83
+ return false;
84
+ }
85
+ if (!children) return false;
86
+ active.add(value);
87
+ stack.push({ value, leave: true });
88
+ for (let index = children.length - 1; index >= 0; index -= 1) stack.push({ value: children[index] });
89
+ }
90
+ return true;
91
+ }
92
+
93
+ /** A JSON-safe plain object (no cycles, no non-finite numbers, no exotic prototypes). */
94
+ export const recordValue: FieldValidator = (value) => plainRecord(value) && jsonValue(value);
95
+ const stringRecord: FieldValidator = (value) => {
96
+ if (!recordValue(value)) return false;
97
+ try {
98
+ return Object.values(Object.getOwnPropertyDescriptors(value as object))
99
+ .every((descriptor) => "value" in descriptor && typeof descriptor.value === "string");
100
+ } catch {
101
+ return false;
102
+ }
103
+ };
104
+ const stringArray: FieldValidator = (value) => Array.isArray(value) && value.every(stringValue);
105
+ const permission = (...values: string[]): FieldValidator => (value) => typeof value === "string" && values.includes(value);
106
+
107
+ const EMPTY: NativeSchema = { fields: {} };
108
+ const FOLLOWUP: NativeSchema = { fields: { followup_message: stringValue } };
109
+ const PERMISSION_ASK: NativeSchema = {
110
+ fields: { permission: permission("allow", "deny", "ask"), user_message: stringValue, agent_message: stringValue },
111
+ required: ["permission"],
112
+ };
113
+
114
+ const PRE_TOOL_USE: NativeSchema = {
115
+ fields: {
116
+ permission: permission("allow", "deny", "ask"),
117
+ user_message: stringValue,
118
+ agent_message: stringValue,
119
+ updated_input: recordValue,
120
+ additional_context: stringValue,
121
+ },
122
+ required: ["permission"],
123
+ };
124
+
125
+ /**
126
+ * Exact native stdout field set Cursor 3.18.25 reads per hook event.
127
+ * Nothing beyond this list is invented: any additional key on a candidate
128
+ * value fails {@link isNativeCursorResponse} in native-response.ts.
129
+ */
130
+ export const NATIVE_SCHEMAS: Readonly<Record<string, NativeSchema>> = {
131
+ sessionStart: {
132
+ fields: { env: stringRecord, additional_context: stringValue, continue: booleanValue, user_message: stringValue },
133
+ },
134
+ sessionEnd: EMPTY,
135
+ beforeSubmitPrompt: {
136
+ fields: { continue: booleanValue, user_message: stringValue, additional_context: stringValue },
137
+ required: ["continue"],
138
+ },
139
+ preCompact: { fields: { user_message: stringValue } },
140
+ subagentStart: {
141
+ fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"],
142
+ },
143
+ subagentStop: FOLLOWUP,
144
+ preToolUse: PRE_TOOL_USE,
145
+ postToolUse: { fields: { updated_mcp_tool_output: recordValue, additional_context: stringValue } },
146
+ postToolUseFailure: { fields: { additional_context: stringValue } },
147
+ beforeShellExecution: PERMISSION_ASK,
148
+ afterShellExecution: EMPTY,
149
+ beforeMCPExecution: PERMISSION_ASK,
150
+ afterMCPExecution: EMPTY,
151
+ beforeReadFile: {
152
+ fields: { permission: permission("allow", "deny"), user_message: stringValue }, required: ["permission"],
153
+ },
154
+ afterFileEdit: EMPTY,
155
+ beforeTabFileRead: { fields: { permission: permission("allow", "deny") }, required: ["permission"] },
156
+ afterTabFileEdit: EMPTY,
157
+ afterAgentResponse: EMPTY,
158
+ afterAgentThought: EMPTY,
159
+ stop: FOLLOWUP,
160
+ workspaceOpen: { fields: { pluginPaths: stringArray } },
161
+ };
@@ -49,12 +49,77 @@ function sanitizedCursorInput(input: Record<string, unknown>): Record<string, un
49
49
  return safe;
50
50
  }
51
51
 
52
+ /**
53
+ * Closed table: Cursor's `MCP:<tool>` tool_name form on preToolUse/
54
+ * postToolUse/postToolUseFailure LOSES the MCP server name (ground truth:
55
+ * Cursor CLI 3.18.25 + official docs — only beforeMCPExecution/
56
+ * afterMCPExecution carry `mcp_server_name`). This reconstructs the real
57
+ * server for the closed set of tool names this repo's gates actually depend
58
+ * on (GATED_TOOLS in doc-cache-gate.ts, CONTEXT7_SOURCE, RESEARCH_TOOLS,
59
+ * SHOT_TOOLS, gemini-mcp-gate, shadcn-skill-gate) — same closed-table
60
+ * philosophy as `mcp-tool-name.ts`'s Codex aliasing, never a blanket
61
+ * reversal. Coordinator decision: a tool name OUTSIDE this table (server
62
+ * genuinely unrecoverable, and no safe placeholder) is left as Cursor's raw
63
+ * `MCP:<tool>` string — `test/cursor-followup-normalize.test.ts` pins this
64
+ * as the committed contract ("commandless MCP tools keep their name"), so a
65
+ * fabricated `mcp__cursor__<tool>` placeholder is never introduced for the
66
+ * unknown case.
67
+ */
68
+ const CURSOR_MCP_TOOL_SERVERS: Readonly<Record<string, string>> = Object.assign(Object.create(null), {
69
+ "query-docs": "context7",
70
+ "resolve-library-id": "context7",
71
+ web_search_exa: "exa",
72
+ get_code_context_exa: "exa",
73
+ deep_researcher_start: "exa",
74
+ deep_researcher_check: "exa",
75
+ create_frontend: "gemini-design",
76
+ modify_frontend: "gemini-design",
77
+ snippet_frontend: "gemini-design",
78
+ search_items_in_registries: "shadcn",
79
+ view_items_in_registries: "shadcn",
80
+ get_item_examples_from_registries: "shadcn",
81
+ get_add_command_for_items: "shadcn",
82
+ get_audit_checklist: "shadcn",
83
+ });
84
+
85
+ /**
86
+ * The real MCP server for a bare Cursor tool name (the part after `MCP:`),
87
+ * or `undefined` when it isn't in the closed table. fuse-browser is inferred
88
+ * from the `browser_*` prefix — every fuse-browser tool is named that way
89
+ * and no other server in this ecosystem uses it — the remaining,
90
+ * non-distinctive tool names go through {@link CURSOR_MCP_TOOL_SERVERS}.
91
+ * NO placeholder fallback (coordinator decision, see {@link CURSOR_MCP_TOOL_SERVERS}):
92
+ * an unknown tool name means the server is genuinely unrecoverable, so the
93
+ * caller leaves the raw `MCP:<tool>` string untouched instead of fabricating one.
94
+ */
95
+ function cursorMcpServer(bareTool: string): string | undefined {
96
+ if (bareTool.startsWith("browser_")) return "fuse-browser";
97
+ return CURSOR_MCP_TOOL_SERVERS[bareTool];
98
+ }
99
+
100
+ /**
101
+ * Canonicalize Cursor's `MCP:<tool>` tool_name (preToolUse/postToolUse/
102
+ * postToolUseFailure) into the shared `mcp__<server>__<tool>` shape every
103
+ * other harness/gate expects. Returns `undefined` — meaning "leave the raw
104
+ * `MCP:<tool>` string as-is" — both when `tool` isn't the `MCP:` form and
105
+ * when the bare tool name is outside the closed {@link CURSOR_MCP_TOOL_SERVERS}
106
+ * table (server unrecoverable, no placeholder fabricated).
107
+ */
108
+ function cursorBareMcpToolName(tool: string | undefined): string | undefined {
109
+ if (!tool || !tool.startsWith("MCP:")) return undefined;
110
+ const bare = tool.slice(4);
111
+ const server = cursorMcpServer(bare);
112
+ return server ? `mcp__${server}__${bare}` : undefined;
113
+ }
114
+
52
115
  function cursorToolName(raw: Record<string, unknown>, event: string, tool: string | undefined, hasCommand: boolean): string {
53
116
  if (hasCommand) return "Bash";
54
117
  const server = str(raw.mcp_server_name)?.trim().replace(/[^A-Za-z0-9_-]+/g, "_");
55
118
  if (/^(before|after)MCPExecution$/i.test(event) && server && tool && !tool.startsWith("mcp__")) {
56
119
  return `mcp__${server}__${tool}`;
57
120
  }
121
+ const bareMcp = cursorBareMcpToolName(tool);
122
+ if (bareMcp) return bareMcp;
58
123
  if (tool === "Write") return "Edit";
59
124
  return tool ?? "";
60
125
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Cursor plugin-root resolution — independent from the rules-plugin probing
3
+ * in `../../runtime/lifecycle/rules-root.ts`. Ground truth (Cursor 3.18.25
4
+ * binary + cursor.com/docs/hooks): `CURSOR_PLUGIN_ROOT` / `CLAUDE_PLUGIN_ROOT`
5
+ * (both equal to the plugin install dir) are injected ONLY into
6
+ * plugin-declared hook processes — never user (`~/.cursor/hooks.json`),
7
+ * project (`.cursor/hooks.json`), or enterprise hooks. A plugin hook's cwd is
8
+ * the plugin install dir, EXCEPT for `stop`/`subagentStop`, where it is the
9
+ * workspace root — callers must pass the right `cwd` for the event they are
10
+ * handling. Precedence: (1) `CURSOR_PLUGIN_ROOT` env, (2) `CLAUDE_PLUGIN_ROOT`
11
+ * env, (3) `cwd` when it carries a Cursor plugin marker
12
+ * (`.cursor-plugin/plugin.json`, `plugin.json` + `hooks/hooks.json`, or a
13
+ * bare `hooks/hooks.json` — matches installed-plugin layouts under
14
+ * `~/.cursor/plugins/cache/**` and `~/.cursor/plugins/local/<name>/`), (4)
15
+ * `none`. Cursor refuses symlinked config paths itself; we do not share that
16
+ * constraint, so every resolved candidate is realpath-followed instead.
17
+ */
18
+ import { existsSync, realpathSync, statSync } from "node:fs";
19
+ import { isAbsolute, join } from "node:path";
20
+
21
+ /** How the resolved Cursor plugin root was determined. */
22
+ export type CursorPluginRootSource =
23
+ | "env:CURSOR_PLUGIN_ROOT"
24
+ | "env:CLAUDE_PLUGIN_ROOT"
25
+ | "cwd:plugin-marker"
26
+ | "none";
27
+
28
+ /** Result of resolving the Cursor plugin install root. */
29
+ export interface CursorPluginRootResult {
30
+ /** Realpath-resolved plugin install directory, or `null` when unproven. */
31
+ root: string | null;
32
+ /** Which precedence step produced `root`. */
33
+ source: CursorPluginRootSource;
34
+ /** One diagnostic entry per candidate that was examined and rejected. */
35
+ checked: string[];
36
+ }
37
+
38
+ /** Validate an env candidate: non-empty, NUL-free, absolute, existing dir. */
39
+ function validateEnvCandidate(label: string, value: string | undefined, checked: string[]): string | null {
40
+ if (value === undefined || value === "") {
41
+ checked.push(`${label}: unset`);
42
+ return null;
43
+ }
44
+ if (value.includes("\0")) {
45
+ checked.push(`${label}: invalid (contains NUL): "${value}"`);
46
+ return null;
47
+ }
48
+ if (!isAbsolute(value)) {
49
+ checked.push(`${label}: invalid (not absolute): "${value}"`);
50
+ return null;
51
+ }
52
+ try {
53
+ if (!statSync(value).isDirectory()) {
54
+ checked.push(`${label}: invalid (not a directory): "${value}"`);
55
+ return null;
56
+ }
57
+ } catch {
58
+ checked.push(`${label}: invalid (no such directory): "${value}"`);
59
+ return null;
60
+ }
61
+ try {
62
+ return realpathSync.native(value);
63
+ } catch {
64
+ checked.push(`${label}: invalid (realpath failed): "${value}"`);
65
+ return null;
66
+ }
67
+ }
68
+
69
+ /** True when `dir` carries a recognized Cursor plugin install marker. */
70
+ function hasPluginMarker(dir: string): boolean {
71
+ if (existsSync(join(dir, ".cursor-plugin", "plugin.json"))) return true;
72
+ if (existsSync(join(dir, "plugin.json")) && existsSync(join(dir, "hooks", "hooks.json"))) return true;
73
+ return existsSync(join(dir, "hooks", "hooks.json"));
74
+ }
75
+
76
+ /**
77
+ * Resolve the Cursor plugin install root a plugin-declared hook runs from.
78
+ * @param env - Environment (defaults to `process.env`).
79
+ * @param cwd - The hook process's cwd for the current event (plugin root for
80
+ * most events, workspace root for `stop`/`subagentStop` — caller's choice).
81
+ * @returns The resolved root, its source, and every rejected candidate.
82
+ */
83
+ export function resolveCursorPluginRoot(
84
+ env: Record<string, string | undefined>,
85
+ cwd: string,
86
+ ): CursorPluginRootResult {
87
+ const checked: string[] = [];
88
+ const fromCursor = validateEnvCandidate("env:CURSOR_PLUGIN_ROOT", env.CURSOR_PLUGIN_ROOT, checked);
89
+ if (fromCursor) return { root: fromCursor, source: "env:CURSOR_PLUGIN_ROOT", checked };
90
+ const fromClaude = validateEnvCandidate("env:CLAUDE_PLUGIN_ROOT", env.CLAUDE_PLUGIN_ROOT, checked);
91
+ if (fromClaude) return { root: fromClaude, source: "env:CLAUDE_PLUGIN_ROOT", checked };
92
+ if (hasPluginMarker(cwd)) {
93
+ let resolved = cwd;
94
+ try {
95
+ resolved = realpathSync.native(cwd);
96
+ } catch {
97
+ /* keep raw cwd when realpath fails (e.g. already-canonical or unreadable parent) */
98
+ }
99
+ return { root: resolved, source: "cwd:plugin-marker", checked };
100
+ }
101
+ checked.push(`cwd:"${cwd}": no plugin marker found`);
102
+ return { root: null, source: "none", checked };
103
+ }
@@ -1,6 +1,9 @@
1
1
  import { formatPrompt, type Prompt } from "../../prompt/types";
2
2
  import { cursorEventContract } from "./events";
3
3
  import { parseNativeCursorStdout } from "./native-response";
4
+ import { capAdditionalContext } from "./context-limit";
5
+ import { capAdditionalContextWithBudget } from "./context-budget";
6
+ import type { CursorBudgetContext } from "./interfaces/context-budget";
4
7
 
5
8
  const AGENT_MESSAGE_EVENTS = new Set([
6
9
  "preToolUse",
@@ -30,39 +33,73 @@ function isRecord(value: unknown): value is Record<string, unknown> {
30
33
  return typeof value === "object" && value !== null && !Array.isArray(value);
31
34
  }
32
35
 
33
- /** Render a portable policy prompt using the native Cursor event contract. */
36
+ /**
37
+ * Render a portable policy prompt using the native Cursor event contract.
38
+ * Switches exhaustively on {@link CursorResponseKind} — the `never` default
39
+ * fails to compile if a new kind is ever added without a matching case.
40
+ * `contract.known === false` is not tested separately: the single
41
+ * `UNKNOWN_EVENT` fallback in events.ts always pairs `known: false` with
42
+ * `response: "neutral"`, so both collapse to the same `"{}"` branch.
43
+ */
34
44
  export function toCursorResponse(prompt: Prompt, eventName: string): string {
35
45
  const contract = cursorEventContract(eventName);
36
46
  const message = formatPrompt(prompt);
37
- if (!contract.known || contract.response === "neutral" || contract.response === "plugin-paths") return "{}";
38
- if (contract.response === "post-context" || contract.response === "session-context") {
39
- return JSON.stringify({ additional_context: message });
47
+ switch (contract.response) {
48
+ case "neutral":
49
+ case "plugin-paths":
50
+ return "{}";
51
+ case "post-context":
52
+ case "session-context":
53
+ return capAdditionalContext(JSON.stringify({ additional_context: message }));
54
+ case "followup":
55
+ return JSON.stringify({ followup_message: message });
56
+ case "compact-notice":
57
+ return JSON.stringify({ user_message: prompt.userMessage ?? message });
58
+ case "submit-control":
59
+ return JSON.stringify({ continue: prompt.kind !== "block", user_message: prompt.userMessage ?? message });
60
+ case "permission": {
61
+ if (prompt.kind === "inform") {
62
+ return JSON.stringify({
63
+ permission: "allow",
64
+ ...permissionMessages(eventName, prompt.userMessage, prompt.reason ? message : undefined),
65
+ });
66
+ }
67
+ const userMessage = prompt.kind === "ask"
68
+ ? `[downgraded from ask — Cursor does not enforce approval for this event]\n${message}`
69
+ : message;
70
+ return JSON.stringify({
71
+ permission: "deny",
72
+ ...permissionMessages(eventName, userMessage, userMessage),
73
+ });
74
+ }
75
+ default: {
76
+ const exhaustive: never = contract.response;
77
+ return exhaustive;
78
+ }
40
79
  }
41
- if (contract.response === "followup") return JSON.stringify({ followup_message: message });
42
- if (contract.response === "compact-notice") return JSON.stringify({ user_message: prompt.userMessage ?? message });
43
- if (contract.response === "submit-control") {
44
- return JSON.stringify({ continue: prompt.kind !== "block", user_message: prompt.userMessage ?? message });
45
- }
46
- if (prompt.kind === "inform") {
47
- return JSON.stringify({
48
- permission: "allow",
49
- ...permissionMessages(eventName, prompt.userMessage, prompt.reason ? message : undefined),
50
- });
51
- }
52
- const userMessage = prompt.kind === "ask"
53
- ? `[downgraded from ask — Cursor does not enforce approval for this event]\n${message}`
54
- : message;
55
- return JSON.stringify({
56
- permission: "deny",
57
- ...permissionMessages(eventName, userMessage, userMessage),
58
- });
59
80
  }
60
81
 
61
- /** Convert a shared lifecycle handler's output to the native Cursor envelope. */
62
- export function toCursorLifecycleResponse(stdout: string, eventName: string): string {
82
+ /**
83
+ * Convert a shared lifecycle handler's output to the native Cursor envelope.
84
+ * The `neutral` and empty-`text` short circuits run before the switch (they
85
+ * apply identically across several {@link CursorResponseKind} values), so
86
+ * only the remaining 7 kinds need a case — `never` below still catches a
87
+ * future kind added without updating this function. This is the single
88
+ * point every Cursor stdout passes through exactly once (see `handle.ts`'s
89
+ * `handleHook`), so `budget` — when supplied — is reserved from and
90
+ * recorded into here, never at the inner `toCursorResponse` pre-cap (that
91
+ * one's output is re-capped here again on the native-passthrough branch
92
+ * below, so budgeting it too would double-count the same contribution).
93
+ * @param stdout - The shared handler's raw stdout for this hook invocation.
94
+ * @param eventName - Cursor's raw `hook_event_name`.
95
+ * @param budget - Shared `additional_context` budget context (see
96
+ * {@link CursorBudgetContext}); `undefined` falls back to the flat
97
+ * per-response 10,000-char cap, unbudgeted.
98
+ */
99
+ export function toCursorLifecycleResponse(stdout: string, eventName: string, budget?: CursorBudgetContext): string {
63
100
  const contract = cursorEventContract(eventName);
64
101
  const native = parseNativeCursorStdout(stdout, eventName);
65
- if (native !== null) return native;
102
+ if (native !== null) return capAdditionalContextWithBudget(native, budget);
66
103
  let text = stdout;
67
104
  let decision: "allow" | "deny" | "ask" | undefined;
68
105
  let userMessage = "";
@@ -97,27 +134,37 @@ export function toCursorLifecycleResponse(stdout: string, eventName: string): st
97
134
  }
98
135
  if (contract.response === "neutral") return "{}";
99
136
  if (!text) return contract.response === "permission" ? '{"permission":"allow"}' : "{}";
100
- if (contract.response === "session-context" || contract.response === "post-context") {
101
- return JSON.stringify({ additional_context: text });
102
- }
103
- if (contract.response === "permission") {
104
- const permission = decision === "deny" || decision === "ask" ? "deny" : "allow";
105
- const denied = permission === "deny";
106
- // Cursor subagentStart can gate creation but has no model-context channel.
107
- // Drop shared context and its "injected" notice on allow: preserving either
108
- // would claim delivery the native event contract cannot perform.
109
- if (eventName === "subagentStart" && !denied) return '{"permission":"allow"}';
110
- return JSON.stringify({
111
- permission,
112
- ...permissionMessages(
113
- eventName,
114
- userMessage || (denied ? decisionMessage || agentMessage : ""),
115
- agentMessage || (denied ? decisionMessage || userMessage : structured ? "" : text),
116
- ),
117
- });
137
+ switch (contract.response) {
138
+ case "session-context":
139
+ case "post-context":
140
+ return capAdditionalContextWithBudget(JSON.stringify({ additional_context: text }), budget);
141
+ case "permission": {
142
+ const permission = decision === "deny" || decision === "ask" ? "deny" : "allow";
143
+ const denied = permission === "deny";
144
+ // Cursor subagentStart can gate creation but has no model-context channel.
145
+ // Drop shared context and its "injected" notice on allow: preserving either
146
+ // would claim delivery the native event contract cannot perform.
147
+ if (eventName === "subagentStart" && !denied) return '{"permission":"allow"}';
148
+ return JSON.stringify({
149
+ permission,
150
+ ...permissionMessages(
151
+ eventName,
152
+ userMessage || (denied ? decisionMessage || agentMessage : ""),
153
+ agentMessage || (denied ? decisionMessage || userMessage : structured ? "" : text),
154
+ ),
155
+ });
156
+ }
157
+ case "followup":
158
+ return JSON.stringify({ followup_message: text });
159
+ case "compact-notice":
160
+ return JSON.stringify({ user_message: text });
161
+ case "submit-control":
162
+ return JSON.stringify({ continue: true, user_message: text });
163
+ case "plugin-paths":
164
+ return "{}";
165
+ default: {
166
+ const exhaustive: never = contract.response;
167
+ return exhaustive;
168
+ }
118
169
  }
119
- if (contract.response === "followup") return JSON.stringify({ followup_message: text });
120
- if (contract.response === "compact-notice") return JSON.stringify({ user_message: text });
121
- if (contract.response === "submit-control") return JSON.stringify({ continue: true, user_message: text });
122
- return "{}";
123
170
  }
@@ -1,10 +1,12 @@
1
+ import { join } from "node:path";
1
2
  import { projectLayout } from "../config/layout";
2
3
  import { detectFramework } from "../policy/detect-framework";
3
4
  import { detectCreationIntent } from "../policy/creation-intent";
4
5
  import { recordBrainstormRequired } from "../tracking/session-state";
5
6
  import { withTrack } from "../tracking/store";
6
7
  import { normalizeEvent } from "./normalize";
7
- import { defaultStateDir, trackFile } from "./paths";
8
+ import { defaultStateDir, projectHash, trackFile } from "./paths";
9
+ import { fuseHarnessHome } from "./home-state";
8
10
  import { designLifecycle } from "./design-lifecycle";
9
11
  import { promptSubmitContext } from "./inject-context";
10
12
  import { lifecycleStdout } from "./lifecycle-bridge";
@@ -22,6 +24,7 @@ import { codexPromptOrigin } from "./confirm/codex-prompt-origin";
22
24
  import { cursorProjectCwd } from "../adapters/cursor/context";
23
25
  import { toCursorLifecycleResponse } from "../adapters/cursor/respond";
24
26
  import type { HandleOptions, HandleOutcome } from "./handle-types";
27
+ import type { NormalizedEvent } from "./normalize";
25
28
  export type { HandleOptions, HandleOutcome } from "./handle-types";
26
29
 
27
30
  /** Raw Claude hook event name from a payload (empty when absent). */
@@ -29,6 +32,62 @@ function rawEventName(payload: Record<string, unknown>): string {
29
32
  return typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
30
33
  }
31
34
 
35
+ /**
36
+ * `payload.tool_input` parsed into an object when it's a JSON STRING —
37
+ * Cursor's real wire format for `beforeMCPExecution`/`afterMCPExecution`
38
+ * (ground truth), unlike every other harness (and Cursor's own
39
+ * `preToolUse`/`postToolUse`), which always sends it as an object already.
40
+ * `undefined` when `tool_input` is already an object, absent, or fails to
41
+ * parse into one (fail-open — the caller then keeps the original value).
42
+ * @param payload - The raw hook payload.
43
+ */
44
+ function cursorParsedToolInput(payload: Record<string, unknown>): Record<string, unknown> | undefined {
45
+ const raw = payload.tool_input;
46
+ if (typeof raw !== "string") return undefined;
47
+ try {
48
+ const parsed: unknown = JSON.parse(raw);
49
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
50
+ } catch {
51
+ return undefined;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * `id === "cursor"` only: project the already-resolved canonical `tool_name`
57
+ * (`event.tool`, normalized by {@link normalizeEvent}) and `cwd` (the project
58
+ * root resolved via `cursorProjectCwd`, already applied to `opts.cwd`) onto a
59
+ * shallow payload copy — the single passage point for every downstream
60
+ * consumer that reads `payload.tool_name`/`payload.cwd`/`payload.tool_input`
61
+ * RAW instead of `event.tool`/`opts.cwd`/`event.input` (lifecycle-bridge's
62
+ * `failure-lesson.ts`/`agent-memory.ts`, handle-scope-async's aipilot/memory
63
+ * dispatchers — including `doc-cache-gate.ts`'s `libraryOf`, which never
64
+ * `JSON.parse`s a string `tool_input` itself — and the seo scope's
65
+ * `post-tool-use.ts`). `tool_input` is additionally replaced by its parsed
66
+ * object form via {@link cursorParsedToolInput} when Cursor sent it as a
67
+ * JSON string (`beforeMCPExecution`/`afterMCPExecution`). Cursor's own wire
68
+ * values ("Shell", `MCP:<tool>`, a bare `workspace_roots` array with no
69
+ * `cwd` field, a stringified `tool_input`, …) are preserved under
70
+ * `cursor_tool_name`/`cursor_cwd`/`cursor_tool_input` so nothing is lost.
71
+ * Every other harness id is untouched (returns the SAME object,
72
+ * byte-identical).
73
+ * @param payload - The raw hook payload.
74
+ * @param event - The already-normalized event (`event.tool` is canonical).
75
+ * @param cwd - The resolved project root for this invocation.
76
+ * @param id - Harness adapter id.
77
+ */
78
+ function cursorRawPayloadProjection(payload: Record<string, unknown>, event: NormalizedEvent, cwd: string, id: string): Record<string, unknown> {
79
+ if (id !== "cursor") return payload;
80
+ const parsedToolInput = cursorParsedToolInput(payload);
81
+ return {
82
+ ...payload,
83
+ cursor_tool_name: payload.tool_name,
84
+ cursor_cwd: payload.cwd,
85
+ tool_name: event.tool,
86
+ cwd,
87
+ ...(parsedToolInput ? { cursor_tool_input: payload.tool_input, tool_input: parsedToolInput } : {}),
88
+ };
89
+ }
90
+
32
91
  /**
33
92
  * The full hook handler: on a PRE event it gates the tool-use (stateless guards
34
93
  * then APEX gates from the session track) and returns the native response; on a
@@ -41,6 +100,10 @@ async function handleHookCore(id: string, payload: Record<string, unknown>, opts
41
100
  const cursorCwd = cursorProjectCwd(event.cwd, event.workspaceRoots ?? [], event.filePath, opts.cwd);
42
101
  if (cursorCwd !== opts.cwd) opts = { ...opts, cwd: cursorCwd };
43
102
  }
103
+ // Single passage point (see cursorRawPayloadProjection doc): every raw-payload
104
+ // consumer below this line gets the canonical tool_name/cwd on Cursor; every
105
+ // other harness id gets `payload` back untouched (byte-identical object).
106
+ const hookPayload = cursorRawPayloadProjection(payload, event, opts.cwd, id);
44
107
  const rawPrompt = payload.prompt;
45
108
  const userPrompt = typeof rawPrompt === "string" || Array.isArray(rawPrompt) ? promptText(rawPrompt) : undefined;
46
109
  if (id === "codex" && rawEventName(payload) === "UserPromptSubmit" && userPrompt !== undefined) {
@@ -75,11 +138,11 @@ async function handleHookCore(id: string, payload: Record<string, unknown>, opts
75
138
  if (id === "codex" && rawEventName(payload) === "SessionStart") resyncCodexAgents();
76
139
 
77
140
  // Async per-scope lifecycle (aipilot cache handlers + memory-neural Graphiti).
78
- const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload), payload, opts.cwd, opts.now, id);
141
+ const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload), hookPayload, opts.cwd, opts.now, id);
79
142
  if (asyncOut !== null) return { stdout: asyncOut, exit: 0 };
80
143
 
81
144
  // Ported lifecycle/session/context hooks (SessionStart, SubagentStart/Stop, etc.).
82
- const life = lifecycleStdout(payload, opts.cwd, opts.scope ?? "core", opts.now, id);
145
+ const life = lifecycleStdout(hookPayload, opts.cwd, opts.scope ?? "core", opts.now, id);
83
146
  if (life !== null) {
84
147
  // Claude-Code-only: attachBudgetRecap's systemMessage envelope assumes the
85
148
  // Claude adapter's stdout shape (mirrors the designLifecycle gate above).
@@ -99,18 +162,36 @@ async function handleHookCore(id: string, payload: Record<string, unknown>, opts
99
162
  }
100
163
 
101
164
  if (event.phase === "post") {
102
- return handlePost({ id, payload, event, framework, mcpDir, designCacheDir, file, opts });
165
+ return handlePost({ id, payload: hookPayload, event, framework, mcpDir, designCacheDir, file, opts });
103
166
  }
104
167
 
105
- return handlePre({ id, payload, event, framework, mcpDir, designCacheDir, file, opts });
168
+ return handlePre({ id, payload: hookPayload, event, framework, mcpDir, designCacheDir, file, opts });
106
169
  }
107
170
 
108
171
  /**
109
172
  * Run one hook and adapt every Cursor scope outcome at the common runtime exit.
110
173
  * Other harnesses retain the core handler's stdout and exit status unchanged.
174
+ * Cursor's shared `additional_context` budget context (see
175
+ * `../adapters/cursor/context-budget.ts`) is assembled here too — this is
176
+ * the single point every Cursor stdout passes through exactly once, so it's
177
+ * also the single point that reserves from and records into the registry.
178
+ * With no `session_id`/`conversation_id` at all, `sessionId` is `""` — the
179
+ * registry key would degenerate to one bucket shared by every session-less
180
+ * call on the same (cwd, event) pair, so `budget` stays `undefined` instead
181
+ * (falls back to the flat per-response cap in `toCursorLifecycleResponse`,
182
+ * with zero registry I/O). `stateDir` honors `opts.home` (test-only OS home
183
+ * override, see `HandleOptions`) so tests never need the real `os.homedir()`.
111
184
  */
112
185
  export async function handleHook(id: string, payload: Record<string, unknown>, opts: HandleOptions): Promise<HandleOutcome> {
113
186
  const outcome = await handleHookCore(id, payload, opts);
114
187
  if (id !== "cursor") return outcome;
115
- return { ...outcome, stdout: toCursorLifecycleResponse(outcome.stdout, rawEventName(payload)) };
188
+ const eventName = rawEventName(payload);
189
+ const cursorEvent = normalizeEvent(id, payload);
190
+ const cwd = cursorProjectCwd(cursorEvent.cwd, cursorEvent.workspaceRoots ?? [], cursorEvent.filePath, opts.cwd);
191
+ const sessionId = cursorEvent.sessionId;
192
+ const generationId = typeof payload.generation_id === "string" && payload.generation_id ? payload.generation_id : undefined;
193
+ const toolUseId = typeof payload.tool_use_id === "string" && payload.tool_use_id ? payload.tool_use_id : undefined;
194
+ const stateDir = join(fuseHarnessHome(opts.home), "state", projectHash(cwd));
195
+ const budget = sessionId ? { stateDir, sessionId, event: eventName, generationId, toolUseId } : undefined;
196
+ return { ...outcome, stdout: toCursorLifecycleResponse(outcome.stdout, eventName, budget) };
116
197
  }