@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
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)
|
package/agents/coordinator.md
CHANGED
|
@@ -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.
|
|
324
|
-
4.
|
|
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.
|
|
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
|
+
}
|
package/src/beads/client.ts
CHANGED
|
@@ -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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
package/src/commands/init.ts
CHANGED
|
@@ -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");
|
package/src/commands/inspect.ts
CHANGED
|
@@ -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(
|
|
76
|
-
|
|
77
|
-
|
|
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;
|
package/src/commands/monitor.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/commands/sling.ts
CHANGED
|
@@ -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.
|
|
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");
|
package/src/runtimes/codex.ts
CHANGED
|
@@ -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(
|
|
82
|
-
cmd += ` --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
|
-
|
|
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;
|