@emmaneugene/pi-cursor-sdk 0.4.0 → 0.4.1
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.
- package/CHANGELOG.md +6 -0
- package/dist/cursor-extension-factory-guard.js +31 -0
- package/dist/cursor-pi-tool-bridge-server.js +3 -0
- package/dist/cursor-pi-tool-bridge.js +5 -0
- package/dist/index.js +65 -49
- package/docs/cursor-live-smoke-checklist.md +1 -1
- package/docs/cursor-model-ux-spec.md +1 -1
- package/docs/cursor-native-tool-visual-audit.md +3 -3
- package/docs/cursor-testing-lessons.md +15 -0
- package/docs/platform-smoke.md +1 -1
- package/node_modules/cross-spawn/node_modules/which/CHANGELOG.md +166 -0
- package/package.json +1 -1
- package/scripts/lib/cursor-visual-render.mjs +12 -2
- package/scripts/visual-tui-smoke-self-test.mjs +9 -33
- package/scripts/visual-tui-smoke.mjs +54 -11
- package/src/cursor-extension-factory-guard.ts +57 -0
- package/src/cursor-pi-tool-bridge-server.ts +4 -0
- package/src/cursor-pi-tool-bridge.ts +5 -0
- package/src/index.ts +70 -49
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.1 - 2026-09-07
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Keep the first in-process factory as the Cursor owner. A pi child session that calls `createAgentSession` + `bindExtensions` no longer re-runs the factory in a way that disposes the live parent bridge (`Cursor pi tool bridge extension reloaded`), steals session scope, or aborts the parent `pi__subagent` call.
|
|
8
|
+
|
|
3
9
|
## 0.4.0 - 2026-09-06
|
|
4
10
|
|
|
5
11
|
Breaking: Cursor Cloud support is removed. Cursor SDK runs are local-only.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
let factoryOwnerToken;
|
|
2
|
+
export function claimCursorExtensionFactory() {
|
|
3
|
+
if (factoryOwnerToken)
|
|
4
|
+
return { kind: "nested" };
|
|
5
|
+
const token = Symbol("cursor-extension-factory-owner");
|
|
6
|
+
factoryOwnerToken = token;
|
|
7
|
+
return { kind: "owner", token };
|
|
8
|
+
}
|
|
9
|
+
export function releaseCursorExtensionFactory(token) {
|
|
10
|
+
if (factoryOwnerToken === token) {
|
|
11
|
+
factoryOwnerToken = undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Install the owner's release handler after all other factory registration
|
|
16
|
+
* succeeds. The final handler keeps ownership until earlier shutdown cleanup
|
|
17
|
+
* has finished.
|
|
18
|
+
*/
|
|
19
|
+
export function registerCursorExtensionFactoryRelease(pi, claim) {
|
|
20
|
+
pi.on("session_shutdown", () => {
|
|
21
|
+
releaseCursorExtensionFactory(claim.token);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
export const __testUtils = {
|
|
25
|
+
isInstalled() {
|
|
26
|
+
return factoryOwnerToken !== undefined;
|
|
27
|
+
},
|
|
28
|
+
reset() {
|
|
29
|
+
factoryOwnerToken = undefined;
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -41,6 +41,9 @@ export class CursorPiToolBridgeRegistry {
|
|
|
41
41
|
run.emitStartDiagnostics(bridgeEnabled);
|
|
42
42
|
return run;
|
|
43
43
|
}
|
|
44
|
+
hasLiveRuns() {
|
|
45
|
+
return this.runs.size > 0;
|
|
46
|
+
}
|
|
44
47
|
async disposeAll(reason = "Cursor pi tool bridge disposed") {
|
|
45
48
|
await Promise.all([...this.runs].map(async (run) => {
|
|
46
49
|
run.cancel(reason);
|
|
@@ -49,6 +49,11 @@ Get-CimInstance Win32_Process -Filter "Name = 'bash.exe' OR Name = 'sh.exe'" |
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
export function registerCursorPiToolBridge(pi) {
|
|
52
|
+
// Replacing a bridge during a live MCP run cancels its pending pi tool
|
|
53
|
+
// calls. Keep the active registry as a final safety belt.
|
|
54
|
+
if (registeredCursorPiToolBridge?.hasLiveRuns()) {
|
|
55
|
+
return registeredCursorPiToolBridge;
|
|
56
|
+
}
|
|
52
57
|
bridgeToolExecutionAbortTracker.abortAll("Cursor pi tool bridge extension reloaded");
|
|
53
58
|
void registeredCursorPiToolBridge?.disposeAll("Cursor pi tool bridge extension reloaded");
|
|
54
59
|
const bridge = new CursorPiToolBridgeRegistry(pi);
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { registerCursorAgentsContextDedup } from "./cursor-agents-context-regist
|
|
|
15
15
|
import { registerCursorOverflowNormalization } from "./cursor-provider-overflow.js";
|
|
16
16
|
import { registerCursorSdkSessionProcessErrorGuard } from "./cursor-sdk-process-error-guard.js";
|
|
17
17
|
import { prepareCursorSessionForCompaction } from "./cursor-session-compaction-prep.js";
|
|
18
|
+
import { claimCursorExtensionFactory, registerCursorExtensionFactoryRelease, releaseCursorExtensionFactory, } from "./cursor-extension-factory-guard.js";
|
|
18
19
|
function createCursorProviderConfig(models) {
|
|
19
20
|
return {
|
|
20
21
|
name: "Cursor",
|
|
@@ -29,54 +30,69 @@ function registerCursorProvider(pi, models) {
|
|
|
29
30
|
pi.registerProvider("cursor", createCursorProviderConfig(models));
|
|
30
31
|
}
|
|
31
32
|
export default async function (pi) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
33
|
+
const factoryClaim = claimCursorExtensionFactory();
|
|
34
|
+
if (factoryClaim.kind === "nested")
|
|
35
|
+
return;
|
|
36
|
+
try {
|
|
37
|
+
// Discover first. A discovery failure must not leave process-global
|
|
38
|
+
// registrars from a discarded extension load.
|
|
39
|
+
let fallbackIssue;
|
|
40
|
+
const models = await discoverModels({
|
|
41
|
+
onFallback: (issue) => {
|
|
42
|
+
fallbackIssue = issue;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
// Session cwd must register before other session_start listeners that depend on it.
|
|
46
|
+
registerCursorSessionScope(pi);
|
|
47
|
+
registerCursorSessionAgentLineage(pi);
|
|
48
|
+
registerCursorSessionAgentLifecycle(pi);
|
|
49
|
+
registerCursorSessionAgentResume(pi);
|
|
50
|
+
pi.on("session_before_compact", async () => {
|
|
51
|
+
await prepareCursorSessionForCompaction();
|
|
52
|
+
});
|
|
53
|
+
registerCursorRuntimeControls(pi);
|
|
54
|
+
registerCursorNativeToolDisplay(pi);
|
|
55
|
+
registerCursorQuestionTool(pi);
|
|
56
|
+
registerCursorSkillTool(pi);
|
|
57
|
+
registerCursorPiToolBridge(pi);
|
|
58
|
+
registerCursorAgentsContextDedup(pi);
|
|
59
|
+
registerCursorOverflowNormalization(pi);
|
|
60
|
+
if (fallbackIssue) {
|
|
61
|
+
registerCursorFallbackIssueWarning(pi, fallbackIssue);
|
|
62
|
+
}
|
|
63
|
+
pi.registerCommand("cursor-refresh-models", {
|
|
64
|
+
description: "Refresh the live Cursor model catalog without restarting pi",
|
|
65
|
+
handler: async (_args, ctx) => {
|
|
66
|
+
let refreshFallbackIssue;
|
|
67
|
+
const apiKey = resolveCursorApiKey(await ctx.modelRegistry.getApiKeyForProvider("cursor"));
|
|
68
|
+
const refreshedModels = await discoverModels({
|
|
69
|
+
apiKey,
|
|
70
|
+
forceRefresh: true,
|
|
71
|
+
onFallback: (issue) => {
|
|
72
|
+
refreshFallbackIssue = issue;
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
registerCursorProvider(pi, refreshedModels);
|
|
76
|
+
if (!ctx.hasUI)
|
|
77
|
+
return;
|
|
78
|
+
if (refreshFallbackIssue) {
|
|
79
|
+
ctx.ui.notify(`Cursor model catalog refresh did not use a live catalog: ${refreshFallbackIssue.message}`, "warning");
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
ctx.ui.notify(`Cursor model catalog refreshed with ${refreshedModels.length} model${refreshedModels.length === 1 ? "" : "s"}.`, "info");
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
registerCursorProvider(pi, models);
|
|
87
|
+
// Keep the process error guard near the end so earlier Cursor cleanup
|
|
88
|
+
// remains protected during session shutdown.
|
|
89
|
+
registerCursorSdkSessionProcessErrorGuard(pi);
|
|
90
|
+
// Register last so ownership remains protected until all other Cursor
|
|
91
|
+
// session_shutdown handlers finish.
|
|
92
|
+
registerCursorExtensionFactoryRelease(pi, factoryClaim);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
releaseCursorExtensionFactory(factoryClaim.token);
|
|
96
|
+
throw error;
|
|
55
97
|
}
|
|
56
|
-
pi.registerCommand("cursor-refresh-models", {
|
|
57
|
-
description: "Refresh the live Cursor model catalog without restarting pi",
|
|
58
|
-
handler: async (_args, ctx) => {
|
|
59
|
-
let refreshFallbackIssue;
|
|
60
|
-
const apiKey = resolveCursorApiKey(await ctx.modelRegistry.getApiKeyForProvider("cursor"));
|
|
61
|
-
const refreshedModels = await discoverModels({
|
|
62
|
-
apiKey,
|
|
63
|
-
forceRefresh: true,
|
|
64
|
-
onFallback: (issue) => {
|
|
65
|
-
refreshFallbackIssue = issue;
|
|
66
|
-
},
|
|
67
|
-
});
|
|
68
|
-
registerCursorProvider(pi, refreshedModels);
|
|
69
|
-
if (!ctx.hasUI)
|
|
70
|
-
return;
|
|
71
|
-
if (refreshFallbackIssue) {
|
|
72
|
-
ctx.ui.notify(`Cursor model catalog refresh did not use a live catalog: ${refreshFallbackIssue.message}`, "warning");
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
ctx.ui.notify(`Cursor model catalog refreshed with ${refreshedModels.length} model${refreshedModels.length === 1 ? "" : "s"}.`, "info");
|
|
76
|
-
}
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
registerCursorProvider(pi, models);
|
|
80
|
-
// Register last so session_shutdown cleanup remains protected until other Cursor handlers finish.
|
|
81
|
-
registerCursorSdkSessionProcessErrorGuard(pi);
|
|
82
98
|
}
|
|
@@ -183,7 +183,7 @@ npm run smoke:visual -- "${VISUAL_ARGS[@]}" \
|
|
|
183
183
|
--prompt 'Stay in Cursor plan mode. If Cursor exposes plan, todo, task, or mode activity for this request, use that capability to outline a tiny unit test without editing files. Otherwise answer with a concise numbered plan. Do not use shell or file mutation tools.'
|
|
184
184
|
```
|
|
185
185
|
|
|
186
|
-
By default, `npm run smoke:visual` writes `.ansi`, `.txt`, `.html`, `.png`, and `.jsonl.path` artifacts.
|
|
186
|
+
By default, `npm run smoke:visual` writes `.ansi`, `.txt`, `.html`, `.png`, and `.jsonl.path` artifacts. The runner uses Playwright's Chromium or a system Chrome installation. If neither is available in an agent-harness run, rerun with `--no-screenshot`, open the generated `.html` with `agent_browser`, save a PNG screenshot, and record that PNG path beside the runner artifacts. To visually audit bridge behavior or ambient Cursor settings, opt in with `--bridge`, `--bridge --expose-builtin-tools`, or `--setting-sources <value>` and label that evidence separately; do not count those opt-in runs as default native replay matrix proof.
|
|
187
187
|
|
|
188
188
|
Expected proof for each category is defined in [Cursor Native Tool Visual Audit Workflow](./cursor-native-tool-visual-audit.md). Do not mark a category passed because the prompt was sent. A category passes only when the PNG shows the expected card and the JSONL shows the expected completed `toolCall` / `toolResult` pair with the expected `isError` state.
|
|
189
189
|
|
|
@@ -29,7 +29,7 @@ Current implementation notes:
|
|
|
29
29
|
- The bridge queues MCP calls, emits provider `toolcall_*` events, waits for matching pi `toolResult` messages by `toolCallId`, resolves the result back into the same live Cursor SDK run without creating a new `Agent`, and never calls tool `execute()` handlers directly. The same-run resume invariant holds unless the run was disposed, aborted, or cancelled.
|
|
30
30
|
- Cursor SDK MCP tool calls use a guarded timeout override because installed `@cursor/sdk` 1.0.30 still has a 60-second MCP request default with no public per-server timeout option. The extension extends the verified Cursor SDK MCP `callTool` timeout path to 3600 seconds by default and shortens the verified first-send MCP initialize/listTools timeout paths to 10 seconds by default so unavailable configured MCP servers do not block the first reply for a full minute; unknown MCP protocol timeout stacks keep the SDK default. Users can override tool-call timeouts with `PI_CURSOR_MCP_TOOL_TIMEOUT_MS` or `PI_CURSOR_MCP_TOOL_TIMEOUT_SECONDS`, and initialize/listTools timeouts with `PI_CURSOR_MCP_CONNECT_TIMEOUT_MS` or `PI_CURSOR_MCP_CONNECT_TIMEOUT_SECONDS`. Bridged `CallTool` waits also have a local fail-closed deadline that defaults to and cannot exceed the effective MCP tool timeout; `PI_CURSOR_PI_BRIDGE_CALL_TIMEOUT_MS` can lower it, expiry or MCP cancellation aborts active pi execution when available, and expired bridge events are dropped before pi tool emission.
|
|
31
31
|
- Cursor SDK local safety controls are off by default. `--cursor-auto-review` / `PI_CURSOR_AUTO_REVIEW` and `--cursor-sandbox` / `PI_CURSOR_SANDBOX` pass only explicit enabled values into `Agent.create({ local })`; user or trusted project config can set `local.autoReview` and `local.sandboxOptions.enabled`; project config is active only when Pi's project-trust flow reached the extension and approved the project or the run used explicit `--approve`, and project saves require the same immutable trust provenance rather than creating Pi trust resources automatically. Pi 0.84.0 loads `pi install -l` project-local extensions after the trust event, so those installs require `--approve` on every run that reads or writes `.pi/cursor-sdk.json`. Fast-default and HTTP transport saves preserve unrecognized config fields, reject malformed or non-object JSON without rewriting it, and use one lock-protected read-modify-write path; fast saves mutate only the selected model key. Because Pi can mutate its in-memory session branch before a journal append throws, a completed global save is authoritative and the command reports the partial journal failure instead of attempting an ambiguous rollback; the new global value stays authoritative over stale branch entries until a later successful save or session restart.
|
|
32
|
-
- Local HTTP/1.1/SSE compatibility is strictly opt-in through `PI_CURSOR_HTTP_1_1`, `/cursor-http on|off|toggle`, or user `cursor-sdk.json` `local.useHttp1ForAgent`. Precedence is session, environment, user, then the built-in unset default; project config is excluded. Unset makes no `Cursor.configure()` call. Explicit values configure the installed SDK before local `Agent.create()`, extension-owned explicit state is cleared with the SDK's documented `null` reset when returning to unset and during session shutdown before module reload, and default/HTTP2/HTTP1 choices split pooled local agents. Pi's supported CLI/TUI/print/RPC lifecycle has one active session runtime per process; concurrent independent `AgentSession` embedding in one process is outside this transport toggle's contract because the installed SDK setting and executor cache are module-global. The footer adds `http1` only when HTTP/1.1 transport is enabled.
|
|
32
|
+
- Local HTTP/1.1/SSE compatibility is strictly opt-in through `PI_CURSOR_HTTP_1_1`, `/cursor-http on|off|toggle`, or user `cursor-sdk.json` `local.useHttp1ForAgent`. Precedence is session, environment, user, then the built-in unset default; project config is excluded. Unset makes no `Cursor.configure()` call. Explicit values configure the installed SDK before local `Agent.create()`, extension-owned explicit state is cleared with the SDK's documented `null` reset when returning to unset and during session shutdown before module reload, and default/HTTP2/HTTP1 choices split pooled local agents. Pi's supported CLI/TUI/print/RPC lifecycle has one active session runtime per process; concurrent independent `AgentSession` embedding in one process is outside this transport toggle's contract because the installed SDK setting and executor cache are module-global. The extension factory is also process-global: the first load owns the bridge, session scope, and pooled SDK agent. A nested child `createAgentSession` load is ignored until that owner session shuts down, so a live parent `pi__subagent` MCP run is not disposed as an extension reload. The footer adds `http1` only when HTTP/1.1 transport is enabled.
|
|
33
33
|
- Bridge diagnostics are opt-in only: `PI_CURSOR_PI_TOOL_BRIDGE_DEBUG=1` writes typed, allowlisted, scrubbed single-line JSONL records to `process.stderr` with prefix `[pi-cursor-sdk:bridge]`. Diagnostics are scrubbed operational logs, not anonymous telemetry. They intentionally include tool names, safe correlation IDs, run lifecycle, exposed pi↔MCP name pairs, queued requests, result resolution, rejection, cancellation, and pending counts. Correlation IDs are generated independently from the tokenized endpoint path, and Cursor MCP call IDs are hashed before serialization. Diagnostics must not include endpoint paths/URLs/path components/tokens, API keys, bearer tokens, cookies, session credentials, raw args/results, stdout/stderr payloads, file contents, Cursor settings output, or local private session paths in tracked docs, and they must not call pi UI status, notification, or footer APIs. If tool names themselves are unacceptable for a release target, bridge debug diagnostics are not safe for shared logs under the current contract.
|
|
34
34
|
- This repo does not provide a generic desktop-automation, browser-driver, or CDP recipe. Provider docs should describe pi-cursor-sdk's Cursor provider/bridge contract only.
|
|
35
35
|
- Cursor internal tool activity is recorded from SDK events and scrubbed. Maintainer reference for `@cursor/sdk@1.0.30` `ToolType` values, runtime alias normalization, and intentional mapping/fallback rules: [Cursor native tool replay — SDK ToolType replay matrix](./cursor-native-tool-replay.md#sdk-tooltype-replay-matrix) (official SDK docs: https://cursor.com/docs/sdk/typescript). In TUI sessions and structured JSON/RPC modes, supported completed `read`, `bash`, `grep`, `find`, `ls`, `edit`, `write`, diagnostics, delete, todo/plan, task, image generation, MCP, semantic search, and screen recording activity is replayed through pi's native tool-call rendering path with recorded Cursor results, so users and JSON/RPC consumers can see native-looking cards/events without rerunning Cursor's reads/shell commands/file edits. Cursor `glob` activity is replayed through native `find` cards. Cursor write activity is replayed through native-looking `write` cards, and Cursor StrReplace/edit activity uses native-looking `edit` only when recorded arguments truthfully satisfy pi's `edit` schema; path-only Cursor edit and notebook edit replay falls back to neutral Cursor activity before pi validation. Diagnostics, delete, todos/plans, task/subagent, image, and MCP activity use neutral Cursor activity cards with pi's default success/error shell. Cursor SDK `task` activity is labeled **Cursor subagent** by default because it represents Cursor-spawned child-agent work; the card summary includes description plus subagent kind/model/short ID when Cursor reports them, and `PI_CURSOR_TASK_PRESENTATION=task` restores the older **Cursor task** wording for comparison. This is visibility over Cursor SDK task events, not a native pi subagent session: pi shows start/final output plus any `conversationSteps` tool-call summaries Cursor returns, but cannot show a live nested read/shell/MCP trail when the SDK only returns final subagent text. Neutral Cursor activity calls include `activityTitle` and, when available, `activitySummary` so partial/collapsed cards preserve identity such as `Cursor plan`, `Cursor todos`, `Cursor subagent`, `Cursor MCP`, or `Cursor edit`. For long-running or externally meaningful Cursor tools (`task`, `shell`, `mcp`, `generateImage`, `recordScreen`, `semSearch`, web search/fetch, plan/todo), the provider may surface one low-noise deferred in-progress thinking line such as `Cursor MCP: external_search` from bounded, scrubbed SDK args; fast local tools (`read`, `grep`, `glob`, and similar) skip lifecycle lines when completion follows immediately, and pi bridge MCP calls are excluded because pi already shows real pi tool execution ([lifecycle visibility](./cursor-native-tool-replay.md#low-noise-tool-lifecycle-visibility)). Replay-only tools display recorded Cursor results, normalize workspace-local paths/diff headers for display, use pi diff colors for edit previews and path-inferred syntax highlighting for write previews, and fail closed if called without a recorded result. Native replay wrappers are registered only for tool names not already owned by another extension; conflicting tools use the bounded scrubbed transcript fallback. Cursor workflow tools such as mode/task/todo/plan activity are not pi workflow controls; reported todo/plan events are displayed as Cursor activity only. Plan/todo replay cards can be followed by Cursor's final plan text, selected from `run.wait().result` when Cursor provides one and trimmed against already-emitted text. Started Cursor SDK tool calls that never receive a completion event are surfaced with bounded user-visible labels/traces (neutral activity cards when native replay routing allows, otherwise the same inactive or transcript trace fallbacks used for completed replay) instead of being silently discarded when the run failed, was aborted, or produced no assistant text; after a successful text-producing run, missing-completion starts remain maintainer-debug-only for all tools: installed `@cursor/sdk` 1.0.30 emits `tool-call-started` with no completion delta, step, or conversation entry when a permission policy or hook denies a call, and offers no way to distinguish such denials from lost completions, so suppression is the deliberate choice over false error cards. Explicit failures remain visible when Cursor reports them through completed tool calls or step results. Pi bridge MCP starts remain excluded from duplicate incomplete Cursor cards because pi already shows real pi tool execution. `PI_CURSOR_NATIVE_TOOL_DISPLAY=0` disables native replay, and `PI_CURSOR_REGISTER_NATIVE_TOOLS=0` is a registration-only opt-out that keeps the transcript fallback without shadowing pi tool names. When bridge or native replay cards are emitted, the provider mirrors Codex's turn shape as Cursor SDK activity arrives: assistant `toolUse`, pi `toolResult`s, live post-tool Cursor thinking/text, any later tool batches as further `toolUse` turns, then Cursor's final assistant answer. For shell replay, completed `stdout` / `stderr` are primary; unambiguous `shell-output-delta` data is also shown as bounded live progress while one shell call is active and used as display-only fallback for empty successful shell completions, while overlapping shell calls drop ambiguous deltas instead of guessing. Print mode keeps bounded scrubbed transcript output instead, preserving `pi -p` assistant text output. Cursor text deltas stream live when no live-run turn split is active.
|
|
@@ -76,7 +76,7 @@ npm install
|
|
|
76
76
|
npx playwright install chromium
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
Automatic PNG capture uses Playwright's Chromium or a system Chrome installation. If neither is available, run `npx playwright install chromium`. When running inside the pi agent harness, `agent_browser` is the preferred screenshot tool for generated HTML/ANSI output because it can open local files, verify saved artifacts, and capture exact evidence paths. In that case, run `npm run smoke:visual -- --no-screenshot ...` and screenshot the generated `.html` with `agent_browser`. Outside the harness, use Playwright through the checked-in runner.
|
|
80
80
|
|
|
81
81
|
## Runner contract
|
|
82
82
|
|
|
@@ -95,8 +95,8 @@ npx playwright install chromium
|
|
|
95
95
|
- `TERM=xterm-256color`
|
|
96
96
|
- cwd set to the target audit repo; the tmux session starts in `--cwd` with a non-login shell so a stale tmux-server cwd cannot print `getcwd` errors
|
|
97
97
|
- `--session-id` forwarded to pi only when explicitly provided, so fresh captures avoid the new-session warning line
|
|
98
|
-
- prompt
|
|
99
|
-
- bounded post-
|
|
98
|
+
- prompt submitted after the Cursor TUI footer appears, using bracketed tmux paste and a literal carriage return
|
|
99
|
+
- bounded TUI readiness wait via `--startup-ms` and bounded post-submit wait via `--wait-ms`
|
|
100
100
|
- artifacts outside the repo by default
|
|
101
101
|
- `<label>.ansi`, `<label>.txt`, `<label>.html`, `<label>.png`, `<label>.jsonl.path`, and `<label>.manifest.json`
|
|
102
102
|
- `--label`, `--ext`, `--cwd`, `--prompt`, `--prompt-file`, `--wait-ms`, and `--out-dir`
|
|
@@ -28,6 +28,21 @@ Passing hundreds of unit tests did not prove that chain was safe. Regression cov
|
|
|
28
28
|
|
|
29
29
|
When changing provider/runtime behavior, ask whether the bug spans **pi extension lifecycle**, **active tool state**, **provider streaming**, and **persisted JSONL**. If yes, add an integration-style unit test or live smoke coverage for that chain.
|
|
30
30
|
|
|
31
|
+
## In-process child sessions re-run the extension factory
|
|
32
|
+
|
|
33
|
+
Pi subagents build a child `AgentSession` in the same process (`createAgentSession` + `bindExtensions`). That re-invokes the `pi-cursor-sdk` factory against a new ExtensionAPI while the parent Cursor run still owns the process-global bridge, session scope, and pooled SDK agent.
|
|
34
|
+
|
|
35
|
+
The observed failure: the child dies at `0 tool uses` with no transcript file, the parent turn aborts with `This operation was aborted`, and bridge diagnostics show `request_rejected` / `cancelled` with `Cursor pi tool bridge extension reloaded`.
|
|
36
|
+
|
|
37
|
+
Regression coverage:
|
|
38
|
+
|
|
39
|
+
- `src/cursor-extension-factory-guard.ts` — the first factory owns the process; nested loads no-op until the owner session shuts down and Pi can create its replacement runtime
|
|
40
|
+
- `test/cursor-extension-factory-guard.test.ts` — owner tokens reject stale release and release for every Pi shutdown reason
|
|
41
|
+
- `test/index-factory-guard.test.ts` — nested factory does not re-register, does not steal session scope, and does not dispose a live parent MCP run
|
|
42
|
+
- `test/cursor-pi-tool-bridge.test.ts` — `registerCursorPiToolBridge` keeps the existing registry when a run is live
|
|
43
|
+
|
|
44
|
+
If the host resolves a Cursor model for a child, that child uses the process-global Cursor agent pool. This guard stops the parent teardown; it does not give the child an independent Cursor session agent. Cursor-child model selection remains a separate live-test requirement.
|
|
45
|
+
|
|
31
46
|
## Dual-check invariant: `context.tools` vs pi active tools
|
|
32
47
|
|
|
33
48
|
Native replay routing intentionally uses two layers:
|
package/docs/platform-smoke.md
CHANGED
|
@@ -434,7 +434,7 @@ Doctor checks:
|
|
|
434
434
|
17. `tar` is available on macOS and native Windows.
|
|
435
435
|
18. `node-pty` self-test passes on every target.
|
|
436
436
|
19. Target pi tool probe proves the shell tool accepts platform-rendered commands on every target.
|
|
437
|
-
20. Host-side xterm/Playwright render self-test passes by rendering a minimal ANSI fixture through the repo xterm helper and launching Playwright Chromium to write a tiny PNG. If
|
|
437
|
+
20. Host-side xterm/Playwright render self-test passes by rendering a minimal ANSI fixture through the repo xterm helper and launching Playwright Chromium or system Chrome to write a tiny PNG. If neither browser is available, run `npm install` and `npx playwright install chromium` before live suites.
|
|
438
438
|
21. `CURSOR_API_KEY` is present.
|
|
439
439
|
22. Artifact root is writable.
|
|
440
440
|
23. `git status --short` is recorded.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Changes
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
## 2.0.2
|
|
5
|
+
|
|
6
|
+
* Rename bin to `node-which`
|
|
7
|
+
|
|
8
|
+
## 2.0.1
|
|
9
|
+
|
|
10
|
+
* generate changelog and publish on version bump
|
|
11
|
+
* enforce 100% test coverage
|
|
12
|
+
* Promise interface
|
|
13
|
+
|
|
14
|
+
## 2.0.0
|
|
15
|
+
|
|
16
|
+
* Parallel tests, modern JavaScript, and drop support for node < 8
|
|
17
|
+
|
|
18
|
+
## 1.3.1
|
|
19
|
+
|
|
20
|
+
* update deps
|
|
21
|
+
* update travis
|
|
22
|
+
|
|
23
|
+
## v1.3.0
|
|
24
|
+
|
|
25
|
+
* Add nothrow option to which.sync
|
|
26
|
+
* update tap
|
|
27
|
+
|
|
28
|
+
## v1.2.14
|
|
29
|
+
|
|
30
|
+
* appveyor: drop node 5 and 0.x
|
|
31
|
+
* travis-ci: add node 6, drop 0.x
|
|
32
|
+
|
|
33
|
+
## v1.2.13
|
|
34
|
+
|
|
35
|
+
* test: Pass missing option to pass on windows
|
|
36
|
+
* update tap
|
|
37
|
+
* update isexe to 2.0.0
|
|
38
|
+
* neveragain.tech pledge request
|
|
39
|
+
|
|
40
|
+
## v1.2.12
|
|
41
|
+
|
|
42
|
+
* Removed unused require
|
|
43
|
+
|
|
44
|
+
## v1.2.11
|
|
45
|
+
|
|
46
|
+
* Prevent changelog script from being included in package
|
|
47
|
+
|
|
48
|
+
## v1.2.10
|
|
49
|
+
|
|
50
|
+
* Use env.PATH only, not env.Path
|
|
51
|
+
|
|
52
|
+
## v1.2.9
|
|
53
|
+
|
|
54
|
+
* fix for paths starting with ../
|
|
55
|
+
* Remove unused `is-absolute` module
|
|
56
|
+
|
|
57
|
+
## v1.2.8
|
|
58
|
+
|
|
59
|
+
* bullet items in changelog that contain (but don't start with) #
|
|
60
|
+
|
|
61
|
+
## v1.2.7
|
|
62
|
+
|
|
63
|
+
* strip 'update changelog' changelog entries out of changelog
|
|
64
|
+
|
|
65
|
+
## v1.2.6
|
|
66
|
+
|
|
67
|
+
* make the changelog bulleted
|
|
68
|
+
|
|
69
|
+
## v1.2.5
|
|
70
|
+
|
|
71
|
+
* make a changelog, and keep it up to date
|
|
72
|
+
* don't include tests in package
|
|
73
|
+
* Properly handle relative-path executables
|
|
74
|
+
* appveyor
|
|
75
|
+
* Attach error code to Not Found error
|
|
76
|
+
* Make tests pass on Windows
|
|
77
|
+
|
|
78
|
+
## v1.2.4
|
|
79
|
+
|
|
80
|
+
* Fix typo
|
|
81
|
+
|
|
82
|
+
## v1.2.3
|
|
83
|
+
|
|
84
|
+
* update isexe, fix regression in pathExt handling
|
|
85
|
+
|
|
86
|
+
## v1.2.2
|
|
87
|
+
|
|
88
|
+
* update deps, use isexe module, test windows
|
|
89
|
+
|
|
90
|
+
## v1.2.1
|
|
91
|
+
|
|
92
|
+
* Sometimes windows PATH entries are quoted
|
|
93
|
+
* Fixed a bug in the check for group and user mode bits. This bug was introduced during refactoring for supporting strict mode.
|
|
94
|
+
* doc cli
|
|
95
|
+
|
|
96
|
+
## v1.2.0
|
|
97
|
+
|
|
98
|
+
* Add support for opt.all and -as cli flags
|
|
99
|
+
* test the bin
|
|
100
|
+
* update travis
|
|
101
|
+
* Allow checking for multiple programs in bin/which
|
|
102
|
+
* tap 2
|
|
103
|
+
|
|
104
|
+
## v1.1.2
|
|
105
|
+
|
|
106
|
+
* travis
|
|
107
|
+
* Refactored and fixed undefined error on Windows
|
|
108
|
+
* Support strict mode
|
|
109
|
+
|
|
110
|
+
## v1.1.1
|
|
111
|
+
|
|
112
|
+
* test +g exes against secondary groups, if available
|
|
113
|
+
* Use windows exe semantics on cygwin & msys
|
|
114
|
+
* cwd should be first in path on win32, not last
|
|
115
|
+
* Handle lower-case 'env.Path' on Windows
|
|
116
|
+
* Update docs
|
|
117
|
+
* use single-quotes
|
|
118
|
+
|
|
119
|
+
## v1.1.0
|
|
120
|
+
|
|
121
|
+
* Add tests, depend on is-absolute
|
|
122
|
+
|
|
123
|
+
## v1.0.9
|
|
124
|
+
|
|
125
|
+
* which.js: root is allowed to execute files owned by anyone
|
|
126
|
+
|
|
127
|
+
## v1.0.8
|
|
128
|
+
|
|
129
|
+
* don't use graceful-fs
|
|
130
|
+
|
|
131
|
+
## v1.0.7
|
|
132
|
+
|
|
133
|
+
* add license to package.json
|
|
134
|
+
|
|
135
|
+
## v1.0.6
|
|
136
|
+
|
|
137
|
+
* isc license
|
|
138
|
+
|
|
139
|
+
## 1.0.5
|
|
140
|
+
|
|
141
|
+
* Awful typo
|
|
142
|
+
|
|
143
|
+
## 1.0.4
|
|
144
|
+
|
|
145
|
+
* Test for path absoluteness properly
|
|
146
|
+
* win: Allow '' as a pathext if cmd has a . in it
|
|
147
|
+
|
|
148
|
+
## 1.0.3
|
|
149
|
+
|
|
150
|
+
* Remove references to execPath
|
|
151
|
+
* Make `which.sync()` work on Windows by honoring the PATHEXT variable.
|
|
152
|
+
* Make `isExe()` always return true on Windows.
|
|
153
|
+
* MIT
|
|
154
|
+
|
|
155
|
+
## 1.0.2
|
|
156
|
+
|
|
157
|
+
* Only files can be exes
|
|
158
|
+
|
|
159
|
+
## 1.0.1
|
|
160
|
+
|
|
161
|
+
* Respect the PATHEXT env for win32 support
|
|
162
|
+
* should 0755 the bin
|
|
163
|
+
* binary
|
|
164
|
+
* guts
|
|
165
|
+
* package
|
|
166
|
+
* 1st
|
package/package.json
CHANGED
|
@@ -112,7 +112,17 @@ export async function writeTerminalScreenshot(htmlPath, pngPath, width, height)
|
|
|
112
112
|
let browser;
|
|
113
113
|
try {
|
|
114
114
|
const { chromium } = await import("playwright");
|
|
115
|
-
|
|
115
|
+
try {
|
|
116
|
+
browser = await chromium.launch();
|
|
117
|
+
} catch (bundledBrowserError) {
|
|
118
|
+
try {
|
|
119
|
+
browser = await chromium.launch({ channel: "chrome" });
|
|
120
|
+
} catch (systemBrowserError) {
|
|
121
|
+
const bundledMessage = bundledBrowserError instanceof Error ? bundledBrowserError.message : String(bundledBrowserError);
|
|
122
|
+
const systemMessage = systemBrowserError instanceof Error ? systemBrowserError.message : String(systemBrowserError);
|
|
123
|
+
throw new Error(`bundled Chromium: ${bundledMessage}\nsystem Chrome: ${systemMessage}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
116
126
|
const page = await browser.newPage({
|
|
117
127
|
viewport: {
|
|
118
128
|
width: Math.max(1_200, width * 10),
|
|
@@ -125,7 +135,7 @@ export async function writeTerminalScreenshot(htmlPath, pngPath, width, height)
|
|
|
125
135
|
await page.locator("#terminal").screenshot({ path: pngPath });
|
|
126
136
|
} catch (error) {
|
|
127
137
|
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
-
throw new Error(`failed to capture PNG with Playwright: ${message}\nInstall Chromium with: npx playwright install chromium\nOr rerun with --no-screenshot and capture ${htmlPath} with agent_browser.`);
|
|
138
|
+
throw new Error(`failed to capture PNG with Playwright: ${message}\nInstall system Chrome or Chromium with: npx playwright install chromium\nOr rerun with --no-screenshot and capture ${htmlPath} with agent_browser.`);
|
|
129
139
|
} finally {
|
|
130
140
|
if (browser) await browser.close();
|
|
131
141
|
}
|
|
@@ -24,7 +24,7 @@ function parseEnvCapture(path) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
export function runVisualSmokeSelfTest(deps) {
|
|
27
|
-
const { ROOT, DEFAULT_MODE, DEFAULT_MODEL, DEFAULT_SETTING_SOURCES, DEBUG_ENV_NAMES, shellQuote, parseArgs, snapshotJsonlMtimes, findLatestJsonl, sealedNodePath, resolveCommand, requireNode, requireCommand, buildLaunchPlan, run, runVisualSmoke } = deps;
|
|
27
|
+
const { ROOT, DEFAULT_MODE, DEFAULT_MODEL, DEFAULT_SETTING_SOURCES, DEBUG_ENV_NAMES, shellQuote, parseArgs, snapshotJsonlMtimes, findLatestJsonl, jsonlContainsUserPrompt, waitForCursorTui, sealedNodePath, resolveCommand, requireNode, requireCommand, buildLaunchPlan, run, runVisualSmoke } = deps;
|
|
28
28
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-cursor-sdk-visual-self-test-"));
|
|
29
29
|
try {
|
|
30
30
|
const binDir = join(tempDir, "bin");
|
|
@@ -59,6 +59,9 @@ export function runVisualSmokeSelfTest(deps) {
|
|
|
59
59
|
utimesSync(freshJsonl, new Date(3_000), new Date(3_000));
|
|
60
60
|
assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 2_000, previousMtimes: previousJsonlMtimes }) === freshJsonl, "JSONL discovery should ignore unchanged stale files before run start");
|
|
61
61
|
assertSelfTest(findLatestJsonl(jsonlDir, { sinceMs: 4_000, previousMtimes: snapshotJsonlMtimes(jsonlDir) }) === undefined, "JSONL discovery should not return stale evidence when current run has no changed JSONL");
|
|
62
|
+
writeFileSync(freshJsonl, `${JSON.stringify({ type: "message", message: { role: "user", content: "submitted prompt" } })}\n`, "utf8");
|
|
63
|
+
assertSelfTest(jsonlContainsUserPrompt(freshJsonl, "submitted prompt"), "JSONL prompt check should find the submitted user message");
|
|
64
|
+
assertSelfTest(!jsonlContainsUserPrompt(freshJsonl, "different prompt"), "JSONL prompt check should reject a different user message");
|
|
62
65
|
|
|
63
66
|
assertSelfTest(!sealedNodePath(process.execPath, "").includes(delimiter), "empty inherited PATH must not leave an empty PATH segment");
|
|
64
67
|
const hostilePath = `${binDir}${delimiter}${process.env.PATH ?? ""}`;
|
|
@@ -73,6 +76,7 @@ export function runVisualSmokeSelfTest(deps) {
|
|
|
73
76
|
cwd: ROOT,
|
|
74
77
|
mode: DEFAULT_MODE,
|
|
75
78
|
model: DEFAULT_MODEL,
|
|
79
|
+
prompt: "self-test prompt",
|
|
76
80
|
outDir: tempDir,
|
|
77
81
|
safeLabel: "self-test",
|
|
78
82
|
sessionDir: join(tempDir, "session"),
|
|
@@ -96,6 +100,7 @@ export function runVisualSmokeSelfTest(deps) {
|
|
|
96
100
|
assertSelfTest(plan.clearEnvNames.includes(name), `${name} must be cleared by default`);
|
|
97
101
|
}
|
|
98
102
|
assertSelfTest(plan.script.includes(shellQuote(fakePi)), "launch script must use resolved pi path");
|
|
103
|
+
assertSelfTest(!plan.script.includes(baseOptions.prompt), "launch script must keep the prompt out of the process command line");
|
|
99
104
|
assertSelfTest(!plan.script.includes(" exec pi "), "launch script must not use bare pi");
|
|
100
105
|
const hostileEnv = {
|
|
101
106
|
...process.env,
|
|
@@ -144,48 +149,18 @@ export function runVisualSmokeSelfTest(deps) {
|
|
|
144
149
|
assertSelfTest(!capturedEventDebugEnv.has("PI_CURSOR_SDK_EVENT_DEBUG_STDERR"), "stale event debug stderr flag should be cleared");
|
|
145
150
|
|
|
146
151
|
const fakeTmux = join(binDir, "tmux");
|
|
147
|
-
const deleteBufferMarker = join(tempDir, "delete-buffer-called");
|
|
148
|
-
writeFileSync(
|
|
149
|
-
fakeTmux,
|
|
150
|
-
`#!/bin/sh\ncase "$1" in\n -V) echo 'tmux fake'; exit 0 ;;\n new-session) exit 0 ;;\n load-buffer) cat >/dev/null; exit 0 ;;\n paste-buffer) exit 77 ;;\n delete-buffer) echo deleted > ${shellQuote(deleteBufferMarker)}; exit 0 ;;\n kill-session) exit 0 ;;\n *) echo "unexpected tmux command: $*" >&2; exit 64 ;;\nesac\n`,
|
|
151
|
-
"utf8",
|
|
152
|
-
);
|
|
153
|
-
chmodSync(fakeTmux, 0o755);
|
|
154
|
-
const originalPath = process.env.PATH;
|
|
155
|
-
try {
|
|
156
|
-
process.env.PATH = hostilePath;
|
|
157
|
-
let pasteFailed = false;
|
|
158
|
-
try {
|
|
159
|
-
runVisualSmoke({
|
|
160
|
-
...baseOptions,
|
|
161
|
-
prompt: "buffer cleanup prompt",
|
|
162
|
-
startupMs: 1,
|
|
163
|
-
waitMs: 1,
|
|
164
|
-
width: 80,
|
|
165
|
-
height: 24,
|
|
166
|
-
historyLines: 100,
|
|
167
|
-
});
|
|
168
|
-
} catch (error) {
|
|
169
|
-
pasteFailed = /paste-buffer failed/.test(error instanceof Error ? error.message : String(error));
|
|
170
|
-
}
|
|
171
|
-
assertSelfTest(pasteFailed, "fake tmux paste failure should exercise prompt-buffer cleanup path");
|
|
172
|
-
assertSelfTest(existsSync(deleteBufferMarker), "prompt tmux buffer should be deleted when paste/send fails");
|
|
173
|
-
} finally {
|
|
174
|
-
if (originalPath === undefined) delete process.env.PATH;
|
|
175
|
-
else process.env.PATH = originalPath;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
152
|
writeFileSync(
|
|
179
153
|
fakeTmux,
|
|
180
154
|
`#!/bin/sh
|
|
181
155
|
case "$1" in
|
|
182
156
|
-V) echo 'tmux fake'; exit 0 ;;
|
|
183
157
|
new-session) exit 0 ;;
|
|
158
|
+
set-option) exit 0 ;;
|
|
184
159
|
load-buffer) cat >/dev/null; exit 0 ;;
|
|
185
160
|
paste-buffer) exit 0 ;;
|
|
186
161
|
send-keys) exit 0 ;;
|
|
187
162
|
delete-buffer) exit 0 ;;
|
|
188
|
-
capture-pane)
|
|
163
|
+
capture-pane) printf 'captured visual smoke output\ncursor · fast:off\n'; exit 0 ;;
|
|
189
164
|
kill-session) exit 0 ;;
|
|
190
165
|
*) echo "unexpected tmux command: $*" >&2; exit 64 ;;
|
|
191
166
|
esac
|
|
@@ -194,6 +169,7 @@ esac
|
|
|
194
169
|
);
|
|
195
170
|
chmodSync(fakeTmux, 0o755);
|
|
196
171
|
const noJsonlManifest = join(tempDir, "self-test-jsonl-missing.manifest.json");
|
|
172
|
+
const originalPath = process.env.PATH;
|
|
197
173
|
try {
|
|
198
174
|
process.env.PATH = hostilePath;
|
|
199
175
|
let missingJsonlFailed = false;
|
|
@@ -14,7 +14,7 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
14
14
|
const DEFAULT_WIDTH = 150;
|
|
15
15
|
const DEFAULT_HEIGHT = 45;
|
|
16
16
|
const DEFAULT_WAIT_MS = 60_000;
|
|
17
|
-
const DEFAULT_STARTUP_MS =
|
|
17
|
+
const DEFAULT_STARTUP_MS = 15_000;
|
|
18
18
|
const DEFAULT_HISTORY_LINES = 3_000;
|
|
19
19
|
const DEFAULT_MODEL = "cursor/grok-4.6";
|
|
20
20
|
const DEFAULT_MODE = "plan";
|
|
@@ -33,15 +33,15 @@ Usage:
|
|
|
33
33
|
|
|
34
34
|
Required:
|
|
35
35
|
--label LABEL Artifact filename prefix. Sanitized for paths.
|
|
36
|
-
--prompt PROMPT Prompt to
|
|
36
|
+
--prompt PROMPT Prompt to submit in the interactive pi TUI.
|
|
37
37
|
Use --prompt-file PATH for multi-line prompts.
|
|
38
38
|
|
|
39
39
|
Common options:
|
|
40
40
|
--ext PATH Extension repo to load with pi -e. Default: repo root.
|
|
41
41
|
--cwd PATH Working directory for the pi session. Default: current directory.
|
|
42
42
|
--out-dir PATH Artifact directory. Default: /tmp/pi-cursor-sdk-visual-smoke-<timestamp>.
|
|
43
|
-
--wait-ms N Milliseconds to wait
|
|
44
|
-
--startup-ms N
|
|
43
|
+
--wait-ms N Milliseconds to wait for the response before capture. Default: ${DEFAULT_WAIT_MS}.
|
|
44
|
+
--startup-ms N Maximum milliseconds to wait for TUI readiness. Default: ${DEFAULT_STARTUP_MS}.
|
|
45
45
|
--model MODEL Cursor model. Default: ${DEFAULT_MODEL}.
|
|
46
46
|
--mode agent|plan Cursor SDK mode. Default: ${DEFAULT_MODE}.
|
|
47
47
|
--session-dir PATH pi session directory. Default: <out-dir>/<label>.session.
|
|
@@ -82,8 +82,8 @@ Artifacts written:
|
|
|
82
82
|
Prerequisites:
|
|
83
83
|
- pi, node, tmux, and npm-installed dev dependencies on PATH / in node_modules.
|
|
84
84
|
- The runner resolves pi/tmux from the parent PATH, uses process.execPath for node, and seals pi-shim PATH for prereq checks and tmux.
|
|
85
|
-
-
|
|
86
|
-
|
|
85
|
+
- Automatic PNG capture uses Playwright's Chromium or a system Chrome installation.
|
|
86
|
+
If neither is available, install Chromium once: npx playwright install chromium
|
|
87
87
|
- In the pi agent harness, --no-screenshot plus agent_browser on the generated HTML is also acceptable.
|
|
88
88
|
|
|
89
89
|
Examples:
|
|
@@ -340,6 +340,41 @@ function findLatestJsonl(root, { sinceMs = 0, previousMtimes = new Map() } = {})
|
|
|
340
340
|
return matches[0]?.path;
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
function jsonlContainsUserPrompt(path, prompt) {
|
|
344
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
345
|
+
if (!line.trim()) continue;
|
|
346
|
+
let entry;
|
|
347
|
+
try {
|
|
348
|
+
entry = JSON.parse(line);
|
|
349
|
+
} catch {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (entry?.type !== "message" || entry.message?.role !== "user") continue;
|
|
353
|
+
const content = entry.message.content;
|
|
354
|
+
const text = typeof content === "string"
|
|
355
|
+
? content
|
|
356
|
+
: Array.isArray(content)
|
|
357
|
+
? content.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("")
|
|
358
|
+
: "";
|
|
359
|
+
if (text === prompt) return true;
|
|
360
|
+
}
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function waitForCursorTui(commands, sessionName, timeoutMs) {
|
|
365
|
+
const deadline = Date.now() + timeoutMs;
|
|
366
|
+
do {
|
|
367
|
+
try {
|
|
368
|
+
const pane = capturePane(commands.tmux, sessionName, ["-p", "-S", "-100"]);
|
|
369
|
+
if (pane.includes("cursor ·")) return;
|
|
370
|
+
} catch {
|
|
371
|
+
// The pane can be unavailable briefly while tmux starts the shell.
|
|
372
|
+
}
|
|
373
|
+
sleep(100);
|
|
374
|
+
} while (Date.now() < deadline);
|
|
375
|
+
throw new Error(`Cursor TUI did not become ready within ${timeoutMs}ms`);
|
|
376
|
+
}
|
|
377
|
+
|
|
343
378
|
function checkLeftovers(patterns) {
|
|
344
379
|
if (patterns.length === 0) return;
|
|
345
380
|
const result = run("ps", ["-axo", "pid,etime,command"]);
|
|
@@ -465,18 +500,19 @@ function runVisualSmoke(options) {
|
|
|
465
500
|
]);
|
|
466
501
|
if (start.status !== 0) throw new Error(`tmux new-session failed: ${start.stderr?.toString().trim() || start.status}`);
|
|
467
502
|
sessionStarted = true;
|
|
503
|
+
const remainOnExit = run(commands.tmux, ["set-option", "-t", sessionName, "remain-on-exit", "on"]);
|
|
504
|
+
if (remainOnExit.status !== 0) throw new Error(`tmux remain-on-exit setup failed: ${remainOnExit.stderr?.toString().trim() || remainOnExit.status}`);
|
|
468
505
|
|
|
469
|
-
|
|
506
|
+
waitForCursorTui(commands, sessionName, options.startupMs);
|
|
470
507
|
const load = run(commands.tmux, ["load-buffer", "-b", bufferName, "-"], { input: Buffer.from(options.prompt, "utf8") });
|
|
471
508
|
if (load.status !== 0) throw new Error(`tmux load-buffer failed: ${load.stderr?.toString().trim() || load.status}`);
|
|
472
509
|
bufferLoaded = true;
|
|
473
510
|
try {
|
|
474
|
-
const paste = run(commands.tmux, ["paste-buffer", "-b", bufferName, "-t", sessionName]);
|
|
511
|
+
const paste = run(commands.tmux, ["paste-buffer", "-p", "-S", "-b", bufferName, "-t", sessionName]);
|
|
475
512
|
if (paste.status !== 0) throw new Error(`tmux paste-buffer failed: ${paste.stderr?.toString().trim() || paste.status}`);
|
|
476
|
-
// Give bracketed paste handling a moment to finish before submitting.
|
|
477
513
|
sleep(250);
|
|
478
|
-
const
|
|
479
|
-
if (
|
|
514
|
+
const submit = run(commands.tmux, ["send-keys", "-t", sessionName, "-H", "0d"]);
|
|
515
|
+
if (submit.status !== 0) throw new Error(`tmux prompt submit failed: ${submit.stderr?.toString().trim() || submit.status}`);
|
|
480
516
|
} finally {
|
|
481
517
|
run(commands.tmux, ["delete-buffer", "-b", bufferName]);
|
|
482
518
|
bufferLoaded = false;
|
|
@@ -507,6 +543,11 @@ function runVisualSmoke(options) {
|
|
|
507
543
|
writeVisualManifest(manifestPath, options, partialArtifacts, { message, writtenAt: new Date().toISOString() });
|
|
508
544
|
throw new Error(message);
|
|
509
545
|
}
|
|
546
|
+
if (!jsonlContainsUserPrompt(jsonlPath, options.prompt)) {
|
|
547
|
+
const message = "current-run JSONL does not contain the submitted prompt";
|
|
548
|
+
writeVisualManifest(manifestPath, options, { ...partialArtifacts, jsonlPath }, { message, writtenAt: new Date().toISOString() });
|
|
549
|
+
throw new Error(message);
|
|
550
|
+
}
|
|
510
551
|
writeUtf8(jsonlPathFile, `${jsonlPath}\n`);
|
|
511
552
|
|
|
512
553
|
return { ...partialArtifacts, jsonlPath };
|
|
@@ -531,6 +572,8 @@ try {
|
|
|
531
572
|
parseArgs,
|
|
532
573
|
snapshotJsonlMtimes,
|
|
533
574
|
findLatestJsonl,
|
|
575
|
+
jsonlContainsUserPrompt,
|
|
576
|
+
waitForCursorTui,
|
|
534
577
|
sealedNodePath,
|
|
535
578
|
resolveCommand,
|
|
536
579
|
requireNode,
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ExtensionHandler, SessionShutdownEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* First factory invocation in this process owns Cursor's process-global
|
|
5
|
+
* registrars (bridge, session scope, pooled SDK agent). Pi child sessions call
|
|
6
|
+
* `createAgentSession` + `bindExtensions` in the same process, which re-invokes
|
|
7
|
+
* this factory against a new ExtensionAPI while the parent Cursor run is still
|
|
8
|
+
* live. A nested factory must not re-register.
|
|
9
|
+
*
|
|
10
|
+
* Ownership ends after the owner's shutdown handlers finish. Pi then reloads
|
|
11
|
+
* the extension for `new`, `resume`, `fork`, and explicit reload operations.
|
|
12
|
+
*/
|
|
13
|
+
export type CursorExtensionFactoryClaim =
|
|
14
|
+
| { kind: "owner"; token: symbol }
|
|
15
|
+
| { kind: "nested" };
|
|
16
|
+
|
|
17
|
+
export interface CursorExtensionFactoryGuardApi {
|
|
18
|
+
on(event: "session_shutdown", handler: ExtensionHandler<SessionShutdownEvent>): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let factoryOwnerToken: symbol | undefined;
|
|
22
|
+
|
|
23
|
+
export function claimCursorExtensionFactory(): CursorExtensionFactoryClaim {
|
|
24
|
+
if (factoryOwnerToken) return { kind: "nested" };
|
|
25
|
+
const token = Symbol("cursor-extension-factory-owner");
|
|
26
|
+
factoryOwnerToken = token;
|
|
27
|
+
return { kind: "owner", token };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function releaseCursorExtensionFactory(token: symbol): void {
|
|
31
|
+
if (factoryOwnerToken === token) {
|
|
32
|
+
factoryOwnerToken = undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Install the owner's release handler after all other factory registration
|
|
38
|
+
* succeeds. The final handler keeps ownership until earlier shutdown cleanup
|
|
39
|
+
* has finished.
|
|
40
|
+
*/
|
|
41
|
+
export function registerCursorExtensionFactoryRelease(
|
|
42
|
+
pi: CursorExtensionFactoryGuardApi,
|
|
43
|
+
claim: Extract<CursorExtensionFactoryClaim, { kind: "owner" }>,
|
|
44
|
+
): void {
|
|
45
|
+
pi.on("session_shutdown", () => {
|
|
46
|
+
releaseCursorExtensionFactory(claim.token);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const __testUtils = {
|
|
51
|
+
isInstalled(): boolean {
|
|
52
|
+
return factoryOwnerToken !== undefined;
|
|
53
|
+
},
|
|
54
|
+
reset(): void {
|
|
55
|
+
factoryOwnerToken = undefined;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -64,6 +64,10 @@ export class CursorPiToolBridgeRegistry implements CursorPiToolBridge {
|
|
|
64
64
|
return run;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
hasLiveRuns(): boolean {
|
|
68
|
+
return this.runs.size > 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
67
71
|
async disposeAll(reason = "Cursor pi tool bridge disposed"): Promise<void> {
|
|
68
72
|
await Promise.all([...this.runs].map(async (run) => {
|
|
69
73
|
run.cancel(reason);
|
|
@@ -89,6 +89,11 @@ Get-CimInstance Win32_Process -Filter "Name = 'bash.exe' OR Name = 'sh.exe'" |
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
export function registerCursorPiToolBridge(pi: CursorPiToolBridgeExtensionApi): CursorPiToolBridge {
|
|
92
|
+
// Replacing a bridge during a live MCP run cancels its pending pi tool
|
|
93
|
+
// calls. Keep the active registry as a final safety belt.
|
|
94
|
+
if (registeredCursorPiToolBridge?.hasLiveRuns()) {
|
|
95
|
+
return registeredCursorPiToolBridge;
|
|
96
|
+
}
|
|
92
97
|
bridgeToolExecutionAbortTracker.abortAll("Cursor pi tool bridge extension reloaded");
|
|
93
98
|
void registeredCursorPiToolBridge?.disposeAll("Cursor pi tool bridge extension reloaded");
|
|
94
99
|
const bridge = new CursorPiToolBridgeRegistry(pi);
|
package/src/index.ts
CHANGED
|
@@ -16,6 +16,11 @@ import { registerCursorAgentsContextDedup } from "./cursor-agents-context-regist
|
|
|
16
16
|
import { registerCursorOverflowNormalization } from "./cursor-provider-overflow.js";
|
|
17
17
|
import { registerCursorSdkSessionProcessErrorGuard } from "./cursor-sdk-process-error-guard.js";
|
|
18
18
|
import { prepareCursorSessionForCompaction } from "./cursor-session-compaction-prep.js";
|
|
19
|
+
import {
|
|
20
|
+
claimCursorExtensionFactory,
|
|
21
|
+
registerCursorExtensionFactoryRelease,
|
|
22
|
+
releaseCursorExtensionFactory,
|
|
23
|
+
} from "./cursor-extension-factory-guard.js";
|
|
19
24
|
|
|
20
25
|
type CursorExtensionApi =
|
|
21
26
|
& Pick<ExtensionAPI, "registerProvider" | "registerCommand" | "on">
|
|
@@ -31,7 +36,8 @@ type CursorExtensionApi =
|
|
|
31
36
|
& Parameters<typeof registerCursorFallbackIssueWarning>[0]
|
|
32
37
|
& Parameters<typeof registerCursorAgentsContextDedup>[0]
|
|
33
38
|
& Parameters<typeof registerCursorOverflowNormalization>[0]
|
|
34
|
-
& Parameters<typeof registerCursorSdkSessionProcessErrorGuard>[0]
|
|
39
|
+
& Parameters<typeof registerCursorSdkSessionProcessErrorGuard>[0]
|
|
40
|
+
& Parameters<typeof registerCursorExtensionFactoryRelease>[0];
|
|
35
41
|
|
|
36
42
|
function createCursorProviderConfig(models: ProviderModelConfig[]): ProviderConfig {
|
|
37
43
|
return {
|
|
@@ -49,55 +55,70 @@ function registerCursorProvider(pi: Pick<ExtensionAPI, "registerProvider">, mode
|
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export default async function (pi: CursorExtensionApi) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
registerCursorSessionAgentLineage(pi);
|
|
55
|
-
registerCursorSessionAgentLifecycle(pi);
|
|
56
|
-
registerCursorSessionAgentResume(pi);
|
|
57
|
-
pi.on("session_before_compact", async () => {
|
|
58
|
-
await prepareCursorSessionForCompaction();
|
|
59
|
-
});
|
|
60
|
-
registerCursorRuntimeControls(pi);
|
|
61
|
-
registerCursorNativeToolDisplay(pi);
|
|
62
|
-
registerCursorQuestionTool(pi);
|
|
63
|
-
registerCursorSkillTool(pi);
|
|
64
|
-
registerCursorPiToolBridge(pi);
|
|
65
|
-
registerCursorAgentsContextDedup(pi);
|
|
66
|
-
registerCursorOverflowNormalization(pi);
|
|
67
|
-
let fallbackIssue: CursorModelFallbackIssue | undefined;
|
|
68
|
-
const models = await discoverModels({
|
|
69
|
-
onFallback: (issue) => {
|
|
70
|
-
fallbackIssue = issue;
|
|
71
|
-
},
|
|
72
|
-
});
|
|
58
|
+
const factoryClaim = claimCursorExtensionFactory();
|
|
59
|
+
if (factoryClaim.kind === "nested") return;
|
|
73
60
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
61
|
+
try {
|
|
62
|
+
// Discover first. A discovery failure must not leave process-global
|
|
63
|
+
// registrars from a discarded extension load.
|
|
64
|
+
let fallbackIssue: CursorModelFallbackIssue | undefined;
|
|
65
|
+
const models = await discoverModels({
|
|
66
|
+
onFallback: (issue) => {
|
|
67
|
+
fallbackIssue = issue;
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Session cwd must register before other session_start listeners that depend on it.
|
|
72
|
+
registerCursorSessionScope(pi);
|
|
73
|
+
registerCursorSessionAgentLineage(pi);
|
|
74
|
+
registerCursorSessionAgentLifecycle(pi);
|
|
75
|
+
registerCursorSessionAgentResume(pi);
|
|
76
|
+
pi.on("session_before_compact", async () => {
|
|
77
|
+
await prepareCursorSessionForCompaction();
|
|
78
|
+
});
|
|
79
|
+
registerCursorRuntimeControls(pi);
|
|
80
|
+
registerCursorNativeToolDisplay(pi);
|
|
81
|
+
registerCursorQuestionTool(pi);
|
|
82
|
+
registerCursorSkillTool(pi);
|
|
83
|
+
registerCursorPiToolBridge(pi);
|
|
84
|
+
registerCursorAgentsContextDedup(pi);
|
|
85
|
+
registerCursorOverflowNormalization(pi);
|
|
77
86
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
let refreshFallbackIssue: CursorModelFallbackIssue | undefined;
|
|
82
|
-
const apiKey = resolveCursorApiKey(await ctx.modelRegistry.getApiKeyForProvider("cursor"));
|
|
83
|
-
const refreshedModels = await discoverModels({
|
|
84
|
-
apiKey,
|
|
85
|
-
forceRefresh: true,
|
|
86
|
-
onFallback: (issue) => {
|
|
87
|
-
refreshFallbackIssue = issue;
|
|
88
|
-
},
|
|
89
|
-
});
|
|
90
|
-
registerCursorProvider(pi, refreshedModels);
|
|
91
|
-
if (!ctx.hasUI) return;
|
|
92
|
-
if (refreshFallbackIssue) {
|
|
93
|
-
ctx.ui.notify(`Cursor model catalog refresh did not use a live catalog: ${refreshFallbackIssue.message}`, "warning");
|
|
94
|
-
} else {
|
|
95
|
-
ctx.ui.notify(`Cursor model catalog refreshed with ${refreshedModels.length} model${refreshedModels.length === 1 ? "" : "s"}.`, "info");
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
});
|
|
87
|
+
if (fallbackIssue) {
|
|
88
|
+
registerCursorFallbackIssueWarning(pi, fallbackIssue);
|
|
89
|
+
}
|
|
99
90
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
91
|
+
pi.registerCommand("cursor-refresh-models", {
|
|
92
|
+
description: "Refresh the live Cursor model catalog without restarting pi",
|
|
93
|
+
handler: async (_args, ctx) => {
|
|
94
|
+
let refreshFallbackIssue: CursorModelFallbackIssue | undefined;
|
|
95
|
+
const apiKey = resolveCursorApiKey(await ctx.modelRegistry.getApiKeyForProvider("cursor"));
|
|
96
|
+
const refreshedModels = await discoverModels({
|
|
97
|
+
apiKey,
|
|
98
|
+
forceRefresh: true,
|
|
99
|
+
onFallback: (issue) => {
|
|
100
|
+
refreshFallbackIssue = issue;
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
registerCursorProvider(pi, refreshedModels);
|
|
104
|
+
if (!ctx.hasUI) return;
|
|
105
|
+
if (refreshFallbackIssue) {
|
|
106
|
+
ctx.ui.notify(`Cursor model catalog refresh did not use a live catalog: ${refreshFallbackIssue.message}`, "warning");
|
|
107
|
+
} else {
|
|
108
|
+
ctx.ui.notify(`Cursor model catalog refreshed with ${refreshedModels.length} model${refreshedModels.length === 1 ? "" : "s"}.`, "info");
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
registerCursorProvider(pi, models);
|
|
114
|
+
// Keep the process error guard near the end so earlier Cursor cleanup
|
|
115
|
+
// remains protected during session shutdown.
|
|
116
|
+
registerCursorSdkSessionProcessErrorGuard(pi);
|
|
117
|
+
// Register last so ownership remains protected until all other Cursor
|
|
118
|
+
// session_shutdown handlers finish.
|
|
119
|
+
registerCursorExtensionFactoryRelease(pi, factoryClaim);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
releaseCursorExtensionFactory(factoryClaim.token);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
103
124
|
}
|