@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.
@@ -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-BjG6unTj.mjs";
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. Resolution order (first hit wins):
2153
- * 1. `CLAUDE_PLUGIN_ROOT` (claude-code/codex plugin-declared hooks);
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: no equivalent `PostToolUseFailure` hook exists on Codex or
3426
- * Hermes, so this handler is never reached through those adapters.
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-response.ts
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: EMPTY,
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
- /** Render a portable policy prompt using the native Cursor event contract. */
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
- if (!contract.known || contract.response === "neutral" || contract.response === "plugin-paths") return "{}";
6863
- if (contract.response === "post-context" || contract.response === "session-context") return JSON.stringify({ additional_context: message });
6864
- if (contract.response === "followup") return JSON.stringify({ followup_message: message });
6865
- if (contract.response === "compact-notice") return JSON.stringify({ user_message: prompt.userMessage ?? message });
6866
- if (contract.response === "submit-control") return JSON.stringify({
6867
- continue: prompt.kind !== "block",
6868
- user_message: prompt.userMessage ?? message
6869
- });
6870
- if (prompt.kind === "inform") return JSON.stringify({
6871
- permission: "allow",
6872
- ...permissionMessages(eventName, prompt.userMessage, prompt.reason ? message : void 0)
6873
- });
6874
- const userMessage = prompt.kind === "ask" ? `[downgraded from ask — Cursor does not enforce approval for this event]\n${message}` : message;
6875
- return JSON.stringify({
6876
- permission: "deny",
6877
- ...permissionMessages(eventName, userMessage, userMessage)
6878
- });
6879
- }
6880
- /** Convert a shared lifecycle handler's output to the native Cursor envelope. */
6881
- function toCursorLifecycleResponse(stdout, eventName) {
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
- if (contract.response === "session-context" || contract.response === "post-context") return JSON.stringify({ additional_context: text });
6907
- if (contract.response === "permission") {
6908
- const permission = decision === "deny" || decision === "ask" ? "deny" : "allow";
6909
- const denied = permission === "deny";
6910
- if (eventName === "subagentStart" && !denied) return "{\"permission\":\"allow\"}";
6911
- return JSON.stringify({
6912
- permission,
6913
- ...permissionMessages(eventName, userMessage || (denied ? decisionMessage || agentMessage : ""), agentMessage || (denied ? decisionMessage || userMessage : structured ? "" : text))
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), payload, opts.cwd, opts.now, id);
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(payload, opts.cwd, opts.scope ?? "core", opts.now, id);
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, rawEventName(payload))
11624
+ stdout: toCursorLifecycleResponse(outcome.stdout, eventName, budget)
11142
11625
  };
11143
11626
  }
11144
11627
  //#endregion