@uinaf/skillcheck 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ import { spawn } from "node:child_process";
2
+ //#region src/cursor-process.ts
3
+ let child;
4
+ let terminal = false;
5
+ let cleaning = false;
6
+ let watchdog = setTimeout(cleanup, 5e3);
7
+ function cleanup() {
8
+ if (cleaning) return;
9
+ cleaning = true;
10
+ clearTimeout(watchdog);
11
+ if (process.platform !== "win32") process.kill(-process.pid, "SIGKILL");
12
+ child?.kill("SIGKILL");
13
+ process.exit(1);
14
+ }
15
+ function fail(error) {
16
+ if (terminal) return;
17
+ terminal = true;
18
+ process.send?.({
19
+ type: "failure",
20
+ error
21
+ }, cleanup);
22
+ clearTimeout(watchdog);
23
+ watchdog = setTimeout(cleanup, 1e3);
24
+ }
25
+ if (!process.send) process.exit(1);
26
+ process.on("disconnect", cleanup);
27
+ process.on("message", (message) => {
28
+ if (message === "stop") return cleanup();
29
+ if (message === "cleanup" && terminal) {
30
+ process.send?.({ type: "cleanup" }, cleanup);
31
+ return;
32
+ }
33
+ if (child !== void 0 || terminal) return;
34
+ if (typeof message !== "object" || message === null || !("command" in message) || typeof message.command !== "string" || !("cwd" in message) || typeof message.cwd !== "string" || !("args" in message) || !Array.isArray(message.args) || !message.args.every((arg) => typeof arg === "string") || !("timeoutMs" in message) || typeof message.timeoutMs !== "number" || !Number.isFinite(message.timeoutMs) || message.timeoutMs <= 0) return fail("cursor-agent supervisor received invalid configuration");
35
+ clearTimeout(watchdog);
36
+ watchdog = setTimeout(() => fail("cursor-agent supervisor watchdog timed out"), message.timeoutMs + 1e3);
37
+ const command = message.command;
38
+ try {
39
+ child = spawn(command, message.args, {
40
+ cwd: message.cwd,
41
+ stdio: [
42
+ "pipe",
43
+ "inherit",
44
+ "inherit"
45
+ ]
46
+ });
47
+ } catch (error) {
48
+ return fail(`failed to spawn ${command}: ${error instanceof Error ? error.message : String(error)}`);
49
+ }
50
+ child.on("error", (error) => fail(`failed to spawn ${command}: ${error.message}`));
51
+ child.stdin?.on("error", (error) => fail(`cursor-agent stdin failed: ${error.message}`));
52
+ process.stdin.on("error", (error) => fail(`cursor-agent prompt stream failed: ${error.message}`));
53
+ if (child.stdin) process.stdin.pipe(child.stdin);
54
+ child.on("exit", (code, signal) => {
55
+ if (terminal) return;
56
+ if (!child?.stdin?.writableFinished) return fail("cursor-agent stdin closed before prompt delivery");
57
+ terminal = true;
58
+ process.stdin.unpipe();
59
+ clearTimeout(watchdog);
60
+ watchdog = setTimeout(cleanup, 1e3);
61
+ process.send?.({
62
+ type: "terminal",
63
+ code,
64
+ signal
65
+ }, (error) => {
66
+ if (error) cleanup();
67
+ });
68
+ });
69
+ });
70
+ //#endregion
71
+ export {};
@@ -1,4 +1,5 @@
1
- import { spawn } from "node:child_process";
1
+ import { fork } from "node:child_process";
2
+ import path from "node:path";
2
3
  //#region src/cursor-provider.ts
