@os-eco/overstory-cli 0.8.6 → 0.8.7

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.
@@ -0,0 +1,497 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { cleanupTempDir } from "../test-helpers.ts";
6
+ import type { ResolvedModel } from "../types.ts";
7
+ import { CursorRuntime } from "./cursor.ts";
8
+ import type { SpawnOpts } from "./types.ts";
9
+
10
+ describe("CursorRuntime", () => {
11
+ const runtime = new CursorRuntime();
12
+
13
+ describe("id and instructionPath", () => {
14
+ test("id is 'cursor'", () => {
15
+ expect(runtime.id).toBe("cursor");
16
+ });
17
+
18
+ test("instructionPath is .cursor/rules/overstory.md", () => {
19
+ expect(runtime.instructionPath).toBe(".cursor/rules/overstory.md");
20
+ });
21
+ });
22
+
23
+ describe("buildSpawnCommand", () => {
24
+ test("bypass permission mode includes --yolo", () => {
25
+ const opts: SpawnOpts = {
26
+ model: "sonnet",
27
+ permissionMode: "bypass",
28
+ cwd: "/tmp/worktree",
29
+ env: {},
30
+ };
31
+ const cmd = runtime.buildSpawnCommand(opts);
32
+ expect(cmd).toBe("agent --model sonnet --yolo");
33
+ });
34
+
35
+ test("ask permission mode omits permission flag", () => {
36
+ const opts: SpawnOpts = {
37
+ model: "opus",
38
+ permissionMode: "ask",
39
+ cwd: "/tmp/worktree",
40
+ env: {},
41
+ };
42
+ const cmd = runtime.buildSpawnCommand(opts);
43
+ expect(cmd).toBe("agent --model opus");
44
+ expect(cmd).not.toContain("--yolo");
45
+ expect(cmd).not.toContain("--permission-mode");
46
+ });
47
+
48
+ test("appendSystemPrompt is ignored (agent CLI has no such flag)", () => {
49
+ const opts: SpawnOpts = {
50
+ model: "sonnet",
51
+ permissionMode: "bypass",
52
+ cwd: "/tmp/worktree",
53
+ env: {},
54
+ appendSystemPrompt: "You are a builder agent.",
55
+ };
56
+ const cmd = runtime.buildSpawnCommand(opts);
57
+ expect(cmd).toBe("agent --model sonnet --yolo");
58
+ expect(cmd).not.toContain("append-system-prompt");
59
+ expect(cmd).not.toContain("You are a builder agent");
60
+ });
61
+
62
+ test("appendSystemPromptFile is ignored (agent CLI has no such flag)", () => {
63
+ const opts: SpawnOpts = {
64
+ model: "opus",
65
+ permissionMode: "bypass",
66
+ cwd: "/project",
67
+ env: {},
68
+ appendSystemPromptFile: "/project/.overstory/agent-defs/coordinator.md",
69
+ };
70
+ const cmd = runtime.buildSpawnCommand(opts);
71
+ expect(cmd).toBe("agent --model opus --yolo");
72
+ expect(cmd).not.toContain("cat");
73
+ expect(cmd).not.toContain("coordinator.md");
74
+ });
75
+
76
+ test("cwd and env are not embedded in command string", () => {
77
+ const opts: SpawnOpts = {
78
+ model: "sonnet",
79
+ permissionMode: "bypass",
80
+ cwd: "/some/specific/path",
81
+ env: { CURSOR_API_KEY: "test-key-123" },
82
+ };
83
+ const cmd = runtime.buildSpawnCommand(opts);
84
+ expect(cmd).not.toContain("/some/specific/path");
85
+ expect(cmd).not.toContain("test-key-123");
86
+ expect(cmd).not.toContain("CURSOR_API_KEY");
87
+ });
88
+
89
+ test("all model names pass through unchanged", () => {
90
+ for (const model of ["sonnet", "opus", "haiku", "gpt-4o", "openrouter/gpt-5"]) {
91
+ const opts: SpawnOpts = {
92
+ model,
93
+ permissionMode: "bypass",
94
+ cwd: "/tmp",
95
+ env: {},
96
+ };
97
+ const cmd = runtime.buildSpawnCommand(opts);
98
+ expect(cmd).toContain(`--model ${model}`);
99
+ }
100
+ });
101
+
102
+ test("produces identical output for same inputs (deterministic)", () => {
103
+ const opts: SpawnOpts = {
104
+ model: "sonnet",
105
+ permissionMode: "bypass",
106
+ cwd: "/tmp/worktree",
107
+ env: {},
108
+ };
109
+ const cmd1 = runtime.buildSpawnCommand(opts);
110
+ const cmd2 = runtime.buildSpawnCommand(opts);
111
+ expect(cmd1).toBe(cmd2);
112
+ });
113
+ });
114
+
115
+ describe("buildPrintCommand", () => {
116
+ test("basic prompt includes -p and --yolo", () => {
117
+ const argv = runtime.buildPrintCommand("Summarize this diff");
118
+ expect(argv).toEqual(["agent", "-p", "Summarize this diff", "--yolo"]);
119
+ });
120
+
121
+ test("with model override appends --model flag", () => {
122
+ const argv = runtime.buildPrintCommand("Classify this error", "sonnet");
123
+ expect(argv).toEqual(["agent", "-p", "Classify this error", "--yolo", "--model", "sonnet"]);
124
+ });
125
+
126
+ test("without model omits --model flag", () => {
127
+ const argv = runtime.buildPrintCommand("Hello");
128
+ expect(argv).not.toContain("--model");
129
+ });
130
+
131
+ test("model undefined omits --model flag", () => {
132
+ const argv = runtime.buildPrintCommand("Hello", undefined);
133
+ expect(argv).not.toContain("--model");
134
+ expect(argv).toContain("--yolo");
135
+ });
136
+
137
+ test("--yolo always present regardless of model", () => {
138
+ const withModel = runtime.buildPrintCommand("prompt", "opus");
139
+ const withoutModel = runtime.buildPrintCommand("prompt");
140
+ expect(withModel).toContain("--yolo");
141
+ expect(withoutModel).toContain("--yolo");
142
+ });
143
+
144
+ test("prompt with special characters is preserved", () => {
145
+ const prompt = 'Fix the "bug" in file\'s path & run tests';
146
+ const argv = runtime.buildPrintCommand(prompt);
147
+ expect(argv[2]).toBe(prompt);
148
+ });
149
+
150
+ test("empty prompt is passed through", () => {
151
+ const argv = runtime.buildPrintCommand("");
152
+ expect(argv).toEqual(["agent", "-p", "", "--yolo"]);
153
+ });
154
+ });
155
+
156
+ describe("detectReady", () => {
157
+ test("returns loading for empty pane", () => {
158
+ expect(runtime.detectReady("")).toEqual({ phase: "loading" });
159
+ });
160
+
161
+ test("returns loading for partial content (prompt only, no status bar)", () => {
162
+ const state = runtime.detectReady("Welcome!\n\u276f");
163
+ expect(state).toEqual({ phase: "loading" });
164
+ });
165
+
166
+ test("returns loading for partial content (status bar only, no prompt)", () => {
167
+ const state = runtime.detectReady("shift+tab to toggle");
168
+ expect(state).toEqual({ phase: "loading" });
169
+ });
170
+
171
+ test("returns ready for ❯ + shift+tab", () => {
172
+ const state = runtime.detectReady("Cursor Agent\n\u276f\nshift+tab to chat");
173
+ expect(state).toEqual({ phase: "ready" });
174
+ });
175
+
176
+ test("returns ready for ❯ + esc", () => {
177
+ const state = runtime.detectReady("Cursor Agent\n\u276f\nesc to cancel");
178
+ expect(state).toEqual({ phase: "ready" });
179
+ });
180
+
181
+ test("returns ready for ❯ + agent keyword", () => {
182
+ const state = runtime.detectReady("Agent Ready\n\u276f\ntype here");
183
+ expect(state).toEqual({ phase: "ready" });
184
+ });
185
+
186
+ test("returns ready for > prefix + shift+tab", () => {
187
+ const pane = "Cursor\n> \nshift+tab";
188
+ expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
189
+ });
190
+
191
+ test("returns ready for > prefix + esc", () => {
192
+ const pane = "Some content\n> type here\npress esc to exit";
193
+ expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
194
+ });
195
+
196
+ test("returns ready for > prefix + agent keyword", () => {
197
+ const pane = "Agent v1.0\n> ";
198
+ expect(runtime.detectReady(pane)).toEqual({ phase: "ready" });
199
+ });
200
+
201
+ test("case-insensitive match for status bar keywords", () => {
202
+ expect(runtime.detectReady("\u276f\nSHIFT+TAB")).toEqual({ phase: "ready" });
203
+ expect(runtime.detectReady("\u276f\nESC to exit")).toEqual({ phase: "ready" });
204
+ expect(runtime.detectReady("\u276f\nAGENT mode")).toEqual({ phase: "ready" });
205
+ });
206
+
207
+ test("returns loading for random pane content", () => {
208
+ expect(runtime.detectReady("Loading...\nPlease wait")).toEqual({ phase: "loading" });
209
+ });
210
+
211
+ test("never returns dialog phase", () => {
212
+ const panes = [
213
+ "",
214
+ "Cursor Agent",
215
+ "> ready",
216
+ "\u276f\nshift+tab",
217
+ "Loading...",
218
+ "trust this folder",
219
+ ];
220
+ for (const pane of panes) {
221
+ const result = runtime.detectReady(pane);
222
+ expect(result.phase).not.toBe("dialog");
223
+ }
224
+ });
225
+
226
+ test("Shift+Tab (capital) is matched case-insensitively", () => {
227
+ const state = runtime.detectReady("\u276f\nShift+Tab to toggle");
228
+ expect(state).toEqual({ phase: "ready" });
229
+ });
230
+ });
231
+
232
+ describe("deployConfig", () => {
233
+ let tempDir: string;
234
+
235
+ beforeEach(async () => {
236
+ tempDir = await mkdtemp(join(tmpdir(), "ov-cursor-test-"));
237
+ });
238
+
239
+ afterEach(async () => {
240
+ await cleanupTempDir(tempDir);
241
+ });
242
+
243
+ test("writes overlay to .cursor/rules/overstory.md when provided", async () => {
244
+ const worktreePath = join(tempDir, "worktree");
245
+
246
+ await runtime.deployConfig(
247
+ worktreePath,
248
+ { content: "# Cursor Instructions\nYou are a builder." },
249
+ { agentName: "test-builder", capability: "builder", worktreePath },
250
+ );
251
+
252
+ const overlayPath = join(worktreePath, ".cursor", "rules", "overstory.md");
253
+ const content = await Bun.file(overlayPath).text();
254
+ expect(content).toBe("# Cursor Instructions\nYou are a builder.");
255
+ });
256
+
257
+ test("creates .cursor/rules/ directory if it does not exist", async () => {
258
+ const worktreePath = join(tempDir, "new-worktree");
259
+
260
+ await runtime.deployConfig(
261
+ worktreePath,
262
+ { content: "# Instructions" },
263
+ { agentName: "test", capability: "builder", worktreePath },
264
+ );
265
+
266
+ const fileExists = await Bun.file(
267
+ join(worktreePath, ".cursor", "rules", "overstory.md"),
268
+ ).exists();
269
+ expect(fileExists).toBe(true);
270
+ });
271
+
272
+ test("skips overlay write when overlay is undefined", async () => {
273
+ const worktreePath = join(tempDir, "worktree");
274
+
275
+ await runtime.deployConfig(worktreePath, undefined, {
276
+ agentName: "coordinator",
277
+ capability: "coordinator",
278
+ worktreePath,
279
+ });
280
+
281
+ const overlayExists = await Bun.file(
282
+ join(worktreePath, ".cursor", "rules", "overstory.md"),
283
+ ).exists();
284
+ expect(overlayExists).toBe(false);
285
+ });
286
+
287
+ test("does not write guard files (no hook deployment)", async () => {
288
+ const worktreePath = join(tempDir, "worktree");
289
+
290
+ await runtime.deployConfig(
291
+ worktreePath,
292
+ { content: "# Instructions" },
293
+ {
294
+ agentName: "test-builder",
295
+ capability: "builder",
296
+ worktreePath,
297
+ qualityGates: [
298
+ { command: "bun test", name: "tests", description: "all tests must pass" },
299
+ ],
300
+ },
301
+ );
302
+
303
+ const overlayFile = Bun.file(join(worktreePath, ".cursor", "rules", "overstory.md"));
304
+ expect(await overlayFile.exists()).toBe(true);
305
+
306
+ const settingsFile = Bun.file(join(worktreePath, ".claude", "settings.local.json"));
307
+ expect(await settingsFile.exists()).toBe(false);
308
+
309
+ const piGuardFile = Bun.file(join(worktreePath, ".pi", "extensions", "overstory-guard.ts"));
310
+ expect(await piGuardFile.exists()).toBe(false);
311
+ });
312
+
313
+ test("overwrites existing overlay file", async () => {
314
+ const overlayPath = join(tempDir, ".cursor", "rules", "overstory.md");
315
+ const { mkdir: mkdirFS } = await import("node:fs/promises");
316
+ await mkdirFS(join(tempDir, ".cursor", "rules"), { recursive: true });
317
+ await Bun.write(overlayPath, "# Old content");
318
+
319
+ await runtime.deployConfig(
320
+ tempDir,
321
+ { content: "# New content" },
322
+ { agentName: "test-agent", capability: "builder", worktreePath: tempDir },
323
+ );
324
+
325
+ const content = await Bun.file(overlayPath).text();
326
+ expect(content).toBe("# New content");
327
+ });
328
+ });
329
+
330
+ describe("parseTranscript", () => {
331
+ let tempDir: string;
332
+
333
+ beforeEach(async () => {
334
+ tempDir = await mkdtemp(join(tmpdir(), "ov-cursor-transcript-"));
335
+ });
336
+
337
+ afterEach(async () => {
338
+ await cleanupTempDir(tempDir);
339
+ });
340
+
341
+ test("returns null for non-existent file", async () => {
342
+ const result = await runtime.parseTranscript(join(tempDir, "does-not-exist.jsonl"));
343
+ expect(result).toBeNull();
344
+ });
345
+
346
+ test("extracts model from system/init event", async () => {
347
+ const transcript = [
348
+ JSON.stringify({
349
+ type: "system",
350
+ subtype: "init",
351
+ model: "claude-sonnet-4-6",
352
+ }),
353
+ ].join("\n");
354
+
355
+ const path = join(tempDir, "transcript.jsonl");
356
+ await Bun.write(path, transcript);
357
+
358
+ const result = await runtime.parseTranscript(path);
359
+ expect(result).not.toBeNull();
360
+ expect(result?.model).toBe("claude-sonnet-4-6");
361
+ });
362
+
363
+ test("always returns zero tokens (not available in Cursor format)", async () => {
364
+ const transcript = [
365
+ JSON.stringify({ type: "system", subtype: "init", model: "sonnet" }),
366
+ JSON.stringify({ type: "assistant", content: "Hello world" }),
367
+ ].join("\n");
368
+
369
+ const path = join(tempDir, "transcript.jsonl");
370
+ await Bun.write(path, transcript);
371
+
372
+ const result = await runtime.parseTranscript(path);
373
+ expect(result?.inputTokens).toBe(0);
374
+ expect(result?.outputTokens).toBe(0);
375
+ });
376
+
377
+ test("skips malformed JSON lines and continues parsing", async () => {
378
+ const goodEntry = JSON.stringify({
379
+ type: "system",
380
+ subtype: "init",
381
+ model: "opus",
382
+ });
383
+
384
+ const path = join(tempDir, "transcript.jsonl");
385
+ await Bun.write(path, `not json at all\n${goodEntry}\n{broken`);
386
+
387
+ const result = await runtime.parseTranscript(path);
388
+ expect(result).not.toBeNull();
389
+ expect(result?.model).toBe("opus");
390
+ expect(result?.inputTokens).toBe(0);
391
+ expect(result?.outputTokens).toBe(0);
392
+ });
393
+
394
+ test("returns empty model for empty file", async () => {
395
+ const path = join(tempDir, "empty.jsonl");
396
+ await Bun.write(path, "");
397
+
398
+ const result = await runtime.parseTranscript(path);
399
+ expect(result).not.toBeNull();
400
+ expect(result?.inputTokens).toBe(0);
401
+ expect(result?.outputTokens).toBe(0);
402
+ expect(result?.model).toBe("");
403
+ });
404
+
405
+ test("handles trailing newlines", async () => {
406
+ const transcript = `${JSON.stringify({ type: "system", subtype: "init", model: "sonnet" })}\n\n\n`;
407
+
408
+ const path = join(tempDir, "transcript.jsonl");
409
+ await Bun.write(path, transcript);
410
+
411
+ const result = await runtime.parseTranscript(path);
412
+ expect(result?.model).toBe("sonnet");
413
+ expect(result?.inputTokens).toBe(0);
414
+ });
415
+
416
+ test("ignores non-init system events", async () => {
417
+ const transcript = [
418
+ JSON.stringify({ type: "system", subtype: "heartbeat", model: "should-not-match" }),
419
+ JSON.stringify({ type: "system", subtype: "init", model: "correct-model" }),
420
+ ].join("\n");
421
+
422
+ const path = join(tempDir, "transcript.jsonl");
423
+ await Bun.write(path, transcript);
424
+
425
+ const result = await runtime.parseTranscript(path);
426
+ expect(result?.model).toBe("correct-model");
427
+ });
428
+
429
+ test("last init event wins when multiple are present", async () => {
430
+ const transcript = [
431
+ JSON.stringify({ type: "system", subtype: "init", model: "first-model" }),
432
+ JSON.stringify({ type: "system", subtype: "init", model: "second-model" }),
433
+ ].join("\n");
434
+
435
+ const path = join(tempDir, "transcript.jsonl");
436
+ await Bun.write(path, transcript);
437
+
438
+ const result = await runtime.parseTranscript(path);
439
+ expect(result?.model).toBe("second-model");
440
+ });
441
+ });
442
+
443
+ describe("buildEnv", () => {
444
+ test("returns empty object when model has no env", () => {
445
+ const model: ResolvedModel = { model: "sonnet" };
446
+ const env = runtime.buildEnv(model);
447
+ expect(env).toEqual({});
448
+ });
449
+
450
+ test("returns model.env when present", () => {
451
+ const model: ResolvedModel = {
452
+ model: "gpt-4o",
453
+ env: { CURSOR_API_KEY: "test-key", CURSOR_HOST: "https://cursor.sh" },
454
+ };
455
+ const env = runtime.buildEnv(model);
456
+ expect(env).toEqual({
457
+ CURSOR_API_KEY: "test-key",
458
+ CURSOR_HOST: "https://cursor.sh",
459
+ });
460
+ });
461
+
462
+ test("returns empty object when model.env is undefined", () => {
463
+ const model: ResolvedModel = { model: "opus", env: undefined };
464
+ const env = runtime.buildEnv(model);
465
+ expect(env).toEqual({});
466
+ });
467
+
468
+ test("env is safe to spread into session env", () => {
469
+ const model: ResolvedModel = { model: "sonnet" };
470
+ const env = runtime.buildEnv(model);
471
+ const combined = { ...env, OVERSTORY_AGENT_NAME: "builder-1" };
472
+ expect(combined).toEqual({ OVERSTORY_AGENT_NAME: "builder-1" });
473
+ });
474
+ });
475
+
476
+ describe("getTranscriptDir", () => {
477
+ test("returns null (transcript location not yet verified)", () => {
478
+ expect(runtime.getTranscriptDir("/some/project")).toBeNull();
479
+ });
480
+ });
481
+
482
+ describe("requiresBeaconVerification", () => {
483
+ test("not defined — defaults to true (gets resend loop)", () => {
484
+ expect("requiresBeaconVerification" in runtime).toBe(false);
485
+ });
486
+ });
487
+ });
488
+
489
+ describe("CursorRuntime integration: registry resolves 'cursor'", () => {
490
+ test("getRuntime('cursor') returns CursorRuntime", async () => {
491
+ const { getRuntime } = await import("./registry.ts");
492
+ const rt = getRuntime("cursor");
493
+ expect(rt).toBeInstanceOf(CursorRuntime);
494
+ expect(rt.id).toBe("cursor");
495
+ expect(rt.instructionPath).toBe(".cursor/rules/overstory.md");
496
+ });
497
+ });
@@ -0,0 +1,205 @@
1
+ // Cursor CLI runtime adapter for overstory's AgentRuntime interface.
2
+ // Implements the AgentRuntime contract for the `agent` binary (Cursor's CLI agent).
3
+ //
4
+ // Key characteristics:
5
+ // - TUI: `agent` maintains an interactive TUI in tmux
6
+ // - Instruction file: .cursor/rules/overstory.md (Cursor's native rules system)
7
+ // - No hooks: Cursor CLI has no hook/guard mechanism (like Copilot/Gemini)
8
+ // - Permission: `--yolo` flag for bypass mode
9
+ // - Headless: `agent -p "prompt"` for one-shot calls
10
+ // - Transcripts: stream-json NDJSON with system/init events for model
11
+
12
+ import { mkdir } from "node:fs/promises";
13
+ import { dirname, join } from "node:path";
14
+ import type { ResolvedModel } from "../types.ts";
15
+ import type {
16
+ AgentRuntime,
17
+ HooksDef,
18
+ OverlayContent,
19
+ ReadyState,
20
+ SpawnOpts,
21
+ TranscriptSummary,
22
+ } from "./types.ts";
23
+
24
+ /**
25
+ * Cursor CLI runtime adapter.
26
+ *
27
+ * Implements AgentRuntime for the `agent` binary (Cursor's coding agent CLI).
28
+ * The CLI binary is `agent`, not `cursor`.
29
+ *
30
+ * Instructions are delivered via `.cursor/rules/overstory.md` (Cursor's
31
+ * native rules system), which the CLI reads automatically from the workspace.
32
+ *
33
+ * No hook/guard deployment — the `_hooks` parameter in `deployConfig`
34
+ * is unused, same as Copilot and Gemini.
35
+ */
36
+ export class CursorRuntime implements AgentRuntime {
37
+ /** Unique identifier for this runtime. */
38
+ readonly id = "cursor";
39
+
40
+ /** Stability tier for this runtime. */
41
+ readonly stability = "experimental" as const;
42
+
43
+ /** Relative path to the instruction file within a worktree. */
44
+ readonly instructionPath = ".cursor/rules/overstory.md";
45
+
46
+ /**
47
+ * Build the shell command string to spawn an interactive Cursor agent.
48
+ *
49
+ * Maps SpawnOpts to `agent` CLI flags:
50
+ * - `model` → `--model <model>`
51
+ * - `permissionMode === "bypass"` → `--yolo`
52
+ * - `permissionMode === "ask"` → no permission flag
53
+ * - `appendSystemPrompt` and `appendSystemPromptFile` are IGNORED —
54
+ * the `agent` CLI has no equivalent flag.
55
+ *
56
+ * The `cwd` and `env` fields of SpawnOpts are handled by the tmux session
57
+ * creator, not embedded in the command string.
58
+ *
59
+ * @param opts - Spawn options (model, permissionMode; appendSystemPrompt ignored)
60
+ * @returns Shell command string suitable for tmux new-session -c
61
+ */
62
+ buildSpawnCommand(opts: SpawnOpts): string {
63
+ let cmd = `agent --model ${opts.model}`;
64
+
65
+ if (opts.permissionMode === "bypass") {
66
+ cmd += " --yolo";
67
+ }
68
+
69
+ return cmd;
70
+ }
71
+
72
+ /**
73
+ * Build the argv array for a headless one-shot Cursor invocation.
74
+ *
75
+ * Returns an argv array suitable for `Bun.spawn()`. The `-p` flag
76
+ * triggers headless/print mode. `--yolo` is always included to
77
+ * auto-approve tool calls for headless operations.
78
+ *
79
+ * @param prompt - The prompt to pass via `-p`
80
+ * @param model - Optional model override
81
+ * @returns Argv array for Bun.spawn
82
+ */
83
+ buildPrintCommand(prompt: string, model?: string): string[] {
84
+ const cmd = ["agent", "-p", prompt, "--yolo"];
85
+ if (model !== undefined) {
86
+ cmd.push("--model", model);
87
+ }
88
+ return cmd;
89
+ }
90
+
91
+ /**
92
+ * Deploy per-agent instructions to a worktree.
93
+ *
94
+ * Writes the overlay to `.cursor/rules/overstory.md` in the worktree
95
+ * (Cursor's native rules system). Creates the `.cursor/rules/` directory
96
+ * if it doesn't exist.
97
+ *
98
+ * The `hooks` parameter is unused — Cursor CLI has no hook mechanism
99
+ * for per-tool interception.
100
+ *
101
+ * @param worktreePath - Absolute path to the agent's git worktree
102
+ * @param overlay - Overlay content to write, or undefined to skip
103
+ * @param _hooks - Unused for Cursor runtime
104
+ */
105
+ async deployConfig(
106
+ worktreePath: string,
107
+ overlay: OverlayContent | undefined,
108
+ _hooks: HooksDef,
109
+ ): Promise<void> {
110
+ if (!overlay) return;
111
+
112
+ const filePath = join(worktreePath, this.instructionPath);
113
+ await mkdir(dirname(filePath), { recursive: true });
114
+ await Bun.write(filePath, overlay.content);
115
+ }
116
+
117
+ /**
118
+ * Detect Cursor TUI readiness from a tmux pane content snapshot.
119
+ *
120
+ * Detection requires both a prompt indicator AND a status indicator
121
+ * (AND logic):
122
+ *
123
+ * - Prompt: U+276F (❯) or `> ` at line start (`/^> /m`)
124
+ * - Status: "shift+tab", "esc", or "agent" in pane content (case-insensitive)
125
+ *
126
+ * No trust dialog phase exists for Cursor (unlike Claude Code).
127
+ *
128
+ * @param paneContent - Captured tmux pane content to analyze
129
+ * @returns Current readiness phase (never "dialog" for Cursor)
130
+ */
131
+ detectReady(paneContent: string): ReadyState {
132
+ const lower = paneContent.toLowerCase();
133
+
134
+ const hasPrompt = paneContent.includes("\u276f") || /^> /m.test(paneContent);
135
+
136
+ const hasStatusBar =
137
+ lower.includes("shift+tab") || lower.includes("esc") || lower.includes("agent");
138
+
139
+ if (hasPrompt && hasStatusBar) {
140
+ return { phase: "ready" };
141
+ }
142
+
143
+ return { phase: "loading" };
144
+ }
145
+
146
+ /**
147
+ * Parse a Cursor stream-json NDJSON transcript into normalized token usage.
148
+ *
149
+ * Cursor's transcript format uses `{ type: "system", subtype: "init", model: "..." }`
150
+ * events for model identification. Token usage is NOT available in Cursor's
151
+ * format, so inputTokens and outputTokens are always 0.
152
+ *
153
+ * @param path - Absolute path to the transcript NDJSON file
154
+ * @returns Normalized summary with model and zero tokens, or null if unavailable
155
+ */
156
+ async parseTranscript(path: string): Promise<TranscriptSummary | null> {
157
+ const file = Bun.file(path);
158
+ if (!(await file.exists())) {
159
+ return null;
160
+ }
161
+
162
+ try {
163
+ const text = await file.text();
164
+ const lines = text.split("\n").filter((l) => l.trim().length > 0);
165
+
166
+ let model = "";
167
+
168
+ for (const line of lines) {
169
+ let event: Record<string, unknown>;
170
+ try {
171
+ event = JSON.parse(line) as Record<string, unknown>;
172
+ } catch {
173
+ continue;
174
+ }
175
+
176
+ if (
177
+ event.type === "system" &&
178
+ event.subtype === "init" &&
179
+ typeof event.model === "string"
180
+ ) {
181
+ model = event.model;
182
+ }
183
+ }
184
+
185
+ return { inputTokens: 0, outputTokens: 0, model };
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Build runtime-specific environment variables for model/provider routing.
193
+ *
194
+ * @param model - Resolved model with optional provider env vars
195
+ * @returns Environment variable map (may be empty)
196
+ */
197
+ buildEnv(model: ResolvedModel): Record<string, string> {
198
+ return model.env ?? {};
199
+ }
200
+
201
+ /** Cursor does not expose transcript file locations. */
202
+ getTranscriptDir(_projectRoot: string): string | null {
203
+ return null;
204
+ }
205
+ }