@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.
- package/dist/adapters/cursor/index.mjs +1 -1
- package/dist/cli/bin.mjs +2 -2
- package/dist/{handle-C43gA-Pr.mjs → handle-BF1dZFjY.mjs} +542 -59
- package/dist/{normalize-BjG6unTj.mjs → normalize-Dy8g9Ybl.mjs} +83 -3
- package/dist/runtime/index.d.mts +10 -0
- package/dist/runtime/index.mjs +1 -1
- package/package.json +1 -1
- package/src/adapters/cursor/context-budget.ts +144 -0
- package/src/adapters/cursor/context-limit.ts +115 -0
- package/src/adapters/cursor/context.ts +21 -2
- package/src/adapters/cursor/interfaces/context-budget.ts +25 -0
- package/src/adapters/cursor/native-response.ts +10 -129
- package/src/adapters/cursor/native-schemas.ts +161 -0
- package/src/adapters/cursor/normalize.ts +65 -0
- package/src/adapters/cursor/plugin-root.ts +103 -0
- package/src/adapters/cursor/respond.ts +94 -47
- package/src/runtime/handle.ts +87 -6
- package/src/runtime/lifecycle/aipilot/dispatch-aipilot.ts +7 -1
- package/src/runtime/lifecycle/failure-lesson.ts +6 -2
- package/src/runtime/lifecycle/rules-root.ts +18 -2
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
/**
|
|
62
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
}
|
package/src/runtime/handle.ts
CHANGED
|
@@ -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),
|
|
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(
|
|
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
|
-
|
|
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
|
}
|