mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
2093 lines
82 KiB
TypeScript
2093 lines
82 KiB
TypeScript
import { ipcMain, BrowserWindow, shell, dialog, systemPreferences, desktopCapturer, app, screen } from 'electron';
|
|
import { ipc } from '@x/shared';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
connectProvider,
|
|
disconnectProvider,
|
|
listProviders,
|
|
} from './oauth-handler.js';
|
|
import { watcher as watcherCore, workspace } from '@x/core';
|
|
import { WorkDir } from '@x/core/dist/config/config.js';
|
|
import { workspace as workspaceShared } from '@x/shared';
|
|
import * as mcpCore from '@x/core/dist/mcp/mcp.js';
|
|
import * as runsCore from '@x/core/dist/runs/runs.js';
|
|
import { bus } from '@x/core/dist/runs/bus.js';
|
|
import { serviceBus } from '@x/core/dist/services/service_bus.js';
|
|
import type { FSWatcher } from 'chokidar';
|
|
import fs from 'node:fs/promises';
|
|
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import z from 'zod';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
import { RunEvent } from '@x/shared/dist/runs.js';
|
|
import { ServiceEvent } from '@x/shared/dist/service-events.js';
|
|
import type { SessionBusEvent } from '@x/shared/dist/sessions.js';
|
|
import type { ISessions, EmitterSessionBus } from '@x/core/dist/sessions/index.js';
|
|
import container from '@x/core/dist/di/container.js';
|
|
import { listOnboardingModels } from '@x/core/dist/models/models-dev.js';
|
|
import { testModelConnection, listModelsForProvider, generateOneShot } from '@x/core/dist/models/models.js';
|
|
import { getDefaultModelAndProvider } from '@x/core/dist/models/defaults.js';
|
|
import { isSignedIn } from '@x/core/dist/account/account.js';
|
|
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
|
|
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
|
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
|
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
|
|
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
|
|
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
|
|
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
|
|
import { ensureEngine } from '@x/core/dist/code-mode/acp/engine-provisioner.js';
|
|
import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js';
|
|
import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js';
|
|
import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
|
|
import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js';
|
|
import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js';
|
|
import * as codeGit from '@x/core/dist/code-mode/git/service.js';
|
|
import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js';
|
|
import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js';
|
|
import type { CodeSession } from '@x/shared/dist/code-sessions.js';
|
|
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
|
|
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
|
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
|
import { IChannelsConfigRepo } from '@x/core/dist/channels/repo.js';
|
|
import { applyChannelsConfig, getChannelsStatus, logoutWhatsApp, subscribeChannelsStatus } from '@x/core/dist/channels/service.js';
|
|
import { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js';
|
|
import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
|
|
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
|
|
import { syncSlackKnowledgeSources, triggerSync as triggerSlackKnowledgeSync, getSlackKnowledgeSyncStatus } from '@x/core/dist/knowledge/sources/sync_slack.js';
|
|
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
|
|
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
|
|
import * as composioHandler from './composio-handler.js';
|
|
import { consumePendingDeepLink } from './deeplink.js';
|
|
import { qualifyAndDisconnectComposioGoogle } from '@x/core/dist/migrations/composio-google-migration.js';
|
|
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
|
|
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
|
|
import { triggerRun as triggerAgentScheduleRun } from '@x/core/dist/agent-schedule/runner.js';
|
|
import { search } from '@x/core/dist/search/search.js';
|
|
import { resolveMeetingPrep } from '@x/core/dist/knowledge/meeting_prep.js';
|
|
import { readPrepNoteForEvent } from '@x/core/dist/knowledge/meeting_prep_brief.js';
|
|
import { invalidateKnowledgeIndex } from '@x/core/dist/knowledge/knowledge_index.js';
|
|
import { versionHistory, voice } from '@x/core';
|
|
import { classifySchedule, processRowboatInstruction } from '@x/core/dist/knowledge/inline_tasks.js';
|
|
import { getBillingInfo } from '@x/core/dist/billing/billing.js';
|
|
import { summarizeMeeting } from '@x/core/dist/knowledge/summarize_meeting.js';
|
|
import { getAccessToken } from '@x/core/dist/auth/tokens.js';
|
|
import { getRowboatConfig } from '@x/core/dist/config/rowboat.js';
|
|
import { runLiveNoteAgent } from '@x/core/dist/knowledge/live-note/runner.js';
|
|
import { listImportantThreads, listEverythingElseThreads, saveMessageBodyHeight, triggerSync as triggerGmailSync, sendThreadReply, saveThreadDraft, deleteThreadDraft, listDraftThreads, searchThreads, archiveThread, trashThread, markThreadRead, downloadAttachment, getAccountEmail, getAccountName, getConnectionStatus as getGmailConnectionStatus, setThreadImportance } from '@x/core/dist/knowledge/sync_gmail.js';
|
|
import { searchContacts as searchGmailContacts, warmContactIndex } from '@x/core/dist/knowledge/gmail_contacts.js';
|
|
import { searchSentContacts, warmSentContacts } from '@x/core/dist/knowledge/gmail_sent_contacts.js';
|
|
import { getGoogleDocsConnectionStatus, importGoogleDoc, syncGoogleDocDown, syncGoogleDocUp, getGoogleDocLink } from '@x/core/dist/knowledge/google_docs.js';
|
|
import { startManagedGooglePick } from './google-picker-managed.js';
|
|
import { liveNoteBus } from '@x/core/dist/knowledge/live-note/bus.js';
|
|
import { getInstallationId } from '@x/core/dist/analytics/installation.js';
|
|
import { API_URL } from '@x/core/dist/config/env.js';
|
|
import {
|
|
fetchLiveNote,
|
|
setLiveNote,
|
|
setLiveNoteActive,
|
|
deleteLiveNote,
|
|
listLiveNotes,
|
|
} from '@x/core/dist/knowledge/live-note/fileops.js';
|
|
import { runBackgroundTask } from '@x/core/dist/background-tasks/runner.js';
|
|
import { backgroundTaskBus } from '@x/core/dist/background-tasks/bus.js';
|
|
import {
|
|
fetchTask,
|
|
patchTask,
|
|
createTask,
|
|
deleteTask,
|
|
listTasks,
|
|
readRunIds as readTaskRunIds,
|
|
} from '@x/core/dist/background-tasks/fileops.js';
|
|
|
|
type SlackHomeChannel = {
|
|
id: string;
|
|
name: string;
|
|
workspaceUrl?: string;
|
|
workspaceName?: string;
|
|
};
|
|
|
|
type SlackHomeMessage = {
|
|
id: string;
|
|
workspaceName?: string;
|
|
workspaceUrl?: string;
|
|
channelId?: string;
|
|
channelName?: string;
|
|
author?: string;
|
|
text: string;
|
|
ts: string;
|
|
url?: string;
|
|
};
|
|
|
|
function parseWhoamiWorkspaces(data: unknown): Array<{ url: string; name: string }> {
|
|
const parsed = (data ?? {}) as { workspaces?: Array<{ workspace_url?: string; workspace_name?: string }> };
|
|
return (parsed.workspaces || []).map((w) => ({
|
|
url: w.workspace_url || '',
|
|
name: w.workspace_name || '',
|
|
}));
|
|
}
|
|
|
|
type SlackAuthResult = {
|
|
ok: boolean;
|
|
workspaces: Array<{ url: string; name: string }>;
|
|
error?: string;
|
|
errorKind?: 'not_installed' | 'timeout' | 'parse_error' | 'not_authed' | 'rate_limited' | 'network' | 'bad_channel' | 'unknown';
|
|
};
|
|
|
|
// Run `auth import-desktop`, then read back the workspaces via `auth whoami`.
|
|
// Shared by the plain and the quit-Slack-first import handlers.
|
|
async function importDesktopAndReadWorkspaces(): Promise<SlackAuthResult> {
|
|
const imported = await runAgentSlack(['auth', 'import-desktop'], { timeoutMs: 20000, parseJson: false });
|
|
if (!imported.ok) {
|
|
return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
|
|
}
|
|
const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
|
|
if (!whoami.ok) {
|
|
return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
|
|
}
|
|
const workspaces = parseWhoamiWorkspaces(whoami.data);
|
|
if (workspaces.length === 0) {
|
|
return { ok: false, workspaces: [], error: 'No signed-in Slack workspaces found in the desktop app.', errorKind: 'not_authed' };
|
|
}
|
|
return { ok: true, workspaces };
|
|
}
|
|
|
|
// Windows force-quits Slack so its exclusive Cookies-DB lock releases before
|
|
// desktop import (the EBUSY cause). No-op on mac/Linux, where import works with
|
|
// Slack open. taskkill exits non-zero when nothing matches — that's fine.
|
|
async function quitSlackIfWindows(): Promise<void> {
|
|
if (process.platform !== 'win32') return;
|
|
try {
|
|
await execFileAsync('taskkill', ['/F', '/IM', 'Slack.exe'], { timeout: 10000, windowsHide: true });
|
|
} catch {
|
|
// No running Slack process to kill — nothing to do.
|
|
}
|
|
// Give Windows a moment to release the file handles before we copy them.
|
|
await new Promise(resolve => setTimeout(resolve, 800));
|
|
}
|
|
|
|
function extractArrayPayload(parsed: unknown): unknown[] {
|
|
if (Array.isArray(parsed)) return parsed;
|
|
if (parsed && typeof parsed === 'object') {
|
|
const obj = parsed as Record<string, unknown>;
|
|
for (const key of ['messages', 'channels', 'items', 'results', 'data']) {
|
|
if (Array.isArray(obj[key])) return obj[key] as unknown[];
|
|
}
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function slackMessageText(message: Record<string, unknown>): string {
|
|
const value = message.text ?? message.body ?? message.content;
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function slackMessageAuthor(message: Record<string, unknown>): string | undefined {
|
|
const value = message.username ?? message.user ?? message.author;
|
|
return typeof value === 'string' ? value : undefined;
|
|
}
|
|
|
|
function extractSlackUserName(raw: unknown): string | null {
|
|
if (!raw || typeof raw !== 'object') return null;
|
|
const obj = raw as Record<string, unknown>;
|
|
const profile = obj.profile && typeof obj.profile === 'object' ? obj.profile as Record<string, unknown> : undefined;
|
|
const user = obj.user && typeof obj.user === 'object' ? obj.user as Record<string, unknown> : undefined;
|
|
const userProfile = user?.profile && typeof user.profile === 'object' ? user.profile as Record<string, unknown> : undefined;
|
|
|
|
const candidates = [
|
|
profile?.display_name,
|
|
profile?.real_name,
|
|
userProfile?.display_name,
|
|
userProfile?.real_name,
|
|
obj.display_name,
|
|
obj.displayName,
|
|
obj.real_name,
|
|
obj.realName,
|
|
user?.display_name,
|
|
user?.displayName,
|
|
user?.real_name,
|
|
user?.realName,
|
|
obj.name,
|
|
user?.name,
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
if (typeof candidate === 'string' && candidate.trim()) {
|
|
return candidate.trim();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function resolveSlackUserName(
|
|
userId: string,
|
|
workspaceUrl: string | undefined,
|
|
cache: Map<string, string>,
|
|
): Promise<string | null> {
|
|
const key = `${workspaceUrl ?? ''}:${userId}`;
|
|
if (cache.has(key)) return cache.get(key) ?? null;
|
|
|
|
const args = ['user', 'get', userId];
|
|
if (workspaceUrl) {
|
|
args.push('--workspace', workspaceUrl);
|
|
}
|
|
|
|
const result = await runAgentSlack(args, { timeoutMs: 10000, maxBuffer: 512 * 1024 });
|
|
if (result.ok) {
|
|
const name = extractSlackUserName(result.data ?? {});
|
|
if (name) {
|
|
cache.set(key, name);
|
|
return name;
|
|
}
|
|
} else {
|
|
console.warn(`[Slack] Failed to resolve user ${userId}: ${result.message}`);
|
|
}
|
|
|
|
cache.set(key, userId);
|
|
return null;
|
|
}
|
|
|
|
async function resolveSlackMessageText(
|
|
text: string,
|
|
workspaceUrl: string | undefined,
|
|
cache: Map<string, string>,
|
|
): Promise<string> {
|
|
const matches = Array.from(text.matchAll(/<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|@([UW][A-Z0-9]{6,})\b/g));
|
|
if (matches.length === 0) return text;
|
|
|
|
let resolved = text;
|
|
for (const match of matches) {
|
|
const userId = match[1] ?? match[3];
|
|
if (!userId) continue;
|
|
const fallback = match[2] ?? match[0];
|
|
const name = await resolveSlackUserName(userId, workspaceUrl, cache);
|
|
resolved = resolved.replaceAll(match[0], name ?? fallback);
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
async function resolveSlackAuthor(
|
|
author: string | undefined,
|
|
workspaceUrl: string | undefined,
|
|
cache: Map<string, string>,
|
|
): Promise<string | undefined> {
|
|
if (!author) return undefined;
|
|
if (!/^[UW][A-Z0-9]{6,}$/.test(author)) return author;
|
|
return await resolveSlackUserName(author, workspaceUrl, cache) ?? author;
|
|
}
|
|
|
|
function slackMessageUrl(message: Record<string, unknown>, workspaceUrl: string | undefined, channelId: string | undefined, ts: string): string | undefined {
|
|
const direct = message.permalink ?? message.url;
|
|
if (typeof direct === 'string' && direct) return direct;
|
|
if (!workspaceUrl || !channelId) return undefined;
|
|
return `${workspaceUrl.replace(/\/$/, '')}/archives/${channelId}/p${ts.replace('.', '')}`;
|
|
}
|
|
import { browserIpcHandlers } from './browser/ipc.js';
|
|
|
|
/**
|
|
* Convert markdown to a styled HTML document for PDF/DOCX export.
|
|
*/
|
|
function markdownToHtml(markdown: string, title: string): string {
|
|
// Simple markdown to HTML conversion for export purposes
|
|
let html = markdown
|
|
// Resolve wiki links [[Folder/Note Name]] or [[Folder/Note Name|Display]] to plain text
|
|
.replace(/\[\[([^\]|]+)\|([^\]]+)\]\]/g, (_match, _path, display) => display.trim())
|
|
.replace(/\[\[([^\]]+)\]\]/g, (_match, linkPath: string) => {
|
|
// Use the last segment (filename) as the display name
|
|
const segments = linkPath.trim().split('/')
|
|
return segments[segments.length - 1]
|
|
})
|
|
// Escape HTML entities (but preserve markdown syntax)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
|
|
// Headings (must come before other processing)
|
|
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
|
|
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
|
|
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
|
|
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
|
|
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
|
|
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
|
|
|
|
// Bold and italic
|
|
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
|
|
// Inline code
|
|
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
|
|
// Horizontal rules
|
|
html = html.replace(/^---$/gm, '<hr>')
|
|
|
|
// Unordered lists
|
|
html = html.replace(/^[-*]\s+(.+)$/gm, '<li>$1</li>')
|
|
|
|
// Links
|
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
|
|
|
// Blockquotes
|
|
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>')
|
|
|
|
// Paragraphs: wrap remaining lines that aren't already wrapped in HTML tags
|
|
html = html.replace(/^(?!<[a-z/])((?!^\s*$).+)$/gm, '<p>$1</p>')
|
|
|
|
// Clean up consecutive list items into lists
|
|
html = html.replace(/(<li>.*<\/li>\n?)+/g, (match) => `<ul>${match}</ul>`)
|
|
|
|
return `<!DOCTYPE html>
|
|
<html><head><meta charset="utf-8"><title>${title}</title>
|
|
<style>
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 700px; margin: 40px auto; padding: 0 20px; color: #1a1a1a; line-height: 1.6; font-size: 14px; }
|
|
h1 { font-size: 1.8em; margin-top: 1em; } h2 { font-size: 1.4em; margin-top: 1em; } h3 { font-size: 1.2em; }
|
|
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }
|
|
blockquote { border-left: 3px solid #ddd; margin: 1em 0; padding: 0.5em 1em; color: #555; }
|
|
hr { border: none; border-top: 1px solid #ddd; margin: 2em 0; }
|
|
ul { padding-left: 1.5em; }
|
|
a { color: #0066cc; }
|
|
</style></head><body>${html}</body></html>`
|
|
}
|
|
|
|
function resolveShellPath(filePath: string): string {
|
|
if (filePath.startsWith('~')) {
|
|
return path.join(os.homedir(), filePath.slice(1));
|
|
}
|
|
|
|
if (path.isAbsolute(filePath)) {
|
|
return filePath;
|
|
}
|
|
|
|
return workspace.resolveWorkspacePath(filePath);
|
|
}
|
|
|
|
type InvokeChannels = ipc.InvokeChannels;
|
|
type IPCChannels = ipc.IPCChannels;
|
|
|
|
/**
|
|
* Type-safe handler function for invoke channels
|
|
*/
|
|
type InvokeHandler<K extends InvokeChannels> = (
|
|
event: Electron.IpcMainInvokeEvent,
|
|
args: IPCChannels[K]['req']
|
|
) => IPCChannels[K]['res'] | Promise<IPCChannels[K]['res']>;
|
|
|
|
/**
|
|
* Type-safe handler registration map
|
|
* Ensures all invoke channels have handlers
|
|
*/
|
|
type InvokeHandlers = {
|
|
[K in InvokeChannels]: InvokeHandler<K>;
|
|
};
|
|
|
|
// In-flight streaming TTS requests, keyed by renderer-chosen requestId.
|
|
const activeTtsStreams = new Map<string, AbortController>();
|
|
|
|
// Video-mode popout window (shown for the whole duration of a screen share,
|
|
// floating over every app including Rowboat itself) and the last call state
|
|
// pushed by the main window — replayed to the popout when it finishes loading.
|
|
let videoPopoutWin: BrowserWindow | null = null;
|
|
let lastVideoPopoutState: {
|
|
ttsState: 'idle' | 'synthesizing' | 'speaking';
|
|
status: 'listening' | 'thinking' | 'speaking' | null;
|
|
cameraOn: boolean;
|
|
screenSharing: boolean;
|
|
interimText: string | null;
|
|
} | null = null;
|
|
|
|
// Match only real app windows — getAllWindows() can also contain the popout
|
|
// itself and hidden utility windows (e.g. PDF-export renderers), which must
|
|
// not be shown, focused, or sent app events.
|
|
function findMainAppWindow(): BrowserWindow | undefined {
|
|
return BrowserWindow.getAllWindows().find((w) => {
|
|
if (w === videoPopoutWin || w.isDestroyed()) return false;
|
|
const url = w.webContents.getURL();
|
|
const isAppWindow = url.startsWith('app://') || url.startsWith('http://localhost');
|
|
return isAppWindow && !url.includes('#video-popout');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Register all IPC handlers with type safety and runtime validation
|
|
*
|
|
* This function ensures:
|
|
* 1. All invoke channels have handlers (exhaustiveness checking)
|
|
* 2. Handler signatures match channel definitions
|
|
* 3. Request/response payloads are validated at runtime
|
|
*/
|
|
export function registerIpcHandlers(handlers: InvokeHandlers) {
|
|
// Register each handler with runtime validation
|
|
for (const [channel, handler] of Object.entries(handlers) as [
|
|
InvokeChannels,
|
|
InvokeHandler<InvokeChannels>
|
|
][]) {
|
|
ipcMain.handle(channel, async (event, rawArgs) => {
|
|
// Validate request payload
|
|
const args = ipc.validateRequest(channel, rawArgs);
|
|
|
|
// Call handler
|
|
const result = await handler(event, args);
|
|
|
|
// Validate response payload
|
|
return ipc.validateResponse(channel, result);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Electron-Specific Utilities
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Get application versions (Electron-specific)
|
|
*/
|
|
function getVersions(): {
|
|
chrome: string;
|
|
node: string;
|
|
electron: string;
|
|
} {
|
|
return {
|
|
chrome: process.versions.chrome,
|
|
node: process.versions.node,
|
|
electron: process.versions.electron,
|
|
};
|
|
}
|
|
|
|
// ============================================================================
|
|
// Workspace Watcher (with debouncing and lifecycle management)
|
|
// ============================================================================
|
|
|
|
let watcher: FSWatcher | null = null;
|
|
const changeQueue = new Set<string>();
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
/**
|
|
* Emit knowledge commit event to all renderer windows
|
|
*/
|
|
function emitKnowledgeCommitEvent(): void {
|
|
const windows = BrowserWindow.getAllWindows();
|
|
for (const win of windows) {
|
|
if (!win.isDestroyed() && win.webContents) {
|
|
win.webContents.send('knowledge:didCommit', {});
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emit workspace change event to all renderer windows
|
|
*/
|
|
function emitWorkspaceChangeEvent(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
|
|
const windows = BrowserWindow.getAllWindows();
|
|
for (const win of windows) {
|
|
if (!win.isDestroyed() && win.webContents) {
|
|
win.webContents.send('workspace:didChange', event);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process queued changes and emit events (debounced)
|
|
*/
|
|
function processChangeQueue(): void {
|
|
if (changeQueue.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const paths = Array.from(changeQueue);
|
|
changeQueue.clear();
|
|
|
|
if (paths.length === 1) {
|
|
// For single path, try to determine kind from file stats
|
|
const relPath = paths[0]!;
|
|
try {
|
|
const absPath = workspace.resolveWorkspacePath(relPath);
|
|
fs.lstat(absPath)
|
|
.then((stats) => {
|
|
const kind = stats.isDirectory() ? 'dir' : 'file';
|
|
emitWorkspaceChangeEvent({ type: 'changed', path: relPath, kind });
|
|
})
|
|
.catch(() => {
|
|
// File no longer exists (edge case), emit without kind
|
|
emitWorkspaceChangeEvent({ type: 'changed', path: relPath });
|
|
});
|
|
} catch {
|
|
// Invalid path, ignore
|
|
}
|
|
} else {
|
|
// Emit bulkChanged for multiple paths
|
|
emitWorkspaceChangeEvent({ type: 'bulkChanged', paths });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Queue a path change for debounced emission
|
|
*/
|
|
function queueChange(relPath: string): void {
|
|
changeQueue.add(relPath);
|
|
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
}
|
|
|
|
debounceTimer = setTimeout(() => {
|
|
processChangeQueue();
|
|
debounceTimer = null;
|
|
}, 150); // 150ms debounce
|
|
}
|
|
|
|
/**
|
|
* Handle workspace change event from core watcher
|
|
*/
|
|
function touchesKnowledge(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): boolean {
|
|
const hit = (p: string | undefined) => typeof p === 'string' && p.startsWith('knowledge/');
|
|
switch (event.type) {
|
|
case 'created':
|
|
case 'changed':
|
|
case 'deleted':
|
|
return hit(event.path);
|
|
case 'moved':
|
|
return hit(event.from) || hit(event.to);
|
|
case 'bulkChanged':
|
|
return !event.paths || event.paths.some(hit);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function handleWorkspaceChange(event: z.infer<typeof workspaceShared.WorkspaceChangeEvent>): void {
|
|
// Any knowledge-base change drops the cached index so the next read rebuilds.
|
|
if (touchesKnowledge(event)) invalidateKnowledgeIndex();
|
|
// Debounce 'changed' events, emit others immediately
|
|
if (event.type === 'changed' && event.path) {
|
|
queueChange(event.path);
|
|
} else {
|
|
emitWorkspaceChangeEvent(event);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start workspace watcher
|
|
* Watches the configured workspace root recursively and emits change events to renderer
|
|
*
|
|
* This should be called once when the app starts (from main.ts).
|
|
* The watcher runs as a main-process service and catches ALL filesystem changes
|
|
* (both from IPC handlers and external changes like terminal/git).
|
|
*
|
|
* Safe to call multiple times - guards against duplicate watchers.
|
|
*/
|
|
export async function startWorkspaceWatcher(): Promise<void> {
|
|
if (watcher) {
|
|
// Watcher already running - safe to ignore subsequent calls
|
|
return;
|
|
}
|
|
|
|
watcher = await watcherCore.createWorkspaceWatcher(handleWorkspaceChange);
|
|
}
|
|
|
|
/**
|
|
* Stop workspace watcher
|
|
*/
|
|
export function stopWorkspaceWatcher(): void {
|
|
if (watcher) {
|
|
watcher.close();
|
|
watcher = null;
|
|
}
|
|
if (debounceTimer) {
|
|
clearTimeout(debounceTimer);
|
|
debounceTimer = null;
|
|
}
|
|
changeQueue.clear();
|
|
}
|
|
|
|
function emitRunEvent(event: z.infer<typeof RunEvent>): void {
|
|
const windows = BrowserWindow.getAllWindows();
|
|
for (const win of windows) {
|
|
if (!win.isDestroyed() && win.webContents) {
|
|
win.webContents.send('runs:events', event);
|
|
}
|
|
}
|
|
}
|
|
|
|
function emitServiceEvent(event: z.infer<typeof ServiceEvent>): void {
|
|
const windows = BrowserWindow.getAllWindows();
|
|
for (const win of windows) {
|
|
if (!win.isDestroyed() && win.webContents) {
|
|
win.webContents.send('services:events', event);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function emitOAuthEvent(event: { provider: string; success: boolean; error?: string; userId?: string }): void {
|
|
// Native connection status (e.g. Google) is baked into the Copilot system
|
|
// prompt, so any OAuth state change must rebuild it.
|
|
invalidateCopilotInstructionsCache();
|
|
const windows = BrowserWindow.getAllWindows();
|
|
for (const win of windows) {
|
|
if (!win.isDestroyed() && win.webContents) {
|
|
win.webContents.send('oauth:didConnect', event);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function requireCodeSession(sessionId: string): Promise<CodeSession> {
|
|
const repo = container.resolve<ICodeSessionsRepo>('codeSessionsRepo');
|
|
const session = await repo.get(sessionId);
|
|
if (!session) {
|
|
throw new Error(`Unknown code session: ${sessionId}`);
|
|
}
|
|
return session;
|
|
}
|
|
|
|
let codeSessionStatusWatcher: (() => void) | null = null;
|
|
export async function startCodeSessionStatusWatcher(): Promise<void> {
|
|
if (codeSessionStatusWatcher) {
|
|
return;
|
|
}
|
|
const tracker = container.resolve<CodeSessionStatusTracker>('codeSessionStatusTracker');
|
|
await tracker.start();
|
|
codeSessionStatusWatcher = tracker.onTransition((sessionId, status) => {
|
|
const windows = BrowserWindow.getAllWindows();
|
|
for (const win of windows) {
|
|
if (!win.isDestroyed() && win.webContents) {
|
|
win.webContents.send('codeSession:status', { sessionId, status });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
let runsWatcher: (() => void) | null = null;
|
|
export async function startRunsWatcher(): Promise<void> {
|
|
if (runsWatcher) {
|
|
return;
|
|
}
|
|
runsWatcher = await bus.subscribe('*', async (event) => {
|
|
emitRunEvent(event);
|
|
});
|
|
}
|
|
|
|
// New runtime: session bus → renderer windows (session-design.md §10).
|
|
function emitSessionEvent(event: SessionBusEvent): void {
|
|
const windows = BrowserWindow.getAllWindows();
|
|
for (const win of windows) {
|
|
if (!win.isDestroyed() && win.webContents) {
|
|
win.webContents.send('sessions:events', event);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mobile channels: status changes (QR pairing, connect/disconnect) → renderer.
|
|
let channelsWatcher: (() => void) | null = null;
|
|
export function startChannelsWatcher(): void {
|
|
if (channelsWatcher) return;
|
|
channelsWatcher = subscribeChannelsStatus((status) => {
|
|
const windows = BrowserWindow.getAllWindows();
|
|
for (const win of windows) {
|
|
if (!win.isDestroyed() && win.webContents) {
|
|
win.webContents.send('channels:status', status);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
let sessionsWatcher: (() => void) | null = null;
|
|
export function startSessionsWatcher(): void {
|
|
if (sessionsWatcher) {
|
|
return;
|
|
}
|
|
const sessionBus = container.resolve<EmitterSessionBus>('sessionBus');
|
|
sessionsWatcher = sessionBus.subscribe((event) => emitSessionEvent(event));
|
|
}
|
|
|
|
// The renderer window is created before the session-index startup scan
|
|
// finishes, so an early sessions:list could observe a partially built index
|
|
// (the scan runs oldest-first — exactly the newest chats would be missing).
|
|
// sessions:list awaits this deferred; main.ts resolves it when the scan
|
|
// settles (success or failure, so the list never hangs).
|
|
let resolveSessionsIndexReady: () => void;
|
|
const sessionsIndexReady = new Promise<void>((resolve) => {
|
|
resolveSessionsIndexReady = resolve;
|
|
});
|
|
export function markSessionsIndexReady(): void {
|
|
resolveSessionsIndexReady();
|
|
}
|
|
|
|
let servicesWatcher: (() => void) | null = null;
|
|
export async function startServicesWatcher(): Promise<void> {
|
|
if (servicesWatcher) {
|
|
return;
|
|
}
|
|
servicesWatcher = await serviceBus.subscribe(async (event) => {
|
|
emitServiceEvent(event);
|
|
});
|
|
}
|
|
|
|
let liveNoteAgentWatcher: (() => void) | null = null;
|
|
export function startLiveNoteAgentWatcher(): void {
|
|
if (liveNoteAgentWatcher) return;
|
|
liveNoteAgentWatcher = liveNoteBus.subscribe((event) => {
|
|
const windows = BrowserWindow.getAllWindows();
|
|
for (const win of windows) {
|
|
if (!win.isDestroyed() && win.webContents) {
|
|
win.webContents.send('live-note-agent:events', event);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
let backgroundTaskAgentWatcher: (() => void) | null = null;
|
|
export function startBackgroundTaskAgentWatcher(): void {
|
|
if (backgroundTaskAgentWatcher) return;
|
|
backgroundTaskAgentWatcher = backgroundTaskBus.subscribe((event) => {
|
|
const windows = BrowserWindow.getAllWindows();
|
|
for (const win of windows) {
|
|
if (!win.isDestroyed() && win.webContents) {
|
|
win.webContents.send('bg-task-agent:events', event);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export function stopRunsWatcher(): void {
|
|
if (runsWatcher) {
|
|
runsWatcher();
|
|
runsWatcher = null;
|
|
}
|
|
}
|
|
|
|
export function stopServicesWatcher(): void {
|
|
if (servicesWatcher) {
|
|
servicesWatcher();
|
|
servicesWatcher = null;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Handler Implementations
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Register all IPC handlers
|
|
* Add new handlers here as you add channels to IPCChannels
|
|
*/
|
|
export function setupIpcHandlers() {
|
|
// Forward knowledge commit events to renderer for panel refresh
|
|
versionHistory.onCommit(() => emitKnowledgeCommitEvent());
|
|
|
|
// Pre-warm the Gmail contact indices so the first compose-box keystroke is instant.
|
|
// - warmContactIndex(): synchronous local-snapshot fallback (instant, narrow coverage).
|
|
// - warmSentContacts(): kicks off a background Gmail API sync of the SENT label
|
|
// for full historical coverage of people you've actually emailed.
|
|
warmContactIndex();
|
|
warmSentContacts();
|
|
|
|
registerIpcHandlers({
|
|
'app:getVersions': async () => {
|
|
// args is null for this channel (no request payload)
|
|
return getVersions();
|
|
},
|
|
'app:consumePendingDeepLink': async () => {
|
|
return { url: consumePendingDeepLink() };
|
|
},
|
|
'analytics:bootstrap': async () => {
|
|
return {
|
|
installationId: getInstallationId(),
|
|
apiUrl: API_URL,
|
|
appVersion: app.getVersion(),
|
|
};
|
|
},
|
|
'workspace:getRoot': async () => {
|
|
return workspace.getRoot();
|
|
},
|
|
'workspace:exists': async (_, args) => {
|
|
return workspace.exists(args.path);
|
|
},
|
|
'workspace:stat': async (_event, args) => {
|
|
return workspace.stat(args.path);
|
|
},
|
|
'workspace:readdir': async (_event, args) => {
|
|
return workspace.readdir(args.path, args.opts);
|
|
},
|
|
'workspace:readFile': async (_event, args) => {
|
|
return workspace.readFile(args.path, args.encoding);
|
|
},
|
|
'workspace:writeFile': async (_event, args) => {
|
|
return workspace.writeFile(args.path, args.data, args.opts);
|
|
},
|
|
'workspace:mkdir': async (_event, args) => {
|
|
return workspace.mkdir(args.path, args.recursive);
|
|
},
|
|
'workspace:rename': async (_event, args) => {
|
|
return workspace.rename(args.from, args.to, args.overwrite);
|
|
},
|
|
'workspace:copy': async (_event, args) => {
|
|
return workspace.copy(args.from, args.to, args.overwrite);
|
|
},
|
|
'workspace:remove': async (_event, args) => {
|
|
return workspace.remove(args.path, args.opts);
|
|
},
|
|
'gmail:getImportant': async (_event, args) => {
|
|
return listImportantThreads({ cursor: args.cursor, limit: args.limit });
|
|
},
|
|
'gmail:getEverythingElse': async (_event, args) => {
|
|
return listEverythingElseThreads({ cursor: args.cursor, limit: args.limit });
|
|
},
|
|
'gmail:triggerSync': async () => {
|
|
triggerGmailSync();
|
|
return {};
|
|
},
|
|
'gmail:sendReply': async (_event, args) => {
|
|
return sendThreadReply(args);
|
|
},
|
|
'gmail:saveDraft': async (_event, args) => {
|
|
return saveThreadDraft(args);
|
|
},
|
|
'gmail:deleteDraft': async (_event, args) => {
|
|
return deleteThreadDraft(args.draftId);
|
|
},
|
|
'gmail:getDrafts': async () => {
|
|
return listDraftThreads();
|
|
},
|
|
'gmail:search': async (_event, args) => {
|
|
return searchThreads(args.query, { limit: args.limit });
|
|
},
|
|
'gmail:getConnectionStatus': async () => {
|
|
return getGmailConnectionStatus();
|
|
},
|
|
'gmail:getAccountEmail': async () => {
|
|
return { email: await getAccountEmail() };
|
|
},
|
|
'gmail:getAccountName': async () => {
|
|
return { name: await getAccountName() };
|
|
},
|
|
'gmail:setImportance': async (_event, args) => {
|
|
const result = setThreadImportance(args.threadId, args.importance);
|
|
return { ok: result.success, previous: result.previous, error: result.error };
|
|
},
|
|
'gmail:archiveThread': async (_event, args) => {
|
|
return archiveThread(args.threadId);
|
|
},
|
|
'gmail:trashThread': async (_event, args) => {
|
|
return trashThread(args.threadId);
|
|
},
|
|
'gmail:markThreadRead': async (_event, args) => {
|
|
return markThreadRead(args.threadId, args.read);
|
|
},
|
|
'gmail:downloadAttachment': async (_event, args) => {
|
|
return downloadAttachment(args);
|
|
},
|
|
'gmail:saveMessageHeight': async (_event, args) => {
|
|
saveMessageBodyHeight(args.threadId, args.messageId, args.height);
|
|
return {};
|
|
},
|
|
'gmail:searchContacts': async (_event, args) => {
|
|
const query = args?.query ?? '';
|
|
const limit = args?.limit;
|
|
const excludeEmails = args?.excludeEmails;
|
|
|
|
// Primary source: people you've actually sent mail to (Gmail SENT label,
|
|
// cached + refreshed via the Gmail API). Fallback: local-snapshot index
|
|
// — used only when the SENT index hasn't been populated yet (very first
|
|
// launch, before the background sync finishes).
|
|
const sent = await searchSentContacts(query, { limit, excludeEmails }).catch(() => []);
|
|
if (sent.length > 0) {
|
|
return { contacts: sent };
|
|
}
|
|
const fallback = await searchGmailContacts(query, { limit, excludeEmails });
|
|
return { contacts: fallback };
|
|
},
|
|
'mcp:listTools': async (_event, args) => {
|
|
return mcpCore.listTools(args.serverName, args.cursor);
|
|
},
|
|
'mcp:executeTool': async (_event, args) => {
|
|
return { result: await mcpCore.executeTool(args.serverName, args.toolName, args.input) };
|
|
},
|
|
'runs:create': async (_event, args) => {
|
|
return runsCore.createRun(args);
|
|
},
|
|
'runs:createMessage': async (_event, args) => {
|
|
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode, args.codeCwd, args.codePolicy) };
|
|
},
|
|
'runs:authorizePermission': async (_event, args) => {
|
|
await runsCore.authorizePermission(args.runId, args.authorization);
|
|
return { success: true };
|
|
},
|
|
'codeRun:resolvePermission': async (_event, args) => {
|
|
const registry = container.resolve<CodePermissionRegistry>('codePermissionRegistry');
|
|
registry.resolve(args.requestId, args.decision);
|
|
return { success: true };
|
|
},
|
|
'runs:provideHumanInput': async (_event, args) => {
|
|
await runsCore.replyToHumanInputRequest(args.runId, args.reply);
|
|
return { success: true };
|
|
},
|
|
'runs:stop': async (_event, args) => {
|
|
await runsCore.stop(args.runId, args.force);
|
|
return { success: true };
|
|
},
|
|
'runs:fetch': async (_event, args) => {
|
|
return runsCore.fetchRun(args.runId);
|
|
},
|
|
'runs:list': async (_event, args) => {
|
|
return runsCore.listRuns(args.cursor);
|
|
},
|
|
'runs:listByWorkDir': async (_event, args) => {
|
|
return runsCore.listRunsByWorkDir(args.dir);
|
|
},
|
|
'runs:delete': async (_event, args) => {
|
|
await runsCore.deleteRun(args.runId);
|
|
return { success: true };
|
|
},
|
|
// ── New runtime: sessions + turns ─────────────────────────
|
|
// Thin pass-throughs to the sessions service. sendMessage returns the
|
|
// turnId immediately; the turn advances in the background and the
|
|
// renderer reconciles via the sessions:events feed. Input-routing calls
|
|
// settle with that advance's outcome (the renderer fire-and-forgets).
|
|
'sessions:create': async (_event, args) => {
|
|
const sessionId = await container.resolve<ISessions>('sessions').createSession(args);
|
|
return { sessionId };
|
|
},
|
|
'sessions:list': async () => {
|
|
await sessionsIndexReady;
|
|
return { sessions: container.resolve<ISessions>('sessions').listSessions() };
|
|
},
|
|
'sessions:get': async (_event, args) => {
|
|
return container.resolve<ISessions>('sessions').getSession(args.sessionId);
|
|
},
|
|
'sessions:getTurn': async (_event, args) => {
|
|
return container.resolve<ISessions>('sessions').getTurn(args.turnId);
|
|
},
|
|
'sessions:sendMessage': async (_event, args) => {
|
|
return container.resolve<ISessions>('sessions').sendMessage(args.sessionId, args.input, args.config);
|
|
},
|
|
'sessions:respondToPermission': async (_event, args) => {
|
|
await container.resolve<ISessions>('sessions').respondToPermission(args.turnId, args.toolCallId, args.decision, args.metadata);
|
|
return { success: true };
|
|
},
|
|
'sessions:respondToAskHuman': async (_event, args) => {
|
|
await container.resolve<ISessions>('sessions').respondToAskHuman(args.turnId, args.toolCallId, args.answer);
|
|
return { success: true };
|
|
},
|
|
'sessions:stopTurn': async (_event, args) => {
|
|
await container.resolve<ISessions>('sessions').stopTurn(args.turnId, args.reason);
|
|
return { success: true };
|
|
},
|
|
'sessions:resumeTurn': async (_event, args) => {
|
|
await container.resolve<ISessions>('sessions').resumeTurn(args.sessionId);
|
|
return { success: true };
|
|
},
|
|
'sessions:setTitle': async (_event, args) => {
|
|
await container.resolve<ISessions>('sessions').setTitle(args.sessionId, args.title);
|
|
return { success: true };
|
|
},
|
|
'sessions:delete': async (_event, args) => {
|
|
await container.resolve<ISessions>('sessions').deleteSession(args.sessionId);
|
|
return { success: true };
|
|
},
|
|
'sessions:downloadLog': async (event, args) => {
|
|
// Concatenate the session's turn logs into one JSONL for debugging.
|
|
const sessions = container.resolve<ISessions>('sessions');
|
|
const state = await sessions.getSession(args.sessionId);
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
const result = await dialog.showSaveDialog(win!, {
|
|
defaultPath: `${args.sessionId}.jsonl.log`,
|
|
filters: [
|
|
{ name: 'Chat Log', extensions: ['log'] },
|
|
{ name: 'JSONL', extensions: ['jsonl'] },
|
|
{ name: 'All Files', extensions: ['*'] },
|
|
],
|
|
});
|
|
if (result.canceled || !result.filePath) {
|
|
return { success: false };
|
|
}
|
|
try {
|
|
const lines: string[] = [];
|
|
for (const ref of state.turns) {
|
|
const turn = await sessions.getTurn(ref.turnId);
|
|
for (const turnEvent of turn.events) {
|
|
lines.push(JSON.stringify(turnEvent));
|
|
}
|
|
}
|
|
await fs.writeFile(result.filePath, lines.join('\n') + '\n');
|
|
return { success: true };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to download chat log';
|
|
return { success: false, error: message };
|
|
}
|
|
},
|
|
'runs:downloadLog': async (event, args) => {
|
|
const runFileName = `${args.runId}.jsonl`;
|
|
if (path.basename(runFileName) !== runFileName) {
|
|
return { success: false, error: 'Invalid run id' };
|
|
}
|
|
|
|
const sourcePath = path.join(WorkDir, 'runs', runFileName);
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
const result = await dialog.showSaveDialog(win!, {
|
|
defaultPath: `${runFileName}.log`,
|
|
filters: [
|
|
{ name: 'Chat Log', extensions: ['log'] },
|
|
{ name: 'JSONL', extensions: ['jsonl'] },
|
|
{ name: 'All Files', extensions: ['*'] },
|
|
],
|
|
});
|
|
|
|
if (result.canceled || !result.filePath) {
|
|
return { success: false };
|
|
}
|
|
|
|
try {
|
|
await fs.copyFile(sourcePath, result.filePath);
|
|
return { success: true };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to download chat log';
|
|
return { success: false, error: message };
|
|
}
|
|
},
|
|
'models:list': async () => {
|
|
if (await isSignedIn()) {
|
|
return await listGatewayModels();
|
|
}
|
|
return await listOnboardingModels();
|
|
},
|
|
'models:test': async (_event, args) => {
|
|
return await testModelConnection(args.provider, args.model);
|
|
},
|
|
'models:listForProvider': async (_event, args) => {
|
|
try {
|
|
const models = await listModelsForProvider(args.provider);
|
|
return { success: true, models };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to list models';
|
|
return { success: false, error: message };
|
|
}
|
|
},
|
|
'llm:getDefaultModel': async () => {
|
|
return await getDefaultModelAndProvider();
|
|
},
|
|
'llm:generate': async (_event, args) => {
|
|
console.log(`[llm:generate] requested provider=${args.provider ?? '(default)'} model=${args.model ?? '(default)'}`);
|
|
const result = await generateOneShot(args);
|
|
console.log(`[llm:generate] -> provider=${result.provider ?? '?'} model=${result.model ?? '?'} chars=${result.text?.length ?? 0}${result.error ? ` error=${result.error}` : ''}`);
|
|
return result;
|
|
},
|
|
'models:saveConfig': async (_event, args) => {
|
|
const repo = container.resolve<IModelConfigRepo>('modelConfigRepo');
|
|
await repo.setConfig(args);
|
|
return { success: true };
|
|
},
|
|
'oauth:connect': async (_event, args) => {
|
|
const credentials = args.clientId && args.clientSecret
|
|
? { clientId: args.clientId.trim(), clientSecret: args.clientSecret.trim() }
|
|
: undefined;
|
|
return await connectProvider(args.provider, credentials);
|
|
},
|
|
'oauth:disconnect': async (_event, args) => {
|
|
return await disconnectProvider(args.provider);
|
|
},
|
|
'oauth:list-providers': async () => {
|
|
return listProviders();
|
|
},
|
|
'oauth:getState': async () => {
|
|
const repo = container.resolve<IOAuthRepo>('oauthRepo');
|
|
const config = await repo.getClientFacingConfig();
|
|
return { config };
|
|
},
|
|
'account:getRowboat': async () => {
|
|
const signedIn = await isSignedIn();
|
|
if (!signedIn) {
|
|
return { signedIn: false, accessToken: null, config: null };
|
|
}
|
|
|
|
const config = await getRowboatConfig();
|
|
|
|
try {
|
|
const accessToken = await getAccessToken();
|
|
return { signedIn: true, accessToken, config };
|
|
} catch {
|
|
return { signedIn: true, accessToken: null, config };
|
|
}
|
|
},
|
|
'granola:getConfig': async () => {
|
|
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
|
const config = await repo.getConfig();
|
|
return { enabled: config.enabled };
|
|
},
|
|
'codeMode:getConfig': async () => {
|
|
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
|
const config = await repo.getConfig();
|
|
return { enabled: config.enabled, approvalPolicy: config.approvalPolicy };
|
|
},
|
|
'codeMode:setConfig': async (_event, args) => {
|
|
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
|
await repo.setConfig({ enabled: args.enabled, approvalPolicy: args.approvalPolicy });
|
|
invalidateCopilotInstructionsCache();
|
|
return { success: true };
|
|
},
|
|
'codeMode:checkAgentStatus': async () => {
|
|
return await checkCodeModeAgentStatus();
|
|
},
|
|
'codeMode:provisionEngine': async (_event, args) => {
|
|
// Download + install the agent's engine, streaming progress back to the
|
|
// requesting window so Settings can show a live bar. 'check' is instant — skip it.
|
|
try {
|
|
await ensureEngine(args.agent, {
|
|
onProgress: (p) => {
|
|
if (p.phase === 'check') return;
|
|
_event.sender.send('codeMode:engineProgress', {
|
|
agent: args.agent,
|
|
phase: p.phase,
|
|
receivedBytes: p.receivedBytes,
|
|
totalBytes: p.totalBytes,
|
|
});
|
|
},
|
|
});
|
|
return { success: true };
|
|
} catch (e) {
|
|
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
},
|
|
'codeProject:add': async (_event, args) => {
|
|
const repo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
|
|
const project = await repo.add(args.path);
|
|
const git = await codeGit.repoInfo(project.path);
|
|
return { project, git };
|
|
},
|
|
'codeProject:remove': async (_event, args) => {
|
|
const repo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
|
|
await repo.remove(args.projectId);
|
|
return { success: true };
|
|
},
|
|
'codeProject:list': async () => {
|
|
const repo = container.resolve<ICodeProjectsRepo>('codeProjectsRepo');
|
|
const projects = await repo.list();
|
|
return {
|
|
projects: await Promise.all(projects.map(async (project) => ({
|
|
project,
|
|
git: await codeGit.repoInfo(project.path),
|
|
}))),
|
|
};
|
|
},
|
|
'codeSession:create': async (_event, args) => {
|
|
const service = container.resolve<CodeSessionService>('codeSessionService');
|
|
const session = await service.create(args);
|
|
return { session };
|
|
},
|
|
'codeSession:list': async () => {
|
|
const repo = container.resolve<ICodeSessionsRepo>('codeSessionsRepo');
|
|
const tracker = container.resolve<CodeSessionStatusTracker>('codeSessionStatusTracker');
|
|
return { sessions: await repo.list(), statuses: tracker.getStatuses() };
|
|
},
|
|
'codeSession:update': async (_event, args) => {
|
|
const service = container.resolve<CodeSessionService>('codeSessionService');
|
|
return { session: await service.update(args.sessionId, args.patch) };
|
|
},
|
|
'codeMode:listModelOptions': async (_event, args) => {
|
|
const manager = container.resolve<CodeModeManager>('codeModeManager');
|
|
return manager.listModelOptions(args.agent);
|
|
},
|
|
'codeSession:delete': async (_event, args) => {
|
|
const service = container.resolve<CodeSessionService>('codeSessionService');
|
|
disposeTerminal(args.sessionId);
|
|
await service.delete(args.sessionId, {
|
|
removeWorktree: args.removeWorktree,
|
|
deleteBranch: args.deleteBranch,
|
|
});
|
|
return { success: true };
|
|
},
|
|
'codeSession:sendMessage': async (_event, args) => {
|
|
const service = container.resolve<CodeSessionService>('codeSessionService');
|
|
// Intentionally not awaited: the turn can run for minutes and streams over
|
|
// runs:events. sendMessage validates synchronously enough that busy/unknown
|
|
// errors are reported via the run's error events instead.
|
|
const resultPromise = service.sendMessage(args.sessionId, args.text);
|
|
// Surface immediate rejections (busy session, unknown id) to the caller.
|
|
const result = await Promise.race([
|
|
resultPromise,
|
|
new Promise<{ accepted: true }>((resolve) => setTimeout(() => resolve({ accepted: true }), 300)),
|
|
]);
|
|
resultPromise.catch((err) => console.error('codeSession:sendMessage failed', err));
|
|
return result;
|
|
},
|
|
'codeSession:stop': async (_event, args) => {
|
|
const service = container.resolve<CodeSessionService>('codeSessionService');
|
|
await service.stop(args.sessionId);
|
|
return { success: true };
|
|
},
|
|
'codeSession:gitStatus': async (_event, args) => {
|
|
const session = await requireCodeSession(args.sessionId);
|
|
const info = await codeGit.repoInfo(session.cwd);
|
|
if (!info.isGitRepo) {
|
|
return { isRepo: false, branch: null, hasCommits: false, files: [] };
|
|
}
|
|
let files = await codeGit.status(session.cwd);
|
|
if (session.worktree && !session.worktree.removedAt && session.worktree.baseBranch) {
|
|
const branchFiles = await codeGit.changedSinceBase(session.cwd, session.worktree.baseBranch);
|
|
const byPath = new Map(branchFiles.map((file) => [file.path, file]));
|
|
for (const file of files) {
|
|
if (!byPath.has(file.path)) byPath.set(file.path, file);
|
|
}
|
|
files = [...byPath.values()];
|
|
}
|
|
return { isRepo: true, branch: info.branch, hasCommits: info.hasCommits, files };
|
|
},
|
|
'codeSession:fileDiff': async (_event, args) => {
|
|
const session = await requireCodeSession(args.sessionId);
|
|
return codeGit.fileDiff(session.cwd, args.path, {
|
|
baseRef: session.worktree && !session.worktree.removedAt ? session.worktree.baseBranch : null,
|
|
});
|
|
},
|
|
'codeSession:readdir': async (_event, args) => {
|
|
const session = await requireCodeSession(args.sessionId);
|
|
return { entries: await readProjectDir(session.cwd, args.relPath) };
|
|
},
|
|
'codeSession:readFile': async (_event, args) => {
|
|
const session = await requireCodeSession(args.sessionId);
|
|
return readProjectFile(session.cwd, args.relPath);
|
|
},
|
|
'codeSession:mergeBack': async (_event, args) => {
|
|
const service = container.resolve<CodeSessionService>('codeSessionService');
|
|
return service.mergeBack(args.sessionId);
|
|
},
|
|
'codeSession:cleanupWorktree': async (_event, args) => {
|
|
const service = container.resolve<CodeSessionService>('codeSessionService');
|
|
try {
|
|
await service.cleanupWorktree(args.sessionId, args.deleteBranch);
|
|
return { success: true };
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to clean up worktree';
|
|
return { success: false, error: message };
|
|
}
|
|
},
|
|
'granola:setConfig': async (_event, args) => {
|
|
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
|
await repo.setConfig({ enabled: args.enabled });
|
|
|
|
// Trigger sync immediately when enabled
|
|
if (args.enabled) {
|
|
triggerGranolaSync();
|
|
}
|
|
|
|
return { success: true };
|
|
},
|
|
// ── Mobile channels (WhatsApp / Telegram bridge) ─────────────
|
|
'channels:getConfig': async () => {
|
|
return container.resolve<IChannelsConfigRepo>('channelsConfigRepo').getConfig();
|
|
},
|
|
'channels:setConfig': async (_event, args) => {
|
|
await container.resolve<IChannelsConfigRepo>('channelsConfigRepo').setConfig(args);
|
|
await applyChannelsConfig(args);
|
|
return { success: true };
|
|
},
|
|
'channels:getStatus': async () => {
|
|
return getChannelsStatus();
|
|
},
|
|
'channels:whatsappLogout': async () => {
|
|
await logoutWhatsApp();
|
|
return { success: true };
|
|
},
|
|
'slack:getConfig': async () => {
|
|
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
|
|
const config = await repo.getConfig();
|
|
return { enabled: config.enabled, workspaces: config.workspaces };
|
|
},
|
|
'slack:setConfig': async (_event, args) => {
|
|
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
|
|
await repo.setConfig({ enabled: args.enabled, workspaces: args.workspaces });
|
|
// Connecting/disconnecting Slack changes the Copilot's routing (native
|
|
// `slack` skill vs. Composio), so rebuild its cached instructions.
|
|
invalidateCopilotInstructionsCache();
|
|
return { success: true };
|
|
},
|
|
'slack:cliStatus': async () => {
|
|
return await getAgentSlackCliStatus();
|
|
},
|
|
'slack:knowledgeStatus': async () => {
|
|
return {
|
|
cli: await getAgentSlackCliStatus(),
|
|
sources: getSlackKnowledgeSyncStatus(),
|
|
};
|
|
},
|
|
'slack:listWorkspaces': async () => {
|
|
const result = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
|
|
if (!result.ok) {
|
|
return { workspaces: [], error: result.message, errorKind: result.kind };
|
|
}
|
|
const workspaces = parseWhoamiWorkspaces(result.data);
|
|
return { workspaces };
|
|
},
|
|
'slack:importDesktopAuth': async () => {
|
|
// Pull xoxc token(s) + cookie from the running/installed Slack desktop
|
|
// app into agent-slack's credential store, then read back the workspaces.
|
|
return await importDesktopAndReadWorkspaces();
|
|
},
|
|
'slack:quitAndImportDesktop': async () => {
|
|
// Windows-only convenience: kill Slack (which locks its Cookies DB) then
|
|
// run the normal desktop import in one click.
|
|
await quitSlackIfWindows();
|
|
return await importDesktopAndReadWorkspaces();
|
|
},
|
|
'slack:parseCurlAuth': async (_event, args) => {
|
|
// Cross-OS fallback to desktop import: the user pastes a "Copy as cURL"
|
|
// request from a signed-in Slack web tab; parse-curl reads it from stdin
|
|
// and extracts the xoxc token + xoxd cookie. No leveldb, no OS keychain.
|
|
const curl = (args.curl ?? '').trim();
|
|
if (!curl) {
|
|
return { ok: false, workspaces: [], error: 'Paste the copied cURL command first.', errorKind: 'unknown' as const };
|
|
}
|
|
const imported = await runAgentSlack(['auth', 'parse-curl'], { timeoutMs: 15000, parseJson: false, input: curl });
|
|
if (!imported.ok) {
|
|
return { ok: false, workspaces: [], error: imported.message, errorKind: imported.kind };
|
|
}
|
|
const whoami = await runAgentSlack(['auth', 'whoami'], { timeoutMs: 10000 });
|
|
if (!whoami.ok) {
|
|
return { ok: false, workspaces: [], error: whoami.message, errorKind: whoami.kind };
|
|
}
|
|
const workspaces = parseWhoamiWorkspaces(whoami.data);
|
|
if (workspaces.length === 0) {
|
|
return { ok: false, workspaces: [], error: 'Tokens were saved but no workspace was found. Double-check the copied request.', errorKind: 'not_authed' as const };
|
|
}
|
|
return { ok: true, workspaces };
|
|
},
|
|
'slack:listChannels': async (_event, args) => {
|
|
const result = await runAgentSlack(['channel', 'list', '--all', '--workspace', args.workspaceUrl, '--limit', '200'], { timeoutMs: 15000 });
|
|
if (!result.ok) {
|
|
return { channels: [], error: result.message };
|
|
}
|
|
const rawChannels = extractArrayPayload(result.data) as Array<{
|
|
id?: string;
|
|
name?: string;
|
|
is_private?: boolean;
|
|
isPrivate?: boolean;
|
|
is_member?: boolean;
|
|
isMember?: boolean;
|
|
}>;
|
|
const channels = rawChannels.map((ch) => ({
|
|
id: ch.id || ch.name || '',
|
|
name: ch.name || ch.id || '',
|
|
isPrivate: ch.is_private ?? ch.isPrivate,
|
|
isMember: ch.is_member ?? ch.isMember,
|
|
})).filter((ch) => ch.id && ch.name);
|
|
return { channels };
|
|
},
|
|
'slack:getRecentMessages': async (_event, args) => {
|
|
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
|
|
const config = await repo.getConfig();
|
|
if (!config.enabled || config.workspaces.length === 0) {
|
|
return { enabled: false, messages: [] };
|
|
}
|
|
|
|
const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
|
|
const messages: SlackHomeMessage[] = [];
|
|
const userNameCache = new Map<string, string>();
|
|
|
|
try {
|
|
const knowledgeConfig = knowledgeSourcesRepo.getConfig();
|
|
const slackSource = knowledgeConfig.sources.find(source => source.id === 'slack' && source.provider === 'slack' && source.enabled);
|
|
let channels: SlackHomeChannel[] = (slackSource?.scopes ?? [])
|
|
.filter(scope => scope.type === 'channel')
|
|
.map(scope => ({
|
|
id: scope.id,
|
|
name: scope.name ?? scope.id,
|
|
workspaceUrl: scope.workspaceUrl,
|
|
workspaceName: config.workspaces.find(workspace => workspace.url === scope.workspaceUrl)?.name,
|
|
}));
|
|
|
|
if (channels.length === 0) {
|
|
for (const workspace of config.workspaces) {
|
|
const channelList = await runAgentSlack(['channel', 'list', '--workspace', workspace.url, '--limit', '12'], { timeoutMs: 15000 });
|
|
if (!channelList.ok) {
|
|
throw new AgentSlackRunError(channelList.kind, channelList.message);
|
|
}
|
|
const rawChannels = extractArrayPayload(channelList.data);
|
|
for (const raw of rawChannels) {
|
|
if (!raw || typeof raw !== 'object') continue;
|
|
const channel = raw as Record<string, unknown>;
|
|
const id = typeof channel.id === 'string' ? channel.id : undefined;
|
|
const name = typeof channel.name === 'string' ? channel.name : id;
|
|
const isMember = channel.is_member ?? channel.isMember;
|
|
if (!id || !name || isMember === false) continue;
|
|
channels.push({ id, name, workspaceUrl: workspace.url, workspaceName: workspace.name });
|
|
}
|
|
}
|
|
}
|
|
|
|
channels = channels.slice(0, 8);
|
|
|
|
for (const channel of channels) {
|
|
const commandArgs = ['message', 'list', channel.id, '--limit', '5', '--max-body-chars', '500'];
|
|
if (channel.workspaceUrl) {
|
|
commandArgs.push('--workspace', channel.workspaceUrl);
|
|
}
|
|
const messageList = await runAgentSlack(commandArgs, { timeoutMs: 15000, maxBuffer: 1024 * 1024 });
|
|
if (!messageList.ok) {
|
|
console.warn(`[Slack] Failed to load messages for ${channel.name}: ${messageList.message}`);
|
|
continue;
|
|
}
|
|
const rawMessages = extractArrayPayload(messageList.data);
|
|
for (const raw of rawMessages) {
|
|
if (!raw || typeof raw !== 'object') continue;
|
|
const message = raw as Record<string, unknown>;
|
|
const ts = typeof message.ts === 'string' ? message.ts : undefined;
|
|
const text = slackMessageText(message);
|
|
if (!ts || !text) continue;
|
|
const channelId = typeof message.channel_id === 'string'
|
|
? message.channel_id
|
|
: typeof message.channel === 'string'
|
|
? message.channel
|
|
: channel.id;
|
|
const resolvedAuthor = await resolveSlackAuthor(slackMessageAuthor(message), channel.workspaceUrl, userNameCache);
|
|
const resolvedText = await resolveSlackMessageText(text, channel.workspaceUrl, userNameCache);
|
|
messages.push({
|
|
id: `${channel.workspaceUrl ?? 'workspace'}:${channelId}:${ts}`,
|
|
workspaceName: channel.workspaceName,
|
|
workspaceUrl: channel.workspaceUrl,
|
|
channelId,
|
|
channelName: channel.name,
|
|
author: resolvedAuthor,
|
|
text: resolvedText,
|
|
ts,
|
|
url: slackMessageUrl(message, channel.workspaceUrl, channelId, ts),
|
|
});
|
|
}
|
|
}
|
|
|
|
const rankedIds = await rankSlackHomeMessages(messages, limit);
|
|
const byId = new Map(messages.map(message => [message.id, message]));
|
|
const rankedMessages = rankedIds
|
|
.map(id => byId.get(id))
|
|
.filter((message): message is SlackHomeMessage => Boolean(message));
|
|
return { enabled: true, messages: rankedMessages };
|
|
} catch (err: unknown) {
|
|
const message = err instanceof Error ? err.message : 'Failed to load Slack messages';
|
|
const errorKind = err instanceof AgentSlackRunError ? err.kind : undefined;
|
|
return { enabled: true, messages: [], error: message, errorKind };
|
|
}
|
|
},
|
|
'knowledgeSources:getConfig': async () => {
|
|
return knowledgeSourcesRepo.getConfig();
|
|
},
|
|
'knowledgeSources:upsert': async (_event, args) => {
|
|
const config = knowledgeSourcesRepo.upsertSource(args);
|
|
if (args.provider === 'slack') {
|
|
// The Copilot prompt lists the selected Slack channels, so refresh it
|
|
// whenever the channel selection changes.
|
|
invalidateCopilotInstructionsCache();
|
|
triggerSlackKnowledgeSync();
|
|
void syncSlackKnowledgeSources().catch(error => {
|
|
console.error('[SlackKnowledge] Immediate sync after settings update failed:', error);
|
|
});
|
|
}
|
|
return config;
|
|
},
|
|
'onboarding:getStatus': async () => {
|
|
// Show onboarding if it hasn't been completed yet
|
|
const complete = isOnboardingComplete();
|
|
return { showOnboarding: !complete };
|
|
},
|
|
'onboarding:markComplete': async () => {
|
|
markOnboardingComplete();
|
|
return { success: true };
|
|
},
|
|
// Composio integration handlers
|
|
'composio:is-configured': async () => {
|
|
return composioHandler.isConfigured();
|
|
},
|
|
'composio:set-api-key': async (_event, args) => {
|
|
return composioHandler.setApiKey(args.apiKey);
|
|
},
|
|
'composio:initiate-connection': async (_event, args) => {
|
|
return composioHandler.initiateConnection(args.toolkitSlug);
|
|
},
|
|
'composio:get-connection-status': async (_event, args) => {
|
|
return composioHandler.getConnectionStatus(args.toolkitSlug);
|
|
},
|
|
'composio:sync-connection': async (_event, args) => {
|
|
return composioHandler.syncConnection(args.toolkitSlug, args.connectedAccountId);
|
|
},
|
|
'composio:disconnect': async (_event, args) => {
|
|
return composioHandler.disconnect(args.toolkitSlug);
|
|
},
|
|
'composio:list-connected': async () => {
|
|
return composioHandler.listConnected();
|
|
},
|
|
// Composio Tools Library handlers
|
|
'composio:list-toolkits': async () => {
|
|
return composioHandler.listToolkits();
|
|
},
|
|
'migration:check-composio-google': async () => {
|
|
return qualifyAndDisconnectComposioGoogle();
|
|
},
|
|
// Agent schedule handlers
|
|
'agent-schedule:getConfig': async () => {
|
|
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
|
|
try {
|
|
return await repo.getConfig();
|
|
} catch {
|
|
// Return empty config if file doesn't exist
|
|
return { agents: {} };
|
|
}
|
|
},
|
|
'agent-schedule:getState': async () => {
|
|
const repo = container.resolve<IAgentScheduleStateRepo>('agentScheduleStateRepo');
|
|
try {
|
|
return await repo.getState();
|
|
} catch {
|
|
// Return empty state if file doesn't exist
|
|
return { agents: {} };
|
|
}
|
|
},
|
|
'agent-schedule:updateAgent': async (_event, args) => {
|
|
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
|
|
await repo.upsert(args.agentName, args.entry);
|
|
// Trigger the runner to pick up the change immediately
|
|
triggerAgentScheduleRun();
|
|
return { success: true };
|
|
},
|
|
'agent-schedule:deleteAgent': async (_event, args) => {
|
|
const repo = container.resolve<IAgentScheduleRepo>('agentScheduleRepo');
|
|
const stateRepo = container.resolve<IAgentScheduleStateRepo>('agentScheduleStateRepo');
|
|
await repo.delete(args.agentName);
|
|
await stateRepo.deleteAgentState(args.agentName);
|
|
return { success: true };
|
|
},
|
|
// Shell integration handlers
|
|
'shell:openPath': async (_event, args) => {
|
|
const filePath = resolveShellPath(args.path);
|
|
const error = await shell.openPath(filePath);
|
|
return { error: error || undefined };
|
|
},
|
|
'shell:showItemInFolder': async (_event, args) => {
|
|
const filePath = resolveShellPath(args.path);
|
|
shell.showItemInFolder(filePath);
|
|
return { success: true };
|
|
},
|
|
'shell:readFileBase64': async (_event, args) => {
|
|
const filePath = resolveShellPath(args.path);
|
|
const stat = await fs.stat(filePath);
|
|
if (stat.size > 10 * 1024 * 1024) {
|
|
throw new Error('File too large (>10MB)');
|
|
}
|
|
const buffer = await fs.readFile(filePath);
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const mimeMap: Record<string, string> = {
|
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
'.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
|
|
'.bmp': 'image/bmp', '.ico': 'image/x-icon',
|
|
'.wav': 'audio/wav', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4',
|
|
'.ogg': 'audio/ogg', '.flac': 'audio/flac', '.aac': 'audio/aac',
|
|
'.pdf': 'application/pdf', '.json': 'application/json',
|
|
'.txt': 'text/plain', '.md': 'text/markdown',
|
|
};
|
|
const mimeType = mimeMap[ext] || 'application/octet-stream';
|
|
return { data: buffer.toString('base64'), mimeType, size: stat.size };
|
|
},
|
|
'dialog:openDirectory': async (event, args) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
const defaultPath = args.defaultPath ? resolveShellPath(args.defaultPath) : os.homedir();
|
|
const result = await dialog.showOpenDialog(win!, {
|
|
title: args.title ?? 'Choose work directory',
|
|
defaultPath,
|
|
properties: ['openDirectory', 'createDirectory'],
|
|
});
|
|
if (result.canceled || result.filePaths.length === 0) {
|
|
return { path: null };
|
|
}
|
|
return { path: result.filePaths[0] ?? null };
|
|
},
|
|
'terminal:ensure': async (_event, args) => {
|
|
return ensureTerminal(args.id, args.cwd, args.cols, args.rows);
|
|
},
|
|
'terminal:input': async (_event, args) => {
|
|
writeTerminal(args.id, args.data);
|
|
return { success: true };
|
|
},
|
|
'terminal:resize': async (_event, args) => {
|
|
resizeTerminal(args.id, args.cols, args.rows);
|
|
return { success: true };
|
|
},
|
|
'terminal:dispose': async (_event, args) => {
|
|
disposeTerminal(args.id);
|
|
return { success: true };
|
|
},
|
|
'dialog:openFiles': async (event, args) => {
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
const result = await dialog.showOpenDialog(win!, {
|
|
title: args.title ?? 'Attach files',
|
|
...(args.defaultPath ? { defaultPath: resolveShellPath(args.defaultPath) } : {}),
|
|
properties: ['openFile', 'multiSelections'],
|
|
});
|
|
return { paths: result.canceled ? [] : result.filePaths };
|
|
},
|
|
// Knowledge version history handlers
|
|
'knowledge:history': async (_event, args) => {
|
|
const commits = await versionHistory.getFileHistory(args.path);
|
|
return { commits };
|
|
},
|
|
'knowledge:fileAtCommit': async (_event, args) => {
|
|
const content = await versionHistory.getFileAtCommit(args.path, args.oid);
|
|
return { content };
|
|
},
|
|
'knowledge:restore': async (_event, args) => {
|
|
await versionHistory.restoreFile(args.path, args.oid);
|
|
return { ok: true };
|
|
},
|
|
'google-docs:getStatus': async () => {
|
|
return getGoogleDocsConnectionStatus();
|
|
},
|
|
'google-docs:import': async (_event, args) => {
|
|
console.log(`[GoogleDocs] import fileId=${args.fileId} -> ${args.targetFolder}`);
|
|
try {
|
|
const result = await importGoogleDoc(args.fileId, args.targetFolder);
|
|
console.log(`[GoogleDocs] import OK -> ${result.path}`);
|
|
return result;
|
|
} catch (err) {
|
|
console.error('[GoogleDocs] import FAILED:', err instanceof Error ? err.message : err);
|
|
throw err;
|
|
}
|
|
},
|
|
// Managed (rowboat-mode) OAuth-redirect Picker: the Rowboat backend runs the
|
|
// pick with the company Google client; the desktop opens the start URL,
|
|
// waits for the deep link, and imports the picked doc with the existing
|
|
// managed token. No API key, appId, or local credentials.
|
|
'google-docs:pickViaManaged': async (_event, args) => {
|
|
console.log(`[GoogleDocs] managed pick -> ${args.targetFolder}`);
|
|
const result = await startManagedGooglePick(args.targetFolder);
|
|
if (!result) return null;
|
|
console.log(`[GoogleDocs] managed pick import OK -> ${result.path}`);
|
|
return result;
|
|
},
|
|
'google-docs:refreshSnapshot': async (_event, args) => {
|
|
return syncGoogleDocDown(args.path);
|
|
},
|
|
'google-docs:sync': async (_event, args) => {
|
|
return syncGoogleDocUp(args.path, { force: args.force });
|
|
},
|
|
'google-docs:getLink': async (_event, args) => {
|
|
return { link: await getGoogleDocLink(args.path) };
|
|
},
|
|
// Search handler
|
|
'search:query': async (_event, args) => {
|
|
return search(args.query, args.limit, args.types);
|
|
},
|
|
// Inline task schedule classification
|
|
'export:note': async (event, args) => {
|
|
const { markdown, format, title } = args;
|
|
const sanitizedTitle = title.replace(/[/\\?%*:|"<>]/g, '-').trim() || 'Untitled';
|
|
|
|
const filterMap: Record<string, Electron.FileFilter[]> = {
|
|
md: [{ name: 'Markdown', extensions: ['md'] }],
|
|
pdf: [{ name: 'PDF', extensions: ['pdf'] }],
|
|
docx: [{ name: 'Word Document', extensions: ['docx'] }],
|
|
};
|
|
|
|
const win = BrowserWindow.fromWebContents(event.sender);
|
|
const result = await dialog.showSaveDialog(win!, {
|
|
defaultPath: `${sanitizedTitle}.${format}`,
|
|
filters: filterMap[format],
|
|
});
|
|
|
|
if (result.canceled || !result.filePath) {
|
|
return { success: false };
|
|
}
|
|
|
|
const filePath = result.filePath;
|
|
|
|
if (format === 'md') {
|
|
await fs.writeFile(filePath, markdown, 'utf8');
|
|
return { success: true };
|
|
}
|
|
|
|
if (format === 'pdf') {
|
|
// Render markdown as HTML in a hidden window, then print to PDF
|
|
const htmlContent = markdownToHtml(markdown, sanitizedTitle);
|
|
const hiddenWin = new BrowserWindow({
|
|
show: false,
|
|
width: 800,
|
|
height: 600,
|
|
webPreferences: { offscreen: true },
|
|
});
|
|
await hiddenWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
|
// Small delay to ensure CSS/fonts render
|
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
const pdfBuffer = await hiddenWin.webContents.printToPDF({
|
|
printBackground: true,
|
|
pageSize: 'A4',
|
|
});
|
|
hiddenWin.destroy();
|
|
await fs.writeFile(filePath, pdfBuffer);
|
|
return { success: true };
|
|
}
|
|
|
|
if (format === 'docx') {
|
|
const htmlContent = markdownToHtml(markdown, sanitizedTitle);
|
|
const { default: htmlToDocx } = await import('html-to-docx');
|
|
const docxBuffer = await htmlToDocx(htmlContent, undefined, {
|
|
table: { row: { cantSplit: true } },
|
|
footer: false,
|
|
header: false,
|
|
});
|
|
await fs.writeFile(filePath, Buffer.from(docxBuffer as ArrayBuffer));
|
|
return { success: true };
|
|
}
|
|
|
|
return { success: false, error: 'Unknown format' };
|
|
},
|
|
'meeting:checkScreenPermission': async () => {
|
|
if (process.platform !== 'darwin') return { granted: true };
|
|
const status = systemPreferences.getMediaAccessStatus('screen');
|
|
console.log('[meeting] Screen recording permission status:', status);
|
|
if (status === 'granted') return { granted: true };
|
|
// Not granted — call desktopCapturer.getSources() to register the app
|
|
// in the macOS Screen Recording list. On first call this shows the
|
|
// native permission prompt (signed apps are remembered across restarts).
|
|
try { await desktopCapturer.getSources({ types: ['screen'] }); } catch { /* ignore */ }
|
|
// Re-check after the native prompt was dismissed
|
|
const statusAfter = systemPreferences.getMediaAccessStatus('screen');
|
|
console.log('[meeting] Screen recording permission status after prompt:', statusAfter);
|
|
return { granted: statusAfter === 'granted' };
|
|
},
|
|
'meeting:openScreenRecordingSettings': async () => {
|
|
await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
|
|
return { success: true };
|
|
},
|
|
'meeting:summarize': async (_event, args) => {
|
|
const notes = await summarizeMeeting(args.transcript, args.meetingStartTime, args.calendarEventJson);
|
|
return { notes };
|
|
},
|
|
'meeting-prep:resolve': async (_event, args) => {
|
|
const result = await resolveMeetingPrep(args.attendees);
|
|
const prepNote = args.eventId ? await readPrepNoteForEvent(args.eventId) : null;
|
|
return { ...result, prepNote };
|
|
},
|
|
'inline-task:classifySchedule': async (_event, args) => {
|
|
const schedule = await classifySchedule(args.instruction);
|
|
return { schedule };
|
|
},
|
|
'inline-task:process': async (_event, args) => {
|
|
return await processRowboatInstruction(args.instruction, args.noteContent, args.notePath);
|
|
},
|
|
'voice:getConfig': async () => {
|
|
return voice.getVoiceConfig();
|
|
},
|
|
'voice:synthesize': async (_event, args) => {
|
|
return voice.synthesizeSpeech(args.text);
|
|
},
|
|
'voice:synthesizeStreamStart': async (event, args) => {
|
|
const { requestId, text } = args;
|
|
const sender = event.sender;
|
|
const controller = new AbortController();
|
|
activeTtsStreams.set(requestId, controller);
|
|
// Fire-and-forget: chunks are pushed to the renderer as they arrive so
|
|
// playback can begin immediately; the invoke returns once started.
|
|
void voice
|
|
.synthesizeSpeechStream(
|
|
text,
|
|
(chunk) => {
|
|
if (!sender.isDestroyed()) {
|
|
sender.send('voice:tts-chunk', {
|
|
requestId,
|
|
chunkBase64: chunk.toString('base64'),
|
|
done: false,
|
|
});
|
|
}
|
|
},
|
|
controller.signal,
|
|
)
|
|
.then(() => {
|
|
if (!sender.isDestroyed()) {
|
|
sender.send('voice:tts-chunk', { requestId, done: true });
|
|
}
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (!sender.isDestroyed() && !controller.signal.aborted) {
|
|
sender.send('voice:tts-chunk', {
|
|
requestId,
|
|
done: true,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
});
|
|
}
|
|
})
|
|
.finally(() => {
|
|
activeTtsStreams.delete(requestId);
|
|
});
|
|
return { ok: true };
|
|
},
|
|
'voice:synthesizeStreamCancel': async (_event, args) => {
|
|
activeTtsStreams.get(args.requestId)?.abort();
|
|
activeTtsStreams.delete(args.requestId);
|
|
return {};
|
|
},
|
|
'voice:ensureMicAccess': async () => {
|
|
if (process.platform !== 'darwin') return { granted: true };
|
|
const status = systemPreferences.getMediaAccessStatus('microphone');
|
|
console.log('[voice] Microphone permission status:', status);
|
|
if (status === 'granted') return { granted: true };
|
|
// 'not-determined' shows the native TCC prompt and resolves once the
|
|
// user responds; 'denied'/'restricted' resolve false without prompting.
|
|
// Awaiting this here means the triggering mic click proceeds to
|
|
// getUserMedia only after permission is settled — fixing the first
|
|
// click silently failing while the prompt was still up.
|
|
try {
|
|
const granted = await systemPreferences.askForMediaAccess('microphone');
|
|
console.log('[voice] Microphone permission after prompt:', granted);
|
|
return { granted };
|
|
} catch {
|
|
return { granted: false };
|
|
}
|
|
},
|
|
'voice:ensureCameraAccess': async () => {
|
|
if (process.platform !== 'darwin') return { granted: true };
|
|
const status = systemPreferences.getMediaAccessStatus('camera');
|
|
console.log('[video] Camera permission status:', status);
|
|
if (status === 'granted') return { granted: true };
|
|
// Same flow as the microphone: settle the native TCC prompt before the
|
|
// renderer's getUserMedia so the first video click doesn't silently fail.
|
|
try {
|
|
const granted = await systemPreferences.askForMediaAccess('camera');
|
|
console.log('[video] Camera permission after prompt:', granted);
|
|
return { granted };
|
|
} catch {
|
|
return { granted: false };
|
|
}
|
|
},
|
|
'video:setPopout': async (_event, args) => {
|
|
if (!args.show) {
|
|
if (videoPopoutWin && !videoPopoutWin.isDestroyed()) videoPopoutWin.destroy();
|
|
videoPopoutWin = null;
|
|
return {};
|
|
}
|
|
if (videoPopoutWin && !videoPopoutWin.isDestroyed()) return {};
|
|
|
|
const workArea = screen.getPrimaryDisplay().workArea;
|
|
const width = 340;
|
|
const height = 184;
|
|
const ipcDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const preloadPath = app.isPackaged
|
|
? path.join(ipcDir, '../preload/dist/preload.js')
|
|
: path.join(ipcDir, '../../../preload/dist/preload.js');
|
|
const win = new BrowserWindow({
|
|
width,
|
|
height,
|
|
x: workArea.x + workArea.width - width - 24,
|
|
y: workArea.y + 24,
|
|
frame: false,
|
|
resizable: false,
|
|
alwaysOnTop: true,
|
|
skipTaskbar: true,
|
|
show: false,
|
|
hasShadow: true,
|
|
backgroundColor: '#171717',
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
preload: preloadPath,
|
|
},
|
|
});
|
|
// Float above other apps on every workspace. Deliberately NOT
|
|
// `visibleOnFullScreen: true`: on macOS that flag hides the app's Dock
|
|
// icon for as long as such a window exists (the app becomes an
|
|
// "agent" app), which reads as Rowboat having vanished. The trade-off
|
|
// is the popout won't hover over other apps' fullscreen Spaces.
|
|
win.setAlwaysOnTop(true, 'floating');
|
|
win.setVisibleOnAllWorkspaces(true);
|
|
win.webContents.once('did-finish-load', () => {
|
|
if (lastVideoPopoutState) {
|
|
win.webContents.send('video:popout-state', lastVideoPopoutState);
|
|
}
|
|
// showInactive: appearing must not steal focus from the app the user
|
|
// switched to — that would immediately re-hide the popout.
|
|
if (!win.isDestroyed()) win.showInactive();
|
|
});
|
|
win.on('closed', () => {
|
|
if (videoPopoutWin === win) videoPopoutWin = null;
|
|
});
|
|
videoPopoutWin = win;
|
|
if (app.isPackaged) {
|
|
win.loadURL('app://-/index.html#video-popout');
|
|
} else {
|
|
win.loadURL('http://localhost:5173/#video-popout');
|
|
}
|
|
return {};
|
|
},
|
|
'video:popoutState': async (_event, args) => {
|
|
lastVideoPopoutState = args;
|
|
if (videoPopoutWin && !videoPopoutWin.isDestroyed()) {
|
|
videoPopoutWin.webContents.send('video:popout-state', args);
|
|
}
|
|
return {};
|
|
},
|
|
'app:focusMainWindow': async () => {
|
|
const main = findMainAppWindow();
|
|
if (main) {
|
|
if (main.isMinimized()) main.restore();
|
|
main.show();
|
|
main.focus();
|
|
}
|
|
return {};
|
|
},
|
|
'video:getPopoutState': async () => {
|
|
return { state: lastVideoPopoutState };
|
|
},
|
|
'video:popoutAction': async (_event, args) => {
|
|
// Relay a popout control-bar action to the app window, which owns the
|
|
// call (mic, camera, screen capture) and executes it there. 'expand'
|
|
// additionally brings the app window back to the foreground.
|
|
const main = findMainAppWindow();
|
|
if (args.action === 'expand' && main) {
|
|
if (main.isMinimized()) main.restore();
|
|
main.show();
|
|
main.focus();
|
|
}
|
|
main?.webContents.send('video:popout-action', args);
|
|
return {};
|
|
},
|
|
// Live-note handlers
|
|
'live-note:run': async (_event, args) => {
|
|
const result = await runLiveNoteAgent(args.filePath, 'manual', args.context);
|
|
return {
|
|
success: !result.error,
|
|
runId: result.runId,
|
|
action: result.action,
|
|
summary: result.summary,
|
|
contentAfter: result.contentAfter,
|
|
error: result.error,
|
|
};
|
|
},
|
|
'live-note:get': async (_event, args) => {
|
|
try {
|
|
const live = await fetchLiveNote(args.filePath);
|
|
return { success: true, live };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
},
|
|
'live-note:set': async (_event, args) => {
|
|
try {
|
|
await setLiveNote(args.filePath, args.live);
|
|
const live = await fetchLiveNote(args.filePath);
|
|
return { success: true, live };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
},
|
|
'live-note:setActive': async (_event, args) => {
|
|
try {
|
|
await setLiveNoteActive(args.filePath, args.active);
|
|
const live = await fetchLiveNote(args.filePath);
|
|
return { success: true, live };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
},
|
|
'live-note:delete': async (_event, args) => {
|
|
try {
|
|
await deleteLiveNote(args.filePath);
|
|
return { success: true };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
},
|
|
'live-note:stop': async (_event, args) => {
|
|
try {
|
|
const live = await fetchLiveNote(args.filePath);
|
|
if (!live?.lastRunId) {
|
|
return { success: false, error: 'No active run for this note' };
|
|
}
|
|
await runsCore.stop(live.lastRunId, false);
|
|
return { success: true };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
},
|
|
'live-note:listNotes': async () => {
|
|
const notes = await listLiveNotes();
|
|
return { notes };
|
|
},
|
|
// Bg-task handlers
|
|
'bg-task:run': async (_event, args) => {
|
|
const result = await runBackgroundTask(args.slug, 'manual', args.context);
|
|
return {
|
|
success: !result.error,
|
|
runId: result.runId,
|
|
summary: result.summary,
|
|
error: result.error,
|
|
};
|
|
},
|
|
'bg-task:get': async (_event, args) => {
|
|
try {
|
|
const task = await fetchTask(args.slug);
|
|
return { success: true, task };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
},
|
|
'bg-task:patch': async (_event, args) => {
|
|
try {
|
|
const task = await patchTask(args.slug, args.partial);
|
|
return { success: true, task };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
},
|
|
'bg-task:create': async (_event, args) => {
|
|
try {
|
|
const { slug } = await createTask({
|
|
name: args.name,
|
|
instructions: args.instructions,
|
|
...(args.triggers ? { triggers: args.triggers } : {}),
|
|
...(args.projectId ? { projectId: args.projectId } : {}),
|
|
...(args.model ? { model: args.model } : {}),
|
|
...(args.provider ? { provider: args.provider } : {}),
|
|
});
|
|
return { success: true, slug };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
},
|
|
'bg-task:delete': async (_event, args) => {
|
|
try {
|
|
await deleteTask(args.slug);
|
|
return { success: true };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
},
|
|
'bg-task:stop': async (_event, args) => {
|
|
try {
|
|
const task = await fetchTask(args.slug);
|
|
if (!task?.lastRunId) {
|
|
return { success: false, error: 'No active run for this task' };
|
|
}
|
|
await runsCore.stop(task.lastRunId, false);
|
|
return { success: true };
|
|
} catch (err) {
|
|
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
},
|
|
'bg-task:list': async (_event, args) => {
|
|
return listTasks(args);
|
|
},
|
|
'bg-task:listRunIds': async (_event, args) => {
|
|
const runIds = await readTaskRunIds(args.slug, args.limit);
|
|
return { runIds };
|
|
},
|
|
// Billing handler
|
|
'billing:getInfo': async () => {
|
|
return await getBillingInfo();
|
|
},
|
|
'notifications:getSettings': async () => {
|
|
return loadNotificationSettings();
|
|
},
|
|
'notifications:setSettings': async (_event, args) => {
|
|
saveNotificationSettings(args);
|
|
return { success: true };
|
|
},
|
|
// Embedded browser handlers (WebContentsView + navigation)
|
|
...browserIpcHandlers,
|
|
});
|
|
}
|