blob: 80645621a21c0a3e1996d72a2579ff70827dd703 [file]
import { app, dialog, ipcMain, powerSaveBlocker, shell } from 'electron';
import { randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { wireAppLifecycle } from './app-lifecycle.js';
import {
collapseSessionRevisions,
filterModelVisibleTaskLedgerTasks,
isActiveShellRunStatus,
resolveSystemUiLocale,
resolveUiLocale,
} from '@maka/core';
import type {
AppSettings,
BotProvider,
ConnectionEvent,
SessionChangedEvent,
SessionChangedReason,
SessionEvent,
SessionHeader,
UpdateAppSettingsInput,
} from '@maka/core';
import { deriveBotStatusPersistenceUpdate } from './bot-status-persistence.js';
import { runThreadSearch } from './search/thread-search.js';
import { assembleDesktopTools } from './tool-assembly.js';
import { createToolArtifactPersistence } from './tool-artifact-persistence.js';
import { ClaudeSubscriptionService } from './oauth/claude-subscription-service.js';
import { OpenAiCodexService } from './oauth/openai-codex-service.js';
import { createOpenAiCodexE2eFixtureService } from './openai-codex-e2e-fixture.js';
import { GitHubCopilotSubscriptionService } from './oauth/github-copilot-subscription-service.js';
import { XaiOAuthService } from './oauth/xai-oauth-service.js';
import { AntigravitySubscriptionService } from './oauth/antigravity-subscription-service.js';
import type { WorkspacePrivacyContext } from '@maka/core/incognito';
import { ok } from '@maka/core/result';
import {
AgentGraphCoordinator,
AgentGraphSupervisorWakeCoordinator,
BackendRegistry,
FakeBackend,
SessionManager,
createLocalContinuationSafetyInspector,
createConfiguredSubagentCatalog,
buildDeepResearchTools,
getAIModel,
generateSessionTitle as generateRuntimeSessionTitle,
buildProviderOptions,
buildPricingLookup,
BotRegistry,
ShellRunProcessManager,
SessionActivityRegistry,
buildParentAgentTools,
listRunnableBuiltinAgentDefinitions,
listInvocableSkills,
prepareSkillInvocationMessage,
resolveSkillDiscoveryPaths,
} from '@maka/runtime';
import type {
BotIncomingMessage,
BotStatus,
GoalTurnOutcome,
HostCapabilities,
HostCapabilitiesResolver,
MakaTool,
} from '@maka/runtime';
import type { LlmConnection } from '@maka/core/llm-connections';
import {
createSqliteArtifactStore,
createSqliteDeepResearchStore,
createReadImageSnapshotter,
createConnectionStore,
createGitWorktreeChildExecutor,
createSqlitePlanReminderStore,
createSqlitePlanStore,
createProjectCatalog,
openRuntimeEventPersistence,
createSessionStore,
createSettingsStore,
createMcpConfigStore,
createSqliteModelCallLedger,
createSqliteTelemetryRepo,
resetIncompatibleOperationalStateDatabase,
} from '@maka/storage';
import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store';
import { resolveWorkspaceIdentity } from '@maka/storage/workspace-identity';
import { McpClientManager } from '@maka/mcp';
import { registerMcpIpcMain } from './mcp-ipc-main.js';
import {
ensureSessionCanSendOrRebind,
errorMessage,
requireReadyConnection,
} from './chat-readiness.js';
import { assertDesktopExecutionBoundary } from './desktop-execution-admission.js';
import { createFileCredentialStore } from './credential-store.js';
import { bindOnboardingDeps, createOnboardingService } from './onboarding-service.js';
import { createDailyReviewArchiveStore } from './daily-review-archive-store.js';
import { projectEmbeddedDeepResearch } from './deep-research-desktop-projection.js';
import { resolveE2eFixture, seedE2eFixture } from './e2e-fixture.js';
import { resolveBuildInfo } from './build-info.js';
import { resolveShellEnv } from './shell-env.js';
import { LocalMemoryService } from './local-memory-service.js';
import { createAttachmentApprovalRegistry } from './attachment-approval.js';
import { cleanupLegacyHistoryCompactArtifacts } from '@maka/runtime';
import { computerUseServiceHealth } from './computer-use-host.js';
import { createMainWindowController } from './main-window.js';
import { createDailyReviewMainService } from './daily-review-main.js';
import { createPlanReminderMainService } from './plan-reminders-main.js';
import { createBotIncomingMainService } from './bot-incoming-main.js';
import { createEmbeddedBotSessionAdapter } from './embedded-bot-session-adapter.js';
import { createSubscriptionModelFetch } from './subscription-model-fetch.js';
import { createSystemPromptMainService } from './system-prompt-main.js';
import { createMainTaskLedgerWiring } from './task-ledger-wiring.js';
import { createMainAutomationWiring, evaluateAutomationCanFire } from './automation-wiring.js';
import { createMainGoalWiring } from './goal-wiring.js';
import { createOAuthModelConnectionsMainService } from './oauth-model-connections-main.js';
import { registerMemoryIpc } from './memory-ipc-main.js';
import { registerSubscriptionIpc } from './subscription-ipc-main.js';
import { registerBrowserIpc } from './browser-ipc-main.js';
import { registerConnectionsIpc } from './connections-ipc-main.js';
import { registerConfigIpc } from './config-ipc-main.js';
import { registerPlanReminderIpc } from './plan-reminders-ipc-main.js';
import { registerWorkspaceResourcesIpc } from './workspace-resources-ipc-main.js';
import type { NewSessionSkillContext } from './workspace-resources-ipc-main.js';
import { registerDailyReviewIpc } from './daily-review-ipc-main.js';
import { registerInspectorIpc } from './inspector-ipc-main.js';
import { registerUsageIpc } from './usage-ipc-main.js';
import { registerWebSearchIpc } from './web-search-ipc-main.js';
import { registerNotificationsIpc } from './notifications-ipc-main.js';
import { registerAppIpc } from './app-ipc-main.js';
import { createAppUpdateService } from './app-update-service.js';
import { hasInterruptibleUpdateWork } from './app-update-activity.js';
import { registerWorkspaceSearchIpc } from './workspace-search-ipc-main.js';
import { registerOnboardingIpc } from './onboarding-ipc-main.js';
import { registerPermissionsIpc } from './permissions-ipc-main.js';
import { ensureBundledSkillInstalled } from './skills.js';
import {
createPermissionOverlayMain,
registerPermissionOverlayIpc,
} from './permission-overlay/permission-overlay-main.js';
import { registerSettingsIpc } from './settings-ipc-main.js';
import type { SettingsIpcHandle } from './settings-ipc-main.js';
import { createE2eFixtureBotOnboardingAdapters } from './bot-onboarding-e2e-fixture.js';
import { createKeepSystemAwakeController } from './keep-system-awake.js';
import { createSettingsRuntimeEffects } from './settings-runtime-effects.js';
import { createAiSdkBackendFactory, createSessionStreamer } from './session-stream.js';
import {
resolveDesktopBackendToolSurface,
resolveDesktopChildToolSurface,
resolveDesktopNewSessionSkillHost,
resolveDesktopSessionSkillHost,
} from './desktop-backend-tool-surface.js';
import { registerSessionsIpc } from './sessions-ipc-main.js';
import { registerAgentGraphIpc } from './agent-graph-ipc-main.js';
import { createVoiceIpcService, registerVoiceIpc } from './voice-ipc-main.js';
import {
assertSessionCanSendFromHeader,
isSessionLifecycleError,
sessionLifecycleErrorFromReadFailure,
} from './session-lifecycle.js';
import { createProjectRootController } from './project-root-controller.js';
import { createProjectManagementService } from './project-management-service.js';
import {
type DesktopCreateSessionInput,
resolveDesktopSessionSelection,
resolveNewSessionProjectInput,
} from './new-session-project.js';
import {
assertSessionWorkspaceAvailable,
isSessionWorkspaceUnavailableError,
resolveProjectContextRoot,
} from './project-context-root.js';
import { isComputerUseRealModelE2e, isE2e, isIsolatedE2e } from './startup-context.js';
import { resolveDesktopStorageRoot } from './storage-root-startup.js';
import { startupStep, whileAwaitingPerson } from './startup-step.js';
import { openDesktopExecutionStoreWiring } from './execution-store-wiring.js';
const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath());
// Resolve the user's login-shell PATH before any stores, tools, or child
// processes are created. On macOS, apps launched from Finder/Dock inherit a
// minimal PATH that lacks /opt/homebrew/bin, ~/.local/bin, etc. Only PATH is
// imported; application-control variables remain owned by this process.
// Skipped on Windows, when MAKA_SKIP_SHELL_ENV=1, and when launched from a
// terminal (TERM/COLORTERM set).
await resolveShellEnv();
// PR-VISUAL-SMOKE-HEADLESS: resolve the fixture defensively. An unknown
// scenario (e.g. a stale build, or a typo'd MAKA_E2E_FIXTURE) throws
// here during top-level module evaluation. Left uncaught it surfaces a
// blocking native error dialog. In fixture mode we instead log a parseable
// line and exit fast so the run fails in milliseconds with no dialog.
// Outside fixture mode the throw is rethrown.
let e2eFixture: ReturnType<typeof resolveE2eFixture>;
try {
e2eFixture = resolveE2eFixture(
process.env.MAKA_E2E_FIXTURE,
app.isPackaged,
process.env.MAKA_E2E_FIXTURE_REDUCED_MOTION,
process.env.MAKA_E2E_FIXTURE_THEME,
process.env.MAKA_E2E_FIXTURE_LOCALE,
process.env.MAKA_E2E_FIXTURE_TIMEZONE,
process.env.MAKA_E2E_FIXTURE_PLATFORM,
);
} catch (error) {
if (process.env.MAKA_E2E_FIXTURE) {
console.error(`[e2e-fixture] fatal: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
throw error;
}
const userDataDir = app.getPath('userData');
const workspaceRoot = join(userDataDir, 'workspaces', e2eFixture?.workspaceName ?? 'default');
const credentialStore = createFileCredentialStore(workspaceRoot);
if (e2eFixture) {
console.log(`[e2e-fixture] scenario=${e2eFixture.scenario} workspace=${workspaceRoot}`);
await seedE2eFixture({ workspaceRoot, fixture: e2eFixture, credentialStore });
} else {
const storageRoot = await startupStep(
'storage root',
resolveDesktopStorageRoot(workspaceRoot, {
confirmRepair: confirmDesktopStorageRootRepair,
}),
);
if (!storageRoot) {
app.exit(0);
await new Promise<never>(() => {});
}
}
if (resetIncompatibleOperationalStateDatabase(workspaceRoot)) {
console.warn('[startup] cleared incompatible operational state');
}
async function confirmDesktopStorageRootRepair(): Promise<boolean> {
if (!app.isReady()) {
throw new Error('storage-root repair dialog requires app ready');
}
// Explicit startup contract for the storage-root-conflict E2E: printed
// synchronously before the modal, so the test can observe the gate firing
// on any platform (macOS modal loops block CDP evaluation, Linux does not).
console.log('[storage-root] root-identity conflict; parking at repair dialog');
const isChinese = resolveSystemUiLocale(app.getPreferredSystemLanguages()) === 'zh';
// The person owns this delay, so the startup reporter stops calling it a
// hang — otherwise reading the dialog for four seconds prints "still waiting
// on storage root" at somebody who is looking straight at the reason. It
// still says once that an answer is expected, which is the only line printed
// when this dialog fails to appear at all.
const { response } = await whileAwaitingPerson(
dialog.showMessageBox({
type: 'warning',
title: isChinese ? 'Maka 工作区需要修复' : 'Maka workspace needs repair',
message: isChinese ? 'Maka 无法验证这个工作区。' : 'Maka cannot verify this workspace.',
detail: isChinese
? `系统中的磁盘标识可能发生了变化。仅当这是本机原来的 Maka 工作区、而不是复制出的工作区时,才选择修复。\n\n${workspaceRoot}`
: `The disk identity may have changed. Repair only if this is the original Maka workspace on this computer, not a copied workspace.\n\n${workspaceRoot}`,
buttons: isChinese ? ['修复工作区', '退出'] : ['Repair Workspace', 'Exit'],
defaultId: 1,
cancelId: 1,
noLink: true,
}),
);
return response === 0;
}
// 保持系统唤醒 (settings.system.keepSystemAwake): holds an Electron
// `powerSaveBlocker` so in-process scheduled tasks keep firing while the
// machine would otherwise sleep. Injected with electron's blocker; the
// controller owns the id + double-start guard. The blocker dies with the
// process, so quit needs no special teardown.
const keepSystemAwake = createKeepSystemAwakeController(powerSaveBlocker);
const store = createSessionStore(workspaceRoot);
const agentGraphControlStore = createAgentGraphControlStore(workspaceRoot);
const projectCatalog = createProjectCatalog(workspaceRoot, {
onLegacyImportFailure: (error) =>
console.error('[projects] projects.json could not be imported:', error),
});
const worktreeChildExecutor = createGitWorktreeChildExecutor({ storageRoot: workspaceRoot });
const planStore = createSqlitePlanStore(workspaceRoot);
const executionStoreWiring = await startupStep(
'execution store',
openDesktopExecutionStoreWiring(workspaceRoot),
);
const { runStore, shellRunStore } = executionStoreWiring;
const runtimePersistence = await startupStep(
'runtime event persistence',
openRuntimeEventPersistence({
workspaceRoot,
}),
);
const runtimeEventStore = runtimePersistence.runtimeEventStore;
const connectionStore = createConnectionStore(workspaceRoot);
const settingsStore = createSettingsStore(workspaceRoot);
const subagentCatalog = createConfiguredSubagentCatalog({
getSettings: () => settingsStore.get(),
getConnection: (slug) => connectionStore.get(slug),
});
const mcpConfigStore = createMcpConfigStore(workspaceRoot);
const mcpManager = new McpClientManager({ clientName: 'maka-desktop', clientVersion: app.getVersion() });
let mcpStartup: Promise<void> | undefined;
function ensureMcpReady(): Promise<void> {
if (!mcpStartup) {
const startup = mcpConfigStore.get().then((config) => mcpManager.sync(config));
mcpStartup = startup;
void startup.catch(() => {
if (mcpStartup === startup) mcpStartup = undefined;
});
}
return mcpStartup;
}
const telemetryRepo = createSqliteTelemetryRepo(workspaceRoot);
// Canonical model-call accounting ledger (#1679). Separate store, same
// operational database: `telemetryRepo` is now a frozen historical projection
// for LLM calls, and every model call dispatched from here settles into this.
const modelCallLedger = createSqliteModelCallLedger(workspaceRoot);
const dailyReviewArchiveStore = createDailyReviewArchiveStore(workspaceRoot);
const artifactStore = createSqliteArtifactStore(workspaceRoot);
const deepResearchStore = createSqliteDeepResearchStore(workspaceRoot);
const storeReadImage = createReadImageSnapshotter(artifactStore);
const attachmentApprovals = createAttachmentApprovalRegistry();
// PR-OAUTH-SUBSCRIPTION-0: Claude subscription OAuth service.
// Lives in main process only; renderer accesses via IPC. Tokens
// never cross the IPC boundary (xuan G-X3). Cloak path is dynamic-
// imported behind MAKA_CLAUDE_SUBSCRIPTION_CLOAK flag (xuan G-X4)
// and lives in a separate module not statically imported here.
const claudeSubscription = new ClaudeSubscriptionService({
userDataDir: app.getPath('userData'),
openExternal: (url) => shell.openExternal(url),
credentialStore,
});
// PR-MODEL-OAUTH-ALL-0: Codex / Antigravity subscription
// services. Same shape as `claudeSubscription` — main-process only,
// IPC payloads never carry tokens, each gated behind its own
// MAKA_*_EXPERIMENTAL env var. Antigravity is a `preview` placeholder
// until the Google client_id question is resolved.
const openAiCodex = e2eFixture?.scenario === 'oauth-relogin'
? createOpenAiCodexE2eFixtureService()
: new OpenAiCodexService({
userDataDir: app.getPath('userData'),
openExternal: (url) => shell.openExternal(url),
credentialStore,
});
const githubCopilotSubscription = new GitHubCopilotSubscriptionService({ credentialStore });
const xaiOAuth = new XaiOAuthService({
credentialStore,
openExternal: (url) => shell.openExternal(url),
});
const buildSubscriptionModelFetch = createSubscriptionModelFetch({
claudeSubscription,
openAiCodex,
xaiOAuth,
});
const oauthModelConnections = createOAuthModelConnectionsMainService({
connectionStore,
credentialStore,
claudeSubscription,
openAiCodex,
githubCopilotSubscription,
xaiOAuth,
...(e2eFixture?.scenario === 'oauth-relogin'
? { fetchModels: async () => [{ id: 'gpt-5.6-sol' }] }
: {}),
});
const isClaudeSubscriptionAuthenticatedState = oauthModelConnections.isClaudeSubscriptionAuthenticatedState;
function syncClaudeSubscriptionConnection(): Promise<LlmConnection | null> {
return oauthModelConnections.syncClaudeSubscriptionConnection();
}
function activateXaiOAuthConnection(): Promise<LlmConnection | null> {
return oauthModelConnections.activateXaiOAuthConnection();
}
function syncXaiOAuthConnection(): Promise<LlmConnection | null> {
return oauthModelConnections.syncXaiOAuthConnection();
}
function syncOpenAiCodexConnection(): Promise<LlmConnection | null> {
return oauthModelConnections.syncOpenAiCodexConnection();
}
function activateOpenAiCodexConnection(): Promise<LlmConnection | null> {
return oauthModelConnections.activateOpenAiCodexConnection();
}
function syncGitHubCopilotConnection(): Promise<LlmConnection | null> {
return oauthModelConnections.syncGitHubCopilotConnection();
}
function syncOAuthModelConnections(): Promise<void> {
return oauthModelConnections.syncOAuthModelConnections();
}
function disconnectManagedOAuthConnection(connection: LlmConnection): Promise<void> {
return oauthModelConnections.disconnectManagedOAuthConnection(connection);
}
function resolveConnectionSecret(slug: string): Promise<string | null> {
return oauthModelConnections.resolveConnectionSecret(slug);
}
const voiceIpcService = createVoiceIpcService({
settingsStore,
connectionStore,
resolveConnectionSecret,
});
/**
* Read-only credential-presence check for status paths (onboarding's
* `getSnapshot`) that must not trigger `resolveConnectionSecret`'s
* OAuth near-expiry refresh — that refresh hits the network and
* mutates local token state, which a read-only status read must never
* do just by being observed. Send/test/fetch-models paths keep using
* `resolveConnectionSecret` so they still benefit from the refresh.
*
* Takes the `LlmConnection` directly rather than a slug: callers that
* already hold the connection list (onboarding does) skip the extra
* `connectionStore.get()` round trip and derive state from one
* consistent snapshot.
*/
function hasConnectionSecret(connection: LlmConnection): Promise<boolean> {
return oauthModelConnections.hasConnectionSecret(connection);
}
const antigravitySubscription = new AntigravitySubscriptionService({
userDataDir: app.getPath('userData'),
openExternal: (url) => shell.openExternal(url),
credentialStore,
});
const planReminderStore = createSqlitePlanReminderStore(workspaceRoot);
const taskLedgerWiring = createMainTaskLedgerWiring(workspaceRoot);
const taskLedgerStore = taskLedgerWiring.store;
async function closeWorkflowStores(): Promise<void> {
const stores = [
planStore,
deepResearchStore,
planReminderStore,
taskLedgerStore,
];
const errors: unknown[] = [];
for (const result of await Promise.allSettled(stores.map((workflowStore) => workflowStore.ready()))) {
if (result.status === 'rejected') errors.push(result.reason);
}
for (const workflowStore of stores) {
try {
workflowStore.close();
} catch (error) {
errors.push(error);
}
}
if (errors.length > 0) throw new AggregateError(errors, 'Unable to close workflow stores');
}
const sessionActivities = new SessionActivityRegistry();
// Unified Automation — single "Automation" tool for heartbeat + cron.
// Deps are resolved lazily since runtime/store aren't ready at this point.
const automationWiring = createMainAutomationWiring({
workspaceRoot,
async canFire(automation): Promise<boolean> {
// Kind-aware fire gate (see evaluateAutomationCanFire): incognito blocks all;
// cron is never gated on its creator session; heartbeat needs an idle session.
return evaluateAutomationCanFire(automation, {
isIncognitoActive: async () => (await getWorkspacePrivacyContext()).incognitoActive,
readSessionHeader: (sessionId) => store.readHeader(sessionId),
});
},
// Heartbeat: inject into the automation's own session; resolve after the stream.
async injectTurn(sessionId: string, prompt: string, automationId: string) {
await ensureSessionCanSend(sessionId);
const turnId = randomUUID();
const iterator = runtime.sendMessage(sessionId, {
turnId, text: prompt, origin: { kind: 'automation', automationId },
});
const r = await streamEvents(sessionId, iterator, {
turnId,
goalBoundary: 'external',
});
return { runId: turnId, ok: r.ok, ...(r.error ? { error: r.error } : {}) };
},
// Cron: spawn a FRESH session (explore mode — no unapproved side effects) and
// run the prompt there, so each fire is a first-class session + run.
async createFreshRun(prompt: string, automationId: string) {
const slug = await connectionStore.getDefault();
const { connection, model } = await getReadyConnection(slug, undefined);
const session = await createDesktopSession({
backend: 'ai-sdk',
llmConnectionSlug: connection.slug,
model,
permissionMode: 'explore',
name: `Automation: ${prompt.slice(0, 32)}`,
labels: ['automation', 'cron'],
});
emitSessionsChanged('created', session.id);
await ensureSessionCanSend(session.id);
const turnId = randomUUID();
const iterator = runtime.sendMessage(session.id, {
turnId, text: prompt, origin: { kind: 'automation', automationId },
});
const r = await streamEvents(session.id, iterator, {
turnId,
goalBoundary: 'external',
});
// Archive the fresh cron session after its run finalizes so recurring crons
// do not accumulate an unbounded pile of active sessions. The session (with
// its run/trace) is preserved under the archive, labelled automation/cron.
try {
await agentGraphCoordinator.stop(session.id);
await goalWiring.archiveSession(session.id, () => runtime.archive(session.id));
desktopSessionSkillHosts.delete(session.id);
emitSessionsChanged('archived', session.id);
} catch {}
return { runId: turnId, ok: r.ok, ...(r.error ? { error: r.error } : {}) };
},
});
// Load durable Automations from operational storage on startup.
void automationWiring.loadDurableAutomations();
// Goal execution — autonomous turn-boundary continuation with an external
// evaluator (CC-style). Self-contained: no automation coupling (a goal is
// bounded by its own caps; a waiting goal re-checks via normal continuation).
const goalWiring = createMainGoalWiring({
getDefaultConnectionSlug: () => connectionStore.getDefault(),
getConnection: (slug) => connectionStore.get(slug),
getSessionModel: async (sessionId) => {
const header = await store.readHeader(sessionId);
if (!header) return null;
return { connectionSlug: header.llmConnectionSlug, model: header.model };
},
resolveConnectionSecret,
buildSubscriptionModelFetch,
getAIModel: (input) => getAIModel(input),
buildProviderOptions: (connection, modelId) => buildProviderOptions(connection, modelId),
getRecentMessages: async (sessionId) => {
const messages = await runtime.getMessages(sessionId);
return messages.slice(-10).map((m) => ({
type: m.type,
text: m.type === 'user' || m.type === 'assistant' ? m.text : undefined,
}));
},
getTokenCount: async (sessionId) => {
const messages = await runtime.getMessages(sessionId);
let total = 0;
for (const m of messages) {
if (m.type === 'token_usage') total += (m.total ?? (m.input + m.output));
}
return total;
},
admitTurn: (sessionId, text) => {
const whenIdle = sessionActivities.whenIdle(sessionId);
if (whenIdle) return { kind: 'busy', whenIdle };
const reservation = sessionActivities.reserve(sessionId);
const turnId = randomUUID();
return {
kind: 'prepared',
turnId,
start: async (): Promise<GoalTurnOutcome> => {
try {
await ensureSessionCanSend(sessionId);
const iterator = runtime.sendMessage(sessionId, { turnId, text });
return (await streamEvents(sessionId, iterator, {
turnId,
goalBoundary: 'coordinator',
activity: reservation,
})).outcome;
} catch (error) {
reservation.release();
return {
kind: 'errored',
turnId,
reason: `Goal continuation could not start: ${errorMessage(error)}`,
};
}
},
};
},
// Surface every goal transition to the renderer so an active autonomous loop
// is visible (badge + clear affordance) — never a silent token burn.
onGoalChange: (goal) => emitSessionsChanged('goal-change', goal.sessionId),
listActionableTaskKeys: async (sessionId) => {
const tasks = await taskLedgerStore.list(sessionId, {
includeTerminal: false,
includeArchived: false,
});
return filterModelVisibleTaskLedgerTasks(tasks)
.filter((task) => task.status === 'pending' || task.status === 'in_progress')
.map((task) => task.key);
},
recordTaskGateDecision: async (trace) => {
const runs = await runStore.listSessionRuns(trace.sessionId);
const run = runs.find((candidate) => candidate.turnId === trace.turnId);
if (!run) return;
await runStore.appendEvent(trace.sessionId, run.runId, {
type: 'task_gate_decided',
id: randomUUID(),
runId: run.runId,
sessionId: trace.sessionId,
turnId: trace.turnId,
ts: Date.now(),
message: `Task gate: ${trace.decision}`,
data: {
goalId: trace.goalId,
decision: trace.decision,
taskKeys: trace.taskKeys,
},
});
},
});
async function getWorkspacePrivacyContext(): Promise<WorkspacePrivacyContext> {
const settings = await settingsStore.get();
return { incognitoActive: settings.privacy.incognitoActive === true };
}
const localMemory = new LocalMemoryService({
workspaceRoot,
getSettings: () => settingsStore.get(),
updateSettings: (patch) => settingsStore.update(patch),
getPrivacyContext: getWorkspacePrivacyContext,
});
// The synchronous Runtime Skill tools execute inside an already-built backend.
// Their resolver uses the exact host cached by that backend. Pre-send
// invocation and slash discovery derive a fresh surface from the persisted
// session header instead; see resolveDesktopSkillHostForSession below.
const desktopSessionSkillHosts = new Map<string, HostCapabilities>();
const resolveDesktopSkillHost: HostCapabilitiesResolver = ({ sessionId }) =>
desktopSessionSkillHosts.get(sessionId) ?? desktopProductToolSurface.hostCapabilities;
// Window is created hidden for E2E and e2e-fixture runs so it never steals
// focus. Derived from the same isE2e gate as userData/fake-backend so the
// hidden-window switch stays in lockstep with the rest of the E2E isolation.
// MAKA_E2E_SHOW_WINDOW opts back into a visible window where there is no
// focus to steal (CI under xvfb): hidden windows only get ~1fps compositor
// BeginFrames on Linux, which stalls content-visibility inflation and any
// frame-paced E2E protocol (measured in the scroll-geometry climb: 38 frames
// over 31s). The E2E harness sets it, not the workflow — see fixtures.ts.
// This value is also what hides the macOS dock icon (see app-lifecycle.ts):
// staying out of sight and staying out of the Dock are one decision, so a run
// that opts into a visible window also opts back into Dock and Cmd+Tab.
const startHidden = (Boolean(e2eFixture) || isIsolatedE2e)
&& process.env.MAKA_E2E_SHOW_WINDOW !== '1';
let onMainWindowClose = (): void => {};
const mainWindowController = createMainWindowController({
workspaceRoot,
e2eFixture,
settingsStore,
startHidden,
onClose: () => onMainWindowClose(),
});
// Shared by 'second-instance' and 'activate': focus the existing window, or
// create one if all windows were closed while the app (macOS: still in the
// dock) stayed running -- a second launch attempt must not be a silent no-op.
function focusOrCreateMainWindow(signal: AbortSignal): void {
if (mainWindowController.hasOpenWindows()) {
mainWindowController.focus();
} else {
void mainWindowController
.createWindow(signal)
.catch((error) => console.error('[window] failed to create:', error));
}
}
const safeSendToRenderer = mainWindowController.send;
const updateMockState = process.env.MAKA_UPDATE_MOCK_STATE === 'available' ||
process.env.MAKA_UPDATE_MOCK_STATE === 'downloading' ||
process.env.MAKA_UPDATE_MOCK_STATE === 'downloaded'
? process.env.MAKA_UPDATE_MOCK_STATE
: undefined;
taskLedgerStore.subscribe((event) => safeSendToRenderer('tasks:changed', event));
deepResearchStore.subscribe((event) => safeSendToRenderer('deepResearch:changed', event));
const deepResearchTools = buildDeepResearchTools({
store: deepResearchStore,
artifactStore,
onArtifactCreated: (event) => safeSendToRenderer('artifacts:changed', event),
});
const backends = new BackendRegistry();
const shellRuns = new ShellRunProcessManager({
store: shellRunStore,
newId: randomUUID,
now: Date.now,
onShellRunUpdate: (update) => {
safeSendToRenderer('shell-runs:update', update);
},
});
const updateService = createAppUpdateService({
currentVersion: app.getVersion(),
isPackaged: app.isPackaged,
openExternal: (url) => shell.openExternal(url),
mockLatestVersion: process.env.MAKA_UPDATE_MOCK_VERSION,
mockState: updateMockState,
onStatusChange: (status) => safeSendToRenderer('app:updateStatusChanged', status),
hasActiveTasks: () => hasInterruptibleUpdateWork({
sessionActivities,
automationScheduler: automationWiring.scheduler,
shellRuns,
}),
});
const {
persistToolArtifacts,
snapshotReadImage,
toolResultArchive,
} = createToolArtifactPersistence({ artifactStore, storeReadImage, safeSendToRenderer });
const {
riveTools,
browserTools,
computerUse,
computerUseOverlay,
computerUsePip,
computerUseStatusItem,
computerUseScreenLock,
computerUseTools,
desktopProductToolSurface,
builtinTools,
childAgentTools,
sandboxDiagnosticsProvider,
} = assembleDesktopTools({
// The mirror anchors to the app window and becomes its child; without this
// it falls back to floating above every application on the primary display,
// with no pointer to hover-test against and so no controls at all.
mainWindow: mainWindowController,
keepSystemAwake,
isComputerUseRealModelE2e,
workspaceRoot,
taskLedgerStore,
taskLedgerWiring,
automationWiring,
goalWiring,
settingsStore,
updateAgentSettings,
shellRuns,
artifactStore,
snapshotReadImage,
getWorkspacePrivacyContext,
resolveDesktopSkillHost,
});
if (computerUse.backendId !== 'none') {
const seededComputerUseSkill = await startupStep(
'Computer Use skill',
ensureBundledSkillInstalled(workspaceRoot, 'computer-use'),
);
if (!seededComputerUseSkill.ok) {
console.warn(
`[skills] Computer Use is available, but its bundled Skill was not installed: ${seededComputerUseSkill.reason}`,
);
}
}
let agentGraphCoordinator: AgentGraphCoordinator;
let agentGraphSupervisorWakeCoordinator: AgentGraphSupervisorWakeCoordinator;
const desktopBackendToolSurfaceDeps = {
isComputerUseRealModelE2e,
ensureMcpReady,
getReadyConnection,
mcpManager,
deepResearchTools,
computerUseTools,
builtinTools,
toolEconomy: desktopProductToolSurface.identity.policy.economy,
planStore,
getWebSearchSettings: async () => (await settingsStore.get()).webSearch,
getPrivacySettings: async () => (await settingsStore.get()).privacy,
childTools: childAgentTools,
buildParentAgentToolsForChildSurface: (tools: readonly MakaTool[]) =>
buildParentAgentTools({
taskLedger: taskLedgerStore,
definitions: listRunnableBuiltinAgentDefinitions({
tools,
worktreeChildExecutorAvailable: worktreeChildExecutor !== undefined,
}),
}),
getAgentGraphSupervisorTools: (sessionId: string) =>
agentGraphCoordinator.toolsForSession(sessionId),
};
async function resolveDesktopChildTools(sessionId: string) {
const header = await store.readHeader(sessionId);
return resolveDesktopChildToolSurface(desktopBackendToolSurfaceDeps, {
header,
tools: childAgentTools,
});
}
// Cursor-overlay teardown assigns a module-scoped `let`, so it stays in boot.ts.
onMainWindowClose = () => {
computerUseOverlay.destroyAll();
// The mirror is a child of the window that just closed; without this it
// outlives its parent, still polling the pointer at 20Hz.
computerUsePip.destroyAll();
};
const systemPromptService = createSystemPromptMainService({
settingsStore,
workspaceRoot,
localMemory,
taskLedger: taskLedgerStore,
goalManager: goalWiring.manager,
hostCapabilities: desktopProductToolSurface.hostCapabilities,
});
let lookupPricing = buildPricingLookup();
let usageReadiness: Promise<void> | undefined;
function ensureUsageReady(): Promise<void> {
if (!usageReadiness) {
const readiness = telemetryRepo.load().then(() => {
lookupPricing = buildPricingLookup(telemetryRepo.listPricingOverrides());
});
usageReadiness = readiness;
void readiness.catch(() => {
if (usageReadiness === readiness) usageReadiness = undefined;
});
}
return usageReadiness;
}
// Track the last status fields that affect persisted diagnostics. The reason
// is part of the key because a running bridge can remain degraded while a
// newer, more useful failure replaces the previous one.
const previousBotStatus = new Map<BotProvider, Pick<BotStatus, 'readiness' | 'reason'>>();
let botIncoming: ReturnType<typeof createBotIncomingMainService>;
// Single authority for the "current project root" selection, shared across the
// app/window, git, workspace-search, and session-entry IPC surfaces.
// botIncoming and automation cron runs read the current
// selection through the thin `resolveCurrentProjectRoot` adapter below.
const projectRootController = createProjectRootController({
lastProjectPathFile: join(workspaceRoot, 'last-project-path.json'),
fallbackRoots: () => [process.cwd(), app.getAppPath()],
});
const projectManagement = createProjectManagementService({
catalog: projectCatalog,
sessions: store,
chooseDirectory: async () => {
const result = await mainWindowController.showOpenDialog({
title: '添加项目',
properties: ['openDirectory'],
});
return result.canceled ? undefined : result.filePaths[0];
},
selection: projectRootController,
});
const resolveCurrentProjectRoot: () => Promise<string> = () => projectRootController.current();
const resolveProjectRootForContext = (sessionId: unknown): Promise<string> =>
resolveProjectContextRoot(sessionId, {
currentProjectRoot: resolveCurrentProjectRoot,
readSessionCwd: async (id) => (await store.readHeader(id)).cwd,
});
const botRegistry = new BotRegistry({
onIncomingMessage: (message: BotIncomingMessage) => {
// Only log incoming bot messages in dev — production stdout leaking
// platform + chatId is operational noise at best and a small privacy
// signal at worst (which bridges are connected, with what frequency).
if (process.env.VITE_DEV_SERVER_URL || process.env.NODE_ENV === 'development') {
console.log('[bot] incoming message', message.platform, message.chatId);
}
void botIncoming.handleBotIncomingMessage(message);
},
onStatusChange: (status: BotStatus) => {
safeSendToRenderer('settings:bots:statusChanged', status);
// PR-BOT-LASTERROR-FROM-SEND-0: persist send-path failure reasons
// to settings so they survive a Settings page close/reopen. The
// existing connection-test path writes `lastError` only on test
// failures; without this hook, a runtime 429 / timeout would
// disappear the moment the renderer status panel closed.
const previous = previousBotStatus.get(status.platform);
previousBotStatus.set(status.platform, {
readiness: status.readiness,
reason: status.reason,
});
const update = deriveBotStatusPersistenceUpdate(previous, status);
if (update) {
void settingsStore.update({
botChat: {
channels: {
[status.platform]: {
...update,
readinessUpdatedAt: Date.now(),
},
},
},
}).catch(() => {});
}
},
});
const planReminders = createPlanReminderMainService({
store: planReminderStore,
getPrivacyContext: getWorkspacePrivacyContext,
sendBotMessage: (platform, chatId, text) =>
botRegistry.sendMessage(platform, chatId, text),
emitChanged: (reason, reminder) => {
safeSendToRenderer('plans:changed', {
type: 'plans_changed',
reason,
reminderId: reminder.id,
ts: Date.now(),
});
},
emitDue: (reminder) => {
safeSendToRenderer('plans:due', reminder);
},
});
backends.register('ai-sdk', createAiSdkBackendFactory({
...desktopBackendToolSurfaceDeps,
buildSubscriptionModelFetch,
systemPromptService,
telemetryRepo,
modelCallLedger,
ensureUsageReady,
artifactStore,
desktopSessionSkillHosts,
sandboxDiagnosticsProvider,
persistToolArtifacts,
toolResultArchive,
runtimeCommitStore: runtimePersistence.runtimeCommitStore,
safeSendToRenderer,
emitSessionsChanged,
getRuntime: () => runtime,
getLookupPricing: () => lookupPricing,
}));
backends.register('fake', (ctx) =>
new FakeBackend({ sessionId: ctx.sessionId, header: ctx.header, store: ctx.store, appendMessage: ctx.appendMessage }),
);
// E2E: also route 'ai-sdk' (requested by sessions:create, the single
// session-creation IPC) through the deterministic fake backend, so no
// session-creation path can escape the E2E seam and hit a real provider.
// Registered after the real ai-sdk factory to override it (BackendRegistry
// uses last-write-wins).
// Production builds never set MAKA_E2E.
if (isE2e) {
backends.register('ai-sdk', (ctx) =>
new FakeBackend({ sessionId: ctx.sessionId, header: ctx.header, store: ctx.store, appendMessage: ctx.appendMessage }),
);
}
const runtime = new SessionManager({
store,
planStore,
runStore,
runtimeEventStore,
...(runtimePersistence.runtimeCommitStore
? {
runtimeCommitSink: runtimePersistence.runtimeCommitStore,
toolBoundaryProtocol: runtimePersistence.runtimeCommitStore.toolBoundaryProtocol,
}
: {}),
shellRuns,
backends,
childTools: childAgentTools,
resolveChildTools: resolveDesktopChildTools,
subagentCatalog,
worktreeChildExecutor,
safeBoundaryResumeEnabled: process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME === '1',
onContinuationLifecycleEvent: (event) => {
console.info('[runtime-resume]', JSON.stringify(event));
},
inspectContinuationSafety: createLocalContinuationSafetyInspector({
readSessionCwd: async (sessionId) => (await store.readHeader(sessionId)).cwd,
resolveWorkspaceIdentity: async (cwd) => resolveWorkspaceIdentity({ path: cwd }),
listAvailableToolNames: async () => builtinTools.map((tool) => tool.name),
hasPendingBackgroundOperations: async (sessionId) => {
const [shellUpdates, runs] = await Promise.all([
shellRuns.listSessionUpdates(sessionId),
runStore.listSessionRuns(sessionId),
]);
return (
shellUpdates.some((update) => isActiveShellRunStatus(update.result.status)) ||
runs.some(
(run) =>
run.parentRunId !== undefined &&
['created', 'running', 'waiting_for_user'].includes(run.status),
)
);
},
}),
listArtifactsForTurn: async (sessionId, turnId) =>
(await artifactStore.list(sessionId)).filter((artifact) =>
artifact.turnId === turnId && artifact.status !== 'deleted'
),
cleanupHistoryCompactArtifacts: async (input) => {
await cleanupLegacyHistoryCompactArtifacts({
...input,
artifactStore,
onDiagnostic: (diagnostic) => console.warn('[history-compact-cleanup]', diagnostic),
});
},
generateSessionTitle: async ({ sessionId, header, sourceText }) => {
const { connection, apiKey, model } = await getReadyConnection(header.llmConnectionSlug, header.model);
return generateRuntimeSessionTitle({
model: getAIModel({
connection,
apiKey: apiKey ?? '',
modelId: model,
fetch: buildSubscriptionModelFetch(connection, sessionId, model),
}),
providerOptions: buildProviderOptions(connection, model),
sourceText,
});
},
onSessionTitleChanged: (sessionId) => emitSessionsChanged('renamed', sessionId),
newId: randomUUID,
now: Date.now,
});
agentGraphSupervisorWakeCoordinator = new AgentGraphSupervisorWakeCoordinator({
activityRegistry: sessionActivities,
wakeStore: agentGraphControlStore,
readSnapshot: (rootSessionId) => agentGraphCoordinator.getSnapshot(rootSessionId),
startTurn: async (sessionId, input, activity, abortSignal) => {
let stopPromise: Promise<void> | undefined;
const stop = () => {
stopPromise ??= runtime.stopSession(sessionId, { source: 'graph_supervisor' });
};
abortSignal.addEventListener('abort', stop, { once: true });
if (abortSignal.aborted) stop();
try {
await ensureSessionCanSend(sessionId);
if (abortSignal.aborted) {
return { kind: 'aborted', turnId: input.turnId };
}
const iterator = runtime.sendMessage(sessionId, input);
return (
await streamEvents(sessionId, iterator, {
turnId: input.turnId,
goalBoundary: 'none',
activity,
})
).outcome;
} finally {
abortSignal.removeEventListener('abort', stop);
await stopPromise;
}
},
inspectAttempt: async (rootSessionId, attemptId, turnId) => {
const runs = (await runStore.listSessionRuns(rootSessionId)).filter(
(run) => run.agentGraphWakeAttemptId === attemptId && run.turnId === turnId,
);
if (runs.length > 1) {
throw new Error(
`Agent graph supervisor wake attempt ${attemptId} has multiple AgentRuns`,
);
}
return runs[0]?.status ?? 'missing';
},
newId: randomUUID,
onError: (rootSessionId) => {
emitSessionsChanged('status-change', rootSessionId);
},
});
agentGraphCoordinator = new AgentGraphCoordinator({
sessionStore: store,
runStore,
runtimeEventStore,
controlStore: agentGraphControlStore,
runtime,
newId: randomUUID,
onReconciliation: (rootSessionId, result) => {
agentGraphSupervisorWakeCoordinator.notify(rootSessionId, result);
},
});
let settingsIpc: SettingsIpcHandle | undefined;
let mcpToolSnapshot = JSON.stringify(mcpManager.tools());
mcpManager.onChange(() => {
safeSendToRenderer('mcp:changed', mcpManager.statuses());
const nextSnapshot = JSON.stringify(mcpManager.tools());
if (nextSnapshot === mcpToolSnapshot) return;
mcpToolSnapshot = nextSnapshot;
void runtime.refreshIdleBackends().catch((error) => {
console.warn('[mcp] failed to refresh backend tool snapshots:', error);
});
});
const dailyReview = createDailyReviewMainService({
archiveStore: dailyReviewArchiveStore,
connectionStore,
telemetryRepo,
modelCallLedger,
ensureUsageReady,
listSessions: async () => collapseSessionRevisions(await runtime.listSessions()),
resolveConnectionSecret,
buildSubscriptionModelFetch,
});
botIncoming = createBotIncomingMainService({
botRegistry,
sessions: createEmbeddedBotSessionAdapter({
runtime,
createSession: createDesktopSession,
getDefaultConnectionSlug: () => connectionStore.getDefault(),
getReadyConnection,
readSessionHeader: async (sessionId) => {
try {
return await store.readHeader(sessionId);
} catch (error) {
throw sessionLifecycleErrorFromReadFailure(error) ?? error;
}
},
ensureSessionCanSend,
emitSessionsChanged,
runAgentTurn: ({ sessionId, iterator, turnId, onEvent }) =>
streamEvents(sessionId, iterator, {
turnId,
goalBoundary: 'external',
observeEvent: onEvent,
}),
}),
});
// PR110b: onboarding service composes existing stores + runtime to
// derive `OnboardingState` and manage `OnboardingMilestone[]`.
// Constructed AFTER `runtime` so `listSessions()` is bindable. The
// service checks credential presence through `hasConnectionSecret`
// (read-only — recognizes OAuth-subscription connections like the
// send-path's `resolveConnectionSecret` does, but never refreshes),
// so simply opening onboarding can't hit the network or mutate token
// state.
const onboardingService = createOnboardingService(
bindOnboardingDeps({
settingsStore,
connectionStore,
hasCredential: hasConnectionSecret,
listSessions: () => runtime.listSessions(),
}),
);
function registerIpc(): void {
const currentProjectRoot = resolveCurrentProjectRoot;
ipcMain.handle('deepResearch:get', async (_event, sessionId: string) =>
projectEmbeddedDeepResearch(await deepResearchStore.read(sessionId)));
registerMcpIpcMain({
ipcMain,
store: mcpConfigStore,
manager: mcpManager,
ensureReady: ensureMcpReady,
refreshIdleBackends: () => runtime.refreshIdleBackends(),
emitChanged: (statuses) => safeSendToRenderer('mcp:changed', statuses),
});
registerAppIpc({
mainWindowController,
projectRoot: projectRootController,
getSessionProjectRoot: async (sessionId) => (await store.readHeader(sessionId)).cwd,
getProjectRoot: resolveProjectRootForContext,
workspaceRoot,
buildInfo,
e2eFixture,
projectManagement,
updateService,
});
registerMemoryIpc({ localMemory });
registerConfigIpc({ connectionStore, settingsStore, credentialStore, workspaceRoot });
registerNotificationsIpc({ settingsStore, mainWindowController, e2e: isE2e });
registerWorkspaceResourcesIpc({
workspaceRoot,
artifactStore,
mainWindowController,
sendToRenderer: safeSendToRenderer,
listInvocableSkills: listDesktopInvocableSkills,
skillHost: desktopProductToolSurface.hostCapabilities,
getCurrentProjectRoot: currentProjectRoot,
getSkillSelectionReport: systemPromptService.getLastSkillSelectionReport,
invalidateSkillSelectionReport: systemPromptService.invalidateSkillSelectionReport,
});
registerWorkspaceSearchIpc({ getProjectRoot: resolveProjectRootForContext });
registerPlanReminderIpc({ planReminders, getWorkspacePrivacyContext });
registerAgentGraphIpc({
coordinator: agentGraphCoordinator,
sendToRenderer: safeSendToRenderer,
});
registerVoiceIpc({ ipcMain, service: voiceIpcService });
registerSessionsIpc({
workspaceRoot,
runtime,
store,
taskLedgerStore,
goalWiring,
automationManager: automationWiring.manager,
computerUseOverlay,
computerUsePip,
computerUseStatusItem,
computerUseScreenLock,
computerUseTools,
artifactStore,
attachmentApprovals,
settingsStore,
connectionStore,
mainWindowController,
e2eFixture,
emitSessionsChanged,
ensureSessionCanSend,
prepareSkillInvocation: prepareDesktopSkillInvocation,
invalidateSessionBindings: (sessionId) => botIncoming.invalidateSessionBindings(sessionId),
clearSkillHost: (sessionId) => desktopSessionSkillHosts.delete(sessionId),
stopAgentGraph: async (sessionId) => {
const header = await store.readHeader(sessionId);
if (!header.subagentParent) await agentGraphCoordinator.stop(sessionId);
},
notifyAgentGraphPermissionResponse: (sessionId) => {
agentGraphSupervisorWakeCoordinator.notifyPermissionResponse(sessionId);
},
ensureSessionWorkspaceAvailable,
createSession: createDesktopSession,
getReadyConnection,
streamEvents,
getWorkspacePrivacyContext,
canCreateFakeSession: canCreateFakeSessionFromRenderer,
consumeNativeAudioOperation: (input) =>
voiceIpcService.consumeNativeAudioOperation(input),
});
registerSubscriptionIpc({
ipcMain,
connectionStore,
claudeSubscription,
openAiCodex,
githubCopilotSubscription,
xaiOAuth,
antigravitySubscription,
isClaudeSubscriptionAuthenticatedState,
syncClaudeSubscriptionConnection,
activateOpenAiCodexConnection,
syncOpenAiCodexConnection,
syncGitHubCopilotConnection,
activateXaiOAuthConnection,
syncXaiOAuthConnection,
emitConnectionListChanged,
});
registerWebSearchIpc({ settingsStore, getWorkspacePrivacyContext });
registerBrowserIpc({ mainWindowController });
registerConnectionsIpc({
ipcMain,
connectionStore,
credentialStore,
syncOAuthModelConnections,
resolveConnectionSecret,
hasConnectionSecret,
disconnectManagedOAuthConnection,
emitConnectionListChanged,
// Same seam as the fake-backend override above, for the other IPC that can
// leave the machine: adding a catalog provider runs remote model discovery
// against the provider's real endpoint. In E2E the key is a placeholder, so
// discovery can only fail — but it fails at whatever speed the network
// answers, and the add dialog stays open for the whole round trip. The
// provider-side budget (10s) is exactly the suite's expect timeout (10s),
// so a slow answer flips `await expect(dialog).toBeHidden()` from pass to
// fail with no code change. Fail deterministically and offline instead,
// which is the outcome a placeholder key produces anyway.
...(isE2e
? {
fetchModels: async () => {
throw new Error('E2E: remote model discovery is disabled');
},
}
: {}),
});
registerOnboardingIpc({ onboardingService });
registerPermissionsIpc({
settingsStore,
connectionStore,
telemetryRepo,
modelCallLedger,
ensureUsageReady,
botRegistry,
getComputerUseCapabilityInput: computerUseCapabilityInput,
});
// Drag-to-grant onboarding for the two TCC permissions macOS offers no
// programmatic consent dialog for. See docs/permission-onboarding-plan.md.
const permissionOverlay = createPermissionOverlayMain({
resolveLocale: async () => {
const settings = await settingsStore.get();
return resolveUiLocale(
settings.personalization.uiLocale,
resolveSystemUiLocale(app.getPreferredSystemLanguages()),
);
},
});
registerPermissionOverlayIpc({ controller: permissionOverlay, ipcMain });
// A screen-saver-level panel pinned to every Space is visible to the
// user if it outlives a slow quit; close it explicitly rather than
// relying on process teardown to race it away.
app.on('before-quit', () => permissionOverlay.dismiss());
settingsIpc = registerSettingsIpc({
settingsStore,
botRegistry,
normalizeSettingsPatch,
applySettingsRuntimeEffects,
...(e2eFixture?.scenario === 'settings-bots'
? {
botOnboardingAdapters: createE2eFixtureBotOnboardingAdapters(),
botOnboardingApplySettingsRuntimeEffects: async () => undefined,
// The fixture no-ops runtime effects, so no real bridge starts.
// Report the onboarded channel as running to demonstrate the
// successful "connected" path (the P0-3 warning path is covered by
// bot-onboarding-main.test.ts).
botOnboardingReadChannelStatus: () => ({ running: true }),
}
: {}),
});
registerDailyReviewIpc({ dailyReview, dailyReviewArchiveStore, mainWindowController });
registerInspectorIpc({
ipcMain,
readSessionRuntimeEvents: (sessionId) => runtimeEventStore.readSessionRuntimeEvents(sessionId),
listSessionRuns: (sessionId) => runStore.listSessionRuns(sessionId),
readRunEvents: (sessionId, runId) => runStore.readEvents(sessionId, runId),
});
registerUsageIpc({
ipcMain,
settingsStore,
telemetryRepo,
modelCallLedger,
readRunEvents: (sessionId, runId) => runStore.readEvents(sessionId, runId),
ensureUsageReady,
refreshPricingLookup: () => {
lookupPricing = buildPricingLookup(telemetryRepo.listPricingOverrides());
},
sendToRenderer: safeSendToRenderer,
});
}
function canCreateFakeSessionFromRenderer(): boolean {
return !app.isPackaged && (
Boolean(e2eFixture) ||
Boolean(process.env.VITE_DEV_SERVER_URL) ||
process.env.NODE_ENV === 'development'
);
}
const { normalizeSettingsPatch, applySettingsRuntimeEffects, handleExternalSettingsChange } =
createSettingsRuntimeEffects({
settingsStore,
botRegistry,
keepSystemAwake,
safeSendToRenderer,
});
async function updateAgentSettings(patch: UpdateAppSettingsInput): Promise<AppSettings> {
const normalizedPatch = await normalizeSettingsPatch(patch);
const next = await settingsStore.update(normalizedPatch);
await applySettingsRuntimeEffects(next, patch);
safeSendToRenderer('settings:externalChanged', { ts: Date.now() });
return next;
}
const streamEvents = createSessionStreamer({
sessionActivities,
goalWiring,
computerUseOverlay,
computerUsePip,
computerUseStatusItem,
computerUseScreenLock,
computerUseTools,
safeSendToRenderer,
emitSessionsChanged,
interruptActivePlanExecution: (sessionId, reason) =>
runtime.interruptActivePlanExecution(sessionId, reason),
});
async function ensureSessionCanSend(sessionId: string): Promise<void> {
const boundary = await runtime.readExecutionBoundary(sessionId);
assertDesktopExecutionBoundary(sessionId, boundary);
const header = await readAvailableSessionHeader(sessionId);
let result: Awaited<ReturnType<typeof ensureSessionCanSendOrRebind>>;
try {
result = await ensureSessionCanSendOrRebind(sessionId, header, {
readyConnectionDeps,
getDefaultSlug: () => connectionStore.getDefault(),
listConnectionSlugs: async () => (await connectionStore.list()).map((connection) => connection.slug),
updateSession: (_sessionId, patch) => runtime.updateSession(_sessionId, {
...patch,
status: 'active',
blockedReason: undefined,
statusUpdatedAt: Date.now(),
}),
});
} catch (error) {
if (isSessionLifecycleError(error)) throw error;
await runtime.setSessionStatus(sessionId, 'blocked', 'NO_REAL_CONNECTION').catch(() => {});
emitSessionsChanged('status-change', sessionId);
throw error;
}
if (result.rebound) {
emitSessionsChanged('rebound', sessionId, {
connectionSlug: result.connectionSlug,
modelId: result.modelId,
});
}
}
async function readAvailableSessionHeader(sessionId: string) {
let header;
try {
header = await store.readHeader(sessionId);
} catch (error) {
const lifecycleError = sessionLifecycleErrorFromReadFailure(error);
if (lifecycleError) throw lifecycleError;
throw error;
}
assertSessionCanSendFromHeader(header);
await assertSessionWorkspaceAvailable(header.cwd);
return header;
}
async function ensureSessionWorkspaceAvailable(sessionId: string): Promise<void> {
await readAvailableSessionHeader(sessionId);
}
async function createDesktopSession(input: DesktopCreateSessionInput) {
const selected = await resolveDesktopSessionSelection(input, projectManagement);
await assertSessionWorkspaceAvailable(selected.cwd);
return runtime.createSession(await resolveNewSessionProjectInput(selected, projectCatalog));
}
const readyConnectionDeps = {
getConnection: (slug: string) => connectionStore.get(slug),
getApiKey: (slug: string) => resolveConnectionSecret(slug),
};
function getReadyConnection(slug: string | null | undefined, model?: string) {
return requireReadyConnection(slug, readyConnectionDeps, model);
}
async function resolveDesktopSkillHostForSession(
sessionId: string,
): Promise<HostCapabilities> {
const header = await store.readHeader(sessionId);
return resolveDesktopSessionSkillHost(desktopBackendToolSurfaceDeps, {
sessionId,
header,
childTools: childAgentTools,
});
}
async function resolveDesktopSkillHostForNewSession(
projectRoot: string,
context?: NewSessionSkillContext,
): Promise<HostCapabilities> {
const ready = await getReadyConnection(
context?.llmConnectionSlug ?? (await connectionStore.getDefault()),
context?.model,
);
return resolveDesktopNewSessionSkillHost(desktopBackendToolSurfaceDeps, {
projectRoot,
workspaceRoot,
readyConnection: ready,
context,
});
}
async function prepareDesktopSkillInvocation(
sessionId: string,
text: string,
skillIds?: readonly string[],
) {
const [projectRoot, host] = await Promise.all([
resolveProjectRootForContext(sessionId),
resolveDesktopSkillHostForSession(sessionId),
]);
return prepareSkillInvocationMessage({
text,
...(skillIds ? { skillIds } : {}),
source: resolveSkillDiscoveryPaths(projectRoot, workspaceRoot),
host,
});
}
async function listDesktopInvocableSkills(
sessionId?: string,
newSessionContext?: NewSessionSkillContext,
) {
try {
const projectRoot = await resolveProjectRootForContext(sessionId);
const host = sessionId
? await resolveDesktopSkillHostForSession(sessionId)
: await resolveDesktopSkillHostForNewSession(projectRoot, newSessionContext);
return await listInvocableSkills(
resolveSkillDiscoveryPaths(projectRoot, workspaceRoot),
host,
);
} catch (error) {
// Stale sessions with a removed working directory remain browseable, but
// cannot offer project-aware Skill suggestions. Treat that expected state
// as an empty projection instead of generating a rejected IPC/log entry.
if (sessionId && isSessionWorkspaceUnavailableError(error)) return [];
throw error;
}
}
function emitConnectionListChanged(): void {
const event: ConnectionEvent = {
type: 'connection_list_changed',
id: randomUUID(),
ts: Date.now(),
};
safeSendToRenderer('connections:event', event);
}
function emitSessionsChanged(
reason: SessionChangedReason,
sessionId?: string,
extra?: Pick<SessionChangedEvent, 'connectionSlug' | 'modelId' | 'turnId'>,
): void {
const event: SessionChangedEvent = {
type: 'sessions_changed',
reason,
ts: Date.now(),
};
if (sessionId) event.sessionId = sessionId;
if (extra?.connectionSlug) event.connectionSlug = extra.connectionSlug;
if (extra?.modelId) event.modelId = extra.modelId;
if (extra?.turnId) event.turnId = extra.turnId;
safeSendToRenderer('sessions:changed', event);
}
registerIpc();
wireAppLifecycle({
startHidden,
e2eFixture,
userDataDir,
workspaceRoot,
sessionStore: store,
projectCatalog,
credentialStore,
connectionStore,
settingsStore,
telemetryRepo,
artifactStore,
modelCallLedger,
ensureUsageReady,
keepSystemAwake,
botRegistry,
planReminders,
dailyReview,
updateService,
automationWiring,
goalWiring,
computerUse,
computerUseOverlay,
computerUsePip,
computerUseStatusItem,
computerUseScreenLock,
shellRuns,
mcpManager,
runtimePersistence,
executionStoreWiring,
closeWorkflowStores,
mainWindowController,
runtime,
agentGraphCoordinator,
agentGraphSupervisorWakeCoordinator,
agentGraphControlStore,
streamEvents,
focusOrCreateMainWindow,
emitConnectionListChanged,
emitSessionsChanged,
handleExternalSettingsChange,
getSettingsIpc: () => settingsIpc,
});
function computerUseCapabilityInput() {
const executorState = computerUse.backend?.executorState?.();
return {
backendId: computerUse.backendId,
health: computerUseServiceHealth(computerUse.backendId, executorState),
};
}