copperhead 0.6.0 → 0.8.0

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 (118) hide show
  1. package/README.md +36 -4
  2. package/dist/agent/animate.js +76 -0
  3. package/dist/agent/animate.js.map +1 -0
  4. package/dist/agent/box.js +89 -0
  5. package/dist/agent/box.js.map +1 -0
  6. package/dist/agent/dock-renderer.js +173 -0
  7. package/dist/agent/dock-renderer.js.map +1 -0
  8. package/dist/agent/logo.js +21 -0
  9. package/dist/agent/logo.js.map +1 -0
  10. package/dist/agent/loop.js +130 -18
  11. package/dist/agent/loop.js.map +1 -1
  12. package/dist/agent/prompts.js +2 -1
  13. package/dist/agent/prompts.js.map +1 -1
  14. package/dist/agent/providers/claude-code.js +85 -116
  15. package/dist/agent/providers/claude-code.js.map +1 -1
  16. package/dist/agent/providers/cursor.js +317 -0
  17. package/dist/agent/providers/cursor.js.map +1 -0
  18. package/dist/agent/providers/tool-protocol.js +205 -0
  19. package/dist/agent/providers/tool-protocol.js.map +1 -0
  20. package/dist/agent/recovery.js +148 -0
  21. package/dist/agent/recovery.js.map +1 -0
  22. package/dist/agent/render.js +44 -12
  23. package/dist/agent/render.js.map +1 -1
  24. package/dist/agent/response-cache.js +81 -0
  25. package/dist/agent/response-cache.js.map +1 -0
  26. package/dist/agent/runmeta.js +4 -5
  27. package/dist/agent/runmeta.js.map +1 -1
  28. package/dist/agent/theme.js +84 -0
  29. package/dist/agent/theme.js.map +1 -0
  30. package/dist/agent/tools.js +61 -4
  31. package/dist/agent/tools.js.map +1 -1
  32. package/dist/agent/transcript.js.map +1 -1
  33. package/dist/cli.js +134 -13
  34. package/dist/cli.js.map +1 -1
  35. package/dist/commands/create.js +482 -38
  36. package/dist/commands/create.js.map +1 -1
  37. package/dist/commands/demo.js +146 -0
  38. package/dist/commands/demo.js.map +1 -0
  39. package/dist/commands/doctor.js +240 -0
  40. package/dist/commands/doctor.js.map +1 -0
  41. package/dist/commands/repl-inspect.js +342 -0
  42. package/dist/commands/repl-inspect.js.map +1 -0
  43. package/dist/commands/repl.js +618 -0
  44. package/dist/commands/repl.js.map +1 -0
  45. package/dist/config.js +24 -2
  46. package/dist/config.js.map +1 -1
  47. package/dist/kicad/bootstrap.js +166 -0
  48. package/dist/kicad/bootstrap.js.map +1 -0
  49. package/dist/kicad/cli.js +126 -6
  50. package/dist/kicad/cli.js.map +1 -1
  51. package/dist/kicad/spice.js +306 -0
  52. package/dist/kicad/spice.js.map +1 -0
  53. package/dist/kicad/symlib.js +228 -0
  54. package/dist/kicad/symlib.js.map +1 -0
  55. package/dist/memory/bom-table.js +193 -22
  56. package/dist/memory/bom-table.js.map +1 -1
  57. package/dist/memory/drift.js +33 -11
  58. package/dist/memory/drift.js.map +1 -1
  59. package/dist/util/cli-args.js +35 -0
  60. package/dist/util/cli-args.js.map +1 -0
  61. package/dist/util/dock.js +155 -0
  62. package/dist/util/dock.js.map +1 -0
  63. package/dist/util/git.js +165 -4
  64. package/dist/util/git.js.map +1 -1
  65. package/dist/util/live-prompt.js +542 -0
  66. package/dist/util/live-prompt.js.map +1 -0
  67. package/dist/util/paths.js +9 -0
  68. package/dist/util/paths.js.map +1 -1
  69. package/dist/util/preflight.js +37 -0
  70. package/dist/util/preflight.js.map +1 -1
  71. package/dist/util/retry.js +23 -0
  72. package/dist/util/retry.js.map +1 -1
  73. package/dist/util/select.js +172 -0
  74. package/dist/util/select.js.map +1 -0
  75. package/dist/util/tmp.js +119 -0
  76. package/dist/util/tmp.js.map +1 -0
  77. package/package.json +3 -2
  78. package/src/agent/animate.ts +90 -0
  79. package/src/agent/box.ts +99 -0
  80. package/src/agent/dock-renderer.ts +181 -0
  81. package/src/agent/logo.ts +23 -0
  82. package/src/agent/loop.ts +148 -18
  83. package/src/agent/prompts.ts +2 -1
  84. package/src/agent/providers/claude-code.ts +91 -122
  85. package/src/agent/providers/cursor.ts +364 -0
  86. package/src/agent/providers/tool-protocol.ts +212 -0
  87. package/src/agent/recovery.ts +162 -0
  88. package/src/agent/render.ts +56 -12
  89. package/src/agent/response-cache.ts +80 -0
  90. package/src/agent/runmeta.ts +6 -7
  91. package/src/agent/theme.ts +91 -0
  92. package/src/agent/tools.ts +62 -4
  93. package/src/agent/transcript.ts +1 -0
  94. package/src/agent/types.ts +17 -0
  95. package/src/cli.ts +139 -15
  96. package/src/commands/create.ts +581 -40
  97. package/src/commands/demo.ts +184 -0
  98. package/src/commands/doctor.ts +289 -0
  99. package/src/commands/repl-inspect.ts +353 -0
  100. package/src/commands/repl.ts +685 -0
  101. package/src/config.ts +40 -3
  102. package/src/kicad/bootstrap.ts +181 -0
  103. package/src/kicad/cli.ts +132 -7
  104. package/src/kicad/spice.ts +399 -0
  105. package/src/kicad/symlib.ts +248 -0
  106. package/src/layout/claude-ui-layout.md +72 -0
  107. package/src/layout/repl-ui-layout.md +139 -0
  108. package/src/memory/bom-table.ts +191 -20
  109. package/src/memory/drift.ts +42 -11
  110. package/src/util/cli-args.ts +42 -0
  111. package/src/util/dock.ts +161 -0
  112. package/src/util/git.ts +176 -4
  113. package/src/util/live-prompt.ts +595 -0
  114. package/src/util/paths.ts +10 -0
  115. package/src/util/preflight.ts +44 -0
  116. package/src/util/retry.ts +29 -0
  117. package/src/util/select.ts +192 -0
  118. package/src/util/tmp.ts +113 -0
