From 242d8b5e78db34feb0e7dca979ff563e5e7ac000 Mon Sep 17 00:00:00 2001 From: Spencer Smith Date: Sat, 4 Jul 2026 00:32:12 -0600 Subject: [PATCH 1/5] fix(providers): CursorAgent deliver prompt via @path to saved file 99 saves the full prompt to tmp/99-*-prompt before make_request. Pass a single-line --print message referencing that file with @ instead of putting the multiline XML query on argv. Closes ThePrimeagen/99#180 --- lua/99/providers.lua | 27 +++++++++++++++++++++++++-- lua/99/test/providers_spec.lua | 29 ++++++++++++++++++----------- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/lua/99/providers.lua b/lua/99/providers.lua index 2fa2f83b..6b5d6798 100644 --- a/lua/99/providers.lua +++ b/lua/99/providers.lua @@ -231,10 +231,33 @@ end --- @class CursorAgentProvider : _99.Providers.BaseProvider local CursorAgentProvider = setmetatable({}, { __index = BaseProvider }) +--- @param context _99.Prompt +--- @return string +local function cursor_agent_prompt_file(context) + local path = vim.fn.fnamemodify(context.tmp_file .. "-prompt", ":p") + path = vim.fs.normalize(path) + if vim.fn.filereadable(path) ~= 1 then + path = vim.fs.normalize(vim.fn.fnamemodify( + vim.fs.joinpath(vim.fn.getcwd(), context.tmp_file .. "-prompt"), + ":p" + )) + end + return path +end + +--- @param context _99.Prompt +--- @return string +local function cursor_agent_print_prompt(context) + return string.format( + "Read and follow every instruction in @%s using your file tools, then complete the task exactly as specified in that file.", + cursor_agent_prompt_file(context) + ) +end + --- @param query string --- @param context _99.Prompt --- @return string[] -function CursorAgentProvider._build_command(_, query, context) +function CursorAgentProvider._build_command(_, _query, context) -- TODO: trust is sort of a hack and should probably be removed in favor of having a -- trust flag from the setup call return { @@ -244,7 +267,7 @@ function CursorAgentProvider._build_command(_, query, context) "--model", context.model, "--print", - query, + cursor_agent_print_prompt(context), } end diff --git a/lua/99/test/providers_spec.lua b/lua/99/test/providers_spec.lua index 803cd054..60636862 100644 --- a/lua/99/test/providers_spec.lua +++ b/lua/99/test/providers_spec.lua @@ -48,17 +48,24 @@ describe("providers", function() end) describe("CursorAgentProvider", function() - it("builds correct command with model", function() - local request = { model = "anthropic/claude-sonnet-4-5" } - local cmd = - Providers.CursorAgentProvider._build_command(nil, "test query", request) - eq({ - "cursor-agent", - "--model", - "anthropic/claude-sonnet-4-5", - "--print", - "test query", - }, cmd) + it("uses @ prompt file instead of XML on argv", function() + local request = { model = "sonnet-4.5", tmp_file = "tmp/99-1234" } + local cmd = Providers.CursorAgentProvider._build_command( + nil, + "ignored", + request + ) + local print_arg = cmd[#cmd] + eq("cursor-agent", cmd[1]) + eq("--trust", cmd[2]) + eq("--force", cmd[3]) + eq("--model", cmd[4]) + eq("sonnet-4.5", cmd[5]) + eq("--print", cmd[6]) + assert(print_arg:match("@")) + assert(print_arg:match("99%-1234%-prompt")) + assert(not print_arg:match("")) + assert(not print_arg:match("\n")) end) it("has correct default model", function() From 1629aec0479f7103ee0aa56ef0bb63e7fef43cf7 Mon Sep 17 00:00:00 2001 From: Spencer Smith Date: Sat, 4 Jul 2026 00:32:37 -0600 Subject: [PATCH 2/5] fix(providers): CursorAgent --workspace, cwd, and tmp path resolution Pass --workspace and set vim.system cwd to the project root. Resolve context.tmp_file under that root so agent writes and 99 reads the same TEMP_FILE path. Closes ThePrimeagen/99#181 --- lua/99/providers.lua | 83 +++++++++++++++++++++++++++++----- lua/99/test/providers_spec.lua | 12 +++-- 2 files changed, 78 insertions(+), 17 deletions(-) diff --git a/lua/99/providers.lua b/lua/99/providers.lua index 6b5d6798..f9527db7 100644 --- a/lua/99/providers.lua +++ b/lua/99/providers.lua @@ -233,24 +233,69 @@ local CursorAgentProvider = setmetatable({}, { __index = BaseProvider }) --- @param context _99.Prompt --- @return string -local function cursor_agent_prompt_file(context) - local path = vim.fn.fnamemodify(context.tmp_file .. "-prompt", ":p") - path = vim.fs.normalize(path) - if vim.fn.filereadable(path) ~= 1 then - path = vim.fs.normalize(vim.fn.fnamemodify( - vim.fs.joinpath(vim.fn.getcwd(), context.tmp_file .. "-prompt"), - ":p" - )) +local function cursor_agent_workspace(_context) + return vim.fn.getcwd() +end + +--- @param relative string +--- @param ws string +--- @return string[] +local function cursor_agent_tmp_paths(relative, ws) + local seen, out = {}, {} + + local function add(path) + path = vim.fs.normalize(vim.fn.fnamemodify(path, ":p")) + if not seen[path] then + seen[path] = true + out[#out + 1] = path + end + end + + if relative:match("^[%a]:[/\\]") or relative:match("^/") then + add(relative) + return out + end + + local rel = relative:gsub("^%.[\\/]", "") + for _, base in ipairs({ ws, vim.fn.getcwd() }) do + add(vim.fs.joinpath(base, rel)) + end + return out +end + +--- @param paths string[] +--- @return string, string|nil +local function cursor_agent_read_first(paths) + for _, path in ipairs(paths) do + local ok, lines = pcall(vim.fn.readfile, path) + if ok and #lines > 0 then + return table.concat(lines, "\n"), path + end end - return path + return "", nil end --- @param context _99.Prompt +--- @param ws string|nil --- @return string -local function cursor_agent_print_prompt(context) +local function cursor_agent_prompt_file(context, ws) + ws = ws or cursor_agent_workspace(context) + for _, path in ipairs(cursor_agent_tmp_paths(context.tmp_file .. "-prompt", ws)) do + if vim.fn.filereadable(path) == 1 then + return path + end + end + return cursor_agent_tmp_paths(context.tmp_file .. "-prompt", ws)[1] +end + +--- @param context _99.Prompt +--- @param ws string|nil +--- @return string +local function cursor_agent_print_prompt(context, ws) + local prompt_file = cursor_agent_prompt_file(context, ws) return string.format( "Read and follow every instruction in @%s using your file tools, then complete the task exactly as specified in that file.", - cursor_agent_prompt_file(context) + prompt_file ) end @@ -260,17 +305,31 @@ end function CursorAgentProvider._build_command(_, _query, context) -- TODO: trust is sort of a hack and should probably be removed in favor of having a -- trust flag from the setup call + local ws = cursor_agent_workspace(context) return { "cursor-agent", + "--workspace", + ws, "--trust", -- directories are always trusted and can be ran in "--force", -- allows for commands to run "--model", context.model, "--print", - cursor_agent_print_prompt(context), + cursor_agent_print_prompt(context, ws), } end +--- @param context _99.Prompt +--- @return boolean, string +function CursorAgentProvider:_retrieve_response(context) + local ws = cursor_agent_workspace(context) + local tmp = cursor_agent_read_first(cursor_agent_tmp_paths(context.tmp_file, ws)) + if tmp:match("%S") then + return true, tmp + end + return BaseProvider._retrieve_response(self, context) +end + --- @return string function CursorAgentProvider._get_provider_name() return "CursorAgentProvider" diff --git a/lua/99/test/providers_spec.lua b/lua/99/test/providers_spec.lua index 60636862..5022c845 100644 --- a/lua/99/test/providers_spec.lua +++ b/lua/99/test/providers_spec.lua @@ -57,11 +57,13 @@ describe("providers", function() ) local print_arg = cmd[#cmd] eq("cursor-agent", cmd[1]) - eq("--trust", cmd[2]) - eq("--force", cmd[3]) - eq("--model", cmd[4]) - eq("sonnet-4.5", cmd[5]) - eq("--print", cmd[6]) + eq("--workspace", cmd[2]) + eq(vim.fn.getcwd(), cmd[3]) + eq("--trust", cmd[4]) + eq("--force", cmd[5]) + eq("--model", cmd[6]) + eq("sonnet-4.5", cmd[7]) + eq("--print", cmd[8]) assert(print_arg:match("@")) assert(print_arg:match("99%-1234%-prompt")) assert(not print_arg:match("")) From 121599acd85dae68d94b0a4e80a635de2f0d17b8 Mon Sep 17 00:00:00 2001 From: Spencer Smith Date: Sat, 4 Jul 2026 00:32:57 -0600 Subject: [PATCH 3/5] fix(providers): normalize CursorAgent citations to qfix lines Convert cursor-agent citation blocks in TEMP_FILE to 99 qfix lines for search/vibe in _retrieve_response via normalize_qfix_response. Co-authored-by: Cursor --- lua/99/providers.lua | 70 +++++++++++++++++++++++++++++++++- lua/99/test/providers_spec.lua | 17 ++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/lua/99/providers.lua b/lua/99/providers.lua index f9527db7..52fee7cb 100644 --- a/lua/99/providers.lua +++ b/lua/99/providers.lua @@ -4,6 +4,8 @@ --- @field on_complete fun(status: _99.Prompt.EndingState, res: string): nil --- @field on_start fun(): nil +local QFixHelpers = require("99.ops.qfix-helpers") + --- @param fn fun(...: any): nil --- @return fun(...: any): nil local function once(fn) @@ -231,6 +233,11 @@ end --- @class CursorAgentProvider : _99.Providers.BaseProvider local CursorAgentProvider = setmetatable({}, { __index = BaseProvider }) +local CURSOR_AGENT_QFIX_OPS = { + search = true, + vibe = true, +} + --- @param context _99.Prompt --- @return string local function cursor_agent_workspace(_context) @@ -299,6 +306,49 @@ local function cursor_agent_print_prompt(context, ws) ) end +--- @param text string +--- @return string +function CursorAgentProvider.normalize_qfix_response(text) + local hits, seen = {}, {} + + local function add(path, lnum, col, line_count, notes) + path = vim.trim(path) + local key = string.format("%s:%d:%d", path, lnum, col) + if seen[key] then + return + end + seen[key] = true + hits[#hits + 1] = string.format( + "%s:%d:%d,%d,%s", + path, + lnum, + col, + line_count, + notes or "match" + ) + end + + for line in vim.gsplit(text or "", "\n", true) do + line = vim.trim(line) + if QFixHelpers.parse_line(line) and not seen[line] then + seen[line] = true + hits[#hits + 1] = line + end + end + if #hits > 0 then + return table.concat(hits, "\n") + end + + for sl, el, path in (text or ""):gmatch("```(%d+):(%d+):([^\r\n]+)") do + sl = tonumber(sl) + el = tonumber(el) + if sl and el and path then + add(path, sl, 1, math.max(1, el - sl + 1), "match") + end + end + return table.concat(hits, "\n") +end + --- @param query string --- @param context _99.Prompt --- @return string[] @@ -324,10 +374,26 @@ end function CursorAgentProvider:_retrieve_response(context) local ws = cursor_agent_workspace(context) local tmp = cursor_agent_read_first(cursor_agent_tmp_paths(context.tmp_file, ws)) + + local function finish(text) + if CURSOR_AGENT_QFIX_OPS[context.operation] then + text = CursorAgentProvider.normalize_qfix_response(text) + end + if text:match("%S") then + return true, text + end + return false, "" + end + if tmp:match("%S") then - return true, tmp + return finish(tmp) + end + + local ok, text = BaseProvider._retrieve_response(self, context) + if ok and text:match("%S") then + return finish(text) end - return BaseProvider._retrieve_response(self, context) + return false, "" end --- @return string diff --git a/lua/99/test/providers_spec.lua b/lua/99/test/providers_spec.lua index 5022c845..6b35b9cd 100644 --- a/lua/99/test/providers_spec.lua +++ b/lua/99/test/providers_spec.lua @@ -48,7 +48,7 @@ describe("providers", function() end) describe("CursorAgentProvider", function() - it("uses @ prompt file instead of XML on argv", function() + it("builds command with @ prompt file and workspace", function() local request = { model = "sonnet-4.5", tmp_file = "tmp/99-1234" } local cmd = Providers.CursorAgentProvider._build_command( nil, @@ -73,6 +73,21 @@ describe("providers", function() it("has correct default model", function() eq("sonnet-4.5", Providers.CursorAgentProvider._get_default_model()) end) + + it("normalize_qfix_response converts cursor citations", function() + local text = table.concat({ + "```6:11:C:\\Dev\\test\\test.js", + "function main() {}", + "```", + }, "\n") + local out = Providers.CursorAgentProvider.normalize_qfix_response(text) + assert(out:match("C:\\Dev\\test\\test.js:6:1,6")) + end) + + it("normalize_qfix_response keeps existing qfix lines", function() + local line = "C:/project/file.lua:10:1,3,note" + eq(line, Providers.CursorAgentProvider.normalize_qfix_response(line)) + end) end) describe("GeminiCLIProvider", function() From 803d995c6ff48f8d1b3ccd42f743432bbd92a25d Mon Sep 17 00:00:00 2001 From: Spencer Smith Date: Sat, 4 Jul 2026 04:48:30 -0600 Subject: [PATCH 4/5] fix(providers): parse citation fences before qfix line scan Avoid false qfix matches from code inside citation blocks (e.g. strings containing colons). Co-authored-by: Cursor --- lua/99/providers.lua | 23 +++++++++++++---------- lua/99/test/providers_spec.lua | 11 +++++++++++ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/lua/99/providers.lua b/lua/99/providers.lua index 52fee7cb..86a14d80 100644 --- a/lua/99/providers.lua +++ b/lua/99/providers.lua @@ -328,22 +328,25 @@ function CursorAgentProvider.normalize_qfix_response(text) ) end - for line in vim.gsplit(text or "", "\n", true) do - line = vim.trim(line) - if QFixHelpers.parse_line(line) and not seen[line] then - seen[line] = true - hits[#hits + 1] = line + text = text or "" + + -- Citation fences first — code lines inside a fence can false-match qfix parsing. + for sl, el, path in text:gmatch("```(%d+):(%d+):([^\r\n]+)") do + sl = tonumber(sl) + el = tonumber(el) + if sl and el and path then + add(path, sl, 1, math.max(1, el - sl + 1), "match") end end if #hits > 0 then return table.concat(hits, "\n") end - for sl, el, path in (text or ""):gmatch("```(%d+):(%d+):([^\r\n]+)") do - sl = tonumber(sl) - el = tonumber(el) - if sl and el and path then - add(path, sl, 1, math.max(1, el - sl + 1), "match") + for line in vim.gsplit(text, "\n", true) do + line = vim.trim(line) + if QFixHelpers.parse_line(line) and not seen[line] then + seen[line] = true + hits[#hits + 1] = line end end return table.concat(hits, "\n") diff --git a/lua/99/test/providers_spec.lua b/lua/99/test/providers_spec.lua index 6b35b9cd..d1ea4aa3 100644 --- a/lua/99/test/providers_spec.lua +++ b/lua/99/test/providers_spec.lua @@ -88,6 +88,17 @@ describe("providers", function() local line = "C:/project/file.lua:10:1,3,note" eq(line, Providers.CursorAgentProvider.normalize_qfix_response(line)) end) + + it("normalize_qfix_response ignores qfix-like strings inside citation fences", function() + local text = table.concat({ + "```6:11:C:\\Dev\\test\\test.js", + 'const x = err("file.js:5:3,4,msg")', + "```", + }, "\n") + local out = Providers.CursorAgentProvider.normalize_qfix_response(text) + assert(out:match("C:\\Dev\\test\\test.js:6:1,6")) + assert(not out:match("file%.js:5")) + end) end) describe("GeminiCLIProvider", function() From 80b79e9e77907345f6251a0b35b5fe9b7ace0e77 Mon Sep 17 00:00:00 2001 From: Spencer Smith Date: Sat, 4 Jul 2026 05:01:54 -0600 Subject: [PATCH 5/5] fix(providers): skip qfix line scan inside markdown code fences Plain ` fences without citation headers bypassed the citation-first path but still contained scannable lines. Only scan text outside fenced regions. Co-authored-by: Cursor --- lua/99/providers.lua | 16 +++++++++++++++- lua/99/test/providers_spec.lua | 20 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lua/99/providers.lua b/lua/99/providers.lua index 86a14d80..7b3f4643 100644 --- a/lua/99/providers.lua +++ b/lua/99/providers.lua @@ -306,6 +306,20 @@ local function cursor_agent_print_prompt(context, ws) ) end +--- @param text string +--- @return string +local function cursor_agent_text_outside_fences(text) + local out, in_fence = {}, false + for line in vim.gsplit(text or "", "\n", true) do + if line:match("^```") then + in_fence = not in_fence + elseif not in_fence then + out[#out + 1] = line + end + end + return table.concat(out, "\n") +end + --- @param text string --- @return string function CursorAgentProvider.normalize_qfix_response(text) @@ -342,7 +356,7 @@ function CursorAgentProvider.normalize_qfix_response(text) return table.concat(hits, "\n") end - for line in vim.gsplit(text, "\n", true) do + for line in vim.gsplit(cursor_agent_text_outside_fences(text), "\n", true) do line = vim.trim(line) if QFixHelpers.parse_line(line) and not seen[line] then seen[line] = true diff --git a/lua/99/test/providers_spec.lua b/lua/99/test/providers_spec.lua index d1ea4aa3..6e0dd8bd 100644 --- a/lua/99/test/providers_spec.lua +++ b/lua/99/test/providers_spec.lua @@ -99,6 +99,26 @@ describe("providers", function() assert(out:match("C:\\Dev\\test\\test.js:6:1,6")) assert(not out:match("file%.js:5")) end) + + it("normalize_qfix_response ignores qfix-like strings in plain code fences", function() + local text = table.concat({ + "```javascript", + 'const x = err("file.js:5:3,4,msg")', + "```", + }, "\n") + eq("", Providers.CursorAgentProvider.normalize_qfix_response(text)) + end) + + it("normalize_qfix_response keeps qfix lines outside code fences", function() + local line = "C:/project/file.lua:10:1,3,note" + local text = table.concat({ + line, + "```javascript", + 'const x = err("file.js:5:3,4,msg")', + "```", + }, "\n") + eq(line, Providers.CursorAgentProvider.normalize_qfix_response(text)) + end) end) describe("GeminiCLIProvider", function()