@os-eco/overstory-cli 0.9.2 → 0.9.3

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.
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { cleanupTempDir } from "../test-helpers.ts";
6
6
  import type { ResolvedModel } from "../types.ts";
7
- import { CopilotRuntime } from "./copilot.ts";
7
+ import { CopilotRuntime, ensureCopilotTrustedFolders } from "./copilot.ts";
8
8
  import type { SpawnOpts } from "./types.ts";
9
9
 
10
10
  describe("CopilotRuntime", () => {
@@ -29,7 +29,7 @@ describe("CopilotRuntime", () => {
29
29
  env: {},
30
30
  };
31
31
  const cmd = runtime.buildSpawnCommand(opts);
32
- expect(cmd).toBe("copilot --model sonnet --allow-all-tools");
32
+ expect(cmd).toBe("copilot --model claude-sonnet-4-6 --allow-all-tools");
33
33
  });
34
34
 
35
35
  test("ask permission mode omits permission flag", () => {
@@ -40,7 +40,7 @@ describe("CopilotRuntime", () => {
40
40
  env: {},
41
41
  };
42
42
  const cmd = runtime.buildSpawnCommand(opts);
43
- expect(cmd).toBe("copilot --model opus");
43
+ expect(cmd).toBe("copilot --model claude-opus-4-6");
44
44
  expect(cmd).not.toContain("--allow-all-tools");
45
45
  expect(cmd).not.toContain("--permission-mode");
46
46
  });
@@ -54,7 +54,7 @@ describe("CopilotRuntime", () => {
54
54
  appendSystemPrompt: "You are a builder agent.",
55
55
  };
56
56
  const cmd = runtime.buildSpawnCommand(opts);
57
- expect(cmd).toBe("copilot --model sonnet --allow-all-tools");
57
+ expect(cmd).toBe("copilot --model claude-sonnet-4-6 --allow-all-tools");
58
58
  expect(cmd).not.toContain("append-system-prompt");
59
59
  expect(cmd).not.toContain("You are a builder agent");
60
60
  });
@@ -68,7 +68,7 @@ describe("CopilotRuntime", () => {
68
68
  appendSystemPromptFile: "/project/.overstory/agent-defs/coordinator.md",
69
69
  };
70
70
  const cmd = runtime.buildSpawnCommand(opts);
71
- expect(cmd).toBe("copilot --model opus --allow-all-tools");
71
+ expect(cmd).toBe("copilot --model claude-opus-4-6 --allow-all-tools");
72
72
  expect(cmd).not.toContain("cat");
73
73
  expect(cmd).not.toContain("coordinator.md");
74
74
  });
@@ -86,16 +86,23 @@ describe("CopilotRuntime", () => {
86
86
  expect(cmd).not.toContain("GITHUB_TOKEN");
87
87
  });
88
88
 
