Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,11 +158,12 @@ Configure the port in `.opencode/ensemble.json`:

```json
{
"dashboardPort": 4747
"dashboardPort": 4747,
"dashboardHost": "127.0.0.1"
}
```

Set to `0` to disable. The dashboard starts automatically when OpenCode loads the plugin.
Set `dashboardPort` to `0` to disable. The dashboard starts automatically when OpenCode loads the plugin. `dashboardHost` defaults to loopback; changing it can expose dashboard data to other machines on your network.

## Install

Expand Down Expand Up @@ -393,6 +394,7 @@ Configure via JSON files, environment variables, or both. Project config overrid
"timeoutMs": 1800000,
"rateLimitCapacity": 10,
"dashboardPort": 4747,
"dashboardHost": "127.0.0.1",
"defaultModel": "anthropic/claude-sonnet-4-6",
"modelPool": ["anthropic/claude-opus-4-7", "anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
"modelsByAgent": {},
Expand All @@ -414,6 +416,7 @@ All fields are optional. Missing fields use defaults.
| `timeoutMs` | `1800000` (30 min) | Hard timeout for busy teammates. `0` disables. |
| `rateLimitCapacity` | `10` | Token bucket capacity for team tool calls. `0` disables. |
| `dashboardPort` | `4747` | Dashboard server port. `0` disables. |
| `dashboardHost` | `127.0.0.1` | Dashboard network interface to bind. Keep the loopback default unless you intend to expose the dashboard on your network. |
| `defaultModel` | `""` | Default model for all agents (e.g. `"anthropic/claude-sonnet-4-6"`). Empty = OpenCode's default. |
| `modelPool` | `[]` | List of models for rotation/random assignment. |
| `modelsByAgent` | `{}` | Map agent type to model (e.g. `{"build": "anthropic/claude-opus-4-7"}`). |
Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface EnsembleConfig {
rateLimitCapacity?: number
/** Dashboard server port (default: 4747, 0 to disable) */
dashboardPort?: number
/** Dashboard bind address (default: 127.0.0.1) */
dashboardHost?: string
/** Max peer messages per agent per window before nudge (default: 5, 0 to disable) */
peerMessageLimit?: number
/** Time window for peer message rate limiting in ms (default: 300000 = 5 min) */
Expand All @@ -42,6 +44,7 @@ export const DEFAULT_CONFIG: Required<EnsembleConfig> = {
timeoutMs: 30 * 60 * 1000,
rateLimitCapacity: 10,
dashboardPort: 4747,
dashboardHost: "127.0.0.1",
peerMessageLimit: 5,
peerMessageWindowMs: 300_000,
defaultModel: "",
Expand All @@ -65,6 +68,7 @@ function readConfigFile(filePath: string): Partial<EnsembleConfig> {
if (typeof raw.timeoutMs === "number") result.timeoutMs = raw.timeoutMs
if (typeof raw.rateLimitCapacity === "number") result.rateLimitCapacity = raw.rateLimitCapacity
if (typeof raw.dashboardPort === "number") result.dashboardPort = raw.dashboardPort
if (typeof raw.dashboardHost === "string") result.dashboardHost = raw.dashboardHost
if (typeof raw.peerMessageLimit === "number") result.peerMessageLimit = raw.peerMessageLimit
if (typeof raw.peerMessageWindowMs === "number") result.peerMessageWindowMs = raw.peerMessageWindowMs
if (typeof raw.defaultModel === "string") result.defaultModel = raw.defaultModel
Expand Down
5 changes: 3 additions & 2 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ export interface DashboardServer {

/** Optional dependencies for the dashboard server. */
export interface DashboardOptions {
/** Network interface to bind (defaults to loopback). */
host?: string
/** In-memory activity buffer for real-time per-session events. */
activityBuffer?: ActivityBuffer
/** SDK client for on-demand session message retrieval. */
Expand All @@ -216,7 +218,6 @@ export interface DashboardOptions {
function sendJson(res: ServerResponse, data: unknown): void {
res.writeHead(200, {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
})
res.end(JSON.stringify(data))
}
Expand Down Expand Up @@ -424,7 +425,7 @@ export async function startDashboard(db: Database, port: number, options?: Dashb
resolve(null)
})

server.listen(port, () => {
server.listen(port, options?.host ?? "127.0.0.1", () => {
log(`dashboard:started port=${port} url=http://localhost:${port}`)
resolve(toDashboardServer(server))
})
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ const plugin: Plugin = async (input) => {

// Start dashboard server (main instance only, not worktree instances)
if (config.dashboardPort !== 0) {
startDashboard(db, config.dashboardPort, { activityBuffer, client }).catch((err) => {
startDashboard(db, config.dashboardPort, { activityBuffer, client, host: config.dashboardHost }).catch((err) => {
log(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
})
}
Expand Down
2 changes: 1 addition & 1 deletion src/v2-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,7 @@ export async function setupEnsemble(
let dashboard: DashboardServer | null = null
const dashboardPort = options.dashboardPort ?? config.dashboardPort
if (dashboardPort !== 0 && !isWorktreeInstance(ctx.location.directory)) {
dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client }).catch((err) => {
dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client, host: config.dashboardHost }).catch((err) => {
vlog(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
return null
})
Expand Down
8 changes: 8 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ describe("config", () => {
expect(config.stallThresholdMs).toBe(300_000) // default preserved
})

test("defaults dashboardHost to loopback and loads configured host", () => {
expect(DEFAULT_CONFIG.dashboardHost).toBe("127.0.0.1")
const configDir = path.join(tmpDir, ".opencode")
mkdirSync(configDir, { recursive: true })
writeFileSync(path.join(configDir, "ensemble.json"), JSON.stringify({ dashboardHost: "0.0.0.0" }))
expect(loadConfig(tmpDir).dashboardHost).toBe("0.0.0.0")
})

test("invalid JSON logs warning and returns defaults", () => {
const configDir = path.join(tmpDir, ".opencode")
mkdirSync(configDir, { recursive: true })
Expand Down
10 changes: 8 additions & 2 deletions test/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,25 @@ describe("dashboard", () => {
const res = await fetch(`http://localhost:${port}/api/health`)
expect(res.status).toBe(200)
expect(res.headers.get("content-type")).toContain("application/json")
expect(res.headers.get("access-control-allow-origin")).toBe("*")
expect(res.headers.get("access-control-allow-origin")).toBeNull()
const body = (await res.json()) as HealthResponse
expect(body.ensemble).toBe(true)
expect(typeof body.pid).toBe("number")
})

test("binds to the configured interface", async () => {
server = await startDashboard(db, port, { host: "127.0.0.2" })
const response = await fetch(`http://127.0.0.2:${port}/api/health`)
expect(response.status).toBe(200)
})
})

describe("GET /api/state", () => {
test("returns empty teams array when no teams exist", async () => {
server = await startDashboard(db, port)
const res = await fetch(`http://localhost:${port}/api/state`)
expect(res.status).toBe(200)
expect(res.headers.get("access-control-allow-origin")).toBe("*")
expect(res.headers.get("access-control-allow-origin")).toBeNull()
const body = (await res.json()) as StateResponse
expect(body).toEqual({ version: 1, projects: [], teams: [] })
})
Expand Down