148 KiB
OpenCode Native Semantic Messaging Plan
Status: planning document
Scope: claude_team + agent_teams_orchestrator
Goal: make OpenCode teammates use the correct app MCP messaging protocol without breaking Codex/Claude native teammates.
Problem
OpenCode teammates currently run in a different tool environment than Codex/Claude native teammates.
Native teammates can use SendMessage.
OpenCode teammates must use app MCP tools exposed by the agent-teams server, especially:
agent-teams_message_sendagent-teams_cross_team_sendfor messages to other teamsagent-teams_member_briefingagent-teams_runtime_bootstrap_checkin- board tools such as
task_briefing,task_start,task_add_comment,task_complete
The current code already tells OpenCode to use agent-teams_message_send in some places, but other downstream prompts still contain hardcoded SendMessage. That creates inconsistent instructions:
- OpenCode launch prompt says: use
agent-teams_message_send. member_briefingsays: useSendMessage.- task assignment notification says: use
SendMessage. - clarification protocol says: use
SendMessage.
This can make OpenCode teammates look started but not answer through the Messages UI.
Decision
Chosen approach: OpenCode-native semantic messaging seam.
Option 1: frontend-only display patch - 🎯 2 🛡️ 2 🧠 2, about 50-120 LOC This hides symptoms only. It does not fix the wrong tool instructions sent to OpenCode.
Option 2: orchestrator-only patch - 🎯 6 🛡️ 6 🧠 4, about 180-320 LOC
This is necessary for runtime identity and MCP proof, but not sufficient because member_briefing and task assignment messages are produced in claude_team.
Option 3: orchestrator + claude_team controller/MCP semantic seam - 🎯 9 🛡️ 9 🧠 7, about 1300-2200 LOC with tests
This fixes the actual contract. Orchestrator owns OpenCode session identity. claude_team owns team protocol text and MCP tool schemas.
Extra Research Corrections
This section records the higher-risk places that were checked after the first draft.
member_briefingis not the only source ofSendMessagewording.buildAssignmentMessage()andbuildMemberTaskProtocol()also contain hardcoded native instructions, so the fix must cover assignment and clarification paths too.- Controller member resolution currently drops provider metadata. Without preserving
providerId/provider, task assignment notifications cannot reliably choose OpenCode wording for an OpenCode owner. message_sendstorage already supportstaskRefs, but MCP schema does not expose it yet. If prompts mention task traceability, schema must accepttaskRefsor the plan creates another mismatch.message_sendcurrently uses the rawtovalue as the inbox filename. If an agent sends to the aliasteam-leadwhile the configured lead is actually namedlead, the row can land ininboxes/team-lead.jsonand bypass lead relay.message_sendmust canonicalize local recipients and sender aliases before persistence.- OpenCode tool names appear through multiple aliases:
agent-teams_message_send,agent_teams_message_send,mcp__agent-teams__message_send,mcp__agent_teams__message_send, and sometimes plainmessage_send. Capture/logging code must not hardcode only one spelling. runtime_bootstrap_checkinneedsruntimeSessionId. The adapter cannot know it. Only orchestrator knowsrecord.opencodeSessionIdafterensureSession(), so identity injection belongs inagent_teams_orchestrator.runtime_bootstrap_checkindoes not acceptlaneId.laneIdis bridge/session routing state, not an MCP tool argument. The plan must not show examples with unsupported payload fields.runtime_deliver_messageis a real delivery tool, not a dummy readiness marker. It writes throughRuntimeDeliveryServiceinto app-owned destinations. That makes it dangerous to leave ambiguous: OpenCode may choose it for ordinary replies unless descriptions/prompts clearly say normal human/team replies usemessage_sendin v1.- The required app-tool proof must cover all teammate-operational tools that
member_briefingcan instruct, not justmessage_sendand four task tools. claude_teamalready exportsAGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMESfromagent-teams-controller; app-side required tools should derive from that instead of duplicating a second list.- Orchestrator direct
mcp:tools/listproof sees plain MCP names likemessage_send, not OpenCode canonical ids. Do not compare direct stdio results againstagent_teams_message_sendoragent-teams_message_send. - However, orchestrator readiness currently exposes
toolProof.observedToolsthroughreadiness.evidence.observedMcpTools. Direct proof should match plain names internally, but bridge output should keep a clearly named field if canonical OpenCode ids are needed later. Do not silently changeobservedMcpToolssemantics. agent_teams_orchestratordoes not currently depend onagent-teams-controller. Do not import the controller catalog into the orchestrator in v1 unless intentionally adding a new cross-repo/package dependency.- The old project-proof gate was removed from OpenCode launch readiness. Do not reintroduce project-scoped launch blocking for selected models; runtime readiness should be based on inventory, capabilities, runtime stores, app MCP tool proof, and the execution probe.
- Current controller teammate-operational catalog includes more than the obvious message/task-start tools:
task_attach_comment_file,task_attach_file,task_create,task_create_from_message,task_link, andtask_unlinkare also teammate-operational and must be included in any explicit orchestrator v1 list. mcp-server/src/agent-teams-controller.d.tsandsrc/types/agent-teams-controller.d.tsmirror controller signatures and must be updated whenmemberBriefing(memberName, options)is added.agent-teams-controlleris CommonJS and existing TS code imports it asimport * as agentTeamsControllerModule from 'agent-teams-controller'; use that pattern in new app-side imports instead of assuming a default ESM export.agentTeamsToolNames.tscurrently canonicalizes onlymcp__agent-teams__andmcp__agent_teams__. Its regex helper for task-boundary lines must be updated with the same alias prefixes as the canonicalizer, or task logs can keep missing OpenCodeagent-teams_task_startstyle tool names.TeamProvisioningService.captureSendMessages()intentionally ignores normal non-nativemessage_sendafter cross-team fallback handling because the MCP tool itself persists the inbox row. Alias support must not turn OpenCodemessage_sendinto a second live lead-process message.OpenCodeSessionBridge.promptAsync()returns after enqueueing the prompt, andrunLaunch()currently reconciles immediately. A tool-only/bootstrap response can arrive just after that first reconcile, so launch confirmation needs a short bounded settle/preview step before final launch-state mapping.cross_team_sendis a separate teammate-operational transport, not a recipient formessage_send. The semantic seam must keep local/user/team-lead messages separate from cross-team messages.- Large
SendMessageblocks inTeamProvisioningService,teamBootstrapPromptBuilder,useInboxPoller, and native swarm prompts are mostly native runtime contracts. Do not mass-rewrite them. Instead, add routing tests proving OpenCode teammates receive the OpenCode runtime adapter/orchestrator prompt path, while native teammates keep the nativeSendMessagepath. OpenCodeSendMessageCommandBodyis declared twice inOpenCodeBridgeCommandContract.ts. TypeScript interface merging makes it compile, but it is a high-risk edit point because a future change can update only one declaration. Consolidate it before adding run-id recovery semantics.RuntimeRunTombstoneStore.assertEvidenceAccepted()rejects OpenCode runtime evidence whencurrentRunIdis null. The durableactiveRunIdis not inlanes.json; it lives in the lane-scopedRuntimeStoreManifest. Evidence acceptance and message delivery recovery must read that manifest after app restart instead of adding a second run-id source tolanes.json.cross_team_sendschema currently lackstaskRefseven though shared cross-team types already includetaskRefs. Either keep cross-team taskRefs out of v1 prompts or wire it end-to-end. Do not let the semantic helper generate unsupportedtaskRefsfor cross-team messages.- UI direct-message delivery currently persists a native
memberDeliveryTextthat tells teammates to useSendMessage, then sends the same text to OpenCode.OpenCodeTeamRuntimeAdapterrecovers by saying "treat SendMessage as abstraction" and regex-parsing the recipient from that text. This is fragile. OpenCode runtime delivery should receive explicit recipient/actionMode/taskRefs metadata and OpenCode-native wording, not parse native prompt text. - UI direct-message delivery currently starts OpenCode runtime delivery with
void provisioning.deliverOpenCodeMemberMessage(...)after the inbox write succeeds. Native teammates can still read the persisted inbox row, but OpenCode lanes do not watch that inbox path. For OpenCode, a post-persist runtime delivery failure can be invisible unless the result is surfaced inSendMessageResultor an equivalent observable channel. - Renderer
sendTeamMessageis typed asPromise<void>and catches IPC errors without rethrowing. Call sites inMessagesPanelandTeamDetailViewattach.catch(...)to clear pending replies, but that catch path is currently dead. The semantic seam must not add more delivery states on top of a store action that hides failure from callers. message_sendto a non-lead OpenCode teammate can be only a file write toinboxes/<member>.json. Codex/Claude native teammates read their inbox files, but OpenCode secondary lanes do not. UI direct-send has a runtime bridge escape hatch, but OpenCode-to-OpenCode teammate messages, task/system notifications, and other persisted inbox routes need an OpenCode-targeted runtime relay or they can silently sit unread.- Runtime delivery has two event shapes:
RuntimeDeliveryTeamChangeEventcarriesdata.detail, while publicTeamChangeEventcarries top-leveldetail.TeamProvisioningService.createOpenCodeRuntimeDeliveryService()currently adapts this shape before emitting to the app. Keep this adapter explicit and tested, because renderer refreshes are type-based but relay/notification/detail-sensitive app branches expectevent.detail. message_send.fromis optional today. If an OpenCode teammate callsmessage_sendtouserwithoutfrom,messageStore.buildMessage()defaults tofrom: "user". That makes the reply durable but semantically wrong:MessagesPanelclears pending replies bymessage.to === "user"andmessage.from === memberName. Add a guard so user-directed MCP messages require a real sender instead of silently writing a user-to-user row.
Non Goals
- Do not rewrite the whole toolset abstraction.
- Do not rename native
SendMessage. - Do not make
runtime_deliver_messagethe normal reply path. - Do not implement a broad frontend workaround.
- Do not change Codex/Claude native flow except where a helper default keeps current wording.
Architecture
Runtime contracts
Native teammate contract:
Use SendMessage with fields:
to, summary, message
OpenCode teammate contract:
Use MCP tool agent-teams_message_send with fields:
teamName, to, from, text, summary
For messages to other teams, use agent-teams_cross_team_send with:
teamName, toTeam, fromMember, text, summary, conversationId?, replyToConversationId?
OpenCode bootstrap contract:
1. Call agent-teams_runtime_bootstrap_checkin with runtime identity: teamName, runId, memberName, runtimeSessionId.
2. Call agent-teams_member_briefing with runtimeProvider="opencode".
3. Use agent-teams_message_send for visible local team/user messages.
4. Use agent-teams_cross_team_send for messages to other teams.
5. Do not answer app/team messages only as plain assistant text when message_send is available.
Why not runtime_deliver_message
runtime_deliver_message is low-level runtime evidence delivery. It requires:
idempotencyKeyrunIdteamNamefromMemberNameruntimeSessionIdtotext- current-run/tombstone validation
That is too fragile as the main LLM-visible reply API. It should remain an audit/runtime channel, not the normal human-facing message tool.
This is a conscious v1 choice, not because runtime_deliver_message cannot write messages. It can write through RuntimeDeliveryService, but making it the normal reply API would require a different contract:
- prompts must teach idempotency key generation
- user/member/cross-team destination semantics must be unified around
to - taskRefs must use runtime delivery envelope shape
- UI capture must normalize runtime-delivered messages with normal
message_sendrows - all native runtimes would still need their existing
SendMessageabstraction
V1 keeps the simpler visible-message contract:
- OpenCode normal reply:
agent-teams_message_send - OpenCode cross-team reply:
agent-teams_cross_team_send - OpenCode runtime evidence/liveness:
runtime_bootstrap_checkin,runtime_heartbeat,runtime_task_event - OpenCode low-level idempotent runtime delivery:
runtime_deliver_message, only when a prompt explicitly instructs the runtime-evidence flow
Runtime Tool Schema Guard
runtime_bootstrap_checkin currently accepts:
{
teamName: string;
runId: string;
memberName: string;
runtimeSessionId: string;
claudeDir?: string;
controlUrl?: string;
waitTimeoutMs?: number;
observedAt?: string;
diagnostics?: string[];
metadata?: Record<string, unknown>;
}
It does not accept laneId.
Implementation rule:
- Use
laneIdonly to find the stored OpenCode session and route bridge commands. - Do not include
laneIdin the MCP tool payload shown to the model. - Add a test or assertion that the identity block example does not contain
"laneId"inside theruntime_bootstrap_checkinJSON.
File Map
claude_team files:
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts/Users/belief/dev/projects/claude/claude_team/src/main/services/team/agentTeamsToolNames.ts/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/tasks.js/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messages.js/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/runtimeHelpers.js/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/memberMessagingProtocol.js/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/mcpToolCatalog.js/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/taskTools.ts/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/messageTools.ts/Users/belief/dev/projects/claude/claude_team/mcp-server/src/agent-teams-controller.d.ts/Users/belief/dev/projects/claude/claude_team/src/types/agent-teams-controller.d.ts/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts
agent_teams_orchestrator files:
/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.ts/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.test.ts/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeEventTranslator.test.ts/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapPromptBuilder.ts/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/hooks/useInboxPoller.ts
Implementation Steps
Step 1 - Add a small messaging protocol helper in controller
Preferred location:
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/memberMessagingProtocol.js
Keep it tiny. It should produce instructions only, not send messages itself.
Example:
function normalizeRuntimeProvider(value) {
const normalized = String(value || '')
.trim()
.toLowerCase();
return normalized === 'opencode' ? 'opencode' : 'native';
}
function createMemberMessagingProtocol(runtimeProvider) {
const provider = normalizeRuntimeProvider(runtimeProvider);
if (provider === 'opencode') {
return {
runtimeProvider: 'opencode',
sendToolName: 'agent-teams_message_send',
sendToolAliases: [
'agent-teams_message_send',
'agent_teams_message_send',
'mcp__agent-teams__message_send',
'mcp__agent_teams__message_send',
'message_send',
],
sendLeadPhrase: 'call MCP tool agent-teams_message_send',
crossTeamPhrase: 'call MCP tool agent-teams_cross_team_send',
buildLeadMessageExample({ teamName, leadName, fromName, text, summary }) {
return `agent-teams_message_send { teamName: "${teamName}", to: "${leadName}", from: "${fromName}", text: "${text}", summary: "${summary}" }`;
},
buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) {
return `agent-teams_cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`;
},
};
}
return {
runtimeProvider: 'native',
sendToolName: 'SendMessage',
sendToolAliases: ['SendMessage'],
sendLeadPhrase: 'use SendMessage',
crossTeamPhrase: 'use the cross-team MCP tool cross_team_send',
buildLeadMessageExample({ leadName, text, summary }) {
return `SendMessage { to: "${leadName}", summary: "${summary}", message: "${text}" }`;
},
buildCrossTeamMessageExample({ teamName, toTeam, fromName, text, summary }) {
return `cross_team_send { teamName: "${teamName}", toTeam: "${toTeam}", fromMember: "${fromName}", text: "${text}", summary: "${summary}" }`;
},
};
}
function isOpenCodeMember(member) {
const provider = String(member?.providerId || member?.provider || '')
.trim()
.toLowerCase();
return provider === 'opencode';
}
module.exports = {
createMemberMessagingProtocol,
isOpenCodeMember,
normalizeRuntimeProvider,
};
Acceptance:
- No UI code depends on this helper.
- No runtime side effects.
- Native default stays
SendMessage. - OpenCode wording says to use the exposed alias if the exact canonical name differs.
- Cross-team wording stays on
cross_team_send; never instructmessage_sendwithto: "cross_team_send"or a remote team as if it were a local teammate.
Step 2 - Preserve provider metadata in controller member resolution
File:
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/runtimeHelpers.js
Current risk:
normalizeMemberRecord() keeps name, role, workflow, agentType, color, cwd, removedAt, but drops provider/model metadata. Task assignment notification cannot know that an owner is OpenCode, and future controller-side protocol inference can drift away from UI/runtime metadata.
Edit pattern:
function copyTrimmedString(member, key) {
return typeof member[key] === 'string' && member[key].trim() ? { [key]: member[key].trim() } : {};
}
Then preserve fields:
return {
name,
...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}),
...(typeof member.workflow === 'string' && member.workflow.trim()
? { workflow: member.workflow.trim() }
: {}),
...(typeof member.agentType === 'string' && member.agentType.trim()
? { agentType: member.agentType.trim() }
: {}),
...(typeof member.color === 'string' && member.color.trim()
? { color: member.color.trim() }
: {}),
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
...copyTrimmedString(member, 'providerId'),
...copyTrimmedString(member, 'providerBackendId'),
...copyTrimmedString(member, 'provider'),
...copyTrimmedString(member, 'model'),
...copyTrimmedString(member, 'effort'),
...copyTrimmedString(member, 'fastMode'),
...(typeof member.removedAt === 'number' ? { removedAt: member.removedAt } : {}),
};
Also merge those fields in mergeResolvedMember():
...(source.providerId ? { providerId: source.providerId } : {}),
...(source.providerBackendId ? { providerBackendId: source.providerBackendId } : {}),
...(source.provider ? { provider: source.provider } : {}),
...(source.model ? { model: source.model } : {}),
...(source.effort ? { effort: source.effort } : {}),
...(source.fastMode ? { fastMode: source.fastMode } : {}),
Acceptance:
- Existing members without provider metadata behave unchanged.
- OpenCode owners can be detected from resolved team metadata.
members.meta.jsoncan override or fill provider fields fromconfig.jsonwithout dropping model/effort/backend details.
Step 3 - Add runtimeProvider to member_briefing
File:
/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/taskTools.ts
Add optional schema field:
runtimeProvider: z.enum(['native', 'opencode']).optional(),
Update execute:
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => ({
content: [
{
type: 'text' as const,
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, {
runtimeProvider,
}),
},
],
}),
Then update the controller method signature.
File:
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/tasks.js
Change:
async function memberBriefing(context, memberName) {
To:
async function memberBriefing(context, memberName, options = {}) {
Inside the function:
const explicitRuntimeProvider = options.runtimeProvider;
const inferredRuntimeProvider =
explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native');
const messagingProtocol = createMemberMessagingProtocol(inferredRuntimeProvider);
Update the TypeScript declaration too.
Files:
/Users/belief/dev/projects/claude/claude_team/mcp-server/src/agent-teams-controller.d.ts
/Users/belief/dev/projects/claude/claude_team/src/types/agent-teams-controller.d.ts
Change:
memberBriefing(memberName: string): Promise<string>;
To:
memberBriefing(
memberName: string,
options?: { runtimeProvider?: 'native' | 'opencode' }
): Promise<string>;
Why this matters:
mcp-server/src/tools/taskTools.ts and app main-process TS code call into the JS controller through declarations. If both declarations are not updated, the implementation may work at runtime but fail typecheck or drift again later.
Acceptance:
member_briefingwithruntimeProvider: "opencode"emits OpenCode-safe instructions.member_briefingwithoutruntimeProviderfalls back to resolved member provider metadata.member_briefingwithoutruntimeProviderand without OpenCode provider metadata remains native.pnpm --filter agent-teams-mcp typecheckstays green.
Step 4 - Replace hardcoded SendMessage in member briefing and task protocol
File:
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/tasks.js
Change:
function buildMemberTaskProtocol(teamName) {
To:
function buildMemberTaskProtocol(teamName, messagingProtocol) {
Replace hardcoded lines like:
After task_complete, notify your team lead via SendMessage.
Do not only change buildMemberTaskProtocol(). Current memberBriefing() also pushes direct top-level lines before buildMemberTaskProtocol():
CRITICAL: ... A SendMessage to the lead is NOT a substitute ...
After task_complete, notify your team lead via SendMessage ...
Those lines must also be generated from messagingProtocol; otherwise OpenCode still receives contradictory briefing text even if the long task protocol is fixed.
Also replace these protocol fragments:
When sending a message about a specific task, include its short display label like #<displayId> in your SendMessage summary field...
STEP 3 - THEN, send a message to your team lead via SendMessage so they notice it promptly.
For OpenCode, the equivalent must mention agent-teams_message_send and its summary field, not SendMessage.
With protocol-specific text:
const notifyLeadExample = messagingProtocol.buildLeadMessageExample({
teamName,
leadName: '<lead-name>',
fromName: '<your-name>',
text: '#abcd1234 done. Full details in task comment e5f6a7b8. Moving to #efgh5678.',
summary: '#abcd1234 done',
});
Then use:
After task_complete, notify your team lead via ${messagingProtocol.sendLeadPhrase}.
Example: ${notifyLeadExample}
Important OpenCode wording:
When using agent-teams_message_send, always include teamName, to, from, text, and summary.
Always set from to your teammate name.
Do not answer only as plain assistant text when agent-teams_message_send is available.
For cross-team replies or messages to another team, use agent-teams_cross_team_send with toTeam/fromMember. Do not put "cross_team_send" or a remote team name into message_send.to.
Acceptance:
- Native text still uses
SendMessage. - OpenCode text does not instruct the model to call
SendMessage. - Board comment remains the durable primary result channel for both runtimes.
- Cross-team instructions remain on
cross_team_send, notmessage_send.
Step 5 - Fix task assignment notification protocol
File:
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/tasks.js
Current function:
function buildAssignmentMessage(context, task, options = {}) {
Change to compute protocol:
function buildAssignmentMessage(context, task, options = {}) {
const messagingProtocol = options.messagingProtocol || createMemberMessagingProtocol('native');
const ownerName = typeof task.owner === 'string' ? task.owner.trim() : '';
const leadName = runtimeHelpers.inferLeadName(context.paths);
...
}
Where owner notification is sent, pass OpenCode protocol if owner is OpenCode:
const owner = resolved.members.find(
(member) => normalizeMemberName(member.name) === normalizeMemberName(task.owner)
);
const messagingProtocol = createMemberMessagingProtocol(
isOpenCodeMember(owner) ? 'opencode' : 'native'
);
text: buildAssignmentMessage(context, task, {
...options,
messagingProtocol,
}),
Acceptance:
- OpenCode owner receives assignment instructions using
agent-teams_message_send. - Native owner still receives
SendMessage.
Step 6 - Extend message_send with taskRefs
File:
/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/messageTools.ts
Storage already supports taskRefs in:
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messageStore.js
Add schema:
taskRefs: z
.array(
z.object({
taskId: z.string().min(1),
displayId: z.string().min(1),
teamName: z.string().min(1),
})
)
.optional(),
Forward it:
...(taskRefs?.length ? { taskRefs } : {}),
Acceptance:
- OpenCode can include the same traceability metadata native prompts already mention.
- Existing
message_sendcallers remain valid. message_send({ to: "user", from: "<member>" })continues to writeinboxes/user.json, which the existing Messages feed already reads.
Step 6.1 - Guard message_send replies to user against missing sender
Files:
/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/messageTools.ts
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messages.js
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messageStore.js
Current risk:
from:
typeof flags.from === 'string' && flags.from.trim()
? flags.from.trim()
: defaults.from || 'user',
For message_send({ teamName, to: "user", text: "done" }), this writes:
{ "from": "user", "to": "user", "text": "done" }
That row is durable, but it is not a teammate reply. It will not reliably clear pending reply state and can make the user think the OpenCode agent ignored the message.
Do not make from required for every message_send call in v1. That can break older/manual uses where message_send is acting as user-to-member delivery.
Preferred narrow guard:
function assertUserDirectedMessageHasSender(context, flags) {
const to = typeof flags.to === 'string' ? flags.to.trim().toLowerCase() : '';
if (to !== 'user') return;
const from = typeof flags.from === 'string' ? flags.from.trim() : '';
if (!from || from.toLowerCase() === 'user') {
throw new Error('message_send to user requires from to be the responding team member name');
}
runtimeHelpers.assertExplicitTeamMemberName(context.paths, from, 'from', {
allowLeadAliases: true,
});
}
Call it in agent-teams-controller/src/internal/messages.js before messageStore.sendInboxMessage(...):
function sendMessage(context, flags) {
assertUserDirectedMessageHasSender(context, flags || {});
return messageStore.sendInboxMessage(context.paths, flags);
}
Keep the MCP schema from: z.string().optional() so existing non-user-directed callers remain valid, but update the tool description:
When to is "user", from is required and must be your configured teammate name.
Reason:
- OpenCode is instructed to include
from, but model compliance is not a safety boundary. - A tool error is better than a wrong durable
from: "user"message row. - This guard affects the generic Agent Teams MCP path, not only OpenCode, but only for semantically invalid user-directed messages.
Acceptance:
message_send({ to: "user", from: "bob" })succeeds and writesfrom: "bob".message_send({ to: "user" })fails with a clear actionable error.message_send({ to: "user", from: "user" })fails.message_send({ to: "alice", text: "..." })still succeeds and defaults to user-origin delivery for legacy/manual uses.- OpenCode prompt examples continue to include
from: "<member>".
Step 6.2 - Disambiguate message_send from runtime_deliver_message
Files:
/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/messageTools.ts
/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/runtimeTools.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/tasks.js
Current risk:
runtime_deliver_messageis visible in the same Agent Teams MCP server asmessage_send.- Its description says it delivers an OpenCode runtime message to app-owned destinations.
- It really can write user/member/cross-team destinations through
RuntimeDeliveryService. - If prompts say "deliver a message" loosely, OpenCode can choose
runtime_deliver_messageinstead of the v1 semantic reply tool.
Do not hide runtime_deliver_message from readiness or app tool availability proof. It is still required for runtime evidence and journal recovery paths.
Instead, make tool descriptions and OpenCode prompts explicitly route normal replies:
description: 'Send a visible team/user message. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.';
description: 'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.';
OpenCode-specific prompt wording should avoid generic "deliver message" language:
For normal visible replies, call agent-teams_message_send.
Do not use runtime_deliver_message for ordinary replies unless a runtime-delivery prompt explicitly asks for runId/runtimeSessionId/idempotencyKey delivery.
Acceptance:
message_senddescription is the most obvious visible-message tool for OpenCode replies.runtime_deliver_messagedescription says it is low-level and not the normal reply path.- OpenCode launch/direct-message/task prompts do not use ambiguous "deliver a message" phrasing without naming
agent-teams_message_send. - Readiness still requires runtime tools where appropriate; this is prompt/tool-description disambiguation, not a capability removal.
Step 6.3 - Canonicalize message_send recipients before persistence
Files:
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messages.js
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/messageStore.js
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/runtimeHelpers.js
/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/messageTools.ts
Current risk:
const memberName =
typeof flags.member === 'string' && flags.member.trim()
? flags.member.trim()
: typeof flags.to === 'string' && flags.to.trim()
? flags.to.trim()
: '';
appendRow(getInboxPath(paths, memberName), payload);
This writes the raw to string as the inbox filename.
Bad cases:
to: "team-lead"when the actual configured lead isleadwritesinboxes/team-lead.json; lead relay readsinboxes/lead.json.to: "lead"when the configured lead isteam-leadcan create a separate alias inbox.to: "cross_team_send"creates a misleading local inbox instead of a clear error telling the agent to usecross_team_send.from: "team-lead"can be stored as an alias instead of the canonical lead name, which breaks pending-reply and activity attribution.
Add a controller-level normalizer before messageStore.sendInboxMessage():
function normalizeMessageSendFlags(context, flags) {
const next = { ...(flags || {}) };
const rawTo = typeof next.to === 'string' ? next.to.trim() : '';
if (!rawTo) {
throw new Error('message_send requires to');
}
if (rawTo.toLowerCase() === 'user') {
next.to = 'user';
} else {
const resolvedTo = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, rawTo, {
allowLeadAliases: true,
});
if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamRecipient?.(rawTo)) {
throw new Error('message_send cannot target another team. Use cross_team_send with toTeam.');
}
if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient?.(rawTo)) {
throw new Error(
'message_send cannot target cross_team_send. Use cross_team_send with toTeam.'
);
}
if (!resolvedTo) {
throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`);
}
next.to = resolvedTo;
next.member = next.to;
}
if (typeof next.from === 'string' && next.from.trim()) {
const rawFrom = next.from.trim();
if (rawFrom.toLowerCase() !== 'user') {
next.from = runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFrom, 'from', {
allowLeadAliases: true,
});
}
}
return next;
}
Then run the user-directed sender guard on the normalized flags.
Important:
to: "user"remains a special destination and does not require a configured member nameduser.- Local member/lead recipients must resolve to configured member names.
- Cross-team team names and cross-team tool names should not be silently treated as local inboxes. The error should tell the model to use
cross_team_send. - If the existing app intentionally supports dotted local member names, do not reject them when they are configured members. Resolve against config/members.meta before applying cross-team heuristics.
- If
runtimeHelpersdoes not export cross-team recipient predicates today, add a small shared helper there instead of duplicating ad hoc regexes inmessages.js.
Acceptance:
message_send({ to: "team-lead", from: "bob" })writes to the actual configured lead inbox.message_send({ to: "lead", from: "bob" })also writes to the actual configured lead inbox whenleadis a lead alias.message_send({ to: "alice", from: "team-lead" })storesfromas the canonical configured lead name.message_send({ to: "unknown", from: "bob" })fails clearly instead of creatinginboxes/unknown.json.message_send({ to: "cross_team_send", from: "bob" })fails with ause cross_team_senderror.message_send({ to: "user", from: "bob" })remains valid and writes toinboxes/user.json.
Step 6.4 - Decide cross-team taskRefs policy before helper generalization
Files:
/Users/belief/dev/projects/claude/claude_team/mcp-server/src/tools/crossTeamTools.ts
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/src/internal/crossTeam.js
/Users/belief/dev/projects/claude/claude_team/src/shared/types/team.ts
Current fact:
CrossTeamMessageandCrossTeamSendRequestalready includetaskRefs.cross_team_sendMCP schema does not exposetaskRefs.agent-teams-controller/src/internal/crossTeam.jsdoes not persisttaskRefsto target inbox orsent-cross-team.json.
Options:
Option A: keep cross-team taskRefs out of v1 prompts - 🎯 8 🛡️ 8 🧠 2, about 0-25 LOC
Safest if we want the smallest messaging seam. The helper must not accept or render taskRefs for buildCrossTeamMessageExample() yet.
Option B: wire cross-team taskRefs end-to-end now - 🎯 8 🛡️ 9 🧠 4, about 70-150 LOC
Best if the helper is meant to be a real semantic messaging seam with uniform traceability. Add taskRefs to cross_team_send schema, normalize it in controller, store it in target inbox row, append it to sent message, and persist it in sent-cross-team.json.
Chosen for v1 if Step 1 helper has a generic taskRefs option: Option B.
Chosen for v1 if Step 1 helper only renders static examples: Option A.
Implementation for Option B:
taskRefs: z
.array(
z.object({
taskId: z.string().min(1),
displayId: z.string().min(1),
teamName: z.string().min(1),
})
)
.optional(),
Controller storage should use the same shape as messageStore.normalizeTaskRefs():
const taskRefs = normalizeTaskRefs(flags.taskRefs);
list.push({
...,
...(taskRefs ? { taskRefs } : {}),
});
messageStore.appendSentMessage(context.paths, {
...,
...(taskRefs ? { taskRefs } : {}),
});
outList.push({
...,
...(taskRefs ? { taskRefs } : {}),
});
Acceptance:
- The helper never emits unsupported
taskRefsforcross_team_send. - If cross-team taskRefs are enabled, they persist in target inbox, local sent message, and
sent-cross-team.json. message_sendandcross_team_sendtaskRefs use identical validation rules.
Step 7 - Centralize Agent Teams tool-name alias matching
Files:
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/agentTeamsToolNames.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts
Current risk:
TeamProvisioningService.captureSendMessages() recognizes only:
part.name === 'mcp__agent-teams__message_send';
But OpenCode and MCP tooling can expose names as:
message_send
agent-teams_message_send
agent_teams_message_send
mcp__agent-teams__message_send
mcp__agent_teams__message_send
Add shared helpers:
const AGENT_TEAMS_PREFIXES = [
'mcp__agent-teams__',
'mcp__agent_teams__',
'agent-teams_',
'agent_teams_',
] as const;
export function canonicalizeAgentTeamsToolName(rawName: string): string {
const normalized = rawName.trim().replace(/^proxy_/, '');
for (const prefix of AGENT_TEAMS_PREFIXES) {
if (normalized.startsWith(prefix)) {
return normalized.slice(prefix.length);
}
}
return normalized;
}
export function isAgentTeamsToolName(rawName: string, canonicalName: string): boolean {
return canonicalizeAgentTeamsToolName(rawName).toLowerCase() === canonicalName.toLowerCase();
}
Do not treat every plain message_send in every transcript as Agent Teams. Add a stricter predicate for plain tool names:
export function isAgentTeamsToolUse(input: {
rawName: string;
canonicalName: string;
toolInput?: Record<string, unknown>;
currentTeamName?: string;
}): boolean {
const rawName = input.rawName.trim();
const canonical = canonicalizeAgentTeamsToolName(rawName);
if (canonical.toLowerCase() !== input.canonicalName.toLowerCase()) {
return false;
}
const hasKnownPrefix =
rawName !== canonical || AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix));
if (hasKnownPrefix) {
return true;
}
// Plain names are accepted only when the payload looks like our app MCP contract.
if (input.canonicalName === 'message_send') {
return (
typeof input.toolInput?.teamName === 'string' &&
input.toolInput.teamName === input.currentTeamName &&
typeof input.toolInput?.to === 'string' &&
typeof input.toolInput?.text === 'string'
);
}
return false;
}
This keeps OpenCode plain direct-MCP aliases working without broadening capture to arbitrary third-party tools with the same short name.
Then update captureSendMessages():
const canonicalToolName = canonicalizeAgentTeamsToolName(part.name);
const isNativeSendMessage = part.name === 'SendMessage';
const isTeamMessageSendTool = isAgentTeamsToolUse({
rawName: part.name,
canonicalName: 'message_send',
toolInput: input as Record<string, unknown>,
currentTeamName: run.teamName,
});
const isDirectCrossTeamSendTool = isAgentTeamsToolUse({
rawName: part.name,
canonicalName: 'cross_team_send',
toolInput: input as Record<string, unknown>,
currentTeamName: run.teamName,
});
Keep the existing no-duplicate-persistence rule:
if (isDirectCrossTeamSendTool) {
// Use this only to trigger cross-team refresh/fallback handling.
continue;
}
if (!isNativeSendMessage) {
// message_send persists through the MCP tool handler itself.
// Do not also push a lead-process message here.
continue;
}
Also update TASK_BOUNDARY_TOOL_LINE_PATTERN in the same file to include the same aliases as canonicalizeAgentTeamsToolName():
const AGENT_TEAMS_PREFIXES = [
'mcp__agent-teams__',
'mcp__agent_teams__',
'agent-teams_',
'agent_teams_',
] as const;
Acceptance:
- Logs/capture/task-log code recognizes the same aliases that prompts and readiness allow.
- Existing
mcp__agent-teams__...names still work. - Plain
message_sendis only treated as Agent Teams when it appears in the known app/team runtime context and its input has ourteamName/to/textshape for the current team. - This does not automatically loosen production readiness. If readiness currently requires canonical OpenCode ids, keep that policy explicit and test it separately from transcript/capture alias parsing.
- Normal MCP
message_sendis not double-persisted as a lead-process message. - Task boundary detection works for
agent-teams_task_start,agent_teams_task_start,mcp__agent-teams__task_start, and proxy-prefixed forms.
Step 8 - Move OpenCode runtime identity injection to orchestrator
File:
/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.ts
Related app contract file:
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts
First clean up the duplicate app-side OpenCodeSendMessageCommandBody declarations in OpenCodeBridgeCommandContract.ts. TypeScript currently merges them, but this is too easy to edit incorrectly when adding run-id recovery. Keep a single declaration:
export interface OpenCodeSendMessageCommandBody {
runId?: string;
laneId: string;
teamId: string;
teamName: string;
projectPath: string;
memberName: string;
text: string;
messageId?: string;
agent?: string;
noReply?: boolean;
}
Current launch flow:
record = await openCodeSessionBridge.ensureSession(...)
await openCodeSessionBridge.promptAsync(record, {
text: prompt,
agent: 'teammate',
})
Add a helper:
function buildOpenCodeRuntimeIdentityBlock(input: {
teamName: string;
memberName: string;
runId: string;
runtimeSessionId: string;
}): string {
const checkinPayload = {
teamName: input.teamName,
runId: input.runId,
memberName: input.memberName,
runtimeSessionId: input.runtimeSessionId,
};
const briefingPayload = {
teamName: input.teamName,
memberName: input.memberName,
runtimeProvider: 'opencode',
};
return [
'<opencode_runtime_identity>',
'You are an OpenCode teammate managed by the desktop app.',
'Your first app-team MCP action must be runtime bootstrap check-in.',
`Call the exposed Agent Teams runtime_bootstrap_checkin tool, usually agent-teams_runtime_bootstrap_checkin or mcp__agent-teams__runtime_bootstrap_checkin, with: ${JSON.stringify(checkinPayload)}`,
'After check-in succeeds, request your teammate rules.',
`Call the exposed Agent Teams member_briefing tool, usually agent-teams_member_briefing or mcp__agent-teams__member_briefing, with: ${JSON.stringify(briefingPayload)}`,
'For visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.',
'</opencode_runtime_identity>',
].join('\n');
}
Wrap launch prompt:
const runtimeIdentityBlock = buildOpenCodeRuntimeIdentityBlock({
teamName: teamId,
memberName: name,
runId,
runtimeSessionId: record.opencodeSessionId,
});
await openCodeSessionBridge.promptAsync(record, {
text: `${runtimeIdentityBlock}\n\n${prompt}`,
agent: 'teammate',
});
Then add a bounded launch-settle helper before mapping the member as final created/confirmed_alive.
Reason:
promptAsync() only enqueues the OpenCode prompt. The current immediate reconcileSession(record) can run before the assistant/tool message materializes. That produces a false created state even when the teammate is about to call runtime_bootstrap_checkin or member_briefing.
Do not add this as a serial wait inside the existing member loop.
Options:
Option A: serial settle inside the existing loop - 🎯 4 🛡️ 5 🧠 2, about 30-60 LOC Easy, but bad for UX. Three OpenCode teammates with an 8 second preview cap can add 24 seconds of launch latency.
Option B: two-phase launch with bounded concurrent settle - 🎯 8 🛡️ 8 🧠 5, about 140-260 LOC
First ensure sessions and enqueue prompts for all members. Then run bounded preview/reconcile concurrently per prompted member with a small local concurrency cap. This fixes early false created without multiplying wait time by teammate count or opening one preview stream per teammate in large teams.
Option C: no settle, rely only on later reconcile - 🎯 5 🛡️ 6 🧠 1, about 0-20 LOC Avoids launch delay, but keeps the stale/early UI state that caused OpenCode teammates to look unspawned or stuck.
Chosen for v1: Option B with a local cap of 3 concurrent settle observers. Do not add a dependency just for this; use a tiny local mapper/helper in the orchestrator testable unit.
Example:
async function reconcileAfterOpenCodeLaunchPrompt(record: OpenCodeSessionRecord) {
await openCodeSessionBridge
.observePreview(record, {
timeoutMs: 8_000,
idleTimeoutMs: 1_500,
})
.catch(() => null);
return openCodeSessionBridge.reconcileSession(record, { limit: 50 });
}
Restructure runLaunch() into two phases:
const promptedMembers: Array<{
name: string;
record: OpenCodeSessionRecord;
}> = [];
for (const item of membersRaw) {
const record = await openCodeSessionBridge.ensureSession(...);
await openCodeSessionBridge.promptAsync(record, {
text: `${runtimeIdentityBlock}\n\n${prompt}`,
agent: 'teammate',
});
promptedMembers.push({ name, record });
}
const settledMembers = await mapWithConcurrency(promptedMembers, 3, async ({ name, record }) => ({
name,
record,
reconciled: await reconcileAfterOpenCodeLaunchPrompt(record),
})
);
Keep per-member prompt/ensure failures isolated. If one member fails before prompt enqueue, mark only that member failed and still prompt/settle the rest.
Use the same bounded helper after permission-answer recovery paths where the UI expects launch state to advance, but keep it concurrent across lane records.
Do not wait indefinitely and do not convert preview timeout into failed. A settle timeout should fall back to the current reconcile result and leave the member in runtime_pending_bootstrap/created rather than producing a false hard failure.
Acceptance:
- Adapter does not need to know
opencodeSessionId. - Every OpenCode teammate receives exact session identity.
member_briefinggetsruntimeProvider: "opencode".- Identity prompt names the canonical OpenCode tool ids and acceptable exposed aliases, not only one spelling.
laneIdstays inrunLaunch()as bridge/session routing context only.- The identity helper should not accept
laneId, so nobody accidentally serializes it into the MCP payload later. - Launch state gets one short chance to observe tool-only/bootstrap assistant activity before deciding the bridge member state.
- Launch settle runs bounded-concurrently across OpenCode members, not serially and not unbounded.
- A launch-settle timeout is not a launch failure.
Also add a smaller recovery prefix in runSendMessage() when body.runId is present.
Reason:
If the initial launch prompt was interrupted before check-in, a later user message can help the OpenCode teammate self-heal. Do not invent a runId if body.runId is absent.
Example:
const runId = asString(body.runId);
const identityReminder = runId
? buildOpenCodeRuntimeIdentityBlock({
teamName: teamId,
memberName,
runId,
runtimeSessionId: record.opencodeSessionId,
})
: null;
await openCodeSessionBridge.promptAsync(record, {
text: identityReminder ? `${identityReminder}\n\n${text}` : text,
agent: asString(body.agent) ?? 'teammate',
noReply: body.noReply === true,
});
Post-send reconcile must not redefine prompt acceptance.
Current runSendMessage() shape:
await openCodeSessionBridge.promptAsync(record, { text, agent, noReply });
const reconciled = await openCodeSessionBridge.reconcileSession(record, { limit: 50 });
return { accepted: true, diagnostics: reconciled.summary.diagnostics };
Risk:
- If
promptAsync()succeeds butreconcileSession()throws or times out, the prompt may already be enqueued in OpenCode. - Reporting
accepted: falsein that case makes the app retry a message that the agent might already process. - That creates duplicate OpenCode prompts while the inbox row may still look unread.
Use this semantic split:
let reconcileDiagnostics: TeamDiagnostic[] = [];
let runtimePid: number | undefined;
await openCodeSessionBridge.promptAsync(record, {
text: identityReminder ? `${identityReminder}\n\n${text}` : text,
agent: asString(body.agent) ?? 'teammate',
noReply: body.noReply === true,
});
try {
const reconciled = await openCodeSessionBridge.reconcileSession(record, { limit: 50 });
runtimePid = resolvedRuntimePidFrom(record, reconciled);
reconcileDiagnostics = reconciled.summary.diagnostics.map((message) =>
teamDiagnostic('opencode_send_reconcile', message, 'info')
);
} catch (error) {
reconcileDiagnostics = [
teamDiagnostic(
'opencode_send_reconcile_failed_after_prompt_accept',
error instanceof Error ? error.message : String(error),
'warning'
),
];
}
return {
accepted: true,
sessionId: record.opencodeSessionId,
memberName,
...(runtimePid ? { runtimePid } : {}),
diagnostics: reconcileDiagnostics,
};
Only promptAsync() failure should make accepted: false or throw as delivery failure. Reconcile failure after prompt acceptance is a warning diagnostic because it affects fresh runtime evidence, not whether the app handed the message to OpenCode.
Acceptance:
runSendMessage()can repair missing check-in when it has a run id.runSendMessage()does not fabricate runtime identity when no run id exists.runSendMessage()returnsaccepted: truewhenpromptAsync()succeeds even if post-send reconcile fails.runSendMessage()returns a warning diagnostic for post-accept reconcile failure, not a false delivery failure.- App-side inbox relay can mark read after prompt acceptance without waiting for assistant reply text.
Step 9 - Keep adapter prompt generic and non-conflicting
File:
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts
Current prompt says:
If available, your first app-team action is to call MCP tool agent-teams_member_briefing...
Change it so it does not conflict with orchestrator identity block:
The desktop bridge may prepend runtime identity and bootstrap instructions. Follow those first.
After runtime identity check-in, call agent-teams_member_briefing with runtimeProvider="opencode" if you have not already done so.
Keep this line:
When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send...
Acceptance:
- No duplicate "first action" conflict.
- OpenCode launch remains understandable even if the orchestrator identity block is absent during tests.
Step 9.5 - Guard native-only prompt boundaries
Files to audit:
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts
/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/teamBootstrap/teamBootstrapPromptBuilder.ts
/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/hooks/useInboxPoller.ts
/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/utils/swarm/teammatePromptAddendum.ts
These files contain many valid native-runtime SendMessage instructions. They should not be bulk-replaced.
Risk:
- A naive global replacement breaks Codex/Claude native agents.
- Leaving them untouched is safe only if OpenCode teammates do not receive these native prompt paths.
Add routing guard tests instead of rewriting native prompts:
it('does not send native member spawn prompt to OpenCode runtime members', async () => {
// Create mixed team with native alice and OpenCode bob.
// Spy on native Agent/Codex spawn prompt builder and OpenCodeTeamRuntimeAdapter.launch.
// Assert bob launch uses OpenCodeTeamRuntimeAdapter prompt.
// Assert bob prompt contains agent-teams_message_send.
// Assert bob prompt does not contain "Use the SendMessage tool".
});
it('keeps native teammate prompt using SendMessage', async () => {
// Create native alice.
// Assert native spawn prompt still contains SendMessage guidance.
});
For teamBootstrapPromptBuilder and useInboxPoller, add an explicit comment/test boundary:
// Native persistent-teammate bootstrap only. OpenCode runtime bootstrap is
// injected by OpenCodeBridgeCommandHandler and must not use this prompt path.
Acceptance:
- OpenCode teammates never receive generic native spawn/reconnect prompts that mention only
SendMessage. - Codex/Claude/Gemini native prompts are unchanged unless a runtime-specific helper is explicitly introduced.
- Future maintainers see that remaining
SendMessagestrings are not missed OpenCode work by default.
Step 9.6 - Make OpenCode direct-message delivery explicit
Files:
/Users/belief/dev/projects/claude/claude_team/src/main/ipc/teams.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/runtime/index.ts
Current flow:
const memberDeliveryText = buildMessageDeliveryText(baseText, {
actionMode,
isLeadRecipient,
replyRecipient: typeof payload.from === 'string' ? payload.from : 'user',
});
await sendMessage(... text: memberDeliveryText ...);
void provisioning.deliverOpenCodeMemberMessage(tn, {
memberName,
text: memberDeliveryText,
messageId: result.messageId,
});
Then OpenCode-specific code does this:
const replyRecipient = extractRequestedReplyRecipient(input.text);
Risk:
- OpenCode receives native-only hidden wording that says
SendMessage. - Recipient routing depends on regex matching English prompt text.
- If
buildMessageDeliveryText()wording changes, OpenCode may send to the wrong recipient or fall back to vague "requested recipient". taskRefsand action mode are embedded as text instead of explicit runtime metadata.
Options:
Option A: keep current text parsing - 🎯 4 🛡️ 5 🧠 1, about 0-15 LOC Smallest, but fragile and contradicts the semantic seam goal.
Option B: pass explicit metadata while still sending the native stored text to OpenCode - 🎯 7 🛡️ 7 🧠 3, about 50-100 LOC
Better recipient reliability, but still leaves confusing SendMessage wording inside the OpenCode prompt.
Option C: keep native inbox text only for native recipients, persist base text for OpenCode recipients, and deliver an OpenCode-native runtime message - 🎯 9 🛡️ 9 🧠 6, about 180-320 LOC with tests Best shape. Codex/Claude keep the existing persisted inbox text because they read inbox files directly. OpenCode inbox rows stay clean/retryable with base user text, while OpenCode receives explicit runtime delivery metadata through the adapter/relay.
Chosen for v1: Option C.
Add explicit fields:
import type { AgentActionMode, TaskRef } from '@shared/types';
export interface OpenCodeTeamRuntimeMessageInput {
runId?: string;
teamName: string;
laneId: string;
memberName: string;
cwd: string;
text: string;
messageId?: string;
replyRecipient?: string;
actionMode?: AgentActionMode;
taskRefs?: TaskRef[];
}
Update IPC call:
const baseText = payload.text!.trim();
const replyRecipient = typeof payload.from === 'string' && payload.from.trim()
? payload.from.trim()
: 'user';
const memberDeliveryText = buildMessageDeliveryText(baseText, {
actionMode,
isLeadRecipient,
replyRecipient,
});
const isOpenCodeRecipient = await provisioning.isOpenCodeRuntimeRecipient(tn, memberName);
const inboxText = isOpenCodeRecipient ? baseText : memberDeliveryText;
const result = await sendMessage(... text: inboxText ...);
if (isOpenCodeRecipient) {
await provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, {
onlyMessageId: result.messageId,
source: 'ui-send',
deliveryMetadata: {
replyRecipient,
actionMode,
taskRefs: validatedTaskRefs.value,
},
});
}
Keep the native inbox write unchanged for native recipients. For OpenCode recipients, do not persist native hidden SendMessage instructions into the inbox row; runtime delivery builds the OpenCode-native wrapper from metadata. This keeps FileWatcher retry safe after a transient OpenCode bridge failure.
Update deliverOpenCodeMemberMessage() signature:
input: {
memberName: string;
text: string;
messageId?: string;
replyRecipient?: string;
actionMode?: AgentActionMode;
taskRefs?: TaskRef[];
}
Update buildOpenCodeRuntimeMessageText():
function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput): string {
const replyRecipient = input.replyRecipient?.trim() || 'user';
const taskRefs = input.taskRefs?.length ? JSON.stringify(input.taskRefs) : null;
return [
'<opencode_app_message_delivery>',
'You are running in OpenCode.',
`Use agent-teams_message_send with teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`,
'Do not answer only as plain assistant text when agent-teams_message_send is available.',
input.actionMode ? `Action mode for this message: ${input.actionMode}.` : null,
taskRefs ? `If your reply is about these tasks, include taskRefs exactly: ${taskRefs}` : null,
input.messageId ? `Inbound app messageId: ${input.messageId}.` : null,
'</opencode_app_message_delivery>',
'',
input.text,
]
.filter((line): line is string => line !== null)
.join('\n');
}
Keep extractRequestedReplyRecipient() only as a fallback for older callers/tests, not as the normal path:
const replyRecipient =
input.replyRecipient?.trim() || extractRequestedReplyRecipient(input.text) || 'user';
Acceptance:
- Stored member inbox text for native teammates remains unchanged.
- Stored member inbox text for OpenCode teammates is base user/team text, not native hidden
SendMessagedelivery instructions. - OpenCode runtime delivery prompt does not contain native-only "CRITICAL: Reply using the SendMessage tool" wording.
- OpenCode recipient routing does not depend on regex-parsing hidden native instructions.
replyRecipient,actionMode, andtaskRefsare available to OpenCode as structured runtime metadata.- Existing callers without
replyRecipientstill work through fallback parsing.
Step 9.7 - Make OpenCode runtime delivery outcome observable
Files:
/Users/belief/dev/projects/claude/claude_team/src/shared/types/team.ts
/Users/belief/dev/projects/claude/claude_team/src/main/ipc/teams.ts
/Users/belief/dev/projects/claude/claude_team/src/renderer/store/slices/teamSlice.ts
/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/messages/MessageComposer.tsx
/Users/belief/dev/projects/claude/claude_team/src/renderer/components/team/dialogs/SendMessageDialog.tsx
Current flow after persisting an inbox row:
if (!isLeadRecipient && isAlive) {
void provisioning
.deliverOpenCodeMemberMessage(tn, {
memberName,
text: memberDeliveryText,
messageId: result.messageId,
})
.then(...)
.catch(...);
}
Risk:
- The IPC call returns success before OpenCode runtime delivery succeeds or fails.
- Native teammates can still read the persisted inbox file, so fire-and-forget is acceptable there.
- OpenCode secondary lanes do not watch the member inbox file, so runtime delivery failure means the visible UI send can be a silent no-op for the agent.
SendMessageDialogauto-closes on anylastResult, so adding a warning field without changing UI behavior can still hide the problem.- Renderer
sendTeamMessagecurrently returnsPromise<void>and swallows IPC errors after setting store state. Existing caller.catch(...)blocks for pending-reply cleanup do not run.
Options:
Option A: keep fire-and-forget and add more logs - 🎯 5 🛡️ 5 🧠 1, about 10-30 LOC This helps debugging but keeps the user-facing contract dishonest.
Option B: await OpenCode runtime relay for live OpenCode non-lead sends, return additive delivery status, and fix renderer action result/error propagation - 🎯 9 🛡️ 9 🧠 5, about 160-300 LOC with tests This keeps native persistence behavior unchanged, makes OpenCode failure visible, keeps retry routing in one OpenCode inbox relay path, and fixes the existing dead caller catch path that controls pending-reply cleanup.
Option C: add a durable OpenCode delivery queue with retries and UI retry state - 🎯 8 🛡️ 10 🧠 8, about 350-700 LOC This is the best long-term reliability shape, but it is too much to bundle into the semantic messaging seam unless delivery reliability remains flaky after v1.
Chosen for v1: Option B.
Add an optional result field:
export interface SendMessageRuntimeDeliveryResult {
providerId?: TeamProviderId;
attempted: boolean;
delivered: boolean;
reason?: string;
diagnostics?: string[];
}
export interface SendMessageResult {
deliveredToInbox: boolean;
deliveredViaStdin?: boolean;
messageId: string;
deduplicated?: boolean;
runtimeDelivery?: SendMessageRuntimeDeliveryResult;
}
Update handleSendMessage() after the inbox write:
let runtimeDelivery: SendMessageResult['runtimeDelivery'];
if (!isLeadRecipient && isAlive) {
const delivery = await withTimeout(
provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, {
onlyMessageId: result.messageId,
source: 'ui-send',
deliveryMetadata: {
replyRecipient,
actionMode,
taskRefs: validatedTaskRefs.value,
},
}),
12_000,
{ attempted: 1, delivered: 0, failed: 1, diagnostics: ['opencode_runtime_delivery_timeout'] }
);
if (delivery.attempted > 0) {
const delivered = delivery.failed === 0 && delivery.delivered > 0;
runtimeDelivery = {
providerId: 'opencode',
attempted: true,
delivered,
...(!delivered
? { reason: delivery.diagnostics[0] ?? 'opencode_runtime_delivery_failed' }
: {}),
...(delivery.diagnostics?.length ? { diagnostics: delivery.diagnostics } : {}),
};
}
}
return runtimeDelivery ? { ...result, runtimeDelivery } : result;
The timeout helper can be local to teams.ts. It must not cancel the underlying OpenCode bridge operation unless a cancellation primitive already exists; it only bounds the IPC response.
Renderer behavior:
function isRuntimeDeliveryFailed(result: SendMessageResult | null | undefined): boolean {
return Boolean(result?.runtimeDelivery?.attempted && !result.runtimeDelivery.delivered);
}
Change the store action contract:
sendTeamMessage: (teamName: string, request: SendMessageRequest) => Promise<SendMessageResult>;
and implementation:
sendTeamMessage: async (teamName, request) => {
set({ sendingMessage: true, sendMessageError: null, lastSendMessageResult: null });
try {
const result = await unwrapIpc('team:sendMessage', () =>
api.teams.sendMessage(teamName, request)
);
// existing optimistic row and state update
return result;
} catch (error) {
set({
sendingMessage: false,
lastSendMessageResult: null,
sendMessageError: mapSendMessageError(error),
});
throw error;
}
};
Update call sites so pending-reply state reflects actual delivery truth:
const result = await sendTeamMessage(teamName, request);
if (isRuntimeDeliveryFailed(result)) {
clearPendingReplyFor(member, sentAtMs);
}
SendMessageDialogshould not auto-close whenisRuntimeDeliveryFailed(lastResult)is true.MessageComposerandSendMessageDialogshould show a concise warning such as:Message saved, but OpenCode runtime delivery failed: <reason>.- Keep the optimistic user-sent message row, because the inbox write did succeed and is useful audit state.
- Do not surface
recipient_is_not_opencode; native recipients should behave as before.
Acceptance:
- Sending to a native teammate returns the same result shape as today unless another existing field applies.
- Sending to a live OpenCode teammate returns
runtimeDelivery: { attempted: true, delivered: true }when bridge delivery accepts the prompt. - Sending to a live OpenCode teammate with bridge/runtime failure returns
runtimeDelivery.delivered === false, leaves the message persisted, and keeps the dialog/composer warning visible. - IPC/send failures reject the renderer store action after updating
sendMessageError, so existing caller cleanup code runs. - Pending-reply state is cleared when OpenCode runtime delivery fails, because the agent did not actually receive the live prompt.
- OpenCode runtime delivery uses base user text plus explicit metadata from Step 9.6, not native
memberDeliveryText. - IPC remains bounded; an OpenCode delivery hang cannot hang the UI indefinitely.
Step 9.8 - Relay persisted OpenCode-targeted inbox messages to runtime lanes
Files:
/Users/belief/dev/projects/claude/claude_team/src/main/index.ts
/Users/belief/dev/projects/claude/claude_team/src/main/ipc/teams.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamInboxReader.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamInboxWriter.ts
Current delivery split:
- Native teammates read their own
inboxes/<member>.jsonfiles. - Native lead does not read inbox files, so FileWatcher calls
relayLeadInboxMessages()and that path writes into the native CLI stdin. - UI-to-OpenCode direct messages are manually pushed through
deliverOpenCodeMemberMessage(). - OpenCode teammates do not watch inbox files, so a generic
message_sendinto an OpenCode teammate inbox is not enough. - Pure OpenCode runtime-adapter launches are marked alive through
runtimeAdapterRunByTeam, but they do not create aProvisioningRun.child;relayLeadInboxMessages()currently returns0in that shape. - The current OpenCode bridge launch handler iterates
body.membersand creates teammate sessions only.leadPromptis carried in the command body, but it does not currently create a storedteam-leadOpenCode session. - Existing
relayMemberInboxMessages()is a native-lead-mediated relay. It sends an internal turn to the native lead and asks it to forward withSendMessage; do not reuse it for OpenCode-native runtime delivery.
Risk examples:
- OpenCode
bobcallsagent-teams_message_send({ to: "jack", from: "bob", text: "please review" }), andjackis also OpenCode. - Task/system notification writes
inboxes/jack.jsonfor an OpenCode teammate. - FileWatcher sees
inboxes/jack.json, but current code intentionally skips non-lead relay because native teammates read their inbox files. - A pure OpenCode team gets
message_send({ to: "team-lead", ... }). FileWatcher treats it like a lead inbox, but there is no native stdin process and no proven OpenCode lead session to receive it. Marking it read would lose the message.
Options:
Option A: only support UI direct-send to OpenCode in v1 - 🎯 5 🛡️ 5 🧠 2, about 0-40 LOC This leaves OpenCode-to-OpenCode and system notification routes unreliable. It is not enough for a real team messaging seam.
Option B: add OpenCode-targeted inbox runtime relay with messageId dedupe/read marking, plus explicit unsupported-lead diagnostics - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests This preserves native behavior, routes only recipients whose provider is OpenCode, and makes any persisted inbox row deliverable to live OpenCode lanes.
Option C: replace both native and OpenCode inbox handling with a new durable delivery queue - 🎯 8 🛡️ 10 🧠 9, about 600-1200 LOC Architecturally clean long-term, but too large for this seam and risky with existing native watchers.
Chosen for v1: Option B.
Add one shared recipient-provider predicate and reuse it from both IPC send and relay:
async isOpenCodeRuntimeRecipient(teamName: string, memberName: string): Promise<boolean> {
// Use the same config + members.meta provider resolution as deliverOpenCodeMemberMessage().
// Do not infer solely from model label if explicit providerId/provider metadata exists.
}
This avoids a split-brain bug where handleSendMessage() persists base text because it thinks the recipient is OpenCode, while relay later treats the same recipient as native or unavailable.
Add a small routing selector so FileWatcher does not encode provider-specific details:
async relayInboxFileToLiveRecipient(
teamName: string,
inboxName: string,
opts?: {
source?: 'watcher' | 'ui-send' | 'manual';
onlyMessageId?: string;
deliveryMetadata?: {
replyRecipient?: string;
actionMode?: AgentActionMode;
taskRefs?: TaskRef[];
};
}
): Promise<OpenCodeInboxRelayResult | NativeLeadRelayResult | InboxRelayNoopResult> {
// 1. Resolve canonical lead name from config/data service.
// 2. If inboxName is the lead and there is a current native run child, call relayLeadInboxMessages().
// 3. If inboxName is OpenCode and there is a stored OpenCode session for that recipient, use OpenCode runtime relay.
// 4. If inboxName is OpenCode lead but no lead session exists, return a visible diagnostic and do not mark rows read.
// 5. If inboxName is native non-lead, no-op because native teammates read inbox files directly.
}
Do not make FileWatcher call relayLeadInboxMessages() directly after this change. The service selector owns the distinction between native lead, OpenCode member, unsupported OpenCode lead, and native teammate.
Add a provisioning service method for OpenCode runtime-addressable recipients:
async relayOpenCodeMemberInboxMessages(
teamName: string,
memberName: string,
opts?: {
onlyMessageId?: string;
source?: 'watcher' | 'ui-send' | 'manual';
deliveryMetadata?: {
replyRecipient?: string;
actionMode?: AgentActionMode;
taskRefs?: TaskRef[];
};
}
): Promise<{ attempted: number; delivered: number; failed: number; diagnostics: string[] }> {
// 1. Return immediately if recipient is not OpenCode.
// 2. Read inboxes/<memberName>.json.
// 3. Select unread messages with stable messageId, optionally restricted to onlyMessageId.
// 4. Skip messageIds already delivered in a per-team/member dedupe set.
// 5. For each message, call deliverOpenCodeMemberMessage() with:
// text: visible message text
// messageId
// replyRecipient: opts.deliveryMetadata?.replyRecipient || message.from || 'user'
// actionMode: opts.deliveryMetadata?.actionMode
// taskRefs: opts.deliveryMetadata?.taskRefs || message.taskRefs
// 6. Mark successfully delivered rows read.
// 7. Keep failed rows unread for retry unless the failure is terminal, e.g. recipient_removed.
}
Delivery commit semantics:
- The v1 relay is at-least-once with no data loss, not a new exactly-once queue.
- The durable commit is the inbox row read flag. A relay attempt is "successful" only after OpenCode prompt delivery is accepted and the specific inbox message is marked read.
- In-memory messageId dedupe is only for same-process FileWatcher bursts and UI-send/watch double events. Do not rely on it after app restart.
- If OpenCode accepts the prompt but
markInboxMessagesRead()fails, return a diagnostic likeopencode_inbox_mark_read_failed_after_delivery. The row remains retryable and may be delivered again. That is safer than marking an undelivered message read. - Do not mark an OpenCode-targeted inbox row read before the bridge accepts the runtime prompt.
- Do not reuse
RuntimeDeliveryJournalfor this direction without a separate design. That journal models OpenCode runtime writing to app destinations viaruntime_deliver_message; this relay is app-to-OpenCode prompt delivery.
OpenCode lead rule:
- Mixed team with native Codex/Claude/Gemini lead: keep the existing
relayLeadInboxMessages()path. - OpenCode teammate or secondary lane: use
relayOpenCodeMemberInboxMessages(). - Pure OpenCode lead inbox: launch and store a real OpenCode
team-leadruntime session, then relay throughrelayOpenCodeMemberInboxMessages(). Do not mark messages read unless that delivery is accepted. - Do not fake lead delivery by sending to a random teammate session. That would make messages appear delivered while the actual recipient never saw them.
- If the stored
team-leadsession is missing, keep the row retryable instead of falling back to another teammate.
FileWatcher change:
return teamProvisioningService.relayInboxFileToLiveRecipient(teamName, inboxName, {
source: 'watcher',
});
UI direct-send integration:
const result = await getTeamDataService().sendMessage(...);
const runtimeDelivery = await provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, {
onlyMessageId: result.messageId,
source: 'ui-send',
deliveryMetadata: { replyRecipient, actionMode, taskRefs: validatedTaskRefs.value },
});
For UI direct-send, Step 9.6 must persist base text for OpenCode recipients, not native memberDeliveryText. Then retry through FileWatcher is safe because the inbox row no longer contains native-only SendMessage instructions.
Do not add per-message schema fields unless needed. V1 can pass rich metadata directly for the immediate ui-send relay. Watcher/manual retries can fall back to message.from, message.taskRefs, and default action mode.
Acceptance:
- FileWatcher calls a single provisioning-service relay selector instead of embedding lead/member/provider routing itself.
- Native lead inbox messages still go through
relayLeadInboxMessages()internally. - FileWatcher still does not relay native teammate inbox messages.
- FileWatcher does relay unread inbox messages for OpenCode recipients through
deliverOpenCodeMemberMessage(). - Pure OpenCode lead inbox messages are not marked read or reported as delivered unless a real OpenCode lead runtime session exists.
- Pure OpenCode lead inbox messages without a runtime session produce an explicit diagnostic instead of silently returning success.
- UI direct-send to OpenCode does not double-deliver after FileWatcher sees the inbox write.
- Successful OpenCode runtime relay marks that specific inbox message read; in-memory dedupe only coalesces duplicate events before the read commit is visible.
- Failed transient OpenCode runtime relay leaves the row retryable and reports diagnostics.
- Prompt-accepted-but-mark-read-failed returns a diagnostic instead of pretending exactly-once success.
- OpenCode-to-OpenCode
message_sendbecomes live-delivered to the target OpenCode lane.
Step 10 - Expand OpenCode app MCP readiness proof
File:
/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.ts
First decide how the required teammate-operational tool list is owned.
Option A: import agent-teams-controller into agent_teams_orchestrator - 🎯 5 🛡️ 6 🧠 6, about 80-160 LOC
This removes list duplication, but it adds a new package dependency from the runtime/orchestrator repo into the app controller package. That is a larger architecture decision than this fix needs.
Option B: keep an explicit direct-MCP required list in orchestrator v1 - 🎯 8 🛡️ 8 🧠 3, about 60-140 LOC
This matches the current repo boundary. The orchestrator only needs plain MCP names for direct Client.listTools() proof. Add tests that fail when critical teammate tools like message_send, member_briefing, task_start, or cross_team_send are missing.
Option C: generate a shared protocol contract artifact consumed by both repos - 🎯 8 🛡️ 9 🧠 7, about 250-450 LOC This is the best long-term shape, but it needs generation, publishing, and CI checks. Treat it as a follow-up after v1 proves the semantic seam.
Chosen for v1: Option B. Do not import agent-teams-controller into agent_teams_orchestrator in this change.
Before editing, snapshot the current controller catalog from claude_team:
cd /Users/belief/dev/projects/claude/claude_team
node - <<'NODE'
const catalog = require('./agent-teams-controller/src/mcpToolCatalog.js')
console.log(catalog.AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES.join('\n'))
NODE
Use that output as the explicit orchestrator list for v1. This keeps the repo boundary clean while making the duplication intentional and reviewable.
Current proof only checks runtime tools:
const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [
'runtime_bootstrap_checkin',
'runtime_deliver_message',
'runtime_task_event',
'runtime_heartbeat',
] as const;
Change to route-specific direct MCP names.
Important:
Client.listTools()returns plain names such asmessage_send.- Do not prefix direct stdio results with
agent-teams_. - Only OpenCode app/API tool-id proof should deal with canonical ids like
agent-teams_message_send. agent_teams_message_sendis an accepted alias, not the canonical id produced bybuildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send').- The orchestrator explicit list should be treated as a boundary adapter, not as the source of truth for app-side UI/readiness.
- Keep
readiness.evidence.observedMcpToolscanonical if it is exposed through the bridge. If direct proof needs plain diagnostics, add a second private/internal field such asobservedDirectToolNames.
const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
'runtime_bootstrap_checkin',
'runtime_deliver_message',
'runtime_task_event',
'runtime_heartbeat',
] as const;
const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
'member_briefing',
'task_add_comment',
'task_attach_comment_file',
'task_attach_file',
'task_briefing',
'task_complete',
'task_create',
'task_create_from_message',
'task_get',
'task_get_comment',
'task_link',
'task_list',
'task_set_clarification',
'task_set_owner',
'task_set_status',
'task_start',
'task_unlink',
'review_approve',
'review_request',
'review_request_changes',
'review_start',
'message_send',
'process_list',
'process_register',
'process_stop',
'process_unregister',
'cross_team_send',
'cross_team_list_targets',
'cross_team_get_outbox',
] as const;
const REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES = [
...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS,
...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS,
] as const;
Update direct listTools mapping:
return (result.tools ?? [])
.map((tool) => tool.name)
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0);
Compare plain names internally:
function matchAppMcpTools(observedDirectToolNames: string[], route: string): AppMcpToolProof {
const observedDirect = new Set(observedDirectToolNames)
const missingTools = REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES.filter(
tool => !observedDirect.has(tool)
)
...
}
But emit canonical ids for bridge readiness/evidence:
function buildOpenCodeCanonicalMcpToolId(toolName: string): string {
return `${OPEN_CODE_APP_MCP_SERVER_NAME}_${toolName}`;
}
function matchAppMcpTools(observedDirectToolNames: string[], route: string): AppMcpToolProof {
const observedDirect = new Set(observedDirectToolNames);
const missingTools = REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES.filter(
(tool) => !observedDirect.has(tool)
);
return {
ok: missingTools.length === 0,
observedTools: uniqueSortedStrings(
observedDirectToolNames.map(buildOpenCodeCanonicalMcpToolId)
),
observedDirectToolNames: uniqueSortedStrings(observedDirectToolNames),
missingTools,
diagnostics:
missingTools.length === 0
? []
: [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`],
route,
};
}
If you do not want to widen AppMcpToolProof, skip observedDirectToolNames, but still keep observedTools canonical because readiness.evidence.observedMcpTools feeds production evidence.
Acceptance:
- Readiness fails before launch if OpenCode cannot see a tool that
member_briefingmay instruct it to use. - Cache/dedupe behavior stays unchanged.
- The list intentionally excludes lead-only tools like
lead_briefingand non-teammate groups, but includes all teammate-operational catalog groups including cross-team. - Diagnostics can still display
agent-teams/<tool>labels, but matching must use plain direct MCP names. - Public readiness/evidence still exposes canonical ids like
agent-teams_message_send, not plain direct names, so production evidence remains comparable toREQUIRED_AGENT_TEAMS_APP_TOOL_IDS.
Step 11 - Expand app-side OpenCode MCP tool availability proof
File:
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts
Current required tools only include runtime tools. Add separate lists and make every app-side place that expresses "launch-visible Agent Teams MCP tools" use the full app tool list.
Important current-shape note:
- Normal UI launch readiness goes through
OpenCodeTeamRuntimeAdapter -> OpenCodeReadinessBridge -> agent_teams_orchestrator. OpenCodeTeamLaunchReadinessServiceandOpenCodeMcpToolAvailabilityProbestill have tests and policy helpers, but they are not the only production launch path.- Therefore this step is mostly about shared app-side constants and production gate expectations. The actual live proof still happens in the orchestrator direct MCP preflight from Step 10.
Preferred pattern:
import * as agentTeamsControllerModule from 'agent-teams-controller';
const AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES =
agentTeamsControllerModule.AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES;
export const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
'runtime_bootstrap_checkin',
'runtime_deliver_message',
'runtime_task_event',
'runtime_heartbeat',
] as const;
export const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS: readonly string[] = [
...AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES,
] as const;
export const REQUIRED_AGENT_TEAMS_APP_TOOLS: readonly string[] = [
...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS,
...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS,
] as const;
export const REQUIRED_AGENT_TEAMS_APP_TOOL_IDS = REQUIRED_AGENT_TEAMS_APP_TOOLS.map((tool) =>
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
);
Why typed as readonly string[]:
- The controller catalog is a CommonJS runtime export typed by
.d.ts, not a literal tuple inside this TS file. - Keeping app/full lists as
readonly string[]avoids pretending the spread catalog is a compile-time literal tuple. REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLSremains a literal tuple because runtime schema contracts depend on exact names.
Add a small import-shape test:
it('loads teammate-operational tool names from agent-teams-controller package main', () => {
expect(REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS).toContain('message_send');
expect(REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS).toContain('cross_team_send');
expect(REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS).not.toContain('lead_briefing');
});
Acceptance:
- App-side readiness policy and orchestrator readiness proof agree semantically, even though the orchestrator matches plain direct names and the app production gate expects canonical OpenCode ids.
- Missing app tools are classified as launch-blocking.
- Runtime schema verification still only applies to runtime tools. Operational tools can be name-proven first unless their schemas become part of the launch-critical contract.
- The app-side list follows
agent-teams-controller/src/mcpToolCatalog.js, so adding a new teammate-operational tool updates readiness automatically. - Existing callers that truly mean runtime schema tools should use
REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS, not the full app list. - Existing callers that mean launch-visible app tools should use
REQUIRED_AGENT_TEAMS_APP_TOOLSorREQUIRED_AGENT_TEAMS_APP_TOOL_IDS.
Step 12 - Keep app tool proof in readiness only
Files:
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts
Current rule:
- OpenCode launch readiness should not require a project-scoped proof artifact.
- App tool proof belongs in the live readiness path through capability, runtime-store, MCP tool, and execution checks.
- If tool requirements change, update
OpenCodeMcpToolAvailabilityand readiness tests directly.
Acceptance:
message_send,member_briefing,task_start, andcross_team_sendare covered by readiness tests.- Missing app MCP tools fails readiness directly with a clear diagnostic.
- No project-specific artifact is required to create or launch a team.
Step 13 - Resolve secondary lane current-run evidence from lane manifest
Files:
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/store/RuntimeStoreManifest.ts
Important correction after code inspection:
OpenCodeRuntimeLaneIndexEntrycurrently haslaneId,state,updatedAt, anddiagnostics.- The durable run identity is already represented by
RuntimeStoreManifest.activeRunId. - Do not add
activeRunIdtolanes.jsonin v1. That would create a second source of truth and a new drift path. - Use
lanes.jsononly as the active/degraded/stopped lane directory index. - Use the lane-scoped manifest as the authoritative durable run identity.
Current lane index shape should stay narrow:
export interface OpenCodeRuntimeLaneIndexEntry {
laneId: string;
state: 'active' | 'stopped' | 'degraded';
updatedAt: string;
diagnostics?: string[];
}
Use the existing manifest reader:
const evidence = await new OpenCodeRuntimeManifestEvidenceReader({
teamsBasePath: getTeamsBasePath(),
}).read(teamName, laneId);
const activeRunId = evidence.activeRunId?.trim() || null;
Add a narrow helper in TeamProvisioningService:
private async resolveDurableOpenCodeRuntimeRunId(
teamName: string,
laneId: string
): Promise<string | null> {
const live = this.getCurrentOpenCodeRuntimeRunId(teamName, laneId);
if (live) return live;
const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(
() => null
);
const laneEntry = laneIndex?.lanes[laneId];
if (laneEntry?.state !== 'active') {
return null;
}
const manifest = await new OpenCodeRuntimeManifestEvidenceReader({
teamsBasePath: getTeamsBasePath(),
})
.read(teamName, laneId)
.catch(() => null);
return manifest?.activeRunId?.trim() || null;
}
Do not let read() legacy fallback accidentally revive unrelated lanes. The helper must first require lanes.json to say the specific lane is active; then it may read the lane-scoped manifest. If the lane is degraded or missing, return null and let the existing stale-lane recovery path handle it.
Then consume the durable run id in three places.
- Runtime evidence acceptance:
Current risk:
currentRunId: this.getCurrentOpenCodeRuntimeRunId(input.teamName, input.laneId),
getCurrentOpenCodeRuntimeRunId() currently uses in-memory maps. After app restart, a still-running OpenCode lane can call runtime_bootstrap_checkin, but RuntimeRunTombstoneStore.assertEvidenceAccepted() rejects it with current_run_missing.
Change to async durable resolution:
const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(input.teamName, input.laneId);
await store.assertEvidenceAccepted({
teamName: input.teamName,
runId: input.runId,
currentRunId,
evidenceKind: input.evidenceKind,
});
- Message delivery recovery:
Current risk:
if (!trackedRunId) {
const laneIndex = await readOpenCodeRuntimeLaneIndex(...)
if (laneIndex?.lanes[laneIdentity.laneId]?.state !== 'active') {
return { delivered: false, reason: 'opencode_runtime_not_active' };
}
}
const result = await adapter.sendMessageToMember({
...(trackedRunId ? { runId: trackedRunId } : {}),
...
});
This checks durable active state but drops durable activeRunId, so runSendMessage() cannot prepend the identity reminder after restart.
Use a resolved run id:
const durableRunId = trackedRunId
? trackedRunId
: await this.resolveDurableOpenCodeRuntimeRunId(teamName, laneIdentity.laneId);
if (!trackedRunId && !durableRunId) {
return { delivered: false, reason: 'opencode_runtime_not_active' };
}
const result = await adapter.sendMessageToMember({
...(durableRunId ? { runId: durableRunId } : {}),
...
});
- Runtime delivery service current-run resolver:
Current risk:
getCurrentRunId: async (candidateTeamName) =>
this.getCurrentOpenCodeRuntimeRunId(candidateTeamName, laneId),
This is used by runtime_deliver_message delivery journaling. After restart, it has the same in-memory-only weakness. Change it to:
getCurrentRunId: async (candidateTeamName) =>
this.resolveDurableOpenCodeRuntimeRunId(candidateTeamName, laneId),
Acceptance:
- Do not add
activeRunIdtolanes.jsonin v1. runtime_bootstrap_checkinandruntime_heartbeatcan be accepted after app restart whenlanes.jsonsays the lane is active and the lane-scoped manifest hasactiveRunId.- UI/user messages to an OpenCode secondary lane after app restart pass the manifest
activeRunIdtoopencode.sendMessage, allowing identity reminder recovery. runtime_deliver_messagecurrent-run checks use the same durable manifest fallback.- Stale lane recovery still degrades missing lane state.
- This supports
runtime_bootstrap_checkin; normal messages still usemessage_send.
Step 14 - Guard runtime delivery team-change event shape
Files:
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/TeamProvisioningService.ts
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts
/Users/belief/dev/projects/claude/claude_team/src/shared/types/team.ts
/Users/belief/dev/projects/claude/claude_team/src/main/index.ts
Current contracts:
export interface RuntimeDeliveryTeamChangeEvent {
type: string;
teamName: string;
data?: Record<string, unknown>;
}
export interface TeamChangeEvent {
type: TeamChangeEventType;
teamName: string;
runId?: string;
detail?: string;
taskId?: string;
}
Do not leak RuntimeDeliveryTeamChangeEvent directly to renderer or src/main/index.ts.
Keep the adapter in createOpenCodeRuntimeDeliveryService() explicit:
emit: (event) => {
this.teamChangeEmitter?.({
type: event.type as TeamChangeEvent['type'],
teamName: event.teamName,
detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined,
});
};
Reason:
TeamMessageFeedServicecache invalidation currently happens ontype === "inbox"ortype === "lead-message", so OpenCode replies can still become visible by type alone.- Renderer message refresh also schedules on
event.type, notevent.detail. - But app-side relay, native notification, and several filesystem-derived branches inspect top-level
event.detail, notevent.data.detail. - If future code emits
data.detaildirectly, the UI may still refresh sometimes, while relay/notification behavior silently diverges.
Acceptance:
- Runtime delivery destination ports may continue returning local
data.detail. - Only the app-facing
TeamChangeEventcrosses theTeamProvisioningServiceboundary. - App-facing runtime delivery events expose top-level
detailforinboxes/user.json,sentMessages.json, and cross-team outbox changes. - No frontend fake refresh is added.
- No change is required to
RuntimeDeliveryServiceitself unless tests show the local event shape has already leaked outside the adapter.
Step 15 - Keep frontend changes bounded and truth-based
Expected frontend changes: store action contract plus warning/display behavior only.
Reason:
TeamInboxReaderalready readsinboxes/user.json.TeamMessageFeedServicealready merges inbox messages.src/renderer/store/index.tsschedules message refresh onevent.type === "inbox"andevent.type === "lead-message".MessagesPanelalready clears pending replies when a message hasto === "user".- Step 9.7 requires the renderer store action to return
SendMessageResultand rethrow real send failures after setting store error state. - Step 9.7 requires
MessagesPanel/SendMessageDialogto surface OpenCode runtime delivery failure as a warning, not as a fake agent reply.
Do not add a frontend fake "agent answered" path. Frontend may show "message saved but runtime delivery failed" because that is real delivery state; it must not synthesize teammate thoughts/replies.
Remaining Uncertainty Register
These are the places most likely to produce regressions if implemented casually.
-
Canonical OpenCode MCP id spelling - 🎯 8 🛡️ 8 🧠 3, about 20-50 LOC in tests
buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')keeps the dash inagent-teams_message_send. Direct MCP stdio proof uses plainmessage_send. Transcript parsing accepts aliases. Add tests for these contexts so nobody normalizes everything to underscore by accident. -
Orchestrator explicit teammate tool list drift - 🎯 7 🛡️ 7 🧠 4, about 40-90 LOC in tests The v1 orchestrator list is duplicated by design to avoid adding a dependency. This is acceptable only if tests cover the current controller teammate-operational catalog snapshot, including attachment/link/create/cross-team tools. If this fails repeatedly, move to Option C from Step 10: generated shared protocol contract.
-
Runtime provider inference - 🎯 7 🛡️ 8 🧠 4, about 60-120 LOC with tests
runtimeProvider: "opencode"is the most reliable signal and should be sent by orchestrator. Provider metadata inference is a fallback for controller-generated messages and manual briefing calls. Native fallback must remain default when neither explicit runtimeProvider nor OpenCode metadata is present. -
Production evidence freshness - 🎯 8 🛡️ 9 🧠 3, about 30-80 LOC in tests Old evidence that proves runtime tools only must fail production gate after this change. This is intentional. The diagnostic must explain which app MCP tools are missing so regeneration is obvious.
-
Model compliance versus protocol availability - 🎯 6 🛡️ 8 🧠 5, about 80-180 LOC with event tests The protocol can make the correct tools visible and instruct the model correctly, but the model may still answer in plain text. The reliable app truth should be: runtime check-in proves the lane is alive,
message_sendproves visible user/team response, and tool-only assistant events still count aslatestAssistantMessageIdfor launch liveness. -
OpenCode send-message command durability - 🎯 7 🛡️ 7 🧠 4, about 40-120 LOC if kept direct, 140-260 LOC if moved into the state-changing bridge
OpenCodeReadinessBridge.sendOpenCodeTeamMessage()currently executesopencode.sendMessagedirectly, while launch/reconcile/stop go throughOpenCodeStateChangingBridgeCommandService. For this seam, do not expand scope unless needed: keep direct send, require adapter callers to passrunId, and use the runId only for identity reminder/recovery. TreatpromptAsync()success as delivery acceptance; post-send reconcile failure is a warning, not a false delivery failure. If stale-send bugs continue, promoteopencode.sendMessageinto the state-changing command service as a separate reliability pass. -
Cross-team transport split - 🎯 8 🛡️ 9 🧠 4, about 50-120 LOC in prompt helper/tests OpenCode needs two visible messaging transports:
message_sendfor local user/lead/member messages, andcross_team_sendfor remote teams. Collapsing both intomessage_sendwould resurrect the exact bug existing prompts warn about: treatingcross_team_sendas a recipient. The helper should expose both phrases/examples, but implementation remains narrow because it only affects wording and tests. -
Direct-proof output shape - 🎯 8 🛡️ 9 🧠 4, about 40-100 LOC in orchestrator/tests The orchestrator direct MCP proof must match plain names from
Client.listTools(), butreadiness.evidence.observedMcpToolsshould remain canonical ids because production evidence consumes it. This is a boundary-shape risk, not a model behavior risk. Add tests that plain direct names pass matching while public bridge evidence containsagent-teams_message_send.
8.1 Plain tool-name false positives - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests
Alias parsing must accept plain message_send for OpenCode direct MCP proof/capture, but a plain name alone is not enough in arbitrary transcripts. For capture/log paths, require Agent Teams payload shape and current team match before treating a plain short name as our tool. Canonical/prefixed names remain accepted directly.
-
Durable run id consumption - 🎯 9 🛡️ 9 🧠 5, about 90-180 LOC with tests
activeRunIdalready lives in the lane-scopedRuntimeStoreManifest;lanes.jsononly proves lane state. Bootstrap evidence acceptance, runtime delivery journaling, and message delivery must read the manifest when in-memory run maps are empty after app restart. Without this, OpenCode lanes can be active inlanes.jsonbut still reject check-in or send messages without identity recovery. Do not add a duplicate run id field tolanes.jsonin v1. -
Cross-team taskRefs mismatch - 🎯 7 🛡️ 8 🧠 4, about 0-25 LOC if forbidden in v1, 70-150 LOC if wired end-to-end Shared types already include
taskRefsfor cross-team messages, butcross_team_sendschema/controller do not persist them. The semantic helper must not generate unsupported fields. Either explicitly forbid cross-team taskRefs in v1 helper examples or wire schema/storage/tests now. -
OpenCode direct-message metadata - 🎯 9 🛡️ 9 🧠 5, about 120-220 LOC with tests OpenCode runtime delivery should not parse native
SendMessageprompt text to discover the reply recipient. PassreplyRecipient,actionMode, andtaskRefsas explicit adapter metadata, and build a separate OpenCode-native delivery prompt. This lowers model confusion and removes regex coupling tobuildMessageDeliveryText(). -
Runtime delivery event adapter shape - 🎯 9 🛡️ 9 🧠 3, about 20-60 LOC in tests
RuntimeDeliveryServiceuses a localdata.detailenvelope, but the app usesTeamChangeEvent.detail. The existing adapter maps it correctly. Test this so a future refactor does not bypass the adapter and make OpenCode replies visible in some UI paths while relay/notification/detail-sensitive paths silently miss the change. -
User-directed
message_sendsender identity - 🎯 9 🛡️ 9 🧠 3, about 35-90 LOC with tests The protocol cannot rely only on prompt text saying "include from". Iffromis absent forto: "user", the controller currently creates a durable user-to-user row. Add a narrow guard that rejects missing/invalid sender only for user-directed MCP messages, while keeping legacy user-to-membermessage_senddefaults intact. -
OpenCode direct-message delivery acknowledgement - 🎯 9 🛡️ 9 🧠 5, about 130-260 LOC with tests The send-message IPC path currently treats inbox persistence as success and starts OpenCode runtime delivery asynchronously. That is okay for native teammates because they watch/read inbox files, but not for OpenCode lanes. Add an additive runtime delivery result and fix the renderer store action to return/rethrow, so UI can distinguish "message saved" from "OpenCode runtime actually received the prompt" and pending replies do not hang on hidden failures.
-
Runtime delivery tool ambiguity - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests
runtime_deliver_messagecan write real destinations, so it is not safe to rely on the name alone and hope the model choosesmessage_send. V1 should keep it available for runtime evidence but make descriptions/prompts explicit that normal visible replies usemessage_send, while runtime delivery is a low-level idempotent path only when explicitly requested. -
OpenCode-targeted inbox relay - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests The app currently has special relay for native lead inboxes and native teammate file-watch behavior, but OpenCode teammates do not watch
inboxes/<member>.json. Any plan that only fixes UI direct-send still leaves OpenCode-to-OpenCode and system notification routes unreliable. Add recipient-provider-aware runtime relay with at-least-once semantics, read-flag commit, duplicate-event dedupe, and explicit unsupported OpenCode lead diagnostics. Do not reuse nativerelayMemberInboxMessages(). -
message_sendrecipient canonicalization - 🎯 9 🛡️ 9 🧠 4, about 70-150 LOC with tests Raw recipient names currently become inbox filenames. This is fragile because prompts and tests use lead aliases liketeam-leadwhile teams can have a custom lead name. Resolvetoandfromagainst configured members before persistence, withuseras the only special local destination and cross-team tool names rejected clearly. -
OpenCode lead runtime session gap - 🎯 8 🛡️ 9 🧠 5, about 60-140 LOC for v1 diagnostics, 300-700 LOC if adding a real lead lane The app-side OpenCode adapter passes
leadPrompt, but the orchestrator launch handler currently creates sessions frombody.membersonly.relayLeadInboxMessages()also requires nativerun.child. V1 must not pretend pure OpenCode lead inbox delivery works. Either route to an existing storedteam-leadOpenCode session if one is later introduced, or leave rows unread with an explicit diagnostic. Creating a real OpenCode lead lane is a separate feature, not a hidden side effect of this messaging seam.
Tests
claude_team MCP/controller tests
File:
/Users/belief/dev/projects/claude/claude_team/mcp-server/test/tools.test.ts
/Users/belief/dev/projects/claude/claude_team/agent-teams-controller/test/controller.test.js
Add tests:
it('returns OpenCode-safe member briefing when runtimeProvider is opencode', async () => {
const briefing = await getTool('member_briefing').execute({
claudeDir,
teamName,
memberName: 'bob',
runtimeProvider: 'opencode',
});
const text = (briefing as { content: Array<{ text: string }> }).content[0]?.text ?? '';
expect(text).toContain('agent-teams_message_send');
expect(text).not.toMatch(/via SendMessage|SendMessage summary field/);
});
it('keeps native member briefing using SendMessage by default', async () => {
const briefing = await getTool('member_briefing').execute({
claudeDir,
teamName,
memberName: 'alice',
});
const text = (briefing as { content: Array<{ text: string }> }).content[0]?.text ?? '';
expect(text).toContain('SendMessage');
expect(text).not.toContain('runtimeProvider: "opencode"');
});
it('infers OpenCode-safe member briefing from provider metadata when runtimeProvider is omitted', async () => {
// Configure bob with providerId: 'opencode'.
const briefing = await getTool('member_briefing').execute({
claudeDir,
teamName,
memberName: 'bob',
});
const text = (briefing as { content: Array<{ text: string }> }).content[0]?.text ?? '';
expect(text).toContain('agent-teams_message_send');
expect(text).not.toMatch(/via SendMessage|SendMessage summary field/);
});
it('persists taskRefs through message_send', async () => {
await getTool('message_send').execute({
claudeDir,
teamName,
to: 'user',
from: 'bob',
text: 'Done',
summary: '#abcd1234 done',
taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }],
});
const rows = JSON.parse(
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'user.json'), 'utf8')
);
expect(rows[0].taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]);
});
it('rejects user-directed message_send without a teammate sender', async () => {
await expect(
getTool('message_send').execute({
claudeDir,
teamName,
to: 'user',
text: 'Done',
})
).rejects.toThrow(/to user requires from/i);
});
it('keeps legacy user-to-member message_send valid without from', async () => {
await getTool('message_send').execute({
claudeDir,
teamName,
to: 'alice',
text: 'Please check this',
});
const rows = JSON.parse(
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')
);
expect(rows.at(-1)).toMatchObject({
from: 'user',
to: 'alice',
text: 'Please check this',
});
});
it('rejects user-directed message_send when from is not a configured team member', async () => {
await expect(
getTool('message_send').execute({
claudeDir,
teamName,
to: 'user',
from: 'unknown-agent',
text: 'Done',
})
).rejects.toThrow(/unknown from|configured team member/i);
});
it('canonicalizes message_send lead aliases before writing inbox files', async () => {
// Configure lead member with name "lead", not "team-lead".
await getTool('message_send').execute({
claudeDir,
teamName,
to: 'team-lead',
from: 'bob',
text: 'Need help',
});
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'lead.json'))).toBe(true);
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'team-lead.json'))).toBe(
false
);
});
it('canonicalizes message_send sender aliases before persistence', async () => {
// Configure lead member with name "lead", not "team-lead".
await getTool('message_send').execute({
claudeDir,
teamName,
to: 'alice',
from: 'team-lead',
text: 'Please review',
});
const rows = JSON.parse(
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')
);
expect(rows.at(-1)).toMatchObject({ from: 'lead', to: 'alice' });
});
it('rejects message_send to unknown local recipients instead of creating arbitrary inboxes', async () => {
await expect(
getTool('message_send').execute({
claudeDir,
teamName,
to: 'unknown-agent',
from: 'bob',
text: 'Hello',
})
).rejects.toThrow(/unknown to|configured team member/i);
});
it('rejects message_send to cross_team_send pseudo recipient with a clear tool hint', async () => {
await expect(
getTool('message_send').execute({
claudeDir,
teamName,
to: 'cross_team_send',
from: 'bob',
text: 'Hello',
})
).rejects.toThrow(/use cross_team_send/i);
});
it('rejects message_send to qualified external recipients after local roster lookup fails', async () => {
await expect(
getTool('message_send').execute({
claudeDir,
teamName,
to: 'other-team.team-lead',
from: 'bob',
text: 'Hello',
})
).rejects.toThrow(/use cross_team_send/i);
});
it('keeps configured dotted local members valid before applying cross-team heuristics', async () => {
// Configure a local member named "qa.bot".
await getTool('message_send').execute({
claudeDir,
teamName,
to: 'qa.bot',
from: 'bob',
text: 'Local dotted member',
});
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'qa.bot.json'))).toBe(
true
);
});
it('describes message_send as the normal visible reply tool for OpenCode', () => {
const tool = getRegisteredTool('message_send');
expect(tool.description).toMatch(/visible.*message|normal replies/i);
expect(tool.description).toMatch(/to is "user".*from is required/i);
});
it('describes runtime_deliver_message as low-level and not the normal reply path', () => {
const tool = getRegisteredTool('runtime_deliver_message');
expect(tool.description).toMatch(/low-level|runtime delivery journal/i);
expect(tool.description).toMatch(/normal visible replies.*message_send/i);
});
Add cross-team taskRefs tests only if Step 6.4 chooses Option B:
it('persists taskRefs through cross_team_send when enabled', async () => {
await getTool('cross_team_send').execute({
claudeDir,
teamName,
toTeam: 'review-team',
fromMember: 'bob',
text: 'Please review task #abcd1234',
summary: '#abcd1234 review request',
taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }],
});
const targetInbox = JSON.parse(
fs.readFileSync(
path.join(claudeDir, 'teams', 'review-team', 'inboxes', 'team-lead.json'),
'utf8'
)
);
expect(targetInbox.at(-1).taskRefs).toEqual([
{ teamName, taskId: 'task-1', displayId: 'abcd1234' },
]);
const outbox = JSON.parse(
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'sent-cross-team.json'), 'utf8')
);
expect(outbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]);
});
Add controller-level tests:
it('preserves provider metadata when resolving team members', () => {
// Create team config with bob providerId opencode.
// Resolve members through controller/runtime helper path.
// Assert bob.providerId === 'opencode'.
});
it('uses OpenCode messaging protocol in assignment notifications for OpenCode owners', () => {
// Create bob as providerId opencode.
// Create task with owner bob and notifyOwner true.
// Read bob inbox.
// Assert inbox text contains agent-teams_message_send.
// Assert inbox text does not match /via SendMessage|SendMessage summary field/.
});
it('keeps cross-team replies on cross_team_send for OpenCode briefings', async () => {
const briefing = await controller.tasks.memberBriefing('bob', {
runtimeProvider: 'opencode',
});
expect(briefing).toContain('agent-teams_cross_team_send');
expect(briefing).toContain('toTeam');
expect(briefing).not.toMatch(/message_send[^\\n]+cross_team_send/);
});
it('keeps native assignment notifications using SendMessage', () => {
// Create alice without providerId.
// Create task with owner alice.
// Assert inbox text still contains SendMessage.
});
Add alias tests for app capture/log support:
it.each([
'message_send',
'agent-teams_message_send',
'agent_teams_message_send',
'mcp__agent-teams__message_send',
'mcp__agent_teams__message_send',
])('canonicalizes %s to message_send', (toolName) => {
expect(canonicalizeAgentTeamsToolName(toolName)).toBe('message_send');
});
it.each([
'"name":"agent-teams_task_start"',
'"name":"agent_teams_task_start"',
'"name":"mcp__agent-teams__task_start"',
'"name":"proxy_agent-teams_task_complete"',
])('detects task boundary aliases in raw log line %s', (line) => {
expect(lineHasAgentTeamsTaskBoundaryToolName(line)).toBe(true);
});
it('does not double-persist MCP message_send as a lead-process message', () => {
// Feed captureSendMessages a message_send tool_use to a normal local teammate.
// Assert MCP persistence path is not duplicated by pushLiveLeadProcessMessage/persistSentMessage.
// Cross-team pseudo-recipient fallback remains covered separately.
});
it('does not classify unrelated plain message_send tool_use without Agent Teams payload shape', () => {
expect(
isAgentTeamsToolUse({
rawName: 'message_send',
canonicalName: 'message_send',
toolInput: { channel: 'general', body: 'hello' },
currentTeamName: 'atlas-hq',
})
).toBe(false);
});
Add app-side OpenCode readiness tests:
it('uses full app tool ids for OpenCode readiness expectations', async () => {
const result = await bridge.runReadiness({
selectedModel: 'minimax-m2.5-free',
// ...
});
expect(result.supportLevel).toBe('production_supported');
expect(result.evidence.observedMcpTools).toEqual(
expect.arrayContaining(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS)
);
});
it('keeps runtime schema validation scoped to runtime proof tools', () => {
expect(APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => contract.name)).toEqual(
REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS
);
expect(REQUIRED_AGENT_TEAMS_APP_TOOLS).toContain('message_send');
expect(APP_MCP_RUNTIME_TOOL_CONTRACTS.map((contract) => contract.name)).not.toContain(
'message_send'
);
});
Add OpenCode direct-message delivery tests:
it('delivers OpenCode runtime message with explicit reply recipient instead of parsing native SendMessage text', async () => {
const bridge = createBridgeSpy();
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
await adapter.sendMessageToMember({
runId: 'run-1',
teamName,
laneId: 'secondary:bob',
memberName: 'bob',
cwd,
text: 'Can you check this?',
messageId: 'msg-1',
replyRecipient: 'user',
});
const sentText = bridge.sentMessages[0].text;
expect(sentText).toContain('to="user"');
expect(sentText).toContain('agent-teams_message_send');
expect(sentText).not.toContain('CRITICAL: Reply using the SendMessage tool');
});
it('passes taskRefs and actionMode into OpenCode runtime message prompt', async () => {
const bridge = createBridgeSpy();
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
await adapter.sendMessageToMember({
runId: 'run-1',
teamName,
laneId: 'secondary:bob',
memberName: 'bob',
cwd,
text: 'Please respond on task #abcd1234',
replyRecipient: 'alice',
actionMode: 'do',
taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }],
});
const sentText = bridge.sentMessages[0].text;
expect(sentText).toContain('to="alice"');
expect(sentText).toContain('Action mode for this message: do');
expect(sentText).toContain('"displayId":"abcd1234"');
});
it('keeps native persisted inbox text unchanged but stores base text for OpenCode recipients', async () => {
// Exercise the IPC send-message path.
// For a native recipient, assert sendMessage() persisted memberDeliveryText containing native SendMessage guidance.
// For an OpenCode recipient, assert the persisted inbox row text is baseText and does not contain SendMessage guidance.
// Assert relayOpenCodeMemberInboxMessages() received explicit replyRecipient/actionMode/taskRefs metadata.
});
it('returns runtimeDelivery success for live OpenCode direct messages', async () => {
// Exercise handleSendMessage() for a live non-lead OpenCode recipient.
// Mock relayOpenCodeMemberInboxMessages() to return { attempted: 1, delivered: 1, failed: 0 }.
// Assert result.runtimeDelivery is { providerId: "opencode", attempted: true, delivered: true }.
// Assert inbox persistence still happened with base text for OpenCode.
});
it('returns runtimeDelivery failure without hiding the persisted message', async () => {
// Exercise handleSendMessage() for a live non-lead OpenCode recipient.
// Mock relayOpenCodeMemberInboxMessages() to return { attempted: 1, delivered: 0, failed: 1, diagnostics: ["opencode_runtime_not_active"] }.
// Assert result.deliveredToInbox is true.
// Assert result.runtimeDelivery.delivered is false and reason is preserved.
});
it('does not auto-close the send dialog when OpenCode runtime delivery fails', async () => {
// Mount SendMessageDialog with lastResult.runtimeDelivery.delivered === false.
// Assert the dialog stays open and shows an actionable warning.
});
it('sendTeamMessage rejects after setting sendMessageError when IPC send fails', async () => {
// Mock api.teams.sendMessage to reject.
// Await expect(store.getState().sendTeamMessage(...)).rejects.toThrow().
// Assert sendMessageError is set and lastSendMessageResult is null.
});
it('clears pending reply when OpenCode runtime delivery fails after inbox persistence', async () => {
// Mock sendTeamMessage to resolve with runtimeDelivery.delivered === false.
// Send from MessagesPanel or TeamDetailView.
// Assert the member pending-reply spinner is removed and the user-sent row remains.
});
it('shows OpenCode message_send replies from inboxes/user.json without frontend fake state', async () => {
// Seed the message feed with a durable inboxes/user.json row:
// { from: "bob", to: "user", source: "opencode_message_send", text: "done" }.
// Assert TeamMessageFeedService includes it.
// Assert MessagesPanel renders it as bob -> user.
// Assert reconcilePendingRepliesByMember clears bob after this reply timestamp.
});
it('relays unread OpenCode-targeted inbox messages to the live OpenCode runtime lane', async () => {
// Configure jack with providerId: "opencode" and an active lane.
// Seed inboxes/jack.json with unread { from: "bob", to: "jack", messageId: "msg-1" }.
// Call relayOpenCodeMemberInboxMessages(teamName, "jack").
// Assert deliverOpenCodeMemberMessage() was called with memberName "jack", text, messageId, and replyRecipient "bob".
// Assert the inbox row is marked read or its messageId is recorded as delivered.
});
it('does not runtime-relay native teammate inbox messages', async () => {
// Configure alice as Codex/native.
// Seed inboxes/alice.json with unread message.
// Call relayOpenCodeMemberInboxMessages(teamName, "alice").
// Assert attempted/delivered are 0 and deliverOpenCodeMemberMessage() is not called.
});
it('uses the same OpenCode recipient predicate for persisted text and runtime relay', async () => {
// Configure jack as OpenCode in members.meta and native-looking model fallback in config.
// Assert handleSendMessage() persists base text for jack.
// Assert relayOpenCodeMemberInboxMessages() attempts runtime delivery for jack.
});
it('does not double-deliver UI direct messages after FileWatcher inbox change', async () => {
// Send UI message to OpenCode jack through handleSendMessage().
// Simulate FileWatcher inbox event for inboxes/jack.json.
// Assert the same messageId is delivered at most once to OpenCode runtime.
});
it('keeps failed transient OpenCode inbox relay retryable', async () => {
// Mock deliverOpenCodeMemberMessage() to fail with opencode_runtime_not_active.
// Assert the row remains unread and the diagnostic is returned.
});
it('does not mark OpenCode inbox rows read before bridge acceptance', async () => {
// Mock deliverOpenCodeMemberMessage() to fail before prompt acceptance.
// Assert markInboxMessagesRead() is not called and the row remains unread.
});
it('reports prompt-accepted mark-read failure as non-exactly-once diagnostic', async () => {
// Mock deliverOpenCodeMemberMessage() to accept the prompt.
// Mock markInboxMessagesRead() to throw.
// Assert diagnostics include opencode_inbox_mark_read_failed_after_delivery.
// Assert the result does not claim a clean exactly-once success.
});
it('routes native lead inbox relay through the legacy stdin path', async () => {
// Configure a mixed team with Codex/Claude lead and OpenCode secondary teammates.
// Seed inboxes/<lead>.json with one unread message.
// Call relayInboxFileToLiveRecipient(teamName, leadName).
// Assert relayLeadInboxMessages() is called and OpenCode runtime delivery is not attempted.
});
it('relays pure OpenCode lead inbox through the stored lead session', async () => {
// Configure a pure OpenCode runtime-adapter team with a stored team-lead session.
// Seed inboxes/<lead>.json with one unread message.
// Call relayInboxFileToLiveRecipient(teamName, leadName).
// Assert the relay kind is opencode_member and the prompt targets team-lead.
// Assert the inbox row is marked read only after accepted runtime delivery.
});
it('keeps OpenCode member relay independent from unsupported OpenCode lead relay', async () => {
// Configure a pure OpenCode team with a stored teammate session for bob but no team-lead session.
// Seed inboxes/bob.json and inboxes/<lead>.json.
// Assert bob is relayed and marked read.
// Assert lead remains unread with an unsupported-lead diagnostic.
});
Orchestrator tests
File:
/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeBridgeCommandHandler.test.ts
Update helper mockRequiredMcpTools() to include app tools:
tools: [
{ name: 'runtime_bootstrap_checkin' },
{ name: 'runtime_deliver_message' },
{ name: 'runtime_task_event' },
{ name: 'runtime_heartbeat' },
{ name: 'member_briefing' },
{ name: 'task_add_comment' },
{ name: 'task_attach_comment_file' },
{ name: 'task_attach_file' },
{ name: 'task_briefing' },
{ name: 'task_complete' },
{ name: 'task_create' },
{ name: 'task_create_from_message' },
{ name: 'task_get' },
{ name: 'task_get_comment' },
{ name: 'task_link' },
{ name: 'task_list' },
{ name: 'task_set_clarification' },
{ name: 'task_set_owner' },
{ name: 'task_set_status' },
{ name: 'task_start' },
{ name: 'task_unlink' },
{ name: 'review_approve' },
{ name: 'review_request' },
{ name: 'review_request_changes' },
{ name: 'review_start' },
{ name: 'message_send' },
{ name: 'process_list' },
{ name: 'process_register' },
{ name: 'process_stop' },
{ name: 'process_unregister' },
{ name: 'cross_team_send' },
{ name: 'cross_team_list_targets' },
{ name: 'cross_team_get_outbox' },
],
Add missing tool failure test:
test('readiness fails when app MCP message_send is missing', async () => {
// Arrange listTools without message_send.
// Assert result.ok === false or launchAllowed === false.
// Assert diagnostics mention message_send.
});
Add direct-name proof test:
test('direct MCP proof compares plain tool names, not OpenCode ids', async () => {
// Arrange listTools returning plain names: message_send, member_briefing, ...
// Assert readiness succeeds.
// Assert internal matching did not require listTools to return agent-teams_message_send.
// Assert public readiness evidence still contains canonical agent-teams_message_send.
// Assert public readiness evidence does not expose plain message_send as the production artifact id.
});
Add a bridge-output shape test:
test('readiness evidence emits canonical OpenCode ids after plain direct MCP proof', async () => {
// Arrange direct listTools with plain message_send/member_briefing/task_start/cross_team_send.
const result = await runReadiness();
expect(result.data.requiredToolsPresent).toBe(true);
expect(result.data.evidence.observedMcpTools).toContain('agent-teams_message_send');
expect(result.data.evidence.observedMcpTools).toContain('agent-teams_member_briefing');
expect(result.data.evidence.observedMcpTools).not.toContain('message_send');
});
Add launch prompt identity test:
test('launch prepends OpenCode runtime identity and opencode briefing mode', async () => {
const prompts: string[] = [];
openCodeSessionBridge.promptAsync = async (_record, input) => {
prompts.push(input.text);
};
// Execute opencode.launchTeam.
expect(prompts[0]).toContain('agent-teams_runtime_bootstrap_checkin');
expect(prompts[0]).toContain('mcp__agent-teams__runtime_bootstrap_checkin');
expect(prompts[0]).toContain('runtimeSessionId');
expect(prompts[0]).toContain('agent-teams_member_briefing');
expect(prompts[0]).toContain('mcp__agent-teams__member_briefing');
expect(prompts[0]).toContain('"runtimeProvider":"opencode"');
expect(prompts[0]).not.toMatch(/runtime_bootstrap_checkin[^<]+laneId/);
});
Add launch settle test:
test('launch waits briefly for OpenCode preview before final reconcile', async () => {
openCodeSessionBridge.promptAsync = async () => undefined;
openCodeSessionBridge.observePreview = async () => ({
record,
summary: {
previewOutcome: 'observed',
latestAssistantMessageId: 'msg-tool-only',
latestAssistantPreview: 'calling agent-teams_runtime_bootstrap_checkin',
runtimeState: 'running',
diagnostics: [],
},
});
openCodeSessionBridge.reconcileSession = async () => confirmedAliveReconcile();
// Execute opencode.launchTeam.
expect(openCodeSessionBridge.observePreview).toHaveBeenCalled();
expect(result.data.members.bob?.launchState).toBe('confirmed_alive');
});
test('launch settle runs concurrently for multiple OpenCode members', async () => {
const observeStarted: string[] = [];
const observeRelease = createDeferred<void>();
openCodeSessionBridge.observePreview = async (record) => {
observeStarted.push(record.memberName);
await observeRelease.promise;
return previewObserved(record);
};
const launchPromise = runOpenCodeLaunchTeamWithMembers(['bob', 'jack', 'tom']);
await waitUntil(() => observeStarted.length === 3);
observeRelease.resolve();
const result = await launchPromise;
expect(observeStarted.sort()).toEqual(['bob', 'jack', 'tom']);
expect(result.data.teamLaunchState).not.toBe('failed');
});
test('launch settle caps concurrent preview observers', async () => {
let activeObservers = 0;
let maxActiveObservers = 0;
openCodeSessionBridge.observePreview = async (record) => {
activeObservers += 1;
maxActiveObservers = Math.max(maxActiveObservers, activeObservers);
await delay(25);
activeObservers -= 1;
return previewObserved(record);
};
await runOpenCodeLaunchTeamWithMembers(['a', 'b', 'c', 'd', 'e']);
expect(maxActiveObservers).toBeLessThanOrEqual(3);
});
test('launch preview timeout does not become a hard member failure', async () => {
openCodeSessionBridge.observePreview = async () => {
throw new Error('preview timeout');
};
openCodeSessionBridge.reconcileSession = async () => createdReconcile();
// Execute opencode.launchTeam.
expect(result.data.members.bob?.launchState).toBe('created');
expect(result.data.members.bob?.diagnostics.join('\n')).not.toContain('preview timeout');
});
Add send-message recovery test:
test('sendMessage prepends OpenCode identity reminder only when runId is present', async () => {
const prompts: string[] = [];
openCodeSessionBridge.promptAsync = async (_record, input) => {
prompts.push(input.text);
};
// Execute opencode.sendMessage once with runId and once without runId.
expect(prompts[0]).toContain('agent-teams_runtime_bootstrap_checkin');
expect(prompts[0]).toContain('runtimeSessionId');
expect(prompts[1]).not.toContain('agent-teams_runtime_bootstrap_checkin');
});
test('sendMessage treats post-accept reconcile failure as warning, not delivery failure', async () => {
openCodeSessionBridge.promptAsync = async () => undefined;
openCodeSessionBridge.reconcileSession = async () => {
throw new Error('reconcile timeout');
};
const result = await runOpenCodeSendMessage({ runId: 'run-1', memberName: 'bob' });
expect(result.data.accepted).toBe(true);
expect(result.data.diagnostics.map((d) => d.code)).toContain(
'opencode_send_reconcile_failed_after_prompt_accept'
);
});
test('sendMessage reports delivery failure only when prompt enqueue fails', async () => {
openCodeSessionBridge.promptAsync = async () => {
throw new Error('prompt rejected');
};
await expect(runOpenCodeSendMessage({ runId: 'run-1', memberName: 'bob' })).rejects.toThrow(
'prompt rejected'
);
});
Add app restart durable-run tests:
test('runtime evidence acceptance falls back to lane-scoped manifest activeRunId', async () => {
// Arrange no in-memory runtimeAdapterRunByTeam/secondaryRuntimeRunByTeam entry.
// Arrange lanes.json lane active.
// Arrange lane-scoped manifest.json activeRunId.
// Call runtime_bootstrap_checkin or runtime_heartbeat with the same runId.
// Assert evidence is accepted, not rejected with current_run_missing.
});
test('OpenCode message delivery uses lane-scoped manifest activeRunId after restart', async () => {
// Arrange recipient is an OpenCode secondary lane.
// Arrange no tracked provisioning run.
// Arrange lanes.json lane active and lane-scoped manifest activeRunId.
// Spy adapter.sendMessageToMember.
// Call deliverOpenCodeMemberMessage.
// Assert sendMessageToMember receives runId from the lane-scoped manifest.
});
test('runtime delivery service current-run resolver uses lane-scoped manifest after restart', async () => {
// Arrange RuntimeDeliveryService with no in-memory run.
// Arrange lanes.json lane active and lane-scoped manifest activeRunId.
// Call runtime_deliver_message through the service path.
// Assert current-run validation receives the manifest run id.
});
Add runtime delivery event-shape tests:
it('maps runtime delivery local data.detail to app TeamChangeEvent.detail', async () => {
const emitted: TeamChangeEvent[] = [];
const service = createProvisioningService({
teamChangeEmitter: (event) => emitted.push(event),
});
await deliverOpenCodeRuntimeMessageToUser(service, {
teamName,
fromMemberName: 'bob',
text: 'done',
});
expect(emitted).toContainEqual(
expect.objectContaining({
type: 'lead-message',
teamName,
detail: 'opencode-runtime-delivery',
})
);
expect(emitted[0]).not.toHaveProperty('data.detail');
});
it('emits top-level inbox detail for runtime delivery to member inboxes', async () => {
const emitted: TeamChangeEvent[] = [];
const service = createProvisioningService({
teamChangeEmitter: (event) => emitted.push(event),
});
await deliverOpenCodeRuntimeMessageToMember(service, {
teamName,
fromMemberName: 'bob',
toMemberName: 'alice',
text: 'please review',
});
expect(emitted).toContainEqual(
expect.objectContaining({
type: 'inbox',
teamName,
detail: 'inboxes/alice.json',
})
);
});
Add renderer/store smoke test if an existing test harness already covers store event subscriptions:
it('refreshes tracked team messages on OpenCode runtime delivery team-change events by type', async () => {
// Arrange selected/tracked team.
// Emit { type: 'inbox', teamName, detail: 'inboxes/user.json' }.
// Assert refreshTeamMessagesHead(teamName) is scheduled/called.
});
Event translator test
File:
/Users/belief/dev/projects/claude/agent_teams_orchestrator/src/services/opencode/OpenCodeEventTranslator.test.ts
Add a test that a tool-only assistant message still produces latestAssistantMessageId.
Reason:
Launch state currently maps to confirmed_alive when summary.latestAssistantMessageId exists. If OpenCode replies only with tool calls, we still need that to count as alive.
Current code already appears to support this because OpenCodeTranscriptProjector.projectMessage() creates an assistant canonical message even when it only has tool parts. The test is still important because bridgeStateFromSummary() depends on this behavior.
Add assertions to the existing tool lifecycle test:
expect(summary.latestAssistantMessageId).toBe('msg-assistant-tool');
expect(summary.latestAssistantText).toBeNull();
expect(summary.latestAssistantPreview).toBeNull();
Commands
Run targeted tests first:
cd /Users/belief/dev/projects/claude/claude_team
pnpm --filter agent-teams-controller test -- test/controller.test.js
pnpm --filter agent-teams-mcp test -- test/tools.test.ts
pnpm vitest run test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/TeamProvisioningServiceRelay.test.ts test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts test/main/ipc/teams.test.ts test/main/services/team/OpenCodeMcpToolAvailability.test.ts test/main/services/team/OpenCodeReadinessBridge.test.ts test/renderer/store/teamChangeThrottle.test.ts test/renderer/store/teamSlice.test.ts test/renderer/components/team/messages/MessagesPanel.test.ts test/renderer/components/team/dialogs/SendMessageDialog.test.tsx
cd /Users/belief/dev/projects/claude/agent_teams_orchestrator
bun test src/services/opencode/OpenCodeBridgeCommandHandler.test.ts src/services/teamBootstrap/teamBootstrapSpec.test.ts src/hooks/useInboxPoller.test.ts
Then broader checks:
cd /Users/belief/dev/projects/claude/claude_team
pnpm typecheck:workspace
pnpm --filter agent-teams-mcp test:e2e
cd /Users/belief/dev/projects/claude/agent_teams_orchestrator
bun run build
Avoid heavy E2E until targeted tests pass.
Manual Verification
- Launch a mixed team with one Codex lead, one Codex teammate, and two OpenCode teammates.
- Confirm OpenCode launch prompt includes runtime identity in logs.
- Confirm OpenCode teammates call
runtime_bootstrap_checkin. - Confirm OpenCode teammates call
member_briefingwithruntimeProvider: "opencode". - Send a message to an OpenCode teammate from the UI.
- Confirm reply appears in Messages UI from that member with
to: "user". - Confirm the send result has no OpenCode runtime delivery warning when the bridge accepts the prompt.
- Temporarily force an OpenCode runtime delivery failure in a dev build and confirm the message remains persisted but the dialog/composer shows
Message saved, but OpenCode runtime delivery failed. - Ask one OpenCode teammate to message another OpenCode teammate and confirm the target receives a live runtime prompt, not only an inbox file row.
- In a pure OpenCode test team without a stored lead session, send to the lead and confirm the inbox row remains unread with an explicit unsupported-lead diagnostic, not a fake success.
- Assign a task to an OpenCode teammate.
- Confirm owner notification says
agent-teams_message_send, notSendMessage. - Complete a task from OpenCode and confirm task comment exists before visible summary message.
Rollout Order
- Add controller messaging protocol helper.
- Preserve provider metadata in controller.
- Add
runtimeProvidertomember_briefing. - Update
mcp-server/src/agent-teams-controller.d.tsandsrc/types/agent-teams-controller.d.ts. - Update member briefing and task assignment wording.
- Extend
message_sendwithtaskRefs. - Add the narrow
message_send(to: "user")sender identity guard. - Canonicalize
message_sendlocal recipients/senders before persistence. - Disambiguate
message_sendandruntime_deliver_messagedescriptions/prompts. - Choose and implement/forbid cross-team
taskRefsbefore helper examples can emit them. - Centralize Agent Teams tool-name alias matching.
- Consolidate duplicate
OpenCodeSendMessageCommandBodydeclarations. - Add orchestrator runtime identity prompt injection without unsupported
laneIdin tool payloads. - Add bounded concurrent OpenCode launch settle/preview before final launch-state mapping.
- Add native-only prompt boundary guard tests so OpenCode does not receive generic
SendMessagespawn prompts. - Make OpenCode direct-message runtime delivery explicit instead of parsing native
SendMessageprompt text. - Make OpenCode direct-message runtime delivery outcome observable in
SendMessageResultand UI. - Add the inbox relay selector that separates native lead, OpenCode runtime recipient, native teammate no-op, and unsupported OpenCode lead diagnostics.
- Add OpenCode-targeted inbox runtime relay with dedupe/read marking.
- Expand orchestrator direct MCP proof with the explicit plain-name adapter list while keeping public observed evidence as canonical OpenCode ids.
- Expand app-side OpenCode MCP availability proof from controller catalog.
- Keep OpenCode readiness requiring the full app tool id list without project-scoped artifacts.
- Add lane-scoped manifest
activeRunIdrecovery and consume it in evidence acceptance/message delivery/runtime delivery service. - Add runtime delivery
TeamChangeEvent.detailadapter guard tests. - Add tests.
- Run targeted tests.
- Run broader checks.
- Manually verify one real mixed OpenCode launch.
Failure Modes To Watch
- OpenCode launch prompt contains both "first call member_briefing" and "first call runtime_bootstrap_checkin". Fix by making adapter prompt defer to orchestrator identity block.
- OpenCode identity block shows unsupported
laneIdinsideruntime_bootstrap_checkin. Fix the example/helper, because the runtime tool schema does not accept it. - OpenCode member prompt contains native-only "Use SendMessage" guidance. This means routing leaked through a native prompt builder; fix routing, not by global-replacing all native
SendMessagetext. - OpenCode direct-message prompt contains native-only "CRITICAL: Reply using the SendMessage tool" guidance. This means runtime delivery is reusing
memberDeliveryText; pass explicit metadata and build OpenCode-native delivery text. - OpenCode uses
runtime_deliver_messagefor an ordinary reply after a UI message. This means the tool descriptions/prompts are still ambiguous or the runtime-delivery path is being over-promoted in the normal reply contract. message_sendcreatesinboxes/team-lead.jsonwhile the configured lead is named differently. This means local recipient canonicalization is missing or not using lead aliases.message_sendcreatesinboxes/unknown-agent.jsonfor an unconfigured local recipient. This should be a tool error, not a new durable inbox.- UI send to an OpenCode teammate closes as success while OpenCode inbox runtime relay fails only in logs. This means delivery is still fire-and-forget or the
runtimeDeliveryresult is ignored by the renderer. inboxes/<opencode-member>.jsoncontains native hiddenSendMessageinstructions. This makes retry unsafe because FileWatcher relay can later deliver a native prompt to OpenCode.SendMessageDialogauto-closes whenlastResult.runtimeDelivery.delivered === false. This hides a real OpenCode delivery failure after inbox persistence and should be treated as a UI contract bug.- OpenCode-to-OpenCode
message_sendcreatesinboxes/<target>.jsonbut the target never reacts. This means OpenCode-targeted inbox relay is missing or recipient provider detection failed. - OpenCode-targeted inbox relay uses existing
relayMemberInboxMessages(). That is wrong for this seam because it routes through native lead stdin and nativeSendMessagewording instead of direct OpenCode runtime prompt delivery. - Pure OpenCode
message_sendto the lead createsinboxes/<lead>.jsonand then disappears as read. This is data loss: without a stored OpenCode lead session, the row must stay unread with an explicit unsupported-lead diagnostic. - FileWatcher still calls
relayLeadInboxMessages()directly for every lead inbox. This keeps pure OpenCode lead delivery as a silent no-op because that method requiresrun.child; route through the service selector instead. - OpenCode relay marks an inbox row read before the bridge accepts the prompt. This can lose messages; read marking is the durable commit and must happen after accepted runtime delivery.
- OpenCode send reports delivery failure after
promptAsync()succeeded only because post-send reconcile timed out. That creates duplicate retries. Reconcile after prompt acceptance is evidence freshness, not delivery acceptance. - UI direct-send to OpenCode arrives twice. This means direct runtime delivery and FileWatcher inbox relay are not sharing messageId dedupe/read state.
- Pending-reply spinner stays forever after a send IPC error. This means
sendTeamMessageis still swallowing failures instead of rethrowing after updating store state. - Pending-reply spinner stays after
runtimeDelivery.delivered === false. This means caller code did not consume the returnedSendMessageResultor did not treat OpenCode runtime failure as "agent did not receive prompt". member_briefingdefault accidentally switches native teammates tomessage_send. Tests must prevent this.- Cross-team prompt/helper emits
taskRefswhilecross_team_sendschema rejects them. Either remove taskRefs from cross-team examples or wire schema/storage end-to-end. - OpenCode owner detection fails because provider metadata is still missing from resolved members.
- Readiness passes while
message_sendis missing. This means proof list is still incomplete. - Readiness passes while review/process/task-set tools are missing. This means proof only checked a small subset instead of all teammate-operational briefing tools.
- Direct MCP readiness fails even though
tools/listcontainsmessage_send. This usually means direct stdio proof is incorrectly comparing plain names against OpenCode canonical ids. - Readiness passes with runtime-only app tool coverage. This means
OpenCodeMcpToolAvailabilitystill uses only runtime tools instead of the full app tool id list. - App-side and orchestrator required tool lists drift. For v1, this is controlled by tests and explicit comments. If drift keeps recurring, move to a generated shared contract artifact.
- OpenCode member stays
createdeven though the prompt was accepted. This usually meanspromptAsync()was reconciled too early; use the bounded launch-settle helper before final launch mapping. - Preview observation times out and marks a teammate failed. That is wrong. Preview timeout should only fall back to reconcile and keep the member pending.
- Launch settle opens too many preview observers at once. Cap concurrency locally; do not scale observer count linearly with team size.
- Prompt tells OpenCode to use one alias, but log/capture code only recognizes another alias. Fix by using shared canonicalization helpers.
- Alias capture starts duplicating
message_sendin live messages. Keep the non-native no-double-persist guard incaptureSendMessages(). - OpenCode uses
message_sendfor cross-team replies. That is wrong; cross-team replies must usecross_team_sendwithtoTeam. - OpenCode replies with
message_send({ to: "user", text: "..." })and nofrom. This must fail clearly instead of writingfrom: "user". message_sendwrites toinboxes/user.json, but UI does not show it. That would be a separate feed regression, not a protocol issue.- Secondary lane check-in rejects due to missing current run id after app restart. Check
lanes.jsononly for active/degraded state, then read lane-scopedmanifest.json.activeRunId. - Secondary lane check-in still rejects after lane-scoped manifest has
activeRunId. This means evidence acceptance is still using only in-memory runtime maps. - OpenCode secondary lane receives UI message after restart but does not get identity recovery. This means
deliverOpenCodeMemberMessage()checkedlanes.jsonstate but did not pass manifestactiveRunIdtosendMessageToMember(). - Runtime delivery emits
{ data: { detail } }directly as a public team-change event. This can make type-based message refresh work while detail-based relay/notification branches miss the file path. Keep public events onTeamChangeEvent.detail.
Definition Of Done
- Native Codex/Claude prompts still use
SendMessage. - OpenCode launch, briefing, assignment, completion, and clarification instructions consistently use
agent-teams_message_send. - OpenCode cross-team instructions consistently use
agent-teams_cross_team_send, notmessage_send. - OpenCode readiness fails if required app MCP tools are absent.
- Orchestrator direct proof matches plain MCP names internally and emits canonical OpenCode ids in readiness evidence.
- Runtime tool descriptions make
message_sendthe normal visible reply API and keepruntime_deliver_messagescoped to explicit low-level runtime delivery flows. - OpenCode can prove liveness through
runtime_bootstrap_checkin. - OpenCode secondary lanes can accept runtime evidence and receive identity-reminder messages after app restart using lane-scoped manifest
activeRunId. - OpenCode runtime delivery receives explicit reply recipient/action mode/taskRefs and does not parse native
SendMessagehidden prompt text. - OpenCode-targeted inbox rows do not persist native-only
SendMessageinstructions; retries can safely rebuild OpenCode-native runtime prompts. message_sendcanonicalizes local recipients/senders before persistence, so lead aliases and unknown recipients cannot create wrong inbox files.- UI direct sends to live OpenCode teammates either confirm runtime delivery or show a visible warning; there is no log-only post-send delivery failure.
- Persisted inbox messages addressed to OpenCode teammates are live-relayed to their runtime lanes, while native teammates keep file-watch behavior and lead keeps lead relay behavior.
- OpenCode inbox relay is direct-to-runtime and does not reuse native
relayMemberInboxMessages()/SendMessageforwarding. - Pure OpenCode lead inbox delivery uses the stored
team-leadruntime session and does not silently fall back to another teammate. - Renderer send-message actions return
SendMessageResulton success and reject on real send failure, so pending-reply cleanup is not dependent on dead.catch()paths. message_sendcannot createfrom: "user", to: "user"rows; user-directed MCP replies require a configured teammate sender.- OpenCode replies appear in Messages UI without frontend fake state.
- Tests cover native default, OpenCode override, assignment protocol, tool alias canonicalization, tool proof, taskRefs persistence, user-directed sender guard, local recipient canonicalization, direct-message runtime delivery result visibility, OpenCode reply feed projection, OpenCode-targeted inbox relay/dedupe, pure OpenCode lead relay, launch identity injection, lane-scoped manifest activeRunId recovery, and runtime delivery team-change event shape.