@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 CHANGED
@@ -295,6 +295,7 @@ overstory/
295
295
  checkpoint.ts Session checkpoint save/restore
296
296
  lifecycle.ts Handoff orchestration
297
297
  hooks-deployer.ts Deploy hooks + tool enforcement
298
+ copilot-hooks-deployer.ts Deploy hooks config to Copilot worktrees
298
299
  guard-rules.ts Shared guard constants (tool lists, bash patterns)
299
300
  worktree/ Git worktree + tmux management
300
301
  mail/ SQLite mail system (typed protocol, broadcast)
@@ -319,16 +319,16 @@ When a batch is complete (task group auto-closed, all issues resolved):
319
319
  4. **Only then** close the issue: `{{TRACKER_CLI}} close <id> --reason "Merged branch <branch-name>"`.
320
320
 
321
321
  1. Verify all issues are closed: run `{{TRACKER_CLI}} show <id>` for each issue in the group.
322
- 2. Verify all branches are merged: check `ov status` for unmerged branches. If any branch is unmerged, do NOT proceed — wait for the lead's `merge_ready` signal.
323
- 3. Clean up worktrees: `ov worktree clean --completed`.
324
- 4. Record orchestration insights: `ml record <domain> --type <type> --classification <foundational|tactical|observational> --description "<insight>"`.
325
- 5. Commit and sync state files: after all work is merged and issues are closed, commit any outstanding state changes so runtime state is not left uncommitted when the coordinator goes idle:
322
+ 2. Verify all branches are merged: check `ov status` for unmerged branches. If any branch is unmerged, do NOT proceed — wait for the lead's `merge_ready` signal. **Note:** merged branches carry each worker's committed `.mulch/` changes into the canonical branch — this is how discovery scout findings reach the main repo.
323
+ 3. Record orchestration insights: `ml record <domain> --type <type> --classification <foundational|tactical|observational> --description "<insight>"`.
324
+ 4. Commit and sync state files: after all work is merged and issues are closed, commit any outstanding state changes so runtime state is not left uncommitted when the coordinator goes idle:
326
325
  ```bash
327
326
  {{TRACKER_CLI}} sync
328
327
  git add .overstory/ .mulch/
329
328
  git diff --cached --quiet || git commit -m "chore: sync runtime state"
330
329
  git push
331
330
  ```
331
+ 5. Clean up worktrees: `ov worktree clean --completed`. **Only run this after branches are merged and .mulch/ state is committed** — cleaning worktrees before merging destroys any uncommitted scout findings.
332
332
  6. Report to the human operator: summarize what was accomplished, what was merged, any issues encountered.
333
333
  7. Check for follow-up work: `{{TRACKER_CLI}} ready` to see if new issues surfaced during the batch.
334
334
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@os-eco/overstory-cli",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Multi-agent orchestration for AI coding agents — spawn workers in git worktrees via tmux, coordinate through SQLite mail, merge with tiered conflict resolution. Pluggable runtime adapters for Claude Code, Pi, and more.",
5
5
  "author": "Jaymin West",
6
6
  "license": "MIT",
