A Hermes Agent plugin that lets your agent delegate coding tasks to the Cursor agent — and watch it work in real time.
Registers seven session tools that run Cursor agents inside a target repo over the official cursor-sdk python package, stream per-edit progress (reasoning + full file diffs) back through the calling agent's progress callback, and return a structured summary of everything that changed.
Because it's an ordinary Hermes tool call inside a real session, the result persists in the transcript and reloads for free — and interrupts map to a native run.cancel().
Earlier versions drove cursor-agent over ACP (JSON-RPC on stdio) and, before that, scraped --print stdout. The SDK replaces both with a supported contract:
| stdout scraping | ACP (v0.4) | cursor-sdk (this plugin) | |
|---|---|---|---|
| Event format | freeform JSON, inferred | typed session/update |
typed SDKMessage dataclasses |
| Cancellation | kill -9 the process |
native session/cancel |
native run.cancel() |
| Resume | none | session/load (best effort) |
Agent.resume(agent_id) |
| Network drop mid-run | run lost | run lost | run.observe(after_offset=…) re-attach |
| Transient failures | opaque | opaque | typed errors with is_retryable / retry_after |
The SDK runs a local sidecar bridge (managed automatically) that owns agent state, so a dropped stream — or a restarted plugin process — can re-attach to a run that is still executing instead of losing it.
The legacy --print runner is kept in runner.py as a reference/fallback.
Four explicit tools mirroring Hermes's terminal/process split. The single handle is the cursor session_id, returned by cursor_start and passed to the rest.
cursor_start(task, repo, model?, session_id?, inactivity_timeout_s?, max_wall_s?)— dispatch a coding task; returns asession_idimmediately and runs in the background (the conversation stays free). Pass a priorsession_idto continue that Cursor session with full context (Agent.resume); expired → graceful fresh start (resumed: false). Optionalmodeloverrides the cursor model (config fallback:plugins.ghost_cursor.model).cursor_send(session_id, message, inactivity_timeout_s?, max_wall_s?)— steer / follow up. Honest semantics: there is no true mid-run queue — this interrupts the current run (run.cancel()) and re-prompts the same session withmessage+ full context. It's "interrupt + re-prompt with context", not "append to a running turn". Works mid-run or after a run settled.cursor_status(session_id)— strictly read-only progress view: status, files changed with diffs so far, latest reasoning, session_id, elapsed,last_activity_s(seconds since the last stream event — spot a silent run without touching it). Polling never cancels the run (tested property — it was the footgun that killed foreground runs).cursor_stop(session_id)— gracefulsession/cancel, SIGKILL only on hang. Returns final status + partialfiles_changed.cursor_subscribe(session, interval_s)— subscribe to periodic progress digests while a run is active: everyinterval_sseconds a compact update (status header + the events since the previous tick) is delivered as a new message on the same rail as run completions.cursor_send_messagesets the initial interval viaupdate_interval_s(default 180,0disables);cursor_subscribechanges it mid-run (shorter intervals reschedule the pending tick immediately) or unsubscribes withinterval_s=0. The setting persists on the session across restarts; the final result is always delivered separately regardless.
Cross-cutting: live streaming (reasoning + per-edit file_diffs via the agent's tool_progress_callback), completion delivery on every terminal state (success/fail/error/timeout/cancel), same-repo concurrency guard (a second cursor_start on a repo with an active handle is rejected — two agents on one tree = corruption; different repos run in parallel), handle persistence across turns (a JSON table under <HERMES_HOME>/state/), git-diff fallback for shell-driven edits, and a check_fn so the tools only appear when cursor-agent is installed.
Migrating from v0.2? The single blocking
cursor_edit+ hidden auto-resume registry are gone (breaking). Start work withcursor_start, resume by passing the handle back — no repo+timestamp heuristic guessing which session to continue.
- Hermes Agent
pip install cursor-sdk(python ≥ 3.10)CURSOR_API_KEYexported (create one at the Cursor dashboard)- The target repo should be a git repo (enables the diff fallback)
Drop the plugin into your Hermes plugins directory and enable it:
# 1. copy the plugin
mkdir -p ~/.hermes/plugins/ghost_cursor
cp __init__.py sdk_runner.py events.py runner.py jobs.py handles.py eventlog.py names.py render.py plugin.yaml ~/.hermes/plugins/ghost_cursor/
# 2. enable it in ~/.hermes/config.yaml
# plugins:
# enabled:
# - ghost_cursor
# 3. restart the gateway so the tools load
hermes gateway restartVerify it registered:
# cursor_create_session / cursor_send_message / cursor_status / cursor_stop /
# cursor_events / cursor_list / cursor_subscribe should show up as tools once
# cursor-sdk is importableOnce loaded, the agent gains a cursor_edit tool. In practice you just talk to your agent normally — when a task is coding work, it reaches for cursor_edit:
"Add a
subtract(a, b)function tocalc.py"
The tool spawns cursor-agent in the repo, streams the edit live, and returns the diff.
Programmatic shape of the result:
{
"success": true,
"status": "completed",
"repo": "/path/to/repo",
"summary": "Added subtract(a, b) to calc.py.",
"files_changed": [
{ "path": "calc.py", "added": 4, "removed": 0, "status": "M", "diff": "--- a/calc.py\n+++ b/calc.py\n@@ ..." }
],
"files_changed_count": 1,
"live_progress": true
}Reuse the session_id from a result to continue that Cursor session — it keeps full prior context, so follow-ups build on earlier work (matching style, remembering decisions) instead of re-deriving from scratch:
call 1: cursor_edit(task="Create calc.py with add(a, b).")
→ { ..., "session_id": "b5b4dbe1-…", "resumed": false }
call 2: cursor_edit(task="Now add subtract in the same style.",
session_id="b5b4dbe1-…")
→ { ..., "session_id": "b5b4dbe1-…", "resumed": true }
If the prior session is gone (Cursor restarted, id expired), call 2 transparently starts fresh and reports resumed: false — the task still runs, just without the earlier context.
Note: this is cross-turn resume (continue between calls), not mid-flight steering — you can't inject a nudge into a prompt that's currently running; cancel and re-prompt (with the same
session_id) for that.
Cursor has no true mid-prompt queue (a second prompt cancels and replaces the first), so "interject" is built as stop + auto-resume: when a cursor_edit run is interrupted, its cursor session_id is eagerly persisted to a small registry (keyed by the calling session + repo). The next cursor_edit in the same session/repo — with no explicit session_id — automatically continues that interrupted cursor session, folding your new instruction in with full prior context.
So the flow is: run a task → interrupt it → send a nudge → it picks up the same cursor session and keeps going. No id-threading required. Guards: auto-resume only fires for a recently interrupted run (≤10 min, cancelled/running) — a cleanly completed run is never auto-resumed, so an unrelated next task starts fresh. Passing session_id explicitly always overrides. The result reports auto_resumed: true when this kicked in.
Honest label: this is interject/steer, not seamless queuing — there's a cancel boundary, so work in flight at the moment of interruption is discarded, then continued from the nudge with context intact.
By default cursor_edit runs synchronously (best for quick edits — you see the diff in the same turn). For longer work, pass background: true: the tool dispatches a tracked job and returns immediately with a job_id, so the conversation stays free — you can keep talking to the agent without interrupting (or killing) the running cursor job.
cursor_status(job_id?)— a strictly read-only progress view: current status, files touched so far with per-edit diffs, latest reasoning,session_id, elapsed. Polling it never cancels the job (that property is tested, not assumed — it was the exact footgun that killed foreground runs). Omitjob_idfor the most recent job in this session+repo.- Completion delivery — when the job ends it delivers a message into the session for every terminal state (success, failure, cursor error, timeout, cancelled) — never a silent death. The payload carries the full result (
files_changed,session_id, …) so resume/interject still work across the async boundary. - Auto-promote-on-overrun — a synchronous run that exceeds a soft threshold (default 90s,
plugins.ghost_cursor.promote_after_secondsin config.yaml, 0 disables) is detached to a background job instead of blocking — belt-and-suspenders for a misjudged sync run. - Same-repo concurrency guard — a second background run against a repo that already has an active job is rejected (two agents on one working tree = corruption).
Why this matters: a synchronous tool holds the conversation turn open for the whole run, so messaging the agent mid-run triggers an interrupt that cancels the turn — and the cursor work with it. Background mode decouples the run from the turn, so "how's it going?" becomes a safe read instead of a kill.
Timeouts are inactivity-based: a run that keeps streaming events (reasoning, tool calls, content) is alive and is never killed for total elapsed time. Only a silent run is treated as hung.
inactivity_timeout_s— abort after this many seconds with no stream events; any streamed activity resets the clock. Default 600 (10 min of silence); 0 disables the watchdog.max_wall_s— optional hard ceiling on total run time, a safety net for runaways that stream forever without finishing. Default 0 (disabled).
Precedence for both: explicit tool param → config.yaml (plugins.ghost_cursor.inactivity_timeout_s / plugins.ghost_cursor.max_wall_s) → built-in default. The abort error names whichever limit fired ("no activity for Ns" vs "exceeded max wall time (Ns)"), and either one delivers a normal timeout completion message. cursor_status reports last_activity_s (seconds since the last stream event) so you can spot a run going quiet before the watchdog fires — it's advisory only; the enforcement lives in the SDK runner.
The old timeout parameter is kept as a deprecated alias for inactivity_timeout_s.
A registry-dispatched tool handler isn't handed the calling AIAgent, but Hermes installs the agent's _touch_activity as a thread-local activity callback right before each tool dispatch. _resolve_progress_callback() reads that thread-local, walks __self__ back to the live agent, and uses its tool_progress_callback. Each emission is:
tool_progress_callback("reasoning.available", "cursor_edit", <json-envelope>, None)
which the api_server session-chat-stream forwards mid-turn as event: tool.progress. The JSON delta is a canonical envelope — content / tool_use / tool_result / lifecycle / file_diff — that a UI keys on (tool_name == "cursor_edit", source: "ghost") to render live tool cards and diffs.
| File | Role |
|---|---|
__init__.py |
Plugin entry — registers the seven tools, resolves the progress callback, builds results |
progress.py |
Progress subscriptions — per-run digest timers, cursor_subscribe plumbing, completion-queue delivery guards |
sdk_runner.py |
cursor-sdk transport — bridge lifecycle, Agent create/resume, event streaming, observe() re-attach, bounded retries, watchdogs, native cancel |
events.py |
Canonical envelope builders + SDKMessage → envelope mapping |
runner.py |
Legacy --print stdout runner (reference/fallback) + shared helpers |
plugin.yaml |
Plugin manifest |
test_ghost_cursor_plugin.py |
Tests |
MIT — see LICENSE.
Two GitHub Actions workflows (.github/workflows/):
unit(every push/PR, blocking) — the 110 hermetic unit tests against real Hermes-core imports. Fast, deterministic, no secrets, no network.e2e(scheduled daily + on-demand) — the real deal: no mocks. Installs real Hermes + the realcursor-sdkand exercises every input shape of the handle interface (cursor_startnew/resume,cursor_send, read-onlycursor_status,cursor_stop, same-repo guard, bogus-handle fallback) against a live cheap model. Assertions are invariants ("a.pyexists / imports /add(2,3)==5/ status never cancels the run"), never exact diffs — the model is nondeterministic. It's separate fromunit(not blocking every push) because a real LLM call is occasionally slow/flaky; that's the deliberate cost of "don't mock cursor, don't mock hermes".
Set the repo secret CURSOR_API_KEY for the e2e job. Pin the CI model via the GHOST_CURSOR_TEST_MODEL env in e2e.yml (default gpt-5.4-nano-low — cheap + fast; verify the slug with cursor-agent models).
Reproduce the e2e env locally with Dockerfile.e2e:
docker build -t ghost-cursor-e2e -f Dockerfile.e2e .
docker run --rm -e CURSOR_API_KEY=sk-... ghost-cursor-e2e