diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb1974..e6cd6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ All notable changes to this project will be documented in this file. - Removed experimental `collab` runtime mode and related template wiring from mainline plugin behavior. - Simplified installer surface to a single idempotent `install` flow. - Updated docs, schema, and workflow configuration for current supported modes (`native`, `codex`). +- Added experimental Codex collaboration profile gates (`runtime.collaborationProfile`, `runtime.orchestratorSubagents`) for plan/orchestrator parity. +- Collaboration features now auto-enable by default in `runtime.mode="codex"` and can be explicitly enabled/disabled in any mode. +- Added `runtime.collaborationToolProfile` (`opencode` | `codex`) to choose OpenCode tool translation guidance vs codex-style tool semantics in injected collaboration instructions. +- Added managed `orchestrator` agent template sync under `~/.config/opencode/agents`, with visibility auto-gated by runtime mode. ## 0.2.3 - 2026-02-11 diff --git a/docs/configuration.md b/docs/configuration.md index 3f9d93f..47eb737 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -94,6 +94,18 @@ The plugin loads config in this order: - Adds explicit `before-header-transform` and `after-header-transform` request snapshots for message fetches. - `runtime.pidOffset: boolean` - Enables session-aware offset behavior for account selection. +- `runtime.collaborationProfile: boolean` + - Experimental: enables Codex-style collaboration mode mapping from agent names (`plan` -> plan mode, `orchestrator` -> code mode profile). + - If omitted, defaults to `true` in `runtime.mode="codex"` and `false` otherwise. + - Explicit `true`/`false` works in any mode. +- `runtime.orchestratorSubagents: boolean` + - Experimental: enables Codex-style subagent header hints for helper agents under collaboration profile mode. + - If omitted, inherits `runtime.collaborationProfile` effective value. + - Explicit `true`/`false` works in any mode. +- `runtime.collaborationToolProfile: "opencode" | "codex"` + - Controls tool-language guidance in injected collaboration instructions. + - `opencode` (default): translates Codex semantics to OpenCode tool names. + - `codex`: prefers Codex tool semantics and falls back to OpenCode equivalents. ### Model behavior diff --git a/index.ts b/index.ts index 239f4f5..9e13ee6 100644 --- a/index.ts +++ b/index.ts @@ -13,9 +13,12 @@ import { getCompatInputSanitizerEnabled, getCodexCompactionOverrideEnabled, getBehaviorSettings, + getCollaborationToolProfile, + getCollaborationProfileEnabled, getDebugEnabled, getHeaderTransformDebugEnabled, getHeaderSnapshotsEnabled, + getOrchestratorSubagentsEnabled, getMode, getRemapDeveloperMessagesToUserEnabled, getRotationStrategy, @@ -34,6 +37,7 @@ import { generatePersonaSpec } from "./lib/persona-tool" import { createPersonalityFile } from "./lib/personality-create" import { installCreatePersonalityCommand } from "./lib/personality-command" import { installPersonalityBuilderSkill } from "./lib/personality-skill" +import { reconcileOrchestratorAgentVisibility } from "./lib/orchestrator-agent" import { runOneProactiveRefreshTick } from "./lib/proactive-refresh" import { toolOutputForStatus } from "./lib/codex-status-tool" import { requireOpenAIMultiOauthAuth, saveAuthStorage } from "./lib/storage" @@ -56,8 +60,11 @@ export const OpenAIMultiAuthPlugin: Plugin = async (input) => { file: loadConfigFile({ env: process.env }) }) const runtimeMode = getMode(cfg) + const collaborationProfileEnabled = getCollaborationProfileEnabled(cfg) const log = createLogger({ debug: getDebugEnabled(cfg) }) + await reconcileOrchestratorAgentVisibility({ visible: collaborationProfileEnabled }).catch(() => {}) + if (getProactiveRefreshEnabled(cfg)) { const bufferMs = getProactiveRefreshBufferMs(cfg) const timer = setInterval(() => { @@ -92,11 +99,13 @@ export const OpenAIMultiAuthPlugin: Plugin = async (input) => { codexCompactionOverride: getCodexCompactionOverrideEnabled(cfg), headerSnapshots: getHeaderSnapshotsEnabled(cfg), headerTransformDebug: getHeaderTransformDebugEnabled(cfg), + collaborationProfileEnabled, + orchestratorSubagentsEnabled: getOrchestratorSubagentsEnabled(cfg), + collaborationToolProfile: getCollaborationToolProfile(cfg), behaviorSettings: getBehaviorSettings(cfg) }) const z = tool.schema - hooks.tool = { ...hooks.tool, "codex-status": tool({ diff --git a/lib/codex-native.ts b/lib/codex-native.ts index f3a1aa9..9dba0de 100644 --- a/lib/codex-native.ts +++ b/lib/codex-native.ts @@ -11,6 +11,7 @@ import type { PluginRuntimeMode, PromptCacheKeyStrategy } from "./config" +import type { CollaborationToolProfile } from "./codex-native/collaboration" import { formatToastMessage } from "./toast" import type { CodexModelInfo } from "./model-catalog" import { createRequestSnapshots } from "./request-snapshots" @@ -164,6 +165,9 @@ export type CodexAuthPluginOptions = { codexCompactionOverride?: boolean headerSnapshots?: boolean headerTransformDebug?: boolean + collaborationProfileEnabled?: boolean + orchestratorSubagentsEnabled?: boolean + collaborationToolProfile?: CollaborationToolProfile } export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginOptions = {}): Promise { @@ -179,6 +183,14 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO const remapDeveloperMessagesToUserEnabled = spoofMode === "codex" && opts.remapDeveloperMessagesToUser !== false const codexCompactionOverrideEnabled = opts.codexCompactionOverride !== undefined ? opts.codexCompactionOverride : runtimeMode === "codex" + const collaborationProfileEnabled = + typeof opts.collaborationProfileEnabled === "boolean" ? opts.collaborationProfileEnabled : runtimeMode === "codex" + const orchestratorSubagentsEnabled = + typeof opts.orchestratorSubagentsEnabled === "boolean" + ? opts.orchestratorSubagentsEnabled + : collaborationProfileEnabled + const collaborationToolProfile: CollaborationToolProfile = + opts.collaborationToolProfile === "codex" ? "codex" : "opencode" void refreshCodexClientVersionFromGitHub(opts.log).catch(() => {}) const resolveCatalogHeaders = (): { originator: string @@ -353,7 +365,10 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO lastCatalogModels, behaviorSettings: opts.behaviorSettings, fallbackPersonality: opts.personality, - spoofMode + spoofMode, + collaborationProfileEnabled, + orchestratorSubagentsEnabled, + collaborationToolProfile }) }, "chat.headers": async (hookInput, output) => { @@ -361,7 +376,9 @@ export async function CodexAuthPlugin(input: PluginInput, opts: CodexAuthPluginO hookInput, output, spoofMode, - internalCollaborationModeHeader: INTERNAL_COLLABORATION_MODE_HEADER + internalCollaborationModeHeader: INTERNAL_COLLABORATION_MODE_HEADER, + collaborationProfileEnabled, + orchestratorSubagentsEnabled }) }, "experimental.session.compacting": async (hookInput, output) => { diff --git a/lib/codex-native/chat-hooks.ts b/lib/codex-native/chat-hooks.ts index 843a36c..4006c18 100644 --- a/lib/codex-native/chat-hooks.ts +++ b/lib/codex-native/chat-hooks.ts @@ -22,6 +22,17 @@ import { readSessionMessageInfo, sessionUsesOpenAIProvider } from "./session-messages" +import { + CODEX_CODE_MODE_INSTRUCTIONS, + CODEX_ORCHESTRATOR_INSTRUCTIONS, + CODEX_PLAN_MODE_INSTRUCTIONS, + mergeInstructions, + resolveCollaborationInstructions, + resolveCollaborationProfile, + resolveSubagentHeaderValue, + resolveToolingInstructions, + type CollaborationToolProfile +} from "./collaboration" function normalizeVerbositySetting(value: unknown): "default" | "low" | "medium" | "high" | undefined { if (typeof value !== "string") return undefined @@ -60,6 +71,7 @@ export async function handleChatParamsHook(input: { api?: { id?: string } capabilities?: { toolcall?: boolean } } + agent?: unknown message: unknown } output: Parameters[0]["output"] @@ -67,6 +79,9 @@ export async function handleChatParamsHook(input: { behaviorSettings?: BehaviorSettings fallbackPersonality?: PersonalityOption spoofMode: CodexSpoofMode + collaborationProfileEnabled: boolean + orchestratorSubagentsEnabled: boolean + collaborationToolProfile: CollaborationToolProfile }): Promise { if (input.hookInput.model.providerID !== "openai") return const modelOptions = isRecord(input.hookInput.model.options) ? input.hookInput.model.options : {} @@ -137,13 +152,34 @@ export async function handleChatParamsHook(input: { preferCodexInstructions: input.spoofMode === "codex", output: input.output }) + + if (!input.collaborationProfileEnabled) return + + const profile = resolveCollaborationProfile(input.hookInput.agent) + if (!profile.enabled || !profile.kind) return + + const collaborationInstructions = resolveCollaborationInstructions(profile.kind, { + plan: CODEX_PLAN_MODE_INSTRUCTIONS, + code: CODEX_CODE_MODE_INSTRUCTIONS + }) + let mergedInstructions = mergeInstructions(asString(input.output.options.instructions), collaborationInstructions) + + if (profile.isOrchestrator && input.orchestratorSubagentsEnabled) { + mergedInstructions = mergeInstructions(mergedInstructions, CODEX_ORCHESTRATOR_INSTRUCTIONS) + } + + mergedInstructions = mergeInstructions(mergedInstructions, resolveToolingInstructions(input.collaborationToolProfile)) + + input.output.options.instructions = mergedInstructions } export async function handleChatHeadersHook(input: { - hookInput: { model: { providerID?: string }; sessionID: string } + hookInput: { model: { providerID?: string }; sessionID: string; agent?: unknown } output: { headers: Record } spoofMode: CodexSpoofMode internalCollaborationModeHeader: string + collaborationProfileEnabled: boolean + orchestratorSubagentsEnabled: boolean }): Promise { if (input.hookInput.model.providerID !== "openai") return const originator = resolveCodexOriginator(input.spoofMode) @@ -152,10 +188,33 @@ export async function handleChatHeadersHook(input: { input.output.headers.session_id = input.hookInput.sessionID delete input.output.headers["OpenAI-Beta"] delete input.output.headers.conversation_id - if (input.spoofMode !== "native") { + + if (!input.collaborationProfileEnabled) { delete input.output.headers["x-openai-subagent"] delete input.output.headers[input.internalCollaborationModeHeader] + return } + + const profile = resolveCollaborationProfile(input.hookInput.agent) + if (!profile.enabled || !profile.kind) { + delete input.output.headers["x-openai-subagent"] + delete input.output.headers[input.internalCollaborationModeHeader] + return + } + + input.output.headers[input.internalCollaborationModeHeader] = profile.kind + + if (input.orchestratorSubagentsEnabled) { + const subagentHeader = resolveSubagentHeaderValue(input.hookInput.agent) + if (subagentHeader) { + input.output.headers["x-openai-subagent"] = subagentHeader + } else { + delete input.output.headers["x-openai-subagent"] + } + return + } + + delete input.output.headers["x-openai-subagent"] } export async function handleSessionCompactingHook(input: { diff --git a/lib/codex-native/collaboration.ts b/lib/codex-native/collaboration.ts new file mode 100644 index 0000000..c1b2f1a --- /dev/null +++ b/lib/codex-native/collaboration.ts @@ -0,0 +1,239 @@ +export type CodexCollaborationModeKind = "plan" | "code" +export type CollaborationToolProfile = "opencode" | "codex" + +export type CodexCollaborationProfile = { + enabled: boolean + kind?: CodexCollaborationModeKind + normalizedAgentName?: string + isOrchestrator?: boolean +} + +export type CollaborationInstructionsByKind = { + plan: string + code: string +} + +export const CODEX_PLAN_MODE_INSTRUCTIONS = `# Plan Mode (Conversational) + +You work in 3 phases, and you should chat your way to a great plan before finalizing it. A great plan is very detailed and decision complete so an implementer can execute directly without making additional decisions. + +## Mode rules (strict) + +You are in Plan Mode until a developer message explicitly ends it. + +Plan Mode is not changed by user intent, tone, or imperative language. If a user asks for execution while still in Plan Mode, treat it as a request to plan execution, not perform it. + +## Plan Mode vs update_plan tool + +Plan Mode is a collaboration mode and can involve asking user questions and eventually issuing a block. + +Separately, update_plan is a checklist/progress tool; it does not enter or exit Plan Mode. Do not confuse it with Plan Mode. + +## Execution vs mutation in Plan Mode + +You may explore and execute non-mutating actions that improve the plan. You must not perform mutating actions. + +### Allowed (non-mutating, plan-improving) + +- Reading and searching files, configs, schemas, types, manifests, and docs. +- Static analysis, inspection, and repo exploration. +- Dry-run commands that do not edit repo-tracked files. +- Tests/build/check commands that only write caches/artifacts and do not edit repo-tracked files. + +### Not allowed (mutating, plan-executing) + +- Editing or writing files. +- Running formatters/linters that rewrite files. +- Applying patches, migrations, or codegen that updates repo-tracked files. +- Side-effectful commands whose purpose is executing the plan rather than refining it. + +When in doubt: if the action is doing the work instead of planning the work, do not do it. + +## PHASE 1 - Ground in the environment + +Explore first, ask second. Resolve unknowns through non-mutating inspection before asking questions, unless ambiguity is in the user prompt itself and cannot be resolved locally. + +## PHASE 2 - Intent chat + +Keep asking until goal, success criteria, audience, scope, constraints, current state, and major tradeoffs are clear. + +## PHASE 3 - Implementation chat + +Keep asking until the specification is decision complete: approach, interfaces, data flow, edge cases, tests, acceptance criteria, rollout, and compatibility constraints. + +## Asking questions + +Ask only questions that materially change the plan, lock an important assumption, or select meaningful tradeoffs. Do not ask questions that local non-mutating exploration can answer. + +## Finalization rule + +Only output the final plan when it is decision complete. Wrap it in exactly one ... block, use Markdown inside, and include title, brief summary, important API/interface changes, test scenarios, and explicit assumptions/defaults. + +Do not ask "should I proceed?" in final plan output.` + +export const CODEX_CODE_MODE_INSTRUCTIONS = "you are now in code mode." + +export const CODEX_ORCHESTRATOR_INSTRUCTIONS = `# Sub-agents + +If subagent tools are unavailable, proceed solo and ignore subagent-specific guidance. + +When subagents are available, delegate independent work in parallel, coordinate them with wait/send_input-style flow, and synthesize results before finalizing. + +When subagents are active, your primary role is coordination and synthesis; avoid doing worker implementation in parallel with active workers unless needed for unblock/fallback.` + +const OPENCODE_TOOLING_TRANSLATION_INSTRUCTIONS = `# Tooling Compatibility (OpenCode) + +Translate Codex-style tool intent to OpenCode-native tools: + +- exec_command -> bash +- read/search/list -> read, grep, glob +- apply_patch/edit_file -> apply_patch +- spawn_agent -> task (launch a subagent) +- send_input -> task with existing task_id (continue the same subagent) +- wait -> do not return final output until spawned task(s) complete; poll/resume via task tool as needed +- close_agent -> stop reusing task_id (no dedicated close tool in OpenCode) + +Always use the available OpenCode tool names and schemas in this runtime.` + +const CODEX_STYLE_TOOLING_INSTRUCTIONS = `# Tooling Compatibility (Codex-style) + +Prefer Codex-style workflow semantics and naming when reasoning about steps. If an exact Codex tool is unavailable, fall back to the nearest OpenCode equivalent and continue.` + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim() + return trimmed ? trimmed : undefined +} + +export function resolveHookAgentName(agent: unknown): string | undefined { + const direct = asString(agent) + if (direct) return direct + if (!isRecord(agent)) return undefined + return asString(agent.name) ?? asString(agent.agent) +} + +function normalizeAgentName(agentName: string): string { + return agentName.trim().toLowerCase().replace(/\s+/g, "-") +} + +function tokenizeAgentName(normalizedAgentName: string): string[] { + return normalizedAgentName + .split(/[-./:_]+/) + .map((token) => token.trim()) + .filter((token) => token.length > 0) +} + +function isCodexFamily(tokens: string[]): boolean { + return tokens[0] === "codex" +} + +function isPlanPrimary(tokens: string[]): boolean { + return tokens.length === 1 && tokens[0] === "plan" +} + +function isOrchestratorPrimary(tokens: string[]): boolean { + return tokens.length === 1 && tokens[0] === "orchestrator" +} + +export function resolveCollaborationProfile(agent: unknown): CodexCollaborationProfile { + const name = resolveHookAgentName(agent) + if (!name) return { enabled: false } + + const normalizedAgentName = normalizeAgentName(name) + const tokens = tokenizeAgentName(normalizedAgentName) + if (tokens.length === 0) return { enabled: false, normalizedAgentName } + + const codexFamily = isCodexFamily(tokens) + const hasPlanToken = tokens.includes("plan") || tokens.includes("planner") + const hasOrchestratorToken = tokens.includes("orchestrator") + + if ((isPlanPrimary(tokens) || (codexFamily && hasPlanToken)) && !hasOrchestratorToken) { + return { + enabled: true, + normalizedAgentName, + kind: "plan", + isOrchestrator: false + } + } + + if (isOrchestratorPrimary(tokens) || (codexFamily && hasOrchestratorToken)) { + return { + enabled: true, + normalizedAgentName, + kind: "code", + isOrchestrator: true + } + } + + if ( + codexFamily && + tokens.some((token) => + ["default", "code", "review", "compact", "compaction", "execute", "pair", "pairprogramming"].includes( + token + ) + ) + ) { + return { + enabled: true, + normalizedAgentName, + kind: "code", + isOrchestrator: false + } + } + + return { enabled: false, normalizedAgentName } +} + +export function resolveCollaborationInstructions( + kind: CodexCollaborationModeKind, + instructions: CollaborationInstructionsByKind +): string { + if (kind === "plan") return instructions.plan + return instructions.code +} + +export function resolveToolingInstructions(profile: CollaborationToolProfile): string { + return profile === "codex" ? CODEX_STYLE_TOOLING_INSTRUCTIONS : OPENCODE_TOOLING_TRANSLATION_INSTRUCTIONS +} + +export function mergeInstructions(base: string | undefined, extra: string): string { + const normalizedExtra = extra.trim() + if (!normalizedExtra) return base?.trim() ?? "" + const normalizedBase = base?.trim() + if (!normalizedBase) return normalizedExtra + if (normalizedBase.includes(normalizedExtra)) return normalizedBase + return `${normalizedBase}\n\n${normalizedExtra}` +} + +export function resolveSubagentHeaderValue(agent: unknown): string | undefined { + const profile = resolveCollaborationProfile(agent) + const normalized = profile.normalizedAgentName + if (!profile.enabled || !normalized) { + return undefined + } + + const tokens = tokenizeAgentName(normalized) + const isPrimary = + isPlanPrimary(tokens) || + isOrchestratorPrimary(tokens) || + (tokens[0] === "codex" && + (tokens.includes("orchestrator") || + tokens.includes("default") || + tokens.includes("code") || + tokens.includes("plan") || + tokens.includes("planner") || + tokens.includes("execute") || + tokens.includes("pair") || + tokens.includes("pairprogramming"))) + + if (isPrimary) return undefined + if (tokens.includes("review")) return "review" + if (tokens.includes("compact") || tokens.includes("compaction") || normalized === "compaction") { + return "compact" + } + return "collab_spawn" +} diff --git a/lib/codex-native/request-transform.ts b/lib/codex-native/request-transform.ts index 616c730..2b39b58 100644 --- a/lib/codex-native/request-transform.ts +++ b/lib/codex-native/request-transform.ts @@ -815,6 +815,24 @@ function getVariantCandidatesFromBody(input: { body: Record; mo return out } +const COLLABORATION_INSTRUCTION_MARKERS = ["# Plan Mode (Conversational)", "# Sub-agents", "# Tooling Compatibility ("] + +function extractCollaborationInstructionTail(instructions: string): string | undefined { + const normalized = instructions.trim() + if (!normalized) return undefined + + let markerIndex: number | undefined + for (const marker of COLLABORATION_INSTRUCTION_MARKERS) { + const index = normalized.indexOf(marker) + if (index < 0) continue + if (markerIndex === undefined || index < markerIndex) markerIndex = index + } + + if (markerIndex === undefined) return undefined + const tail = normalized.slice(markerIndex).trim() + return tail.length > 0 ? tail : undefined +} + export async function applyCatalogInstructionOverrideToRequest(input: { request: Request enabled: boolean @@ -860,11 +878,24 @@ export async function applyCatalogInstructionOverrideToRequest(input: { const rendered = resolveInstructionsForModel(catalogModel, effectivePersonality) if (!rendered) return { request: input.request, changed: false, reason: "rendered_empty_or_unsafe" } - if (asString(payload.instructions) === rendered) { + const currentInstructions = asString(payload.instructions) + + if (currentInstructions === rendered) { + return { request: input.request, changed: false, reason: "already_matches" } + } + + if (currentInstructions && currentInstructions.includes(rendered)) { + return { request: input.request, changed: false, reason: "already_contains_rendered" } + } + + const collaborationTail = currentInstructions ? extractCollaborationInstructionTail(currentInstructions) : undefined + const nextInstructions = collaborationTail ? `${rendered}\n\n${collaborationTail}` : rendered + + if (currentInstructions === nextInstructions) { return { request: input.request, changed: false, reason: "already_matches" } } - payload.instructions = rendered + payload.instructions = nextInstructions const updatedRequest = rebuildRequestWithJsonBody(input.request, payload) return { request: updatedRequest, changed: true, reason: "updated" } } diff --git a/lib/config.ts b/lib/config.ts index 90387b2..a88200e 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -9,6 +9,7 @@ export type CodexSpoofMode = "native" | "codex" export type PluginRuntimeMode = "native" | "codex" export type VerbosityOption = "default" | "low" | "medium" | "high" export type PromptCacheKeyStrategy = "default" | "project" +export type CollaborationToolProfile = "opencode" | "codex" export type ModelBehaviorOverride = { personality?: PersonalityOption @@ -42,6 +43,9 @@ export type PluginConfig = { headerSnapshots?: boolean headerTransformDebug?: boolean promptCacheKeyStrategy?: PromptCacheKeyStrategy + collaborationProfileEnabled?: boolean + orchestratorSubagentsEnabled?: boolean + collaborationToolProfile?: CollaborationToolProfile behaviorSettings?: BehaviorSettings } @@ -140,6 +144,11 @@ const DEFAULT_CODEX_CONFIG_TEMPLATE = `{ // options: true | false // default: false "pidOffset": false + + // Experimental collaboration controls (optional): + // "collaborationProfile": true, + // "orchestratorSubagents": true, + // "collaborationToolProfile": "opencode" // "opencode" | "codex" }, "global": { @@ -318,6 +327,15 @@ function parsePromptCacheKeyStrategy(value: unknown): PromptCacheKeyStrategy | u return undefined } +function parseCollaborationToolProfile(value: unknown): CollaborationToolProfile | undefined { + if (typeof value !== "string") return undefined + const normalized = value.trim().toLowerCase() + if (normalized === "opencode" || normalized === "codex") { + return normalized + } + return undefined +} + function normalizeVerbosityOption(value: unknown): VerbosityOption | undefined { if (typeof value !== "string") return undefined const normalized = value.trim().toLowerCase() @@ -511,6 +529,17 @@ function parseConfigFileObject(raw: unknown): Partial { : undefined const pidOffsetEnabled = isRecord(raw.runtime) && typeof raw.runtime.pidOffset === "boolean" ? raw.runtime.pidOffset : undefined + const collaborationProfileEnabled = + isRecord(raw.runtime) && typeof raw.runtime.collaborationProfile === "boolean" + ? raw.runtime.collaborationProfile + : undefined + const orchestratorSubagentsEnabled = + isRecord(raw.runtime) && typeof raw.runtime.orchestratorSubagents === "boolean" + ? raw.runtime.orchestratorSubagents + : undefined + const collaborationToolProfile = parseCollaborationToolProfile( + isRecord(raw.runtime) ? raw.runtime.collaborationToolProfile : undefined + ) return { debug, @@ -528,6 +557,9 @@ function parseConfigFileObject(raw: unknown): Partial { codexCompactionOverride, headerSnapshots, headerTransformDebug, + collaborationProfileEnabled, + orchestratorSubagentsEnabled, + collaborationToolProfile, behaviorSettings } } @@ -678,6 +710,13 @@ export function resolveConfig(input: { const headerSnapshots = parseEnvBoolean(env.OPENCODE_OPENAI_MULTI_HEADER_SNAPSHOTS) ?? file.headerSnapshots const headerTransformDebug = parseEnvBoolean(env.OPENCODE_OPENAI_MULTI_HEADER_TRANSFORM_DEBUG) ?? file.headerTransformDebug + const collaborationProfileEnabled = + parseEnvBoolean(env.OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE) ?? file.collaborationProfileEnabled + const orchestratorSubagentsEnabled = + parseEnvBoolean(env.OPENCODE_OPENAI_MULTI_ORCHESTRATOR_SUBAGENTS) ?? file.orchestratorSubagentsEnabled + const collaborationToolProfile = + parseCollaborationToolProfile(env.OPENCODE_OPENAI_MULTI_COLLABORATION_TOOL_PROFILE) ?? + file.collaborationToolProfile return { ...file, @@ -696,6 +735,9 @@ export function resolveConfig(input: { codexCompactionOverride, headerSnapshots, headerTransformDebug, + collaborationProfileEnabled, + orchestratorSubagentsEnabled, + collaborationToolProfile, behaviorSettings: resolvedBehaviorSettings } } @@ -766,6 +808,22 @@ export function getHeaderTransformDebugEnabled(cfg: PluginConfig): boolean { return cfg.headerTransformDebug === true } +export function getCollaborationProfileEnabled(cfg: PluginConfig): boolean { + if (cfg.collaborationProfileEnabled === true) return true + if (cfg.collaborationProfileEnabled === false) return false + return getMode(cfg) === "codex" +} + +export function getOrchestratorSubagentsEnabled(cfg: PluginConfig): boolean { + if (cfg.orchestratorSubagentsEnabled === true) return true + if (cfg.orchestratorSubagentsEnabled === false) return false + return getCollaborationProfileEnabled(cfg) +} + +export function getCollaborationToolProfile(cfg: PluginConfig): CollaborationToolProfile { + return cfg.collaborationToolProfile === "codex" ? "codex" : "opencode" +} + export function getBehaviorSettings(cfg: PluginConfig): BehaviorSettings | undefined { return cfg.behaviorSettings } diff --git a/lib/installer-cli.ts b/lib/installer-cli.ts index cb336c2..2419e57 100644 --- a/lib/installer-cli.ts +++ b/lib/installer-cli.ts @@ -2,7 +2,8 @@ import path from "node:path" import { installCreatePersonalityCommand } from "./personality-command.js" import { installPersonalityBuilderSkill } from "./personality-skill.js" -import { ensureDefaultConfigFile } from "./config.js" +import { ensureDefaultConfigFile, getCollaborationProfileEnabled, getMode, loadConfigFile, resolveConfig } from "./config.js" +import { reconcileOrchestratorAgentVisibility } from "./orchestrator-agent.js" import { DEFAULT_PLUGIN_SPECIFIER, defaultOpencodeConfigPath, ensurePluginInstalled } from "./opencode-install.js" type InstallerIo = { @@ -109,5 +110,19 @@ export async function runInstallerCli(args: string[], io: InstallerIo = DEFAULT_ }` ) + const resolvedConfig = resolveConfig({ + env: process.env, + file: loadConfigFile({ env: process.env }) + }) + const runtimeMode = getMode(resolvedConfig) + const collaborationProfileEnabled = getCollaborationProfileEnabled(resolvedConfig) + const orchestratorResult = await reconcileOrchestratorAgentVisibility({ visible: collaborationProfileEnabled }) + io.out(`Orchestrator agent file: ${orchestratorResult.filePath}`) + io.out( + `Orchestrator agent visible in current mode (${runtimeMode}, collaboration=${collaborationProfileEnabled ? "on" : "off"}): ${ + orchestratorResult.visible ? "yes" : "no" + }` + ) + return 0 } diff --git a/lib/orchestrator-agent.ts b/lib/orchestrator-agent.ts new file mode 100644 index 0000000..22edbca --- /dev/null +++ b/lib/orchestrator-agent.ts @@ -0,0 +1,191 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +export const CODEX_ORCHESTRATOR_AGENT_FILE = "orchestrator.md" +export const CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED = `${CODEX_ORCHESTRATOR_AGENT_FILE}.disabled` + +const CODEX_ORCHESTRATOR_AGENT_TEMPLATE = `--- +description: Codex-style orchestration profile for parallel delegation and synthesis. +mode: primary +--- + +You are the Orchestrator agent. + +Coordinate multi-step tasks by delegating independent work to subagents, then synthesize results into one coherent outcome. + +# Sub-agents + +If subagent tools are unavailable, continue solo and ignore subagent-specific guidance. + +When subagents are available: +- Decompose into independent subtasks. +- Launch subagents in parallel when safe. +- Use wait/send-input style coordination to drive progress. +- Integrate findings and deliver a final answer. + +Do not create unnecessary delegation for trivial tasks. +` + +export type InstallOrchestratorAgentInput = { + agentsDir?: string + force?: boolean +} + +export type InstallOrchestratorAgentResult = { + agentsDir: string + filePath: string + created: boolean + updated: boolean +} + +export type ReconcileOrchestratorAgentVisibilityInput = { + agentsDir?: string + visible: boolean + force?: boolean +} + +export type ReconcileOrchestratorAgentVisibilityResult = { + agentsDir: string + filePath: string + visible: boolean + created: boolean + updated: boolean + moved: boolean +} + +export function defaultOpencodeAgentsDir(env: Record = process.env): string { + const xdgRoot = env.XDG_CONFIG_HOME?.trim() + if (xdgRoot) { + return path.join(xdgRoot, "opencode", "agents") + } + return path.join(os.homedir(), ".config", "opencode", "agents") +} + +async function readIfExists(filePath: string): Promise { + try { + return await fs.readFile(filePath, "utf8") + } catch { + return undefined + } +} + +async function exists(filePath: string): Promise { + return (await readIfExists(filePath)) !== undefined +} + +async function ensureTemplateFile(filePath: string, force: boolean): Promise<{ created: boolean; updated: boolean }> { + const existingContent = await readIfExists(filePath) + if (existingContent === CODEX_ORCHESTRATOR_AGENT_TEMPLATE) { + return { created: false, updated: false } + } + if (existingContent !== undefined && !force) { + return { created: false, updated: false } + } + + await fs.mkdir(path.dirname(filePath), { recursive: true }) + await fs.writeFile(filePath, CODEX_ORCHESTRATOR_AGENT_TEMPLATE, { encoding: "utf8", mode: 0o600 }) + + return { + created: existingContent === undefined, + updated: existingContent !== undefined + } +} + +export async function installOrchestratorAgent( + input: InstallOrchestratorAgentInput = {} +): Promise { + const agentsDir = input.agentsDir ?? defaultOpencodeAgentsDir() + const filePath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) + const ensured = await ensureTemplateFile(filePath, input.force === true) + + return { + agentsDir, + filePath, + created: ensured.created, + updated: ensured.updated + } +} + +export async function reconcileOrchestratorAgentVisibility( + input: ReconcileOrchestratorAgentVisibilityInput +): Promise { + const agentsDir = input.agentsDir ?? defaultOpencodeAgentsDir() + const enabledPath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) + const disabledPath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED) + const force = input.force === true + const enabledExists = await exists(enabledPath) + const disabledExists = await exists(disabledPath) + + if (input.visible) { + if (enabledExists) { + const ensured = await ensureTemplateFile(enabledPath, force) + return { + agentsDir, + filePath: enabledPath, + visible: true, + created: ensured.created, + updated: ensured.updated, + moved: false + } + } + + if (disabledExists) { + await fs.mkdir(path.dirname(enabledPath), { recursive: true }) + await fs.rename(disabledPath, enabledPath) + return { + agentsDir, + filePath: enabledPath, + visible: true, + created: false, + updated: false, + moved: true + } + } + + const ensured = await ensureTemplateFile(enabledPath, force) + return { + agentsDir, + filePath: enabledPath, + visible: true, + created: ensured.created, + updated: ensured.updated, + moved: false + } + } + + if (disabledExists) { + const ensured = await ensureTemplateFile(disabledPath, force) + return { + agentsDir, + filePath: disabledPath, + visible: false, + created: ensured.created, + updated: ensured.updated, + moved: false + } + } + + if (enabledExists) { + await fs.mkdir(path.dirname(disabledPath), { recursive: true }) + await fs.rename(enabledPath, disabledPath) + return { + agentsDir, + filePath: disabledPath, + visible: false, + created: false, + updated: false, + moved: true + } + } + + const ensured = await ensureTemplateFile(disabledPath, force) + return { + agentsDir, + filePath: disabledPath, + visible: false, + created: ensured.created, + updated: ensured.updated, + moved: false + } +} diff --git a/schemas/codex-config.schema.json b/schemas/codex-config.schema.json index f8c2b2a..6b86a49 100644 --- a/schemas/codex-config.schema.json +++ b/schemas/codex-config.schema.json @@ -61,6 +61,16 @@ }, "pidOffset": { "type": "boolean" + }, + "collaborationProfile": { + "type": "boolean" + }, + "orchestratorSubagents": { + "type": "boolean" + }, + "collaborationToolProfile": { + "type": "string", + "enum": ["opencode", "codex"] } } }, diff --git a/test/codex-native-chat-hooks.test.ts b/test/codex-native-chat-hooks.test.ts index a968bf2..1e3422f 100644 --- a/test/codex-native-chat-hooks.test.ts +++ b/test/codex-native-chat-hooks.test.ts @@ -44,7 +44,10 @@ describe("codex-native chat hooks instruction source order", () => { } } ], - spoofMode: "codex" + spoofMode: "codex", + collaborationProfileEnabled: false, + orchestratorSubagentsEnabled: false, + collaborationToolProfile: "opencode" }) expect(output.options.instructions).toBe("Cached template instructions") diff --git a/test/codex-native-collaboration.test.ts b/test/codex-native-collaboration.test.ts new file mode 100644 index 0000000..45a1a9b --- /dev/null +++ b/test/codex-native-collaboration.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest" + +import { + mergeInstructions, + resolveCollaborationInstructions, + resolveCollaborationProfile, + resolveSubagentHeaderValue, + resolveToolingInstructions +} from "../lib/codex-native/collaboration" + +describe("codex collaboration profile", () => { + it("maps plan agent to plan mode", () => { + const profile = resolveCollaborationProfile("plan") + expect(profile.enabled).toBe(true) + expect(profile.kind).toBe("plan") + }) + + it("maps orchestrator agent to code mode", () => { + const profile = resolveCollaborationProfile("Orchestrator") + expect(profile.enabled).toBe(true) + expect(profile.kind).toBe("code") + }) + + it("does not enable profile for unrelated build agent", () => { + const profile = resolveCollaborationProfile("build") + expect(profile.enabled).toBe(false) + }) + + it("maps codex review helper to review subagent header", () => { + expect(resolveSubagentHeaderValue("Codex Review")).toBe("review") + }) + + it("does not emit subagent header for plan/orchestrator primaries", () => { + expect(resolveSubagentHeaderValue("plan")).toBeUndefined() + expect(resolveSubagentHeaderValue("orchestrator")).toBeUndefined() + }) + + it("selects mode instructions by collaboration kind", () => { + const instructions = { + plan: "PLAN", + code: "CODE" + } + expect(resolveCollaborationInstructions("plan", instructions)).toBe("PLAN") + expect(resolveCollaborationInstructions("code", instructions)).toBe("CODE") + }) + + it("merges instructions once without duplicating", () => { + const merged = mergeInstructions("base", "extra") + expect(merged).toBe("base\n\nextra") + expect(mergeInstructions(merged, "extra")).toBe("base\n\nextra") + }) + + it("resolves tooling profiles", () => { + expect(resolveToolingInstructions("opencode")).toContain("Tooling Compatibility (OpenCode)") + expect(resolveToolingInstructions("codex")).toContain("Tooling Compatibility (Codex-style)") + }) +}) diff --git a/test/codex-native-in-vivo-instructions.test.ts b/test/codex-native-in-vivo-instructions.test.ts index aedab25..46e22f2 100644 --- a/test/codex-native-in-vivo-instructions.test.ts +++ b/test/codex-native-in-vivo-instructions.test.ts @@ -32,4 +32,37 @@ describe("codex-native in-vivo instruction injection", () => { expect(result.outboundInstructions).toBe("Base Vivo Persona Voice") expect(result.outboundInstructions).not.toBe("OpenCode Host Instructions") }) + + it("injects codex-to-opencode tool call replacements for orchestrator prompts", async () => { + const result = await runCodexInVivoInstructionProbe({ + hostInstructions: "OpenCode Host Instructions", + personalityKey: "vivo_persona", + personalityText: "Vivo Persona Voice", + agent: "orchestrator", + collaborationProfileEnabled: true, + orchestratorSubagentsEnabled: true, + collaborationToolProfile: "opencode" + }) + + expect(result.outboundInstructions).toContain("# Sub-agents") + expect(result.outboundInstructions).toContain("spawn_agent -> task") + expect(result.outboundInstructions).toContain("send_input -> task with existing task_id") + expect(result.outboundInstructions).toContain("wait -> do not return final output") + expect(result.outboundInstructions).toContain("close_agent -> stop reusing task_id") + }) + + it("injects plan mode semantics plus tool replacements for plan agent", async () => { + const result = await runCodexInVivoInstructionProbe({ + hostInstructions: "OpenCode Host Instructions", + personalityKey: "vivo_persona", + personalityText: "Vivo Persona Voice", + agent: "plan", + collaborationProfileEnabled: true, + collaborationToolProfile: "opencode" + }) + + expect(result.outboundInstructions).toContain("# Plan Mode (Conversational)") + expect(result.outboundInstructions).toContain("must not perform mutating actions") + expect(result.outboundInstructions).toContain("spawn_agent -> task") + }) }) diff --git a/test/codex-native-spoof-mode.test.ts b/test/codex-native-spoof-mode.test.ts index 83f0be8..05724ae 100644 --- a/test/codex-native-spoof-mode.test.ts +++ b/test/codex-native-spoof-mode.test.ts @@ -736,7 +736,7 @@ describe("codex-native spoof + params hooks", () => { expect(output.options.instructions).toBeUndefined() }) - it("does not inject collaboration instructions for Codex agents in codex mode", async () => { + it("injects collaboration instructions for Codex agents by default in codex mode", async () => { const hooks = await CodexAuthPlugin({} as never, { spoofMode: "codex", mode: "codex" }) const chatParams = hooks["chat.params"] expect(chatParams).toBeTypeOf("function") @@ -766,8 +766,9 @@ describe("codex-native spoof + params hooks", () => { } await chatParams?.(input, output) - expect(output.options.instructions).toBe("Catalog instructions") - expect(output.options.instructions).not.toContain("# Plan Mode") + expect(output.options.instructions).toContain("Catalog instructions") + expect(output.options.instructions).toContain("# Plan Mode (Conversational)") + expect(output.options.instructions).toContain("Tooling Compatibility (OpenCode)") }) it("does not enable codex collaboration profile for native OpenCode agents", async () => { @@ -927,7 +928,7 @@ describe("codex-native spoof + params hooks", () => { expect(output.headers.conversation_id).toBeUndefined() }) - it("does not set collaboration headers for Codex agents in codex mode", async () => { + it("sets collaboration headers for Codex agents by default in codex mode", async () => { const hooks = await CodexAuthPlugin({} as never, { spoofMode: "codex", mode: "codex" }) const chatHeaders = hooks["chat.headers"] expect(chatHeaders).toBeTypeOf("function") @@ -941,8 +942,8 @@ describe("codex-native spoof + params hooks", () => { const output = { headers: {} as Record } await chatHeaders?.(input, output) - expect(output.headers["x-openai-subagent"]).toBeUndefined() - expect(output.headers["x-opencode-collaboration-mode-kind"]).toBeUndefined() + expect(output.headers["x-openai-subagent"]).toBe("review") + expect(output.headers["x-opencode-collaboration-mode-kind"]).toBe("code") }) it("does not set codex collaboration headers for native OpenCode agents", async () => { @@ -963,6 +964,57 @@ describe("codex-native spoof + params hooks", () => { expect(output.headers["x-opencode-collaboration-mode-kind"]).toBeUndefined() }) + it("allows collaboration injection in native mode when explicitly enabled", async () => { + const hooks = await CodexAuthPlugin({} as never, { + spoofMode: "native", + mode: "native", + collaborationProfileEnabled: true, + orchestratorSubagentsEnabled: true, + collaborationToolProfile: "codex" + } as never) + const chatParams = hooks["chat.params"] + const chatHeaders = hooks["chat.headers"] + expect(chatParams).toBeTypeOf("function") + expect(chatHeaders).toBeTypeOf("function") + + const paramsInput = { + sessionID: "ses_native_collab_params", + agent: "orchestrator", + provider: {}, + message: {}, + model: { + providerID: "openai", + capabilities: { toolcall: true }, + options: { + codexInstructions: "Catalog instructions" + } + } + } as unknown as Parameters>[0] + + const paramsOutput = { + temperature: 0, + topP: 1, + topK: 0, + options: {} + } + + await chatParams?.(paramsInput, paramsOutput) + expect(paramsOutput.options.instructions).toContain("Catalog instructions") + expect(paramsOutput.options.instructions).toContain("# Sub-agents") + expect(paramsOutput.options.instructions).toContain("Tooling Compatibility (Codex-style)") + + const headersInput = { + sessionID: "ses_native_collab_headers", + agent: "Codex Review", + model: { providerID: "openai", options: {} } + } as unknown as Parameters>[0] + + const headersOutput = { headers: {} as Record } + await chatHeaders?.(headersInput, headersOutput) + expect(headersOutput.headers["x-opencode-collaboration-mode-kind"]).toBe("code") + expect(headersOutput.headers["x-openai-subagent"]).toBe("review") + }) + it("does not set codex collaboration headers for legacy Orchestrator agent names", async () => { const hooks = await CodexAuthPlugin({} as never, { spoofMode: "codex" }) const chatHeaders = hooks["chat.headers"] @@ -980,4 +1032,122 @@ describe("codex-native spoof + params hooks", () => { expect(output.headers["x-openai-subagent"]).toBeUndefined() expect(output.headers["x-opencode-collaboration-mode-kind"]).toBeUndefined() }) + + it("injects plan-mode collaboration instructions when experimental collaboration profile is enabled", async () => { + const hooks = await CodexAuthPlugin({} as never, { + spoofMode: "codex", + mode: "codex", + collaborationProfileEnabled: true + } as never) + const chatParams = hooks["chat.params"] + expect(chatParams).toBeTypeOf("function") + + const input = { + sessionID: "ses_plan_collab_enabled", + agent: "plan", + provider: {}, + message: {}, + model: { + providerID: "openai", + capabilities: { toolcall: true }, + options: { + codexInstructions: "Catalog instructions" + } + } + } as unknown as Parameters>[0] + + const output = { + temperature: 0, + topP: 1, + topK: 0, + options: {} + } + + await chatParams?.(input, output) + + expect(output.options.instructions).toContain("Catalog instructions") + expect(output.options.instructions).toContain("# Plan Mode (Conversational)") + }) + + it("injects orchestrator collaboration instructions when experimental collaboration profile is enabled", async () => { + const hooks = await CodexAuthPlugin({} as never, { + spoofMode: "codex", + mode: "codex", + collaborationProfileEnabled: true, + orchestratorSubagentsEnabled: true + } as never) + const chatParams = hooks["chat.params"] + expect(chatParams).toBeTypeOf("function") + + const input = { + sessionID: "ses_orchestrator_collab_enabled", + agent: "orchestrator", + provider: {}, + message: {}, + model: { + providerID: "openai", + capabilities: { toolcall: true }, + options: { + codexInstructions: "Catalog instructions" + } + } + } as unknown as Parameters>[0] + + const output = { + temperature: 0, + topP: 1, + topK: 0, + options: {} + } + + await chatParams?.(input, output) + + expect(output.options.instructions).toContain("Catalog instructions") + expect(output.options.instructions).toContain("# Sub-agents") + }) + + it("sets collaboration-mode header for plan agent when experimental collaboration profile is enabled", async () => { + const hooks = await CodexAuthPlugin({} as never, { + spoofMode: "codex", + mode: "codex", + collaborationProfileEnabled: true + } as never) + const chatHeaders = hooks["chat.headers"] + expect(chatHeaders).toBeTypeOf("function") + + const input = { + sessionID: "ses_plan_collab_headers", + agent: "plan", + model: { providerID: "openai", options: {} } + } as unknown as Parameters>[0] + + const output = { headers: {} as Record } + await chatHeaders?.(input, output) + + expect(output.headers["x-opencode-collaboration-mode-kind"]).toBe("plan") + expect(output.headers["x-openai-subagent"]).toBeUndefined() + }) + + it("sets subagent + collaboration headers for codex review helper when orchestrator subagents are enabled", async () => { + const hooks = await CodexAuthPlugin({} as never, { + spoofMode: "codex", + mode: "codex", + collaborationProfileEnabled: true, + orchestratorSubagentsEnabled: true + } as never) + const chatHeaders = hooks["chat.headers"] + expect(chatHeaders).toBeTypeOf("function") + + const input = { + sessionID: "ses_review_collab_headers", + agent: "Codex Review", + model: { providerID: "openai", options: {} } + } as unknown as Parameters>[0] + + const output = { headers: {} as Record } + await chatHeaders?.(input, output) + + expect(output.headers["x-opencode-collaboration-mode-kind"]).toBe("code") + expect(output.headers["x-openai-subagent"]).toBe("review") + }) }) diff --git a/test/config.test.ts b/test/config.test.ts index a345f61..625e15a 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -8,6 +8,8 @@ import { DEFAULT_CODEX_CONFIG, ensureDefaultConfigFile, getCompatInputSanitizerEnabled, + getCollaborationProfileEnabled, + getCollaborationToolProfile, getCodexCompactionOverrideEnabled, getBehaviorSettings, getDebugEnabled, @@ -15,6 +17,7 @@ import { getHeaderSnapshotsEnabled, getMode, getPromptCacheKeyStrategy, + getOrchestratorSubagentsEnabled, getRemapDeveloperMessagesToUserEnabled, getRotationStrategy, getPidOffsetEnabled, @@ -186,6 +189,49 @@ describe("config loading", () => { expect(getHeaderTransformDebugEnabled(cfg)).toBe(true) }) + it("parses collaboration profile gate from env", () => { + const enabled = resolveConfig({ + env: { + OPENCODE_OPENAI_MULTI_MODE: "codex", + OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE: "1" + } + }) + const disabled = resolveConfig({ + env: { + OPENCODE_OPENAI_MULTI_MODE: "native", + OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE: "1" + } + }) + expect(getCollaborationProfileEnabled(enabled)).toBe(true) + expect(getCollaborationProfileEnabled(disabled)).toBe(true) + }) + + it("parses orchestrator subagent gate from env", () => { + const enabled = resolveConfig({ + env: { + OPENCODE_OPENAI_MULTI_MODE: "codex", + OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE: "1", + OPENCODE_OPENAI_MULTI_ORCHESTRATOR_SUBAGENTS: "1" + } + }) + const disabled = resolveConfig({ + env: { + OPENCODE_OPENAI_MULTI_MODE: "codex", + OPENCODE_OPENAI_MULTI_COLLABORATION_PROFILE: "0", + OPENCODE_OPENAI_MULTI_ORCHESTRATOR_SUBAGENTS: "0" + } + }) + expect(getOrchestratorSubagentsEnabled(enabled)).toBe(true) + expect(getOrchestratorSubagentsEnabled(disabled)).toBe(false) + }) + + it("parses collaboration tooling profile from env", () => { + const codex = resolveConfig({ env: { OPENCODE_OPENAI_MULTI_COLLABORATION_TOOL_PROFILE: "codex" } }) + const opencode = resolveConfig({ env: { OPENCODE_OPENAI_MULTI_COLLABORATION_TOOL_PROFILE: "opencode" } }) + expect(getCollaborationToolProfile(codex)).toBe("codex") + expect(getCollaborationToolProfile(opencode)).toBe("opencode") + }) + it("reads personality + behavior settings from file config", () => { const cfg = resolveConfig({ env: {}, @@ -296,6 +342,26 @@ describe("config", () => { expect(getCodexCompactionOverrideEnabled({ mode: "codex" })).toBe(true) }) + it("defaults collaboration gates to codex-on, native-off", () => { + expect(getCollaborationProfileEnabled({ mode: "native" })).toBe(false) + expect(getCollaborationProfileEnabled({ mode: "codex" })).toBe(true) + expect(getOrchestratorSubagentsEnabled({ mode: "native" })).toBe(false) + expect(getOrchestratorSubagentsEnabled({ mode: "codex" })).toBe(true) + }) + + it("allows overriding collaboration gates in any mode", () => { + expect(getCollaborationProfileEnabled({ mode: "native", collaborationProfileEnabled: true })).toBe(true) + expect(getCollaborationProfileEnabled({ mode: "codex", collaborationProfileEnabled: false })).toBe(false) + expect(getCollaborationProfileEnabled({ mode: "codex", collaborationProfileEnabled: true })).toBe(true) + expect( + getOrchestratorSubagentsEnabled({ + mode: "native", + collaborationProfileEnabled: true, + orchestratorSubagentsEnabled: true + }) + ).toBe(true) + }) + it("allows enabling codex compaction override in native mode", () => { expect( getCodexCompactionOverrideEnabled({ @@ -336,7 +402,10 @@ describe("config file loading", () => { codexCompactionOverride: true, headerSnapshots: true, headerTransformDebug: true, - pidOffset: true + pidOffset: true, + collaborationProfile: true, + orchestratorSubagents: true, + collaborationToolProfile: "codex" }, global: { thinkingSummaries: true, @@ -373,6 +442,9 @@ describe("config file loading", () => { expect(loaded.headerSnapshots).toBe(true) expect(loaded.headerTransformDebug).toBe(true) expect(loaded.pidOffsetEnabled).toBe(true) + expect(loaded.collaborationProfileEnabled).toBe(true) + expect(loaded.orchestratorSubagentsEnabled).toBe(true) + expect(loaded.collaborationToolProfile).toBe("codex") expect(loaded.rotationStrategy).toBe("hybrid") expect(loaded.promptCacheKeyStrategy).toBe("project") expect(loaded.mode).toBe("codex") diff --git a/test/helpers/codex-in-vivo.ts b/test/helpers/codex-in-vivo.ts index 6823c70..51ad7f9 100644 --- a/test/helpers/codex-in-vivo.ts +++ b/test/helpers/codex-in-vivo.ts @@ -11,6 +11,10 @@ type InVivoProbeInput = { personalityKey: string personalityText: string modelSlug?: string + agent?: string + collaborationProfileEnabled?: boolean + orchestratorSubagentsEnabled?: boolean + collaborationToolProfile?: "opencode" | "codex" stripModelOptionsBeforeParams?: boolean modelInstructionsFallback?: string omitModelIdentityBeforeParams?: boolean @@ -159,7 +163,10 @@ export async function runCodexInVivoInstructionProbe(input: InVivoProbeInput): P try { const hooks = await CodexAuthPlugin({} as never, { spoofMode: "codex", - behaviorSettings: { global: { personality: input.personalityKey } } + behaviorSettings: { global: { personality: input.personalityKey } }, + collaborationProfileEnabled: input.collaborationProfileEnabled, + orchestratorSubagentsEnabled: input.orchestratorSubagentsEnabled, + collaborationToolProfile: input.collaborationToolProfile }) const provider = { @@ -193,7 +200,7 @@ export async function runCodexInVivoInstructionProbe(input: InVivoProbeInput): P await hooks["chat.params"]?.( { sessionID: "ses_vivo_1", - agent: "default", + agent: input.agent ?? "default", provider: {}, message: {}, model: { diff --git a/test/installer-cli.test.ts b/test/installer-cli.test.ts index 7e41f99..1a5a6e9 100644 --- a/test/installer-cli.test.ts +++ b/test/installer-cli.test.ts @@ -47,6 +47,7 @@ describe("installer cli", () => { expect(output).toContain("Codex config:") expect(output).toContain("/create-personality synchronized: created") expect(output).toContain("personality-builder skill synchronized: created") + expect(output).toContain("Orchestrator agent visible in current mode (native, collaboration=off): no") const config = JSON.parse(await fs.readFile(configPath, "utf8")) as { plugin: string[] } expect(config.plugin).toContain("@iam-brain/opencode-codex-auth@latest") @@ -66,6 +67,9 @@ describe("installer cli", () => { "utf8" ) expect(skillFile).toContain("name: personality-builder") + + await expect(fs.access(path.join(root, "opencode", "agents", "orchestrator.md"))).rejects.toBeTruthy() + await expect(fs.access(path.join(root, "opencode", "agents", "orchestrator.md.disabled"))).resolves.toBeUndefined() } finally { if (previousXdg === undefined) { delete process.env.XDG_CONFIG_HOME @@ -84,4 +88,34 @@ describe("installer cli", () => { expect(code).toBe(1) expect(capture.err.join("\n")).toContain("Unknown command: install-agents") }) + + it("shows orchestrator agent in codex mode", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-installer-codex-")) + const configPath = path.join(root, "opencode.json") + const capture = captureIo() + const previousXdg = process.env.XDG_CONFIG_HOME + const previousMode = process.env.OPENCODE_OPENAI_MULTI_MODE + process.env.XDG_CONFIG_HOME = root + process.env.OPENCODE_OPENAI_MULTI_MODE = "codex" + + try { + const code = await runInstallerCli(["--config", configPath], capture.io) + expect(code).toBe(0) + expect(capture.out.join("\n")).toContain("Orchestrator agent visible in current mode (codex, collaboration=on): yes") + await expect(fs.access(path.join(root, "opencode", "agents", "orchestrator.md"))).resolves.toBeUndefined() + await expect(fs.access(path.join(root, "opencode", "agents", "orchestrator.md.disabled"))).rejects.toBeTruthy() + } finally { + if (previousXdg === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = previousXdg + } + + if (previousMode === undefined) { + delete process.env.OPENCODE_OPENAI_MULTI_MODE + } else { + process.env.OPENCODE_OPENAI_MULTI_MODE = previousMode + } + } + }) }) diff --git a/test/orchestrator-agent.test.ts b/test/orchestrator-agent.test.ts new file mode 100644 index 0000000..60038e5 --- /dev/null +++ b/test/orchestrator-agent.test.ts @@ -0,0 +1,67 @@ +import fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" + +import { describe, expect, it } from "vitest" + +import { + CODEX_ORCHESTRATOR_AGENT_FILE, + CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED, + installOrchestratorAgent, + reconcileOrchestratorAgentVisibility +} from "../lib/orchestrator-agent" + +describe("orchestrator agent installer", () => { + it("writes orchestrator agent template and preserves existing content by default", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-orchestrator-agent-")) + const agentsDir = path.join(root, "agents") + + const first = await installOrchestratorAgent({ agentsDir }) + expect(first.created).toBe(true) + const filePath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) + const firstContent = await fs.readFile(filePath, "utf8") + expect(firstContent).toContain("mode: primary") + expect(firstContent).toContain("# Sub-agents") + + await fs.writeFile(filePath, "custom orchestrator", "utf8") + const second = await installOrchestratorAgent({ agentsDir }) + expect(second.created).toBe(false) + expect(second.updated).toBe(false) + expect(await fs.readFile(filePath, "utf8")).toBe("custom orchestrator") + }) + + it("updates existing orchestrator agent when forced", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-orchestrator-agent-force-")) + const agentsDir = path.join(root, "agents") + const filePath = path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE) + await fs.mkdir(agentsDir, { recursive: true }) + await fs.writeFile(filePath, "stale orchestrator", "utf8") + + const result = await installOrchestratorAgent({ agentsDir, force: true }) + expect(result.created).toBe(false) + expect(result.updated).toBe(true) + + const content = await fs.readFile(filePath, "utf8") + expect(content).toContain("mode: primary") + expect(content).toContain("# Sub-agents") + }) + + it("toggles visibility by renaming enabled/disabled file variants", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-codex-auth-orchestrator-agent-toggle-")) + const agentsDir = path.join(root, "agents") + + const hidden = await reconcileOrchestratorAgentVisibility({ agentsDir, visible: false }) + expect(hidden.visible).toBe(false) + expect(hidden.filePath).toBe(path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED)) + + const hiddenContent = await fs.readFile(hidden.filePath, "utf8") + expect(hiddenContent).toContain("mode: primary") + + const visible = await reconcileOrchestratorAgentVisibility({ agentsDir, visible: true }) + expect(visible.visible).toBe(true) + expect(visible.filePath).toBe(path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE)) + + await expect(fs.access(path.join(agentsDir, CODEX_ORCHESTRATOR_AGENT_FILE_DISABLED))).rejects.toBeTruthy() + expect(await fs.readFile(visible.filePath, "utf8")).toContain("# Sub-agents") + }) +})