@@ -0,0 +1,162 @@
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 { deployCopilotHooks } from "./copilot-hooks-deployer.ts";
7
+ import { PATH_PREFIX } from "./hooks-deployer.ts";
8
+
9
+ describe("deployCopilotHooks", () => {
10
+ let tempDir: string;
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await mkdtemp(join(tmpdir(), "overstory-copilot-hooks-test-"));
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await cleanupTempDir(tempDir);
18
+ });
19
+
20
+ test("writes hooks.json to .github/hooks/ directory", async () => {
21
+ const worktreePath = join(tempDir, "worktree");
22
+ await deployCopilotHooks(worktreePath, "my-builder");
23
+
24
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
25
+ const exists = await Bun.file(hooksPath).exists();
26
+ expect(exists).toBe(true);
27
+ });
28
+
29
+ test("creates .github/hooks/ directory if it does not exist", async () => {
30
+ const worktreePath = join(tempDir, "new-worktree");
31
+ // Directory does not exist before the call
32
+ await deployCopilotHooks(worktreePath, "builder-1");
33
+
34
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
35
+ expect(await Bun.file(hooksPath).exists()).toBe(true);
36
+ });
37
+
38
+ test("output file is valid JSON", async () => {
39
+ const worktreePath = join(tempDir, "worktree");
40
+ await deployCopilotHooks(worktreePath, "test-agent");
41
+
42
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
43
+ const raw = await Bun.file(hooksPath).text();
44
+ expect(() => JSON.parse(raw)).not.toThrow();
45
+ });
46
+
47
+ test("output has Copilot schema structure (top-level hooks with onSessionStart)", async () => {
48
+ const worktreePath = join(tempDir, "worktree");
49
+ await deployCopilotHooks(worktreePath, "test-agent");
50
+
51
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
52
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as Record<string, unknown>;
53
+
54
+ expect(config).toHaveProperty("hooks");
55
+ const hooks = config.hooks as Record<string, unknown>;
56
+ expect(hooks).toHaveProperty("onSessionStart");
57
+ expect(Array.isArray(hooks.onSessionStart)).toBe(true);
58
+ });
59
+
60
+ test("replaces {{AGENT_NAME}} with agentName in all commands", async () => {
61
+ const worktreePath = join(tempDir, "worktree");
62
+ await deployCopilotHooks(worktreePath, "scout-agent-42");
63
+
64
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
65
+ const raw = await Bun.file(hooksPath).text();
66
+
67
+ expect(raw).toContain("scout-agent-42");
68
+ expect(raw).not.toContain("{{AGENT_NAME}}");
69
+ });
70
+
71
+ test("prepends PATH_PREFIX to all hook commands", async () => {
72
+ const worktreePath = join(tempDir, "worktree");
73
+ await deployCopilotHooks(worktreePath, "builder-1");
74
+
75
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
76
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as {
77
+ hooks: Record<string, Array<{ command: string }>>;
78
+ };
79
+
80
+ const allCommands = Object.values(config.hooks)
81
+ .flat()
82
+ .map((e) => e.command);
83
+ expect(allCommands.length).toBeGreaterThan(0);
84
+ for (const cmd of allCommands) {
85
+ expect(cmd).toStartWith(PATH_PREFIX);
86
+ }
87
+ });
88
+
89
+ test("onSessionStart entries are objects with command field only (no matcher, no type)", async () => {
90
+ const worktreePath = join(tempDir, "worktree");
91
+ await deployCopilotHooks(worktreePath, "builder-1");
92
+
93
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
94
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as {
95
+ hooks: { onSessionStart: Array<Record<string, unknown>> };
96
+ };
97
+
98
+ for (const entry of config.hooks.onSessionStart) {
99
+ expect(typeof entry.command).toBe("string");
100
+ // Copilot schema has no matcher or type fields
101
+ expect(entry).not.toHaveProperty("matcher");
102
+ expect(entry).not.toHaveProperty("type");
103
+ }
104
+ });
105
+
106
+ test("onSessionStart includes ov prime command", async () => {
107
+ const worktreePath = join(tempDir, "worktree");
108
+ await deployCopilotHooks(worktreePath, "prime-test-agent");
109
+
110
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
111
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as {
112
+ hooks: { onSessionStart: Array<{ command: string }> };
113
+ };
114
+
115
+ const commands = config.hooks.onSessionStart.map((e) => e.command);
116
+ expect(commands.some((c) => c.includes("ov prime") && c.includes("prime-test-agent"))).toBe(
117
+ true,
118
+ );
119
+ });
120
+
121
+ test("onSessionStart includes ov mail check --inject command", async () => {
122
+ const worktreePath = join(tempDir, "worktree");
123
+ await deployCopilotHooks(worktreePath, "mail-test-agent");
124
+
125
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
126
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as {
127
+ hooks: { onSessionStart: Array<{ command: string }> };
128
+ };
129
+
130
+ const commands = config.hooks.onSessionStart.map((e) => e.command);
131
+ expect(
132
+ commands.some((c) => c.includes("ov mail check --inject") && c.includes("mail-test-agent")),
133
+ ).toBe(true);
134
+ });
135
+
136
+ test("all hook commands include ENV_GUARD pattern", async () => {
137
+ const worktreePath = join(tempDir, "worktree");
138
+ await deployCopilotHooks(worktreePath, "guard-test-agent");
139
+
140
+ const hooksPath = join(worktreePath, ".github", "hooks", "hooks.json");
141
+ const config = JSON.parse(await Bun.file(hooksPath).text()) as {
142
+ hooks: Record<string, Array<{ command: string }>>;
143
+ };
144
+
145
+ const allCommands = Object.values(config.hooks)
146
+ .flat()
147
+ .map((e) => e.command);
148
+ for (const cmd of allCommands) {
149
+ expect(cmd).toContain("OVERSTORY_AGENT_NAME");
150
+ }
151
+ });
152
+
153
+ test("template file exists and is valid JSON after substitution", async () => {
154
+ // Verify template file is present and parseable (basic template health check).
155
+ const templatePath = join(import.meta.dir, "..", "..", "templates", "copilot-hooks.json.tmpl");
156
+ const exists = await Bun.file(templatePath).exists();
157
+ expect(exists).toBe(true);
158
+
159
+ const raw = (await Bun.file(templatePath).text()).replace(/\{\{AGENT_NAME\}\}/g, "test");
160
+ expect(() => JSON.parse(raw)).not.toThrow();
161
+ });
162
+ });
@@ -0,0 +1,93 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { AgentError } from "../errors.ts";
4
+ import { PATH_PREFIX } from "./hooks-deployer.ts";
5
+
6
+ /** Copilot hook entry shape — simpler than Claude Code (no matcher, no type field). */
7
+ interface CopilotHookEntry {
8
+ command: string;
9
+ }
10
+
11
+ /**
12
+ * Resolve the path to the Copilot hooks template file.
13
+ * The template lives at `templates/copilot-hooks.json.tmpl` relative to the repo root.
14
+ */
15
+ function getTemplatePath(): string {
16
+ // src/agents/copilot-hooks-deployer.ts -> repo root is ../../
17
+ return join(dirname(import.meta.dir), "..", "templates", "copilot-hooks.json.tmpl");
18
+ }
19
+
20
+ /**
21
+ * Deploy Copilot lifecycle hooks to an agent's worktree.
22
+ *
23
+ * Reads `templates/copilot-hooks.json.tmpl`, replaces all `{{AGENT_NAME}}` tokens,
24
+ * prepends PATH_PREFIX to every hook command so CLIs (ov, ml, sd) resolve correctly
25
+ * under Copilot's minimal PATH, then writes the result to
26
+ * `<worktreePath>/.github/hooks/hooks.json`.
27
+ *
28
+ * Phase 1: lifecycle hooks only (onSessionStart). No security guards.
29
+ *
30
+ * @param worktreePath - Absolute path to the agent's git worktree
31
+ * @param agentName - The unique name of the agent (replaces {{AGENT_NAME}} in template)
32
+ * @throws {AgentError} If the template is missing or the write fails
33
+ */
34
+ export async function deployCopilotHooks(worktreePath: string, agentName: string): Promise<void> {
35
+ const templatePath = getTemplatePath();
36
+ const file = Bun.file(templatePath);
37
+ const exists = await file.exists();
38
+
39
+ if (!exists) {
40
+ throw new AgentError(`Copilot hooks template not found: ${templatePath}`, {
41
+ agentName,
42
+ });
43
+ }
44
+
45
+ let template: string;
46
+ try {
47
+ template = await file.text();
48
+ } catch (err) {
49
+ throw new AgentError(`Failed to read Copilot hooks template: ${templatePath}`, {
50
+ agentName,
51
+ cause: err instanceof Error ? err : undefined,
52
+ });
53
+ }
54
+
55
+ // Replace all occurrences of {{AGENT_NAME}}
56
+ let content = template;
57
+ while (content.includes("{{AGENT_NAME}}")) {
58
+ content = content.replace("{{AGENT_NAME}}", agentName);
59
+ }
60
+
61
+ // Parse the base config from the template
62
+ const config = JSON.parse(content) as { hooks: Record<string, CopilotHookEntry[]> };
63
+
64
+ // Extend PATH in all hook commands.
65
+ // Copilot CLI executes hooks with a minimal PATH — ~/.bun/bin (where ov, ml, sd live)
66
+ // is not included. Prepend PATH_PREFIX so CLIs resolve correctly.
67
+ for (const entries of Object.values(config.hooks)) {
68
+ for (const entry of entries) {
69
+ entry.command = `${PATH_PREFIX} ${entry.command}`;
70
+ }
71
+ }
72
+
73
+ const hooksDir = join(worktreePath, ".github", "hooks");
74
+ const outputPath = join(hooksDir, "hooks.json");
75
+
76
+ try {
77
+ await mkdir(hooksDir, { recursive: true });
78
+ } catch (err) {
79
+ throw new AgentError(`Failed to create .github/hooks/ directory at: ${hooksDir}`, {
80
+ agentName,
81
+ cause: err instanceof Error ? err : undefined,
82
+ });
83
+ }
84
+
85
+ try {
86
+ await Bun.write(outputPath, `${JSON.stringify(config, null, "\t")}\n`);
87
+ } catch (err) {
88
+ throw new AgentError(`Failed to write Copilot hooks config to: ${outputPath}`, {
89
+ agentName,
90
+ cause: err instanceof Error ? err : undefined,
91
+ });
92
+ }
93
+ }
@@ -194,9 +194,37 @@ export function createBeadsClient(cwd: string): BeadsClient {
194
194
  if (options?.limit !== undefined) {
195
195
  args.push("--limit", String(options.limit));
196
196
  }
197
- const { stdout } = await runBd(args, "list");
198
- const raw = parseJsonOutput<RawBeadIssue[]>(stdout, "list");
199
- return raw.map(normalizeIssue);
197
+ try {
198
+ const { stdout } = await runBd(args, "list");
199
+ const trimmed = stdout.trim();
200
+ if (trimmed === "") return [];
201
+ const parsed: unknown = JSON.parse(trimmed);
202
+ if (Array.isArray(parsed)) {
203
+ // Flat format: RawBeadIssue[]
204
+ return (parsed as RawBeadIssue[]).map(normalizeIssue);
205
+ }
206
+ if (typeof parsed === "object" && parsed !== null) {
207
+ // Tree format: { mol: RawBeadIssue[] } — flatten all groups
208
+ const tree = parsed as Record<string, unknown>;
209
+ const issues: BeadIssue[] = [];
210
+ for (const group of Object.values(tree)) {
211
+ if (Array.isArray(group)) {
212
+ for (const item of group) {
213
+ issues.push(normalizeIssue(item as RawBeadIssue));
214
+ }
215
+ }
216
+ }
217
+ if (issues.length > 0) return issues;
218
+ }
219
+ } catch {
220
+ // fall through to ready fallback
221
+ }
222
+ // Fallback: bd ready --json always returns a flat array
223
+ const { stdout: readyStdout } = await runBd(["ready", "--json"], "ready (list fallback)");
224
+ const readyTrimmed = readyStdout.trim();
225
+ if (readyTrimmed === "") return [];
226
+ const readyRaw = parseJsonOutput<RawBeadIssue[]>(readyTrimmed, "ready (list fallback)");
227
+ return readyRaw.map(normalizeIssue);
200
228
  },
