@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.
- package/README.md +1 -0
- package/agents/coordinator.md +4 -4
- package/package.json +1 -1
- package/src/agents/copilot-hooks-deployer.test.ts +162 -0
- package/src/agents/copilot-hooks-deployer.ts +93 -0
- package/src/beads/client.ts +31 -3
- package/src/commands/coordinator.ts +2 -1
- package/src/commands/dashboard.ts +4 -1
- package/src/commands/init.ts +42 -0
- package/src/commands/inspect.ts +8 -4
- package/src/commands/monitor.ts +8 -2
- package/src/commands/sling.ts +5 -0
- package/src/index.ts +1 -1
- package/src/runtimes/codex.test.ts +38 -1
- package/src/runtimes/codex.ts +22 -3
- package/src/runtimes/copilot.test.ts +213 -13
- package/src/runtimes/copilot.ts +93 -11
- package/src/runtimes/pi.test.ts +24 -0
- package/src/runtimes/pi.ts +4 -3
- package/src/runtimes/types.ts +7 -0
- package/src/tracker/factory.test.ts +10 -0
- package/src/tracker/factory.ts +3 -2
- package/src/worktree/tmux.test.ts +166 -49
- package/src/worktree/tmux.ts +36 -37
- package/templates/copilot-hooks.json.tmpl +13 -0
|
@@ -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("
|
|
90
|
-
|
|
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 ${
|
|
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 — .
|
|
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 (
|
|
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");
|
package/src/runtimes/copilot.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
}
|
package/src/runtimes/pi.test.ts
CHANGED
|
@@ -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", () => {
|
package/src/runtimes/pi.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
175
|
+
const hasStatusBar = /\d+\.\d+%\/[\d.]+[kKmM]/.test(paneContent);
|
|
175
176
|
if (hasHeader && hasStatusBar) {
|
|
176
177
|
return { phase: "ready" };
|
|
177
178
|
}
|
package/src/runtimes/types.ts
CHANGED
|
@@ -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", () => {
|
package/src/tracker/factory.ts
CHANGED
|
@@ -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 .
|
|
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
|
}
|