@@ -1,7 +1,8 @@
1
- import { mkdtemp, rm } from 'node:fs/promises';
1
+ import { mkdtemp, rm, utimes } from 'node:fs/promises';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import type { ChatOpts, Msg, Provider, ToolCall, ToolSchema, Turn } from '../types.js';
4
+ import type { ChatOpts, Msg, Provider, ToolSchema, Turn } from '../types.js';
5
+ import { parseToolCalls, renderConversation, renderDelta, renderToolProtocol } from './tool-protocol.js';
5
6
 
6
7
  /**
7
8
  * Saved-login provider: drives Claude Code through the Claude Agent SDK
@@ -51,6 +52,16 @@ export interface QueryOptions {
51
52
  cwd?: string;
52
53
  env?: Record<string, string | undefined>;
53
54
  maxTurns?: number;
55
+ /** Aborting this controller stops the query and tears down the `claude`
56
+ * subprocess it spawned (Agent SDK `Options.abortController`). Used so the
57
+ * watchdog's `close()` on a hung turn kills the process instead of orphaning
58
+ * it (2.2/4.1) — a stranded subprocess keeps writing to its temp cwd and, with
59
+ * KiCad local history, was a source of the disk-fill halt (I8). */
60
+ abortController?: AbortController;
61
+ /** Resume a prior SDK session by id so the subprocess reconstructs earlier
62
+ * turns itself instead of us re-sending the whole conversation each turn (1.1,
63
+ * `Options.resume`). Only set in the opt-in session-resume mode. */
64
+ resume?: string;
54
65
  }