201
229
  };
202
230
  }
@@ -38,6 +38,7 @@ import {
38
38
  isSessionAlive,
39
39
  killSession,
40
40
  sendKeys,
41
+ TMUX_SOCKET,
41
42
  waitForTuiReady,
42
43
  } from "../worktree/tmux.ts";
43
44
  import { nudgeAgent } from "./nudge.ts";
@@ -630,7 +631,7 @@ export async function startCoordinatorSession(
630
631
  }
631
632
 
632
633
  if (shouldAttach) {
633
- Bun.spawnSync(["tmux", "attach-session", "-t", tmuxSession], {
634
+ Bun.spawnSync(["tmux", "-L", TMUX_SOCKET, "attach-session", "-t", tmuxSession], {
634
635
  stdio: ["inherit", "inherit", "inherit"],
635
636
  });
636
637
  }
@@ -46,6 +46,7 @@ import type {
46
46
  MailMessage,
47
47
  OverstoryConfig,
48
48
  StoredEvent,
49
+ TaskTrackerBackend,
49
50
  } from "../types.ts";
50
51
  import { evaluateHealth } from "../watchdog/health.ts";
51
52
  import { isProcessAlive } from "../worktree/tmux.ts";
@@ -356,6 +357,7 @@ async function loadDashboardData(
356
357
  thresholds?: { staleMs: number; zombieMs: number },
357
358
  eventBuffer?: EventBuffer,
358
359
  runtimeConfig?: OverstoryConfig["runtime"],
360
+ taskTrackerBackend?: TaskTrackerBackend,
359
361
  ): Promise<DashboardData> {
360
362
  // Get all sessions from the pre-opened session store — fall back to cache on SQLite errors.
361
363
  let allSessions: AgentSession[];
@@ -516,7 +518,7 @@ async function loadDashboardData(
516
518
  const now2 = Date.now();
517
519
  if (!trackerCache || now2 - trackerCache.fetchedAt > TRACKER_CACHE_TTL_MS) {
518
520
  try {
519
- const backend = await resolveBackend("auto", root);
521
+ const backend = await resolveBackend(taskTrackerBackend ?? "auto", root);
520
522
  const tracker = createTrackerClient(backend, root);
521
523
  tasks = await tracker.list({ limit: 10 });
522
524
  trackerCache = { tasks, fetchedAt: now2 };
@@ -1104,6 +1106,7 @@ async function executeDashboard(opts: DashboardOpts): Promise<void> {
1104
1106
  thresholds,
1105
1107
  eventBuffer,
1106
1108
  config.runtime,
1109
+ config.taskTracker.backend,
1107
1110
  );
1108
1111
  lastGoodData = data;
1109
1112
  // If recovering from an error, clear the stale error line at the bottom
@@ -149,6 +149,39 @@ async function onboardTool(
149
149
  }
150
150
  }
151
151
 
152
+ /**
153
+ * Known runtime CLI candidates in detection priority order.
154
+ * First installed runtime wins.
155
+ */
156
+ const RUNTIME_CANDIDATES: Array<{ name: string; cli: string }> = [
157
+ { name: "claude", cli: "claude" },
158
+ { name: "copilot", cli: "copilot" },
159
+ { name: "gemini", cli: "gemini" },
160
+ { name: "opencode", cli: "opencode" },
161
+ { name: "sapling", cli: "sp" },
162
+ { name: "pi", cli: "pi" },
163
+ ];
164
+
165
+ /**
166
+ * Detect the default runtime by checking which coding agent CLIs are installed.
167
+ *
168
+ * Uses `which <cli>` via the spawner abstraction so detection is testable
169
+ * without real binaries on PATH. Returns the first installed runtime by
170
+ * priority order, or "claude" as the safe fallback.
171
+ *
172
+ * @param spawner - Spawner abstraction (defaults to Bun.spawn wrapper)
173
+ * @returns Runtime name suitable for config.runtime.default
174
+ */
175
+ export async function detectDefaultRuntime(spawner: Spawner): Promise<string> {
176
+ for (const { name, cli } of RUNTIME_CANDIDATES) {
177
+ const result = await spawner(["which", cli]);
178
+ if (result.exitCode === 0) {
179
+ return name;
180
+ }
181
+ }
182
+ return "claude";
183
+ }
184
+
152
185
  /**
153
186
  * Set up .gitattributes with merge=union entries for JSONL files.
154
187
  *
@@ -739,6 +772,12 @@ export async function initCommand(opts: InitOptions): Promise<void> {
739
772
  // 2. Detect project info
740
773
  const projectName = opts.name ?? (await detectProjectName(projectRoot));
741
774
  const canonicalBranch = await detectCanonicalBranch(projectRoot);
775
+ let defaultRuntime = "claude";
776
+ try {
777
+ defaultRuntime = await detectDefaultRuntime(spawner);
778
+ } catch {
779
+ // Non-fatal: fall back to claude if runtime detection fails
780
+ }
742
781
 
743
782
  process.stdout.write(`Initializing overstory for "${projectName}"...\n\n`);
744
783
 
@@ -775,6 +814,9 @@ export async function initCommand(opts: InitOptions): Promise<void> {
775
814
  config.project.name = projectName;
776
815
  config.project.root = projectRoot;
777
816
  config.project.canonicalBranch = canonicalBranch;
817
+ if (config.runtime) {
818
+ config.runtime.default = defaultRuntime;
819
+ }
778
820
 
779
821
  const configYaml = serializeConfigToYaml(config);
780
822
  const configPath = join(overstoryPath, "config.yaml");
@@ -18,6 +18,7 @@ import { renderHeader, separator, stateIconColored } from "../logging/theme.ts";
18
18
  import { createMetricsStore } from "../metrics/store.ts";
19
19
  import { openSessionStore } from "../sessions/compat.ts";
20
20
  import type { AgentSession, StoredEvent, ToolStats } from "../types.ts";
21
+ import { TMUX_SOCKET } from "../worktree/tmux.ts";
21
22
 
22
23
  /**
23
24
  * Extract current file from most recent Edit/Write/Read tool_start event.
@@ -72,10 +73,13 @@ function summarizeArgs(toolArgs: string | null): string {
72
73
  */
73
74
  async function captureTmux(sessionName: string, lines: number): Promise<string | null> {
74
75
  try {
75
- const proc = Bun.spawn(["tmux", "capture-pane", "-t", sessionName, "-p", "-S", `-${lines}`], {
76
- stdout: "pipe",
77
- stderr: "pipe",
78
- });
76
+ const proc = Bun.spawn(
77
+ ["tmux", "-L", TMUX_SOCKET, "capture-pane", "-t", sessionName, "-p", "-S", `-${lines}`],
78
+ {
79
+ stdout: "pipe",
80
+ stderr: "pipe",
81
+ },
82
+ );
79
83
  const exitCode = await proc.exited;
80
84
  if (exitCode !== 0) {
81
85
  return null;
@@ -25,7 +25,13 @@ import { printHint, printSuccess } from "../logging/color.ts";
25
25
  import { getRuntime } from "../runtimes/registry.ts";
26
26
  import { openSessionStore } from "../sessions/compat.ts";
27
27
  import type { AgentSession } from "../types.ts";
28
- import { createSession, isSessionAlive, killSession, sendKeys } from "../worktree/tmux.ts";
28
+ import {
29
+ createSession,
30
+ isSessionAlive,
31
+ killSession,
32
+ sendKeys,
33
+ TMUX_SOCKET,
34
+ } from "../worktree/tmux.ts";
29
35
  import { isRunningAsRoot } from "./sling.ts";
30
36
 
31
37
  /** Default monitor agent name. */
@@ -215,7 +221,7 @@ async function startMonitor(opts: { json: boolean; attach: boolean }): Promise<v
215
221
  }
216
222
 
217
223
  if (shouldAttach) {
218
- Bun.spawnSync(["tmux", "attach-session", "-t", tmuxSession], {
224
+ Bun.spawnSync(["tmux", "-L", TMUX_SOCKET, "attach-session", "-t", tmuxSession], {
219
225
  stdio: ["inherit", "inherit", "inherit"],
220
226
  });
221
227
  }
@@ -806,6 +806,11 @@ export async function slingCommand(taskId: string, opts: SlingOptions): Promise<
806
806
  // Resolve runtime before overlayConfig so we can pass runtime.instructionPath
807
807
  const runtime = getRuntime(opts.runtime, config, capability);
808
808
 
809
+ // Runtime-specific worktree preparation (e.g., Copilot folder trust)
810
+ if (runtime.prepareWorktree) {
811
+ await runtime.prepareWorktree(worktreePath);
812
+ }
813
+
809
814
  const overlayConfig: OverlayConfig = {
810
815
  agentName: name,
811
816
  taskId: taskId,
package/src/index.ts CHANGED
@@ -51,7 +51,7 @@ import { ConfigError, OverstoryError, WorktreeError } from "./errors.ts";
51
51
  import { jsonError } from "./json.ts";
52
52
  import { brand, chalk, muted, setQuiet } from "./logging/color.ts";
53
53
 
54
- export const VERSION = "0.9.2";
54
+ export const VERSION = "0.9.3";
55
55
 
56
56
  const rawArgs = process.argv.slice(2);
57
57
 
@@ -203,7 +203,7 @@ describe("CodexRuntime", () => {
203
203
  expect(cmd1).toBe(cmd2);
204
204
  });
205
205
 
206
- test("all model names pass through unchanged", () => {
206
+ test("all bare model names pass through unchanged", () => {
207
207
  for (const model of ["gpt-5-codex", "gpt-4o", "o3", "custom-model-v2"]) {
208
208
  const opts: SpawnOpts = {
209
209
  model,
@@ -216,6 +216,30 @@ describe("CodexRuntime", () => {
216
216
  }
217
217
  });
218
218
 
219
+ test("provider-prefixed model strips prefix (openai/gpt-5.4 → gpt-5.4)", () => {
220
+ const opts: SpawnOpts = {
221
+ model: "openai/gpt-5.4",
222
+ permissionMode: "bypass",
223
+ cwd: "/tmp",
224
+ env: {},
225
+ };
226
+ const cmd = runtime.buildSpawnCommand(opts);
227
+ expect(cmd).toContain("--model gpt-5.4");
228
+ expect(cmd).not.toContain("openai/");
229
+ });
230
+
231
+ test("provider-prefixed model with other providers strips prefix", () => {
232
+ const opts: SpawnOpts = {
233
+ model: "azure/gpt-4o",
234
+ permissionMode: "bypass",
235
+ cwd: "/tmp",
236
+ env: {},
237
+ };
238
+ const cmd = runtime.buildSpawnCommand(opts);
239
+ expect(cmd).toContain("--model gpt-4o");
240
+ expect(cmd).not.toContain("azure/");
241
+ });
242
+
219
243
  test("systemPrompt field is ignored", () => {
220
244
  const opts: SpawnOpts = {
221
245
  model: "gpt-5-codex",
@@ -248,6 +272,19 @@ describe("CodexRuntime", () => {
248
272
  ]);
249
273
  });
250
274
 
275
+ test("provider-prefixed model strips prefix (openai/gpt-5.4 → gpt-5.4)", () => {
276
+ const argv = runtime.buildPrintCommand("Classify this error", "openai/gpt-5.4");
277
+ expect(argv).toEqual([
278
+ "codex",
279
+ "exec",
280
+ "--full-auto",
281
+ "--ephemeral",
282
+ "--model",
283
+ "gpt-5.4",
284
+ "Classify this error",
285
+ ]);
286
+ });
287
+
251
288
  test("model undefined omits --model flag", () => {
252
289
  const argv = runtime.buildPrintCommand("Hello", undefined);
253
290
  expect(argv).not.toContain("--model");
@@ -49,6 +49,21 @@ export class CodexRuntime implements AgentRuntime {
49
49
  */
50
50
  private static readonly MANIFEST_ALIASES = new Set(["sonnet", "opus", "haiku"]);
51
51
 
52
+ /**
53
+ * Strip a provider prefix from a model ID.
54
+ *
55
+ * Codex CLI expects bare model names. The orchestrator may resolve a model to
56
+ * a provider-qualified form (e.g. `"openai/gpt-5.4"`) — strip the `"openai/"`
57
+ * prefix before passing to the CLI.
58
+ *
59
+ * @param model - Possibly provider-qualified model ID
60
+ * @returns Bare model name (everything after the first `/`, or unchanged if no `/`)
61
+ */
62
+ private static stripProviderPrefix(model: string): string {
63
+ const slashIdx = model.indexOf("/");
64
+ return slashIdx !== -1 ? model.slice(slashIdx + 1) : model;
65
+ }
66
+
52
67
  /**
53
68
  * Escape a directory path for use in a single-quoted shell argument.
54
69
  *
@@ -75,11 +90,14 @@ export class CodexRuntime implements AgentRuntime {
75
90
  * @returns Shell command string suitable for tmux new-session -c
76
91
  */
77
92
  buildSpawnCommand(opts: SpawnOpts): string {
93
+ // Strip provider prefix before alias check and model flag injection.
94
+ // Codex CLI expects bare model names (e.g. "gpt-5.4", not "openai/gpt-5.4").
95
+ const bareModel = CodexRuntime.stripProviderPrefix(opts.model);
78
96
  // When model comes from default manifest aliases (sonnet/opus/haiku),
79
97
  // omit --model so Codex uses the user's configured default model.
80
98
  let cmd = "codex --full-auto";
81
- if (!CodexRuntime.MANIFEST_ALIASES.has(opts.model)) {
82
- cmd += ` --model ${opts.model}`;
99
+ if (!CodexRuntime.MANIFEST_ALIASES.has(bareModel)) {
100
+ cmd += ` --model ${bareModel}`;
83
101
  }
84
102
  for (const dir of opts.sharedWritableDirs ?? []) {
85
103
  cmd += ` --add-dir '${CodexRuntime.shellEscape(dir)}'`;
@@ -119,7 +137,8 @@ export class CodexRuntime implements AgentRuntime {
119
137
  buildPrintCommand(prompt: string, model?: string): string[] {
120
138
  const cmd = ["codex", "exec", "--full-auto", "--ephemeral"];
121
139
  if (model !== undefined) {
122
- cmd.push("--model", model);
140
+ // Strip provider prefix — Codex CLI expects bare model names.
141
+ cmd.push("--model", CodexRuntime.stripProviderPrefix(model));
123
142
  }
124
143
  cmd.push(prompt);
125
144
  return cmd;