3
4
  function newStreamState() {
4
5
  return {
@@ -65,53 +66,114 @@ var CursorAgentProvider = class {
65
66
  const stderr = [];
66
67
  let stdoutBuf = "";
67
68
  return new Promise((resolve) => {
68
- const child = spawn(command, args, {
69
- cwd: this.config.working_dir,
70
- env: process.env,
69
+ const timeoutMs = this.config.timeout_ms ?? 9e5;
70
+ const supervisor = fork(new URL(`./cursor-process${path.extname(import.meta.url)}`, import.meta.url), {
71
+ execArgv: [],
72
+ detached: process.platform !== "win32",
71
73
  stdio: [
72
74
  "pipe",
73
75
  "pipe",
74
- "pipe"
76
+ "pipe",
77
+ "ipc"
75
78
  ]
76
79
  });
77
- const timeoutMs = this.config.timeout_ms ?? 9e5;
78
- const timer = setTimeout(() => {
79
- child.kill("SIGKILL");
80
- resolve({ error: `cursor-agent timed out after ${timeoutMs}ms` });
81
- }, timeoutMs);
82
- child.on("error", (err) => {
83
- clearTimeout(timer);
84
- resolve({ error: `failed to spawn ${command}: ${err.message}` });
85
- });
86
- child.stdout.on("data", (chunk) => {
87
- stdoutBuf += chunk.toString("utf8");
88
- const lines = stdoutBuf.split("\n");
89
- stdoutBuf = lines.pop() ?? "";
90
- for (const line of lines) foldLine(state, line);
91
- });
92
- child.stderr.on("data", (chunk) => {
93
- stderr.push(chunk.toString("utf8"));
94
- });
95
- child.on("close", (code) => {
80
+ let failure;
81
+ let terminal;
82
+ let cleanupConfirmed = false;
83
+ let settled = false;
84
+ let cleanupTimer;
85
+ const finish = (closed, signal = null) => {
86
+ if (settled) return;
87
+ settled = true;
96
88
  clearTimeout(timer);
89
+ clearTimeout(cleanupTimer);
90
+ supervisor.stdin?.destroy();
91
+ supervisor.stdout?.destroy();
92
+ supervisor.stderr?.destroy();
93
+ if (!closed) {
94
+ supervisor.kill("SIGKILL");
95
+ supervisor.unref();
96
+ if (supervisor.connected) supervisor.disconnect();
97
+ }
97
98
  if (stdoutBuf !== "") foldLine(state, stdoutBuf);
98
- if (state.result === void 0) {
99
+ const metadata = { skillCalls: state.skillCalls };
100
+ const fail = (error) => resolve({
101
+ error,
102
+ tokenUsage: state.tokenUsage,
103
+ metadata
104
+ });
105
+ if (failure !== void 0) {
99
106
  const tail = stderr.join("").trim().slice(-2e3);
100
- resolve({ error: `cursor-agent exited ${code ?? "by signal"} without a result event${tail === "" ? "" : `: ${tail}`}` });
101
- return;
107
+ const missingResult = terminal !== void 0 && state.result === void 0 ? " without a result event" : "";
108
+ return fail(`${failure}${missingResult}${tail === "" ? "" : `: ${tail}`}`);
102
109
  }
103
- if (state.isError) {
104
- resolve({ error: state.result || "cursor-agent reported an error result" });
105
- return;
110
+ if (!closed || !cleanupConfirmed || terminal === void 0 || process.platform !== "win32" && signal !== "SIGKILL") return fail("cursor-agent supervisor ended without confirmed cleanup");
111
+ if (terminal.code !== 0 || terminal.signal !== null || state.result === void 0) {
112
+ const tail = stderr.join("").trim().slice(-2e3);
113
+ return fail(`cursor-agent exited ${terminal.signal ?? terminal.code ?? "without status"}${state.result === void 0 ? " without a result event" : ""}${tail === "" ? "" : `: ${tail}`}`);
106
114
  }
115
+ if (state.isError) return fail(state.result || "cursor-agent reported an error result");
107
116
  resolve({
108
117
  output: state.result,
109
118
  tokenUsage: state.tokenUsage,
110
- metadata: { skillCalls: state.skillCalls }
119
+ metadata
111
120
  });
121
+ };
122
+ const boundCleanup = () => {
123
+ cleanupTimer ??= setTimeout(() => {
124
+ failure = failure === void 0 ? "cursor-agent cleanup or pipe draining timed out" : `${failure}; cleanup or pipe draining timed out`;
125
+ finish(false);
126
+ }, 2e3);
127
+ };
128
+ const stop = (error) => {
129
+ if (settled) return;
130
+ failure ??= error;
131
+ boundCleanup();
132
+ if (supervisor.connected) supervisor.send("stop", () => {});
133
+ };
134
+ const timer = setTimeout(() => stop(`cursor-agent timed out after ${timeoutMs}ms`), timeoutMs);
135
+ supervisor.on("error", (err) => stop(`cursor-agent supervisor failed: ${err.message}`));
136
+ supervisor.on("disconnect", () => {
137
+ if (!cleanupConfirmed && !settled) stop("cursor-agent supervisor disconnected before cleanup");
138
+ });
139
+ supervisor.on("message", (message) => {
140
+ if (settled) return;
141
+ if (typeof message !== "object" || message === null) return;
142
+ if (!("type" in message)) return;
143
+ if (message.type === "terminal" && "code" in message && "signal" in message && (message.code === null || typeof message.code === "number") && (message.signal === null || typeof message.signal === "string")) {
144
+ terminal = {
145
+ code: message.code,
146
+ signal: message.signal
147
+ };
148
+ clearTimeout(timer);
149
+ if (terminal.code !== 0 || terminal.signal !== null) failure ??= `cursor-agent exited ${terminal.signal ?? terminal.code ?? "without status"}`;
150
+ boundCleanup();
151
+ supervisor.send("cleanup", (err) => {
152
+ if (err) stop(`cursor-agent cleanup request failed: ${err.message}`);
153
+ });
154
+ } else if (message.type === "failure" && "error" in message && typeof message.error === "string") stop(message.error);
155
+ else if (message.type === "cleanup" && terminal !== void 0) cleanupConfirmed = true;
156
+ });
157
+ supervisor.stdout?.on("data", (chunk) => {
158
+ stdoutBuf += chunk.toString("utf8");
159
+ const lines = stdoutBuf.split("\n");
160
+ stdoutBuf = lines.pop() ?? "";
161
+ for (const line of lines) foldLine(state, line);
162
+ });
163
+ supervisor.stderr?.on("data", (chunk) => stderr.push(chunk.toString("utf8")));
164
+ supervisor.stdout?.on("error", (err) => stop(`cursor-agent stdout failed: ${err.message}`));
165
+ supervisor.stderr?.on("error", (err) => stop(`cursor-agent stderr failed: ${err.message}`));
166
+ supervisor.stdin?.on("error", (err) => stop(`cursor-agent stdin failed: ${err.message}`));
167
+ supervisor.on("close", (_code, signal) => finish(true, signal));
168
+ supervisor.send({
169
+ command,
170
+ args,
171
+ cwd: this.config.working_dir,
172
+ timeoutMs
173
+ }, (err) => {
174
+ if (err) stop(`cursor-agent supervisor initialization failed: ${err.message}`);
112
175
  });
113
- child.stdin.write(prompt);
114
- child.stdin.end();
176
+ supervisor.stdin?.end(prompt);
115
177
  });
116
178
  }
117
179
  };
package/docs/usage.md CHANGED
@@ -56,7 +56,13 @@ the model to that CLI's own default.
56
56
  cursor provider, so the run uses this package's own provider module, which
57
57
  replays the CLI's `stream-json` output: the `result` event becomes the graded
58
58
  output and `SKILL.md` reads under `.cursor/skills/` become the `skill-used`
59
- evidence. The judge leg is unchanged.
59
+ evidence. Grading requires both a successful result event and harness exit code
60
+ zero. On macOS and Linux, a supervisor owns the process group and kills remaining
61
+ helpers when the harness exits, times out, or the provider disconnects. Output
62
+ pipes have a separate two-second cleanup/drain deadline; incomplete cleanup is
63
+ an error. Helpers that detach into another process group are outside this cleanup
64
+ boundary; retained output pipes still cause a bounded error. Windows retains
65
+ only direct-child cleanup. The judge leg is unchanged.
60
66
 
61
67
  `--judge` takes either a bare Claude model (graded through the Anthropic
62
68
  selection in [auth](#auth)) or a provider-qualified promptfoo id, passed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uinaf/skillcheck",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Lint and eval harness for agent skills",
5
5
  "homepage": "https://github.com/uinaf/skillcheck#readme",
6
6
  "bugs": {