55
66
  export interface QueryArgs {
56
67
  prompt: string;
@@ -59,6 +70,7 @@ export interface QueryArgs {
59
70
  export interface QueryMessage {
60
71
  type: string;
61
72
  subtype?: string;
73
+ session_id?: string;
62
74
  message?: { content?: Array<{ type: string; text?: string }> };
63
75
  usage?: { input_tokens?: number; output_tokens?: number };
64
76
  }
@@ -93,17 +105,35 @@ export class ClaudeCodeProvider implements Provider {
93
105
  readonly name = 'claude-code';
94
106
  private callSeq = 0;
95
107
  private cwdPromise?: Promise<string>;
108
+ /** In-flight query aborters, so close() (called by the turn watchdog on a
109
+ * hung turn) can tear down the live subprocess, not just delete its cwd. */
110
+ private readonly inFlight = new Set<AbortController>();
111
+ /** Session-resume state (1.1). `sessionId` is the last session the SDK reported;
112
+ * `sentCount` is how many `messages` we have already handed it, so a resumed
113
+ * turn sends only the delta. Unused unless `sessionResume` is on. */
114
+ private sessionId?: string;
115
+ private sentCount = 0;
96
116
 
97
117
  constructor(
98
118
  private readonly model?: string,
99
119
  private readonly injectedQuery?: QueryLike,
100
120
  private readonly importSdk: ImportLike = (specifier) => import(specifier),
121
+ /**
122
+ * Opt-in: resume one SDK session across turns and send only new messages,
123
+ * instead of flattening and re-sending the entire conversation every turn
124
+ * (1.1). Cuts the ~quadratic history re-send that dominates long-stage cost.
125
+ * OFF by default and deliberately mutually exclusive with the response cache:
126
+ * the cache replays turns the resumed session never saw, so mixing them would
127
+ * desync the session. `makeProvider` enables it only when the cache is off.
128
+ */
129
+ private readonly sessionResume = false,
101
130
  ) {}
102
131
 
103
- // `_opts.maxTokens` is intentionally ignored: the Agent SDK drives the Claude
104
- // Code subprocess and exposes no per-call max-tokens knob. loop.ts calls
105
- // chat() without opts today; noted so a future opts pass is not a surprise.
106
- async chat(messages: Msg[], tools: ToolSchema[], _opts: ChatOpts = {}): Promise<Turn> {
132
+ // `opts.maxTokens` is intentionally ignored: the Agent SDK drives the Claude
133
+ // Code subprocess and exposes no per-call max-tokens knob. `opts.onStream` is
134
+ // honored: this provider streams, so it reports cumulative streamed-text length
135
+ // as blocks arrive, which the loop turns into a liveness heartbeat (5.1).
136
+ async chat(messages: Msg[], tools: ToolSchema[], opts: ChatOpts = {}): Promise<Turn> {
107
137
  const query = await this.resolveQuery();
108
138
 
109
139
  const system = messages
@@ -111,19 +141,28 @@ export class ClaudeCodeProvider implements Provider {
111
141
  .map((m) => m.content)
112
142
  .join('\n\n');
113
143
  const systemPrompt = [system, renderToolProtocol(tools)].filter(Boolean).join('\n\n');
114
- const prompt = renderConversation(messages);
144
+ // Session-resume mode (1.1): once the SDK has given us a session id, resume it
145
+ // and send only the messages added since our last turn — the subprocess still
146
+ // holds the earlier conversation, so re-sending it would just re-bill it. The
147
+ // first turn (no session id yet) sends the full flattened history as usual.
148
+ const resume = this.sessionResume ? this.sessionId : undefined;
149
+ const prompt = resume ? renderDelta(messages, this.sentCount) : renderConversation(messages);
115
150
  const catalog = new Set(tools.map((t) => t.name));
116
151
  const cwd = await this.ensureCwd();
117
152
 
118
153
  let text: string | null = null;
119
154
  let inputTokens = 0;
120
155
  let outputTokens = 0;
156
+ // One aborter per turn: close() aborts it to kill a hung subprocess.
157
+ const aborter = new AbortController();
158
+ this.inFlight.add(aborter);
121
159
  try {
122
160
  for await (const msg of query({
123
161
  prompt,
124
162
  options: {
125
163
  systemPrompt,
126
164
  ...(this.model ? { model: this.model } : {}),
165
+ abortController: aborter,
127
166
  // Layered "the SDK executes nothing" defense (D1/D5):
128
167
  // 1. `tools: []` disables ALL built-in tools (Agent SDK 0.3.x docs:
129
168
  // "[] (empty array) - Disable all built-in tools").
@@ -134,6 +173,7 @@ export class ClaudeCodeProvider implements Provider {
134
173
  // 4. The tool_use tripwire below fails the run loudly if one is
135
174
  // emitted anyway. Any single layer failing is caught by the next.
136
175
  tools: [],
176
+ ...(resume ? { resume } : {}),
137
177
  disallowedTools: DISALLOWED_BUILTINS,
138
178
  canUseTool: async (toolName) => ({
139
179
  behavior: 'deny',
@@ -153,6 +193,9 @@ export class ClaudeCodeProvider implements Provider {
153
193
  for (const block of msg.message?.content ?? []) {
154
194
  if (block.type === 'text' && block.text) {
155
195
  text = (text ?? '') + block.text;
196
+ // Report progress so the loop's heartbeat shows this turn is alive
197
+ // and streaming, not hung, during a multi-minute large-output turn.
198
+ opts.onStream?.(text.length);
156
199
  } else if (block.type === 'tool_use') {
157
200
  // Load-bearing invariant (D1): the SDK must execute nothing, so it
158
201
  // must never emit a tool_use block. If it does, `tools: []` was not
@@ -169,6 +212,9 @@ export class ClaudeCodeProvider implements Provider {
169
212
  if (typeof msg.usage?.input_tokens === 'number') inputTokens = msg.usage.input_tokens;
170
213
  if (typeof msg.usage?.output_tokens === 'number') outputTokens = msg.usage.output_tokens;
171
214
  }
215
+ // The session id can arrive on any message (init/system/result); keep the
216
+ // latest so the next turn can resume it (1.1). No-op unless resume is on.
217
+ if (this.sessionResume && typeof msg.session_id === 'string') this.sessionId = msg.session_id;
172
218
  }
173
219
  } catch (err) {
174
220
  // Auth failures get an actionable message (non-retryable); everything else
@@ -177,15 +223,40 @@ export class ClaudeCodeProvider implements Provider {
177
223
  // distinct `name` makes otherProvider() return null for us.
178
224
  if (isAuthError(err)) throw new Error(authHint((err as Error).message));
179
225
  throw err;
226
+ } finally {
227
+ this.inFlight.delete(aborter);
180
228
  }
181
229
 
230
+ // Only advance the high-water mark on a turn that completed: a thrown turn
231
+ // (rate limit, timeout) is retried, and must re-send the same delta so no
232
+ // message is lost from the resumed session (1.1).
233
+ if (this.sessionResume) this.sentCount = messages.length;
234
+
182
235
  const parsed = parseToolCalls(text, () => `cc-${++this.callSeq}`, catalog);
183
- return { text: parsed.text, toolCalls: parsed.toolCalls, usage: { inputTokens, outputTokens } };
236
+ return {
237
+ text: parsed.text,
238
+ toolCalls: parsed.toolCalls,
239
+ usage: { inputTokens, outputTokens },
240
+ nudge: parsed.nudge,
241
+ };
184
242
  }
185
243
 
186
- /** Remove the scratch cwd. loop.ts calls this on every provider in a finally,
187
- * so the one temp dir this instance created does not leak past the run. */
244
+ /** Tear down in-flight work and remove the scratch cwd. Called by the turn
245
+ * watchdog on a hung turn (via withTimeout's onTimeout) AND once per run in a
246
+ * finally. Aborting first kills the `claude` subprocess a hung turn spawned —
247
+ * without it the process is orphaned and keeps writing to its temp cwd, which
248
+ * (with KiCad local history) was a source of the disk-fill halt (2.2/4.1, I8).
249
+ * A leftover empty dir in the OS tmpdir is harmless; the startup sweep reclaims
250
+ * any that a hard SIGKILL bypassed this cleanup for. */
188
251
  async close(): Promise<void> {
252
+ for (const aborter of this.inFlight) {
253
+ try {
254
+ aborter.abort();
255
+ } catch {
256
+ // best effort: a controller that already settled throws nothing useful
257
+ }
258
+ }
259
+ this.inFlight.clear();
189
260
  const pending = this.cwdPromise;
190
261
  this.cwdPromise = undefined;
191
262
  if (!pending) return;
@@ -199,9 +270,17 @@ export class ClaudeCodeProvider implements Provider {
199
270
  /** One isolated scratch cwd per provider instance, created once and reused
200
271
  * across turns so a long run does not leak a temp dir per turn. Even with
201
272
  * tools disabled this guarantees the SDK has no path into the repo (D5). */
202
- private ensureCwd(): Promise<string> {
273
+ private async ensureCwd(): Promise<string> {
203
274
  if (!this.cwdPromise) this.cwdPromise = mkdtemp(path.join(os.tmpdir(), 'copperhead-cc-'));
204
- return this.cwdPromise;
275
+ const cwd = await this.cwdPromise;
276
+ // Keep this reused scratch dir's mtime fresh on every turn. It is the only
277
+ // long-lived temp dir a run holds (kicad-cli dirs are per-call), so a
278
+ // multi-hour run would otherwise leave it with a stale mtime and a concurrent
279
+ // run's startup sweep (sweepStaleTempDirs, age-gated) could delete it out from
280
+ // under the live process (F4). Best-effort: a touch failure is harmless.
281
+ const now = new Date();
282
+ await utimes(cwd, now, now).catch(() => {});
283
+ return cwd;
205
284
  }
206
285
 
207
286
  private async resolveQuery(): Promise<QueryLike> {
@@ -236,116 +315,6 @@ export class ClaudeCodeProvider implements Provider {
236
315
  }
237
316
  }
238
317
 
239
- function renderToolProtocol(tools: ToolSchema[]): string {
240
- if (!tools.length) return '';
241
- const lines = [
242
- '# Tool protocol',
243
- '',
244
- 'You are the reasoning half of a tool-driven workflow; you cannot run anything yourself.',
245
- 'To take an action, reply with EXACTLY ONE JSON object and nothing else, wrapped in a',
246
- '```json fenced code block:',
247
- '',
248
- '```json',
249
- '{"tool": "<tool_name>", "args": { ... }}',
250
- '```',
251
- '',
252
- 'Use only the tools listed below, with `args` matching the tool\'s JSON Schema. If you have',
253
- 'no tool to call and only want to say something, reply with plain prose and no JSON block.',
254
- '',
255
- '## Available tools',
256
- ];
257
- for (const t of tools) {
258
- lines.push(
259
- '',
260
- `### ${t.name}`,
261
- t.description,
262
- `Parameters (JSON Schema): ${JSON.stringify(t.parameters)}`,
263
- );
264
- }
265
- return lines.join('\n');
266
- }
267
-
268
- function renderConversation(messages: Msg[]): string {
269
- const idToName = new Map<string, string>();
270
- const parts: string[] = [];
271
- for (const m of messages) {
272
- if (m.role === 'system') continue;
273
- if (m.role === 'user') {
274
- parts.push(`[user]\n${m.content}`);
275
- } else if (m.role === 'assistant') {
276
- if (m.content) parts.push(`[assistant]\n${m.content}`);
277
- for (const call of m.toolCalls ?? []) {
278
- idToName.set(call.id, call.name);
279
- parts.push(
280
- `[assistant tool call]\n\`\`\`json\n${JSON.stringify({ tool: call.name, args: call.args })}\n\`\`\``,
281
- );
282
- }
283
- } else {
284
- const name = idToName.get(m.toolCallId) ?? m.toolCallId;
285
- parts.push(`[result of ${name}]\n${m.content}`);
286
- }
287
- }
288
- return parts.join('\n\n');
289
- }
290
-
291
- interface Parsed {
292
- text: string | null;
293
- toolCalls: ToolCall[];
294
- }
295
-
296
- /**
297
- * Extract tool-call JSON from the model's reply. Tolerant by design (D1):
298
- * unparseable output is returned as plain text with no tool calls rather than
299
- * throwing, so a non-conforming turn degrades to the loop's stall/nudge path.
300
- * A parsed block only counts as a tool call when its name is in the current
301
- * turn's catalog (`availableTools(ctx)`): a hallucinated or locked tool name is
302
- * left as prose so the loop nudges, rather than dispatching a bogus call.
303
- */
304
- function parseToolCalls(text: string | null, nextId: () => string, catalog: Set<string>): Parsed {
305
- if (!text) return { text: null, toolCalls: [] };
306
- const toolCalls: ToolCall[] = [];
307
- let prose = text;
308
-
309
- const fence = /```(?:json)?\s*([\s\S]*?)```/gi;
310
- let match: RegExpExecArray | null;
311
- const consumed: string[] = [];
312
- while ((match = fence.exec(text)) !== null) {
313
- const call = toToolCall(match[1], nextId, catalog);
314
- if (call) {
315
- toolCalls.push(call);
316
- consumed.push(match[0]);
317
- }
318
- }
319
- for (const block of consumed) prose = prose.replace(block, '');
320
-
321
- // No fenced tool block: maybe the whole reply is a bare JSON object.
322
- if (!toolCalls.length) {
323
- const call = toToolCall(text, nextId, catalog);
324
- if (call) return { text: null, toolCalls: [call] };
325
- }
326
-
327
- const trimmed = prose.trim();
328
- return { text: trimmed.length ? trimmed : null, toolCalls };
329
- }
330
-
331
- function toToolCall(raw: string | undefined, nextId: () => string, catalog: Set<string>): ToolCall | null {
332
- if (!raw) return null;
333
- let obj: unknown;
334
- try {
335
- obj = JSON.parse(raw.trim());
336
- } catch {
337
- return null;
338
- }
339
- if (!obj || typeof obj !== 'object') return null;
340
- const rec = obj as Record<string, unknown>;
341
- if (typeof rec.tool !== 'string') return null;
342
- // Only accept names the turn actually advertised. An empty catalog means the
343
- // turn offered no tools, so nothing parses as a call.
344
- if (!catalog.has(rec.tool)) return null;
345
- const args = rec.args && typeof rec.args === 'object' ? (rec.args as Record<string, unknown>) : {};
346
- return { id: nextId(), name: rec.tool, args };
347
- }
348
-
349
318
  function isAuthError(err: unknown): boolean {
350
319
  const status = (err as { status?: number; statusCode?: number })?.status
351
320
  ?? (err as { statusCode?: number })?.statusCode;
@@ -0,0 +1,364 @@
1
+ import { mkdtemp, rm, utimes } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { execa } from 'execa';
5
+ import type { ChatOpts, Msg, Provider, ToolSchema, Turn } from '../types.js';
6
+ import { parseToolCalls, renderConversation, renderDelta, renderToolProtocol } from './tool-protocol.js';
7
+
8
+ /**
9
+ * Saved-login provider: drives the Cursor Agent CLI (`agent` / `cursor-agent`) with
10
+ * `agent login` authentication. Reasoning-only: plan mode, sandbox, isolated
11
+ * workspace, JSON tool protocol (see `add-cursor-cli-provider`).
12
+ *
13
+ * Session resume is opt-in and mutually exclusive with the response cache (same
14
+ * rule as claude-code): the cache can replay turns a resumed CLI session never
15
+ * saw, which desyncs history. `makeProvider` enables resume only when the cache
16
+ * is off.
17
+ */
18
+
19
+ export interface CursorRunArgs {
20
+ prompt: string;
21
+ systemPrompt: string;
22
+ workspace: string;
23
+ model?: string;
24
+ resume?: string;
25
+ signal?: AbortSignal;
26
+ env?: NodeJS.ProcessEnv;
27
+ }
28
+
29
+ export interface CursorRunResult {
30
+ text: string;
31
+ sessionId?: string;
32
+ usage: { inputTokens: number; outputTokens: number };
33
+ }
34
+
35
+ export type CursorRunLike = (args: CursorRunArgs) => Promise<CursorRunResult>;
36
+
37
+ const NATIVE_MUTATION_TYPES = new Set([
38
+ 'tool_call',
39
+ 'tool_use',
40
+ 'tool-call',
41
+ 'shell',
42
+ 'write',
43
+ 'edit',
44
+ 'apply_patch',
45
+ 'file_change',
46
+ 'mcp_tool',
47
+ ]);
48
+
49
+ /** Subtype tokens that indicate native execution (whole-token match). */
50
+ const NATIVE_SUBTYPE_RE = /(^|_)(tool|shell|write|edit|patch|mutation)(_|$)/;
51
+
52
+ export class CursorProvider implements Provider {
53
+ readonly name = 'cursor';
54
+ private callSeq = 0;
55
+ private cwdPromise?: Promise<string>;
56
+ private sessionId?: string;
57
+ private sentCount = 0;
58
+ private readonly inFlight = new Set<AbortController>();
59
+
60
+ constructor(
61
+ private readonly model?: string,
62
+ private readonly runFn: CursorRunLike = defaultCursorRun,
63
+ /**
64
+ * Opt-in: resume one CLI session across turns and send only new messages.
65
+ * OFF by default and mutually exclusive with the response cache — mixing
66
+ * them desyncs the resumed session. `makeProvider` enables it only when
67
+ * the cache is off.
68
+ */
69
+ private readonly sessionResume = false,
70
+ ) {}
71
+
72
+ async chat(messages: Msg[], tools: ToolSchema[], opts: ChatOpts = {}): Promise<Turn> {
73
+ const system = messages
74
+ .filter((m) => m.role === 'system')
75
+ .map((m) => m.content)
76
+ .join('\n\n');
77
+ const systemPrompt = [system, renderToolProtocol(tools)].filter(Boolean).join('\n\n');
78
+ const resume = this.sessionResume ? this.sessionId : undefined;
79
+ const prompt = resume ? renderDelta(messages, this.sentCount) : renderConversation(messages);
80
+ const catalog = new Set(tools.map((t) => t.name));
81
+ const workspace = await this.ensureWorkspace();
82
+
83
+ const aborter = new AbortController();
84
+ this.inFlight.add(aborter);
85
+ let inputTokens = 0;
86
+ let outputTokens = 0;
87
+ let text: string | null = null;
88
+ try {
89
+ const result = await this.runFn({
90
+ prompt,
91
+ systemPrompt,
92
+ workspace,
93
+ ...(this.model ? { model: this.model } : {}),
94
+ ...(resume ? { resume } : {}),
95
+ signal: aborter.signal,
96
+ env: subprocessEnv(),
97
+ });
98
+ text = result.text;
99
+ if (this.sessionResume && result.sessionId) this.sessionId = result.sessionId;
100
+ inputTokens = result.usage.inputTokens;
101
+ outputTokens = result.usage.outputTokens;
102
+ opts.onStream?.(text.length);
103
+ } catch (err) {
104
+ if (isAuthError(err)) throw new Error(authHint((err as Error).message));
105
+ throw enhanceCliError(err);
106
+ } finally {
107
+ this.inFlight.delete(aborter);
108
+ }
109
+
110
+ // Only advance the high-water mark when resume is on (same as claude-code).
111
+ if (this.sessionResume) this.sentCount = messages.length;
112
+ const parsed = parseToolCalls(text, () => `cur-${++this.callSeq}`, catalog);
113
+ return {
114
+ text: parsed.text,
115
+ toolCalls: parsed.toolCalls,
116
+ usage: { inputTokens, outputTokens },
117
+ nudge: parsed.nudge,
118
+ };
119
+ }
120
+
121
+ async close(): Promise<void> {
122
+ for (const aborter of this.inFlight) {
123
+ try {
124
+ aborter.abort();
125
+ } catch {
126
+ // best effort
127
+ }
128
+ }
129
+ this.inFlight.clear();
130
+ const pending = this.cwdPromise;
131
+ this.cwdPromise = undefined;
132
+ if (!pending) return;
133
+ try {
134
+ await rm(await pending, { recursive: true, force: true });
135
+ } catch {
136
+ // best effort
137
+ }
138
+ }
139
+
140
+ private async ensureWorkspace(): Promise<string> {
141
+ if (!this.cwdPromise) this.cwdPromise = mkdtemp(path.join(os.tmpdir(), 'copperhead-cursor-'));
142
+ const cwd = await this.cwdPromise;
143
+ const now = new Date();
144
+ await utimes(cwd, now, now).catch(() => {});
145
+ return cwd;
146
+ }
147
+ }
148
+
149
+ /** Minimal env passed to the Cursor CLI subprocess (saved login via `agent login`). */
150
+ const CURSOR_SUBPROCESS_ENV_KEYS = [
151
+ 'PATH',
152
+ 'HOME',
153
+ 'USER',
154
+ 'LOGNAME',
155
+ 'SHELL',
156
+ 'TMPDIR',
157
+ 'TEMP',
158
+ 'TMP',
159
+ 'LANG',
160
+ 'LC_ALL',
161
+ 'LC_CTYPE',
162
+ 'LC_MESSAGES',
163
+ 'TERM',
164
+ 'XDG_CONFIG_HOME',
165
+ 'XDG_DATA_HOME',
166
+ 'XDG_CACHE_HOME',
167
+ 'XDG_RUNTIME_DIR',
168
+ // Windows home / login-config location (`USERPROFILE\.cursor\cli-config.json`)
169
+ 'USERPROFILE',
170
+ 'HOMEDRIVE',
171
+ 'HOMEPATH',
172
+ 'SystemRoot',
173
+ 'ComSpec',
174
+ 'APPDATA',
175
+ 'LOCALAPPDATA',
176
+ ] as const;
177
+
178
+ /** Build an allowlisted env for the Cursor Agent subprocess (no API keys or unrelated secrets). */
179
+ export function subprocessEnv(): NodeJS.ProcessEnv {
180
+ const env: NodeJS.ProcessEnv = {};
181
+ for (const key of CURSOR_SUBPROCESS_ENV_KEYS) {
182
+ const value = process.env[key];
183
+ if (value !== undefined) env[key] = value;
184
+ }
185
+ return env;
186
+ }
187
+
188
+ /** Parse `--print --output-format json` stdout into assistant text and session id. */
189
+ export function parseCursorStdout(stdout: string): CursorRunResult {
190
+ const trimmed = stdout.trim();
191
+ let text = '';
192
+ let sessionId: string | undefined;
193
+ let sawResult = false;
194
+
195
+ // Prefer a single pretty-printed JSON object (whole buffer) before NDJSON lines.
196
+ if (trimmed) {
197
+ try {
198
+ const whole = JSON.parse(trimmed) as Record<string, unknown>;
199
+ const extracted = extractResultFields(whole);
200
+ if (extracted) {
201
+ return {
202
+ text: extracted.text,
203
+ sessionId: extracted.sessionId,
204
+ usage: { inputTokens: 0, outputTokens: 0 },
205
+ };
206
+ }
207
+ } catch (err) {
208
+ if (isCursorHardFail(err)) throw err;
209
+ // fall through to line-based parse
210
+ }
211
+ }
212
+
213
+ const lines = trimmed
214
+ .split('\n')
215
+ .map((l) => l.trim())
216
+ .filter(Boolean);
217
+
218
+ for (const line of lines) {
219
+ let obj: Record<string, unknown>;
220
+ try {
221
+ obj = JSON.parse(line) as Record<string, unknown>;
222
+ } catch {
223
+ continue;
224
+ }
225
+ const extracted = extractResultFields(obj);
226
+ if (extracted) {
227
+ text = extracted.text;
228
+ sessionId = extracted.sessionId ?? sessionId;
229
+ sawResult = true;
230
+ }
231
+ }
232
+
233
+ if (!sawResult && !text && lines.length) {
234
+ // Fallback: last parseable JSON line with a string result field
235
+ for (let i = lines.length - 1; i >= 0; i--) {
236
+ try {
237
+ const obj = JSON.parse(lines[i]!) as Record<string, unknown>;
238
+ if (typeof obj.result === 'string') {
239
+ assertNoNativeMutation(obj);
240
+ text = obj.result;
241
+ if (typeof obj.session_id === 'string') sessionId = obj.session_id;
242
+ sawResult = true;
243
+ break;
244
+ }
245
+ } catch (err) {
246
+ if (isCursorHardFail(err)) throw err;
247
+ continue;
248
+ }
249
+ }
250
+ }
251
+
252
+ if (!sawResult && trimmed) {
253
+ throw new Error(
254
+ `cursor: could not parse Cursor Agent output as JSON — raw stdout: ${trimmed.slice(0, 500)}`,
255
+ );
256
+ }
257
+
258
+ // Official Cursor JSON schema does not expose token usage; callers see zeros.
259
+ return { text, sessionId, usage: { inputTokens: 0, outputTokens: 0 } };
260
+ }
261
+
262
+ function extractResultFields(
263
+ obj: Record<string, unknown>,
264
+ ): { text: string; sessionId?: string } | null {
265
+ assertNoNativeMutation(obj);
266
+ const type = typeof obj.type === 'string' ? obj.type.toLowerCase() : '';
267
+ if (type === 'result' || typeof obj.result === 'string') {
268
+ if (obj.is_error === true) {
269
+ throw new Error(typeof obj.result === 'string' ? obj.result : 'Cursor Agent returned an error result');
270
+ }
271
+ if (typeof obj.result === 'string') {
272
+ return {
273
+ text: obj.result,
274
+ ...(typeof obj.session_id === 'string' ? { sessionId: obj.session_id } : {}),
275
+ };
276
+ }
277
+ }
278
+ return null;
279
+ }
280
+
281
+ function isCursorHardFail(err: unknown): boolean {
282
+ return (
283
+ err instanceof Error &&
284
+ (err.message.includes('reasoning-only invariant') ||
285
+ err.message.includes('Cursor Agent returned an error') ||
286
+ err.message.startsWith('cursor:'))
287
+ );
288
+ }
289
+
290
+ function assertNoNativeMutation(obj: Record<string, unknown>): void {
291
+ const type = typeof obj.type === 'string' ? obj.type.toLowerCase() : '';
292
+ if (NATIVE_MUTATION_TYPES.has(type)) {
293
+ throw new Error(
294
+ `cursor: Cursor Agent emitted native tool event "${obj.type}" — reasoning-only invariant violated. Refusing to continue.`,
295
+ );
296
+ }
297
+ const subtype = typeof obj.subtype === 'string' ? obj.subtype.toLowerCase() : '';
298
+ if (subtype && NATIVE_SUBTYPE_RE.test(subtype) && type !== 'result') {
299
+ throw new Error(
300
+ `cursor: Cursor Agent output subtype "${obj.subtype}" (line type "${obj.type ?? ''}") suggests native execution — reasoning-only invariant violated.`,
301
+ );
302
+ }
303
+ }
304
+
305
+ /** Default subprocess runner: invokes `agent` (or `COPPERHEAD_CURSOR_PATH`) in plan mode. */
306
+ export async function defaultCursorRun(args: CursorRunArgs): Promise<CursorRunResult> {
307
+ const bin = process.env.COPPERHEAD_CURSOR_PATH || 'agent';
308
+ const fullPrompt = [args.systemPrompt, args.prompt].filter(Boolean).join('\n\n---\n\n');
309
+ // Prompt goes on stdin — a single argv element caps at ~128 KiB on Linux
310
+ // (MAX_ARG_STRLEN); real .kicad_pcb reads routinely exceed that.
311
+ const cmdArgs = [
312
+ '--print',
313
+ '--output-format',
314
+ 'json',
315
+ '--mode',
316
+ 'plan',
317
+ '--trust',
318
+ '--sandbox',
319
+ 'enabled',
320
+ '--workspace',
321
+ args.workspace,
322
+ ];
323
+ if (args.model) cmdArgs.push('--model', args.model);
324
+ if (args.resume) cmdArgs.push('--resume', args.resume);
325
+
326
+ const { stdout } = await execa(bin, cmdArgs, {
327
+ input: fullPrompt,
328
+ env: args.env ?? subprocessEnv(),
329
+ cancelSignal: args.signal,
330
+ reject: true,
331
+ maxBuffer: 50 * 1024 * 1024,
332
+ });
333
+ return parseCursorStdout(stdout);
334
+ }
335
+
336
+ function isAuthError(err: unknown): boolean {
337
+ // Subprocess failures use exitCode, not HTTP status. Only treat real HTTP
338
+ // status fields as 401/403; otherwise match the CLI's auth message.
339
+ const status =
340
+ (err as { status?: number; statusCode?: number })?.status ??
341
+ (err as { statusCode?: number })?.statusCode;
342
+ if (status === 401 || status === 403) return true;
343
+ const m = ((err as Error)?.message ?? '').toLowerCase();
344
+ return /unauthenticat|unauthoriz|not logged in|please log in|login required|agent login/.test(m);
345
+ }
346
+
347
+ function authHint(detail: string): string {
348
+ return (
349
+ 'cursor is not authenticated: run `agent login` and verify with `agent status`. ' +
350
+ `Set COPPERHEAD_CURSOR_PATH if the CLI is not on PATH (original error: ${detail})`
351
+ );
352
+ }
353
+
354
+ function enhanceCliError(err: unknown): Error {
355
+ const original = err as Error & { code?: string; exitCode?: number };
356
+ if (original.code === 'ENOENT') {
357
+ return new Error(
358
+ 'Cursor Agent CLI not found on PATH. Install Cursor Agent or set COPPERHEAD_CURSOR_PATH to the `agent` binary.',
359
+ { cause: err },
360
+ );
361
+ }
362
+ if (original.message?.includes('reasoning-only invariant')) return original;
363
+ return new Error(`Cursor CLI provider failed: ${original.message}`, { cause: err });
364
+ }