89
- test("all model names pass through unchanged", () => {
90
- for (const model of ["sonnet", "opus", "haiku", "gpt-4o", "openrouter/gpt-5"]) {
89
+ test("known aliases expand to fully-qualified names, unknown models pass through", () => {
90
+ const cases: Array<[string, string]> = [
91
+ ["sonnet", "claude-sonnet-4-6"],
92
+ ["opus", "claude-opus-4-6"],
93
+ ["haiku", "claude-haiku-4-5"],
94
+ ["gpt-4o", "gpt-4o"],
95
+ ["openrouter/gpt-5", "openrouter/gpt-5"],
96
+ ];
97
+ for (const [alias, expected] of cases) {
91
98
  const opts: SpawnOpts = {
92
- model,
99
+ model: alias,
93
100
  permissionMode: "bypass",
94
101
  cwd: "/tmp",
95
102
  env: {},
96
103
  };
97
104
  const cmd = runtime.buildSpawnCommand(opts);
98
- expect(cmd).toContain(`--model ${model}`);
105
+ expect(cmd).toContain(`--model ${expected}`);
99
106
  }
100
107
  });
101
108
 
@@ -112,13 +119,37 @@ describe("CopilotRuntime", () => {
112
119
  });
113
120
  });
114
121
 
122
+ describe("expandModel", () => {
123
+ test("expands 'sonnet' to claude-sonnet-4-6", () => {
124
+ expect(runtime.expandModel("sonnet")).toBe("claude-sonnet-4-6");
125
+ });
126
+
127
+ test("expands 'opus' to claude-opus-4-6", () => {
128
+ expect(runtime.expandModel("opus")).toBe("claude-opus-4-6");
129
+ });
130
+
131
+ test("expands 'haiku' to claude-haiku-4-5", () => {
132
+ expect(runtime.expandModel("haiku")).toBe("claude-haiku-4-5");
133
+ });
134
+
135
+ test("passes through fully-qualified names unchanged", () => {
136
+ expect(runtime.expandModel("claude-sonnet-4-6")).toBe("claude-sonnet-4-6");
137
+ expect(runtime.expandModel("gpt-4o")).toBe("gpt-4o");
138
+ expect(runtime.expandModel("openrouter/gpt-5")).toBe("openrouter/gpt-5");
139
+ });
140
+
141
+ test("passes through unknown aliases unchanged", () => {
142
+ expect(runtime.expandModel("my-custom-model")).toBe("my-custom-model");
143
+ });
144
+ });
145
+
115
146
  describe("buildPrintCommand", () => {
116
147
  test("basic command without model includes --allow-all-tools", () => {
117
148
  const argv = runtime.buildPrintCommand("Summarize this diff");
118
149
  expect(argv).toEqual(["copilot", "-p", "Summarize this diff", "--allow-all-tools"]);
119
150
  });
120
151
 
121
- test("command with model override appends --model flag", () => {
152
+ test("command with model override appends --model flag (alias expanded)", () => {
122
153
  const argv = runtime.buildPrintCommand("Classify this error", "haiku");
123
154
  expect(argv).toEqual([
124
155
  "copilot",
@@ -126,7 +157,19 @@ describe("CopilotRuntime", () => {
126
157
  "Classify this error",
127
158
  "--allow-all-tools",
128
159
  "--model",
129
- "haiku",
160
+ "claude-haiku-4-5",
161
+ ]);
162
+ });
163
+
164
+ test("command with fully-qualified model passes through unchanged", () => {
165
+ const argv = runtime.buildPrintCommand("Summarize", "gpt-4o");
166
+ expect(argv).toEqual([
167
+ "copilot",
168
+ "-p",
169
+ "Summarize",
170
+ "--allow-all-tools",
171
+ "--model",
172
+ "gpt-4o",
130
173
  ]);
131
174
  });
132
175
 
@@ -289,13 +332,13 @@ describe("CopilotRuntime", () => {
289
332
  worktreePath,
290
333
  });
291
334
 
292
- // No overlay written — .github directory should not be created.
335
+ // No overlay written — copilot-instructions.md should not exist.
293
336
  const overlayPath = join(worktreePath, ".github", "copilot-instructions.md");
294
337
  const overlayExists = await Bun.file(overlayPath).exists();
295
338
  expect(overlayExists).toBe(false);
296
339
  });
297
340
 
298
- test("does not write settings.local.json (no hook deployment)", async () => {
341
+ test("does not write settings.local.json (Copilot uses its own hooks format)", async () => {
299
342
  const worktreePath = join(tempDir, "worktree");
300
343
 
301
344
  await runtime.deployConfig(
@@ -309,6 +352,42 @@ describe("CopilotRuntime", () => {
309
352
  const settingsExists = await Bun.file(settingsPath).exists();
310
353
  expect(settingsExists).toBe(false);
311
354
  });
355
+
356
+ test("writes .github/hooks/hooks.json with Copilot schema when deployConfig is called", async () => {
357
+ const worktreePath = join(tempDir, "worktree-hooks");
358
+
359
+ await runtime.deployConfig(
360
+ worktreePath,
361
+ { content: "# Instructions" },
362
+ { agentName: "test-builder", capability: "builder", worktreePath },
363
+ );
364
+
365
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
366
+ const hooksExists = await Bun.file(hooksPath).exists();
367
+ expect(hooksExists).toBe(true);
368
+
369
+ const hooksContent = JSON.parse(await Bun.file(hooksPath).text()) as Record<string, unknown>;
370
+ // Copilot schema: top-level "hooks" key with onSessionStart array
371
+ expect(hooksContent).toHaveProperty("hooks");
372
+ const hooks = hooksContent.hooks as Record<string, unknown>;
373
+ expect(hooks).toHaveProperty("onSessionStart");
374
+ expect(Array.isArray(hooks.onSessionStart)).toBe(true);
375
+ });
376
+
377
+ test("hooks.json contains agentName substituted in commands", async () => {
378
+ const worktreePath = join(tempDir, "worktree-agentname");
379
+
380
+ await runtime.deployConfig(worktreePath, undefined, {
381
+ agentName: "my-test-agent",
382
+ capability: "builder",
383
+ worktreePath,
384
+ });
385
+
386
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
387
+ const raw = await Bun.file(hooksPath).text();
388
+ expect(raw).toContain("my-test-agent");
389
+ expect(raw).not.toContain("{{AGENT_NAME}}");
390
+ });
312
391
  });
313
392
 
314
393
  describe("parseTranscript", () => {
@@ -496,6 +575,127 @@ describe("CopilotRuntime", () => {
496
575
  });
497
576
  });
498
577
 
578
+ describe("ensureCopilotTrustedFolders", () => {
579
+ let tempDir: string;
580
+
581
+ beforeEach(async () => {
582
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-copilot-trust-test-"));
583
+ });
584
+
585
+ afterEach(async () => {
586
+ await cleanupTempDir(tempDir);
587
+ });
588
+
589
+ test("creates config.json with trustedFolders when file does not exist", async () => {
590
+ const configDir = join(tempDir, "github-copilot");
591
+ await ensureCopilotTrustedFolders("/some/worktree", configDir);
592
+
593
+ const configPath = join(configDir, "config.json");
594
+ const content = JSON.parse(await Bun.file(configPath).text()) as Record<string, unknown>;
595
+ expect(Array.isArray(content.trustedFolders)).toBe(true);
596
+ expect(content.trustedFolders).toContain("/some/worktree");
597
+ });
598
+
599
+ test("creates config directory if it does not exist", async () => {
600
+ const configDir = join(tempDir, "nested", "github-copilot");
601
+ await ensureCopilotTrustedFolders("/my/worktree", configDir);
602
+
603
+ const configPath = join(configDir, "config.json");
604
+ const exists = await Bun.file(configPath).exists();
605
+ expect(exists).toBe(true);
606
+ });
607
+
608
+ test("appends to existing trustedFolders without duplicates", async () => {
609
+ const configDir = join(tempDir, "github-copilot");
610
+ await ensureCopilotTrustedFolders("/first/path", configDir);
611
+ await ensureCopilotTrustedFolders("/second/path", configDir);
612
+
613
+ const configPath = join(configDir, "config.json");
614
+ const content = JSON.parse(await Bun.file(configPath).text()) as Record<string, unknown>;
615
+ expect(Array.isArray(content.trustedFolders)).toBe(true);
616
+ const trusted = content.trustedFolders as string[];
617
+ expect(trusted).toContain("/first/path");
618
+ expect(trusted).toContain("/second/path");
619
+ expect(trusted.length).toBe(2);
620
+ });
621
+
622
+ test("does not duplicate existing entries", async () => {
623
+ const configDir = join(tempDir, "github-copilot");
624
+ await ensureCopilotTrustedFolders("/some/worktree", configDir);
625
+ await ensureCopilotTrustedFolders("/some/worktree", configDir);
626
+
627
+ const configPath = join(configDir, "config.json");
628
+ const content = JSON.parse(await Bun.file(configPath).text()) as Record<string, unknown>;
629
+ const trusted = content.trustedFolders as string[];
630
+ expect(trusted.filter((p) => p === "/some/worktree").length).toBe(1);
631
+ });
632
+
633
+ test("preserves other config keys when updating trustedFolders", async () => {
634
+ const configDir = join(tempDir, "github-copilot");
635
+ const configPath = join(configDir, "config.json");
636
+ await Bun.write(
637
+ configPath,
638
+ `${JSON.stringify({ someOtherKey: "value", trustedFolders: [] }, null, "\t")}\n`,
639
+ );
640
+
641
+ await ensureCopilotTrustedFolders("/new/worktree", configDir);
642
+
643
+ const content = JSON.parse(await Bun.file(configPath).text()) as Record<string, unknown>;
644
+ expect(content.someOtherKey).toBe("value");
645
+ expect(content.trustedFolders as string[]).toContain("/new/worktree");
646
+ });
647
+
648
+ test("handles invalid JSON in existing config by starting fresh", async () => {
649
+ const configDir = join(tempDir, "github-copilot");
650
+ await Bun.write(join(configDir, "config.json"), "not valid json");
651
+ await ensureCopilotTrustedFolders("/new/worktree", configDir);
652
+
653
+ const configPath = join(configDir, "config.json");
654
+ const content = JSON.parse(await Bun.file(configPath).text()) as Record<string, unknown>;
655
+ expect(content.trustedFolders as string[]).toContain("/new/worktree");
656
+ });
657
+
658
+ test("treats non-array trustedFolders as empty and replaces it", async () => {
659
+ const configDir = join(tempDir, "github-copilot");
660
+ const configPath = join(configDir, "config.json");
661
+ await Bun.write(
662
+ configPath,
663
+ `${JSON.stringify({ trustedFolders: "not-an-array" }, null, "\t")}\n`,
664
+ );
665
+
666
+ await ensureCopilotTrustedFolders("/my/worktree", configDir);
667
+
668
+ const content = JSON.parse(await Bun.file(configPath).text()) as Record<string, unknown>;
669
+ expect(Array.isArray(content.trustedFolders)).toBe(true);
670
+ expect(content.trustedFolders as string[]).toContain("/my/worktree");
671
+ });
672
+ });
673
+
674
+ describe("CopilotRuntime.prepareWorktree", () => {
675
+ let tempDir: string;
676
+
677
+ beforeEach(async () => {
678
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-copilot-prepworktree-test-"));
679
+ });
680
+
681
+ afterEach(async () => {
682
+ await cleanupTempDir(tempDir);
683
+ });
684
+
685
+ test("prepareWorktree is defined on CopilotRuntime", () => {
686
+ const runtime = new CopilotRuntime();
687
+ expect(typeof runtime.prepareWorktree).toBe("function");
688
+ });
689
+
690
+ test("calling prepareWorktree with a worktreePath does not throw", async () => {
691
+ // We can't inject configDir through the interface method, but we can verify
692
+ // it doesn't throw. The actual file write goes to real homedir, which is
693
+ // tested via ensureCopilotTrustedFolders directly.
694
+ const runtime = new CopilotRuntime();
695
+ await expect(runtime.prepareWorktree("/test/worktree")).resolves.toBeUndefined();
696
+ });
697
+ });
698
+
499
699
  describe("CopilotRuntime integration: registry resolves 'copilot'", () => {
500
700
  test("getRuntime('copilot') returns CopilotRuntime", async () => {
501
701
  const { getRuntime } = await import("./registry.ts");
@@ -2,7 +2,9 @@
2
2
  // Implements the AgentRuntime contract for the `copilot` CLI (GitHub Copilot).
3
3
 
4
4
  import { mkdir } from "node:fs/promises";
5
+ import { homedir } from "node:os";
5
6
  import { join } from "node:path";
7
+ import { deployCopilotHooks } from "../agents/copilot-hooks-deployer.ts";
6
8
  import type { ResolvedModel } from "../types.ts";
7
9
  import type {
8
10
  AgentRuntime,
@@ -13,6 +15,54 @@ import type {
13
15
  TranscriptSummary,
14
16
  } from "./types.ts";
15
17
 
18
+ /**
19
+ * Map of overstory model aliases to fully-qualified Copilot model names.
20
+ *
21
+ * The copilot CLI rejects short aliases like "sonnet" and requires fully-qualified
22
+ * names like "claude-sonnet-4-6". Unknown names (e.g. "gpt-4o", "openrouter/gpt-5")
23
+ * are passed through unchanged.
24
+ */
25
+ const MODEL_MAP: Record<string, string> = {
26
+ sonnet: "claude-sonnet-4-6",
27
+ opus: "claude-opus-4-6",
28
+ haiku: "claude-haiku-4-5",
29
+ };
30
+
31
+ /**
32
+ * Add a worktree path to the Copilot trusted folders list.
33
+ *
34
+ * Reads `~/.config/github-copilot/config.json`, appends the worktreePath to
35
+ * the `trustedFolders` array if not already present, and writes back atomically.
36
+ * Creates the config directory if it does not exist.
37
+ *
38
+ * Exported for testability — callers can inject a custom configDir to avoid
39
+ * touching the real home directory in tests.
40
+ *
41
+ * @param worktreePath - Absolute path to the worktree to pre-trust
42
+ * @param configDir - Override the config directory (default: ~/.config/github-copilot)
43
+ */
44
+ export async function ensureCopilotTrustedFolders(
45
+ worktreePath: string,
46
+ configDir: string = join(homedir(), ".config", "github-copilot"),
47
+ ): Promise<void> {
48
+ const configPath = join(configDir, "config.json");
49
+ await mkdir(configDir, { recursive: true });
50
+
51
+ let config: Record<string, unknown> = {};
52
+ try {
53
+ config = JSON.parse(await Bun.file(configPath).text()) as Record<string, unknown>;
54
+ } catch {
55
+ // File doesn't exist or contains invalid JSON — start fresh.
56
+ }
57
+
58
+ const trusted = Array.isArray(config.trustedFolders) ? (config.trustedFolders as string[]) : [];
59
+ if (!trusted.includes(worktreePath)) {
60
+ trusted.push(worktreePath);
61
+ config.trustedFolders = trusted;
62
+ await Bun.write(configPath, `${JSON.stringify(config, null, "\t")}\n`);
63
+ }
64
+ }
65
+
16
66
  /**
17
67
  * GitHub Copilot runtime adapter.
18
68
  *
@@ -34,11 +84,25 @@ export class CopilotRuntime implements AgentRuntime {
34
84
  /** Relative path to the instruction file within a worktree. */
35
85
  readonly instructionPath = ".github/copilot-instructions.md";
36
86
 
87
+ /**
88
+ * Expand a model alias to a fully-qualified Copilot model name.
89
+ *
90
+ * Looks up the alias in MODEL_MAP. If not found, returns the model unchanged.
91
+ * This allows known aliases (sonnet, opus, haiku) to be resolved while
92
+ * passing through fully-qualified names (e.g. gpt-4o, openrouter/gpt-5).
93
+ *
94
+ * @param model - Short alias or fully-qualified model name
95
+ * @returns Fully-qualified model name accepted by the copilot CLI
96
+ */
97
+ expandModel(model: string): string {
98
+ return MODEL_MAP[model] ?? model;
99
+ }
100
+
37
101
  /**
38
102
  * Build the shell command string to spawn an interactive Copilot agent.
39
103
  *
40
104
  * Maps SpawnOpts to `copilot` CLI flags:
41
- * - `model` → `--model <model>`
105
+ * - `model` → `--model <model>` (aliases expanded via MODEL_MAP)
42
106
  * - `permissionMode === "bypass"` → `--allow-all-tools`
43
107
  * - `permissionMode === "ask"` → no permission flag added
44
108
  * - `appendSystemPrompt` and `appendSystemPromptFile` are IGNORED —
@@ -51,7 +115,7 @@ export class CopilotRuntime implements AgentRuntime {
51
115
  * @returns Shell command string suitable for tmux new-session -c
52
116
  */
53
117
  buildSpawnCommand(opts: SpawnOpts): string {
54
- let cmd = `copilot --model ${opts.model}`;
118
+ let cmd = `copilot --model ${this.expandModel(opts.model)}`;
55
119
 
56
120
  if (opts.permissionMode === "bypass") {
57
121
  cmd += " --allow-all-tools";
@@ -79,29 +143,27 @@ export class CopilotRuntime implements AgentRuntime {
79
143
  buildPrintCommand(prompt: string, model?: string): string[] {
80
144
  const cmd = ["copilot", "-p", prompt, "--allow-all-tools"];
81
145
  if (model !== undefined) {
82
- cmd.push("--model", model);
146
+ cmd.push("--model", this.expandModel(model));
83
147
  }
84
148
  return cmd;
85
149
  }
86
150
 
87
151
  /**
88
- * Deploy per-agent instructions to a worktree.
152
+ * Deploy per-agent instructions and lifecycle hooks to a worktree.
89
153
  *
90
- * For Copilot this writes only the instruction file:
154
+ * For Copilot this writes:
91
155
  * - `.github/copilot-instructions.md` — the agent's task-specific overlay.
92
156
  * Skipped when overlay is undefined.
93
- *
94
- * The `hooks` parameter is unused — Copilot does not support Claude Code's
95
- * hook mechanism, so no settings file is deployed.
157
+ * - `.github/hooks/hooks.json` — Copilot lifecycle hooks (onSessionStart).
96
158
  *
97
159
  * @param worktreePath - Absolute path to the agent's git worktree
98
160
  * @param overlay - Overlay content to write as copilot-instructions.md, or undefined to skip
99
- * @param _hooks - Unused for Copilot runtime
161
+ * @param hooks - Hook config providing agentName for hook command substitution
100
162
  */
101
163
  async deployConfig(
102
164
  worktreePath: string,
103
165
  overlay: OverlayContent | undefined,
104
- _hooks: HooksDef,
166
+ hooks: HooksDef,
105
167
  ): Promise<void> {
106
168
  if (overlay) {
107
169
  const githubDir = join(worktreePath, ".github");
@@ -109,7 +171,8 @@ export class CopilotRuntime implements AgentRuntime {
109
171
  await Bun.write(join(githubDir, "copilot-instructions.md"), overlay.content);
110
172
  }
111
173
 
112
- // No hook deployment for Copilot the runtime has no hook mechanism.
174
+ // Deploy Copilot lifecycle hooks (Phase 1: onSessionStart only).
175
+ await deployCopilotHooks(worktreePath, hooks.agentName);
113
176
  }
114
177
 
115
178
  /**
@@ -231,4 +294,23 @@ export class CopilotRuntime implements AgentRuntime {
231
294
  getTranscriptDir(_projectRoot: string): string | null {
232
295
  return null;
233
296
  }
297
+
298
+ /**
299
+ * Pre-trust the worktree path in the Copilot config.
300
+ *
301
+ * Copilot shows an interactive folder trust dialog when it encounters a new
302
+ * worktree path. Pre-writing the path to ~/.config/github-copilot/config.json
303
+ * before spawn prevents this dialog from blocking the agent session.
304
+ *
305
+ * Errors are non-fatal: a warning is emitted to stderr and spawn continues.
306
+ *
307
+ * @param worktreePath - Absolute path to the agent's worktree
308
+ */
309
+ async prepareWorktree(worktreePath: string): Promise<void> {
310
+ try {
311
+ await ensureCopilotTrustedFolders(worktreePath);
312
+ } catch {
313
+ process.stderr.write(`Warning: Could not pre-trust Copilot folder: ${worktreePath}\n`);
314
+ }
315
+ }
234
316
  }
@@ -313,6 +313,30 @@ describe("PiRuntime", () => {
313
313
  const state = runtime.detectReady(pane);
314
314
  expect(state).toEqual({ phase: "ready" });
315
315
  });
316
+
317
+ test("returns ready for 1.0M context window (Opus/Sonnet large context)", () => {
318
+ const pane = [
319
+ " pi v0.55.1",
320
+ " escape to interrupt",
321
+ "",
322
+ "────────────────────────────────",
323
+ "~/Projects/os-eco/overstory (main)",
324
+ "0.0%/1.0M (auto) (anthropic) claude-opus-4-6 • high",
325
+ ].join("\n");
326
+ const state = runtime.detectReady(pane);
327
+ expect(state).toEqual({ phase: "ready" });
328
+ });
329
+
330
+ test("returns loading when only 1.0M status bar present (no header)", () => {
331
+ const state = runtime.detectReady("0.0%/1.0M (auto) (anthropic) claude-opus-4-6");
332
+ expect(state).toEqual({ phase: "loading" });
333
+ });
334
+
335
+ test("returns ready for 2.0M context window", () => {
336
+ const pane = " pi v1.0\n\n0.0%/2.0M done";
337
+ const state = runtime.detectReady(pane);
338
+ expect(state).toEqual({ phase: "ready" });
339
+ });
316
340
  });
317
341
 
318
342
  describe("buildEnv", () => {
@@ -168,10 +168,11 @@ export class PiRuntime implements AgentRuntime {
168
168
  */
169
169
  detectReady(paneContent: string): ReadyState {
170
170
  // Pi's TUI shows "pi v<version>" in the header and a status bar with
171
- // a token usage indicator like "0.0%/200k" when fully rendered.
172
- // Earlier detection checked for "model:" which Pi's TUI never contains.
171
+ // a token usage indicator like "0.0%/200k" or "0.0%/1.0M" when fully rendered.
172
+ // The context window size uses k-scale (e.g. 200k) for smaller models and
173
+ // M-scale (e.g. 1.0M) for Opus/Sonnet with 1M+ context windows.
173
174
  const hasHeader = paneContent.includes("pi v");
174
- const hasStatusBar = /\d+\.\d+%\/\d+k/.test(paneContent);
175
+ const hasStatusBar = /\d+\.\d+%\/[\d.]+[kKmM]/.test(paneContent);
175
176
  if (hasHeader && hasStatusBar) {
176
177
  return { phase: "ready" };
177
178
  }
@@ -246,4 +246,11 @@ export interface AgentRuntime {
246
246
  * The caller provides the raw stdout ReadableStream from Bun.spawn().
247
247
  */
248
248
  parseEvents?(stream: ReadableStream<Uint8Array>): AsyncIterable<AgentEvent>;
249
+
250
+ /**
251
+ * Prepare a worktree path before spawning an agent.
252
+ * Called by sling.ts after worktree creation but before agent spawn.
253
+ * Used by runtimes that need environment setup (e.g., Copilot folder trust).
254
+ */
255
+ prepareWorktree?(worktreePath: string): Promise<void>;
249
256
  }
@@ -68,6 +68,16 @@ describe("resolveBackend", () => {
68
68
  await rm(tempDir, { recursive: true });
69
69
  }
70
70
  });
71
+ test("returns beads for auto when both .seeds/ and .beads/ exist", async () => {
72
+ const tempDir = await mkdtemp(join(tmpdir(), "tracker-test-"));
73
+ try {
74
+ await mkdir(join(tempDir, ".beads"));
75
+ await mkdir(join(tempDir, ".seeds"));
76
+ expect(await resolveBackend("auto", tempDir)).toBe("beads");
77
+ } finally {
78
+ await rm(tempDir, { recursive: true });
79
+ }
80
+ });
71
81
  });
72
82
 
73
83
  describe("trackerCliName", () => {
@@ -38,7 +38,8 @@ export async function resolveBackend(
38
38
  ): Promise<TrackerBackend> {
39
39
  if (configBackend === "beads") return "beads";
40
40
  if (configBackend === "seeds") return "seeds";
41
- // "auto" detection: check for .seeds/ directory first (newer tool), then .beads/
41
+ // "auto" detection: check for .beads/ first (never auto-scaffolded by ov init,
42
+ // so its presence signals explicit user setup), then .seeds/.
42
43
  const dirExists = async (path: string): Promise<boolean> => {
43
44
  try {
44
45
  const s = await stat(path);
@@ -47,8 +48,8 @@ export async function resolveBackend(
47
48
  return false;
48
49
  }
49
50
  };
50
- if (await dirExists(join(cwd, ".seeds"))) return "seeds";
51
51
  if (await dirExists(join(cwd, ".beads"))) return "beads";
52
+ if (await dirExists(join(cwd, ".seeds"))) return "seeds";
52
53
  // Default fallback — seeds is the preferred tracker
53
54
  return "seeds";
54
55
  }