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
39 changes: 35 additions & 4 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,8 @@ export namespace Flag {
export const OPENCODE_DISABLE_CLAUDE_CODE = truthy("OPENCODE_DISABLE_CLAUDE_CODE")
export const OPENCODE_DISABLE_CLAUDE_CODE_PROMPT =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
export const OPENCODE_DISABLE_EXTERNAL_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
export declare const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS: boolean
export declare const OPENCODE_DISABLE_EXTERNAL_SKILLS: boolean
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export declare const OPENCODE_CLIENT: string
Expand All @@ -47,6 +45,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export declare const OPENCODE_EXPERIMENTAL_AGENT_TEAMS: boolean
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
Expand Down Expand Up @@ -91,3 +90,35 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", {
enumerable: true,
configurable: false,
})

// Dynamic getter for OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
// Evaluated at access time so tests and external tooling can toggle it
Object.defineProperty(Flag, "OPENCODE_DISABLE_CLAUDE_CODE_SKILLS", {
get() {
return truthy("OPENCODE_DISABLE_CLAUDE_CODE") || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
},
enumerable: true,
configurable: false,
})

// Dynamic getter for OPENCODE_DISABLE_EXTERNAL_SKILLS
// Independent of OPENCODE_DISABLE_CLAUDE_CODE so disabling Claude Code
// doesn't block .agents/skills/ loading
Object.defineProperty(Flag, "OPENCODE_DISABLE_EXTERNAL_SKILLS", {
get() {
return truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
},
enumerable: true,
configurable: false,
})

// Dynamic getter for OPENCODE_EXPERIMENTAL_AGENT_TEAMS
// This must be evaluated at access time, not module load time,
// because integration tests and external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_EXPERIMENTAL_AGENT_TEAMS", {
get() {
return Flag.OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_AGENT_TEAMS")
},
enumerable: true,
configurable: false,
})
22 changes: 22 additions & 0 deletions packages/opencode/src/project/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
import { Snapshot } from "../snapshot"
import { Truncate } from "../tool/truncation"
import { Flag } from "@/flag/flag"

export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory })
Expand All @@ -32,4 +33,25 @@ export async function InstanceBootstrap() {
await Project.setInitialized(Instance.project.id)
}
})

// Team features — order matters:
// 1. onCleanedRestorePermissions() registers synchronously so it's ready
// before recover(), which could trigger cleanup if all members are shutdown.
// 2. recover() marks stale busy executions as cancelled, transitions members to ready, and notifies leads.
// 3. autoCleanup() subscribes AFTER recover finishes (.finally()) to avoid
// spurious MemberStatusChanged events during recovery triggering premature cleanup.
// Fire-and-forget: don't block bootstrap completion.
if (Flag.OPENCODE_EXPERIMENTAL_AGENT_TEAMS) {
// Dynamic import — only load team module when the feature flag is enabled
import("../team").then(({ Team }) => {
Team.onCleanedRestorePermissions()
Team.recover()
.catch((err) => {
Log.Default.warn("team recovery failed", { error: err instanceof Error ? err.message : err })
})
.finally(() => {
Team.autoCleanup()
})
})
}
}
163 changes: 163 additions & 0 deletions packages/opencode/src/team/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import z from "zod"
import { BusEvent } from "../bus/bus-event"

export const MemberStatus = z.enum(["ready", "busy", "shutdown_requested", "shutdown", "error"])
export type MemberStatus = z.infer<typeof MemberStatus>

export const ExecutionStatus = z.enum([
"idle",
"starting",
"running",
"cancel_requested",
"cancelling",
"cancelled",
"completing",
"completed",
"failed",
"timed_out",
])
export type ExecutionStatus = z.infer<typeof ExecutionStatus>

/** Validates safe identifiers for team/member names — prevents path traversal */
const SafeName = z
.string()
.regex(/^[a-z0-9][a-z0-9-]{0,63}$/, "Must be lowercase alphanumeric with hyphens, 1-64 chars")

export const TeamMemberSchema = z.object({
name: SafeName,
sessionID: z.string(),
agent: z.string(),
status: MemberStatus,
execution_status: ExecutionStatus.optional(),
prompt: z.string().optional(),
/** Model this teammate is using, in "providerID/modelID" format. */
model: z.string().optional(),
planApproval: z.enum(["none", "pending", "approved", "rejected"]).optional(),
})
export type TeamMember = z.infer<typeof TeamMemberSchema>

export const TeamInfoSchema = z.object({
name: SafeName,
leadSessionID: z.string(),
members: z.array(TeamMemberSchema),
created: z.number(),
delegate: z.boolean().optional(),
})
export type TeamInfo = z.infer<typeof TeamInfoSchema>

export const TeamTaskSchema = z.object({
id: z.string(),
content: z.string(),
status: z.enum(["pending", "in_progress", "completed", "cancelled", "blocked"]),
priority: z.enum(["high", "medium", "low"]),
assignee: z.string().optional(),
depends_on: z.array(z.string()).optional(),
})
export type TeamTask = z.infer<typeof TeamTaskSchema>

