Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
637326d
docs(agents): add note about not replacing the opencode string
nigel-dev Feb 15, 2026
5134240
chore: add @opencode-ai/sdk dependency for serve-based orchestration
nigel-dev Feb 15, 2026
9578fd9
fix: route notifications to launching session instead of active sessi…
nigel-dev Feb 15, 2026
bfffbc3
test: add session routing and v3 migration test coverage (#74)
nigel-dev Feb 15, 2026
dd58446
feat: annotate session titles when jobs need attention (#75)
nigel-dev Feb 15, 2026
4e323a9
feat: add serve-based launch with port management and SDK client (#65)
nigel-dev Feb 15, 2026
41a7cbe
feat: enhance mc_attach with smart tmux window for serve-mode jobs (#65)
nigel-dev Feb 15, 2026
6843ae7
feat: add SDK-based monitoring with question relay and permission han…
nigel-dev Feb 15, 2026
b5fc7d1
feat: add rich job observability with structured telemetry and event …
nigel-dev Feb 15, 2026
cd53752
fix: add captureExitStatus to z-workflows tmux mock
nigel-dev Feb 15, 2026
91cb148
feat: add dynamic orchestration with replanning, inter-job comms, ses…
nigel-dev Feb 15, 2026
36294ba
fix: add fixBeforeRollbackTimeout to config round-trip test
nigel-dev Feb 15, 2026
eb2506f
feat: add permission policy engine with configurable per-job/per-plan…
nigel-dev Feb 15, 2026
2c32e5e
test: add comprehensive coverage for dynamic orchestration features (…
nigel-dev Feb 15, 2026
17e6a4c
docs: add v1.5 serve mode, observability, notifications, permissions,…
nigel-dev Feb 15, 2026
8d31e8e
fix: prevent race between cancelPlan and async job completion handlers
nigel-dev Feb 15, 2026
f4b137f
fix: forward model info to SDK in serve mode launch path
nigel-dev Feb 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Guide for coding agents working on `opencode-mission-control` in ULW mode.

> NOTE! Never replace the string opencode with claude or claude code unless explicitly asked to.

## Mission

- Keep changes small, safe, and shippable.
Expand Down
386 changes: 364 additions & 22 deletions MANUAL_TEST_PLAN.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
},
"dependencies": {
"@opencode-ai/plugin": "^1.0.85",
"@opencode-ai/sdk": "^1.1.53",
"zod": "^4.3.6"
},
"devDependencies": {
Expand Down
98 changes: 97 additions & 1 deletion src/hooks/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,93 @@ interface SetupNotificationsOptions {
isSubagent: () => Promise<boolean>;
}

interface SessionTitleState {
originalTitle: string;
annotations: Map<string, string>; // jobName → status text ("done", "failed", "needs input")
}

const titleState = new Map<string, SessionTitleState>();

function extractSessionTitle(response: unknown): string | undefined {
if (!response || typeof response !== 'object') return undefined;
const obj = response as Record<string, unknown>;

// SDK may wrap in { data: { ... } } or return flat
if (obj.data && typeof obj.data === 'object') {
const data = obj.data as Record<string, unknown>;
if (typeof data.title === 'string') return data.title;
}

if (typeof obj.title === 'string') return obj.title;

return undefined;
}

function buildAnnotatedTitle(state: SessionTitleState): string {
if (state.annotations.size === 0) return state.originalTitle;
if (state.annotations.size === 1) {
const [[jobName, statusText]] = [...state.annotations.entries()];
return `${jobName} ${statusText}`;
}
return `${state.annotations.size} jobs need attention`;
}

export async function annotateSessionTitle(
client: Client,
sessionID: string,
jobName: string,
statusText: string,
): Promise<void> {
if (!sessionID || !sessionID.startsWith('ses')) return;

try {
if (!titleState.has(sessionID)) {
const session = await client.session.get({ path: { id: sessionID } });
const originalTitle = extractSessionTitle(session) ?? '';
titleState.set(sessionID, {
originalTitle,
annotations: new Map(),
});
}

const state = titleState.get(sessionID)!;
state.annotations.set(jobName, statusText);
const annotatedTitle = buildAnnotatedTitle(state);

await client.session.update({
path: { id: sessionID },
body: { title: annotatedTitle },
});
} catch {
// Fire-and-forget: don't block on title update failures
}
}

export async function resetSessionTitle(client: Client, sessionID: string): Promise<void> {
const state = titleState.get(sessionID);
if (!state) return;

const originalTitle = state.originalTitle;
titleState.delete(sessionID);

try {
await client.session.update({
path: { id: sessionID },
body: { title: originalTitle },
});
} catch {
// Fire-and-forget: don't block on title reset failures
}
}

export function hasAnnotation(sessionID: string): boolean {
return titleState.has(sessionID);
}

// Exposed for testing only
export function _getTitleStateForTesting(): Map<string, SessionTitleState> {
return titleState;
}

async function sendMessage(client: Client, sessionID: string, text: string): Promise<void> {
await client.session.prompt({
Expand Down Expand Up @@ -58,7 +144,7 @@ export function setupNotifications(options: SetupNotificationsOptions): void {
// If detection fails, continue sending (safer default)
}

const sessionID = await getActiveSessionID();
const sessionID = job.launchSessionID ?? await getActiveSessionID();
if (!sessionID || !sessionID.startsWith('ses')) {
return;
}
Expand All @@ -82,6 +168,16 @@ export function setupNotifications(options: SetupNotificationsOptions): void {

await sendMessage(client, sessionID, message);
sent.add(dedupKey);

const titleAnnotationMap: Partial<Record<NotificationEvent, string>> = {
complete: 'done',
failed: 'failed',
awaiting_input: 'needs input',
};
const statusText = titleAnnotationMap[event];
if (statusText) {
await annotateSessionTitle(client, sessionID, job.name, statusText);
}
};

const enqueue = (event: NotificationEvent, job: Job): void => {
Expand Down
12 changes: 9 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Plugin } from '@opencode-ai/plugin';
import { getSharedMonitor, setSharedNotifyCallback, getSharedNotifyCallback, setSharedOrchestrator } from './lib/orchestrator-singleton';
import { getCompactionContext, getJobCompactionContext } from './hooks/compaction';
import { shouldShowAutoStatus, getAutoStatusMessage } from './hooks/auto-status';
import { setupNotifications } from './hooks/notifications';
import { setupNotifications, hasAnnotation, resetSessionTitle } from './hooks/notifications';
import { registerCommands, createCommandHandler } from './commands';
import { isTmuxAvailable } from './lib/tmux';
import { loadPlan } from './lib/plan-state';
Expand Down Expand Up @@ -175,9 +175,9 @@ export const MissionControl: Plugin = async ({ client }) => {

let notifyPending: Promise<void> = Promise.resolve();
if (!isJobAgent) {
setSharedNotifyCallback((message: string) => {
setSharedNotifyCallback((message: string, targetSessionID?: string) => {
notifyPending = notifyPending.then(async () => {
const sessionID = await getActiveSessionID();
const sessionID = targetSessionID ?? await getActiveSessionID();
if (!sessionID || !sessionID.startsWith('ses')) return;
await client.session.prompt({
path: { id: sessionID },
Expand Down Expand Up @@ -221,12 +221,18 @@ export const MissionControl: Plugin = async ({ client }) => {
'command.execute.before': (input: { command: string; sessionID: string; arguments: string }, output: { parts: unknown[] }) => {
if (isValidSessionID(input.sessionID)) {
activeSessionID = input.sessionID;
if (hasAnnotation(input.sessionID)) {
resetSessionTitle(client, input.sessionID).catch(() => {});
}
}
return createCommandHandler(client)(input, output);
},
'tool.execute.before': async (input: { sessionID?: string; [key: string]: unknown }) => {
if (input.sessionID && isValidSessionID(input.sessionID)) {
activeSessionID = input.sessionID;
if (hasAnnotation(input.sessionID)) {
resetSessionTitle(client, input.sessionID).catch(() => {});
}
}
},
'chat.message': async (input) => {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { homedir } from 'os';
import { z } from 'zod';
import { getDataDir } from './paths';
import { MCConfigSchema, PartialMCConfigSchema } from './schemas';
import { PermissionPolicy } from './permission-policy';
import { atomicWrite } from './utils';

export type WorktreeSetup = {
Expand All @@ -22,6 +23,11 @@ const DEFAULT_CONFIG: MCConfig = {
autoCommit: true,
testTimeout: 600000,
mergeStrategy: 'squash',
useServeMode: true,
portRangeStart: 14100,
portRangeEnd: 14199,
fixBeforeRollbackTimeout: 120000,
defaultPermissionPolicy: PermissionPolicy.getDefaultPolicy(),
omo: {
enabled: false,
defaultMode: 'vanilla',
Expand Down
143 changes: 143 additions & 0 deletions src/lib/job-comms.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { JobSpec } from './plan-types';
import { sendPrompt, waitForServer } from './sdk-client';

export interface RelayContext {
finding: string;
filePath?: string;
lineNumber?: number;
severity?: 'info' | 'warning' | 'error';
}

export interface RelayMessage {
from: string;
to: string;
context: RelayContext;
timestamp: string;
}

export class JobComms {
private messageBus: Map<string, RelayMessage[]> = new Map();
private relayPatterns: Map<string, Bun.Glob[]> = new Map();
private relayPatternSources: Map<string, string[]> = new Map();

registerJob(job: JobSpec): void {
if (job.relayPatterns && job.relayPatterns.length > 0) {
const patterns: Bun.Glob[] = [];
const sources: string[] = [];
for (const pattern of job.relayPatterns) {
const normalized = pattern.endsWith('/') ? `${pattern}**` : pattern;
patterns.push(new Bun.Glob(normalized));
sources.push(pattern);
}
this.relayPatterns.set(job.name, patterns);
this.relayPatternSources.set(job.name, sources);
}
if (!this.messageBus.has(job.name)) {
this.messageBus.set(job.name, []);
}
}

unregisterJob(jobName: string): void {
this.relayPatterns.delete(jobName);
this.relayPatternSources.delete(jobName);
this.messageBus.delete(jobName);
}

relayFinding(from: string, to: string, context: RelayContext): void {
const message: RelayMessage = {
from,
to,
context,
timestamp: new Date().toISOString(),
};

const messages = this.messageBus.get(to) ?? [];
messages.push(message);
this.messageBus.set(to, messages);
}

getMessagesForJob(jobName: string): RelayMessage[] {
return this.messageBus.get(jobName) ?? [];
}

clearMessagesForJob(jobName: string): void {
this.messageBus.set(jobName, []);
}

shouldRelayForFile(jobName: string, filePath: string): boolean {
const patterns = this.relayPatterns.get(jobName);
if (!patterns || patterns.length === 0) {
return false;
}
return patterns.some((pattern) => pattern.match(filePath));
}

async deliverMessages(
job: JobSpec,
options?: { filterFrom?: string[] },
): Promise<number> {
const messages = this.getMessagesForJob(job.name);
if (messages.length === 0) {
return 0;
}

const filtered = options?.filterFrom
? messages.filter((m) => options.filterFrom!.includes(m.from))
: messages;

if (filtered.length === 0) {
return 0;
}

if (!job.port) {
return 0;
}

try {
const client = await waitForServer(job.port, { timeoutMs: 5000 });

for (const message of filtered) {
const prompt = this.formatRelayPrompt(message);
await sendPrompt(client, job.launchSessionID ?? '', prompt);
}

this.clearMessagesForJob(job.name);
return filtered.length;
} catch {
return 0;
}
}

private formatRelayPrompt(message: RelayMessage): string {
const { from, context } = message;
const { finding, filePath, lineNumber, severity } = context;

const parts: string[] = [`[Inter-Job Communication from ${from}]`];

if (severity) {
parts.push(`Severity: ${severity.toUpperCase()}`);
}

parts.push(`Finding: ${finding}`);

if (filePath) {
parts.push(`File: ${filePath}`);
}

if (lineNumber) {
parts.push(`Line: ${lineNumber}`);
}

parts.push('\nConsider how this finding may affect your current work.');

return parts.join('\n');
}

getAllRegisteredJobs(): string[] {
return Array.from(this.messageBus.keys());
}

getRelayPatternsForJob(jobName: string): string[] | undefined {
return this.relayPatternSources.get(jobName);
}
}
Loading