@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.
Files changed (80) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +270 -0
  3. package/dist/cli/discover.js +527 -0
  4. package/dist/cli/mcptool.js +10339 -0
  5. package/dist/cli/opencode-cursor.js +2989 -0
  6. package/dist/index.js +20588 -0
  7. package/dist/plugin-entry.js +19848 -0
  8. package/package.json +82 -0
  9. package/scripts/cursor-agent-runner.mjs +272 -0
  10. package/scripts/sdk-runner.mjs +412 -0
  11. package/src/acp/metrics.ts +83 -0
  12. package/src/acp/sessions.ts +107 -0
  13. package/src/acp/tools.ts +209 -0
  14. package/src/auth.ts +175 -0
  15. package/src/cli/discover.ts +53 -0
  16. package/src/cli/mcptool.ts +133 -0
  17. package/src/cli/model-discovery.ts +71 -0
  18. package/src/cli/opencode-cursor.ts +1195 -0
  19. package/src/client/cursor-agent-child.ts +459 -0
  20. package/src/client/sdk-child.ts +550 -0
  21. package/src/client/simple.ts +293 -0
  22. package/src/commands/status.ts +39 -0
  23. package/src/index.ts +39 -0
  24. package/src/mcp/client-manager.ts +166 -0
  25. package/src/mcp/config.ts +169 -0
  26. package/src/mcp/tool-bridge.ts +133 -0
  27. package/src/models/config.ts +64 -0
  28. package/src/models/discovery.ts +105 -0
  29. package/src/models/index.ts +3 -0
  30. package/src/models/pricing.ts +196 -0
  31. package/src/models/sync.ts +247 -0
  32. package/src/models/types.ts +11 -0
  33. package/src/models/variants.ts +446 -0
  34. package/src/plugin-entry.ts +28 -0
  35. package/src/plugin-toggle.ts +81 -0
  36. package/src/plugin.ts +2802 -0
  37. package/src/provider/backend.ts +71 -0
  38. package/src/provider/boundary.ts +168 -0
  39. package/src/provider/passthrough-tracker.ts +38 -0
  40. package/src/provider/runtime-interception.ts +818 -0
  41. package/src/provider/tool-loop-guard.ts +644 -0
  42. package/src/provider/tool-schema-compat.ts +800 -0
  43. package/src/provider.ts +268 -0
  44. package/src/proxy/formatter.ts +60 -0
  45. package/src/proxy/handler.ts +29 -0
  46. package/src/proxy/incremental-prompt.ts +74 -0
  47. package/src/proxy/prompt-builder.ts +204 -0
  48. package/src/proxy/server.ts +207 -0
  49. package/src/proxy/session-resume.ts +312 -0
  50. package/src/proxy/tool-loop.ts +359 -0
  51. package/src/proxy/types.ts +13 -0
  52. package/src/services/toast-service.ts +81 -0
  53. package/src/streaming/ai-sdk-parts.ts +109 -0
  54. package/src/streaming/delta-tracker.ts +89 -0
  55. package/src/streaming/line-buffer.ts +44 -0
  56. package/src/streaming/openai-sse.ts +118 -0
  57. package/src/streaming/parser.ts +22 -0
  58. package/src/streaming/types.ts +158 -0
  59. package/src/tools/core/executor.ts +25 -0
  60. package/src/tools/core/registry.ts +27 -0
  61. package/src/tools/core/types.ts +31 -0
  62. package/src/tools/defaults.ts +954 -0
  63. package/src/tools/discovery.ts +140 -0
  64. package/src/tools/executors/cli.ts +59 -0
  65. package/src/tools/executors/local.ts +25 -0
  66. package/src/tools/executors/mcp.ts +39 -0
  67. package/src/tools/executors/sdk.ts +39 -0
  68. package/src/tools/index.ts +8 -0
  69. package/src/tools/registry.ts +34 -0
  70. package/src/tools/router.ts +123 -0
  71. package/src/tools/schema.ts +58 -0
  72. package/src/tools/skills/loader.ts +61 -0
  73. package/src/tools/skills/resolver.ts +21 -0
  74. package/src/tools/types.ts +29 -0
  75. package/src/types.ts +8 -0
  76. package/src/usage.ts +112 -0
  77. package/src/utils/binary.ts +71 -0
  78. package/src/utils/errors.ts +224 -0
  79. package/src/utils/logger.ts +191 -0
  80. 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
+ }