@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
|
@@ -16,12 +16,12 @@ import { t as loadRefs } from "./loader-AGz4nK7d.mjs";
|
|
|
16
16
|
import { C as apexAuthorizationGate, S as trivialCount, _ as recordBrainstormRequired, a as journalLogPath, b as recordTarget, c as appendEvent, d as signTrack, f as verifyTrack, g as recordAgent, h as emptyTrack, i as withTrack, l as withTrackLockSync, m as agentsFresh, o as readTrackSync, p as writeLastNonce, r as trackJournalEnabled, s as diffTrackEvents, t as loadTrack, u as LOCK_FAILED, v as recordDoc, x as recordTrivialEdit, y as recordRefRead } from "./store-5-ZPKb0u.mjs";
|
|
17
17
|
import { i as parseApplyPatch, n as canonicalizeMcpToolName, r as canonicalizeCodexShellTool, t as isBypassPermissions } from "./permission-mode-B-SFyR0X.mjs";
|
|
18
18
|
import { t as commandToString } from "./command-string-CALMTnwN.mjs";
|
|
19
|
-
import { n as cursorProjectCwd, r as cursorEventContract, t as extractCursorEvent } from "./normalize-
|
|
19
|
+
import { n as cursorProjectCwd, r as cursorEventContract, t as extractCursorEvent } from "./normalize-Dy8g9Ybl.mjs";
|
|
20
20
|
import { d as collectFiles, f as pathExists, g as writeText, h as spawnCapture, i as denyResponse, l as systemMessage, m as sleep, n as blockResponse, p as readText$1, r as contextResponse, s as informResponse, t as attachSystemMessage } from "./claude-Ckv2_TgP.mjs";
|
|
21
21
|
import { i as toKimiResponse, n as kimiDenyResponse } from "./kimi-G2wcSh5-.mjs";
|
|
22
22
|
import { r as toHermesResponse } from "./hermes-B9-p_3IF.mjs";
|
|
23
23
|
import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
24
|
-
import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { appendFileSync, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { mkdir, rmdir } from "node:fs/promises";
|
|
27
27
|
import { createHash } from "node:crypto";
|
|
@@ -2144,13 +2144,114 @@ function postEditTypescript(filePath) {
|
|
|
2144
2144
|
return contextResponse("PostToolUse", `Lint issues in ${basename(filePath)}: ${issues.join(" | ")}`);
|
|
2145
2145
|
}
|
|
2146
2146
|
//#endregion
|
|
2147
|
+
//#region src/adapters/cursor/plugin-root.ts
|
|
2148
|
+
/**
|
|
2149
|
+
* Cursor plugin-root resolution — independent from the rules-plugin probing
|
|
2150
|
+
* in `../../runtime/lifecycle/rules-root.ts`. Ground truth (Cursor 3.18.25
|
|
2151
|
+
* binary + cursor.com/docs/hooks): `CURSOR_PLUGIN_ROOT` / `CLAUDE_PLUGIN_ROOT`
|
|
2152
|
+
* (both equal to the plugin install dir) are injected ONLY into
|
|
2153
|
+
* plugin-declared hook processes — never user (`~/.cursor/hooks.json`),
|
|
2154
|
+
* project (`.cursor/hooks.json`), or enterprise hooks. A plugin hook's cwd is
|
|
2155
|
+
* the plugin install dir, EXCEPT for `stop`/`subagentStop`, where it is the
|
|
2156
|
+
* workspace root — callers must pass the right `cwd` for the event they are
|
|
2157
|
+
* handling. Precedence: (1) `CURSOR_PLUGIN_ROOT` env, (2) `CLAUDE_PLUGIN_ROOT`
|
|
2158
|
+
* env, (3) `cwd` when it carries a Cursor plugin marker
|
|
2159
|
+
* (`.cursor-plugin/plugin.json`, `plugin.json` + `hooks/hooks.json`, or a
|
|
2160
|
+
* bare `hooks/hooks.json` — matches installed-plugin layouts under
|
|
2161
|
+
* `~/.cursor/plugins/cache/**` and `~/.cursor/plugins/local/<name>/`), (4)
|
|
2162
|
+
* `none`. Cursor refuses symlinked config paths itself; we do not share that
|
|
2163
|
+
* constraint, so every resolved candidate is realpath-followed instead.
|
|
2164
|
+
*/
|
|
2165
|
+
/** Validate an env candidate: non-empty, NUL-free, absolute, existing dir. */
|
|
2166
|
+
function validateEnvCandidate(label, value, checked) {
|
|
2167
|
+
if (value === void 0 || value === "") {
|
|
2168
|
+
checked.push(`${label}: unset`);
|
|
2169
|
+
return null;
|
|
2170
|
+
}
|
|
2171
|
+
if (value.includes("\0")) {
|
|
2172
|
+
checked.push(`${label}: invalid (contains NUL): "${value}"`);
|
|
2173
|
+
return null;
|
|
2174
|
+
}
|
|
2175
|
+
if (!isAbsolute(value)) {
|
|
2176
|
+
checked.push(`${label}: invalid (not absolute): "${value}"`);
|
|
2177
|
+
return null;
|
|
2178
|
+
}
|
|
2179
|
+
try {
|
|
2180
|
+
if (!statSync(value).isDirectory()) {
|
|
2181
|
+
checked.push(`${label}: invalid (not a directory): "${value}"`);
|
|
2182
|
+
return null;
|
|
2183
|
+
}
|
|
2184
|
+
} catch {
|
|
2185
|
+
checked.push(`${label}: invalid (no such directory): "${value}"`);
|
|
2186
|
+
return null;
|
|
2187
|
+
}
|
|
2188
|
+
try {
|
|
2189
|
+
return realpathSync.native(value);
|
|
2190
|
+
} catch {
|
|
2191
|
+
checked.push(`${label}: invalid (realpath failed): "${value}"`);
|
|
2192
|
+
return null;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
/** True when `dir` carries a recognized Cursor plugin install marker. */
|
|
2196
|
+
function hasPluginMarker(dir) {
|
|
2197
|
+
if (existsSync(join(dir, ".cursor-plugin", "plugin.json"))) return true;
|
|
2198
|
+
if (existsSync(join(dir, "plugin.json")) && existsSync(join(dir, "hooks", "hooks.json"))) return true;
|
|
2199
|
+
return existsSync(join(dir, "hooks", "hooks.json"));
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Resolve the Cursor plugin install root a plugin-declared hook runs from.
|
|
2203
|
+
* @param env - Environment (defaults to `process.env`).
|
|
2204
|
+
* @param cwd - The hook process's cwd for the current event (plugin root for
|
|
2205
|
+
* most events, workspace root for `stop`/`subagentStop` — caller's choice).
|
|
2206
|
+
* @returns The resolved root, its source, and every rejected candidate.
|
|
2207
|
+
*/
|
|
2208
|
+
function resolveCursorPluginRoot(env, cwd) {
|
|
2209
|
+
const checked = [];
|
|
2210
|
+
const fromCursor = validateEnvCandidate("env:CURSOR_PLUGIN_ROOT", env.CURSOR_PLUGIN_ROOT, checked);
|
|
2211
|
+
if (fromCursor) return {
|
|
2212
|
+
root: fromCursor,
|
|
2213
|
+
source: "env:CURSOR_PLUGIN_ROOT",
|
|
2214
|
+
checked
|
|
2215
|
+
};
|
|
2216
|
+
const fromClaude = validateEnvCandidate("env:CLAUDE_PLUGIN_ROOT", env.CLAUDE_PLUGIN_ROOT, checked);
|
|
2217
|
+
if (fromClaude) return {
|
|
2218
|
+
root: fromClaude,
|
|
2219
|
+
source: "env:CLAUDE_PLUGIN_ROOT",
|
|
2220
|
+
checked
|
|
2221
|
+
};
|
|
2222
|
+
if (hasPluginMarker(cwd)) {
|
|
2223
|
+
let resolved = cwd;
|
|
2224
|
+
try {
|
|
2225
|
+
resolved = realpathSync.native(cwd);
|
|
2226
|
+
} catch {}
|
|
2227
|
+
return {
|
|
2228
|
+
root: resolved,
|
|
2229
|
+
source: "cwd:plugin-marker",
|
|
2230
|
+
checked
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
checked.push(`cwd:"${cwd}": no plugin marker found`);
|
|
2234
|
+
return {
|
|
2235
|
+
root: null,
|
|
2236
|
+
source: "none",
|
|
2237
|
+
checked
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
//#endregion
|
|
2147
2241
|
//#region src/runtime/lifecycle/rules-root.ts
|
|
2148
2242
|
/**
|
|
2149
2243
|
* Dynamic rules-plugin root resolution. The historical `CLAUDE_PLUGIN_ROOT ??
|
|
2150
2244
|
* cwd` chain only worked when the harness exported the plugin root — Kimi
|
|
2151
2245
|
* injects `KIMI_PLUGIN_ROOT` instead, and a bare cwd fallback never held a
|
|
2152
|
-
* `rules/` dir.
|
|
2153
|
-
*
|
|
2246
|
+
* `rules/` dir.
|
|
2247
|
+
*
|
|
2248
|
+
* `id === "cursor"` is resolved by a SEPARATE branch (`resolveCursorPluginRoot`)
|
|
2249
|
+
* before any of the below, because Cursor's env contract differs from the
|
|
2250
|
+
* other harnesses (see `../../adapters/cursor/plugin-root.ts`) — it is never
|
|
2251
|
+
* folded into the shared switch. For every other id, resolution order (first
|
|
2252
|
+
* hit wins, unchanged):
|
|
2253
|
+
* 1. `CLAUDE_PLUGIN_ROOT` (read for ALL non-cursor ids, historical quirk —
|
|
2254
|
+
* frozen by non-regression tests, do not "fix" without an explicit ask);
|
|
2154
2255
|
* 2. `KIMI_PLUGIN_ROOT` (kimi plugin-declared hooks);
|
|
2155
2256
|
* 3. Per-harness install probe (claude marketplace, codex versioned cache,
|
|
2156
2257
|
* kimi managed plugins) — first `<plugin>/rules` dir whose plugin folder
|
|
@@ -2204,6 +2305,12 @@ function probeKimi(env, home) {
|
|
|
2204
2305
|
* @returns The plugin root to read `rules/` from.
|
|
2205
2306
|
*/
|
|
2206
2307
|
function resolveRulesRoot(id, cwd, env = process.env) {
|
|
2308
|
+
if (id === "cursor") {
|
|
2309
|
+
const result = resolveCursorPluginRoot(env, cwd);
|
|
2310
|
+
if (result.root) return result.root;
|
|
2311
|
+
process.stderr.write(`[fuse-harness] cursor: no plugin root proven (checked: ${result.checked.join("; ")}); rules root falls back to ${cwd}\n`);
|
|
2312
|
+
return cwd;
|
|
2313
|
+
}
|
|
2207
2314
|
if (env.CLAUDE_PLUGIN_ROOT) return env.CLAUDE_PLUGIN_ROOT;
|
|
2208
2315
|
if (env.KIMI_PLUGIN_ROOT) return env.KIMI_PLUGIN_ROOT;
|
|
2209
2316
|
const home = env.HOME ?? homedir();
|
|
@@ -3422,8 +3529,12 @@ function recordFailure(tool, opts) {
|
|
|
3422
3529
|
* matches the failure message — reusing the PreToolUse {@link lessonFor} index and
|
|
3423
3530
|
* its cooldown (idempotent under the ~11-process fan-out). Fail-open throughout.
|
|
3424
3531
|
*
|
|
3425
|
-
* Claude-Code-only
|
|
3426
|
-
*
|
|
3532
|
+
* Claude-Code-only in the sense that no equivalent `PostToolUseFailure` hook
|
|
3533
|
+
* exists on Codex or Hermes — but Cursor's own `postToolUseFailure` DOES
|
|
3534
|
+
* arrive here too, via `lifecycle-bridge.ts`'s `lifecycleStdout` translating
|
|
3535
|
+
* the wire event name and forwarding to `dispatchLifecycle`'s
|
|
3536
|
+
* `"PostToolUseFailure"` case (see `handle.ts`'s `cursorRawPayloadProjection`
|
|
3537
|
+
* for how `data.tool_name` below arrives already canonicalized on Cursor).
|
|
3427
3538
|
* @packageDocumentation
|
|
3428
3539
|
*/
|
|
3429
3540
|
/** The failure message across the documented `error` field and defensive fallbacks; "" when none. */
|
|
@@ -6032,7 +6143,7 @@ async function dispatchAipilot(event, payload, cwd, now, home = homedir(), id =
|
|
|
6032
6143
|
await cacheAnalyticsSave(home, now);
|
|
6033
6144
|
return "";
|
|
6034
6145
|
}
|
|
6035
|
-
if (event === "PreToolUse") return docCacheGate(payload, cwd, now, home, id);
|
|
6146
|
+
if (event === "PreToolUse" || id === "cursor" && event === "BeforeMCPExecution") return docCacheGate(payload, cwd, now, home, id);
|
|
6036
6147
|
return null;
|
|
6037
6148
|
}
|
|
6038
6149
|
/** PostToolUse (Write/Edit SOLID check, else TaskCreate/TaskUpdate sync) for the ai-pilot scope; `id` selects the harness target (defaults to "claude-code"). */
|
|
@@ -6654,9 +6765,10 @@ async function autoDocumentRead(filePath, now = Date.now(), id = "claude-code")
|
|
|
6654
6765
|
return systemMessage(`📖 [${docType}] ${fname} logged`);
|
|
6655
6766
|
}
|
|
6656
6767
|
//#endregion
|
|
6657
|
-
//#region src/adapters/cursor/native-
|
|
6768
|
+
//#region src/adapters/cursor/native-schemas.ts
|
|
6658
6769
|
const stringValue = (value) => typeof value === "string";
|
|
6659
6770
|
const booleanValue = (value) => typeof value === "boolean";
|
|
6771
|
+
/** A plain `{}`-literal or `Object.create(null)` object — never a class instance or array. */
|
|
6660
6772
|
const plainRecord = (value) => {
|
|
6661
6773
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
6662
6774
|
try {
|
|
@@ -6723,6 +6835,7 @@ function jsonValue(root) {
|
|
|
6723
6835
|
}
|
|
6724
6836
|
return true;
|
|
6725
6837
|
}
|
|
6838
|
+
/** A JSON-safe plain object (no cycles, no non-finite numbers, no exotic prototypes). */
|
|
6726
6839
|
const recordValue = (value) => plainRecord(value) && jsonValue(value);
|
|
6727
6840
|
const stringRecord = (value) => {
|
|
6728
6841
|
if (!recordValue(value)) return false;
|
|
@@ -6744,6 +6857,21 @@ const PERMISSION_ASK = {
|
|
|
6744
6857
|
},
|
|
6745
6858
|
required: ["permission"]
|
|
6746
6859
|
};
|
|
6860
|
+
const PRE_TOOL_USE = {
|
|
6861
|
+
fields: {
|
|
6862
|
+
permission: permission("allow", "deny", "ask"),
|
|
6863
|
+
user_message: stringValue,
|
|
6864
|
+
agent_message: stringValue,
|
|
6865
|
+
updated_input: recordValue,
|
|
6866
|
+
additional_context: stringValue
|
|
6867
|
+
},
|
|
6868
|
+
required: ["permission"]
|
|
6869
|
+
};
|
|
6870
|
+
/**
|
|
6871
|
+
* Exact native stdout field set Cursor 3.18.25 reads per hook event.
|
|
6872
|
+
* Nothing beyond this list is invented: any additional key on a candidate
|
|
6873
|
+
* value fails {@link isNativeCursorResponse} in native-response.ts.
|
|
6874
|
+
*/
|
|
6747
6875
|
const NATIVE_SCHEMAS = {
|
|
6748
6876
|
sessionStart: { fields: {
|
|
6749
6877
|
env: stringRecord,
|
|
@@ -6755,7 +6883,8 @@ const NATIVE_SCHEMAS = {
|
|
|
6755
6883
|
beforeSubmitPrompt: {
|
|
6756
6884
|
fields: {
|
|
6757
6885
|
continue: booleanValue,
|
|
6758
|
-
user_message: stringValue
|
|
6886
|
+
user_message: stringValue,
|
|
6887
|
+
additional_context: stringValue
|
|
6759
6888
|
},
|
|
6760
6889
|
required: ["continue"]
|
|
6761
6890
|
},
|
|
@@ -6768,18 +6897,12 @@ const NATIVE_SCHEMAS = {
|
|
|
6768
6897
|
required: ["permission"]
|
|
6769
6898
|
},
|
|
6770
6899
|
subagentStop: FOLLOWUP,
|
|
6771
|
-
preToolUse:
|
|
6772
|
-
fields: {
|
|
6773
|
-
...PERMISSION_ASK.fields,
|
|
6774
|
-
updated_input: recordValue
|
|
6775
|
-
},
|
|
6776
|
-
required: ["permission"]
|
|
6777
|
-
},
|
|
6900
|
+
preToolUse: PRE_TOOL_USE,
|
|
6778
6901
|
postToolUse: { fields: {
|
|
6779
6902
|
updated_mcp_tool_output: recordValue,
|
|
6780
6903
|
additional_context: stringValue
|
|
6781
6904
|
} },
|
|
6782
|
-
postToolUseFailure:
|
|
6905
|
+
postToolUseFailure: { fields: { additional_context: stringValue } },
|
|
6783
6906
|
beforeShellExecution: PERMISSION_ASK,
|
|
6784
6907
|
afterShellExecution: EMPTY,
|
|
6785
6908
|
beforeMCPExecution: PERMISSION_ASK,
|
|
@@ -6802,6 +6925,16 @@ const NATIVE_SCHEMAS = {
|
|
|
6802
6925
|
stop: FOLLOWUP,
|
|
6803
6926
|
workspaceOpen: { fields: { pluginPaths: stringArray } }
|
|
6804
6927
|
};
|
|
6928
|
+
//#endregion
|
|
6929
|
+
//#region src/adapters/cursor/native-response.ts
|
|
6930
|
+
/**
|
|
6931
|
+
* Check that every enumerable own key of `value` is a documented field for
|
|
6932
|
+
* `eventName` and passes its validator, and that every required field is
|
|
6933
|
+
* present. Rejects prototype-polluted or exotic-shaped candidates via
|
|
6934
|
+
* {@link recordValue}.
|
|
6935
|
+
* @param value - Parsed JSON candidate.
|
|
6936
|
+
* @param eventName - The Cursor hook event the candidate would answer.
|
|
6937
|
+
*/
|
|
6805
6938
|
function isNativeCursorResponse(value, eventName) {
|
|
6806
6939
|
try {
|
|
6807
6940
|
if (!recordValue(value)) return false;
|
|
@@ -6830,6 +6963,240 @@ function parseNativeCursorStdout(stdout, eventName) {
|
|
|
6830
6963
|
}
|
|
6831
6964
|
}
|
|
6832
6965
|
//#endregion
|
|
6966
|
+
//#region src/adapters/cursor/context-limit.ts
|
|
6967
|
+
/**
|
|
6968
|
+
* @module context-limit
|
|
6969
|
+
* Cursor 3.18.25's `hooks-carriers` drops an `additional_context` carrier
|
|
6970
|
+
* once `o.length>1e4` — but `o` is the MERGED text of every hook's
|
|
6971
|
+
* `additional_context` for that event (concatenated with `"\n\n---\n\n"`
|
|
6972
|
+
* before the 10,000-char check), not this harness's response in isolation.
|
|
6973
|
+
* Capping our own contribution at {@link ADDITIONAL_CONTEXT_LIMIT} is
|
|
6974
|
+
* therefore the LAST-RESORT guard, not the real protection: on its own it
|
|
6975
|
+
* only proves OUR piece stays under 10,000, while the total across every
|
|
6976
|
+
* hook plugin configured on the same event can still exceed it and get
|
|
6977
|
+
* dropped wholesale — measured at ~8,400 chars on `sessionStart` from core
|
|
6978
|
+
* plugins alone, close enough to the ceiling that one more plugin tips it
|
|
6979
|
+
* over. The actual protection is the cross-process shared budget registry
|
|
6980
|
+
* in `./context-budget.ts` (Cursor id only), which reserves a slice of the
|
|
6981
|
+
* 10,000 ceiling per (session, event, generation) key BEFORE calling
|
|
6982
|
+
* {@link truncateAdditionalContext} here with the reserved amount instead of
|
|
6983
|
+
* the flat {@link ADDITIONAL_CONTEXT_LIMIT} — this module stays a pure,
|
|
6984
|
+
* budget-agnostic primitive so it keeps working unbudgeted (its historical,
|
|
6985
|
+
* still-correct behavior) wherever no budget context is available. The
|
|
6986
|
+
* limit unit is UTF-16 code units (`String.prototype.length`), matching
|
|
6987
|
+
* `value.length` here exactly. Only 5 events carry `additional_context`
|
|
6988
|
+
* through this carrier — sessionStart, beforeSubmitPrompt, preToolUse,
|
|
6989
|
+
* postToolUse, postToolUseFailure — subagentStart/subagentStop use a
|
|
6990
|
+
* different, unlimited channel. "Drops silently" also only holds when no
|
|
6991
|
+
* `failClosed: true` hook is declared on that step/tool: with one declared,
|
|
6992
|
+
* an oversized carrier REJECTS the tool call instead of being dropped quiet.
|
|
6993
|
+
*/
|
|
6994
|
+
/** Cursor's hard `additional_context` character ceiling. */
|
|
6995
|
+
const ADDITIONAL_CONTEXT_LIMIT = 1e4;
|
|
6996
|
+
/** Suffix appended by {@link truncateAdditionalContext} once a value is cut. */
|
|
6997
|
+
const TRUNCATION_MARKER = "\n[fuse-harness] additional_context truncated to Cursor's 10000-char limit";
|
|
6998
|
+
function isPlainObject(value) {
|
|
6999
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7000
|
+
}
|
|
7001
|
+
/**
|
|
7002
|
+
* Truncate a string to end with {@link TRUNCATION_MARKER} once its length
|
|
7003
|
+
* exceeds `limit`. Leaves shorter values untouched. Idempotent under a
|
|
7004
|
+
* SHRINKING `limit` across repeated calls (e.g. an unbudgeted flat-cap pass
|
|
7005
|
+
* followed by a budgeted re-cap of the same stdout — see `./respond.ts`'s
|
|
7006
|
+
* `toCursorLifecycleResponse` doc): when `value` already ends with
|
|
7007
|
+
* {@link TRUNCATION_MARKER}, that marker is stripped BEFORE re-slicing so the
|
|
7008
|
+
* result carries exactly one marker instead of risking a duplicated/cut one.
|
|
7009
|
+
* @param value - Candidate `additional_context` body.
|
|
7010
|
+
* @param limit - Effective ceiling for this call (defaults to the flat
|
|
7011
|
+
* {@link ADDITIONAL_CONTEXT_LIMIT}; a shared-budget caller passes a smaller,
|
|
7012
|
+
* per-reservation value instead).
|
|
7013
|
+
*/
|
|
7014
|
+
function truncateAdditionalContext(value, limit = ADDITIONAL_CONTEXT_LIMIT) {
|
|
7015
|
+
const alreadyMarked = value.endsWith(TRUNCATION_MARKER);
|
|
7016
|
+
if (!alreadyMarked && value.length <= limit) return value;
|
|
7017
|
+
if (limit <= 73) return TRUNCATION_MARKER.slice(0, Math.max(0, limit));
|
|
7018
|
+
return (alreadyMarked ? value.slice(0, value.length - 73) : value).slice(0, limit - 73) + TRUNCATION_MARKER;
|
|
7019
|
+
}
|
|
7020
|
+
/**
|
|
7021
|
+
* Length of a Cursor stdout JSON's `additional_context` string field, or 0
|
|
7022
|
+
* when the stdout is not JSON, has no such field, or that field isn't a
|
|
7023
|
+
* string.
|
|
7024
|
+
* @param stdout - A native Cursor JSON stdout candidate.
|
|
7025
|
+
*/
|
|
7026
|
+
function additionalContextLength(stdout) {
|
|
7027
|
+
let parsed;
|
|
7028
|
+
try {
|
|
7029
|
+
parsed = JSON.parse(stdout);
|
|
7030
|
+
} catch {
|
|
7031
|
+
return 0;
|
|
7032
|
+
}
|
|
7033
|
+
return isPlainObject(parsed) && typeof parsed.additional_context === "string" ? parsed.additional_context.length : 0;
|
|
7034
|
+
}
|
|
7035
|
+
/**
|
|
7036
|
+
* Re-serialize a Cursor stdout string with its `additional_context` field
|
|
7037
|
+
* dropped entirely — used once the shared budget has no room left even for
|
|
7038
|
+
* a truncated marker. Returns the input byte-for-byte unchanged when it is
|
|
7039
|
+
* not JSON or has no string `additional_context` field.
|
|
7040
|
+
* @param stdout - A native Cursor JSON stdout candidate.
|
|
7041
|
+
*/
|
|
7042
|
+
function omitAdditionalContext(stdout) {
|
|
7043
|
+
let parsed;
|
|
7044
|
+
try {
|
|
7045
|
+
parsed = JSON.parse(stdout);
|
|
7046
|
+
} catch {
|
|
7047
|
+
return stdout;
|
|
7048
|
+
}
|
|
7049
|
+
if (!isPlainObject(parsed) || typeof parsed.additional_context !== "string") return stdout;
|
|
7050
|
+
const { additional_context: _omitted, ...rest } = parsed;
|
|
7051
|
+
return JSON.stringify(rest);
|
|
7052
|
+
}
|
|
7053
|
+
/**
|
|
7054
|
+
* Re-serialize a Cursor stdout string with its `additional_context` field
|
|
7055
|
+
* capped at `limit` characters. Returns the input byte-for-byte unchanged
|
|
7056
|
+
* when it is not JSON, has no string `additional_context` field, or that
|
|
7057
|
+
* field is already within the limit — so callers can wrap every return path
|
|
7058
|
+
* unconditionally.
|
|
7059
|
+
* @param stdout - A native Cursor JSON stdout candidate.
|
|
7060
|
+
* @param limit - Effective ceiling for this call (see {@link truncateAdditionalContext}).
|
|
7061
|
+
*/
|
|
7062
|
+
function capAdditionalContext(stdout, limit = ADDITIONAL_CONTEXT_LIMIT) {
|
|
7063
|
+
let parsed;
|
|
7064
|
+
try {
|
|
7065
|
+
parsed = JSON.parse(stdout);
|
|
7066
|
+
} catch {
|
|
7067
|
+
return stdout;
|
|
7068
|
+
}
|
|
7069
|
+
if (!isPlainObject(parsed) || typeof parsed.additional_context !== "string") return stdout;
|
|
7070
|
+
const truncated = truncateAdditionalContext(parsed.additional_context, limit);
|
|
7071
|
+
if (truncated === parsed.additional_context) return stdout;
|
|
7072
|
+
return JSON.stringify({
|
|
7073
|
+
...parsed,
|
|
7074
|
+
additional_context: truncated
|
|
7075
|
+
});
|
|
7076
|
+
}
|
|
7077
|
+
//#endregion
|
|
7078
|
+
//#region src/adapters/cursor/context-budget.ts
|
|
7079
|
+
/**
|
|
7080
|
+
* @module context-budget
|
|
7081
|
+
* Cursor-only shared `additional_context` budget registry. Cursor 3.18.25
|
|
7082
|
+
* runs every hook plugin configured on an event in its OWN process, then
|
|
7083
|
+
* merges their `additional_context` outputs with a 9-char `"\n\n---\n\n"`
|
|
7084
|
+
* separator and drops the WHOLE merge past 10,000 UTF-16 units — so no
|
|
7085
|
+
* single process can know the total by itself. This module gives every
|
|
7086
|
+
* plugin's process a shared, best-effort view of that total via a small
|
|
7087
|
+
* JSON registry file under the project's state dir (see `../../runtime/paths.ts`),
|
|
7088
|
+
* keyed by `${sessionId}|${event}|${generationId ?? ""}|${toolUseId ?? ""}`
|
|
7089
|
+
* (one key per merge group — Cursor merges preToolUse/postToolUse/
|
|
7090
|
+
* postToolUseFailure PER TOOL CALL, so `toolUseId` joins the key on those
|
|
7091
|
+
* three events), with entries older than 10s ignored (concurrent hooks on one
|
|
7092
|
+
* event fire within the same second). Best-effort, fail-open throughout: any
|
|
7093
|
+
* I/O or JSON error degrades to "no shared budget", i.e. the flat
|
|
7094
|
+
* per-response cap in `./context-limit.ts` alone — never a thrown error, and
|
|
7095
|
+
* never a Cursor-side regression.
|
|
7096
|
+
*/
|
|
7097
|
+
const REGISTRY_FILE = "cursor-context-budget.json";
|
|
7098
|
+
/** Matches Cursor 3.18.25's observed `"\n\n---\n\n"` merge separator length. */
|
|
7099
|
+
const SEPARATOR_LENGTH = 9;
|
|
7100
|
+
const ENTRY_WINDOW_MS = 1e4;
|
|
7101
|
+
/** Below this, a truncated value would carry more marker than budget — omit the field instead. */
|
|
7102
|
+
const OMIT_THRESHOLD = 173;
|
|
7103
|
+
function isRegistry(value) {
|
|
7104
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7105
|
+
}
|
|
7106
|
+
function registryPath(stateDir) {
|
|
7107
|
+
return join(stateDir, REGISTRY_FILE);
|
|
7108
|
+
}
|
|
7109
|
+
function budgetKey(ctx) {
|
|
7110
|
+
return `${ctx.sessionId}|${ctx.event}|${ctx.generationId ?? ""}|${ctx.toolUseId ?? ""}`;
|
|
7111
|
+
}
|
|
7112
|
+
function loadRegistry(path) {
|
|
7113
|
+
try {
|
|
7114
|
+
if (!existsSync(path)) return {};
|
|
7115
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
7116
|
+
return isRegistry(parsed) ? parsed : {};
|
|
7117
|
+
} catch {
|
|
7118
|
+
return {};
|
|
7119
|
+
}
|
|
7120
|
+
}
|
|
7121
|
+
function freshEntries(entries, now) {
|
|
7122
|
+
return (entries ?? []).filter((entry) => now - entry.at <= ENTRY_WINDOW_MS);
|
|
7123
|
+
}
|
|
7124
|
+
/** Sum of a key's fresh entry lengths plus the separators already joining them. */
|
|
7125
|
+
function consumed(entries) {
|
|
7126
|
+
return entries.reduce((total, entry) => total + entry.length, 0) + SEPARATOR_LENGTH * Math.max(0, entries.length - 1);
|
|
7127
|
+
}
|
|
7128
|
+
/**
|
|
7129
|
+
* Reserve room in the shared budget for one hook's `additional_context`
|
|
7130
|
+
* contribution to one (session, event, generation) merge group. `wanted` is
|
|
7131
|
+
* accepted for a symmetric call shape with {@link recordAdditionalContext}
|
|
7132
|
+
* but does not shrink `allowed` itself — the ceiling only depends on what
|
|
7133
|
+
* OTHER entries already hold; a smaller `wanted` simply means the caller
|
|
7134
|
+
* won't need all of it. Best-effort, fail-open: any I/O/JSON error returns
|
|
7135
|
+
* the full flat ceiling, as if no other plugin had run.
|
|
7136
|
+
* @param input - Registry location, reservation key, and the wanted length.
|
|
7137
|
+
*/
|
|
7138
|
+
function reserveAdditionalContext(input) {
|
|
7139
|
+
try {
|
|
7140
|
+
const now = input.now ?? Date.now();
|
|
7141
|
+
const fresh = freshEntries(loadRegistry(registryPath(input.stateDir))[budgetKey(input)], now);
|
|
7142
|
+
const separator = fresh.length > 0 ? SEPARATOR_LENGTH : 0;
|
|
7143
|
+
return { allowed: Math.max(0, ADDITIONAL_CONTEXT_LIMIT - consumed(fresh) - separator) };
|
|
7144
|
+
} catch {
|
|
7145
|
+
return { allowed: ADDITIONAL_CONTEXT_LIMIT };
|
|
7146
|
+
}
|
|
7147
|
+
}
|
|
7148
|
+
/**
|
|
7149
|
+
* Record the length actually emitted for one reservation, best-effort.
|
|
7150
|
+
* Prunes every key's stale entries while it holds the write so the registry
|
|
7151
|
+
* file stays bounded. Silently no-ops on any I/O error (fail-open).
|
|
7152
|
+
* @param input - Registry location, reservation key, and the emitted length.
|
|
7153
|
+
*/
|
|
7154
|
+
function recordAdditionalContext(input) {
|
|
7155
|
+
try {
|
|
7156
|
+
const now = input.now ?? Date.now();
|
|
7157
|
+
const path = registryPath(input.stateDir);
|
|
7158
|
+
const registry = loadRegistry(path);
|
|
7159
|
+
const pruned = {};
|
|
7160
|
+
for (const [key, entries] of Object.entries(registry)) {
|
|
7161
|
+
const fresh = freshEntries(entries, now);
|
|
7162
|
+
if (fresh.length > 0) pruned[key] = fresh;
|
|
7163
|
+
}
|
|
7164
|
+
const key = budgetKey(input);
|
|
7165
|
+
pruned[key] = [...pruned[key] ?? [], {
|
|
7166
|
+
at: now,
|
|
7167
|
+
length: input.emitted
|
|
7168
|
+
}];
|
|
7169
|
+
atomicWrite(path, JSON.stringify(pruned));
|
|
7170
|
+
} catch {}
|
|
7171
|
+
}
|
|
7172
|
+
/**
|
|
7173
|
+
* Cap a Cursor stdout JSON's `additional_context` against the shared budget
|
|
7174
|
+
* instead of the flat per-response ceiling alone. Falls back to the plain
|
|
7175
|
+
* cap (`./context-limit.ts`), unbudgeted, when `budget` is `undefined` or
|
|
7176
|
+
* the stdout carries no `additional_context` at all.
|
|
7177
|
+
* @param stdout - A native Cursor JSON stdout candidate.
|
|
7178
|
+
* @param budget - Shared budget context, or `undefined` to skip it.
|
|
7179
|
+
*/
|
|
7180
|
+
function capAdditionalContextWithBudget(stdout, budget) {
|
|
7181
|
+
if (!budget) return capAdditionalContext(stdout);
|
|
7182
|
+
const wanted = additionalContextLength(stdout);
|
|
7183
|
+
if (wanted === 0) return stdout;
|
|
7184
|
+
const { allowed } = reserveAdditionalContext({
|
|
7185
|
+
...budget,
|
|
7186
|
+
wanted
|
|
7187
|
+
});
|
|
7188
|
+
if (allowed < OMIT_THRESHOLD) {
|
|
7189
|
+
process.stderr.write(`[fuse-harness] cursor: additional_context budget exhausted for ${budget.event} (allowed=${allowed})\n`);
|
|
7190
|
+
return omitAdditionalContext(stdout);
|
|
7191
|
+
}
|
|
7192
|
+
const capped = capAdditionalContext(stdout, Math.min(ADDITIONAL_CONTEXT_LIMIT, allowed));
|
|
7193
|
+
recordAdditionalContext({
|
|
7194
|
+
...budget,
|
|
7195
|
+
emitted: additionalContextLength(capped)
|
|
7196
|
+
});
|
|
7197
|
+
return capped;
|
|
7198
|
+
}
|
|
7199
|
+
//#endregion
|
|
6833
7200
|
//#region src/adapters/cursor/respond.ts
|
|
6834
7201
|
const AGENT_MESSAGE_EVENTS = /* @__PURE__ */ new Set([
|
|
6835
7202
|
"preToolUse",
|
|
@@ -6855,33 +7222,63 @@ function joinMessages(...values) {
|
|
|
6855
7222
|
function isRecord(value) {
|
|
6856
7223
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6857
7224
|
}
|
|
6858
|
-
/**
|
|
7225
|
+
/**
|
|
7226
|
+
* Render a portable policy prompt using the native Cursor event contract.
|
|
7227
|
+
* Switches exhaustively on {@link CursorResponseKind} — the `never` default
|
|
7228
|
+
* fails to compile if a new kind is ever added without a matching case.
|
|
7229
|
+
* `contract.known === false` is not tested separately: the single
|
|
7230
|
+
* `UNKNOWN_EVENT` fallback in events.ts always pairs `known: false` with
|
|
7231
|
+
* `response: "neutral"`, so both collapse to the same `"{}"` branch.
|
|
7232
|
+
*/
|
|
6859
7233
|
function toCursorResponse(prompt, eventName) {
|
|
6860
7234
|
const contract = cursorEventContract(eventName);
|
|
6861
7235
|
const message = formatPrompt(prompt);
|
|
6862
|
-
|
|
6863
|
-
|
|
6864
|
-
|
|
6865
|
-
|
|
6866
|
-
|
|
6867
|
-
|
|
6868
|
-
user_message: prompt.userMessage ?? message
|
|
6869
|
-
|
|
6870
|
-
|
|
6871
|
-
|
|
6872
|
-
|
|
6873
|
-
|
|
6874
|
-
|
|
6875
|
-
|
|
6876
|
-
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
7236
|
+
switch (contract.response) {
|
|
7237
|
+
case "neutral":
|
|
7238
|
+
case "plugin-paths": return "{}";
|
|
7239
|
+
case "post-context":
|
|
7240
|
+
case "session-context": return capAdditionalContext(JSON.stringify({ additional_context: message }));
|
|
7241
|
+
case "followup": return JSON.stringify({ followup_message: message });
|
|
7242
|
+
case "compact-notice": return JSON.stringify({ user_message: prompt.userMessage ?? message });
|
|
7243
|
+
case "submit-control": return JSON.stringify({
|
|
7244
|
+
continue: prompt.kind !== "block",
|
|
7245
|
+
user_message: prompt.userMessage ?? message
|
|
7246
|
+
});
|
|
7247
|
+
case "permission": {
|
|
7248
|
+
if (prompt.kind === "inform") return JSON.stringify({
|
|
7249
|
+
permission: "allow",
|
|
7250
|
+
...permissionMessages(eventName, prompt.userMessage, prompt.reason ? message : void 0)
|
|
7251
|
+
});
|
|
7252
|
+
const userMessage = prompt.kind === "ask" ? `[downgraded from ask — Cursor does not enforce approval for this event]\n${message}` : message;
|
|
7253
|
+
return JSON.stringify({
|
|
7254
|
+
permission: "deny",
|
|
7255
|
+
...permissionMessages(eventName, userMessage, userMessage)
|
|
7256
|
+
});
|
|
7257
|
+
}
|
|
7258
|
+
default: return contract.response;
|
|
7259
|
+
}
|
|
7260
|
+
}
|
|
7261
|
+
/**
|
|
7262
|
+
* Convert a shared lifecycle handler's output to the native Cursor envelope.
|
|
7263
|
+
* The `neutral` and empty-`text` short circuits run before the switch (they
|
|
7264
|
+
* apply identically across several {@link CursorResponseKind} values), so
|
|
7265
|
+
* only the remaining 7 kinds need a case — `never` below still catches a
|
|
7266
|
+
* future kind added without updating this function. This is the single
|
|
7267
|
+
* point every Cursor stdout passes through exactly once (see `handle.ts`'s
|
|
7268
|
+
* `handleHook`), so `budget` — when supplied — is reserved from and
|
|
7269
|
+
* recorded into here, never at the inner `toCursorResponse` pre-cap (that
|
|
7270
|
+
* one's output is re-capped here again on the native-passthrough branch
|
|
7271
|
+
* below, so budgeting it too would double-count the same contribution).
|
|
7272
|
+
* @param stdout - The shared handler's raw stdout for this hook invocation.
|
|
7273
|
+
* @param eventName - Cursor's raw `hook_event_name`.
|
|
7274
|
+
* @param budget - Shared `additional_context` budget context (see
|
|
7275
|
+
* {@link CursorBudgetContext}); `undefined` falls back to the flat
|
|
7276
|
+
* per-response 10,000-char cap, unbudgeted.
|
|
7277
|
+
*/
|
|
7278
|
+
function toCursorLifecycleResponse(stdout, eventName, budget) {
|
|
6882
7279
|
const contract = cursorEventContract(eventName);
|
|
6883
7280
|
const native = parseNativeCursorStdout(stdout, eventName);
|
|
6884
|
-
if (native !== null) return native;
|
|
7281
|
+
if (native !== null) return capAdditionalContextWithBudget(native, budget);
|
|
6885
7282
|
let text = stdout;
|
|
6886
7283
|
let decision;
|
|
6887
7284
|
let userMessage = "";
|
|
@@ -6903,23 +7300,27 @@ function toCursorLifecycleResponse(stdout, eventName) {
|
|
|
6903
7300
|
}
|
|
6904
7301
|
if (contract.response === "neutral") return "{}";
|
|
6905
7302
|
if (!text) return contract.response === "permission" ? "{\"permission\":\"allow\"}" : "{}";
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
6910
|
-
|
|
6911
|
-
|
|
6912
|
-
permission
|
|
6913
|
-
|
|
7303
|
+
switch (contract.response) {
|
|
7304
|
+
case "session-context":
|
|
7305
|
+
case "post-context": return capAdditionalContextWithBudget(JSON.stringify({ additional_context: text }), budget);
|
|
7306
|
+
case "permission": {
|
|
7307
|
+
const permission = decision === "deny" || decision === "ask" ? "deny" : "allow";
|
|
7308
|
+
const denied = permission === "deny";
|
|
7309
|
+
if (eventName === "subagentStart" && !denied) return "{\"permission\":\"allow\"}";
|
|
7310
|
+
return JSON.stringify({
|
|
7311
|
+
permission,
|
|
7312
|
+
...permissionMessages(eventName, userMessage || (denied ? decisionMessage || agentMessage : ""), agentMessage || (denied ? decisionMessage || userMessage : structured ? "" : text))
|
|
7313
|
+
});
|
|
7314
|
+
}
|
|
7315
|
+
case "followup": return JSON.stringify({ followup_message: text });
|
|
7316
|
+
case "compact-notice": return JSON.stringify({ user_message: text });
|
|
7317
|
+
case "submit-control": return JSON.stringify({
|
|
7318
|
+
continue: true,
|
|
7319
|
+
user_message: text
|
|
6914
7320
|
});
|
|
7321
|
+
case "plugin-paths": return "{}";
|
|
7322
|
+
default: return contract.response;
|
|
6915
7323
|
}
|
|
6916
|
-
if (contract.response === "followup") return JSON.stringify({ followup_message: text });
|
|
6917
|
-
if (contract.response === "compact-notice") return JSON.stringify({ user_message: text });
|
|
6918
|
-
if (contract.response === "submit-control") return JSON.stringify({
|
|
6919
|
-
continue: true,
|
|
6920
|
-
user_message: text
|
|
6921
|
-
});
|
|
6922
|
-
return "{}";
|
|
6923
7324
|
}
|
|
6924
7325
|
//#endregion
|
|
6925
7326
|
//#region src/runtime/lifecycle-bridge.ts
|
|
@@ -11062,6 +11463,63 @@ function rawEventName(payload) {
|
|
|
11062
11463
|
return typeof payload.hook_event_name === "string" ? payload.hook_event_name : "";
|
|
11063
11464
|
}
|
|
11064
11465
|
/**
|
|
11466
|
+
* `payload.tool_input` parsed into an object when it's a JSON STRING —
|
|
11467
|
+
* Cursor's real wire format for `beforeMCPExecution`/`afterMCPExecution`
|
|
11468
|
+
* (ground truth), unlike every other harness (and Cursor's own
|
|
11469
|
+
* `preToolUse`/`postToolUse`), which always sends it as an object already.
|
|
11470
|
+
* `undefined` when `tool_input` is already an object, absent, or fails to
|
|
11471
|
+
* parse into one (fail-open — the caller then keeps the original value).
|
|
11472
|
+
* @param payload - The raw hook payload.
|
|
11473
|
+
*/
|
|
11474
|
+
function cursorParsedToolInput(payload) {
|
|
11475
|
+
const raw = payload.tool_input;
|
|
11476
|
+
if (typeof raw !== "string") return void 0;
|
|
11477
|
+
try {
|
|
11478
|
+
const parsed = JSON.parse(raw);
|
|
11479
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : void 0;
|
|
11480
|
+
} catch {
|
|
11481
|
+
return;
|
|
11482
|
+
}
|
|
11483
|
+
}
|
|
11484
|
+
/**
|
|
11485
|
+
* `id === "cursor"` only: project the already-resolved canonical `tool_name`
|
|
11486
|
+
* (`event.tool`, normalized by {@link normalizeEvent}) and `cwd` (the project
|
|
11487
|
+
* root resolved via `cursorProjectCwd`, already applied to `opts.cwd`) onto a
|
|
11488
|
+
* shallow payload copy — the single passage point for every downstream
|
|
11489
|
+
* consumer that reads `payload.tool_name`/`payload.cwd`/`payload.tool_input`
|
|
11490
|
+
* RAW instead of `event.tool`/`opts.cwd`/`event.input` (lifecycle-bridge's
|
|
11491
|
+
* `failure-lesson.ts`/`agent-memory.ts`, handle-scope-async's aipilot/memory
|
|
11492
|
+
* dispatchers — including `doc-cache-gate.ts`'s `libraryOf`, which never
|
|
11493
|
+
* `JSON.parse`s a string `tool_input` itself — and the seo scope's
|
|
11494
|
+
* `post-tool-use.ts`). `tool_input` is additionally replaced by its parsed
|
|
11495
|
+
* object form via {@link cursorParsedToolInput} when Cursor sent it as a
|
|
11496
|
+
* JSON string (`beforeMCPExecution`/`afterMCPExecution`). Cursor's own wire
|
|
11497
|
+
* values ("Shell", `MCP:<tool>`, a bare `workspace_roots` array with no
|
|
11498
|
+
* `cwd` field, a stringified `tool_input`, …) are preserved under
|
|
11499
|
+
* `cursor_tool_name`/`cursor_cwd`/`cursor_tool_input` so nothing is lost.
|
|
11500
|
+
* Every other harness id is untouched (returns the SAME object,
|
|
11501
|
+
* byte-identical).
|
|
11502
|
+
* @param payload - The raw hook payload.
|
|
11503
|
+
* @param event - The already-normalized event (`event.tool` is canonical).
|
|
11504
|
+
* @param cwd - The resolved project root for this invocation.
|
|
11505
|
+
* @param id - Harness adapter id.
|
|
11506
|
+
*/
|
|
11507
|
+
function cursorRawPayloadProjection(payload, event, cwd, id) {
|
|
11508
|
+
if (id !== "cursor") return payload;
|
|
11509
|
+
const parsedToolInput = cursorParsedToolInput(payload);
|
|
11510
|
+
return {
|
|
11511
|
+
...payload,
|
|
11512
|
+
cursor_tool_name: payload.tool_name,
|
|
11513
|
+
cursor_cwd: payload.cwd,
|
|
11514
|
+
tool_name: event.tool,
|
|
11515
|
+
cwd,
|
|
11516
|
+
...parsedToolInput ? {
|
|
11517
|
+
cursor_tool_input: payload.tool_input,
|
|
11518
|
+
tool_input: parsedToolInput
|
|
11519
|
+
} : {}
|
|
11520
|
+
};
|
|
11521
|
+
}
|
|
11522
|
+
/**
|
|
11065
11523
|
* The full hook handler: on a PRE event it gates the tool-use (stateless guards
|
|
11066
11524
|
* then APEX gates from the session track) and returns the native response; on a
|
|
11067
11525
|
* POST event it records the activity into the track. The loop that makes the
|
|
@@ -11076,6 +11534,7 @@ async function handleHookCore(id, payload, opts) {
|
|
|
11076
11534
|
cwd: cursorCwd
|
|
11077
11535
|
};
|
|
11078
11536
|
}
|
|
11537
|
+
const hookPayload = cursorRawPayloadProjection(payload, event, opts.cwd, id);
|
|
11079
11538
|
const rawPrompt = payload.prompt;
|
|
11080
11539
|
const userPrompt = typeof rawPrompt === "string" || Array.isArray(rawPrompt) ? promptText(rawPrompt) : void 0;
|
|
11081
11540
|
if (id === "codex" && rawEventName(payload) === "UserPromptSubmit" && userPrompt !== void 0) submitCodexConfirmation(event.sessionId, userPrompt, opts.now, opts.home, codexPromptOrigin(payload));
|
|
@@ -11090,12 +11549,12 @@ async function handleHookCore(id, payload, opts) {
|
|
|
11090
11549
|
exit: 0
|
|
11091
11550
|
};
|
|
11092
11551
|
if (id === "codex" && rawEventName(payload) === "SessionStart") resyncCodexAgents();
|
|
11093
|
-
const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload),
|
|
11552
|
+
const asyncOut = await asyncScopeStdout(opts.scope, rawEventName(payload), hookPayload, opts.cwd, opts.now, id);
|
|
11094
11553
|
if (asyncOut !== null) return {
|
|
11095
11554
|
stdout: asyncOut,
|
|
11096
11555
|
exit: 0
|
|
11097
11556
|
};
|
|
11098
|
-
const life = lifecycleStdout(
|
|
11557
|
+
const life = lifecycleStdout(hookPayload, opts.cwd, opts.scope ?? "core", opts.now, id);
|
|
11099
11558
|
if (life !== null) return {
|
|
11100
11559
|
stdout: id === "claude-code" ? attachBudgetRecap(life, rawEventName(payload), event.sessionId, opts.cwd, opts.now) : life,
|
|
11101
11560
|
exit: 0
|
|
@@ -11110,7 +11569,7 @@ async function handleHookCore(id, payload, opts) {
|
|
|
11110
11569
|
}
|
|
11111
11570
|
if (event.phase === "post") return handlePost({
|
|
11112
11571
|
id,
|
|
11113
|
-
payload,
|
|
11572
|
+
payload: hookPayload,
|
|
11114
11573
|
event,
|
|
11115
11574
|
framework,
|
|
11116
11575
|
mcpDir,
|
|
@@ -11120,7 +11579,7 @@ async function handleHookCore(id, payload, opts) {
|
|
|
11120
11579
|
});
|
|
11121
11580
|
return handlePre({
|
|
11122
11581
|
id,
|
|
11123
|
-
payload,
|
|
11582
|
+
payload: hookPayload,
|
|
11124
11583
|
event,
|
|
11125
11584
|
framework,
|
|
11126
11585
|
mcpDir,
|
|
@@ -11132,13 +11591,37 @@ async function handleHookCore(id, payload, opts) {
|
|
|
11132
11591
|
/**
|
|
11133
11592
|
* Run one hook and adapt every Cursor scope outcome at the common runtime exit.
|
|
11134
11593
|
* Other harnesses retain the core handler's stdout and exit status unchanged.
|
|
11594
|
+
* Cursor's shared `additional_context` budget context (see
|
|
11595
|
+
* `../adapters/cursor/context-budget.ts`) is assembled here too — this is
|
|
11596
|
+
* the single point every Cursor stdout passes through exactly once, so it's
|
|
11597
|
+
* also the single point that reserves from and records into the registry.
|
|
11598
|
+
* With no `session_id`/`conversation_id` at all, `sessionId` is `""` — the
|
|
11599
|
+
* registry key would degenerate to one bucket shared by every session-less
|
|
11600
|
+
* call on the same (cwd, event) pair, so `budget` stays `undefined` instead
|
|
11601
|
+
* (falls back to the flat per-response cap in `toCursorLifecycleResponse`,
|
|
11602
|
+
* with zero registry I/O). `stateDir` honors `opts.home` (test-only OS home
|
|
11603
|
+
* override, see `HandleOptions`) so tests never need the real `os.homedir()`.
|
|
11135
11604
|
*/
|
|
11136
11605
|
async function handleHook(id, payload, opts) {
|
|
11137
11606
|
const outcome = await handleHookCore(id, payload, opts);
|
|
11138
11607
|
if (id !== "cursor") return outcome;
|
|
11608
|
+
const eventName = rawEventName(payload);
|
|
11609
|
+
const cursorEvent = normalizeEvent(id, payload);
|
|
11610
|
+
const cwd = cursorProjectCwd(cursorEvent.cwd, cursorEvent.workspaceRoots ?? [], cursorEvent.filePath, opts.cwd);
|
|
11611
|
+
const sessionId = cursorEvent.sessionId;
|
|
11612
|
+
const generationId = typeof payload.generation_id === "string" && payload.generation_id ? payload.generation_id : void 0;
|
|
11613
|
+
const toolUseId = typeof payload.tool_use_id === "string" && payload.tool_use_id ? payload.tool_use_id : void 0;
|
|
11614
|
+
const stateDir = join(fuseHarnessHome(opts.home), "state", projectHash$1(cwd));
|
|
11615
|
+
const budget = sessionId ? {
|
|
11616
|
+
stateDir,
|
|
11617
|
+
sessionId,
|
|
11618
|
+
event: eventName,
|
|
11619
|
+
generationId,
|
|
11620
|
+
toolUseId
|
|
11621
|
+
} : void 0;
|
|
11139
11622
|
return {
|
|
11140
11623
|
...outcome,
|
|
11141
|
-
stdout: toCursorLifecycleResponse(outcome.stdout,
|
|
11624
|
+
stdout: toCursorLifecycleResponse(outcome.stdout, eventName, budget)
|
|
11142
11625
|
};
|
|
11143
11626
|
}
|
|
11144
11627
|
//#endregion
|