diff --git a/src/main/index.ts b/src/main/index.ts
index e5bdb94b..5025e682 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -88,7 +88,9 @@ import {
} from '@main/services/team/TeamMcpConfigBuilder';
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
import { killTrackedCliProcesses } from '@main/utils/childProcess';
+import { getWindowsElevationStatus } from '@main/utils/windowsElevation';
import {
+ APP_GET_WINDOWS_ELEVATION_STATUS,
APP_STARTUP_GET_STATUS,
APP_STARTUP_PROGRESS,
CONTEXT_CHANGED,
@@ -147,6 +149,7 @@ import { clearAutoResumeService } from './services/team/AutoResumeService';
import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer';
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient';
+import { OpenCodeBridgeDiagnosticsStore } from './services/team/opencode/bridge/OpenCodeBridgeDiagnosticsStore';
import {
createOpenCodeBridgeCommandLeaseStore,
createOpenCodeBridgeCommandLedgerStore,
@@ -391,6 +394,10 @@ async function createOpenCodeRuntimeAdapterRegistry(
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
const useHttpMcpBridge = isOpenCodeMcpHttpBridgeEnabled(bridgeEnv);
const explicitLocalMcpLaunchEnv = snapshotOpenCodeLocalMcpLaunchEnv(bridgeEnv);
+ delete bridgeEnv.ELECTRON_RUN_AS_NODE;
+ if (explicitLocalMcpLaunchEnv) {
+ copyOpenCodeLocalMcpLaunchEnv(explicitLocalMcpLaunchEnv, bridgeEnv);
+ }
delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
const applyMcpLaunchSpecEnv = async (
targetEnv: NodeJS.ProcessEnv,
@@ -410,6 +417,9 @@ async function createOpenCodeRuntimeAdapterRegistry(
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry;
targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args);
+ targetEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON = JSON.stringify(
+ mcpLaunchSpec.env ?? {}
+ );
}
} catch (error) {
logger.warn(
@@ -515,13 +525,16 @@ async function createOpenCodeRuntimeAdapterRegistry(
}
return nextEnv;
};
+ const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
const bridgeClient = new OpenCodeBridgeCommandClient({
binaryPath,
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
env: bridgeEnv,
envProvider: resolveBridgeCommandEnv,
+ diagnostics: new OpenCodeBridgeDiagnosticsStore({
+ directory: join(bridgeControlDir, 'diagnostics'),
+ }),
});
- const bridgeControlDir = join(app.getPath('userData'), 'opencode-bridge');
const clientIdentity = createOpenCodeBridgeClientIdentity({
appVersion: typeof app.getVersion === 'function' ? app.getVersion() : '1.3.0',
gitSha: process.env.VITE_GIT_SHA ?? process.env.GIT_SHA ?? null,
@@ -546,6 +559,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
});
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
stateChangingCommands,
+ appVersion: clientIdentity.appVersion,
});
openCodeLifecycleBridge = readinessBridge;
return new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(readinessBridge)]);
@@ -963,6 +977,7 @@ function registerAppStartupHandlers(): void {
appStartupHandlersRegistered = true;
registerRendererLogHandlers(ipcMain);
ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus);
+ ipcMain.handle(APP_GET_WINDOWS_ELEVATION_STATUS, () => getWindowsElevationStatus());
}
function cloneStartupSteps(): AppStartupStep[] {
diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts
index ada20d67..de7e0a6b 100644
--- a/src/main/ipc/teams.ts
+++ b/src/main/ipc/teams.ts
@@ -97,7 +97,6 @@ import {
import { wrapAgentBlock } from '@shared/constants/agentBlocks';
import { KANBAN_COLUMN_IDS } from '@shared/constants/kanban';
import { MAX_TEXT_LENGTH } from '@shared/constants/teamLimits';
-import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
import {
extractFlagsFromHelp,
extractUserFlags,
@@ -111,7 +110,6 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
-import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import {
buildStandaloneSlashCommandMeta,
parseStandaloneSlashCommand,
@@ -133,7 +131,6 @@ import {
import {
getAutoResumeService,
initializeAutoResumeService,
- planRateLimitAutoResume,
} from '../services/team/AutoResumeService';
import {
cloneLaunchIoGovernorPayload,
@@ -156,6 +153,7 @@ import {
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import { TeamWorktreeGitService } from '../services/team/TeamWorktreeGitService';
+import { teamMessageNotificationScanner } from './teams/teamMessageNotificationScanner';
import {
validateFromField,
validateMemberName,
@@ -301,14 +299,6 @@ function validateTeamGetDataOptions(
};
}
-/**
- * In-memory set of rate-limit message keys already processed.
- * Independent of NotificationManager storage — survives notification deletion/pruning.
- * Without this, deleted rate-limit notifications would re-appear on next getData() scan.
- */
-const seenRateLimitKeys = new Set
();
-const SEEN_RATE_LIMIT_KEYS_MAX = 500;
-
async function withTimeoutValue(
promise: Promise,
timeoutMs: number,
@@ -442,178 +432,6 @@ function buildLeadDirectDelegateAckBlock(actionMode?: AgentActionMode): string |
);
}
-/**
- * In-memory set of API error message keys already processed.
- * Independent of NotificationManager storage — survives notification deletion/pruning.
- */
-const seenApiErrorKeys = new Set();
-const SEEN_API_ERROR_KEYS_MAX = 500;
-
-function formatNotificationClockTime(date: Date): string {
- return new Intl.DateTimeFormat(undefined, {
- hour: '2-digit',
- minute: '2-digit',
- hour12: false,
- }).format(date);
-}
-
-function buildRateLimitNotificationBody(plan: ReturnType): string {
- if (plan.kind === 'scheduled') {
- return `Auto-resume scheduled at ${formatNotificationClockTime(new Date(plan.fireAtMs))}`;
- }
- return 'Manual restart needed';
-}
-
-/**
- * Check messages for rate limit indicators and fire notifications for new ones.
- * Uses both in-memory seenRateLimitKeys (to prevent resurrection after deletion)
- * and NotificationManager dedupeKey (to prevent storage duplicates).
- */
-function checkRateLimitMessages(
- messages: readonly {
- messageId?: string;
- from: string;
- text: string;
- timestamp: string;
- to?: string;
- source?: string;
- leadSessionId?: string;
- }[],
- teamName: string,
- teamDisplayName: string,
- projectPath?: string,
- teamIsAlive = true,
- currentLeadSessionId: string | null = null
-): void {
- const observedAt = new Date();
- const autoResumeEnabled =
- ConfigManager.getInstance().getConfig().notifications.autoResumeOnRateLimit;
-
- for (const msg of messages) {
- if (msg.from === 'user') continue;
- if (!isRateLimitMessage(msg.text)) continue;
-
- const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
- const dedupeKey = `rate-limit:${teamName}:${rawKey}`;
- const isLeadAutoResumeCandidate =
- !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
- const autoResumeSessionMatches =
- msg.source !== 'lead_session' ||
- (Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
- const autoResumePlan = planRateLimitAutoResume({
- enabled: autoResumeEnabled,
- canAutoResume: teamIsAlive && isLeadAutoResumeCandidate && autoResumeSessionMatches,
- messageText: msg.text,
- observedAt,
- messageTimestamp: new Date(msg.timestamp),
- });
-
- // In-memory guard: prevents resurrection after user deletes the notification.
- if (!seenRateLimitKeys.has(dedupeKey)) {
- seenRateLimitKeys.add(dedupeKey);
-
- // Evict oldest entries to prevent unbounded growth
- if (seenRateLimitKeys.size > SEEN_RATE_LIMIT_KEYS_MAX) {
- const first = seenRateLimitKeys.values().next().value;
- if (first) seenRateLimitKeys.delete(first);
- }
-
- void NotificationManager.getInstance()
- .addTeamNotification({
- teamEventType: 'rate_limit',
- teamName,
- teamDisplayName,
- from: msg.from,
- summary: 'Rate limit',
- body: buildRateLimitNotificationBody(autoResumePlan),
- dedupeKey,
- target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
- projectPath,
- })
- .catch(() => undefined);
- }
-
- // Only schedule auto-resume while a live team run currently exists.
- // Persisted history for an offline/stopped team may still contain the old
- // rate-limit message, but arming a new timer from that stale history would
- // resurrect the nudge into a later manual restart.
- if (autoResumePlan.kind === 'scheduled') {
- // Only let persisted lead_session history rebuild auto-resume when it
- // clearly belongs to the currently running lead session. Otherwise an old
- // rate-limit from a previous manual run can resurrect into a newer restart.
- // Pass the original message timestamp so relative reset windows survive restarts
- // and old history does not rebuild a fresh auto-resume timer from "now".
- getAutoResumeService().handleRateLimitMessage(
- teamName,
- msg.text,
- observedAt,
- new Date(msg.timestamp)
- );
- }
- }
-}
-
-/**
- * Check messages for API errors (e.g. "API Error: 429 ...") and fire OS notifications.
- * Mirrors the rate-limit approach: in-memory dedup + NotificationManager dedupeKey.
- * Skips rate-limit messages (they have their own notification path).
- */
-function checkApiErrorMessages(
- messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
- teamName: string,
- teamDisplayName: string,
- projectPath?: string
-): void {
- for (const msg of messages) {
- if (msg.from === 'user') continue;
- if (!isApiErrorMessage(msg.text)) continue;
- // Don't double-notify if it's also a rate limit message
- if (isRateLimitMessage(msg.text)) continue;
-
- const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
- const dedupeKey = `api-error:${teamName}:${rawKey}`;
-
- if (seenApiErrorKeys.has(dedupeKey)) continue;
- seenApiErrorKeys.add(dedupeKey);
-
- if (seenApiErrorKeys.size > SEEN_API_ERROR_KEYS_MAX) {
- const first = seenApiErrorKeys.values().next().value;
- if (first) seenApiErrorKeys.delete(first);
- }
-
- // Extract status code for summary
- const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text);
- const statusCode = statusMatch?.[1] ?? '???';
-
- void NotificationManager.getInstance()
- .addTeamNotification({
- teamEventType: 'api_error',
- teamName,
- teamDisplayName,
- from: msg.from,
- summary: `API Error ${statusCode}`,
- body: 'Manual restart needed',
- dedupeKey,
- target: { kind: 'member', teamName, memberName: msg.from, focus: 'logs' },
- projectPath,
- })
- .catch(() => undefined);
- }
-}
-
-function scanTeamMessageNotifications(
- messages: readonly { messageId?: string; from: string; text: string; timestamp: string }[],
- teamName: string,
- teamDisplayName: string,
- projectPath?: string
-): void {
- if (messages.length === 0) {
- return;
- }
- checkRateLimitMessages(messages, teamName, teamDisplayName, projectPath);
- checkApiErrorMessages(messages, teamName, teamDisplayName, projectPath);
-}
-
let teamDataService: TeamDataService | null = null;
let teamProvisioningService: TeamProvisioningService | null = null;
let teamMemberLogsFinder: TeamMemberLogsFinder | null = null;
@@ -1145,17 +963,24 @@ async function handleGetData(
if (live.length === 0) {
if (durableMessages.length > 0) {
- checkRateLimitMessages(
- durableMessages,
- tn,
- displayName,
+ teamMessageNotificationScanner.checkRateLimitMessages(durableMessages, {
+ teamName: tn,
+ teamDisplayName: displayName,
projectPath,
- isAlive,
- currentLeadSessionId
- );
- checkApiErrorMessages(durableMessages, tn, displayName, projectPath);
+ teamIsAlive: isAlive,
+ currentLeadSessionId,
+ });
+ teamMessageNotificationScanner.checkApiErrorMessages(durableMessages, {
+ teamName: tn,
+ teamDisplayName: displayName,
+ projectPath,
+ });
} else {
- scanTeamMessageNotifications(live, tn, displayName, projectPath);
+ teamMessageNotificationScanner.scan(live, {
+ teamName: tn,
+ teamDisplayName: displayName,
+ projectPath,
+ });
}
return { success: true, data: { ...data, isAlive } };
}
@@ -1177,8 +1002,18 @@ async function handleGetData(
}
}
- checkRateLimitMessages(merged, tn, displayName, projectPath, isAlive, currentLeadSessionId);
- checkApiErrorMessages(merged, tn, displayName, projectPath);
+ teamMessageNotificationScanner.checkRateLimitMessages(merged, {
+ teamName: tn,
+ teamDisplayName: displayName,
+ projectPath,
+ teamIsAlive: isAlive,
+ currentLeadSessionId,
+ });
+ teamMessageNotificationScanner.checkApiErrorMessages(merged, {
+ teamName: tn,
+ teamDisplayName: displayName,
+ projectPath,
+ });
return { success: true, data: { ...data, isAlive } };
}
@@ -2844,12 +2679,11 @@ async function handleGetMessagesPage(
.catch(() => ({ displayName: teamName }));
void notificationContextPromise
.then((notificationContext) => {
- scanTeamMessageNotifications(
- messagesPage.messages,
+ teamMessageNotificationScanner.scan(messagesPage.messages, {
teamName,
- notificationContext.displayName,
- notificationContext.projectPath
- );
+ teamDisplayName: notificationContext.displayName,
+ projectPath: notificationContext.projectPath,
+ });
})
.catch((error: unknown) => {
logger.debug(
diff --git a/src/main/ipc/teams/teamMessageNotificationScanner.ts b/src/main/ipc/teams/teamMessageNotificationScanner.ts
new file mode 100644
index 00000000..e974c0fe
--- /dev/null
+++ b/src/main/ipc/teams/teamMessageNotificationScanner.ts
@@ -0,0 +1,240 @@
+import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
+import { NotificationManager } from '@main/services/infrastructure/NotificationManager';
+import {
+ getAutoResumeService,
+ planRateLimitAutoResume,
+ type RateLimitAutoResumePlan,
+} from '@main/services/team/AutoResumeService';
+import { isApiErrorMessage } from '@shared/utils/apiErrorDetector';
+import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
+
+import type { TeamNotificationPayload } from '@main/utils/teamNotificationBuilder';
+
+export interface TeamNotificationMessage {
+ messageId?: string;
+ from: string;
+ text: string;
+ timestamp: string;
+ to?: string;
+ source?: string;
+ leadSessionId?: string;
+}
+
+interface TeamNotificationSink {
+ addTeamNotification(payload: TeamNotificationPayload): Promise;
+}
+
+interface AutoResumeSink {
+ handleRateLimitMessage(
+ teamName: string,
+ messageText: string,
+ observedAt: Date,
+ messageTimestamp: Date
+ ): void;
+}
+
+interface ConfigReader {
+ getConfig(): {
+ notifications: {
+ autoResumeOnRateLimit: boolean;
+ };
+ };
+}
+
+export interface TeamMessageNotificationScannerDeps {
+ configReader?: ConfigReader;
+ notificationSink?: TeamNotificationSink;
+ autoResumeSink?: AutoResumeSink;
+ planAutoResume?: typeof planRateLimitAutoResume;
+ isRateLimit?: (text: string) => boolean;
+ isApiError?: (text: string) => boolean;
+ now?: () => Date;
+ formatClockTime?: (date: Date) => string;
+}
+
+export interface TeamMessageNotificationContext {
+ teamName: string;
+ teamDisplayName: string;
+ projectPath?: string;
+ teamIsAlive?: boolean;
+ currentLeadSessionId?: string | null;
+}
+
+const SEEN_RATE_LIMIT_KEYS_MAX = 500;
+const SEEN_API_ERROR_KEYS_MAX = 500;
+
+function formatNotificationClockTime(date: Date): string {
+ return new Intl.DateTimeFormat(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ }).format(date);
+}
+
+function buildRateLimitNotificationBody(
+ plan: RateLimitAutoResumePlan,
+ formatClockTime: (date: Date) => string
+): string {
+ if (plan.kind === 'scheduled') {
+ return `Auto-resume scheduled at ${formatClockTime(new Date(plan.fireAtMs))}`;
+ }
+ return 'Manual restart needed';
+}
+
+function evictOldestIfNeeded(keys: Set, maxSize: number): void {
+ if (keys.size <= maxSize) {
+ return;
+ }
+
+ const first = keys.values().next().value;
+ if (first) {
+ keys.delete(first);
+ }
+}
+
+function createDefaultNotificationSink(): TeamNotificationSink {
+ return {
+ addTeamNotification: (payload) => NotificationManager.getInstance().addTeamNotification(payload),
+ };
+}
+
+export class TeamMessageNotificationScanner {
+ readonly #seenRateLimitKeys = new Set();
+ readonly #seenApiErrorKeys = new Set();
+ readonly #configReader: ConfigReader;
+ readonly #notificationSink: TeamNotificationSink;
+ readonly #planAutoResume: typeof planRateLimitAutoResume;
+ readonly #isRateLimit: (text: string) => boolean;
+ readonly #isApiError: (text: string) => boolean;
+ readonly #now: () => Date;
+ readonly #formatClockTime: (date: Date) => string;
+ readonly #autoResumeSink: AutoResumeSink | null;
+
+ constructor(deps: TeamMessageNotificationScannerDeps = {}) {
+ this.#configReader = deps.configReader ?? ConfigManager.getInstance();
+ this.#notificationSink = deps.notificationSink ?? createDefaultNotificationSink();
+ this.#planAutoResume = deps.planAutoResume ?? planRateLimitAutoResume;
+ this.#isRateLimit = deps.isRateLimit ?? isRateLimitMessage;
+ this.#isApiError = deps.isApiError ?? isApiErrorMessage;
+ this.#now = deps.now ?? (() => new Date());
+ this.#formatClockTime = deps.formatClockTime ?? formatNotificationClockTime;
+ this.#autoResumeSink = deps.autoResumeSink ?? null;
+ }
+
+ checkRateLimitMessages(
+ messages: readonly TeamNotificationMessage[],
+ context: TeamMessageNotificationContext
+ ): void {
+ const observedAt = this.#now();
+ const autoResumeEnabled = this.#configReader.getConfig().notifications.autoResumeOnRateLimit;
+
+ for (const msg of messages) {
+ if (msg.from === 'user') continue;
+ if (!this.#isRateLimit(msg.text)) continue;
+
+ const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
+ const dedupeKey = `rate-limit:${context.teamName}:${rawKey}`;
+ const isLeadAutoResumeCandidate =
+ !msg.to && (msg.source === 'lead_process' || msg.source === 'lead_session');
+ const currentLeadSessionId = context.currentLeadSessionId ?? null;
+ const autoResumeSessionMatches =
+ msg.source !== 'lead_session' ||
+ (Boolean(currentLeadSessionId) && msg.leadSessionId === currentLeadSessionId);
+ const autoResumePlan = this.#planAutoResume({
+ enabled: autoResumeEnabled,
+ canAutoResume:
+ (context.teamIsAlive ?? true) &&
+ isLeadAutoResumeCandidate &&
+ autoResumeSessionMatches,
+ messageText: msg.text,
+ observedAt,
+ messageTimestamp: new Date(msg.timestamp),
+ });
+
+ if (!this.#seenRateLimitKeys.has(dedupeKey)) {
+ this.#seenRateLimitKeys.add(dedupeKey);
+ evictOldestIfNeeded(this.#seenRateLimitKeys, SEEN_RATE_LIMIT_KEYS_MAX);
+
+ void this.#notificationSink
+ .addTeamNotification({
+ teamEventType: 'rate_limit',
+ teamName: context.teamName,
+ teamDisplayName: context.teamDisplayName,
+ from: msg.from,
+ summary: 'Rate limit',
+ body: buildRateLimitNotificationBody(autoResumePlan, this.#formatClockTime),
+ dedupeKey,
+ target: {
+ kind: 'member',
+ teamName: context.teamName,
+ memberName: msg.from,
+ focus: 'logs',
+ },
+ projectPath: context.projectPath,
+ })
+ .catch(() => undefined);
+ }
+
+ if (autoResumePlan.kind === 'scheduled') {
+ const autoResumeSink = this.#autoResumeSink ?? getAutoResumeService();
+ autoResumeSink.handleRateLimitMessage(
+ context.teamName,
+ msg.text,
+ observedAt,
+ new Date(msg.timestamp)
+ );
+ }
+ }
+ }
+
+ checkApiErrorMessages(
+ messages: readonly TeamNotificationMessage[],
+ context: TeamMessageNotificationContext
+ ): void {
+ for (const msg of messages) {
+ if (msg.from === 'user') continue;
+ if (!this.#isApiError(msg.text)) continue;
+ if (this.#isRateLimit(msg.text)) continue;
+
+ const rawKey = msg.messageId ?? `${msg.from}:${msg.timestamp}`;
+ const dedupeKey = `api-error:${context.teamName}:${rawKey}`;
+
+ if (this.#seenApiErrorKeys.has(dedupeKey)) continue;
+ this.#seenApiErrorKeys.add(dedupeKey);
+ evictOldestIfNeeded(this.#seenApiErrorKeys, SEEN_API_ERROR_KEYS_MAX);
+
+ const statusMatch = /^API Error:\s*(\d{3})/.exec(msg.text);
+ const statusCode = statusMatch?.[1] ?? '???';
+
+ void this.#notificationSink
+ .addTeamNotification({
+ teamEventType: 'api_error',
+ teamName: context.teamName,
+ teamDisplayName: context.teamDisplayName,
+ from: msg.from,
+ summary: `API Error ${statusCode}`,
+ body: 'Manual restart needed',
+ dedupeKey,
+ target: {
+ kind: 'member',
+ teamName: context.teamName,
+ memberName: msg.from,
+ focus: 'logs',
+ },
+ projectPath: context.projectPath,
+ })
+ .catch(() => undefined);
+ }
+ }
+
+ scan(messages: readonly TeamNotificationMessage[], context: TeamMessageNotificationContext): void {
+ if (messages.length === 0) {
+ return;
+ }
+
+ this.checkRateLimitMessages(messages, context);
+ this.checkApiErrorMessages(messages, context);
+ }
+}
+
+export const teamMessageNotificationScanner = new TeamMessageNotificationScanner();
diff --git a/src/main/services/infrastructure/FileWatcher.ts b/src/main/services/infrastructure/FileWatcher.ts
index e22a60a7..c318debc 100644
--- a/src/main/services/infrastructure/FileWatcher.ts
+++ b/src/main/services/infrastructure/FileWatcher.ts
@@ -435,6 +435,10 @@ export class FileWatcher extends EventEmitter {
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
+ // Teams deliberately use TeamTaskWatchRegistry instead of recursive fs.watch.
+ // Linux recursive watching expands across the whole team runtime tree and can
+ // hit EMFILE/ENOSPC. The registry keeps the watched surface aligned with
+ // processTeamsChange(): team root JSON files plus inbox JSON files only.
const registry = new TeamTaskWatchRegistry({
kind: 'teams',
rootPath: this.teamsPath,
@@ -479,6 +483,8 @@ export class FileWatcher extends EventEmitter {
// Guard: stop() may have been called while awaiting pathExists
if (!this.isWatching) return;
+ // Tasks share the same shallow registry rule as teams. Keep polling out of
+ // the normal path here; it is only the known-error fallback below.
const registry = new TeamTaskWatchRegistry({
kind: 'tasks',
rootPath: this.tasksPath,
@@ -647,6 +653,9 @@ export class FileWatcher extends EventEmitter {
error: unknown,
watcher?: CloseableWatcher
): boolean {
+ // Polling fallback is intentionally narrow. Projects/todos keep their native
+ // watcher retry behavior, while teams/tasks can switch to scoped polling only
+ // after known OS watcher-limit or platform errors from Chokidar/fs.watch.
if ((watcherType !== 'teams' && watcherType !== 'tasks') || !this.isWatchLimitError(error)) {
return false;
}
@@ -721,6 +730,8 @@ export class FileWatcher extends EventEmitter {
};
runPoll();
+ // This is fallback content polling after watcher failure, not the default mode.
+ // Keep intervals conservative and scoped to the same shallow artifacts as the registry.
const timer = setInterval(runPoll, this.getTeamTaskPollIntervalMs(watcherType));
timer.unref();
@@ -799,6 +810,8 @@ export class FileWatcher extends EventEmitter {
const snapshot = new Map();
const teamEntries = await this.safeReadDir(this.teamsPath);
+ // Fallback polling mirrors TeamTaskWatchRegistry. Do not recurse into members,
+ // runtime, .opencode-runtime, logs, or other deep trees from here.
for (const teamEntry of teamEntries) {
if (!teamEntry.isDirectory()) {
continue;
@@ -825,6 +838,8 @@ export class FileWatcher extends EventEmitter {
const snapshot = new Map();
const teamEntries = await this.safeReadDir(this.tasksPath);
+ // Keep task fallback scoped to tasks//*.json. Hidden files and nested
+ // runtime directories are intentionally outside the public team-change surface.
for (const teamEntry of teamEntries) {
if (!teamEntry.isDirectory()) {
continue;
@@ -1351,6 +1366,9 @@ export class FileWatcher extends EventEmitter {
return;
}
+ // Keep this classifier in lockstep with TeamTaskWatchRegistry.shouldEmit().
+ // If a path is emitted by the registry but ignored here, the UI will miss it.
+ // If a path is added here but not emitted there, Chokidar mode will never see it.
if (relative === 'processes.json') {
const event: TeamChangeEvent = { type: 'process', teamName, detail: relative };
this.emit('team-change', event);
@@ -1414,6 +1432,8 @@ export class FileWatcher extends EventEmitter {
return;
}
+ // Keep this in sync with the tasks registry and fallback polling filters:
+ // only tasks//*.json is a user-visible task event.
// Ignore known non-task files in ~/.claude/tasks
if (
relative === '.lock' ||
diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
index a01eef47..73f8d55c 100644
--- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
+++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts
@@ -23,8 +23,8 @@ import type {
const logger = createLogger('ClaudeMultimodelBridgeService');
-const PROVIDER_STATUS_TIMEOUT_MS = 25_000;
-const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 15_000;
+const PROVIDER_STATUS_TIMEOUT_MS = 90_000;
+const PROVIDER_STATUS_SUMMARY_TIMEOUT_MS = 30_000;
const PROVIDER_MODELS_TIMEOUT_MS = 25_000;
const PROVIDER_STATUS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
const PROVIDER_MODELS_MAX_BUFFER_BYTES = 8 * 1024 * 1024;
@@ -396,11 +396,21 @@ function createRuntimeStatusErrorProviderStatus(
error: unknown
): CliProviderStatus {
const message = error instanceof Error ? error.message : String(error);
+ const lower = message.toLowerCase();
+ const detailMessage =
+ providerId === 'opencode' && (lower.includes('timed out') || lower.includes('timeout'))
+ ? [
+ 'OpenCode runtime status did not return before the desktop timeout.',
+ 'This means the Agent Teams runtime process did not produce provider-status JSON in time, not necessarily that OpenCode auth is missing.',
+ 'Likely causes include slow or hung OpenCode CLI startup, provider/model inventory, local OpenCode plugins, cache/profile corruption, stale bundled runtime, or Windows security software delaying child processes.',
+ `Raw timeout detail: ${message}`,
+ ].join(' ')
+ : message;
return {
...createDefaultProviderStatus(providerId),
verificationState: 'error',
statusMessage: 'Provider status unavailable',
- detailMessage: message,
+ detailMessage,
};
}
@@ -985,8 +995,11 @@ export class ClaudeMultimodelBridgeService {
if (options.summary) {
args.push('--summary');
}
+ const timeout =
+ options.timeoutMs ??
+ (options.summary ? PROVIDER_STATUS_SUMMARY_TIMEOUT_MS : PROVIDER_STATUS_TIMEOUT_MS);
const { stdout } = await execCli(binaryPath, args, {
- timeout: options.timeoutMs ?? PROVIDER_STATUS_TIMEOUT_MS,
+ timeout,
maxBuffer: PROVIDER_STATUS_MAX_BUFFER_BYTES,
env,
});
diff --git a/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts b/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts
index 1efebad6..51b2446c 100644
--- a/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts
+++ b/src/main/services/runtime/agentTeamsMcpLaunchEnv.ts
@@ -8,6 +8,8 @@ const logger = createLogger('Runtime:AgentTeamsMcpLaunchEnv');
const MCP_COMMAND_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND';
const MCP_ENTRY_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY';
const MCP_ARGS_JSON_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON';
+const MCP_ENV_JSON_ENV = 'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENV_JSON';
+const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
export type AgentTeamsMcpLaunchEnv = Record;
@@ -17,11 +19,24 @@ export function hasAgentTeamsMcpLocalLaunchEnv(env: AgentTeamsMcpLaunchEnv): boo
);
}
+function ensureLegacyMcpChildEnvJson(env: AgentTeamsMcpLaunchEnv): void {
+ if (env[MCP_ENV_JSON_ENV]?.trim()) {
+ return;
+ }
+ const electronRunAsNode = env[ELECTRON_RUN_AS_NODE_ENV]?.trim();
+ if (electronRunAsNode) {
+ env[MCP_ENV_JSON_ENV] = JSON.stringify({
+ [ELECTRON_RUN_AS_NODE_ENV]: electronRunAsNode,
+ });
+ }
+}
+
export async function ensureAgentTeamsMcpLocalLaunchEnv(
env: AgentTeamsMcpLaunchEnv,
resolveLaunchSpec: () => Promise = resolveAgentTeamsMcpLaunchSpec
): Promise {
if (hasAgentTeamsMcpLocalLaunchEnv(env)) {
+ ensureLegacyMcpChildEnvJson(env);
return;
}
@@ -36,6 +51,7 @@ export async function ensureAgentTeamsMcpLocalLaunchEnv(
env[MCP_COMMAND_ENV] = command;
env[MCP_ENTRY_ENV] = entry;
env[MCP_ARGS_JSON_ENV] = JSON.stringify(launchSpec.args);
+ env[MCP_ENV_JSON_ENV] = JSON.stringify(launchSpec.env ?? {});
} catch (error) {
logger.warn(
`Unable to resolve Agent Teams MCP local launch env: ${
diff --git a/src/main/services/runtime/providerAwareCliEnv.ts b/src/main/services/runtime/providerAwareCliEnv.ts
index cedafaaa..a6bdd0ae 100644
--- a/src/main/services/runtime/providerAwareCliEnv.ts
+++ b/src/main/services/runtime/providerAwareCliEnv.ts
@@ -11,6 +11,7 @@ import { providerConnectionService } from './ProviderConnectionService';
import type { CliProviderId, TeamProviderId } from '@shared/types';
type ProviderEnvTargetId = CliProviderId | TeamProviderId | undefined;
+const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
export interface ProviderAwareCliEnvOptions {
binaryPath?: string | null;
@@ -29,6 +30,10 @@ export interface ProviderAwareCliEnvResult {
providerArgs: string[];
}
+function removeGlobalElectronRunAsNodeEnv(env: NodeJS.ProcessEnv): void {
+ delete env[ELECTRON_RUN_AS_NODE_ENV];
+}
+
export async function buildProviderAwareCliEnv(
options: ProviderAwareCliEnvOptions = {}
): Promise {
@@ -79,6 +84,7 @@ export async function buildProviderAwareCliEnv(
options.providerBackendId,
...storedApiKeyAccessArgs
);
+ removeGlobalElectronRunAsNodeEnv(env);
return {
env,
connectionIssues: {},
@@ -92,22 +98,25 @@ export async function buildProviderAwareCliEnv(
options.providerBackendId,
...storedApiKeyAccessArgs
);
+ removeGlobalElectronRunAsNodeEnv(env);
+ const providerArgs = await providerConnectionService.getConfiguredConnectionLaunchArgs(
+ env,
+ resolvedProviderId,
+ options.providerBackendId,
+ options.binaryPath
+ );
+ const connectionIssues = await providerConnectionService.getConfiguredConnectionIssues(
+ env,
+ [resolvedProviderId],
+ resolvedProviderId === 'codex' || resolvedProviderId === 'gemini'
+ ? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined }
+ : undefined
+ );
return {
env,
- providerArgs: await providerConnectionService.getConfiguredConnectionLaunchArgs(
- env,
- resolvedProviderId,
- options.providerBackendId,
- options.binaryPath
- ),
- connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(
- env,
- [resolvedProviderId],
- resolvedProviderId === 'codex' || resolvedProviderId === 'gemini'
- ? { [resolvedProviderId]: options.providerBackendId?.trim() || undefined }
- : undefined
- ),
+ providerArgs,
+ connectionIssues,
};
}
@@ -116,6 +125,7 @@ export async function buildProviderAwareCliEnv(
env,
...storedApiKeyAccessArgs
);
+ removeGlobalElectronRunAsNodeEnv(env);
return {
env,
connectionIssues: {},
@@ -124,9 +134,11 @@ export async function buildProviderAwareCliEnv(
}
await providerConnectionService.applyAllConfiguredConnectionEnv(env, ...storedApiKeyAccessArgs);
+ removeGlobalElectronRunAsNodeEnv(env);
+ const connectionIssues = await providerConnectionService.getConfiguredConnectionIssues(env);
return {
env,
- connectionIssues: await providerConnectionService.getConfiguredConnectionIssues(env),
+ connectionIssues,
providerArgs: [],
};
}
diff --git a/src/main/services/team/AgentTeamsMcpHttpServer.ts b/src/main/services/team/AgentTeamsMcpHttpServer.ts
index da4802cc..da29fe72 100644
--- a/src/main/services/team/AgentTeamsMcpHttpServer.ts
+++ b/src/main/services/team/AgentTeamsMcpHttpServer.ts
@@ -405,7 +405,12 @@ function buildStatePath(): string {
}
function buildLaunchSpecHash(launchSpec: McpLaunchSpec): string {
- return sha256Hex(JSON.stringify({ command: launchSpec.command, args: launchSpec.args }));
+ const env = launchSpec.env
+ ? Object.fromEntries(
+ Object.entries(launchSpec.env).sort(([left], [right]) => left.localeCompare(right))
+ )
+ : {};
+ return sha256Hex(JSON.stringify({ command: launchSpec.command, args: launchSpec.args, env }));
}
function buildExpectedIdentity(
@@ -1095,6 +1100,7 @@ export class AgentTeamsMcpHttpServer {
};
const childEnv = applyAgentTeamsIdentityEnv({
...process.env,
+ ...launchSpec.env,
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST,
diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts
index 27e3954c..fbac3aee 100644
--- a/src/main/services/team/TeamDataService.ts
+++ b/src/main/services/team/TeamDataService.ts
@@ -105,7 +105,7 @@ const logger = createLogger('Service:TeamDataService');
const MIN_TEXT_LENGTH = 30;
const MAX_LEAD_TEXTS = 150;
-const LEAD_SESSION_PARSE_CACHE_SCHEMA_VERSION = 'combined-v1';
+const LEAD_SESSION_PARSE_CACHE_SCHEMA_VERSION = 'combined-v2';
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
const TASK_MAP_YIELD_EVERY = 250;
const TASK_COMMENT_NOTIFICATION_SOURCE = 'system_notification';
@@ -3221,7 +3221,8 @@ export class TeamDataService {
const MAX_SCAN_BYTES = 8 * 1024 * 1024;
const INITIAL_SCAN_BYTES = 256 * 1024;
- const textsReversed: InboxMessage[] = [];
+ const rawLinesReversed: string[] = [];
+ const seenRawLines = new Set();
const seenMessageIds = new Set();
const handle = await fs.promises.open(jsonlPath, 'r');
try {
@@ -3229,7 +3230,7 @@ export class TeamDataService {
const fileSize = stat.size;
let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize);
- while (textsReversed.length < maxTexts && scanBytes <= MAX_SCAN_BYTES) {
+ while (scanBytes <= MAX_SCAN_BYTES) {
const start = Math.max(0, fileSize - scanBytes);
const buffer = Buffer.alloc(scanBytes);
await handle.read(buffer, 0, scanBytes, start);
@@ -3241,96 +3242,11 @@ export class TeamDataService {
for (let i = lines.length - 1; i >= fromIndex; i--) {
const trimmed = lines[i]?.trim();
if (!trimmed) continue;
-
- let msg: Record;
- try {
- msg = JSON.parse(trimmed) as Record;
- } catch {
- continue;
- }
-
- if (msg.type !== 'assistant') continue;
-
- const message = (msg.message ?? msg) as Record;
- const content = message.content;
- if (!Array.isArray(content)) continue;
-
- const timestamp =
- typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
-
- const textParts: string[] = [];
- for (const block of content as Record[]) {
- if (block.type !== 'text' || typeof block.text !== 'string') continue;
- textParts.push(block.text);
- }
- if (textParts.length === 0) continue;
-
- const combined = stripAgentBlocks(textParts.join('\n')).trim();
- if (combined.length < MIN_TEXT_LENGTH) continue;
-
- const toolCallsList: ToolCallMeta[] = [];
- const lookaheadLimit = Math.min(i + 200, lines.length);
- for (let j = i + 1; j < lookaheadLimit; j++) {
- const tLine = lines[j]?.trim();
- if (!tLine) continue;
- let tMsg: Record;
- try {
- tMsg = JSON.parse(tLine) as Record;
- } catch {
- continue;
- }
- if (tMsg.type !== 'assistant') continue;
- const tMessage = (tMsg.message ?? tMsg) as Record;
- const tContent = tMessage.content;
- if (!Array.isArray(tContent)) continue;
- const tBlocks = tContent as Record[];
- if (tBlocks.some((b) => b.type === 'text')) break;
- for (const b of tBlocks) {
- if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
- const input = (b.input ?? {}) as Record;
- toolCallsList.push({
- name: b.name,
- preview: extractToolPreview(b.name, input),
- });
- }
- }
- }
- const toolCalls = toolCallsList.length > 0 ? toolCallsList : undefined;
- const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
-
- const entryUuid = typeof msg.uuid === 'string' ? msg.uuid.trim() : '';
- const assistantMessageId = typeof message.id === 'string' ? message.id.trim() : '';
- const stableMessageId = entryUuid
- ? `lead-thought-${entryUuid}`
- : assistantMessageId
- ? `lead-thought-msg-${assistantMessageId}`
- : null;
-
- const textPrefix = combined
- .slice(0, 50)
- .replace(/[^\p{L}\p{N}]/gu, '')
- .slice(0, 20);
-
- const messageId =
- stableMessageId ?? `lead-session-${leadSessionId}-${timestamp}-${textPrefix}`;
- if (seenMessageIds.has(messageId)) continue;
- seenMessageIds.add(messageId);
-
- textsReversed.push({
- from: leadName,
- text: combined,
- timestamp,
- read: true,
- source: 'lead_session',
- leadSessionId,
- messageId,
- toolSummary,
- toolCalls,
- });
- if (textsReversed.length >= maxTexts) break;
+ if (seenRawLines.has(trimmed)) continue;
+ seenRawLines.add(trimmed);
+ rawLinesReversed.push(trimmed);
}
- if (textsReversed.length >= maxTexts) break;
if (scanBytes === fileSize) break;
scanBytes = Math.min(fileSize, scanBytes * 2);
}
@@ -3338,8 +3254,163 @@ export class TeamDataService {
await handle.close();
}
- textsReversed.reverse();
- return textsReversed.length > maxTexts ? textsReversed.slice(-maxTexts) : textsReversed;
+ const rawLines = rawLinesReversed.reverse();
+ const texts: InboxMessage[] = [];
+ let syntheticBuffer: {
+ firstMsg: Record;
+ firstMessage: Record;
+ timestamp: string;
+ parts: string[];
+ } | null = null;
+
+ const collectToolCallsAfterIndex = (index: number): ToolCallMeta[] | undefined => {
+ const toolCallsList: ToolCallMeta[] = [];
+ const lookaheadLimit = Math.min(index + 200, rawLines.length);
+ for (let j = index + 1; j < lookaheadLimit; j++) {
+ const tLine = rawLines[j]?.trim();
+ if (!tLine) continue;
+ let tMsg: Record;
+ try {
+ tMsg = JSON.parse(tLine) as Record;
+ } catch {
+ continue;
+ }
+ if (tMsg.type !== 'assistant') break;
+ const tMessage = (tMsg.message ?? tMsg) as Record;
+ const tContent = tMessage.content;
+ if (!Array.isArray(tContent)) continue;
+ const tBlocks = tContent as Record[];
+ if (tBlocks.some((b) => b.type === 'text')) break;
+ for (const b of tBlocks) {
+ if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
+ const input = (b.input ?? {}) as Record;
+ toolCallsList.push({
+ name: b.name,
+ preview: extractToolPreview(b.name, input),
+ });
+ }
+ }
+ }
+ return toolCallsList.length > 0 ? toolCallsList : undefined;
+ };
+
+ const pushLeadText = (
+ msg: Record,
+ message: Record,
+ combined: string,
+ timestamp: string,
+ toolCalls?: ToolCallMeta[],
+ streamGroup = false
+ ): void => {
+ if (combined.length < MIN_TEXT_LENGTH) return;
+
+ const entryUuid = typeof msg.uuid === 'string' ? msg.uuid.trim() : '';
+ const assistantMessageId = typeof message.id === 'string' ? message.id.trim() : '';
+ const stableMessageId = entryUuid
+ ? streamGroup
+ ? `lead-thought-stream-${entryUuid}`
+ : `lead-thought-${entryUuid}`
+ : assistantMessageId
+ ? `lead-thought-msg-${assistantMessageId}`
+ : null;
+
+ const textPrefix = combined
+ .slice(0, 50)
+ .replace(/[^\p{L}\p{N}]/gu, '')
+ .slice(0, 20);
+
+ const messageId =
+ stableMessageId ?? `lead-session-${leadSessionId}-${timestamp}-${textPrefix}`;
+ if (seenMessageIds.has(messageId)) return;
+ seenMessageIds.add(messageId);
+
+ const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
+ texts.push({
+ from: leadName,
+ text: combined,
+ timestamp,
+ read: true,
+ source: 'lead_session',
+ leadSessionId,
+ messageId,
+ toolSummary,
+ toolCalls,
+ });
+ };
+
+ const flushSyntheticBuffer = (): void => {
+ if (!syntheticBuffer) return;
+ const combined = stripAgentBlocks(syntheticBuffer.parts.join('')).trim();
+ pushLeadText(
+ syntheticBuffer.firstMsg,
+ syntheticBuffer.firstMessage,
+ combined,
+ syntheticBuffer.timestamp,
+ undefined,
+ true
+ );
+ syntheticBuffer = null;
+ };
+
+ for (let i = 0; i < rawLines.length; i++) {
+ const trimmed = rawLines[i]?.trim();
+ if (!trimmed) continue;
+
+ let msg: Record;
+ try {
+ msg = JSON.parse(trimmed) as Record;
+ } catch {
+ continue;
+ }
+
+ if (msg.type !== 'assistant') {
+ flushSyntheticBuffer();
+ continue;
+ }
+
+ const message = (msg.message ?? msg) as Record;
+ const content = message.content;
+ if (!Array.isArray(content)) {
+ flushSyntheticBuffer();
+ continue;
+ }
+
+ const textParts: string[] = [];
+ for (const block of content as Record[]) {
+ if (block.type !== 'text' || typeof block.text !== 'string') continue;
+ textParts.push(block.text);
+ }
+
+ if (textParts.length === 0) {
+ if ((content as Record[]).some((block) => block.type === 'tool_use')) {
+ flushSyntheticBuffer();
+ }
+ continue;
+ }
+
+ const timestamp =
+ typeof msg.timestamp === 'string' ? msg.timestamp : new Date().toISOString();
+ const isSyntheticChunk = message.model === '' && message.type === 'message';
+ if (isSyntheticChunk) {
+ if (!syntheticBuffer) {
+ syntheticBuffer = {
+ firstMsg: msg,
+ firstMessage: message,
+ timestamp,
+ parts: [],
+ };
+ }
+ syntheticBuffer.parts.push(textParts.join(''));
+ continue;
+ }
+
+ flushSyntheticBuffer();
+ const combined = stripAgentBlocks(textParts.join('\n')).trim();
+ pushLeadText(msg, message, combined, timestamp, collectToolCallsAfterIndex(i));
+ }
+
+ flushSyntheticBuffer();
+ return texts.length > maxTexts ? texts.slice(-maxTexts) : texts;
}
private async extractLeadSessionTextsFromJsonl(
diff --git a/src/main/services/team/TeamMcpConfigBuilder.ts b/src/main/services/team/TeamMcpConfigBuilder.ts
index 4405fa72..a4a3e95e 100644
--- a/src/main/services/team/TeamMcpConfigBuilder.ts
+++ b/src/main/services/team/TeamMcpConfigBuilder.ts
@@ -21,6 +21,7 @@ import type { TeamMemberMcpPolicy, TeamMemberMcpScope } from '@shared/types';
export interface McpLaunchSpec {
command: string;
args: string[];
+ env?: Record;
}
export interface McpLaunchSpecResolveProgress {
@@ -40,10 +41,12 @@ interface WriteMcpConfigOptions {
const MCP_SERVER_NAME = 'agent-teams';
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
const MCP_CONTROL_URL_ENV = 'CLAUDE_TEAM_CONTROL_URL';
+const ELECTRON_RUN_AS_NODE_ENV = 'ELECTRON_RUN_AS_NODE';
const logger = createLogger('Service:TeamMcpConfigBuilder');
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
const NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
+const ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS = 5_000;
/**
* Stale configs older than this are removed on startup (best-effort).
* 7 days is intentionally long: respawnAfterAuthFailure() reuses saved
@@ -58,6 +61,7 @@ const MCP_CONFIG_SCOPE_PRECEDENCE: readonly TeamMemberMcpScope[] = ['user', 'pro
function isPackagedApp(): boolean {
try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
const { app } = require('electron') as typeof import('electron');
return app.isPackaged;
} catch {
@@ -88,6 +92,26 @@ function getWorkspaceRoot(): string {
return process.cwd();
}
+function shouldUsePackagedElectronNodeRuntime(): boolean {
+ return (
+ isPackagedApp() && typeof process.execPath === 'string' && process.execPath.trim().length > 0
+ );
+}
+
+function getPackagedElectronNodeEnv(): Record {
+ return {
+ [ELECTRON_RUN_AS_NODE_ENV]: '1',
+ };
+}
+
+function buildPackagedElectronNodeLaunchSpec(entry: string): McpLaunchSpec {
+ return {
+ command: process.execPath.trim(),
+ args: [entry],
+ env: getPackagedElectronNodeEnv(),
+ };
+}
+
function getWorkspaceMcpServerDir(): string {
return path.join(getWorkspaceRoot(), 'mcp-server');
}
@@ -178,9 +202,11 @@ async function hasValidServerCopy(dir: string): Promise {
}
let _resolvedNodePath: string | undefined;
+let _packagedElectronNodeRuntimeProbe: { ok: true } | { ok: false; error: unknown } | undefined;
export function clearResolvedNodePathForTests(): void {
_resolvedNodePath = undefined;
+ _packagedElectronNodeRuntimeProbe = undefined;
}
function emitProgress(
@@ -301,6 +327,37 @@ async function probeNodeRuntimePath(
return { ok: false, error: lastError ?? 'no Node.js candidates were available' };
}
+async function probePackagedElectronNodeRuntime(
+ options?: McpLaunchSpecResolveOptions
+): Promise<{ ok: true } | { ok: false; error: unknown }> {
+ if (_packagedElectronNodeRuntimeProbe) {
+ return _packagedElectronNodeRuntimeProbe;
+ }
+
+ emitProgress(options, 'electron-node-runtime', 'Checking bundled Electron Node runtime...');
+ try {
+ const { stdout } = await execCli(
+ process.execPath.trim(),
+ ['-e', 'process.stdout.write("agent-teams-electron-node-ok")'],
+ {
+ encoding: 'utf-8',
+ timeout: ELECTRON_NODE_RUNTIME_PROBE_TIMEOUT_MS,
+ env: {
+ ...process.env,
+ ...getPackagedElectronNodeEnv(),
+ },
+ }
+ );
+ if (stdout.trim() !== 'agent-teams-electron-node-ok') {
+ throw new Error('Electron Node runtime probe did not return the expected marker');
+ }
+ _packagedElectronNodeRuntimeProbe = { ok: true };
+ } catch (error) {
+ _packagedElectronNodeRuntimeProbe = { ok: false, error };
+ }
+ return _packagedElectronNodeRuntimeProbe;
+}
+
async function probeShellNodeRuntimePath(
options?: McpLaunchSpecResolveOptions
): Promise<{ ok: true; path: string } | { ok: false; error: unknown }> {
@@ -450,6 +507,27 @@ export async function resolveAgentTeamsMcpLaunchSpec(
const packagedEntry = await resolvePackagedServerEntry(options);
checked.push(packagedEntry);
if (await pathExists(packagedEntry)) {
+ if (shouldUsePackagedElectronNodeRuntime()) {
+ const electronProbe = await probePackagedElectronNodeRuntime(options);
+ if (electronProbe.ok) {
+ emitProgress(
+ options,
+ 'electron-node-runtime-found',
+ 'Using bundled Electron Node runtime...'
+ );
+ return buildPackagedElectronNodeLaunchSpec(packagedEntry);
+ }
+ logger.warn(
+ `Bundled Electron Node runtime is unavailable for Agent Teams MCP; falling back to Node.js runtime: ${stringifyError(
+ electronProbe.error
+ )}`
+ );
+ emitProgress(
+ options,
+ 'electron-node-runtime-fallback',
+ 'Bundled Electron Node runtime unavailable, resolving Node.js fallback...'
+ );
+ }
return {
command: await resolveNodePath(options),
args: [packagedEntry],
@@ -520,6 +598,7 @@ export class TeamMcpConfigBuilder {
args: launchSpec.args,
enabled: true,
env: {
+ ...launchSpec.env,
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
...(controlApiBaseUrl ? { [MCP_CONTROL_URL_ENV]: controlApiBaseUrl } : {}),
},
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 29a41b5b..672aaaed 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -110,6 +110,10 @@ import {
} from '@shared/utils/inboxNoise';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
+import {
+ isOpenCodeWindowsAccessDeniedDiagnostic,
+ normalizeOpenCodeWindowsAccessDeniedDiagnostic,
+} from '@shared/utils/openCodeWindowsAccessDenied';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { isDefaultProviderModelSelection } from '@shared/utils/providerModelSelection';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@@ -228,11 +232,19 @@ import {
normalizeMemberDiagnosticText,
shouldUseGeminiStagedLaunch,
} from './provisioning/TeamProvisioningPromptBuilders';
+
+import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main';
export type { RuntimeBootstrapMemberMcpLaunchConfig } from './provisioning/TeamProvisioningBootstrapSpec';
export {
buildAddMemberSpawnMessage,
buildRestartMemberSpawnMessage,
} from './provisioning/TeamProvisioningPromptBuilders';
+import { openCodeRuntimeApprovalProvider } from './approvals/OpenCodeRuntimeApprovalProvider';
+import {
+ RuntimeToolApprovalCoordinator,
+ type RuntimeToolApprovalEntry,
+} from './approvals/RuntimeToolApprovalCoordinator';
+import { isOpenCodeBridgeNoOutputDiagnostic } from './opencode/bridge/OpenCodeBridgeSupportDiagnostics';
import {
buildOpenCodePromptDeliveryAttemptId,
createOpenCodePromptDeliveryLedgerStore,
@@ -406,10 +418,10 @@ import type {
TeamRuntimeLaunchInput,
TeamRuntimeLaunchResult,
TeamRuntimeMemberLaunchEvidence,
+ TeamRuntimeMemberSpec,
TeamRuntimePrepareResult,
TeamRuntimeStopInput,
} from './runtime';
-import type { RuntimeTurnSettledProvider } from '@features/member-work-sync/main';
type OpenCodeRuntimeMessageAdapter = TeamLaunchRuntimeAdapter & {
sendMessageToMember(
@@ -608,6 +620,7 @@ import type {
TeamProvisioningPrepareResult,
TeamProvisioningProgress,
TeamProvisioningState,
+ TeamProvisioningSupportDiagnostic,
TeamRuntimeState,
TeamTask,
ToolActivityEventPayload,
@@ -962,9 +975,22 @@ function pushUniqueLine(lines: string[], line: string): void {
}
}
+function pushUniqueSupportDiagnostics(
+ diagnostics: TeamProvisioningSupportDiagnostic[],
+ incoming: readonly TeamProvisioningSupportDiagnostic[] | undefined
+): void {
+ for (const diagnostic of incoming ?? []) {
+ if (!diagnostics.some((existing) => existing.id === diagnostic.id)) {
+ diagnostics.push({ ...diagnostic });
+ }
+ }
+}
+
function looksLikeOpenCodeProviderPrepareDiagnostic(value: string): boolean {
const lower = value.trim().toLowerCase();
return (
+ isOpenCodeBridgeNoOutputDiagnostic(value) ||
+ isOpenCodeWindowsAccessDeniedDiagnostic(value) ||
lower.includes('opencode /experimental/tool') ||
lower.includes('/experimental/tool') ||
lower.includes('mcp_unavailable') ||
@@ -981,6 +1007,15 @@ function normalizeOpenCodePrepareDiagnostic(value: string, reason?: string): str
return trimmed;
}
+ if (isOpenCodeBridgeNoOutputDiagnostic(trimmed)) {
+ return 'OpenCode runtime check returned no output.';
+ }
+
+ const accessDeniedDiagnostic = normalizeOpenCodeWindowsAccessDeniedDiagnostic(trimmed);
+ if (accessDeniedDiagnostic) {
+ return accessDeniedDiagnostic;
+ }
+
if (/opencode cli (?:not detected on path|not found)/i.test(trimmed)) {
return OPENCODE_RUNTIME_BINARY_UNREACHABLE_DIAGNOSTIC;
}
@@ -1028,6 +1063,11 @@ function isOpenCodeModelVerificationTimeoutDiagnostic(value: string | null | und
function selectOpenCodeModelPreparePrimaryReason(
prepare: Extract
): string {
+ const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(prepare);
+ if (providerDiagnostic) {
+ return providerDiagnostic;
+ }
+
const candidates = [...prepare.diagnostics, prepare.reason]
.map((entry) => entry?.trim() ?? '')
.filter(Boolean);
@@ -1035,6 +1075,15 @@ function selectOpenCodeModelPreparePrimaryReason(
return timeoutReason ?? candidates[0] ?? prepare.reason;
}
+function selectOpenCodePrepareProviderDiagnostic(
+ prepare: Pick
+): string | undefined {
+ return [...prepare.diagnostics, ...prepare.warnings].find(
+ (entry) =>
+ isOpenCodeBridgeNoOutputDiagnostic(entry) || isOpenCodeWindowsAccessDeniedDiagnostic(entry)
+ );
+}
+
function isOpenCodeModelPrepareBusyDeferred(
prepare: Extract,
primaryReason: string
@@ -2232,6 +2281,7 @@ interface ProvisioningRun {
leadName: string;
startedAt: string;
textParts: string[];
+ textJoinMode?: 'block' | 'stream';
replyVisibility?: 'user' | 'internal_activity';
hasVisibleSendMessage?: boolean;
hasUserVisibleSendMessage?: boolean;
@@ -2248,6 +2298,14 @@ interface ProvisioningRun {
}[];
/** Monotonic counter for individual lead assistant messages. */
leadMsgSeq: number;
+ /** Active text bubble for token-streamed lead assistant output. */
+ liveLeadTextBuffer: {
+ messageId: string;
+ text: string;
+ timestamp: string;
+ toolCalls?: ToolCallMeta[];
+ toolSummary?: string;
+ } | null;
/** Accumulated tool_use details between text messages. */
pendingToolCalls: ToolCallMeta[];
/** Active runtime tool calls keyed by tool_use_id. */
@@ -3513,6 +3571,37 @@ function isRegisteredRuntimeMetadataFailureReason(reason?: string): boolean {
return reason?.trim() === 'registered runtime metadata without live process';
}
+function isProcessTableUnavailableFailureReason(reason?: string): boolean {
+ const text = reason?.trim();
+ if (!text || !mentionsProcessTableUnavailable(text)) {
+ return false;
+ }
+ return (
+ /^process table (?:is )?unavailable$/i.test(text) ||
+ /^runtime pid could not be verified because process table (?:is )?unavailable$/i.test(text)
+ );
+}
+
+function stripProcessTableUnavailableDiagnosticSuffix(reason: string): string | null {
+ const match = /^(.*?);\s*process table (?:is )?unavailable$/i.exec(reason.trim());
+ const baseReason = match?.[1]?.trim();
+ return baseReason && baseReason.length > 0 ? baseReason : null;
+}
+
+function isBaseAutoClearableLaunchFailureReason(reason?: string): boolean {
+ return (
+ isNeverSpawnedDuringLaunchReason(reason) ||
+ isLaunchGraceWindowFailureReason(reason) ||
+ isConfigRegistrationFailureReason(reason) ||
+ isRegisteredRuntimeMetadataFailureReason(reason) ||
+ isOpenCodeBridgeLaunchFailureReason(reason) ||
+ isBootstrapMcpResourceReadFailureReason(reason) ||
+ isBootstrapCheckInTimeoutFailureReason(reason) ||
+ isBootstrapInstructionPromptFailureReason(reason) ||
+ isLaunchCleanupBootstrapIncompleteFailureReason(reason)
+ );
+}
+
function isBootstrapMcpResourceReadFailureReason(reason?: string): boolean {
const text = reason?.trim().toLowerCase() ?? '';
return (
@@ -3530,6 +3619,58 @@ function isBootstrapInstructionPromptFailureReason(reason?: string): boolean {
return typeof reason === 'string' && isBootstrapInstructionPrompt(reason);
}
+function isLaunchCleanupBootstrapIncompleteFailureReason(reason?: string): boolean {
+ const text = reason?.trim();
+ if (!text) {
+ return false;
+ }
+ if (
+ text === 'Launch ended before teammate bootstrap completed.' ||
+ text === 'Deterministic bootstrap failed before teammate check-in.'
+ ) {
+ return true;
+ }
+ return (
+ text.startsWith('Launch ended before teammate bootstrap completed. ') &&
+ text.includes('Runtime process was alive after bootstrap failure')
+ );
+}
+
+function isBootstrapMemberEvidenceCurrentForMember(
+ current: { firstSpawnAcceptedAt?: string; lastEvaluatedAt?: string },
+ bootstrapMember: Pick<
+ PersistedTeamLaunchMemberState,
+ 'firstSpawnAcceptedAt' | 'lastHeartbeatAt' | 'lastRuntimeAliveAt' | 'lastEvaluatedAt'
+ >,
+ evidenceKind: 'acceptance' | 'confirmation'
+): boolean {
+ const bootstrapFirstSpawnAcceptedMs = Date.parse(bootstrapMember.firstSpawnAcceptedAt ?? '');
+ const bootstrapLastEvaluatedMs = Date.parse(bootstrapMember.lastEvaluatedAt ?? '');
+ const hasDurableBootstrapSpawnAcceptedAt =
+ Number.isFinite(bootstrapFirstSpawnAcceptedMs) &&
+ (!Number.isFinite(bootstrapLastEvaluatedMs) ||
+ bootstrapFirstSpawnAcceptedMs <= bootstrapLastEvaluatedMs);
+ const evidenceAt =
+ evidenceKind === 'confirmation'
+ ? (bootstrapMember.lastHeartbeatAt ??
+ bootstrapMember.lastRuntimeAliveAt ??
+ bootstrapMember.lastEvaluatedAt)
+ : hasDurableBootstrapSpawnAcceptedAt
+ ? bootstrapMember.firstSpawnAcceptedAt
+ : bootstrapMember.lastEvaluatedAt;
+ const evidenceMs = Date.parse(evidenceAt ?? '');
+ if (!Number.isFinite(evidenceMs)) {
+ return false;
+ }
+ const firstSpawnAcceptedMs = Date.parse(current.firstSpawnAcceptedAt ?? '');
+ const lastEvaluatedMs = Date.parse(current.lastEvaluatedAt ?? '');
+ const hasDurableSpawnBoundary =
+ Number.isFinite(firstSpawnAcceptedMs) &&
+ (!Number.isFinite(lastEvaluatedMs) || firstSpawnAcceptedMs <= lastEvaluatedMs);
+ const boundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN;
+ return !Number.isFinite(boundaryMs) || evidenceMs >= boundaryMs;
+}
+
function isTmuxNoServerRunningError(error: unknown): boolean {
const text = error instanceof Error ? error.message : String(error ?? '');
return (
@@ -3539,15 +3680,15 @@ function isTmuxNoServerRunningError(error: unknown): boolean {
}
function isAutoClearableLaunchFailureReason(reason?: string): boolean {
+ const text = reason?.trim();
+ if (!text) {
+ return false;
+ }
+ const baseReason = stripProcessTableUnavailableDiagnosticSuffix(text);
return (
- isNeverSpawnedDuringLaunchReason(reason) ||
- isLaunchGraceWindowFailureReason(reason) ||
- isConfigRegistrationFailureReason(reason) ||
- isRegisteredRuntimeMetadataFailureReason(reason) ||
- isOpenCodeBridgeLaunchFailureReason(reason) ||
- isBootstrapMcpResourceReadFailureReason(reason) ||
- isBootstrapCheckInTimeoutFailureReason(reason) ||
- isBootstrapInstructionPromptFailureReason(reason)
+ isBaseAutoClearableLaunchFailureReason(text) ||
+ isProcessTableUnavailableFailureReason(text) ||
+ (baseReason != null && isBaseAutoClearableLaunchFailureReason(baseReason))
);
}
@@ -4967,6 +5108,16 @@ export class TeamProvisioningService {
| undefined = new Map>();
private readonly runtimeAdapterTraceLinesByRunId = new Map();
private readonly runtimeAdapterTraceKeyByRunId = new Map();
+ private readonly runtimeToolApprovalCoordinator = new RuntimeToolApprovalCoordinator({
+ getSettings: (teamName) => this.getToolApprovalSettings(teamName),
+ answerApproval: ({ entry, allow, message }) =>
+ this.answerRuntimeToolApproval(entry, allow, message),
+ emitApprovalEvent: (event) => this.emitToolApprovalEvent(event),
+ showApprovalNotification: (approval) =>
+ this.maybeShowToolApprovalOsNotification(undefined, approval),
+ dismissApprovalNotification: (requestId) => this.dismissApprovalNotification(requestId),
+ logWarning: (message) => logger.warn(message),
+ });
private readonly runtimeAdapterRunByTeam = new Map<
string,
{
@@ -10393,6 +10544,7 @@ export class TeamProvisioningService {
}
private deleteSecondaryRuntimeRun(teamName: string, laneId: string): void {
+ this.clearOpenCodeRuntimeToolApprovals(teamName, { laneId, emitDismiss: true });
const runs = this.secondaryRuntimeRunByTeam.get(teamName);
if (!runs) {
return;
@@ -10404,6 +10556,7 @@ export class TeamProvisioningService {
}
private clearSecondaryRuntimeRuns(teamName: string): void {
+ this.clearOpenCodeRuntimeToolApprovals(teamName, { emitDismiss: true });
this.secondaryRuntimeRunByTeam.delete(teamName);
}
@@ -11223,6 +11376,7 @@ export class TeamProvisioningService {
private resetTeamScopedTransientStateForNewRun(teamName: string): void {
peekAutoResumeService()?.cancelPendingAutoResume(teamName);
+ this.clearOpenCodeRuntimeToolApprovals(teamName, { emitDismiss: true });
this.invalidateRuntimeSnapshotCaches(teamName);
this.retainedClaudeLogsByTeam.delete(teamName);
this.persistedTranscriptClaudeLogsCache.delete(teamName);
@@ -18230,6 +18384,7 @@ export class TeamProvisioningService {
details: result.details ? [...result.details] : undefined,
warnings: result.warnings ? [...result.warnings] : undefined,
issues: result.issues?.map((issue) => ({ ...issue })),
+ supportDiagnostics: result.supportDiagnostics?.map((diagnostic) => ({ ...diagnostic })),
};
}
@@ -18274,6 +18429,7 @@ export class TeamProvisioningService {
const details: string[] = [];
const blockingMessages: string[] = [];
const issues: TeamProvisioningPrepareIssue[] = [];
+ const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
const selectedModelIds = Array.from(
new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
);
@@ -18323,9 +18479,13 @@ export class TeamProvisioningService {
normalizeOpenCodePrepareDiagnostic(warning, prepareReason)
)
);
+ pushUniqueSupportDiagnostics(supportDiagnostics, prepare.supportDiagnostics);
if (!prepare.ok) {
+ const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(prepare);
blockingMessages.push(
- normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason)
+ providerDiagnostic
+ ? normalizeOpenCodePrepareDiagnostic(providerDiagnostic, prepare.reason)
+ : normalizeOpenCodePrepareDiagnostic(`OpenCode: ${prepare.reason}`, prepare.reason)
);
}
continue;
@@ -18341,6 +18501,7 @@ export class TeamProvisioningService {
warnings.push(...openCodeModelPrepare.warnings);
blockingMessages.push(...openCodeModelPrepare.blockingMessages);
issues.push(...openCodeModelPrepare.issues);
+ pushUniqueSupportDiagnostics(supportDiagnostics, openCodeModelPrepare.supportDiagnostics);
continue;
}
@@ -18509,6 +18670,10 @@ export class TeamProvisioningService {
: 'Some provider runtimes are not ready',
warnings: failureWarnings.length > 0 ? failureWarnings : undefined,
issues: issues.length > 0 ? issues : undefined,
+ supportDiagnostics:
+ supportDiagnostics.length > 0
+ ? supportDiagnostics.map((diagnostic) => ({ ...diagnostic }))
+ : undefined,
};
}
@@ -18525,6 +18690,10 @@ export class TeamProvisioningService {
: 'CLI is warmed up and ready to launch',
warnings: warnings.length > 0 ? warnings : undefined,
issues: issues.length > 0 ? issues : undefined,
+ supportDiagnostics:
+ supportDiagnostics.length > 0
+ ? supportDiagnostics.map((diagnostic) => ({ ...diagnostic }))
+ : undefined,
};
}
@@ -18543,15 +18712,17 @@ export class TeamProvisioningService {
warnings: string[];
blockingMessages: string[];
issues: TeamProvisioningPrepareIssue[];
+ supportDiagnostics: TeamProvisioningSupportDiagnostic[];
}> {
const details: string[] = [];
const warnings: string[] = [];
const blockingMessages: string[] = [];
const issues: TeamProvisioningPrepareIssue[] = [];
+ const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
const startedAt = Date.now();
if (modelIds.length === 0) {
- return { details, warnings, blockingMessages, issues };
+ return { details, warnings, blockingMessages, issues, supportDiagnostics };
}
if (verificationMode === 'compatibility') {
@@ -18599,6 +18770,11 @@ export class TeamProvisioningService {
reason: prepare.ok ? null : prepare.reason,
diagnostics: prepare.diagnostics,
warnings: prepare.warnings,
+ supportDiagnostics: prepare.supportDiagnostics?.map((diagnostic) => ({
+ id: diagnostic.id,
+ kind: diagnostic.kind,
+ title: diagnostic.title,
+ })),
});
return prepare;
} catch (error) {
@@ -18670,6 +18846,7 @@ export class TeamProvisioningService {
}
const { modelId, prepare } = result;
+ pushUniqueSupportDiagnostics(supportDiagnostics, prepare.supportDiagnostics);
const prepareReason = prepare.ok ? undefined : prepare.reason;
warnings.push(
...prepare.warnings.map((warning) =>
@@ -18770,7 +18947,7 @@ export class TeamProvisioningService {
blockingMessages,
});
- return { details, warnings, blockingMessages, issues };
+ return { details, warnings, blockingMessages, issues, supportDiagnostics };
}
private isProviderScopedOpenCodePrepareFailure(
@@ -18799,11 +18976,13 @@ export class TeamProvisioningService {
warnings: string[];
blockingMessages: string[];
issues: TeamProvisioningPrepareIssue[];
+ supportDiagnostics: TeamProvisioningSupportDiagnostic[];
} | null> {
const details: string[] = [];
const warnings: string[] = [];
const blockingMessages: string[] = [];
const issues: TeamProvisioningPrepareIssue[] = [];
+ const supportDiagnostics: TeamProvisioningSupportDiagnostic[] = [];
const startedAt = Date.now();
appendPreflightDebugLog('opencode_compatibility_batch_start', {
@@ -18849,11 +19028,20 @@ export class TeamProvisioningService {
ok: sharedPrepare.ok,
reason: sharedPrepare.ok ? null : sharedPrepare.reason,
diagnostics: sharedPrepare.diagnostics,
+ supportDiagnostics: sharedPrepare.supportDiagnostics?.map((diagnostic) => ({
+ id: diagnostic.id,
+ kind: diagnostic.kind,
+ title: diagnostic.title,
+ })),
});
if (!sharedPrepare.ok) {
+ pushUniqueSupportDiagnostics(supportDiagnostics, sharedPrepare.supportDiagnostics);
+ const providerDiagnostic = selectOpenCodePrepareProviderDiagnostic(sharedPrepare);
const primaryReason = normalizeOpenCodePrepareDiagnostic(
- sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ?? sharedPrepare.reason,
+ providerDiagnostic ??
+ sharedPrepare.diagnostics.find((entry) => entry.trim().length > 0) ??
+ sharedPrepare.reason,
sharedPrepare.reason
);
if (primaryReason.trim().length > 0) {
@@ -18869,7 +19057,7 @@ export class TeamProvisioningService {
code: sharedPrepare.reason,
message: primaryReason.trim() || `OpenCode: ${sharedPrepare.reason}`,
});
- return { details, warnings, blockingMessages, issues };
+ return { details, warnings, blockingMessages, issues, supportDiagnostics };
}
const latestReadiness =
@@ -18925,7 +19113,7 @@ export class TeamProvisioningService {
details,
});
- return { details, warnings, blockingMessages, issues };
+ return { details, warnings, blockingMessages, issues, supportDiagnostics };
}
private resolveOpenCodeCompatibilityModel(
@@ -20871,6 +21059,7 @@ export class TeamProvisioningService {
leadRelayCapture: null,
activeCrossTeamReplyHints: [],
leadMsgSeq: 0,
+ liveLeadTextBuffer: null,
pendingToolCalls: [],
activeToolCalls: new Map(),
pendingDirectCrossTeamSendRefresh: false,
@@ -21487,6 +21676,19 @@ export class TeamProvisioningService {
launchResult,
launchInput
);
+ const requestTeamColor = 'color' in input.request ? input.request.color : undefined;
+ const requestTeamDisplayName =
+ 'displayName' in input.request ? input.request.displayName : undefined;
+ this.syncOpenCodeRuntimeToolApprovals({
+ teamName: input.request.teamName,
+ runId,
+ laneId: 'primary',
+ cwd: launchCwd,
+ members: result.members,
+ expectedMembers: launchInput.expectedMembers,
+ teamColor: requestTeamColor,
+ teamDisplayName: requestTeamDisplayName,
+ });
const success = result.teamLaunchState === 'clean_success';
const pending = result.teamLaunchState === 'partial_pending';
const failed = result.teamLaunchState === 'partial_failure';
@@ -22172,6 +22374,7 @@ export class TeamProvisioningService {
leadRelayCapture: null,
activeCrossTeamReplyHints: [],
leadMsgSeq: 0,
+ liveLeadTextBuffer: null,
pendingToolCalls: [],
activeToolCalls: new Map(),
pendingDirectCrossTeamSendRefresh: false,
@@ -22686,6 +22889,11 @@ export class TeamProvisioningService {
const teamName = runtimeProgress.teamName;
const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName);
this.cancelledRuntimeAdapterRunIds.add(runId);
+ this.clearOpenCodeRuntimeToolApprovals(teamName, {
+ runId,
+ laneId: 'primary',
+ emitDismiss: true,
+ });
this.runtimeAdapterRunByTeam.delete(teamName);
this.aliveRunByTeam.delete(teamName);
if (this.provisioningRunByTeam.get(teamName) === runId) {
@@ -24433,7 +24641,9 @@ export class TeamProvisioningService {
replyText = (await capturePromise).trim() || null;
} catch {
// Best-effort: if we captured some text but never got result.success, keep it.
- const partial = run.leadRelayCapture?.textParts?.join('')?.trim();
+ const partial = run.leadRelayCapture
+ ? this.joinLeadRelayCaptureText(run.leadRelayCapture)
+ : null;
replyText = partial && partial.length > 0 ? partial : null;
} finally {
if (run.leadRelayCapture) {
@@ -27498,11 +27708,7 @@ export class TeamProvisioningService {
private async overlayPrimaryBootstrapTruthIntoRunStatusesFromBootstrapState(
run: ProvisioningRun
): Promise {
- if (
- !run.isLaunch ||
- !Array.isArray(run.mixedSecondaryLanes) ||
- run.mixedSecondaryLanes.length === 0
- ) {
+ if (!run.isLaunch) {
return;
}
@@ -27527,7 +27733,7 @@ export class TeamProvisioningService {
}
const primaryMemberNames = new Set(
- (run.effectiveMembers ?? [])
+ [...(run.effectiveMembers ?? []), ...(run.expectedMembers ?? []).map((name) => ({ name }))]
.map((member) => member.name?.trim())
.filter((name): name is string => Boolean(name))
);
@@ -27546,6 +27752,9 @@ export class TeamProvisioningService {
}
const current =
run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
+ if (!isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation')) {
+ continue;
+ }
if (current.launchState === 'skipped_for_launch' || current.skippedForLaunch === true) {
continue;
}
@@ -27636,6 +27845,9 @@ export class TeamProvisioningService {
if (!current || bootstrapMember?.bootstrapConfirmed !== true) {
continue;
}
+ if (!isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation')) {
+ continue;
+ }
if (
current.providerId === 'opencode' ||
isPersistedOpenCodeSecondaryLaneMember(current) ||
@@ -28092,6 +28304,47 @@ export class TeamProvisioningService {
this.emitMemberSpawnChange(run, lane.member.name);
}
+ private async applyOpenCodeSecondaryPermissionAnswerResult(
+ entry: RuntimeToolApprovalEntry,
+ result: TeamRuntimeLaunchResult
+ ): Promise {
+ const trackedRunId = this.getTrackedRunId(entry.approval.teamName);
+ const run = trackedRunId ? this.runs.get(trackedRunId) : null;
+ if (!run) {
+ throw new Error(`Run not found for team "${entry.approval.teamName}"`);
+ }
+ const lane = (run.mixedSecondaryLanes ?? []).find(
+ (candidate) => candidate.laneId === entry.laneId
+ );
+ if (!lane) {
+ throw new Error(
+ `OpenCode secondary lane ${entry.laneId} was not found for team "${entry.approval.teamName}"`
+ );
+ }
+
+ const guarded = await this.guardCommittedOpenCodeSecondaryLaneEvidence({
+ teamName: entry.approval.teamName,
+ laneId: entry.laneId,
+ memberName: entry.memberName,
+ result,
+ });
+ lane.result = guarded;
+ lane.warnings = [...guarded.warnings];
+ lane.diagnostics = [...guarded.diagnostics];
+ lane.state = 'finished';
+ await this.publishMixedSecondaryLaneStatusChange(run, lane);
+ this.syncOpenCodeRuntimeToolApprovals({
+ teamName: entry.approval.teamName,
+ runId: entry.approval.runId,
+ laneId: entry.laneId,
+ cwd: entry.cwd ?? '',
+ members: guarded.members,
+ expectedMembers: entry.expectedMembers ?? [],
+ teamDisplayName: entry.approval.teamDisplayName,
+ teamColor: entry.approval.teamColor,
+ });
+ }
+
private async guardCommittedOpenCodeSecondaryLaneEvidence(params: {
teamName: string;
laneId: string;
@@ -28607,6 +28860,18 @@ export class TeamProvisioningService {
await finishCancelledLane();
return;
}
+ const laneExpectedMembers: TeamRuntimeMemberSpec[] = [
+ {
+ name: lane.member.name,
+ role: lane.member.role,
+ workflow: lane.member.workflow,
+ isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined,
+ providerId: 'opencode',
+ model: lane.member.model,
+ effort: lane.member.effort,
+ cwd: laneCwd,
+ },
+ ];
const launchOpenCodeLane = () =>
adapter.launch({
runId: laneRunId,
@@ -28619,18 +28884,7 @@ export class TeamProvisioningService {
effort: lane.member.effort,
runtimeOnly: true,
skipPermissions: run.request.skipPermissions !== false,
- expectedMembers: [
- {
- name: lane.member.name,
- role: lane.member.role,
- workflow: lane.member.workflow,
- isolation: lane.member.isolation === 'worktree' ? ('worktree' as const) : undefined,
- providerId: 'opencode',
- model: lane.member.model,
- effort: lane.member.effort,
- cwd: laneCwd,
- },
- ],
+ expectedMembers: laneExpectedMembers,
previousLaunchState,
});
let rawResult: TeamRuntimeLaunchResult;
@@ -28723,6 +28977,16 @@ export class TeamProvisioningService {
)
: resultWithTiming;
lane.result = normalizedResult;
+ this.syncOpenCodeRuntimeToolApprovals({
+ teamName: run.teamName,
+ runId: laneRunId,
+ laneId: lane.laneId,
+ cwd: laneCwd,
+ members: normalizedResult.members,
+ expectedMembers: laneExpectedMembers,
+ teamColor: run.request.color,
+ teamDisplayName: run.request.displayName,
+ });
lane.warnings = [...normalizedResult.warnings];
const launchDiagnostics = appendDiagnosticOnce(
[...requestedDiagnostics, ...migration.diagnostics, ...normalizedResult.diagnostics],
@@ -29905,9 +30169,25 @@ export class TeamProvisioningService {
if (!current || !bootstrapMember) {
continue;
}
+ if (
+ bootstrapMember.bootstrapConfirmed === true &&
+ !isPersistedOpenCodeSecondaryLaneMember(current) &&
+ isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation')
+ ) {
+ const currentConfirmed =
+ current.bootstrapConfirmed === true || current.launchState === 'confirmed_alive';
+ const failureReason = current.hardFailureReason ?? current.runtimeDiagnostic;
+ const hasAutoClearableFailure =
+ (current.launchState === 'failed_to_start' || current.hardFailure === true) &&
+ isAutoClearableLaunchFailureReason(failureReason);
+ if (!currentConfirmed || hasAutoClearableFailure) {
+ return true;
+ }
+ }
const bootstrapProvesSpawnAcceptance =
- bootstrapMember.agentToolAccepted === true ||
- typeof bootstrapMember.firstSpawnAcceptedAt === 'string';
+ (bootstrapMember.agentToolAccepted === true ||
+ typeof bootstrapMember.firstSpawnAcceptedAt === 'string') &&
+ isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'acceptance');
if (!bootstrapProvesSpawnAcceptance) {
continue;
}
@@ -30118,7 +30398,11 @@ export class TeamProvisioningService {
lastEvaluatedAt: now,
};
const isOpenCodeSecondaryLaneMember = isPersistedOpenCodeSecondaryLaneMember(current);
- if (bootstrapMember?.agentToolAccepted && !current.agentToolAccepted) {
+ if (
+ bootstrapMember?.agentToolAccepted &&
+ !current.agentToolAccepted &&
+ isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'acceptance')
+ ) {
current.agentToolAccepted = true;
current.firstSpawnAcceptedAt =
current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt;
@@ -30126,7 +30410,8 @@ export class TeamProvisioningService {
if (
bootstrapMember?.bootstrapConfirmed &&
!current.bootstrapConfirmed &&
- !isOpenCodeSecondaryLaneMember
+ !isOpenCodeSecondaryLaneMember &&
+ isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation')
) {
current.bootstrapConfirmed = true;
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
@@ -30931,6 +31216,21 @@ export class TeamProvisioningService {
return null;
}
+ private isSyntheticLeadTextChunk(msg: Record): boolean {
+ const message = (msg.message ?? msg) as Record;
+ return message.model === '' && message.type === 'message';
+ }
+
+ private joinLeadRelayCaptureText(
+ capture: NonNullable
+ ): string {
+ return capture.textParts.join(capture.textJoinMode === 'stream' ? '' : '\n').trim();
+ }
+
+ private resetLiveLeadTextBuffer(run: ProvisioningRun): void {
+ run.liveLeadTextBuffer = null;
+ }
+
private appendProvisioningAssistantText(
run: ProvisioningRun,
msg: Record,
@@ -30976,27 +31276,61 @@ export class TeamProvisioningService {
run: ProvisioningRun,
cleanText: string,
stableMessageId?: string,
- messageTimestamp?: string
+ messageTimestamp?: string,
+ options?: { coalesceStreamChunk?: boolean }
): void {
- run.leadMsgSeq += 1;
const leadName = this.getRunLeadName(run);
- const messageId = stableMessageId || `lead-turn-${run.runId}-${run.leadMsgSeq}`;
const timestamp =
typeof messageTimestamp === 'string' &&
messageTimestamp.trim().length > 0 &&
Number.isFinite(Date.parse(messageTimestamp))
? messageTimestamp
: nowIso();
- // Attach accumulated tool call details from preceding tool_use messages, then reset.
- const toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
- const toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
- run.pendingToolCalls = [];
+ const coalesceStreamChunk = options?.coalesceStreamChunk === true;
+ let messageId = stableMessageId;
+ let text = cleanText;
+ let timestampForMessage = timestamp;
+ let toolCalls: ToolCallMeta[] | undefined;
+ let toolSummary: string | undefined;
+
+ if (coalesceStreamChunk) {
+ if (!run.liveLeadTextBuffer) {
+ run.leadMsgSeq += 1;
+ toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
+ toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
+ run.liveLeadTextBuffer = {
+ messageId: `lead-turn-${run.runId}-${run.leadMsgSeq}`,
+ text: cleanText,
+ timestamp,
+ toolCalls,
+ toolSummary,
+ };
+ run.pendingToolCalls = [];
+ } else {
+ run.liveLeadTextBuffer.text += cleanText;
+ }
+
+ messageId = run.liveLeadTextBuffer.messageId;
+ text = stripAgentBlocks(run.liveLeadTextBuffer.text).trim();
+ timestampForMessage = run.liveLeadTextBuffer.timestamp;
+ toolCalls = run.liveLeadTextBuffer.toolCalls;
+ toolSummary = run.liveLeadTextBuffer.toolSummary;
+ } else {
+ this.resetLiveLeadTextBuffer(run);
+ run.leadMsgSeq += 1;
+ messageId = messageId || `lead-turn-${run.runId}-${run.leadMsgSeq}`;
+ // Attach accumulated tool call details from preceding tool_use messages, then reset.
+ toolCalls = run.pendingToolCalls.length > 0 ? [...run.pendingToolCalls] : undefined;
+ toolSummary = toolCalls ? formatToolSummaryFromCalls(toolCalls) : undefined;
+ run.pendingToolCalls = [];
+ }
+
const leadMsg: InboxMessage = {
from: leadName,
- text: cleanText,
- timestamp,
+ text,
+ timestamp: timestampForMessage,
read: true,
- summary: cleanText.length > 60 ? cleanText.slice(0, 57) + '...' : cleanText,
+ summary: text.length > 60 ? text.slice(0, 57) + '...' : text,
messageId,
source: 'lead_process',
toolSummary,
@@ -31271,6 +31605,11 @@ export class TeamProvisioningService {
startedAt: previousProgress?.startedAt ?? startedAt,
updatedAt: startedAt,
});
+ this.clearOpenCodeRuntimeToolApprovals(teamName, {
+ runId,
+ laneId: 'primary',
+ emitDismiss: true,
+ });
this.runtimeAdapterRunByTeam.delete(teamName);
this.aliveRunByTeam.delete(teamName);
if (this.provisioningRunByTeam.get(teamName) === runId) {
@@ -31778,6 +32117,7 @@ export class TeamProvisioningService {
}
if (msg.type === 'user') {
+ this.resetLiveLeadTextBuffer(run);
// Check for permission_request in raw user message text BEFORE teammate-message parsing.
// The permission_request may arrive as plain JSON without wrapper,
// and handleNativeTeammateUserMessage only processes blocks.
@@ -31855,12 +32195,17 @@ export class TeamProvisioningService {
// until relayLeadInboxMessages() finally clears run.leadRelayCapture.
if (run.leadRelayCapture && !run.leadRelayCapture.settled) {
const capture = run.leadRelayCapture;
+ if (this.isSyntheticLeadTextChunk(msg)) {
+ capture.textJoinMode = 'stream';
+ } else if (!capture.textJoinMode) {
+ capture.textJoinMode = 'block';
+ }
capture.textParts.push(text);
if (capture.idleHandle) {
clearTimeout(capture.idleHandle);
}
capture.idleHandle = setTimeout(() => {
- const combined = capture.textParts.join('\n').trim();
+ const combined = this.joinLeadRelayCaptureText(capture);
capture.resolveOnce(combined);
}, capture.idleMs);
} else if (run.provisioningComplete) {
@@ -31873,13 +32218,18 @@ export class TeamProvisioningService {
!run.suppressGeminiPostLaunchHydrationOutput &&
!hasCapturedVisibleSendMessage
) {
- const cleanText = stripAgentBlocks(text).trim();
- if (cleanText.length > 0 && !isTeamInternalControlMessageText(cleanText)) {
+ const isSyntheticChunk = this.isSyntheticLeadTextChunk(msg);
+ const displayText = isSyntheticChunk ? text : stripAgentBlocks(text).trim();
+ if (
+ (displayText.length > 0 || (isSyntheticChunk && run.liveLeadTextBuffer)) &&
+ !isTeamInternalControlMessageText(displayText)
+ ) {
this.pushLiveLeadTextMessage(
run,
- cleanText,
+ displayText,
this.getStableLeadThoughtMessageId(msg) ?? undefined,
- messageTimestamp
+ messageTimestamp,
+ { coalesceStreamChunk: isSyntheticChunk }
);
}
}
@@ -31887,13 +32237,18 @@ export class TeamProvisioningService {
// Pre-ready: keep showing provisioning narration in the banner, but also mirror it
// into the live cache so Messages/Activity can show the earliest assistant output.
if (!run.silentUserDmForward && !hasCapturedVisibleSendMessage) {
- const cleanText = stripAgentBlocks(text).trim();
- if (cleanText.length > 0 && !isTeamInternalControlMessageText(cleanText)) {
+ const isSyntheticChunk = this.isSyntheticLeadTextChunk(msg);
+ const displayText = isSyntheticChunk ? text : stripAgentBlocks(text).trim();
+ if (
+ (displayText.length > 0 || (isSyntheticChunk && run.liveLeadTextBuffer)) &&
+ !isTeamInternalControlMessageText(displayText)
+ ) {
this.pushLiveLeadTextMessage(
run,
- cleanText,
+ displayText,
this.getStableLeadThoughtMessageId(msg) ?? undefined,
- messageTimestamp
+ messageTimestamp,
+ { coalesceStreamChunk: isSyntheticChunk }
);
}
}
@@ -31915,6 +32270,7 @@ export class TeamProvisioningService {
preview: extractToolPreview(block.name, input),
toolUseId: typeof block.id === 'string' ? block.id : undefined,
});
+ this.resetLiveLeadTextBuffer(run);
this.startRuntimeToolActivity(run, this.getRunLeadName(run), block);
}
}
@@ -32063,9 +32419,10 @@ export class TeamProvisioningService {
}
if (run.leadRelayCapture) {
const capture = run.leadRelayCapture;
- const combined = capture.textParts.join('\n').trim();
+ const combined = this.joinLeadRelayCaptureText(capture);
capture.resolveOnce(combined);
}
+ this.resetLiveLeadTextBuffer(run);
// Clear silent relay flag after any successful turn.
run.activeCrossTeamReplyHints = [];
run.pendingInboxRelayCandidates = [];
@@ -32101,6 +32458,7 @@ export class TeamProvisioningService {
if (run.leadRelayCapture) {
run.leadRelayCapture.rejectOnce(errorMsg);
}
+ this.resetLiveLeadTextBuffer(run);
// Clear silent relay flag after any errored turn.
run.pendingDirectCrossTeamSendRefresh = false;
run.activeCrossTeamReplyHints = [];
@@ -32638,11 +32996,13 @@ export class TeamProvisioningService {
const toolName = typeof request?.tool_name === 'string' ? request.tool_name : 'Unknown';
const toolInput = (request?.input ?? {}) as Record;
+ const providerId = toolInput.provider === 'codex' ? 'codex' : undefined;
const approval: ToolApprovalRequest = {
requestId,
runId: run.runId,
teamName: run.teamName,
+ ...(providerId ? { providerId } : {}),
source: 'lead',
toolName,
toolInput,
@@ -32746,13 +33106,35 @@ export class TeamProvisioningService {
this.maybeShowToolApprovalOsNotification(run, approval);
}
+ private syncOpenCodeRuntimeToolApprovals(input: {
+ teamName: string;
+ runId: string;
+ laneId: string;
+ cwd: string;
+ members: Record;
+ expectedMembers: TeamRuntimeMemberSpec[];
+ teamColor?: string;
+ teamDisplayName?: string;
+ }): void {
+ const entries = openCodeRuntimeApprovalProvider.collectPendingApprovals(input);
+ this.runtimeToolApprovalCoordinator.sync(
+ {
+ teamName: input.teamName,
+ runId: input.runId,
+ laneId: input.laneId,
+ providerId: 'opencode',
+ },
+ entries
+ );
+ }
+
/**
* Shows a native OS notification for a pending tool approval when the app
* is not in focus. On macOS, adds Allow/Deny action buttons that respond
* directly from the notification without switching to the app.
*/
private maybeShowToolApprovalOsNotification(
- run: ProvisioningRun,
+ run: ProvisioningRun | undefined,
approval: ToolApprovalRequest
): void {
const win = this.mainWindowRef;
@@ -32765,13 +33147,16 @@ export class TeamProvisioningService {
const snoozedUntil = config.notifications.snoozedUntil;
if (snoozedUntil && Date.now() < snoozedUntil) return;
- const { Notification: ElectronNotification } = require('electron') as typeof import('electron');
- if (!ElectronNotification.isSupported()) return;
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { Notification: ElectronNotification } = require('electron') as Partial<
+ typeof import('electron')
+ >;
+ if (!ElectronNotification?.isSupported?.()) return;
const isMac = process.platform === 'darwin';
const isLinux = process.platform === 'linux';
const iconPath = isMac ? undefined : getAppIconPath();
- const teamLabel = run.request.displayName ?? run.teamName;
+ const teamLabel = approval.teamDisplayName ?? run?.request.displayName ?? approval.teamName;
const body = this.formatToolApprovalBody(approval.toolName, approval.toolInput);
// Actions (Allow/Deny buttons) supported on macOS and Windows.
@@ -32818,17 +33203,17 @@ export class TeamProvisioningService {
cleanup();
const allow = index === 0;
logger.info(
- `[${run.teamName}] Tool approval ${allow ? 'allowed' : 'denied'} via OS notification`
+ `[${approval.teamName}] Tool approval ${allow ? 'allowed' : 'denied'} via OS notification`
);
void this.respondToToolApproval(
- run.teamName,
- run.runId,
+ approval.teamName,
+ approval.runId,
approval.requestId,
allow,
allow ? undefined : 'Denied via notification'
).catch((err) => {
logger.error(
- `[${run.teamName}] Failed to respond via notification: ${err instanceof Error ? err.message : String(err)}`
+ `[${approval.teamName}] Failed to respond via notification: ${err instanceof Error ? err.message : String(err)}`
);
});
});
@@ -32846,6 +33231,16 @@ export class TeamProvisioningService {
}
}
+ private clearOpenCodeRuntimeToolApprovals(
+ teamName: string,
+ options: { runId?: string; laneId?: string; emitDismiss?: boolean } = {}
+ ): void {
+ this.runtimeToolApprovalCoordinator.clear(teamName, {
+ ...options,
+ providerId: 'opencode',
+ });
+ }
+
private formatToolApprovalBody(toolName: string, toolInput: Record): string {
switch (toolName) {
case 'AskUserQuestion':
@@ -33065,6 +33460,83 @@ export class TeamProvisioningService {
this.inFlightResponses.delete(requestId);
}
}
+
+ this.runtimeToolApprovalCoordinator.reEvaluate();
+ }
+
+ private async answerRuntimeToolApproval(
+ entry: RuntimeToolApprovalEntry,
+ allow: boolean,
+ _message?: string
+ ): Promise {
+ if (entry.providerId !== 'opencode') {
+ throw new Error(`Runtime approval provider is not supported: ${entry.providerId}`);
+ }
+ const adapter = this.getOpenCodeRuntimeAdapter();
+ if (!adapter?.answerRuntimePermission) {
+ throw new Error('OpenCode runtime permission answer bridge is not available');
+ }
+
+ const previousLaunchState = await this.launchStateStore.read(entry.approval.teamName);
+ const result = await adapter.answerRuntimePermission({
+ runId: entry.approval.runId,
+ laneId: entry.laneId,
+ teamName: entry.approval.teamName,
+ cwd: entry.cwd ?? '',
+ providerId: 'opencode',
+ memberName: entry.memberName,
+ requestId: entry.providerRequestId,
+ decision: allow ? 'allow' : 'reject',
+ expectedMembers: entry.expectedMembers ?? [],
+ previousLaunchState,
+ });
+
+ if (entry.laneId === 'primary') {
+ const launchInput: TeamRuntimeLaunchInput = {
+ runId: entry.approval.runId,
+ laneId: entry.laneId,
+ teamName: entry.approval.teamName,
+ cwd: entry.cwd ?? '',
+ providerId: 'opencode',
+ skipPermissions: false,
+ expectedMembers: entry.expectedMembers ?? [],
+ previousLaunchState,
+ };
+ const { result: committed } = await this.persistOpenCodeRuntimeAdapterLaunchResult(
+ result,
+ launchInput
+ );
+ if (committed.teamLaunchState === 'partial_failure') {
+ this.runtimeAdapterRunByTeam.delete(entry.approval.teamName);
+ } else {
+ this.runtimeAdapterRunByTeam.set(entry.approval.teamName, {
+ runId: entry.approval.runId,
+ providerId: 'opencode',
+ cwd: entry.cwd,
+ members: committed.members,
+ });
+ this.aliveRunByTeam.set(entry.approval.teamName, entry.approval.runId);
+ }
+ this.syncOpenCodeRuntimeToolApprovals({
+ teamName: entry.approval.teamName,
+ runId: entry.approval.runId,
+ laneId: entry.laneId,
+ cwd: entry.cwd ?? '',
+ members: committed.members,
+ expectedMembers: entry.expectedMembers ?? [],
+ teamDisplayName: entry.approval.teamDisplayName,
+ teamColor: entry.approval.teamColor,
+ });
+ } else {
+ await this.applyOpenCodeSecondaryPermissionAnswerResult(entry, result);
+ }
+
+ this.teamChangeEmitter?.({
+ type: 'process',
+ teamName: entry.approval.teamName,
+ runId: entry.approval.runId,
+ detail: allow ? 'permission-allowed' : 'permission-denied',
+ });
}
/**
@@ -33078,6 +33550,17 @@ export class TeamProvisioningService {
allow: boolean,
message?: string
): Promise {
+ const handledByRuntime = await this.runtimeToolApprovalCoordinator.respond(
+ teamName,
+ runId,
+ requestId,
+ allow,
+ message
+ );
+ if (handledByRuntime) {
+ return;
+ }
+
// Look in both provisioning and alive runs — control_requests arrive during provisioning too
const currentRunId = this.getTrackedRunId(teamName);
if (!currentRunId) throw new Error(`No active process for team "${teamName}"`);
@@ -33092,8 +33575,8 @@ export class TeamProvisioningService {
// to handle the race where timeout already responded and deleted the approval
this.clearApprovalTimeout(requestId);
if (!this.tryClaimResponse(requestId)) {
- // Timeout already responded — silently exit, UI cleanup via autoResolved event
- run.pendingApprovals.delete(requestId);
+ // Another response is already being written; leave the pending approval tracked
+ // until that write succeeds or fails.
return;
}
@@ -33118,15 +33601,22 @@ export class TeamProvisioningService {
approval.toolName,
approval.toolInput
);
- } finally {
- run.pendingApprovals.delete(requestId);
this.inFlightResponses.delete(requestId);
+ run.pendingApprovals.delete(requestId);
this.dismissApprovalNotification(requestId);
+ } catch (error) {
+ this.inFlightResponses.delete(requestId);
+ if (run.pendingApprovals.has(requestId)) {
+ this.startApprovalTimeout(run, requestId);
+ }
+ throw error;
}
return;
}
if (!run.child?.stdin?.writable) {
+ this.inFlightResponses.delete(requestId);
+ this.startApprovalTimeout(run, requestId);
throw new Error(`Team "${teamName}" process stdin is not writable`);
}
@@ -33192,11 +33682,16 @@ export class TeamProvisioningService {
}
});
});
- } finally {
- run.pendingApprovals.delete(requestId);
+ } catch (error) {
this.inFlightResponses.delete(requestId);
- this.dismissApprovalNotification(requestId);
+ if (run.pendingApprovals.has(requestId)) {
+ this.startApprovalTimeout(run, requestId);
+ }
+ throw error;
}
+ run.pendingApprovals.delete(requestId);
+ this.inFlightResponses.delete(requestId);
+ this.dismissApprovalNotification(requestId);
}
/**
@@ -35710,7 +36205,6 @@ export class TeamProvisioningService {
const nextMembers: Record[] = [];
for (const m of membersRaw) {
const name = typeof m.name === 'string' ? m.name.trim() : '';
- const agentType = typeof m.agentType === 'string' ? m.agentType : '';
if (!name) continue;
if (isLeadMember(m) || name === 'user') {
nextMembers.push(m);
diff --git a/src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider.ts b/src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider.ts
new file mode 100644
index 00000000..002cb22b
--- /dev/null
+++ b/src/main/services/team/approvals/OpenCodeRuntimeApprovalProvider.ts
@@ -0,0 +1,185 @@
+import type {
+ TeamRuntimeMemberLaunchEvidence,
+ TeamRuntimeMemberSpec,
+ TeamRuntimePendingApproval,
+} from '../runtime/TeamRuntimeAdapter';
+import type {
+ RuntimeApprovalLaunchPolicy,
+ RuntimeApprovalProviderPort,
+ RuntimeToolApprovalAnswerInput,
+ RuntimeToolApprovalEntry,
+} from './RuntimeToolApprovalCoordinator';
+import type { ToolApprovalRequest } from '@shared/types/team';
+
+interface CollectOpenCodeRuntimeApprovalsInput {
+ teamName: string;
+ runId: string;
+ laneId: string;
+ cwd: string;
+ members: Record;
+ expectedMembers: TeamRuntimeMemberSpec[];
+ teamColor?: string;
+ teamDisplayName?: string;
+ nowIso?: () => string;
+}
+
+export class OpenCodeRuntimeApprovalProvider implements RuntimeApprovalProviderPort<
+ { toolApprovalMode?: 'auto' | 'manual' },
+ CollectOpenCodeRuntimeApprovalsInput
+> {
+ readonly providerId = 'opencode' as const;
+
+ buildLaunchPolicy(
+ skipPermissions: boolean,
+ _context: { toolApprovalMode?: 'auto' | 'manual' } = {}
+ ): RuntimeApprovalLaunchPolicy {
+ return {
+ providerId: this.providerId,
+ mode: skipPermissions ? 'auto' : 'manual',
+ config: {
+ permission: skipPermissions ? 'allow' : 'ask',
+ },
+ };
+ }
+
+ collectPendingApprovals(input: CollectOpenCodeRuntimeApprovalsInput): RuntimeToolApprovalEntry[] {
+ return collectOpenCodeRuntimeApprovalEntries(input);
+ }
+
+ async answerApproval(_input: RuntimeToolApprovalAnswerInput): Promise {
+ throw new Error('OpenCode approval answers are handled by the runtime adapter bridge.');
+ }
+
+ assertManualSupported(): void {
+ return;
+ }
+}
+
+export const openCodeRuntimeApprovalProvider = new OpenCodeRuntimeApprovalProvider();
+
+export function collectOpenCodeRuntimeApprovalEntries(
+ input: CollectOpenCodeRuntimeApprovalsInput
+): RuntimeToolApprovalEntry[] {
+ const entries: RuntimeToolApprovalEntry[] = [];
+ const nowIso = input.nowIso ?? (() => new Date().toISOString());
+ for (const [memberName, member] of Object.entries(input.members)) {
+ for (const approval of collectOpenCodeRuntimePendingApprovals(member)) {
+ const providerRequestId = approval.requestId.trim();
+ if (!providerRequestId) {
+ continue;
+ }
+ const requestId = buildOpenCodeRuntimeApprovalRequestId(input.runId, providerRequestId);
+ const toolName = openCodeApprovalToolName(approval);
+ const toolInput = openCodeApprovalToolInput(approval);
+ const uiRequest: ToolApprovalRequest = {
+ requestId,
+ runId: input.runId,
+ teamName: input.teamName,
+ providerId: 'opencode',
+ source: memberName,
+ toolName,
+ toolInput,
+ receivedAt: nowIso(),
+ teamColor: input.teamColor,
+ teamDisplayName: input.teamDisplayName,
+ runtimePermission: {
+ providerId: 'opencode',
+ laneId: input.laneId,
+ memberName,
+ providerRequestId,
+ sessionId: approval.sessionId ?? member.sessionId ?? null,
+ },
+ };
+ entries.push({
+ providerId: 'opencode',
+ approval: uiRequest,
+ providerRequestId,
+ laneId: input.laneId,
+ memberName,
+ cwd: input.cwd,
+ expectedMembers: input.expectedMembers,
+ });
+ }
+ }
+ return entries;
+}
+
+function collectOpenCodeRuntimePendingApprovals(
+ member: TeamRuntimeMemberLaunchEvidence
+): TeamRuntimePendingApproval[] {
+ const approvals = [...(member.pendingApprovals ?? []), ...(member.pendingPermissions ?? [])];
+ const byRequestId = new Map();
+ for (const approval of approvals) {
+ const requestId = approval.requestId.trim();
+ if (!requestId || approval.providerId !== 'opencode' || byRequestId.has(requestId)) {
+ continue;
+ }
+ byRequestId.set(requestId, { ...approval, requestId });
+ }
+ for (const requestId of member.pendingPermissionRequestIds ?? []) {
+ const trimmed = requestId.trim();
+ if (!trimmed || byRequestId.has(trimmed)) {
+ continue;
+ }
+ byRequestId.set(trimmed, {
+ providerId: 'opencode',
+ requestId: trimmed,
+ sessionId: member.sessionId ?? null,
+ tool: null,
+ title: null,
+ kind: null,
+ });
+ }
+ return Array.from(byRequestId.values());
+}
+
+export function buildOpenCodeRuntimeApprovalRequestId(
+ runId: string,
+ providerRequestId: string
+): string {
+ return `opencode:${runId}:${providerRequestId}`;
+}
+
+export function openCodeApprovalToolName(approval: TeamRuntimePendingApproval): string {
+ const rawTool = approval.tool?.trim() || approval.kind?.trim() || approval.title?.trim();
+ const normalized = rawTool?.toLowerCase();
+ switch (normalized) {
+ case 'bash':
+ case 'shell':
+ case 'terminal':
+ return 'Bash';
+ case 'edit':
+ return 'Edit';
+ case 'write':
+ return 'Write';
+ case 'read':
+ return 'Read';
+ default:
+ return rawTool || 'OpenCodeTool';
+ }
+}
+
+export function openCodeApprovalToolInput(
+ approval: TeamRuntimePendingApproval
+): Record {
+ const raw: Record =
+ approval.raw && typeof approval.raw === 'object' ? approval.raw : {};
+ const patterns = Array.isArray(raw.patterns)
+ ? raw.patterns.filter((value): value is string => typeof value === 'string')
+ : undefined;
+ const firstPattern = patterns?.[0];
+ const title = approval.title?.trim();
+ const input: Record = {
+ providerRequestId: approval.requestId,
+ provider: 'opencode',
+ ...(approval.sessionId ? { sessionId: approval.sessionId } : {}),
+ ...(approval.tool ? { tool: approval.tool } : {}),
+ ...(approval.kind ? { kind: approval.kind } : {}),
+ ...(title ? { title } : {}),
+ ...(patterns?.length ? { patterns } : {}),
+ };
+ if (openCodeApprovalToolName(approval) === 'Bash' && firstPattern) {
+ input.command = firstPattern;
+ }
+ return input;
+}
diff --git a/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts b/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts
new file mode 100644
index 00000000..eb921f39
--- /dev/null
+++ b/src/main/services/team/approvals/RuntimeToolApprovalCoordinator.ts
@@ -0,0 +1,429 @@
+import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
+
+import type {
+ TeamRuntimeApprovalProviderId,
+ TeamRuntimeMemberSpec,
+} from '../runtime/TeamRuntimeAdapter';
+import type {
+ ToolApprovalAutoResolved,
+ ToolApprovalDismiss,
+ ToolApprovalRequest,
+ ToolApprovalSettings,
+} from '@shared/types/team';
+
+export type RuntimeApprovalProviderId = TeamRuntimeApprovalProviderId;
+
+export type RuntimeApprovalDecision = 'allow' | 'deny';
+
+export interface RuntimeApprovalLaunchPolicy {
+ providerId: RuntimeApprovalProviderId;
+ mode: 'auto' | 'manual';
+ config: Record;
+}
+
+export interface RuntimeApprovalProviderPort {
+ readonly providerId: RuntimeApprovalProviderId;
+ buildLaunchPolicy(skipPermissions: boolean, context: TContext): RuntimeApprovalLaunchPolicy;
+ collectPendingApprovals(runtimeState: TRuntimeState): RuntimeToolApprovalEntry[];
+ answerApproval(input: RuntimeToolApprovalAnswerInput): Promise;
+ assertManualSupported(context: TContext): void;
+}
+
+export interface RuntimeToolApprovalEntry {
+ providerId: RuntimeApprovalProviderId;
+ approval: ToolApprovalRequest;
+ providerRequestId: string;
+ laneId: string;
+ memberName: string;
+ cwd?: string;
+ expectedMembers?: TeamRuntimeMemberSpec[];
+ metadata?: Record;
+}
+
+export interface RuntimeToolApprovalAnswerInput {
+ entry: RuntimeToolApprovalEntry;
+ allow: boolean;
+ message?: string;
+}
+
+export type RuntimeToolApprovalEvent =
+ | ToolApprovalRequest
+ | ToolApprovalDismiss
+ | ToolApprovalAutoResolved;
+
+export interface RuntimeToolApprovalCoordinatorDeps {
+ getSettings(teamName: string): ToolApprovalSettings;
+ answerApproval(input: RuntimeToolApprovalAnswerInput): Promise;
+ emitApprovalEvent(event: RuntimeToolApprovalEvent): void;
+ showApprovalNotification?(approval: ToolApprovalRequest): void;
+ dismissApprovalNotification?(requestId: string): void;
+ logWarning?(message: string): void;
+}
+
+export interface RuntimeToolApprovalSyncScope {
+ teamName: string;
+ runId: string;
+ laneId?: string;
+ providerId?: RuntimeApprovalProviderId;
+}
+
+export interface RuntimeToolApprovalClearOptions {
+ runId?: string;
+ laneId?: string;
+ providerId?: RuntimeApprovalProviderId;
+ emitDismiss?: boolean;
+}
+
+export function mapAppApprovalDecisionToProviderDecision(
+ decision: RuntimeApprovalDecision
+): 'allow' | 'reject' {
+ return decision === 'allow' ? 'allow' : 'reject';
+}
+
+export class RuntimeToolApprovalCoordinator {
+ private readonly approvalsByTeam = new Map>();
+ private readonly timers = new Map>();
+ private readonly inFlightResponses = new Set();
+
+ constructor(private readonly deps: RuntimeToolApprovalCoordinatorDeps) {}
+
+ sync(scope: RuntimeToolApprovalSyncScope, entries: RuntimeToolApprovalEntry[]): void {
+ const observedRequestIds = new Set();
+ for (const entry of entries) {
+ observedRequestIds.add(entry.approval.requestId);
+ this.register(entry);
+ }
+
+ const approvals = this.approvalsByTeam.get(scope.teamName);
+ if (!approvals) {
+ return;
+ }
+
+ for (const [requestId, entry] of approvals) {
+ if (!this.matchesScope(entry, scope)) {
+ continue;
+ }
+ if (observedRequestIds.has(requestId)) {
+ continue;
+ }
+ this.removeEntry(entry);
+ this.deps.emitApprovalEvent({
+ autoResolved: true,
+ requestId,
+ runId: entry.approval.runId,
+ teamName: entry.approval.teamName,
+ reason: 'runtime_resolved',
+ } as ToolApprovalAutoResolved);
+ }
+ }
+
+ register(entry: RuntimeToolApprovalEntry): void {
+ const requestId = entry.approval.requestId;
+ if (!requestId) {
+ return;
+ }
+ const approvals = this.getTeamApprovals(entry.approval.teamName);
+ if (approvals.has(requestId) || this.inFlightResponses.has(requestId)) {
+ return;
+ }
+
+ const autoResult = shouldAutoAllow(
+ this.deps.getSettings(entry.approval.teamName),
+ entry.approval.toolName,
+ entry.approval.toolInput
+ );
+ if (autoResult.autoAllow) {
+ void this.answerUntracked(entry, true, undefined, 'auto_allow_category');
+ return;
+ }
+
+ approvals.set(requestId, entry);
+ this.deps.emitApprovalEvent(entry.approval);
+ this.startTimeout(entry);
+ this.deps.showApprovalNotification?.(entry.approval);
+ }
+
+ async respond(
+ teamName: string,
+ runId: string,
+ requestId: string,
+ allow: boolean,
+ message?: string
+ ): Promise {
+ const entry = this.approvalsByTeam.get(teamName)?.get(requestId);
+ if (!entry) {
+ return false;
+ }
+ if (entry.approval.runId !== runId) {
+ throw new Error(
+ `Stale approval: runId mismatch (expected ${entry.approval.runId}, got ${runId})`
+ );
+ }
+
+ this.clearTimer(requestId);
+ if (!this.tryClaimResponse(requestId)) {
+ return true;
+ }
+
+ try {
+ await this.deps.answerApproval({ entry, allow, message });
+ } catch (error) {
+ this.inFlightResponses.delete(requestId);
+ if (this.get(entry.approval.teamName, requestId) === entry) {
+ this.startTimeout(entry);
+ }
+ throw error;
+ }
+ this.removeEntry(entry);
+ this.inFlightResponses.delete(requestId);
+ return true;
+ }
+
+ clear(teamName: string, options: RuntimeToolApprovalClearOptions = {}): number {
+ const approvals = this.approvalsByTeam.get(teamName);
+ if (!approvals) {
+ return 0;
+ }
+
+ let removed = 0;
+ const removedRunIds = new Set();
+ for (const entry of Array.from(approvals.values())) {
+ if (!this.matchesClearOptions(entry, options)) {
+ continue;
+ }
+ this.removeEntry(entry);
+ removed += 1;
+ removedRunIds.add(entry.approval.runId);
+ }
+
+ if (removed > 0 && options.emitDismiss) {
+ for (const runId of removedRunIds) {
+ this.deps.emitApprovalEvent({ dismissed: true, teamName, runId });
+ }
+ }
+
+ return removed;
+ }
+
+ reEvaluate(): void {
+ for (const approvals of Array.from(this.approvalsByTeam.values())) {
+ for (const entry of Array.from(approvals.values())) {
+ const requestId = entry.approval.requestId;
+ const settings = this.deps.getSettings(entry.approval.teamName);
+ const autoResult = shouldAutoAllow(
+ settings,
+ entry.approval.toolName,
+ entry.approval.toolInput
+ );
+ if (autoResult.autoAllow) {
+ this.clearTimer(requestId);
+ void this.answerTracked(entry, true, undefined, 'auto_allow_category');
+ continue;
+ }
+
+ if (settings.timeoutAction === 'wait') {
+ this.clearTimer(requestId);
+ } else if (!this.timers.has(requestId)) {
+ this.startTimeout(entry);
+ }
+ }
+ }
+ }
+
+ get(teamName: string, requestId: string): RuntimeToolApprovalEntry | undefined {
+ return this.approvalsByTeam.get(teamName)?.get(requestId);
+ }
+
+ size(teamName?: string): number {
+ if (teamName) {
+ return this.approvalsByTeam.get(teamName)?.size ?? 0;
+ }
+ let total = 0;
+ for (const approvals of this.approvalsByTeam.values()) {
+ total += approvals.size;
+ }
+ return total;
+ }
+
+ dispose(): void {
+ for (const requestId of Array.from(this.timers.keys())) {
+ this.clearTimer(requestId);
+ }
+ this.approvalsByTeam.clear();
+ this.inFlightResponses.clear();
+ }
+
+ private startTimeout(entry: RuntimeToolApprovalEntry): void {
+ const { timeoutAction, timeoutSeconds } = this.deps.getSettings(entry.approval.teamName);
+ if (timeoutAction === 'wait') {
+ return;
+ }
+
+ const requestId = entry.approval.requestId;
+ if (this.timers.has(requestId)) {
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ this.timers.delete(requestId);
+ const current = this.get(entry.approval.teamName, requestId);
+ if (!current) {
+ return;
+ }
+ const currentAction = this.deps.getSettings(entry.approval.teamName).timeoutAction;
+ if (currentAction === 'wait') {
+ return;
+ }
+ const allow = currentAction === 'allow';
+ void this.answerTracked(
+ current,
+ allow,
+ allow ? undefined : 'Timed out - auto-denied by settings',
+ allow ? 'timeout_allow' : 'timeout_deny'
+ );
+ }, timeoutSeconds * 1000);
+ timer.unref?.();
+ this.timers.set(requestId, timer);
+ }
+
+ private async answerTracked(
+ entry: RuntimeToolApprovalEntry,
+ allow: boolean,
+ message: string | undefined,
+ reason: ToolApprovalAutoResolved['reason']
+ ): Promise {
+ const requestId = entry.approval.requestId;
+ if (!this.tryClaimResponse(requestId)) {
+ return;
+ }
+ try {
+ await this.deps.answerApproval({ entry, allow, message });
+ this.removeEntry(entry);
+ this.deps.emitApprovalEvent({
+ autoResolved: true,
+ requestId,
+ runId: entry.approval.runId,
+ teamName: entry.approval.teamName,
+ reason,
+ } as ToolApprovalAutoResolved);
+ } catch (error) {
+ this.deps.logWarning?.(
+ `[${entry.approval.teamName}] Failed to auto-resolve runtime approval ${requestId}: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ if (this.get(entry.approval.teamName, requestId) === entry) {
+ this.startTimeout(entry);
+ }
+ } finally {
+ this.inFlightResponses.delete(requestId);
+ }
+ }
+
+ private async answerUntracked(
+ entry: RuntimeToolApprovalEntry,
+ allow: boolean,
+ message: string | undefined,
+ reason: ToolApprovalAutoResolved['reason']
+ ): Promise {
+ const requestId = entry.approval.requestId;
+ if (!this.tryClaimResponse(requestId)) {
+ return;
+ }
+ try {
+ await this.deps.answerApproval({ entry, allow, message });
+ this.deps.emitApprovalEvent({
+ autoResolved: true,
+ requestId,
+ runId: entry.approval.runId,
+ teamName: entry.approval.teamName,
+ reason,
+ } as ToolApprovalAutoResolved);
+ } catch (error) {
+ this.deps.logWarning?.(
+ `[${entry.approval.teamName}] Failed to auto-resolve runtime approval ${requestId}: ${
+ error instanceof Error ? error.message : String(error)
+ }`
+ );
+ } finally {
+ this.inFlightResponses.delete(requestId);
+ }
+ }
+
+ private removeEntry(entry: RuntimeToolApprovalEntry): void {
+ const requestId = entry.approval.requestId;
+ this.clearTimer(requestId);
+ this.inFlightResponses.delete(requestId);
+ this.deps.dismissApprovalNotification?.(requestId);
+ const approvals = this.approvalsByTeam.get(entry.approval.teamName);
+ if (!approvals) {
+ return;
+ }
+ approvals.delete(requestId);
+ if (approvals.size === 0) {
+ this.approvalsByTeam.delete(entry.approval.teamName);
+ }
+ }
+
+ private clearTimer(requestId: string): void {
+ const timer = this.timers.get(requestId);
+ if (!timer) {
+ return;
+ }
+ clearTimeout(timer);
+ this.timers.delete(requestId);
+ }
+
+ private tryClaimResponse(requestId: string): boolean {
+ if (this.inFlightResponses.has(requestId)) {
+ return false;
+ }
+ this.inFlightResponses.add(requestId);
+ return true;
+ }
+
+ private getTeamApprovals(teamName: string): Map {
+ const existing = this.approvalsByTeam.get(teamName);
+ if (existing) {
+ return existing;
+ }
+ const approvals = new Map();
+ this.approvalsByTeam.set(teamName, approvals);
+ return approvals;
+ }
+
+ private matchesScope(
+ entry: RuntimeToolApprovalEntry,
+ scope: RuntimeToolApprovalSyncScope
+ ): boolean {
+ if (entry.approval.teamName !== scope.teamName) {
+ return false;
+ }
+ if (entry.approval.runId !== scope.runId) {
+ return false;
+ }
+ if (scope.laneId && entry.laneId !== scope.laneId) {
+ return false;
+ }
+ if (scope.providerId && entry.providerId !== scope.providerId) {
+ return false;
+ }
+ return true;
+ }
+
+ private matchesClearOptions(
+ entry: RuntimeToolApprovalEntry,
+ options: RuntimeToolApprovalClearOptions
+ ): boolean {
+ if (options.runId && entry.approval.runId !== options.runId) {
+ return false;
+ }
+ if (options.laneId && entry.laneId !== options.laneId) {
+ return false;
+ }
+ if (options.providerId && entry.providerId !== options.providerId) {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts
index acab846a..3b49ccca 100644
--- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts
+++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts
@@ -1,6 +1,6 @@
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
import { execCli } from '@main/utils/childProcess';
-import { randomUUID } from 'crypto';
+import { createHash, randomUUID } from 'crypto';
import { promises as fs } from 'fs';
import * as path from 'path';
@@ -38,6 +38,14 @@ export interface OpenCodeBridgeProcessRunner {
run(input: OpenCodeBridgeProcessRunInput): Promise;
}
+interface OpenCodeBridgeOutputReadResult {
+ content: string;
+ outputSource: 'stdout' | 'file' | 'none';
+ stdoutBytes: number;
+ outputFileBytes: number | null;
+ outputReadError: string | null;
+}
+
export interface OpenCodeBridgeDiagnosticsSink {
append(event: OpenCodeBridgeDiagnosticEvent): Promise;
}
@@ -60,6 +68,7 @@ const DEFAULT_STDERR_LIMIT_BYTES = 256_000;
const WINDOWS_BATCH_EXTENSIONS = new Set(['.cmd', '.bat']);
const EMPTY_STDOUT_READINESS_MAX_ATTEMPTS = 2;
const EMPTY_STDOUT_READINESS_RETRY_DELAY_MS = 250;
+const SAFE_BRIDGE_INPUT_FILE_REQUEST_ID = /^[A-Za-z0-9._-]{1,120}$/;
export function resolveOpenCodeBridgeProcessCwd(
binaryPath: string,
@@ -185,7 +194,16 @@ export class OpenCodeBridgeCommandClient {
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
env: await this.resolveEnv(),
});
- const stdout = await this.readBridgeOutput(processResult.stdout, outputPath);
+ const bridgeOutput = await this.readBridgeOutput(processResult.stdout, outputPath);
+ const processDetails = {
+ exitCode: processResult.exitCode,
+ timedOut: processResult.timedOut,
+ stdoutBytes: bridgeOutput.stdoutBytes,
+ stderrBytes: byteLength(processResult.stderr),
+ outputSource: bridgeOutput.outputSource,
+ outputFileBytes: bridgeOutput.outputFileBytes,
+ outputReadError: bridgeOutput.outputReadError,
+ };
if (processResult.timedOut) {
return this.contractFailure(
@@ -196,6 +214,7 @@ export class OpenCodeBridgeCommandClient {
{
stderr: redactBridgeDiagnosticText(processResult.stderr),
attempts: attempt,
+ ...processDetails,
}
);
}
@@ -207,14 +226,14 @@ export class OpenCodeBridgeCommandClient {
'OpenCode bridge command failed',
true,
{
- exitCode: processResult.exitCode,
stderr: redactBridgeDiagnosticText(processResult.stderr),
attempts: attempt,
+ ...processDetails,
}
);
}
- const parsed = parseSingleBridgeJsonResult(stdout);
+ const parsed = parseSingleBridgeJsonResult(bridgeOutput.content);
if (!parsed.ok) {
if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) {
await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS);
@@ -222,9 +241,10 @@ export class OpenCodeBridgeCommandClient {
}
return this.contractFailure(envelope, 'contract_violation', parsed.error, false, {
- stdoutPreview: redactBridgeDiagnosticText(stdout.slice(0, 2_000)),
+ stdoutPreview: redactBridgeDiagnosticText(bridgeOutput.content.slice(0, 2_000)),
stderrPreview: redactBridgeDiagnosticText(processResult.stderr.slice(0, 2_000)),
attempts: attempt,
+ ...processDetails,
});
}
@@ -232,6 +252,7 @@ export class OpenCodeBridgeCommandClient {
if (!validation.ok) {
return this.contractFailure(envelope, 'contract_violation', validation.reason, false, {
attempts: attempt,
+ ...processDetails,
});
}
@@ -253,14 +274,56 @@ export class OpenCodeBridgeCommandClient {
}
}
- private async readBridgeOutput(stdout: string, outputPath: string): Promise {
- if (stdout.trim().length > 0) {
- return stdout;
- }
+ private async readBridgeOutput(
+ stdout: string,
+ outputPath: string
+ ): Promise {
+ const stdoutBytes = byteLength(stdout);
try {
- return await fs.readFile(outputPath, 'utf8');
- } catch {
- return stdout;
+ const output = await fs.readFile(outputPath, 'utf8');
+ const outputFileBytes = byteLength(output);
+ if (output.trim().length > 0) {
+ return {
+ content: output,
+ outputSource: 'file',
+ stdoutBytes,
+ outputFileBytes,
+ outputReadError: null,
+ };
+ }
+ if (stdout.trim().length > 0) {
+ return {
+ content: stdout,
+ outputSource: 'stdout',
+ stdoutBytes,
+ outputFileBytes,
+ outputReadError: null,
+ };
+ }
+ return {
+ content: output,
+ outputSource: 'none',
+ stdoutBytes,
+ outputFileBytes,
+ outputReadError: null,
+ };
+ } catch (error) {
+ if (stdout.trim().length > 0) {
+ return {
+ content: stdout,
+ outputSource: 'stdout',
+ stdoutBytes,
+ outputFileBytes: 0,
+ outputReadError: getBridgeOutputReadError(error),
+ };
+ }
+ return {
+ content: stdout,
+ outputSource: 'none',
+ stdoutBytes,
+ outputFileBytes: 0,
+ outputReadError: getBridgeOutputReadError(error),
+ };
}
}
@@ -275,7 +338,7 @@ export class OpenCodeBridgeCommandClient {
envelope: OpenCodeBridgeCommandEnvelope
): Promise {
await fs.mkdir(this.tempDirectory, { recursive: true, mode: 0o700 });
- const inputPath = path.join(this.tempDirectory, `opencode-command-${envelope.requestId}.json`);
+ const inputPath = path.join(this.tempDirectory, buildBridgeInputFileName(envelope.requestId));
await fs.writeFile(inputPath, `${JSON.stringify(envelope, null, 2)}\n`, {
encoding: 'utf8',
mode: 0o600,
@@ -291,6 +354,13 @@ export class OpenCodeBridgeCommandClient {
details: Record
): Promise {
const completedAt = this.clock().toISOString();
+ const diagnosticDetails = {
+ command: envelope.command,
+ requestId: envelope.requestId,
+ cwd: redactBridgeDiagnosticText(envelope.cwd),
+ binaryPath: redactBridgeDiagnosticText(this.binaryPath),
+ ...details,
+ };
const diagnostic: OpenCodeBridgeDiagnosticEvent = {
id: this.diagnosticIdFactory(),
type:
@@ -301,11 +371,11 @@ export class OpenCodeBridgeCommandClient {
runId: extractRunId(envelope.body) ?? undefined,
severity: retryable ? 'warning' : 'error',
message,
- data: details,
+ data: diagnosticDetails,
createdAt: completedAt,
};
- await this.diagnostics?.append(diagnostic);
+ await this.diagnostics?.append(diagnostic).catch(() => undefined);
return {
ok: false,
@@ -318,7 +388,7 @@ export class OpenCodeBridgeCommandClient {
kind,
message,
retryable,
- details,
+ details: diagnosticDetails,
},
diagnostics: [diagnostic],
};
@@ -356,3 +426,38 @@ function bufferToString(value: string | Buffer | undefined): string {
}
return '';
}
+
+function byteLength(value: string): number {
+ return Buffer.byteLength(value, 'utf8');
+}
+
+function buildBridgeInputFileName(requestId: string): string {
+ const trimmed = requestId.trim();
+ if (requestId === trimmed && SAFE_BRIDGE_INPUT_FILE_REQUEST_ID.test(trimmed)) {
+ return `opencode-command-${trimmed}.json`;
+ }
+
+ const sanitized =
+ Array.from(trimmed, (char) => (isUnsafeBridgeInputFileNameChar(char) ? '_' : char))
+ .join('')
+ .replace(/\s+/g, '_')
+ .replace(/_+/g, '_')
+ .replace(/^\.+/, '_')
+ .slice(0, 80) || 'request';
+ const fingerprint = createHash('sha256').update(requestId).digest('hex').slice(0, 12);
+ return `opencode-command-${sanitized}-${fingerprint}.json`;
+}
+
+function isUnsafeBridgeInputFileNameChar(char: string): boolean {
+ return char.charCodeAt(0) < 32 || '<>:"/\\|?*'.includes(char);
+}
+
+function getBridgeOutputReadError(error: unknown): string {
+ if (error && typeof error === 'object' && 'code' in error) {
+ const code = (error as { code?: unknown }).code;
+ if (typeof code === 'string' && code.trim()) {
+ return code.trim();
+ }
+ }
+ return error instanceof Error ? error.message : String(error);
+}
diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts
index 0d0c50e2..7c9c36c5 100644
--- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts
+++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts
@@ -64,6 +64,7 @@ export interface OpenCodeLaunchTeamCommandBody {
teamName: string;
projectPath: string;
selectedModel: string;
+ skipPermissions?: boolean;
members: OpenCodeTeamLaunchMemberCommandSpec[];
leadPrompt: string;
expectedCapabilitySnapshotId: string | null;
@@ -71,6 +72,15 @@ export interface OpenCodeLaunchTeamCommandBody {
capabilitySnapshotRecoveryAttemptId?: string;
}
+export interface OpenCodeRuntimePermissionCommandData {
+ requestId: string;
+ sessionId: string | null;
+ tool: string | null;
+ title: string | null;
+ kind: string | null;
+ raw?: Record;
+}
+
export interface OpenCodeTeamMemberLaunchCommandData {
sessionId: string;
launchState: OpenCodeTeamMemberLaunchBridgeState;
@@ -78,6 +88,7 @@ export interface OpenCodeTeamMemberLaunchCommandData {
bootstrapMode?: OpenCodeBootstrapMode;
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
pendingPermissionRequestIds?: string[];
+ pendingPermissions?: OpenCodeRuntimePermissionCommandData[];
diagnostics?: string[];
model: string;
runtimePid?: number;
@@ -132,6 +143,30 @@ export interface OpenCodeStopTeamCommandData {
runtimeStoreManifestHighWatermark?: number | null;
}
+export interface OpenCodeAnswerPermissionCommandBody {
+ runId: string;
+ laneId: string;
+ teamId: string;
+ teamName: string;
+ projectPath: string;
+ memberName?: string;
+ requestId: string;
+ decision: 'allow' | 'always' | 'reject';
+ expectedCapabilitySnapshotId?: string | null;
+ manifestHighWatermark?: number | null;
+}
+
+export interface OpenCodeListRuntimePermissionsCommandBody {
+ teamId: string;
+ teamName: string;
+ laneId?: string;
+ projectPath?: string;
+}
+
+export interface OpenCodeListRuntimePermissionsCommandData {
+ permissions: OpenCodeRuntimePermissionCommandData[];
+}
+
export interface OpenCodeCleanupHostsCommandBody {
reason: 'startup' | 'shutdown' | 'manual' | string;
mode?: 'stale' | 'force';
@@ -590,6 +625,7 @@ export function assertBridgeResultCanMutateState(
command: OpenCodeBridgeCommandName;
runId: string | null;
capabilitySnapshotId: string | null;
+ allowCapabilitySnapshotRecovery?: boolean;
}
): asserts result is OpenCodeBridgeSuccess {
if (!result.ok) {
@@ -612,12 +648,28 @@ export function assertBridgeResultCanMutateState(
if (
expected.capabilitySnapshotId !== null &&
- result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId
+ result.runtime.capabilitySnapshotId !== expected.capabilitySnapshotId &&
+ !(
+ expected.allowCapabilitySnapshotRecovery === true &&
+ hasOpenCodeBridgeDataDiagnosticCode(result.data, 'opencode_capability_snapshot_recovery')
+ )
) {
throw new Error('OpenCode bridge capability snapshot mismatch');
}
}
+function hasOpenCodeBridgeDataDiagnosticCode(value: unknown, code: string): boolean {
+ if (!isRecord(value) || !Array.isArray(value.diagnostics)) {
+ return false;
+ }
+ return value.diagnostics.some((diagnostic) => {
+ if (!isRecord(diagnostic)) {
+ return false;
+ }
+ return diagnostic.code === code;
+ });
+}
+
export function validateOpenCodeBridgeHandshake(input: {
handshake: OpenCodeBridgeHandshake;
expectedClient: OpenCodeBridgePeerIdentity;
@@ -744,6 +796,7 @@ export function assertBridgeEvidenceCanCommitToRuntimeStores(input: {
manifest: RuntimeStoreManifestEvidence;
idempotencyKey: string;
enforceManifestHighWatermark?: boolean;
+ allowCapabilitySnapshotRecovery?: boolean;
}): asserts input is {
result: OpenCodeBridgeSuccess