@evanovation/open-cursor 2.4.15
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/LICENSE +28 -0
- package/README.md +270 -0
- package/dist/cli/discover.js +527 -0
- package/dist/cli/mcptool.js +10339 -0
- package/dist/cli/opencode-cursor.js +2989 -0
- package/dist/index.js +20588 -0
- package/dist/plugin-entry.js +19848 -0
- package/package.json +82 -0
- package/scripts/cursor-agent-runner.mjs +272 -0
- package/scripts/sdk-runner.mjs +412 -0
- package/src/acp/metrics.ts +83 -0
- package/src/acp/sessions.ts +107 -0
- package/src/acp/tools.ts +209 -0
- package/src/auth.ts +175 -0
- package/src/cli/discover.ts +53 -0
- package/src/cli/mcptool.ts +133 -0
- package/src/cli/model-discovery.ts +71 -0
- package/src/cli/opencode-cursor.ts +1195 -0
- package/src/client/cursor-agent-child.ts +459 -0
- package/src/client/sdk-child.ts +550 -0
- package/src/client/simple.ts +293 -0
- package/src/commands/status.ts +39 -0
- package/src/index.ts +39 -0
- package/src/mcp/client-manager.ts +166 -0
- package/src/mcp/config.ts +169 -0
- package/src/mcp/tool-bridge.ts +133 -0
- package/src/models/config.ts +64 -0
- package/src/models/discovery.ts +105 -0
- package/src/models/index.ts +3 -0
- package/src/models/pricing.ts +196 -0
- package/src/models/sync.ts +247 -0
- package/src/models/types.ts +11 -0
- package/src/models/variants.ts +446 -0
- package/src/plugin-entry.ts +28 -0
- package/src/plugin-toggle.ts +81 -0
- package/src/plugin.ts +2802 -0
- package/src/provider/backend.ts +71 -0
- package/src/provider/boundary.ts +168 -0
- package/src/provider/passthrough-tracker.ts +38 -0
- package/src/provider/runtime-interception.ts +818 -0
- package/src/provider/tool-loop-guard.ts +644 -0
- package/src/provider/tool-schema-compat.ts +800 -0
- package/src/provider.ts +268 -0
- package/src/proxy/formatter.ts +60 -0
- package/src/proxy/handler.ts +29 -0
- package/src/proxy/incremental-prompt.ts +74 -0
- package/src/proxy/prompt-builder.ts +204 -0
- package/src/proxy/server.ts +207 -0
- package/src/proxy/session-resume.ts +312 -0
- package/src/proxy/tool-loop.ts +359 -0
- package/src/proxy/types.ts +13 -0
- package/src/services/toast-service.ts +81 -0
- package/src/streaming/ai-sdk-parts.ts +109 -0
- package/src/streaming/delta-tracker.ts +89 -0
- package/src/streaming/line-buffer.ts +44 -0
- package/src/streaming/openai-sse.ts +118 -0
- package/src/streaming/parser.ts +22 -0
- package/src/streaming/types.ts +158 -0
- package/src/tools/core/executor.ts +25 -0
- package/src/tools/core/registry.ts +27 -0
- package/src/tools/core/types.ts +31 -0
- package/src/tools/defaults.ts +954 -0
- package/src/tools/discovery.ts +140 -0
- package/src/tools/executors/cli.ts +59 -0
- package/src/tools/executors/local.ts +25 -0
- package/src/tools/executors/mcp.ts +39 -0
- package/src/tools/executors/sdk.ts +39 -0
- package/src/tools/index.ts +8 -0
- package/src/tools/registry.ts +34 -0
- package/src/tools/router.ts +123 -0
- package/src/tools/schema.ts +58 -0
- package/src/tools/skills/loader.ts +61 -0
- package/src/tools/skills/resolver.ts +21 -0
- package/src/tools/types.ts +29 -0
- package/src/types.ts +8 -0
- package/src/usage.ts +112 -0
- package/src/utils/binary.ts +71 -0
- package/src/utils/errors.ts +224 -0
- package/src/utils/logger.ts +191 -0
- package/src/utils/perf.ts +76 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sdk-runner.mjs
|
|
4
|
+
*
|
|
5
|
+
* Persistent Node.js runner for @cursor/sdk Agent.
|
|
6
|
+
* Reads NDJSON lines from stdin: {"id":"<string>","model":"...","cwd":"...","prompt":"..."}
|
|
7
|
+
* For each request, spawns/reuses an Agent and emits wrapped events to stdout:
|
|
8
|
+
* {"id":"<id>","event":{...StreamJsonEvent...}}
|
|
9
|
+
* When request completes:
|
|
10
|
+
* {"id":"<id>","done":true,"exitCode":0|1}
|
|
11
|
+
*
|
|
12
|
+
* OPERATIONS:
|
|
13
|
+
* - default: {"id","model","cwd","prompt"} -> runs a fresh Agent per request
|
|
14
|
+
* (no caching: conversation state must stay in OpenCode, see handleRequest)
|
|
15
|
+
* - {"id","op":"listModels"} -> emits {"type":"models","models":[{id,name}]}
|
|
16
|
+
*
|
|
17
|
+
* ENVIRONMENT VARIABLES:
|
|
18
|
+
* - CURSOR_API_KEY: Required. API key from cursor.com/settings.
|
|
19
|
+
* - CURSOR_ACP_SETTING_SOURCES: (optional) CSV of setting sources to load.
|
|
20
|
+
* Defaults to empty (isolated mode: no Cursor env rules/skills/MCP per request).
|
|
21
|
+
* Examples: "all" (load everything), "user,project" (load user + project rules).
|
|
22
|
+
* See @cursor/sdk SettingSource type: "project"|"user"|"team"|"mdm"|"plugins"|"all".
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* echo '{"id":"r1","model":"auto","cwd":".","prompt":"hello"}' | CURSOR_API_KEY=... node sdk-runner.mjs
|
|
26
|
+
* CURSOR_ACP_SETTING_SOURCES="user,project" CURSOR_API_KEY=... node sdk-runner.mjs < requests.ndjson
|
|
27
|
+
*
|
|
28
|
+
* Output: NDJSON wrapped events to stdout (one per line).
|
|
29
|
+
* Diagnostics and timings: console.error only (never stdout).
|
|
30
|
+
* Lifecycle: reads stdin indefinitely; on EOF, disposes agents and exits 0.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { pathToFileURL } from "node:url";
|
|
34
|
+
|
|
35
|
+
// Import Agent and Cursor dynamically after API key check to accelerate boot time
|
|
36
|
+
let Agent;
|
|
37
|
+
let Cursor;
|
|
38
|
+
|
|
39
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const STREAM_JSON_EVENT_BUFFER_SIZE = 64 * 1024; // 64KB for line buffering
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Parse CURSOR_ACP_SETTING_SOURCES env var (comma-separated, space-trimmed).
|
|
45
|
+
* If undefined or empty, return [] (isolated: no Cursor env rules/skills/MCP per request).
|
|
46
|
+
* Examples: "all" → ["all"], "user,project" → ["user","project"], "" → [].
|
|
47
|
+
*/
|
|
48
|
+
const SETTING_SOURCES = (() => {
|
|
49
|
+
const raw = process.env.CURSOR_ACP_SETTING_SOURCES ?? "";
|
|
50
|
+
if (!raw.trim()) return [];
|
|
51
|
+
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
52
|
+
})();
|
|
53
|
+
|
|
54
|
+
// ─── Protocol stdout protection ─────────────────────────────────────────────
|
|
55
|
+
// The Cursor SDK writes its own internal logs to process.stdout, which would
|
|
56
|
+
// pollute our NDJSON protocol. Redirect any stdout writes that don't come from
|
|
57
|
+
// our emit helpers to stderr, and keep a private handle to the real stdout.
|
|
58
|
+
const protocolWrite = process.stdout.write.bind(process.stdout);
|
|
59
|
+
const RUNNING_AS_MAIN = process.argv[1]
|
|
60
|
+
? import.meta.url === pathToFileURL(process.argv[1]).href
|
|
61
|
+
: false;
|
|
62
|
+
if (RUNNING_AS_MAIN) {
|
|
63
|
+
process.stdout.write = (chunk, ...args) => process.stderr.write(chunk, ...args);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Write a line to the real (protocol) stdout.
|
|
68
|
+
*/
|
|
69
|
+
function writeProtocolLine(line) {
|
|
70
|
+
return protocolWrite(line);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Convert SDK message to StreamJsonEvent (portable copy from sdk-child.ts).
|
|
77
|
+
*/
|
|
78
|
+
export function namespaceMcpTool(serverName, toolName) {
|
|
79
|
+
const sanitizedServer = String(serverName).replace(/[^a-zA-Z0-9]/g, "_");
|
|
80
|
+
const sanitizedTool = String(toolName).replace(/[^a-zA-Z0-9]/g, "_");
|
|
81
|
+
return `mcp__${sanitizedServer}__${sanitizedTool}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function sdkMessageToStreamJson(msg) {
|
|
85
|
+
switch (msg?.type) {
|
|
86
|
+
case "assistant": {
|
|
87
|
+
const content = msg.message?.content ?? [];
|
|
88
|
+
const textBlocks = content.filter((b) => b.type === "text");
|
|
89
|
+
if (textBlocks.length === 0) return null;
|
|
90
|
+
return {
|
|
91
|
+
type: "assistant",
|
|
92
|
+
message: {
|
|
93
|
+
role: "assistant",
|
|
94
|
+
content: textBlocks.map((b) => ({
|
|
95
|
+
type: "text",
|
|
96
|
+
text: b.text,
|
|
97
|
+
})),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
case "thinking":
|
|
102
|
+
if (!msg.text) return null;
|
|
103
|
+
return {
|
|
104
|
+
type: "thinking",
|
|
105
|
+
subtype: "delta",
|
|
106
|
+
text: msg.text,
|
|
107
|
+
timestamp_ms: msg.thinking_duration_ms,
|
|
108
|
+
};
|
|
109
|
+
case "tool_call": {
|
|
110
|
+
let name = msg.name;
|
|
111
|
+
let args = msg.args;
|
|
112
|
+
// The Cursor SDK emits MCP tool calls as a generic tool named "mcp"
|
|
113
|
+
// with {providerIdentifier, toolName, args} inside. Remap to the
|
|
114
|
+
// namespaced name OpenCode expects (mcp__<server>__<tool>) so the
|
|
115
|
+
// tool-loop can intercept and execute it instead of failing with
|
|
116
|
+
// "unavailable tool 'mcp'".
|
|
117
|
+
if (name === "mcp" && args && typeof args === "object") {
|
|
118
|
+
const provider = args.providerIdentifier;
|
|
119
|
+
const toolName = args.toolName;
|
|
120
|
+
if (provider && toolName) {
|
|
121
|
+
name = namespaceMcpTool(provider, toolName);
|
|
122
|
+
args = args.args ?? {};
|
|
123
|
+
console.error(`[sdk-runner] Remapped mcp tool call -> ${name}`);
|
|
124
|
+
} else {
|
|
125
|
+
console.error(
|
|
126
|
+
`[sdk-runner] mcp tool call missing provider/toolName: ${JSON.stringify(msg.args).slice(0, 200)}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
type: "tool_call",
|
|
132
|
+
call_id: msg.call_id,
|
|
133
|
+
tool_call: {
|
|
134
|
+
[name]: {
|
|
135
|
+
args,
|
|
136
|
+
result: msg.result,
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
case "status": {
|
|
142
|
+
const status = msg.status;
|
|
143
|
+
if (status === "FINISHED") return { type: "result", subtype: "success" };
|
|
144
|
+
if (status === "ERROR")
|
|
145
|
+
return {
|
|
146
|
+
type: "result",
|
|
147
|
+
subtype: "error",
|
|
148
|
+
is_error: true,
|
|
149
|
+
error: { message: msg.message ?? "SDK error" },
|
|
150
|
+
};
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
case "system":
|
|
154
|
+
return {
|
|
155
|
+
type: "system",
|
|
156
|
+
subtype: msg.subtype,
|
|
157
|
+
};
|
|
158
|
+
default:
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Emit a wrapped NDJSON error event to stdout (per-request).
|
|
165
|
+
*/
|
|
166
|
+
function emitErrorEvent(id, message) {
|
|
167
|
+
const event = {
|
|
168
|
+
type: "result",
|
|
169
|
+
subtype: "error",
|
|
170
|
+
is_error: true,
|
|
171
|
+
error: { message },
|
|
172
|
+
};
|
|
173
|
+
writeProtocolLine(JSON.stringify({ id, event }) + "\n");
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Emit request completion marker.
|
|
178
|
+
*/
|
|
179
|
+
function emitDone(id, exitCode = 0) {
|
|
180
|
+
writeProtocolLine(JSON.stringify({ id, done: true, exitCode }) + "\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Emit a wrapped NDJSON event.
|
|
185
|
+
*/
|
|
186
|
+
function emitEvent(id, event) {
|
|
187
|
+
writeProtocolLine(JSON.stringify({ id, event }) + "\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── List Models Handler ───────────────────────────────────────────────────
|
|
191
|
+
/**
|
|
192
|
+
* Handle a listModels request: call Cursor.models.list() and emit wrapped events.
|
|
193
|
+
*/
|
|
194
|
+
async function handleListModels(id) {
|
|
195
|
+
try {
|
|
196
|
+
console.error(`[sdk-runner] listModels request ${id}`);
|
|
197
|
+
|
|
198
|
+
const models = await Cursor.models.list();
|
|
199
|
+
|
|
200
|
+
const modelList = models.map((m) => ({
|
|
201
|
+
id: m.id,
|
|
202
|
+
name: m.displayName || m.id,
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
const event = {
|
|
206
|
+
type: "models",
|
|
207
|
+
models: modelList,
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
emitEvent(id, event);
|
|
211
|
+
console.error(`[sdk-runner] listModels request ${id} complete (${models.length} models)`);
|
|
212
|
+
emitDone(id, 0);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
215
|
+
console.error(`[sdk-runner] listModels request ${id} error: ${message}`);
|
|
216
|
+
emitErrorEvent(id, message);
|
|
217
|
+
emitDone(id, 1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ─── Request Handler ────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Handle a single request: execute the prompt and emit wrapped events.
|
|
225
|
+
*/
|
|
226
|
+
async function handleRequest(apiKey, request) {
|
|
227
|
+
const { id, model, cwd, prompt } = request;
|
|
228
|
+
|
|
229
|
+
// Validate required fields
|
|
230
|
+
if (!id || !model || !cwd || !prompt) {
|
|
231
|
+
console.error(`[sdk-runner] Invalid request missing fields:`, request);
|
|
232
|
+
emitErrorEvent(id || "unknown", "Missing required fields: id, model, cwd, prompt");
|
|
233
|
+
emitDone(id || "unknown", 1);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.error(`[sdk-runner] Request ${id}: model=${model}, cwd=${cwd}`);
|
|
238
|
+
|
|
239
|
+
// NOTE: a fresh Agent is created per request (NOT cached/reused).
|
|
240
|
+
// The proxy sends the full conversation history in every prompt, so reusing
|
|
241
|
+
// an Agent would duplicate context across requests and leak conversation
|
|
242
|
+
// state between independent OpenCode sessions. Concurrent requests on a
|
|
243
|
+
// shared Agent would also interleave. The persistent process still saves
|
|
244
|
+
// the Node boot + SDK import cost (~2-3s) on every request after the first.
|
|
245
|
+
let agent = null;
|
|
246
|
+
const timelineStart = Date.now();
|
|
247
|
+
try {
|
|
248
|
+
// Timing: Agent.create
|
|
249
|
+
const createStart = Date.now();
|
|
250
|
+
agent = await Agent.create({
|
|
251
|
+
apiKey,
|
|
252
|
+
model: { id: model },
|
|
253
|
+
mode: "agent",
|
|
254
|
+
local: { cwd, settingSources: SETTING_SOURCES },
|
|
255
|
+
});
|
|
256
|
+
const createMs = Date.now() - createStart;
|
|
257
|
+
console.error(`[sdk-runner] Agent ready, sending prompt for request ${id}`);
|
|
258
|
+
|
|
259
|
+
// Timing: agent.send() until first event
|
|
260
|
+
const sendStart = Date.now();
|
|
261
|
+
const run = await agent.send(prompt);
|
|
262
|
+
|
|
263
|
+
let sawFinished = false;
|
|
264
|
+
let eventCount = 0;
|
|
265
|
+
let firstEventMs = null;
|
|
266
|
+
|
|
267
|
+
console.error(`[sdk-runner] Streaming events for request ${id}...`);
|
|
268
|
+
for await (const msg of run.stream()) {
|
|
269
|
+
// Capture timing of first event
|
|
270
|
+
if (firstEventMs === null) {
|
|
271
|
+
firstEventMs = Date.now() - sendStart;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (++eventCount <= 3 || eventCount % 50 === 0) {
|
|
275
|
+
console.error(`[sdk-runner] Request ${id} event ${eventCount}: type=${msg?.type}`);
|
|
276
|
+
}
|
|
277
|
+
const event = sdkMessageToStreamJson(msg);
|
|
278
|
+
if (!event) continue;
|
|
279
|
+
if (event.type === "result") sawFinished = true;
|
|
280
|
+
emitEvent(id, event);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Ensure we emit a result event
|
|
284
|
+
if (!sawFinished) {
|
|
285
|
+
const successEvent = { type: "result", subtype: "success" };
|
|
286
|
+
emitEvent(id, successEvent);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const totalMs = Date.now() - timelineStart;
|
|
290
|
+
console.error(`[sdk-runner] Request ${id} complete (${eventCount} events)`);
|
|
291
|
+
console.error(`[sdk-runner] timings ${id}: create=${createMs}ms firstEvent=${firstEventMs ?? "N/A"}ms total=${totalMs}ms`);
|
|
292
|
+
emitDone(id, 0);
|
|
293
|
+
} catch (error) {
|
|
294
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
295
|
+
const totalMs = Date.now() - timelineStart;
|
|
296
|
+
console.error(`[sdk-runner] Request ${id} error: ${message}`);
|
|
297
|
+
console.error(`[sdk-runner] timings ${id}: total=${totalMs}ms (error)`);
|
|
298
|
+
emitErrorEvent(id, message);
|
|
299
|
+
emitDone(id, 1);
|
|
300
|
+
} finally {
|
|
301
|
+
if (agent) {
|
|
302
|
+
await agent[Symbol.asyncDispose]?.().catch(() => {});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
async function main() {
|
|
310
|
+
try {
|
|
311
|
+
// Check API key early before import
|
|
312
|
+
const apiKey = process.env.CURSOR_API_KEY;
|
|
313
|
+
if (!apiKey || !apiKey.trim()) {
|
|
314
|
+
// Can't emit wrapped error since we're not in a request context
|
|
315
|
+
// Just exit early; the parent will timeout or detect EOF
|
|
316
|
+
console.error("[sdk-runner] CURSOR_API_KEY not set");
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Log settingSources config at boot
|
|
321
|
+
console.error(`[sdk-runner] settingSources: ${JSON.stringify(SETTING_SOURCES)}`);
|
|
322
|
+
|
|
323
|
+
// Import Agent dynamically now that API key is validated
|
|
324
|
+
// This accelerates boot time if the runner is forked without a valid key
|
|
325
|
+
try {
|
|
326
|
+
const sdkModule = await import("@cursor/sdk");
|
|
327
|
+
Agent = sdkModule.Agent;
|
|
328
|
+
Cursor = sdkModule.Cursor;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
console.error(`[sdk-runner] Failed to import @cursor/sdk: ${err.message}`);
|
|
331
|
+
console.error("[sdk-runner] Note: sqlite3 native bindings may be incompatible with this platform");
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Persistent loop: dispatch each NDJSON line from stdin AS IT ARRIVES.
|
|
336
|
+
// Requests run concurrently (OpenCode fires e.g. title-gen + chat at once).
|
|
337
|
+
console.error("[sdk-runner] Waiting for requests on stdin...");
|
|
338
|
+
|
|
339
|
+
const inFlight = new Set();
|
|
340
|
+
|
|
341
|
+
const dispatch = (request) => {
|
|
342
|
+
let p;
|
|
343
|
+
if (request.op === "listModels") {
|
|
344
|
+
// Handle listModels operation
|
|
345
|
+
p = handleListModels(request.id)
|
|
346
|
+
.catch((err) => {
|
|
347
|
+
const id = request?.id || "unknown";
|
|
348
|
+
console.error(`[sdk-runner] Unhandled error in listModels ${id}: ${err.message}`);
|
|
349
|
+
emitErrorEvent(id, `Unhandled error: ${err.message}`);
|
|
350
|
+
emitDone(id, 1);
|
|
351
|
+
})
|
|
352
|
+
.finally(() => inFlight.delete(p));
|
|
353
|
+
} else {
|
|
354
|
+
// Handle regular agent request
|
|
355
|
+
p = handleRequest(apiKey, request)
|
|
356
|
+
.catch((err) => {
|
|
357
|
+
const id = request?.id || "unknown";
|
|
358
|
+
console.error(`[sdk-runner] Unhandled error processing request ${id}: ${err.message}`);
|
|
359
|
+
emitErrorEvent(id, `Unhandled error: ${err.message}`);
|
|
360
|
+
emitDone(id, 1);
|
|
361
|
+
})
|
|
362
|
+
.finally(() => inFlight.delete(p));
|
|
363
|
+
}
|
|
364
|
+
inFlight.add(p);
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
let buffer = "";
|
|
368
|
+
const handleLine = (line) => {
|
|
369
|
+
if (!line.trim()) return;
|
|
370
|
+
try {
|
|
371
|
+
dispatch(JSON.parse(line));
|
|
372
|
+
} catch (err) {
|
|
373
|
+
console.error(`[sdk-runner] Failed to parse NDJSON line: ${err.message}`);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
await new Promise((resolveEnd, rejectEnd) => {
|
|
378
|
+
process.stdin.setEncoding("utf8");
|
|
379
|
+
process.stdin.on("data", (chunk) => {
|
|
380
|
+
buffer += chunk;
|
|
381
|
+
const parts = buffer.split("\n");
|
|
382
|
+
buffer = parts.pop() ?? ""; // keep incomplete line
|
|
383
|
+
for (const part of parts) handleLine(part);
|
|
384
|
+
});
|
|
385
|
+
process.stdin.on("end", () => {
|
|
386
|
+
if (buffer.trim()) handleLine(buffer);
|
|
387
|
+
resolveEnd();
|
|
388
|
+
});
|
|
389
|
+
process.stdin.on("error", rejectEnd);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// stdin closed: wait for in-flight requests, then shut down.
|
|
393
|
+
console.error(`[sdk-runner] stdin closed, waiting for ${inFlight.size} in-flight request(s)`);
|
|
394
|
+
await Promise.allSettled([...inFlight]);
|
|
395
|
+
console.error("[sdk-runner] All requests processed, shutting down");
|
|
396
|
+
|
|
397
|
+
// Flush stdout before exiting
|
|
398
|
+
await new Promise((resolve) => protocolWrite("", resolve));
|
|
399
|
+
process.exit(0);
|
|
400
|
+
} catch (error) {
|
|
401
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
402
|
+
console.error(`[sdk-runner] Fatal error: ${message}`);
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (RUNNING_AS_MAIN) {
|
|
408
|
+
main().catch((err) => {
|
|
409
|
+
console.error(`[sdk-runner] Unhandled error in main:`, err);
|
|
410
|
+
process.exit(1);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export interface SessionMetrics {
|
|
2
|
+
sessionId: string;
|
|
3
|
+
model: string;
|
|
4
|
+
promptTokens: number;
|
|
5
|
+
toolCalls: number;
|
|
6
|
+
duration: number;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface AggregateMetrics {
|
|
11
|
+
totalPrompts: number;
|
|
12
|
+
totalToolCalls: number;
|
|
13
|
+
totalDuration: number;
|
|
14
|
+
avgDuration: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class MetricsTracker {
|
|
18
|
+
private sessions: Map<string, SessionMetrics> = new Map();
|
|
19
|
+
|
|
20
|
+
recordPrompt(sessionId: string, model: string, tokens: number): void {
|
|
21
|
+
const existing = this.sessions.get(sessionId);
|
|
22
|
+
if (existing) {
|
|
23
|
+
existing.promptTokens = tokens;
|
|
24
|
+
existing.model = model;
|
|
25
|
+
} else {
|
|
26
|
+
this.sessions.set(sessionId, {
|
|
27
|
+
sessionId,
|
|
28
|
+
model,
|
|
29
|
+
promptTokens: tokens,
|
|
30
|
+
toolCalls: 0,
|
|
31
|
+
duration: 0,
|
|
32
|
+
timestamp: Date.now()
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
recordToolCall(sessionId: string, toolName: string, duration: number): void {
|
|
38
|
+
const existing = this.sessions.get(sessionId);
|
|
39
|
+
if (existing) {
|
|
40
|
+
existing.toolCalls++;
|
|
41
|
+
existing.duration += duration;
|
|
42
|
+
}
|
|
43
|
+
// If no session exists, silently ignore (matches test expectations)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
getSessionMetrics(sessionId: string): SessionMetrics | undefined {
|
|
47
|
+
return this.sessions.get(sessionId);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
getAggregateMetrics(hours: number): AggregateMetrics {
|
|
51
|
+
const cutoff = Date.now() - (hours * 60 * 60 * 1000);
|
|
52
|
+
let totalPrompts = 0;
|
|
53
|
+
let totalToolCalls = 0;
|
|
54
|
+
let totalDuration = 0;
|
|
55
|
+
|
|
56
|
+
for (const metrics of this.sessions.values()) {
|
|
57
|
+
if (metrics.timestamp >= cutoff) {
|
|
58
|
+
totalPrompts++;
|
|
59
|
+
totalToolCalls += metrics.toolCalls;
|
|
60
|
+
totalDuration += metrics.duration;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
totalPrompts,
|
|
66
|
+
totalToolCalls,
|
|
67
|
+
totalDuration,
|
|
68
|
+
avgDuration: totalPrompts > 0 ? Math.round(totalDuration / totalPrompts) : 0
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
clearMetrics(sessionId?: string): void {
|
|
73
|
+
if (sessionId) {
|
|
74
|
+
this.sessions.delete(sessionId);
|
|
75
|
+
} else {
|
|
76
|
+
this.sessions.clear();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
clearAll(): void {
|
|
81
|
+
this.sessions.clear();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "fs/promises";
|
|
2
|
+
import { dirname, join } from "path";
|
|
3
|
+
|
|
4
|
+
export interface Session {
|
|
5
|
+
id: string;
|
|
6
|
+
cwd: string;
|
|
7
|
+
modeId?: string;
|
|
8
|
+
cancelled?: boolean;
|
|
9
|
+
resumeId?: string;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
updatedAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SessionCreateOptions {
|
|
15
|
+
cwd?: string;
|
|
16
|
+
modeId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SessionManager {
|
|
20
|
+
private sessions: Map<string, Session> = new Map();
|
|
21
|
+
private storagePath: string;
|
|
22
|
+
|
|
23
|
+
constructor(storagePath?: string) {
|
|
24
|
+
this.storagePath = storagePath || join(process.cwd(), ".opencode", "sessions.json");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async initialize(): Promise<void> {
|
|
28
|
+
// Load sessions from disk if storage file exists
|
|
29
|
+
try {
|
|
30
|
+
const data = await readFile(this.storagePath, "utf-8");
|
|
31
|
+
const sessions = JSON.parse(data) as Record<string, Session>;
|
|
32
|
+
this.sessions = new Map(Object.entries(sessions));
|
|
33
|
+
} catch {
|
|
34
|
+
// File doesn't exist or is invalid, start fresh
|
|
35
|
+
this.sessions.clear();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async persist(): Promise<void> {
|
|
40
|
+
// Save sessions to disk
|
|
41
|
+
const dir = dirname(this.storagePath);
|
|
42
|
+
await mkdir(dir, { recursive: true });
|
|
43
|
+
const data = JSON.stringify(Object.fromEntries(this.sessions), null, 2);
|
|
44
|
+
await writeFile(this.storagePath, data, "utf-8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async createSession(options: SessionCreateOptions): Promise<Session> {
|
|
48
|
+
const id = `session-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
49
|
+
const session: Session = {
|
|
50
|
+
id,
|
|
51
|
+
cwd: options.cwd || process.cwd(),
|
|
52
|
+
modeId: options.modeId,
|
|
53
|
+
cancelled: false,
|
|
54
|
+
createdAt: Date.now(),
|
|
55
|
+
updatedAt: Date.now()
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
this.sessions.set(id, session);
|
|
59
|
+
await this.persist();
|
|
60
|
+
return session;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async getSession(id: string): Promise<Session | null> {
|
|
64
|
+
return this.sessions.get(id) || null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async updateSession(id: string, updates: Partial<Session>): Promise<void> {
|
|
68
|
+
const session = this.sessions.get(id);
|
|
69
|
+
if (session) {
|
|
70
|
+
Object.assign(session, updates, { updatedAt: Date.now() });
|
|
71
|
+
await this.persist();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async deleteSession(id: string): Promise<void> {
|
|
76
|
+
this.sessions.delete(id);
|
|
77
|
+
await this.persist();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
isCancelled(id: string): boolean {
|
|
81
|
+
const session = this.sessions.get(id);
|
|
82
|
+
return session?.cancelled || false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
markCancelled(id: string): void {
|
|
86
|
+
const session = this.sessions.get(id);
|
|
87
|
+
if (session) {
|
|
88
|
+
session.cancelled = true;
|
|
89
|
+
session.updatedAt = Date.now();
|
|
90
|
+
this.persist().catch(() => {});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
canResume(id: string): boolean {
|
|
95
|
+
const session = this.sessions.get(id);
|
|
96
|
+
return !!session?.resumeId;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setResumeId(id: string, resumeId: string): void {
|
|
100
|
+
const session = this.sessions.get(id);
|
|
101
|
+
if (session) {
|
|
102
|
+
session.resumeId = resumeId;
|
|
103
|
+
session.updatedAt = Date.now();
|
|
104
|
+
this.persist().catch(() => {});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|