export namespace TeamEvent {
export const Created = BusEvent.define(
"team.created",
z.object({
team: TeamInfoSchema,
}),
)

export const MemberSpawned = BusEvent.define(
"team.member.spawned",
z.object({
teamName: z.string(),
member: TeamMemberSchema,
}),
)

export const MemberStatusChanged = BusEvent.define(
"team.member.status",
z.object({
teamName: z.string(),
memberName: z.string(),
status: MemberStatus,
}),
)

export const MemberExecutionChanged = BusEvent.define(
"team.member.execution",
z.object({
teamName: z.string(),
memberName: z.string(),
status: ExecutionStatus,
}),
)

export const Message = BusEvent.define(
"team.message",
z.object({
teamName: z.string(),
from: z.string(),
to: z.string(),
text: z.string(),
}),
)

export const Broadcast = BusEvent.define(
"team.broadcast",
z.object({
teamName: z.string(),
from: z.string(),
text: z.string(),
}),
)

export const TaskUpdated = BusEvent.define(
"team.task.updated",
z.object({
teamName: z.string(),
tasks: z.array(TeamTaskSchema),
}),
)

export const TaskClaimed = BusEvent.define(
"team.task.claimed",
z.object({
teamName: z.string(),
taskId: z.string(),
memberName: z.string(),
}),
)

export const ShutdownRequest = BusEvent.define(
"team.shutdown.request",
z.object({
teamName: z.string(),
memberName: z.string(),
}),
)

export const PlanApproval = BusEvent.define(
"team.plan.approval",
z.object({
teamName: z.string(),
memberName: z.string(),
approved: z.boolean(),
feedback: z.string().optional(),
}),
)

export const MessageRead = BusEvent.define(
"team.message.read",
z.object({
teamName: z.string(),
agentName: z.string(),
count: z.number(),
}),
)

export const Cleaned = BusEvent.define(
"team.cleaned",
z.object({
teamName: z.string(),
leadSessionID: z.string(),
delegate: z.boolean(),
}),
)
}
117 changes: 117 additions & 0 deletions packages/opencode/src/team/inbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Log } from "../util/log"
import { Lock } from "../util/lock"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
import { TeamEvent } from "./events"
import path from "path"
import fs from "fs/promises"

const log = Log.create({ service: "team.inbox" })

export interface InboxMessage {
id: string
from: string
text: string
timestamp: number
read: boolean
}

/** Resolve the JSONL file path for an agent's inbox */
function filepath(teamName: string, agentName: string): string {
return path.join(Global.Path.data, "storage", "team_inbox", Instance.project.id, teamName, agentName + ".jsonl")
}

/** Parse a JSONL file into an array of InboxMessage */
function parse(content: string): InboxMessage[] {
if (!content.trim()) return []
return content
.split("\n")
.filter((line) => line.trim())
.map((line) => JSON.parse(line) as InboxMessage)
}

export namespace Inbox {
/**
* Write a message to an agent's inbox.
* Appends a single JSON line — O(1), no read-modify-write.
*/
export async function write(teamName: string, to: string, message: Omit<InboxMessage, "read">): Promise<void> {
const target = filepath(teamName, to)
using _ = await Lock.write(target)
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.appendFile(target, JSON.stringify({ ...message, read: false }) + "\n")
log.info("inbox write", { teamName, to, from: message.from, id: message.id })
}

/**
* Read all unread messages from an agent's inbox.
*/
export async function unread(teamName: string, agentName: string): Promise<InboxMessage[]> {
const target = filepath(teamName, agentName)
using _ = await Lock.read(target)
const content = await Bun.file(target)
.text()
.catch(() => "")
return parse(content).filter((m) => !m.read)
}

/**
* Read all messages (read and unread) from an agent's inbox.
*/
export async function all(teamName: string, agentName: string): Promise<InboxMessage[]> {
const target = filepath(teamName, agentName)
using _ = await Lock.read(target)
const content = await Bun.file(target)
.text()
.catch(() => "")
return parse(content)
}

/**
* Mark all unread messages as read for an agent.
* Returns the newly-read messages so callers can send delivery receipts.
* Publishes TeamEvent.MessageRead with the count.
*
* This is the only operation that rewrites the entire file.
*/
export async function markRead(teamName: string, agentName: string): Promise<InboxMessage[]> {
const target = filepath(teamName, agentName)
using _ = await Lock.write(target)
const content = await Bun.file(target)
.text()
.catch(() => "")
const messages = parse(content)
const read: InboxMessage[] = []
for (const msg of messages) {
if (msg.read) continue
msg.read = true
read.push({ ...msg })
}
if (read.length === 0) return []
// Rewrite entire file with updated read flags
await Bun.write(target, messages.map((m) => JSON.stringify(m)).join("\n") + "\n")
log.info("inbox marked read", { teamName, agentName, count: read.length })
await Bus.publish(TeamEvent.MessageRead, { teamName, agentName, count: read.length })
return read
}

/**
* Remove an agent's inbox entirely.
*/
export async function remove(teamName: string, agentName: string): Promise<void> {
const target = filepath(teamName, agentName)
await fs.unlink(target).catch(() => {})
}

/**
* Remove all inboxes for a team.
*/
export async function removeAll(teamName: string, agentNames: string[]): Promise<void> {
for (const name of agentNames) {
await remove(teamName, name)
}
// Also remove the lead inbox
await remove(teamName, "lead")
}
}
Loading
Loading