Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,20 @@ Every run carries the shared [`coding-agent-v1`](https://github.com/langchain-ai

`user_id`, `sandbox_type`, and `approval_policy` are omitted — Cursor's hooks expose no stable source for them.

## Troubleshooting

**Nothing shows up in LangSmith / `turn_count` stays 0.** Cursor launches hooks from a GUI context, so the `node` it runs is *not* your shell's version-managed node (nvm/mise/asdf) — it's whatever is on the system `PATH`, which is often an older node (or none). The hooks need **Node ≥ 22.13** (for `node:sqlite`).

The hooks run through a small version guard that fails loudly instead of silently. If your node is too old, you'll see a line in `~/.cursor/langsmith-hook.log` (and hook stderr) like:

```
[langsmith] Node 20.11.0 at /usr/local/bin/node is too old for tracing (need >= 22.13 for node:sqlite). This turn was NOT traced. ...
```

The path in that message is the exact node Cursor used. To fix, make a Node ≥ 22.13 available on the system `PATH` (note: upgrading your *shell's* nvm/mise node does **not** help a Dock/Finder-launched Cursor — that node isn't on the GUI `PATH`). Alternatives: install node ≥ 22.13 in a GUI-visible location (e.g. Homebrew on Intel symlinks into `/usr/local/bin`), or launch Cursor from a terminal (`cursor .`) so it inherits your shell environment.

Tail the log to confirm activity: `tail -f ~/.cursor/langsmith-hook.log`.

## Known limitations

- **Subagent token usage** is not available — Cursor exposes no per-subagent usage breakdown via hooks or its local DB, so a subagent's `Task` run carries its tool calls but no token counts.
Expand Down
38 changes: 38 additions & 0 deletions bundle/guard.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions esbuild.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const entryPoints = [
"dist/hooks/subagent-stop.js",
"dist/hooks/stop.js",
"dist/hooks/session-start.js",
"dist/hooks/guard.js",
];

await build({
Expand Down
16 changes: 8 additions & 8 deletions hooks.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"version": 1,
"hooks": {
"beforeSubmitPrompt": [{ "command": "node BUNDLE_DIR/before-submit-prompt.js" }],
"afterAgentResponse": [{ "command": "node BUNDLE_DIR/after-agent-response.js" }],
"postToolUse": [{ "command": "node BUNDLE_DIR/post-tool-use.js" }],
"postToolUseFailure": [{ "command": "node BUNDLE_DIR/post-tool-use-failure.js" }],
"subagentStart": [{ "command": "node BUNDLE_DIR/subagent-start.js" }],
"subagentStop": [{ "command": "node BUNDLE_DIR/subagent-stop.js" }],
"stop": [{ "command": "node BUNDLE_DIR/stop.js" }],
"sessionStart": [{ "command": "node BUNDLE_DIR/session-start.js" }]
"beforeSubmitPrompt": [{ "command": "node BUNDLE_DIR/guard.js before-submit-prompt" }],
"afterAgentResponse": [{ "command": "node BUNDLE_DIR/guard.js after-agent-response" }],
"postToolUse": [{ "command": "node BUNDLE_DIR/guard.js post-tool-use" }],
"postToolUseFailure": [{ "command": "node BUNDLE_DIR/guard.js post-tool-use-failure" }],
"subagentStart": [{ "command": "node BUNDLE_DIR/guard.js subagent-start" }],
"subagentStop": [{ "command": "node BUNDLE_DIR/guard.js subagent-stop" }],
"stop": [{ "command": "node BUNDLE_DIR/guard.js stop" }],
"sessionStart": [{ "command": "node BUNDLE_DIR/guard.js session-start" }]
}
}
16 changes: 8 additions & 8 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
{
"version": 1,
"hooks": {
"beforeSubmitPrompt": [{ "command": "node ./bundle/before-submit-prompt.js" }],
"afterAgentResponse": [{ "command": "node ./bundle/after-agent-response.js" }],
"postToolUse": [{ "command": "node ./bundle/post-tool-use.js" }],
"postToolUseFailure": [{ "command": "node ./bundle/post-tool-use-failure.js" }],
"subagentStart": [{ "command": "node ./bundle/subagent-start.js" }],
"subagentStop": [{ "command": "node ./bundle/subagent-stop.js" }],
"stop": [{ "command": "node ./bundle/stop.js" }],
"sessionStart": [{ "command": "node ./bundle/session-start.js" }]
"beforeSubmitPrompt": [{ "command": "node ./bundle/guard.js before-submit-prompt" }],
"afterAgentResponse": [{ "command": "node ./bundle/guard.js after-agent-response" }],
"postToolUse": [{ "command": "node ./bundle/guard.js post-tool-use" }],
"postToolUseFailure": [{ "command": "node ./bundle/guard.js post-tool-use-failure" }],
"subagentStart": [{ "command": "node ./bundle/guard.js subagent-start" }],
"subagentStop": [{ "command": "node ./bundle/guard.js subagent-stop" }],
"stop": [{ "command": "node ./bundle/guard.js stop" }],
"sessionStart": [{ "command": "node ./bundle/guard.js session-start" }]
}
}
56 changes: 56 additions & 0 deletions src/hooks/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env node
/**
* Version guard for the bundled hooks.
*
* Cursor launches hooks from a GUI context, where the `node` on PATH is often
* NOT the developer's version-managed node (nvm/mise/asdf): it can be an old
* system/Homebrew node, or missing entirely. The real hooks import
* `node:sqlite`, which needs Node >= 22.13; on older node that import throws
* ERR_UNKNOWN_BUILTIN_MODULE at module-load — *before* any of our code runs —
* so the failure is silent (no log line, no trace, turn_count stuck at 0).
*
* This guard imports nothing that touches node:sqlite. It checks the running
* Node version first, writes a clear message if it's too old, and only then
* dynamically imports the real hook — deferring the sqlite load until the
* check has passed. (A dynamic import runs after this module's own code; a
* static import would be hoisted and crash before the check.)
*
* Invoked as: node ./bundle/guard.js <hook-name>
*/
import { appendFileSync } from "node:fs";
import { MIN_NODE, nodeTooOld } from "../utils/node-version.js";

const hookName = process.argv[2];

if (nodeTooOld(process.versions.node)) {
const msg =
`[langsmith] Node ${process.versions.node} at ${process.execPath} is too old for tracing ` +
`(need >= ${MIN_NODE[0]}.${MIN_NODE[1]} for node:sqlite). This turn was NOT traced. ` +
`Cursor runs this node, not your shell's — install Node >= ${MIN_NODE[0]}.${MIN_NODE[1]} on the ` +
`system PATH, or launch Cursor from a terminal. See README troubleshooting.`;
const logFile =
process.env.LANGSMITH_CURSOR_LOG_FILE ??
`${process.env.HOME ?? ""}/.cursor/langsmith-hook.log`;
try {
appendFileSync(logFile, msg + "\n");
} catch {
// best effort — logging must not itself throw
}
console.error(msg);
// Exit 0: a non-zero exit would make Cursor surface a hook failure every turn.
process.exit(0);
}

if (!hookName) {
console.error("[langsmith] guard: missing hook name argument");
process.exit(0);
}

// Defer loading the real (sqlite-importing) hook until the version check passes.
// Resolve relative to this file so it works regardless of cwd.
await import(new URL(`./${hookName}.js`, import.meta.url).href).catch(
(err: unknown) => {
console.error(`[langsmith] hook ${hookName} failed:`, err);
process.exit(0);
},
);
19 changes: 19 additions & 0 deletions src/utils/node-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/** Minimum Node that provides a usable built-in `node:sqlite` (DatabaseSync). */
export const MIN_NODE: readonly [number, number] = [22, 13];

/**
* True if `version` (e.g. `process.versions.node` → "22.12.1") is older than
* `min` = [major, minor]. Pre-release / non-numeric suffixes are tolerated; an
* unparseable major returns false (don't block when we can't tell).
*/
export function nodeTooOld(
version: string,
min: readonly [number, number] = MIN_NODE,
): boolean {
const parts = version.split(".");
const major = Number.parseInt(parts[0] ?? "", 10);
const minor = Number.parseInt(parts[1] ?? "", 10);
if (!Number.isFinite(major)) return false;
if (major !== min[0]) return major < min[0];
return (Number.isFinite(minor) ? minor : 0) < min[1];
}
29 changes: 29 additions & 0 deletions test/utils/node-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { MIN_NODE, nodeTooOld } from "../../src/utils/node-version.js";

describe("nodeTooOld", () => {
it("flags versions below the minimum", () => {
expect(nodeTooOld("20.11.0")).toBe(true);
expect(nodeTooOld("22.12.0")).toBe(true);
expect(nodeTooOld("22.12.99")).toBe(true);
expect(nodeTooOld("18.0.0")).toBe(true);
});

it("accepts the minimum and above", () => {
expect(nodeTooOld(`${MIN_NODE[0]}.${MIN_NODE[1]}.0`)).toBe(false);
expect(nodeTooOld("22.13.1")).toBe(false);
expect(nodeTooOld("22.22.3")).toBe(false);
expect(nodeTooOld("24.0.0")).toBe(false);
});

it("tolerates pre-release / partial strings", () => {
expect(nodeTooOld("23.0.0-nightly")).toBe(false);
expect(nodeTooOld("22")).toBe(true); // 22.0 < 22.13
expect(nodeTooOld("not-a-version")).toBe(false); // unparseable → don't block
});

it("honors a custom minimum", () => {
expect(nodeTooOld("20.0.0", [18, 0])).toBe(false);
expect(nodeTooOld("16.5.0", [18, 0])).toBe(true);
});
});